@jjlmoya/utils-finance 1.38.0 → 1.40.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 -1
- package/src/entries.ts +7 -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/loanToIncomeRatio/bibliography.astro +11 -0
- package/src/tool/loanToIncomeRatio/bibliography.ts +12 -0
- package/src/tool/loanToIncomeRatio/component.astro +453 -0
- package/src/tool/loanToIncomeRatio/content.test.ts +37 -0
- package/src/tool/loanToIncomeRatio/content.ts +247 -0
- package/src/tool/loanToIncomeRatio/currency.test.ts +21 -0
- package/src/tool/loanToIncomeRatio/currency.ts +19 -0
- package/src/tool/loanToIncomeRatio/entry.ts +31 -0
- package/src/tool/loanToIncomeRatio/i18n/de.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/en.ts +3 -0
- package/src/tool/loanToIncomeRatio/i18n/es.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/fr.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/id.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/it.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/ja.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/ko.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/nl.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/pl.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/pt.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/ru.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/sv.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/tr.ts +2 -0
- package/src/tool/loanToIncomeRatio/i18n/zh.ts +2 -0
- package/src/tool/loanToIncomeRatio/index.ts +11 -0
- package/src/tool/loanToIncomeRatio/loan-to-income-ratio.css +580 -0
- package/src/tool/loanToIncomeRatio/logic.test.ts +55 -0
- package/src/tool/loanToIncomeRatio/logic.ts +92 -0
- package/src/tool/loanToIncomeRatio/seo.astro +11 -0
- package/src/tool/loanToIncomeRatio/ui.ts +55 -0
- package/src/tool/loanToIncomeRatio/validation.ts +34 -0
- package/src/tool/subscriptionCostAnnualizer/bibliography.astro +11 -0
- package/src/tool/subscriptionCostAnnualizer/bibliography.ts +6 -0
- package/src/tool/subscriptionCostAnnualizer/component.astro +58 -0
- package/src/tool/subscriptionCostAnnualizer/content.ts +716 -0
- package/src/tool/subscriptionCostAnnualizer/controller.ts +72 -0
- package/src/tool/subscriptionCostAnnualizer/dom-views.ts +38 -0
- package/src/tool/subscriptionCostAnnualizer/entry.ts +28 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/de.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/en.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/es.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/fr.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/id.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/it.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/ja.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/ko.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/nl.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/pl.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/pt.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/ru.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/sv.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/tr.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/i18n/zh.ts +2 -0
- package/src/tool/subscriptionCostAnnualizer/index.ts +12 -0
- package/src/tool/subscriptionCostAnnualizer/logic.test.ts +37 -0
- package/src/tool/subscriptionCostAnnualizer/logic.ts +120 -0
- package/src/tool/subscriptionCostAnnualizer/seo.astro +11 -0
- package/src/tool/subscriptionCostAnnualizer/storage.ts +28 -0
- package/src/tool/subscriptionCostAnnualizer/subscription-cost-annualizer.css +408 -0
- package/src/tool/subscriptionCostAnnualizer/ui.ts +38 -0
- package/src/tools.ts +3 -1
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -18,10 +18,12 @@ import { pesetaValue } from '../tool/pesetaValue/entry';
|
|
|
18
18
|
import { salaryEmployerCost } from '../tool/salaryEmployerCost/entry';
|
|
19
19
|
import { dividendYield } from '../tool/dividendYield/entry';
|
|
20
20
|
import { inflationPurchasingPower } from '../tool/inflationPurchasingPower/entry';
|
|
21
|
+
import { subscriptionCostAnnualizer } from '../tool/subscriptionCostAnnualizer/entry';
|
|
22
|
+
import { loanToIncomeRatio } from '../tool/loanToIncomeRatio/entry';
|
|
21
23
|
|
|
22
24
|
export const financeCategory: FinanceCategoryEntry = {
|
|
23
25
|
icon: 'mdi:finance',
|
|
24
|
-
tools: [compoundInterest, financialFreedom, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower],
|
|
26
|
+
tools: [compoundInterest, financialFreedom, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio],
|
|
25
27
|
i18n: {
|
|
26
28
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
27
29
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -36,6 +36,10 @@ export { dividendYield } from './tool/dividendYield/entry';
|
|
|
36
36
|
export type { DividendYieldLocaleContent } from './tool/dividendYield/entry';
|
|
37
37
|
export { inflationPurchasingPower } from './tool/inflationPurchasingPower/entry';
|
|
38
38
|
export type { InflationPurchasingPowerLocaleContent } from './tool/inflationPurchasingPower/entry';
|
|
39
|
+
export { subscriptionCostAnnualizer } from './tool/subscriptionCostAnnualizer/entry';
|
|
40
|
+
export type { SubscriptionCostAnnualizerLocaleContent } from './tool/subscriptionCostAnnualizer/entry';
|
|
41
|
+
export { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
|
|
42
|
+
export type { LoanToIncomeRatioLocaleContent } from './tool/loanToIncomeRatio/entry';
|
|
39
43
|
export { financeCategory } from './category';
|
|
40
44
|
import { compoundInterest } from './tool/compoundInterest/entry';
|
|
41
45
|
import { financialFreedom } from './tool/financialFreedom/entry';
|
|
@@ -57,4 +61,6 @@ import { salaryEmployerCost } from './tool/salaryEmployerCost/entry';
|
|
|
57
61
|
import { dividendYield } from './tool/dividendYield/entry';
|
|
58
62
|
import { mortgageExtraPaymentPlanner } from './tool/mortgageExtraPaymentPlanner/entry';
|
|
59
63
|
import { inflationPurchasingPower } from './tool/inflationPurchasingPower/entry';
|
|
60
|
-
|
|
64
|
+
import { subscriptionCostAnnualizer } from './tool/subscriptionCostAnnualizer/entry';
|
|
65
|
+
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];
|
package/src/index.ts
CHANGED
|
@@ -34,3 +34,4 @@ export { PESETA_VALUE_TOOL } from './tool/pesetaValue';
|
|
|
34
34
|
export { SALARY_EMPLOYER_COST_TOOL } from './tool/salaryEmployerCost';
|
|
35
35
|
export { DIVIDEND_YIELD_TOOL } from './tool/dividendYield';
|
|
36
36
|
export { MORTGAGE_EXTRA_PAYMENT_PLANNER_TOOL } from './tool/mortgageExtraPaymentPlanner';
|
|
37
|
+
export { LOAN_TO_INCOME_RATIO_TOOL } from './tool/loanToIncomeRatio';
|
|
@@ -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 22 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(22);
|
|
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 { loanToIncomeRatio } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props { locale?: KnownLocale; }
|
|
7
|
+
const { locale = 'en' } = Astro.props;
|
|
8
|
+
const content = await loanToIncomeRatio.i18n[locale]?.() ?? await loanToIncomeRatio.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: What is a debt-to-income ratio?',
|
|
6
|
+
url: 'https://www.consumerfinance.gov/ask-cfpb/what-is-a-debt-to-income-ratio-en-1791/',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Consumer Financial Protection Bureau: Debt-to-income worksheet',
|
|
10
|
+
url: 'https://files.consumerfinance.gov/f/documents/cfpb_ymyg_a-financial-empowerment-toolkit_large-print.pdf',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { CURRENCIES } from './currency';
|
|
3
|
+
import type { LoanToIncomeRatioUI } from './ui';
|
|
4
|
+
import './loan-to-income-ratio.css';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
ui: LoanToIncomeRatioUI;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { ui } = Astro.props;
|
|
11
|
+
const currencyOptions = [
|
|
12
|
+
{ ...CURRENCIES.EUR, label: ui.labelEuro },
|
|
13
|
+
{ ...CURRENCIES.USD, label: ui.labelUSDollar },
|
|
14
|
+
{ ...CURRENCIES.GBP, label: ui.labelPoundSterling },
|
|
15
|
+
{ ...CURRENCIES.RUB, label: ui.labelRussianRuble },
|
|
16
|
+
];
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<div
|
|
20
|
+
class="lti-tool"
|
|
21
|
+
id="loan-to-income-ratio-tool"
|
|
22
|
+
data-label-within-threshold={ui.labelWithinThreshold}
|
|
23
|
+
data-label-over-threshold={ui.labelOverThreshold}
|
|
24
|
+
data-label-no-new-loan={ui.labelNoNewLoan}
|
|
25
|
+
data-label-invalid-input={ui.labelInvalidInput}
|
|
26
|
+
data-label-copied={ui.labelCopied}
|
|
27
|
+
data-label-percentage-points={ui.labelPercentagePoints}
|
|
28
|
+
data-label-current-scenario={ui.labelCurrentScenario}
|
|
29
|
+
data-label-projected-scenario={ui.labelProjectedScenario}
|
|
30
|
+
data-label-ratio-change={ui.labelRatioChange}
|
|
31
|
+
data-label-threshold-margin={ui.labelThresholdMargin}
|
|
32
|
+
data-label-new-loan-total={ui.labelNewLoanTotal}
|
|
33
|
+
data-label-currency-rate={ui.labelCurrencyRate}
|
|
34
|
+
data-label-currency-rate-date={ui.labelCurrencyRateDate}
|
|
35
|
+
>
|
|
36
|
+
<div class="lti-workbench">
|
|
37
|
+
<form class="lti-input-panel" id="loan-to-income-form">
|
|
38
|
+
<div class="lti-panel-kicker">{ui.labelHowToRead}</div>
|
|
39
|
+
|
|
40
|
+
<div class="lti-field">
|
|
41
|
+
<div class="lti-field-label">{ui.labelCurrency}</div>
|
|
42
|
+
<div class="lti-currency-selector">
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
class="lti-currency-trigger"
|
|
46
|
+
id="lti-currency-trigger"
|
|
47
|
+
aria-haspopup="listbox"
|
|
48
|
+
aria-expanded="false"
|
|
49
|
+
aria-controls="lti-currency-menu"
|
|
50
|
+
>
|
|
51
|
+
<span class="lti-currency-trigger-code" id="lti-selected-currency-code">EUR</span>
|
|
52
|
+
<span class="lti-currency-trigger-label" id="lti-selected-currency-label">{ui.labelEuro}</span>
|
|
53
|
+
<span class="lti-currency-trigger-icon" aria-hidden="true"></span>
|
|
54
|
+
</button>
|
|
55
|
+
<div class="lti-currency-menu" id="lti-currency-menu" hidden>
|
|
56
|
+
<label class="lti-visually-hidden" for="lti-currency-search">{ui.labelSelectCurrency}</label>
|
|
57
|
+
<input
|
|
58
|
+
id="lti-currency-search"
|
|
59
|
+
class="lti-currency-search"
|
|
60
|
+
type="search"
|
|
61
|
+
placeholder={ui.labelSelectCurrency}
|
|
62
|
+
autocomplete="off"
|
|
63
|
+
/>
|
|
64
|
+
<div class="lti-currency-options" id="lti-currency-options" role="listbox" aria-label={ui.labelSelectCurrency}>
|
|
65
|
+
{currencyOptions.map((option) => (
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
class="lti-currency-button"
|
|
69
|
+
role="option"
|
|
70
|
+
data-currency={option.code}
|
|
71
|
+
data-search={`${option.code} ${option.label}`}
|
|
72
|
+
aria-selected={option.code === 'EUR' ? 'true' : 'false'}
|
|
73
|
+
>
|
|
74
|
+
<strong>{option.code}</strong>
|
|
75
|
+
<span>{option.label}</span>
|
|
76
|
+
</button>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<small id="lti-currency-rate">{ui.labelCurrencyRate}: 1 EUR = 1 EUR · {ui.labelCurrencyRateDate}</small>
|
|
82
|
+
<small>{ui.labelCurrencyHelp}</small>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="lti-field">
|
|
86
|
+
<label for="lti-income">{ui.labelIncome}</label>
|
|
87
|
+
<div class="lti-input-wrap">
|
|
88
|
+
<span class="lti-currency-symbol" aria-hidden="true">€</span>
|
|
89
|
+
<input id="lti-income" type="number" min="1" max="1000000000" step="100" value="5000" required />
|
|
90
|
+
</div>
|
|
91
|
+
<small>{ui.labelIncomeHelp}</small>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div class="lti-field">
|
|
95
|
+
<div class="lti-field-label">{ui.labelIncomeBasis}</div>
|
|
96
|
+
<div class="lti-choice-group" role="group" aria-label={ui.labelIncomeBasis}>
|
|
97
|
+
<button type="button" class="lti-choice-button" data-basis="gross" aria-pressed="true">{ui.labelGrossIncome}</button>
|
|
98
|
+
<button type="button" class="lti-choice-button" data-basis="net" aria-pressed="false">{ui.labelNetIncome}</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div class="lti-field">
|
|
103
|
+
<label for="lti-existing">{ui.labelExistingPayments}</label>
|
|
104
|
+
<div class="lti-input-wrap">
|
|
105
|
+
<span class="lti-currency-symbol" aria-hidden="true">€</span>
|
|
106
|
+
<input id="lti-existing" type="number" min="0" max="1000000000" step="50" value="1200" required />
|
|
107
|
+
</div>
|
|
108
|
+
<small>{ui.labelExistingPaymentsHelp}</small>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div class="lti-new-loan">
|
|
112
|
+
<div class="lti-section-label">{ui.labelProjectedScenario}</div>
|
|
113
|
+
<div class="lti-field">
|
|
114
|
+
<label for="lti-new-payment">{ui.labelNewPayment}</label>
|
|
115
|
+
<div class="lti-input-wrap">
|
|
116
|
+
<span class="lti-currency-symbol" aria-hidden="true">€</span>
|
|
117
|
+
<input id="lti-new-payment" type="number" min="0" max="1000000000" step="50" value="500" required />
|
|
118
|
+
</div>
|
|
119
|
+
<small>{ui.labelNewPaymentHelp}</small>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div class="lti-two-fields">
|
|
123
|
+
<div class="lti-field">
|
|
124
|
+
<label for="lti-term">{ui.labelLoanTerm}</label>
|
|
125
|
+
<div class="lti-input-wrap lti-input-wrap-suffix">
|
|
126
|
+
<input id="lti-term" type="number" min="0" max="600" step="1" value="60" required />
|
|
127
|
+
<span>{ui.labelMonths}</span>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="lti-field">
|
|
131
|
+
<label for="lti-threshold">{ui.labelThreshold}</label>
|
|
132
|
+
<div class="lti-input-wrap lti-input-wrap-suffix">
|
|
133
|
+
<input id="lti-threshold" type="number" min="1" max="100" step="0.5" value="36" required />
|
|
134
|
+
<span>%</span>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<small class="lti-threshold-help">{ui.labelThresholdHelp}</small>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div class="lti-form-actions">
|
|
142
|
+
<button type="button" class="lti-button lti-button-secondary" id="lti-reset">{ui.labelReset}</button>
|
|
143
|
+
<button type="button" class="lti-button lti-button-primary" id="lti-copy">{ui.labelCopyResult}</button>
|
|
144
|
+
</div>
|
|
145
|
+
</form>
|
|
146
|
+
|
|
147
|
+
<section class="lti-results-panel" aria-label={ui.labelProjectedScenario}>
|
|
148
|
+
<div class="lti-results-topline">
|
|
149
|
+
<span class="lti-panel-kicker">{ui.labelProjectedScenario}</span>
|
|
150
|
+
<span class="lti-status" id="lti-status" role="status" aria-live="polite">{ui.labelWithinThreshold}</span>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div class="lti-ratio-display">
|
|
154
|
+
<div class="lti-ratio-main">
|
|
155
|
+
<span>{ui.labelProjectedRatio}</span>
|
|
156
|
+
<strong id="lti-projected-ratio">34%</strong>
|
|
157
|
+
<small id="lti-status-detail">{ui.labelWithinThreshold}</small>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="lti-gauge" aria-hidden="true">
|
|
160
|
+
<div class="lti-gauge-track"><span id="lti-gauge-fill"></span><i id="lti-gauge-marker"></i></div>
|
|
161
|
+
<div class="lti-gauge-labels"><span>{ui.labelZeroPercent}</span><span>{ui.labelThreshold}</span><span>{ui.labelFullPercent}</span></div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div class="lti-scenario-grid">
|
|
166
|
+
<article class="lti-scenario-card lti-scenario-current">
|
|
167
|
+
<div class="lti-card-heading"><span class="lti-dot"></span><h3>{ui.labelCurrentScenario}</h3></div>
|
|
168
|
+
<span class="lti-card-ratio-label">{ui.labelCurrentRatio}</span>
|
|
169
|
+
<strong id="lti-current-ratio">24%</strong>
|
|
170
|
+
<dl>
|
|
171
|
+
<div><dt>{ui.labelMonthlyDebt}</dt><dd id="lti-current-debt">€1,200</dd></div>
|
|
172
|
+
<div><dt>{ui.labelRemainingIncome}</dt><dd id="lti-current-remaining">€3,800</dd></div>
|
|
173
|
+
</dl>
|
|
174
|
+
</article>
|
|
175
|
+
<article class="lti-scenario-card lti-scenario-projected">
|
|
176
|
+
<div class="lti-card-heading"><span class="lti-dot"></span><h3>{ui.labelProjectedScenario}</h3></div>
|
|
177
|
+
<span class="lti-card-ratio-label">{ui.labelProjectedRatio}</span>
|
|
178
|
+
<strong id="lti-projected-ratio-card">34%</strong>
|
|
179
|
+
<dl>
|
|
180
|
+
<div><dt>{ui.labelMonthlyDebt}</dt><dd id="lti-projected-debt">€1,700</dd></div>
|
|
181
|
+
<div><dt>{ui.labelRemainingIncome}</dt><dd id="lti-projected-remaining">€3,300</dd></div>
|
|
182
|
+
</dl>
|
|
183
|
+
</article>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div class="lti-insight-grid">
|
|
187
|
+
<div><span>{ui.labelRatioChange}</span><strong id="lti-ratio-change">+10 {ui.labelPercentagePoints}</strong></div>
|
|
188
|
+
<div><span>{ui.labelThresholdMargin}</span><strong id="lti-threshold-margin">€100</strong></div>
|
|
189
|
+
<div><span>{ui.labelNewLoanTotal}</span><strong id="lti-new-loan-total">€30,000</strong></div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<p class="lti-empty-state" id="lti-error" role="alert" aria-live="polite" hidden>{ui.labelInvalidInput}</p>
|
|
193
|
+
<p class="lti-no-loan" id="lti-no-loan" hidden>{ui.labelNoNewLoan}</p>
|
|
194
|
+
<div class="lti-formula"><span>{ui.labelFormula}</span><code>{ui.labelFormulaExpression}</code></div>
|
|
195
|
+
</section>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<script>
|
|
200
|
+
import { calculateLoanToIncomeRatio, type IncomeBasis } from './logic';
|
|
201
|
+
import { CURRENCIES, convertCurrencyAmount, type CurrencyCode } from './currency';
|
|
202
|
+
|
|
203
|
+
const container = document.getElementById('loan-to-income-ratio-tool');
|
|
204
|
+
const form = document.getElementById('loan-to-income-form') as HTMLFormElement | null;
|
|
205
|
+
const getInput = (id: string) => document.getElementById(id) as HTMLInputElement | null;
|
|
206
|
+
const income = getInput('lti-income');
|
|
207
|
+
const existing = getInput('lti-existing');
|
|
208
|
+
const newPayment = getInput('lti-new-payment');
|
|
209
|
+
const term = getInput('lti-term');
|
|
210
|
+
const threshold = getInput('lti-threshold');
|
|
211
|
+
const error = document.getElementById('lti-error');
|
|
212
|
+
const noLoan = document.getElementById('lti-no-loan');
|
|
213
|
+
const status = document.getElementById('lti-status');
|
|
214
|
+
const statusDetail = document.getElementById('lti-status-detail');
|
|
215
|
+
const gaugeFill = document.getElementById('lti-gauge-fill');
|
|
216
|
+
const gaugeMarker = document.getElementById('lti-gauge-marker');
|
|
217
|
+
const currencyTrigger = document.getElementById('lti-currency-trigger') as HTMLButtonElement | null;
|
|
218
|
+
const currencyMenu = document.getElementById('lti-currency-menu');
|
|
219
|
+
const currencySearch = document.getElementById('lti-currency-search') as HTMLInputElement | null;
|
|
220
|
+
const currencyButtons = Array.from(document.querySelectorAll<HTMLButtonElement>('[data-currency]'));
|
|
221
|
+
const basisButtons = Array.from(document.querySelectorAll<HTMLButtonElement>('[data-basis]'));
|
|
222
|
+
const currencySymbols = Array.from(document.querySelectorAll<HTMLElement>('.lti-currency-symbol'));
|
|
223
|
+
const labels = {
|
|
224
|
+
within: container?.dataset.labelWithinThreshold ?? '',
|
|
225
|
+
over: container?.dataset.labelOverThreshold ?? '',
|
|
226
|
+
invalid: container?.dataset.labelInvalidInput ?? '',
|
|
227
|
+
copied: container?.dataset.labelCopied ?? '',
|
|
228
|
+
percentagePoints: container?.dataset.labelPercentagePoints ?? '',
|
|
229
|
+
currentScenario: container?.dataset.labelCurrentScenario ?? '',
|
|
230
|
+
projectedScenario: container?.dataset.labelProjectedScenario ?? '',
|
|
231
|
+
ratioChange: container?.dataset.labelRatioChange ?? '',
|
|
232
|
+
thresholdMargin: container?.dataset.labelThresholdMargin ?? '',
|
|
233
|
+
newLoanTotal: container?.dataset.labelNewLoanTotal ?? '',
|
|
234
|
+
currencyRate: container?.dataset.labelCurrencyRate ?? '',
|
|
235
|
+
currencyRateDate: container?.dataset.labelCurrencyRateDate ?? '',
|
|
236
|
+
};
|
|
237
|
+
let selectedCurrency: CurrencyCode = 'EUR';
|
|
238
|
+
let selectedBasis: IncomeBasis = 'gross';
|
|
239
|
+
|
|
240
|
+
const roundInput = (value: number): number => Math.round((value + Number.EPSILON) * 100) / 100;
|
|
241
|
+
const formatMoney = (value: number): string => {
|
|
242
|
+
const currency = CURRENCIES[selectedCurrency];
|
|
243
|
+
return new Intl.NumberFormat(currency.locale, {
|
|
244
|
+
style: 'currency',
|
|
245
|
+
currency: currency.code,
|
|
246
|
+
maximumFractionDigits: currency.code === 'RUB' ? 0 : 2,
|
|
247
|
+
}).format(value).replace(/\u00a0/g, ' ');
|
|
248
|
+
};
|
|
249
|
+
const setText = (id: string, value: string): void => {
|
|
250
|
+
const element = document.getElementById(id);
|
|
251
|
+
if (element) element.textContent = value;
|
|
252
|
+
};
|
|
253
|
+
const readNumber = (element: HTMLInputElement | null): number => Number(element?.value ?? '');
|
|
254
|
+
const amountInputs = [income, existing, newPayment];
|
|
255
|
+
|
|
256
|
+
const filterCurrencyOptions = (): void => {
|
|
257
|
+
const query = currencySearch?.value.trim().toLocaleLowerCase() ?? '';
|
|
258
|
+
currencyButtons.forEach((button) => {
|
|
259
|
+
const searchableText = button.dataset.search?.toLocaleLowerCase() ?? '';
|
|
260
|
+
button.hidden = Boolean(query) && !searchableText.includes(query);
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const openCurrencyMenu = (): void => {
|
|
265
|
+
if (!currencyMenu || !currencyTrigger) return;
|
|
266
|
+
currencyMenu.hidden = false;
|
|
267
|
+
currencyTrigger.setAttribute('aria-expanded', 'true');
|
|
268
|
+
filterCurrencyOptions();
|
|
269
|
+
currencySearch?.focus();
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const closeCurrencyMenu = (returnFocus = false): void => {
|
|
273
|
+
if (!currencyMenu || !currencyTrigger) return;
|
|
274
|
+
currencyMenu.hidden = true;
|
|
275
|
+
currencyTrigger.setAttribute('aria-expanded', 'false');
|
|
276
|
+
if (currencySearch) currencySearch.value = '';
|
|
277
|
+
filterCurrencyOptions();
|
|
278
|
+
if (returnFocus) currencyTrigger.focus();
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const focusCurrencyOption = (direction: 1 | -1): void => {
|
|
282
|
+
const visibleButtons = currencyButtons.filter((button) => !button.hidden);
|
|
283
|
+
if (!visibleButtons.length) return;
|
|
284
|
+
const focusedIndex = visibleButtons.indexOf(document.activeElement as HTMLButtonElement);
|
|
285
|
+
const nextIndex = focusedIndex < 0 ? 0 : (focusedIndex + direction + visibleButtons.length) % visibleButtons.length;
|
|
286
|
+
visibleButtons[nextIndex]?.focus();
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const renderCurrency = (): void => {
|
|
290
|
+
const currency = CURRENCIES[selectedCurrency];
|
|
291
|
+
const selectedButton = currencyButtons.find((button) => button.dataset.currency === selectedCurrency);
|
|
292
|
+
currencyButtons.forEach((button) => {
|
|
293
|
+
const active = button.dataset.currency === selectedCurrency;
|
|
294
|
+
button.setAttribute('aria-selected', String(active));
|
|
295
|
+
button.toggleAttribute('data-active', active);
|
|
296
|
+
});
|
|
297
|
+
setText('lti-selected-currency-code', currency.code);
|
|
298
|
+
setText('lti-selected-currency-label', selectedButton?.querySelector('span')?.textContent ?? currency.code);
|
|
299
|
+
currencySymbols.forEach((symbol) => { symbol.textContent = currency.symbol; });
|
|
300
|
+
const rate = currency.unitsPerEuro >= 10 ? currency.unitsPerEuro.toFixed(2) : currency.unitsPerEuro.toFixed(4);
|
|
301
|
+
setText('lti-currency-rate', `${labels.currencyRate}: 1 EUR = ${rate} ${currency.code} · ${labels.currencyRateDate}`);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const convertInputs = (from: CurrencyCode, to: CurrencyCode): void => {
|
|
305
|
+
amountInputs.forEach((input) => {
|
|
306
|
+
if (!input) return;
|
|
307
|
+
const value = Number(input.value);
|
|
308
|
+
if (Number.isFinite(value)) input.value = String(roundInput(convertCurrencyAmount(value, from, to)));
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const renderInvalid = (): void => {
|
|
313
|
+
if (error) {
|
|
314
|
+
error.hidden = false;
|
|
315
|
+
error.textContent = labels.invalid;
|
|
316
|
+
}
|
|
317
|
+
form?.setAttribute('aria-invalid', 'true');
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const renderStatus = (result: NonNullable<ReturnType<typeof calculateLoanToIncomeRatio>>): void => {
|
|
321
|
+
const projectedState = result.projected.withinThreshold ? labels.within : labels.over;
|
|
322
|
+
if (status) status.textContent = projectedState;
|
|
323
|
+
if (statusDetail) statusDetail.textContent = projectedState;
|
|
324
|
+
status?.toggleAttribute('data-over', !result.projected.withinThreshold);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const renderValid = (result: NonNullable<ReturnType<typeof calculateLoanToIncomeRatio>>): void => {
|
|
328
|
+
if (error) error.hidden = true;
|
|
329
|
+
form?.removeAttribute('aria-invalid');
|
|
330
|
+
renderStatus(result);
|
|
331
|
+
setText('lti-current-ratio', `${result.current.ratio}%`);
|
|
332
|
+
setText('lti-projected-ratio', `${result.projected.ratio}%`);
|
|
333
|
+
setText('lti-projected-ratio-card', `${result.projected.ratio}%`);
|
|
334
|
+
setText('lti-current-debt', formatMoney(result.current.monthlyDebt));
|
|
335
|
+
setText('lti-projected-debt', formatMoney(result.projected.monthlyDebt));
|
|
336
|
+
setText('lti-current-remaining', formatMoney(result.current.remainingIncome));
|
|
337
|
+
setText('lti-projected-remaining', formatMoney(result.projected.remainingIncome));
|
|
338
|
+
setText('lti-ratio-change', `${result.ratioChange >= 0 ? '+' : ''}${result.ratioChange} ${labels.percentagePoints}`);
|
|
339
|
+
setText('lti-threshold-margin', formatMoney(result.projected.thresholdMargin));
|
|
340
|
+
setText('lti-new-loan-total', formatMoney(result.newLoanTotal));
|
|
341
|
+
if (gaugeFill) gaugeFill.style.width = `${Math.min(100, Math.max(0, result.projected.ratio))}%`;
|
|
342
|
+
if (gaugeMarker) gaugeMarker.style.left = `${Math.min(100, Math.max(0, result.threshold))}%`;
|
|
343
|
+
if (noLoan) noLoan.hidden = result.projected.monthlyDebt !== result.current.monthlyDebt;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
function update(): void {
|
|
347
|
+
const result = calculateLoanToIncomeRatio({
|
|
348
|
+
income: readNumber(income),
|
|
349
|
+
incomeBasis: selectedBasis,
|
|
350
|
+
existingMonthlyPayments: readNumber(existing),
|
|
351
|
+
newMonthlyPayment: readNumber(newPayment),
|
|
352
|
+
newLoanTermMonths: readNumber(term),
|
|
353
|
+
threshold: readNumber(threshold),
|
|
354
|
+
});
|
|
355
|
+
if (!result) return renderInvalid();
|
|
356
|
+
renderValid(result);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function selectCurrency(nextCurrency: CurrencyCode): void {
|
|
360
|
+
if (nextCurrency === selectedCurrency) return;
|
|
361
|
+
convertInputs(selectedCurrency, nextCurrency);
|
|
362
|
+
selectedCurrency = nextCurrency;
|
|
363
|
+
renderCurrency();
|
|
364
|
+
update();
|
|
365
|
+
closeCurrencyMenu(true);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function selectBasis(nextBasis: IncomeBasis): void {
|
|
369
|
+
selectedBasis = nextBasis;
|
|
370
|
+
basisButtons.forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.basis === selectedBasis)));
|
|
371
|
+
update();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function reset(): void {
|
|
375
|
+
if (income) income.value = '5000';
|
|
376
|
+
if (existing) existing.value = '1200';
|
|
377
|
+
if (newPayment) newPayment.value = '500';
|
|
378
|
+
if (term) term.value = '60';
|
|
379
|
+
if (threshold) threshold.value = '36';
|
|
380
|
+
selectedBasis = 'gross';
|
|
381
|
+
selectedCurrency = 'EUR';
|
|
382
|
+
basisButtons.forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.basis === selectedBasis)));
|
|
383
|
+
renderCurrency();
|
|
384
|
+
update();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function copyResult(): Promise<void> {
|
|
388
|
+
const getText = (id: string): string => document.getElementById(id)?.textContent ?? '';
|
|
389
|
+
const resultText = [
|
|
390
|
+
`${labels.currentScenario}: ${getText('lti-current-ratio')}`,
|
|
391
|
+
`${labels.projectedScenario}: ${getText('lti-projected-ratio')}`,
|
|
392
|
+
`${labels.ratioChange}: ${getText('lti-ratio-change')}`,
|
|
393
|
+
`${labels.thresholdMargin}: ${getText('lti-threshold-margin')}`,
|
|
394
|
+
`${labels.newLoanTotal}: ${getText('lti-new-loan-total')}`,
|
|
395
|
+
].join('\n');
|
|
396
|
+
try {
|
|
397
|
+
await navigator.clipboard.writeText(resultText);
|
|
398
|
+
const copyButton = document.getElementById('lti-copy');
|
|
399
|
+
if (!copyButton) return;
|
|
400
|
+
const previous = copyButton.textContent;
|
|
401
|
+
copyButton.textContent = labels.copied;
|
|
402
|
+
window.setTimeout(() => { copyButton.textContent = previous; }, 1800);
|
|
403
|
+
} catch {
|
|
404
|
+
document.getElementById('lti-copy')?.replaceChildren(labels.invalid);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
amountInputs.forEach((input) => input?.addEventListener('input', update));
|
|
409
|
+
currencyTrigger?.addEventListener('click', () => {
|
|
410
|
+
if (currencyMenu?.hidden) openCurrencyMenu();
|
|
411
|
+
else closeCurrencyMenu();
|
|
412
|
+
});
|
|
413
|
+
currencyTrigger?.addEventListener('keydown', (event) => {
|
|
414
|
+
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
|
415
|
+
event.preventDefault();
|
|
416
|
+
openCurrencyMenu();
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
currencySearch?.addEventListener('input', filterCurrencyOptions);
|
|
420
|
+
currencySearch?.addEventListener('keydown', (event) => {
|
|
421
|
+
if (event.key === 'Escape') {
|
|
422
|
+
event.preventDefault();
|
|
423
|
+
closeCurrencyMenu(true);
|
|
424
|
+
}
|
|
425
|
+
if (event.key === 'ArrowDown') {
|
|
426
|
+
event.preventDefault();
|
|
427
|
+
focusCurrencyOption(1);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
currencyButtons.forEach((button) => button.addEventListener('click', () => selectCurrency(button.dataset.currency as CurrencyCode)));
|
|
431
|
+
currencyButtons.forEach((button) => button.addEventListener('keydown', (event) => {
|
|
432
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
|
433
|
+
event.preventDefault();
|
|
434
|
+
focusCurrencyOption(1);
|
|
435
|
+
}
|
|
436
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
|
437
|
+
event.preventDefault();
|
|
438
|
+
focusCurrencyOption(-1);
|
|
439
|
+
}
|
|
440
|
+
if (event.key === 'Escape') {
|
|
441
|
+
event.preventDefault();
|
|
442
|
+
closeCurrencyMenu(true);
|
|
443
|
+
}
|
|
444
|
+
}));
|
|
445
|
+
document.addEventListener('click', (event) => {
|
|
446
|
+
if (!currencyMenu?.hidden && !currencyMenu?.contains(event.target as Node) && event.target !== currencyTrigger) closeCurrencyMenu();
|
|
447
|
+
});
|
|
448
|
+
basisButtons.forEach((button) => button.addEventListener('click', () => selectBasis(button.dataset.basis as IncomeBasis)));
|
|
449
|
+
document.getElementById('lti-reset')?.addEventListener('click', reset);
|
|
450
|
+
document.getElementById('lti-copy')?.addEventListener('click', copyResult);
|
|
451
|
+
renderCurrency();
|
|
452
|
+
update();
|
|
453
|
+
</script>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { content } from './i18n/en';
|
|
3
|
+
|
|
4
|
+
function visibleSeoText(): string {
|
|
5
|
+
return content.seo
|
|
6
|
+
.map((section) => {
|
|
7
|
+
if ('text' in section) return section.text;
|
|
8
|
+
if ('html' in section) return section.html.replace(/<[^>]*>/gu, ' ');
|
|
9
|
+
if ('items' in section) return section.items.join(' ');
|
|
10
|
+
return '';
|
|
11
|
+
})
|
|
12
|
+
.join(' ')
|
|
13
|
+
.replace(/\s+/gu, ' ')
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('loan-to-income ratio SEO content', () => {
|
|
18
|
+
it('answers the main calculator search intent with actionable copy', () => {
|
|
19
|
+
const seoText = visibleSeoText();
|
|
20
|
+
|
|
21
|
+
expect(content.title).toBe('Debt to Income Ratio Calculator');
|
|
22
|
+
expect(content.description).toContain('debt-to-income ratio calculator');
|
|
23
|
+
expect(seoText).toContain('DTI = monthly debt payments');
|
|
24
|
+
expect(seoText).toContain('1,200 of monthly debt on 5,000 of income is 24%');
|
|
25
|
+
expect(seoText).toContain('not as a lending verdict');
|
|
26
|
+
expect(content.faq).toHaveLength(3);
|
|
27
|
+
expect(content.howTo).toHaveLength(4);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('publishes the SEO schemas from the shared content pipeline', () => {
|
|
31
|
+
expect(content.schemas.map((schema) => schema['@type'])).toEqual([
|
|
32
|
+
'FAQPage',
|
|
33
|
+
'HowTo',
|
|
34
|
+
'SoftwareApplication',
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
});
|