@jjlmoya/utils-finance 1.34.0 → 1.36.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 +2 -1
- package/src/entries.ts +5 -1
- package/src/index.ts +1 -0
- package/src/pages/[locale]/[slug].astro +1 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/inflationPurchasingPower/bibliography.astro +14 -0
- package/src/tool/inflationPurchasingPower/bibliography.ts +12 -0
- package/src/tool/inflationPurchasingPower/component.astro +79 -0
- package/src/tool/inflationPurchasingPower/controller.ts +82 -0
- package/src/tool/inflationPurchasingPower/dom-views.ts +99 -0
- package/src/tool/inflationPurchasingPower/entry.ts +31 -0
- package/src/tool/inflationPurchasingPower/evaluator.ts +18 -0
- package/src/tool/inflationPurchasingPower/i18n/de.ts +56 -0
- package/src/tool/inflationPurchasingPower/i18n/en.ts +131 -0
- package/src/tool/inflationPurchasingPower/i18n/es.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/fr.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/id.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/it.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/ja.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/ko.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/nl.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/pl.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/pt.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/ru.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/sv.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/tr.ts +44 -0
- package/src/tool/inflationPurchasingPower/i18n/zh.ts +44 -0
- package/src/tool/inflationPurchasingPower/index.ts +11 -0
- package/src/tool/inflationPurchasingPower/inflation-purchasing-power-timeline.css +402 -0
- package/src/tool/inflationPurchasingPower/logic.test.ts +35 -0
- package/src/tool/inflationPurchasingPower/logic.ts +92 -0
- package/src/tool/inflationPurchasingPower/seo.astro +14 -0
- package/src/tool/inflationPurchasingPower/storage.ts +29 -0
- package/src/tool/inflationPurchasingPower/ui.ts +42 -0
- package/src/tool/mortgageExtraPaymentPlanner/bibliography.astro +14 -0
- package/src/tool/mortgageExtraPaymentPlanner/bibliography.ts +12 -0
- package/src/tool/mortgageExtraPaymentPlanner/component.astro +138 -0
- package/src/tool/mortgageExtraPaymentPlanner/controller.ts +150 -0
- package/src/tool/mortgageExtraPaymentPlanner/currency-defaults.ts +25 -0
- package/src/tool/mortgageExtraPaymentPlanner/dom-views.ts +161 -0
- package/src/tool/mortgageExtraPaymentPlanner/entry.ts +31 -0
- package/src/tool/mortgageExtraPaymentPlanner/evaluator.ts +24 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/de.ts +74 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/en.ts +185 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/es.ts +49 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/fr.ts +25 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/id.ts +18 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/it.ts +20 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/ja.ts +18 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/ko.ts +18 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/nl.ts +18 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/pl.ts +18 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/pt.ts +18 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/ru.ts +18 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/sv.ts +18 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/tr.ts +18 -0
- package/src/tool/mortgageExtraPaymentPlanner/i18n/zh.ts +18 -0
- package/src/tool/mortgageExtraPaymentPlanner/index.ts +11 -0
- package/src/tool/mortgageExtraPaymentPlanner/logic.test.ts +67 -0
- package/src/tool/mortgageExtraPaymentPlanner/logic.ts +263 -0
- package/src/tool/mortgageExtraPaymentPlanner/mortgage-amortization-extra-payment-planner.css +551 -0
- package/src/tool/mortgageExtraPaymentPlanner/seo.astro +14 -0
- package/src/tool/mortgageExtraPaymentPlanner/storage.ts +65 -0
- package/src/tool/mortgageExtraPaymentPlanner/ui.ts +48 -0
- package/src/tools.ts +3 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { calculateInflationTimeline, getMilestoneYears } from './logic';
|
|
3
|
+
|
|
4
|
+
describe('calculateInflationTimeline', () => {
|
|
5
|
+
it('compounds the cost equivalent and discounts unchanged cash', () => {
|
|
6
|
+
const result = calculateInflationTimeline({ startingAmount: 1000, annualInflationRate: 5, years: 10, annualCashFlow: 0, cashFlowMode: 'none' });
|
|
7
|
+
expect(result?.priceFactor).toBe(1.63);
|
|
8
|
+
expect(result?.points.at(-1)?.costEquivalent).toBe(1628.89);
|
|
9
|
+
expect(result?.points.at(-1)?.unchangedRealValue).toBe(613.91);
|
|
10
|
+
expect(result?.purchasingPowerLossPercent).toBe(38.61);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('adds annual contributions to the nominal balance and discounts the result', () => {
|
|
14
|
+
const result = calculateInflationTimeline({ startingAmount: 1000, annualInflationRate: 2, years: 5, annualCashFlow: 200, cashFlowMode: 'contribution' });
|
|
15
|
+
expect(result?.points.at(-1)?.nominalBalance).toBe(2000);
|
|
16
|
+
expect(result?.points.at(-1)?.realBalance).toBe(1811.46);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('stops a withdrawal scenario at zero and reports depletion', () => {
|
|
20
|
+
const result = calculateInflationTimeline({ startingAmount: 500, annualInflationRate: 2, years: 5, annualCashFlow: 200, cashFlowMode: 'withdrawal' });
|
|
21
|
+
expect(result?.points.at(-1)?.nominalBalance).toBe(0);
|
|
22
|
+
expect(result?.depletionYear).toBe(3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('deduplicates milestone years for short timelines', () => {
|
|
26
|
+
expect(getMilestoneYears(1)).toEqual([0, 1]);
|
|
27
|
+
expect(getMilestoneYears(4)).toEqual([0, 1, 2, 3, 4]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects invalid rates, periods and amounts', () => {
|
|
31
|
+
expect(calculateInflationTimeline({ startingAmount: 0, annualInflationRate: 2, years: 5, annualCashFlow: 0, cashFlowMode: 'none' })).toBeNull();
|
|
32
|
+
expect(calculateInflationTimeline({ startingAmount: 1000, annualInflationRate: -100, years: 5, annualCashFlow: 0, cashFlowMode: 'none' })).toBeNull();
|
|
33
|
+
expect(calculateInflationTimeline({ startingAmount: 1000, annualInflationRate: 2, years: 5.5, annualCashFlow: 0, cashFlowMode: 'none' })).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export type CashFlowMode = 'none' | 'contribution' | 'withdrawal';
|
|
2
|
+
|
|
3
|
+
export interface InflationPurchasingPowerInput {
|
|
4
|
+
startingAmount: number;
|
|
5
|
+
annualInflationRate: number;
|
|
6
|
+
years: number;
|
|
7
|
+
annualCashFlow: number;
|
|
8
|
+
cashFlowMode: CashFlowMode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InflationTimelinePoint {
|
|
12
|
+
year: number;
|
|
13
|
+
costEquivalent: number;
|
|
14
|
+
unchangedRealValue: number;
|
|
15
|
+
nominalBalance: number;
|
|
16
|
+
realBalance: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface InflationPurchasingPowerResult {
|
|
20
|
+
input: InflationPurchasingPowerInput;
|
|
21
|
+
priceFactor: number;
|
|
22
|
+
purchasingPowerLossPercent: number;
|
|
23
|
+
points: InflationTimelinePoint[];
|
|
24
|
+
milestones: InflationTimelinePoint[];
|
|
25
|
+
depletionYear: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const round = (value: number): number => Number(value.toFixed(2));
|
|
29
|
+
|
|
30
|
+
function hasFiniteValues(input: InflationPurchasingPowerInput): boolean {
|
|
31
|
+
return [input.startingAmount, input.annualInflationRate, input.years, input.annualCashFlow].every(Number.isFinite);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasValidRanges(input: InflationPurchasingPowerInput): boolean {
|
|
35
|
+
return input.startingAmount > 0
|
|
36
|
+
&& input.annualInflationRate > -100
|
|
37
|
+
&& input.annualInflationRate <= 100
|
|
38
|
+
&& Number.isInteger(input.years)
|
|
39
|
+
&& input.years >= 1
|
|
40
|
+
&& input.years <= 80
|
|
41
|
+
&& input.annualCashFlow >= 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasValidCashFlowMode(input: InflationPurchasingPowerInput): boolean {
|
|
45
|
+
return ['none', 'contribution', 'withdrawal'].includes(input.cashFlowMode);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isValidInput(input: InflationPurchasingPowerInput): boolean {
|
|
49
|
+
return hasFiniteValues(input) && hasValidRanges(input) && hasValidCashFlowMode(input);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getNominalBalance(input: InflationPurchasingPowerInput, year: number): number {
|
|
53
|
+
const flow = input.cashFlowMode === 'contribution' ? input.annualCashFlow : -input.annualCashFlow;
|
|
54
|
+
if (input.cashFlowMode === 'none') return input.startingAmount;
|
|
55
|
+
return Math.max(0, input.startingAmount + flow * year);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createPoint(input: InflationPurchasingPowerInput, year: number): InflationTimelinePoint {
|
|
59
|
+
const priceFactor = (1 + input.annualInflationRate / 100) ** year;
|
|
60
|
+
const nominalBalance = getNominalBalance(input, year);
|
|
61
|
+
return {
|
|
62
|
+
year,
|
|
63
|
+
costEquivalent: round(input.startingAmount * priceFactor),
|
|
64
|
+
unchangedRealValue: round(input.startingAmount / priceFactor),
|
|
65
|
+
nominalBalance: round(nominalBalance),
|
|
66
|
+
realBalance: round(nominalBalance / priceFactor),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getMilestoneYears(years: number): number[] {
|
|
71
|
+
return [...new Set([0, Math.round(years * 0.25), Math.round(years * 0.5), Math.round(years * 0.75), years])];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function calculateInflationTimeline(input: InflationPurchasingPowerInput): InflationPurchasingPowerResult | null {
|
|
75
|
+
if (!isValidInput(input)) return null;
|
|
76
|
+
const points = Array.from({ length: input.years + 1 }, (_, year) => createPoint(input, year));
|
|
77
|
+
const milestones = getMilestoneYears(input.years)
|
|
78
|
+
.map((year) => points[year])
|
|
79
|
+
.filter((point): point is InflationTimelinePoint => point !== undefined);
|
|
80
|
+
const depletionYear = input.cashFlowMode === 'withdrawal' && input.annualCashFlow > 0
|
|
81
|
+
? points.find((point) => point.nominalBalance === 0 && point.year > 0)?.year ?? null
|
|
82
|
+
: null;
|
|
83
|
+
const priceFactor = (1 + input.annualInflationRate / 100) ** input.years;
|
|
84
|
+
return {
|
|
85
|
+
input,
|
|
86
|
+
priceFactor: round(priceFactor),
|
|
87
|
+
purchasingPowerLossPercent: round((1 - 1 / priceFactor) * 100),
|
|
88
|
+
points,
|
|
89
|
+
milestones,
|
|
90
|
+
depletionYear,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { inflationPurchasingPower } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await inflationPurchasingPower.i18n[locale]?.();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{content?.seo && content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { InflationPurchasingPowerInput } from './logic';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'jjlmoya-finance-inflation-purchasing-power';
|
|
4
|
+
|
|
5
|
+
function isStoredState(value: Partial<InflationPurchasingPowerInput>): boolean {
|
|
6
|
+
return typeof value.startingAmount === 'number'
|
|
7
|
+
&& typeof value.annualInflationRate === 'number'
|
|
8
|
+
&& typeof value.years === 'number'
|
|
9
|
+
&& typeof value.annualCashFlow === 'number';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function loadInflationPurchasingPowerState(fallback: InflationPurchasingPowerInput): InflationPurchasingPowerInput {
|
|
13
|
+
try {
|
|
14
|
+
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
15
|
+
if (!stored) return fallback;
|
|
16
|
+
const parsed = JSON.parse(stored) as Partial<InflationPurchasingPowerInput>;
|
|
17
|
+
if (!isStoredState(parsed)) return fallback;
|
|
18
|
+
const cashFlowMode = parsed.cashFlowMode === 'contribution' || parsed.cashFlowMode === 'withdrawal' ? parsed.cashFlowMode : 'none';
|
|
19
|
+
return { ...fallback, ...parsed, cashFlowMode };
|
|
20
|
+
} catch {
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function saveInflationPurchasingPowerState(state: InflationPurchasingPowerInput): void {
|
|
26
|
+
try {
|
|
27
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface InflationPurchasingPowerUI extends Record<string, string> {
|
|
2
|
+
amountLabel: string;
|
|
3
|
+
amountHelp: string;
|
|
4
|
+
inflationLabel: string;
|
|
5
|
+
inflationHelp: string;
|
|
6
|
+
yearsLabel: string;
|
|
7
|
+
yearsHelp: string;
|
|
8
|
+
cashFlowLabel: string;
|
|
9
|
+
cashFlowHelp: string;
|
|
10
|
+
cashFlowModeLabel: string;
|
|
11
|
+
noCashFlowOption: string;
|
|
12
|
+
contributionOption: string;
|
|
13
|
+
withdrawalOption: string;
|
|
14
|
+
costEquivalentLabel: string;
|
|
15
|
+
realValueLabel: string;
|
|
16
|
+
nominalBalanceLabel: string;
|
|
17
|
+
purchasingPowerLossLabel: string;
|
|
18
|
+
todayLabel: string;
|
|
19
|
+
endLabel: string;
|
|
20
|
+
costLineLabel: string;
|
|
21
|
+
realLineLabel: string;
|
|
22
|
+
balanceLineLabel: string;
|
|
23
|
+
chartDescription: string;
|
|
24
|
+
chartNote: string;
|
|
25
|
+
milestoneTitle: string;
|
|
26
|
+
yearHeader: string;
|
|
27
|
+
costHeader: string;
|
|
28
|
+
realHeader: string;
|
|
29
|
+
balanceHeader: string;
|
|
30
|
+
methodTitle: string;
|
|
31
|
+
methodText: string;
|
|
32
|
+
limitationText: string;
|
|
33
|
+
emptyMessage: string;
|
|
34
|
+
contributionSummary: string;
|
|
35
|
+
withdrawalSummary: string;
|
|
36
|
+
noCashFlowSummary: string;
|
|
37
|
+
depletionWarning: string;
|
|
38
|
+
scenarioNote: string;
|
|
39
|
+
currencyHint: string;
|
|
40
|
+
yearsSuffix: string;
|
|
41
|
+
amountSuffix: string;
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { mortgageExtraPaymentPlanner } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await mortgageExtraPaymentPlanner.i18n[locale]?.();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Banco de España, Simulador de préstamo hipotecario o personal con amortización anticipada',
|
|
6
|
+
url: 'https://clientebancario.bde.es/pcb/es/menu-horizontal/podemosayudarte/simuladores/simulador_amortizacion_anticipada_prestamo.html',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Consumer Financial Protection Bureau, How does paying down a mortgage work?',
|
|
10
|
+
url: 'https://www.consumerfinance.gov/ask-cfpb/how-does-paying-down-a-mortgage-work-en-1943/',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { KnownLocale } from '../../types';
|
|
3
|
+
import { calculateMortgage, getDefaultCurrency, type MortgageInputs } from './logic';
|
|
4
|
+
import type { MortgageExtraPaymentPlannerUI } from './ui';
|
|
5
|
+
import './mortgage-amortization-extra-payment-planner.css';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
ui: MortgageExtraPaymentPlannerUI;
|
|
9
|
+
locale?: KnownLocale;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { ui, locale = 'en' } = Astro.props;
|
|
13
|
+
const initialCurrency = getDefaultCurrency(locale);
|
|
14
|
+
const initial: MortgageInputs = {
|
|
15
|
+
principal: 250000,
|
|
16
|
+
annualRate: 3.5,
|
|
17
|
+
termYears: 25,
|
|
18
|
+
frequency: 'monthly',
|
|
19
|
+
recurringExtra: 150,
|
|
20
|
+
oneOffExtra: 5000,
|
|
21
|
+
oneOffPeriod: 12,
|
|
22
|
+
startDate: '2026-09-01',
|
|
23
|
+
currency: initialCurrency,
|
|
24
|
+
};
|
|
25
|
+
const initialResult = calculateMortgage(initial);
|
|
26
|
+
const config = JSON.stringify({ ui, initial, locale });
|
|
27
|
+
const currencyOptions = Object.entries(ui.currencyOptions) as [string, string][];
|
|
28
|
+
const frequencyOptions = Object.entries(ui.frequencyOptions) as [string, string][];
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
<div class="mortgage-planner" data-mortgage-planner>
|
|
32
|
+
<form class="mortgage-controls" aria-label="Mortgage amortization inputs">
|
|
33
|
+
<div class="mortgage-currency-bar">
|
|
34
|
+
<div>
|
|
35
|
+
<span class="mortgage-eyebrow">{ui.currencyLabel}</span>
|
|
36
|
+
<p class="mortgage-currency-help">{ui.currencyHelp}</p>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="mortgage-field mortgage-currency-control">
|
|
39
|
+
<label>{ui.currencyLabel}</label>
|
|
40
|
+
<div class="mortgage-select" data-mortgage-select="currency" data-value={initial.currency}>
|
|
41
|
+
<button type="button" class="mortgage-select-trigger" data-mortgage-trigger aria-haspopup="listbox" aria-expanded="false">{ui.currencyOptions[initial.currency]}</button>
|
|
42
|
+
<div class="mortgage-select-menu" data-mortgage-menu role="listbox" hidden>
|
|
43
|
+
{currencyOptions.map(([value, label]) => <button type="button" data-mortgage-option data-value={value} role="option" aria-selected={value === initial.currency}>{label}</button>)}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<strong class="mortgage-status" data-mortgage-status>{initialResult ? `0 ${ui.periodsSavedLabel}` : ui.invalidInputMessage}</strong>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="mortgage-input-grid">
|
|
51
|
+
<div class="mortgage-field mortgage-field-major">
|
|
52
|
+
<label for="mortgage-principal">{ui.principalLabel}</label>
|
|
53
|
+
<input id="mortgage-principal" data-mortgage-input="principal" type="number" min="0.01" step="0.01" value={initial.principal} inputmode="decimal" />
|
|
54
|
+
<span>{ui.principalHelp}</span>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="mortgage-field">
|
|
57
|
+
<label for="mortgage-rate">{ui.annualRateLabel}</label>
|
|
58
|
+
<input id="mortgage-rate" data-mortgage-input="annualRate" type="number" min="0" max="100" step="0.01" value={initial.annualRate} inputmode="decimal" />
|
|
59
|
+
<span>{ui.annualRateHelp}</span>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="mortgage-field">
|
|
62
|
+
<label for="mortgage-term">{ui.termLabel}</label>
|
|
63
|
+
<input id="mortgage-term" data-mortgage-input="termYears" type="number" min="0.25" max="100" step="0.25" value={initial.termYears} inputmode="decimal" />
|
|
64
|
+
<span>{ui.termHelp}</span>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="mortgage-field">
|
|
67
|
+
<label>{ui.frequencyLabel}</label>
|
|
68
|
+
<div class="mortgage-select" data-mortgage-select="frequency" data-value={initial.frequency}>
|
|
69
|
+
<button type="button" class="mortgage-select-trigger" data-mortgage-trigger aria-haspopup="listbox" aria-expanded="false">{ui.frequencyOptions[initial.frequency]}</button>
|
|
70
|
+
<div class="mortgage-select-menu" data-mortgage-menu role="listbox" hidden>
|
|
71
|
+
{frequencyOptions.map(([value, label]) => <button type="button" data-mortgage-option data-value={value} role="option" aria-selected={value === initial.frequency}>{label}</button>)}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="mortgage-field">
|
|
76
|
+
<label for="mortgage-date">{ui.startDateLabel}</label>
|
|
77
|
+
<input id="mortgage-date" data-mortgage-input="startDate" type="date" value={initial.startDate} />
|
|
78
|
+
<span>{ui.startDateHelp}</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<details class="mortgage-extra-details">
|
|
83
|
+
<summary>{ui.extraSectionTitle}</summary>
|
|
84
|
+
<div class="mortgage-extra-grid">
|
|
85
|
+
<div class="mortgage-field">
|
|
86
|
+
<label for="mortgage-recurring-extra">{ui.recurringExtraLabel}</label>
|
|
87
|
+
<input id="mortgage-recurring-extra" data-mortgage-input="recurringExtra" type="number" min="0" step="0.01" value={initial.recurringExtra} inputmode="decimal" />
|
|
88
|
+
<span>{ui.recurringExtraHelp}</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="mortgage-field">
|
|
91
|
+
<label for="mortgage-one-off-extra">{ui.oneOffExtraLabel}</label>
|
|
92
|
+
<input id="mortgage-one-off-extra" data-mortgage-input="oneOffExtra" type="number" min="0" step="0.01" value={initial.oneOffExtra} inputmode="decimal" />
|
|
93
|
+
<span>{ui.oneOffExtraHelp}</span>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="mortgage-field">
|
|
96
|
+
<label for="mortgage-one-off-period">{ui.oneOffPeriodLabel}</label>
|
|
97
|
+
<input id="mortgage-one-off-period" data-mortgage-input="oneOffPeriod" type="number" min="1" step="1" value={initial.oneOffPeriod} inputmode="numeric" />
|
|
98
|
+
<span>{ui.oneOffPeriodHelp}</span>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</details>
|
|
102
|
+
</form>
|
|
103
|
+
|
|
104
|
+
<section class="mortgage-output" aria-live="polite">
|
|
105
|
+
<div class="mortgage-output-heading">
|
|
106
|
+
<span class="mortgage-eyebrow">{ui.outputTitle}</span>
|
|
107
|
+
<span class="mortgage-output-currency" data-mortgage-output-currency>{ui.currencySymbols[initial.currency]} {initial.currency}</span>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="mortgage-output-grid">
|
|
110
|
+
<div class="mortgage-scene" data-mortgage-scene>
|
|
111
|
+
<div class="mortgage-initial-scene" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span></div>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="mortgage-results" data-mortgage-results>
|
|
114
|
+
{initialResult && <div class="mortgage-static-result"><span>{ui.regularPaymentLabel}</span><strong>{new Intl.NumberFormat(locale, { style: 'currency', currency: initial.currency }).format(initialResult.regularPayment)}</strong></div>}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
<p class="mortgage-empty" data-mortgage-empty hidden>{ui.emptyMessage}</p>
|
|
118
|
+
<div class="mortgage-annual-wrap">
|
|
119
|
+
<div class="mortgage-annual-heading"><h2>{ui.annualScheduleTitle}</h2><span>{ui.currencyLabel}</span></div>
|
|
120
|
+
<div class="mortgage-annual-table-scroll" data-mortgage-annual></div>
|
|
121
|
+
</div>
|
|
122
|
+
</section>
|
|
123
|
+
|
|
124
|
+
<details class="mortgage-method">
|
|
125
|
+
<summary>{ui.formulaTitle}</summary>
|
|
126
|
+
<p>{ui.formulaText}</p>
|
|
127
|
+
<p class="mortgage-risk-note">{ui.riskNote}</p>
|
|
128
|
+
</details>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<script is:inline type="application/json" data-mortgage-config set:html={config}></script>
|
|
132
|
+
<script>
|
|
133
|
+
import { initMortgagePlanner } from './controller';
|
|
134
|
+
|
|
135
|
+
const root = document.querySelector<HTMLElement>('[data-mortgage-planner]');
|
|
136
|
+
const configElement = document.querySelector<HTMLScriptElement>('[data-mortgage-config]');
|
|
137
|
+
if (root && configElement?.textContent) initMortgagePlanner(root, JSON.parse(configElement.textContent));
|
|
138
|
+
</script>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { calculateMortgage, convertCurrencyAmount, type CurrencyCode, type MortgageFrequency, type MortgageInputs } from './logic';
|
|
2
|
+
import { renderMortgageViews } from './dom-views';
|
|
3
|
+
import { readMortgageState, writeMortgageState } from './storage';
|
|
4
|
+
import type { MortgageExtraPaymentPlannerUI } from './ui';
|
|
5
|
+
|
|
6
|
+
interface MortgageConfig {
|
|
7
|
+
ui: MortgageExtraPaymentPlannerUI;
|
|
8
|
+
initial: MortgageInputs;
|
|
9
|
+
locale: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function fieldValue(root: HTMLElement, key: string): string {
|
|
13
|
+
return root.querySelector<HTMLInputElement>(`[data-mortgage-input="${key}"]`)?.value ?? '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function numberValue(root: HTMLElement, key: string): number {
|
|
17
|
+
const value = Number(fieldValue(root, key));
|
|
18
|
+
return Number.isFinite(value) ? value : 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function setInput(root: HTMLElement, key: string, value: string | number): void {
|
|
22
|
+
const input = root.querySelector<HTMLInputElement>(`[data-mortgage-input="${key}"]`);
|
|
23
|
+
if (input) input.value = `${value}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function selectedValue(root: HTMLElement, key: string, fallback: string): string {
|
|
27
|
+
return root.querySelector<HTMLElement>(`[data-mortgage-select="${key}"]`)?.dataset.value ?? fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readInputs(root: HTMLElement, state: MortgageInputs): MortgageInputs {
|
|
31
|
+
return {
|
|
32
|
+
...state,
|
|
33
|
+
principal: numberValue(root, 'principal'),
|
|
34
|
+
annualRate: numberValue(root, 'annualRate'),
|
|
35
|
+
termYears: numberValue(root, 'termYears'),
|
|
36
|
+
recurringExtra: numberValue(root, 'recurringExtra'),
|
|
37
|
+
oneOffExtra: numberValue(root, 'oneOffExtra'),
|
|
38
|
+
oneOffPeriod: numberValue(root, 'oneOffPeriod'),
|
|
39
|
+
startDate: fieldValue(root, 'startDate'),
|
|
40
|
+
frequency: selectedValue(root, 'frequency', state.frequency) as MortgageFrequency,
|
|
41
|
+
currency: selectedValue(root, 'currency', state.currency) as CurrencyCode,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function closeSelects(root: HTMLElement): void {
|
|
46
|
+
root.querySelectorAll<HTMLElement>('[data-mortgage-menu]').forEach((menu) => {
|
|
47
|
+
menu.hidden = true;
|
|
48
|
+
menu.previousElementSibling?.setAttribute('aria-expanded', 'false');
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function selectedLabel(ui: MortgageExtraPaymentPlannerUI, key: string, value: string): string {
|
|
53
|
+
if (key === 'frequency') return ui.frequencyOptions[value as MortgageFrequency] ?? value;
|
|
54
|
+
return ui.currencyOptions[value as CurrencyCode] ?? value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function syncSelect(root: HTMLElement, ui: MortgageExtraPaymentPlannerUI, key: string, value: string): void {
|
|
58
|
+
const select = root.querySelector<HTMLElement>(`[data-mortgage-select="${key}"]`);
|
|
59
|
+
if (!select) return;
|
|
60
|
+
select.dataset.value = value;
|
|
61
|
+
const trigger = select.querySelector<HTMLButtonElement>('[data-mortgage-trigger]');
|
|
62
|
+
if (trigger) trigger.textContent = selectedLabel(ui, key, value);
|
|
63
|
+
select.querySelectorAll<HTMLButtonElement>('[data-mortgage-option]').forEach((option) => {
|
|
64
|
+
const active = option.dataset.value === value;
|
|
65
|
+
option.classList.toggle('is-active', active);
|
|
66
|
+
option.setAttribute('aria-selected', `${active}`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function syncCurrency(root: HTMLElement, ui: MortgageExtraPaymentPlannerUI, currency: CurrencyCode): void {
|
|
71
|
+
const outputCurrency = root.querySelector<HTMLElement>('[data-mortgage-output-currency]');
|
|
72
|
+
if (outputCurrency) outputCurrency.textContent = `${ui.currencySymbols[currency]} ${currency}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function convertMonetaryInputs(root: HTMLElement, from: CurrencyCode, to: CurrencyCode): void {
|
|
76
|
+
if (from === to) return;
|
|
77
|
+
const factor = convertCurrencyAmount(1, from, to);
|
|
78
|
+
['principal', 'recurringExtra', 'oneOffExtra'].forEach((key) => {
|
|
79
|
+
const value = numberValue(root, key);
|
|
80
|
+
setInput(root, key, Number((value * factor).toFixed(2)));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function bindSelect(root: HTMLElement, key: string, onChange: (value: string) => void): void {
|
|
85
|
+
const select = root.querySelector<HTMLElement>(`[data-mortgage-select="${key}"]`);
|
|
86
|
+
const trigger = select?.querySelector<HTMLButtonElement>('[data-mortgage-trigger]');
|
|
87
|
+
const menu = select?.querySelector<HTMLElement>('[data-mortgage-menu]');
|
|
88
|
+
if (!select || !trigger || !menu) return;
|
|
89
|
+
trigger.addEventListener('click', () => {
|
|
90
|
+
const shouldOpen = menu.hidden;
|
|
91
|
+
closeSelects(root);
|
|
92
|
+
menu.hidden = !shouldOpen;
|
|
93
|
+
trigger.setAttribute('aria-expanded', `${shouldOpen}`);
|
|
94
|
+
});
|
|
95
|
+
trigger.addEventListener('keydown', (event) => {
|
|
96
|
+
if (event.key === 'Escape') {
|
|
97
|
+
menu.hidden = true;
|
|
98
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
menu.querySelectorAll<HTMLButtonElement>('[data-mortgage-option]').forEach((option) => {
|
|
102
|
+
option.addEventListener('click', () => {
|
|
103
|
+
onChange(option.dataset.value ?? '');
|
|
104
|
+
menu.hidden = true;
|
|
105
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
106
|
+
trigger.focus();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function bindInputs(root: HTMLElement, update: () => void): void {
|
|
112
|
+
root.querySelectorAll<HTMLInputElement>('[data-mortgage-input]').forEach((input) => {
|
|
113
|
+
input.addEventListener('input', update);
|
|
114
|
+
input.addEventListener('change', update);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function syncAll(root: HTMLElement, state: MortgageInputs, ui: MortgageExtraPaymentPlannerUI): void {
|
|
119
|
+
['principal', 'annualRate', 'termYears', 'recurringExtra', 'oneOffExtra', 'oneOffPeriod', 'startDate'].forEach((key) => setInput(root, key, state[key as keyof MortgageInputs] as string | number));
|
|
120
|
+
syncSelect(root, ui, 'frequency', state.frequency);
|
|
121
|
+
syncSelect(root, ui, 'currency', state.currency);
|
|
122
|
+
syncCurrency(root, ui, state.currency);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function initMortgagePlanner(root: HTMLElement, config: MortgageConfig): void {
|
|
126
|
+
let state: MortgageInputs = { ...config.initial, ...readMortgageState(config.locale) };
|
|
127
|
+
const update = () => {
|
|
128
|
+
state = readInputs(root, state);
|
|
129
|
+
writeMortgageState(state, config.locale);
|
|
130
|
+
syncCurrency(root, config.ui, state.currency);
|
|
131
|
+
const result = calculateMortgage(state);
|
|
132
|
+
renderMortgageViews(root, result ? { result, inputs: state, ui: config.ui, locale: config.locale } : null);
|
|
133
|
+
const status = root.querySelector<HTMLElement>('[data-mortgage-status]');
|
|
134
|
+
if (status) status.textContent = result ? `${result.periodsSaved} ${config.ui.periodsSavedLabel}` : config.ui.invalidInputMessage;
|
|
135
|
+
};
|
|
136
|
+
syncAll(root, state, config.ui);
|
|
137
|
+
bindInputs(root, update);
|
|
138
|
+
bindSelect(root, 'frequency', (value) => { state.frequency = value as MortgageFrequency; syncSelect(root, config.ui, 'frequency', value); update(); });
|
|
139
|
+
bindSelect(root, 'currency', (value) => {
|
|
140
|
+
const nextCurrency = value as CurrencyCode;
|
|
141
|
+
convertMonetaryInputs(root, state.currency, nextCurrency);
|
|
142
|
+
state.currency = nextCurrency;
|
|
143
|
+
syncSelect(root, config.ui, 'currency', value);
|
|
144
|
+
update();
|
|
145
|
+
});
|
|
146
|
+
document.addEventListener('click', (event) => {
|
|
147
|
+
if (!root.contains(event.target as Node)) closeSelects(root);
|
|
148
|
+
});
|
|
149
|
+
update();
|
|
150
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CurrencyCode } from './logic';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_CURRENCY_BY_LOCALE: Record<string, CurrencyCode> = {
|
|
4
|
+
'en-au': 'AUD',
|
|
5
|
+
'en-ca': 'CAD',
|
|
6
|
+
'en-gb': 'GBP',
|
|
7
|
+
'en-in': 'INR',
|
|
8
|
+
'fr-ca': 'CAD',
|
|
9
|
+
'pt-br': 'BRL',
|
|
10
|
+
de: 'EUR',
|
|
11
|
+
en: 'USD',
|
|
12
|
+
es: 'EUR',
|
|
13
|
+
fr: 'EUR',
|
|
14
|
+
id: 'IDR',
|
|
15
|
+
it: 'EUR',
|
|
16
|
+
ja: 'JPY',
|
|
17
|
+
ko: 'KRW',
|
|
18
|
+
nl: 'EUR',
|
|
19
|
+
pl: 'PLN',
|
|
20
|
+
pt: 'EUR',
|
|
21
|
+
ru: 'RUB',
|
|
22
|
+
sv: 'SEK',
|
|
23
|
+
tr: 'TRY',
|
|
24
|
+
zh: 'CNY',
|
|
25
|
+
};
|