@jjlmoya/utils-finance 1.39.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.
Files changed (63) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +4 -2
  3. package/src/entries.ts +8 -1
  4. package/src/index.ts +2 -0
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/loanToIncomeRatio/bibliography.astro +11 -0
  8. package/src/tool/loanToIncomeRatio/bibliography.ts +12 -0
  9. package/src/tool/loanToIncomeRatio/component.astro +453 -0
  10. package/src/tool/loanToIncomeRatio/content.test.ts +37 -0
  11. package/src/tool/loanToIncomeRatio/content.ts +247 -0
  12. package/src/tool/loanToIncomeRatio/currency.test.ts +21 -0
  13. package/src/tool/loanToIncomeRatio/currency.ts +19 -0
  14. package/src/tool/loanToIncomeRatio/entry.ts +31 -0
  15. package/src/tool/loanToIncomeRatio/i18n/de.ts +2 -0
  16. package/src/tool/loanToIncomeRatio/i18n/en.ts +3 -0
  17. package/src/tool/loanToIncomeRatio/i18n/es.ts +2 -0
  18. package/src/tool/loanToIncomeRatio/i18n/fr.ts +2 -0
  19. package/src/tool/loanToIncomeRatio/i18n/id.ts +2 -0
  20. package/src/tool/loanToIncomeRatio/i18n/it.ts +2 -0
  21. package/src/tool/loanToIncomeRatio/i18n/ja.ts +2 -0
  22. package/src/tool/loanToIncomeRatio/i18n/ko.ts +2 -0
  23. package/src/tool/loanToIncomeRatio/i18n/nl.ts +2 -0
  24. package/src/tool/loanToIncomeRatio/i18n/pl.ts +2 -0
  25. package/src/tool/loanToIncomeRatio/i18n/pt.ts +2 -0
  26. package/src/tool/loanToIncomeRatio/i18n/ru.ts +2 -0
  27. package/src/tool/loanToIncomeRatio/i18n/sv.ts +2 -0
  28. package/src/tool/loanToIncomeRatio/i18n/tr.ts +2 -0
  29. package/src/tool/loanToIncomeRatio/i18n/zh.ts +2 -0
  30. package/src/tool/loanToIncomeRatio/index.ts +11 -0
  31. package/src/tool/loanToIncomeRatio/loan-to-income-ratio.css +580 -0
  32. package/src/tool/loanToIncomeRatio/logic.test.ts +55 -0
  33. package/src/tool/loanToIncomeRatio/logic.ts +92 -0
  34. package/src/tool/loanToIncomeRatio/seo.astro +11 -0
  35. package/src/tool/loanToIncomeRatio/ui.ts +55 -0
  36. package/src/tool/loanToIncomeRatio/validation.ts +34 -0
  37. package/src/tool/savingsRate/bibliography.astro +11 -0
  38. package/src/tool/savingsRate/bibliography.ts +12 -0
  39. package/src/tool/savingsRate/component.astro +326 -0
  40. package/src/tool/savingsRate/content.ts +1877 -0
  41. package/src/tool/savingsRate/entry.ts +31 -0
  42. package/src/tool/savingsRate/i18n/de.ts +3 -0
  43. package/src/tool/savingsRate/i18n/en.ts +1 -0
  44. package/src/tool/savingsRate/i18n/es.ts +3 -0
  45. package/src/tool/savingsRate/i18n/fr.ts +3 -0
  46. package/src/tool/savingsRate/i18n/id.ts +3 -0
  47. package/src/tool/savingsRate/i18n/it.ts +3 -0
  48. package/src/tool/savingsRate/i18n/ja.ts +3 -0
  49. package/src/tool/savingsRate/i18n/ko.ts +3 -0
  50. package/src/tool/savingsRate/i18n/nl.ts +3 -0
  51. package/src/tool/savingsRate/i18n/pl.ts +3 -0
  52. package/src/tool/savingsRate/i18n/pt.ts +3 -0
  53. package/src/tool/savingsRate/i18n/ru.ts +3 -0
  54. package/src/tool/savingsRate/i18n/sv.ts +3 -0
  55. package/src/tool/savingsRate/i18n/tr.ts +3 -0
  56. package/src/tool/savingsRate/i18n/zh.ts +3 -0
  57. package/src/tool/savingsRate/index.ts +11 -0
  58. package/src/tool/savingsRate/logic.test.ts +87 -0
  59. package/src/tool/savingsRate/logic.ts +121 -0
  60. package/src/tool/savingsRate/savings-rate-calculator.css +575 -0
  61. package/src/tool/savingsRate/seo.astro +11 -0
  62. package/src/tool/savingsRate/ui.ts +48 -0
  63. package/src/tools.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-finance",
3
- "version": "1.39.0",
3
+ "version": "1.41.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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';
@@ -19,10 +20,11 @@ import { salaryEmployerCost } from '../tool/salaryEmployerCost/entry';
19
20
  import { dividendYield } from '../tool/dividendYield/entry';
20
21
  import { inflationPurchasingPower } from '../tool/inflationPurchasingPower/entry';
21
22
  import { subscriptionCostAnnualizer } from '../tool/subscriptionCostAnnualizer/entry';
23
+ import { loanToIncomeRatio } from '../tool/loanToIncomeRatio/entry';
22
24
 
23
25
  export const financeCategory: FinanceCategoryEntry = {
24
26
  icon: 'mdi:finance',
25
- tools: [compoundInterest, financialFreedom, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer],
27
+ tools: [compoundInterest, financialFreedom, savingsRate, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio],
26
28
  i18n: {
27
29
  en: () => import('./i18n/en').then((m) => m.content),
28
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';
@@ -38,6 +42,8 @@ export { inflationPurchasingPower } from './tool/inflationPurchasingPower/entry'
38
42
  export type { InflationPurchasingPowerLocaleContent } from './tool/inflationPurchasingPower/entry';
39
43
  export { subscriptionCostAnnualizer } from './tool/subscriptionCostAnnualizer/entry';
40
44
  export type { SubscriptionCostAnnualizerLocaleContent } from './tool/subscriptionCostAnnualizer/entry';
45
+ export { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
46
+ export type { LoanToIncomeRatioLocaleContent } from './tool/loanToIncomeRatio/entry';
41
47
  export { financeCategory } from './category';
42
48
  import { compoundInterest } from './tool/compoundInterest/entry';
43
49
  import { financialFreedom } from './tool/financialFreedom/entry';
@@ -60,4 +66,5 @@ import { dividendYield } from './tool/dividendYield/entry';
60
66
  import { mortgageExtraPaymentPlanner } from './tool/mortgageExtraPaymentPlanner/entry';
61
67
  import { inflationPurchasingPower } from './tool/inflationPurchasingPower/entry';
62
68
  import { subscriptionCostAnnualizer } from './tool/subscriptionCostAnnualizer/entry';
63
- 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];
69
+ import { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
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';
@@ -34,3 +35,4 @@ export { PESETA_VALUE_TOOL } from './tool/pesetaValue';
34
35
  export { SALARY_EMPLOYER_COST_TOOL } from './tool/salaryEmployerCost';
35
36
  export { DIVIDEND_YIELD_TOOL } from './tool/dividendYield';
36
37
  export { MORTGAGE_EXTRA_PAYMENT_PLANNER_TOOL } from './tool/mortgageExtraPaymentPlanner';
38
+ export { LOAN_TO_INCOME_RATIO_TOOL } from './tool/loanToIncomeRatio';
@@ -25,7 +25,7 @@ describe('Locale Completeness Validation', () => {
25
25
  });
26
26
  });
27
27
 
28
- it('all 21 tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(21);
28
+ it('all 23 tools registered', () => {
29
+ expect(ALL_TOOLS.length).toBe(23);
30
30
  });
31
31
  });
@@ -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 21 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(21);
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 { 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
+ });