@jjlmoya/utils-finance 1.43.0 → 1.45.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 (35) hide show
  1. package/package.json +2 -2
  2. package/scripts/validate-icons.mjs +57 -0
  3. package/src/category/index.ts +2 -1
  4. package/src/entries.ts +4 -1
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/insuranceDeductibleBreakEvenCalculator/bibliography.astro +10 -0
  8. package/src/tool/insuranceDeductibleBreakEvenCalculator/bibliography.ts +6 -0
  9. package/src/tool/insuranceDeductibleBreakEvenCalculator/component.astro +172 -0
  10. package/src/tool/insuranceDeductibleBreakEvenCalculator/currency.test.ts +13 -0
  11. package/src/tool/insuranceDeductibleBreakEvenCalculator/currency.ts +26 -0
  12. package/src/tool/insuranceDeductibleBreakEvenCalculator/entry.ts +28 -0
  13. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/content-factory.ts +147 -0
  14. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/de.ts +2 -0
  15. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/en.ts +100 -0
  16. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/es.ts +2 -0
  17. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/fr.ts +2 -0
  18. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/id.ts +2 -0
  19. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/it.ts +2 -0
  20. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/ja.ts +2 -0
  21. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/ko.ts +2 -0
  22. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/nl.ts +2 -0
  23. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/pl.ts +2 -0
  24. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/pt.ts +2 -0
  25. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/ru.ts +2 -0
  26. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/sv.ts +2 -0
  27. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/tr.ts +2 -0
  28. package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/zh.ts +2 -0
  29. package/src/tool/insuranceDeductibleBreakEvenCalculator/index.ts +11 -0
  30. package/src/tool/insuranceDeductibleBreakEvenCalculator/insurance-deductible-break-even-calculator.css +424 -0
  31. package/src/tool/insuranceDeductibleBreakEvenCalculator/logic.test.ts +40 -0
  32. package/src/tool/insuranceDeductibleBreakEvenCalculator/logic.ts +72 -0
  33. package/src/tool/insuranceDeductibleBreakEvenCalculator/seo.astro +11 -0
  34. package/src/tool/insuranceDeductibleBreakEvenCalculator/ui.ts +33 -0
  35. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-finance",
3
- "version": "1.43.0",
3
+ "version": "1.45.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -36,7 +36,7 @@
36
36
  "postinstall": "node scripts/postinstall.mjs",
37
37
  "predev": "node scripts/postinstall.mjs",
38
38
  "prestart": "node scripts/postinstall.mjs",
39
- "prebuild": "node scripts/postinstall.mjs",
39
+ "prebuild": "node scripts/postinstall.mjs && node scripts/validate-icons.mjs",
40
40
  "qa": "npm run lint && npm run test && npm run build",
41
41
  "cf:dry-run": "npm run build && wrangler deploy --dry-run",
42
42
  "cf:preview": "npm run build && wrangler deploy --config wrangler.staging.jsonc",
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createRequire } from 'node:module';
4
+ import { readFileSync, readdirSync } from 'node:fs';
5
+ import { dirname, join, relative, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const packageJsonPath = require.resolve('@iconify-json/mdi/package.json');
10
+ const packageRoot = dirname(packageJsonPath);
11
+ const iconSet = JSON.parse(readFileSync(join(packageRoot, 'icons.json'), 'utf8'));
12
+ const availableIcons = new Set([
13
+ ...Object.keys(iconSet.icons ?? {}),
14
+ ...Object.keys(iconSet.aliases ?? {}),
15
+ ]);
16
+
17
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const sourceRoot = resolve(repoRoot, 'src');
19
+ const extensions = new Set(['.astro', '.js', '.mjs', '.ts', '.tsx']);
20
+ const iconPattern = /\bmdi:([a-z0-9-]+)\b/g;
21
+ const failures = [];
22
+ let references = 0;
23
+
24
+ function walk(directory) {
25
+ const entries = readdirSync(directory, { withFileTypes: true });
26
+ for (const entry of entries) {
27
+ const path = join(directory, entry.name);
28
+ if (entry.isDirectory()) {
29
+ if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== 'tests') {
30
+ walk(path);
31
+ }
32
+ continue;
33
+ }
34
+ if (!extensions.has(path.slice(path.lastIndexOf('.')))) continue;
35
+
36
+ const source = readFileSync(path, 'utf8');
37
+ for (const match of source.matchAll(iconPattern)) {
38
+ references += 1;
39
+ const iconName = match[1];
40
+ if (availableIcons.has(iconName)) continue;
41
+
42
+ const line = source.slice(0, match.index).split('\n').length;
43
+ failures.push(`${relative(repoRoot, path)}:${line} — mdi:${iconName}`);
44
+ }
45
+ }
46
+ }
47
+
48
+ walk(sourceRoot);
49
+
50
+ if (failures.length > 0) {
51
+ console.error('Invalid MDI icons found:');
52
+ for (const failure of failures) console.error(`- ${failure}`);
53
+ console.error(`Checked ${references} MDI icon references against @iconify-json/mdi.`);
54
+ process.exitCode = 1;
55
+ } else {
56
+ console.log(`MDI icon validation passed: ${references} references checked.`);
57
+ }
@@ -22,10 +22,11 @@ import { inflationPurchasingPower } from '../tool/inflationPurchasingPower/entry
22
22
  import { subscriptionCostAnnualizer } from '../tool/subscriptionCostAnnualizer/entry';
23
23
  import { loanToIncomeRatio } from '../tool/loanToIncomeRatio/entry';
24
24
  import { capitalGainsTaxScenarioCalculator } from '../tool/capitalGainsTaxScenarioCalculator/entry';
25
+ import { insuranceDeductibleBreakEvenCalculator } from '../tool/insuranceDeductibleBreakEvenCalculator/entry';
25
26
 
26
27
  export const financeCategory: FinanceCategoryEntry = {
27
28
  icon: 'mdi:finance',
28
- tools: [compoundInterest, financialFreedom, savingsRate, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio, capitalGainsTaxScenarioCalculator],
29
+ tools: [compoundInterest, financialFreedom, savingsRate, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio, capitalGainsTaxScenarioCalculator, insuranceDeductibleBreakEvenCalculator],
29
30
  i18n: {
30
31
  en: () => import('./i18n/en').then((m) => m.content),
31
32
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -48,6 +48,8 @@ export { rentalDepositReturn } from './tool/rentalDepositReturn/entry';
48
48
  export type { RentalDepositReturnLocaleContent } from './tool/rentalDepositReturn/entry';
49
49
  export { capitalGainsTaxScenarioCalculator } from './tool/capitalGainsTaxScenarioCalculator/entry';
50
50
  export type { CapitalGainsTaxScenarioCalculatorLocaleContent } from './tool/capitalGainsTaxScenarioCalculator/entry';
51
+ export { insuranceDeductibleBreakEvenCalculator } from './tool/insuranceDeductibleBreakEvenCalculator/entry';
52
+ export type { InsuranceDeductibleBreakEvenCalculatorLocaleContent } from './tool/insuranceDeductibleBreakEvenCalculator/entry';
51
53
  export { financeCategory } from './category';
52
54
  import { compoundInterest } from './tool/compoundInterest/entry';
53
55
  import { financialFreedom } from './tool/financialFreedom/entry';
@@ -73,4 +75,5 @@ import { subscriptionCostAnnualizer } from './tool/subscriptionCostAnnualizer/en
73
75
  import { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
74
76
  import { rentalDepositReturn } from './tool/rentalDepositReturn/entry';
75
77
  import { capitalGainsTaxScenarioCalculator } from './tool/capitalGainsTaxScenarioCalculator/entry';
76
- 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, rentalDepositReturn, capitalGainsTaxScenarioCalculator];
78
+ import { insuranceDeductibleBreakEvenCalculator } from './tool/insuranceDeductibleBreakEvenCalculator/entry';
79
+ 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, rentalDepositReturn, capitalGainsTaxScenarioCalculator, insuranceDeductibleBreakEvenCalculator];
@@ -25,7 +25,7 @@ describe('Locale Completeness Validation', () => {
25
25
  });
26
26
  });
27
27
 
28
- it('all 25 tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(25);
28
+ it('all 26 tools registered', () => {
29
+ expect(ALL_TOOLS.length).toBe(26);
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 25 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(25);
7
+ it('should have 26 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(26);
9
9
  });
10
10
 
11
11
  it('financeCategory should be defined', () => {
@@ -0,0 +1,10 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { insuranceDeductibleBreakEvenCalculator } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+ interface Props { locale?: KnownLocale; }
6
+ const { locale = 'en' } = Astro.props;
7
+ const content = await insuranceDeductibleBreakEvenCalculator.i18n[locale]?.() ?? await insuranceDeductibleBreakEvenCalculator.i18n.en?.();
8
+ ---
9
+
10
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,6 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'NAIC: Homeowners insurance deductibles and claims', url: 'https://content.naic.org/insurance-topics/homeowners-insurance' },
5
+ { name: 'NAIC: How does insurance work?', url: 'https://content.naic.org/consumer/how-does-insurance-work' },
6
+ ];
@@ -0,0 +1,172 @@
1
+ ---
2
+ import type { KnownLocale } from '../../types';
3
+ import { currencyOptions, defaultCurrencyByLocale } from './currency';
4
+ import type { InsuranceDeductibleInput } from './logic';
5
+ import type { InsuranceDeductibleBreakEvenCalculatorUI } from './ui';
6
+
7
+ interface Props { locale?: KnownLocale; ui?: InsuranceDeductibleBreakEvenCalculatorUI; }
8
+ const { ui } = Astro.props;
9
+ const t = ui as InsuranceDeductibleBreakEvenCalculatorUI;
10
+ const currentLocale = Astro.props.locale ?? 'en';
11
+ const defaultCurrency = defaultCurrencyByLocale[currentLocale] ?? 'USD';
12
+ const initial: InsuranceDeductibleInput = { currency: defaultCurrency, damageCost: 2500, deductible: 500, annualPremium: 1200, premiumIncreasePercent: 10, surchargeYears: 3 };
13
+ const config = JSON.stringify({ ui: t, initial, locale: currentLocale, currency: defaultCurrency, currencies: currencyOptions });
14
+ ---
15
+
16
+ <div class="idb-tool" data-idb-tool data-claim={t.labelClaimRecommended} data-pay-yourself={t.labelPayYourselfRecommended} data-break-even={t.labelBreakEvenRecommendation} data-invalid={t.labelInvalidInput} data-copied={t.labelCopied} data-copy-failed={t.labelCopyFailed} data-locale={currentLocale}>
17
+ <form class="idb-card" data-idb-form novalidate>
18
+ <div class="idb-glance" aria-live="polite">
19
+ <div><span>{t.labelRecommendation}</span><strong data-idb-recommendation>{t.labelClaimRecommended}</strong></div>
20
+ <div><span>{t.labelDifference}</span><strong data-idb-difference>0</strong></div>
21
+ </div>
22
+
23
+ <div class="idb-inputs">
24
+ <div class="idb-field idb-currency-field">
25
+ <span class="idb-label" id="idb-currency-label">{t.labelCurrency}</span>
26
+ <div class="idb-currency-select">
27
+ <button type="button" class="idb-currency-trigger" data-idb-currency-trigger aria-expanded="false" aria-haspopup="listbox" aria-labelledby="idb-currency-label">
28
+ <span class="idb-currency-trigger-main"><strong data-idb-selected-symbol>$</strong><span data-idb-selected-code>{defaultCurrency}</span></span><span class="idb-currency-arrow" aria-hidden="true"></span>
29
+ </button>
30
+ <div class="idb-currency-menu" data-idb-currency-menu role="listbox" aria-label={t.labelCurrency} hidden>
31
+ {currencyOptions.map(({ code, symbol, name }) => <button type="button" class="idb-currency-option" data-idb-currency={code} role="option" aria-selected={code === defaultCurrency} aria-label={`${symbol} ${name}`}><strong>{symbol}</strong><span>{code}</span></button>)}
32
+ </div>
33
+ </div>
34
+ <small class="idb-help">{t.labelApproximateRates}</small>
35
+ </div>
36
+ <div class="idb-field">
37
+ <label for="idb-damage">{t.labelDamageCost}</label>
38
+ <div class="idb-input-wrap"><input id="idb-damage" data-field="damageCost" type="number" min="0.01" max="1000000000000" step="0.01" value={initial.damageCost} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><span data-idb-unit aria-hidden="true">$</span></div>
39
+ <small data-error-for="damageCost" hidden></small>
40
+ </div>
41
+ <div class="idb-field">
42
+ <label for="idb-deductible">{t.labelDeductible}</label>
43
+ <div class="idb-input-wrap"><input id="idb-deductible" data-field="deductible" type="number" min="0" max="1000000000000" step="0.01" value={initial.deductible} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><span data-idb-unit aria-hidden="true">$</span></div>
44
+ <small data-error-for="deductible" hidden></small>
45
+ </div>
46
+ <div class="idb-field">
47
+ <label for="idb-premium">{t.labelAnnualPremium}</label>
48
+ <div class="idb-input-wrap"><input id="idb-premium" data-field="annualPremium" type="number" min="0" max="1000000000000" step="0.01" value={initial.annualPremium} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><span data-idb-unit aria-hidden="true">$</span></div>
49
+ <small data-error-for="annualPremium" hidden></small>
50
+ </div>
51
+ <div class="idb-field">
52
+ <label for="idb-increase">{t.labelPremiumIncrease}</label>
53
+ <div class="idb-input-wrap"><input id="idb-increase" data-field="premiumIncreasePercent" type="number" min="0" max="100" step="0.1" value={initial.premiumIncreasePercent} placeholder={t.labelPercentPlaceholder} inputmode="decimal" required /><span aria-hidden="true">%</span></div>
54
+ <small data-error-for="premiumIncreasePercent" hidden></small>
55
+ </div>
56
+ <div class="idb-field">
57
+ <label for="idb-years">{t.labelSurchargeYears}</label>
58
+ <div class="idb-input-wrap"><input id="idb-years" data-field="surchargeYears" type="number" min="0.5" max="20" step="0.5" value={initial.surchargeYears} placeholder={t.labelYearsPlaceholder} inputmode="decimal" required /><span aria-hidden="true">yr</span></div>
59
+ <small data-error-for="surchargeYears" hidden></small>
60
+ </div>
61
+ </div>
62
+
63
+ <div class="idb-actions"><button type="button" class="idb-button idb-button-secondary" data-idb-reset>{t.labelReset}</button><button type="button" class="idb-button idb-button-primary" data-idb-copy>{t.labelCopy}</button><span data-idb-copy-status role="status" aria-live="polite"></span></div>
64
+
65
+ <section class="idb-output" aria-live="polite" aria-atomic="true">
66
+ <div class="idb-visual" data-idb-visual role="img" aria-label={t.labelVisual}>
67
+ <div class="idb-visual-heading"><span>{t.labelClaimRoute}</span><span>{t.labelPayYourselfRoute}</span></div>
68
+ <div class="idb-bars"><div class="idb-bar-side"><strong data-idb-claim-value>0</strong><div class="idb-bar-track"><i data-idb-claim-bar></i></div></div><div class="idb-scale" aria-hidden="true">⇄</div><div class="idb-bar-side"><strong data-idb-pay-value>0</strong><div class="idb-bar-track"><i data-idb-pay-bar></i></div></div></div>
69
+ </div>
70
+ <p class="idb-error-summary" data-idb-error-summary hidden>{t.labelInvalidInput}</p>
71
+ <div class="idb-result-grid">
72
+ <div><span>{t.labelClaimCost}</span><strong data-idb-claim-cost>0</strong></div>
73
+ <div><span>{t.labelPayYourselfCost}</span><strong data-idb-pay-cost>0</strong></div>
74
+ <div><span>{t.labelPremiumSurcharge}</span><strong data-idb-surcharge>0</strong></div>
75
+ <div><span>{t.labelInsurerShare}</span><strong data-idb-insurer-share>0</strong></div>
76
+ <div class="idb-emphasis"><span>{t.labelBreakEvenDamage}</span><strong data-idb-break-even>0</strong></div>
77
+ <div class="idb-emphasis"><span>{t.labelDifference}</span><strong data-idb-result-difference>0</strong></div>
78
+ </div>
79
+ <p class="idb-result-note" data-idb-result-note></p>
80
+ </section>
81
+
82
+ <p class="idb-privacy">{t.labelPrivacyNote}</p>
83
+ <details class="idb-method"><summary>{t.labelMethodTitle}</summary><p>{t.labelMethodText}</p></details>
84
+ </form>
85
+ </div>
86
+
87
+ <script is:inline type="application/json" data-idb-config set:html={config}></script>
88
+ <script>
89
+ import { convertInsuranceCurrency, type InsuranceCurrencyCode } from './currency';
90
+ import { calculateInsuranceDeductibleBreakEven, validateInsuranceInput, type InsuranceDeductibleInput } from './logic';
91
+
92
+ const configElement = document.querySelector<HTMLScriptElement>('[data-idb-config]');
93
+ const root = document.querySelector<HTMLElement>('[data-idb-tool]');
94
+ const form = root?.querySelector<HTMLFormElement>('[data-idb-form]');
95
+ if (root && form && configElement?.textContent) {
96
+ const config = JSON.parse(configElement.textContent) as { initial: InsuranceDeductibleInput; locale: string; currency: InsuranceCurrencyCode; };
97
+ let selectedCurrency = config.currency;
98
+ const fields = Array.from(form.querySelectorAll<HTMLInputElement>('[data-field]'));
99
+ const moneyKeys = ['damageCost', 'deductible', 'annualPremium'] as const;
100
+ const getNumber = (key: keyof InsuranceDeductibleInput): number => Number(form.querySelector<HTMLInputElement>(`[data-field="${key}"]`)?.value ?? Number.NaN);
101
+ const readInput = (): InsuranceDeductibleInput => ({ currency: selectedCurrency, damageCost: getNumber('damageCost'), deductible: getNumber('deductible'), annualPremium: getNumber('annualPremium'), premiumIncreasePercent: getNumber('premiumIncreasePercent'), surchargeYears: getNumber('surchargeYears') });
102
+ const formatMoney = (value: number): string => new Intl.NumberFormat(config.locale, { style: 'currency', currency: selectedCurrency, maximumFractionDigits: 2 }).format(value);
103
+ const formatSigned = (value: number): string => `${value > 0 ? '+' : ''}${formatMoney(value)}`;
104
+ const recommendationKeyFor = (recommendation: string): 'claim' | 'payYourself' | 'breakEven' => {
105
+ if (recommendation === 'claim') return 'claim';
106
+ if (recommendation === 'payYourself') return 'payYourself';
107
+ return 'breakEven';
108
+ };
109
+ const setText = (selector: string, value: string): void => { const element = root.querySelector<HTMLElement>(selector); if (element) element.textContent = value; };
110
+ const setCurrencyState = (): void => {
111
+ root.querySelectorAll<HTMLButtonElement>('[data-idb-currency]').forEach((button) => button.setAttribute('aria-selected', String(button.dataset.idbCurrency === selectedCurrency)));
112
+ const selectedOption = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-idb-currency]')).find((button) => button.dataset.idbCurrency === selectedCurrency);
113
+ const symbol = selectedOption?.querySelector('strong')?.textContent ?? selectedCurrency;
114
+ setText('[data-idb-selected-symbol]', symbol);
115
+ setText('[data-idb-selected-code]', selectedCurrency);
116
+ root.querySelectorAll<HTMLElement>('[data-idb-unit]').forEach((unit) => { unit.textContent = symbol; });
117
+ };
118
+ const clearResults = (): void => { ['claimCost', 'payCost', 'surcharge', 'insurerShare', 'breakEven', 'resultDifference', 'difference'].forEach((key) => setText(`[data-idb-${key === 'resultDifference' ? 'result-difference' : key}]`, '—')); root.querySelector<HTMLElement>('[data-idb-claim-bar]')?.style.setProperty('width', '0%'); root.querySelector<HTMLElement>('[data-idb-pay-bar]')?.style.setProperty('width', '0%'); setText('[data-idb-result-note]', ''); root.dataset.state = 'error'; };
119
+ const showErrors = (input: InsuranceDeductibleInput): boolean => {
120
+ const errors = validateInsuranceInput(input);
121
+ fields.forEach((field) => { const key = field.dataset.field as keyof InsuranceDeductibleInput; const invalid = Boolean(errors[key]); const error = form.querySelector<HTMLElement>(`[data-error-for="${key}"]`); field.setAttribute('aria-invalid', String(invalid)); if (error) { error.hidden = !invalid; error.textContent = invalid ? root.dataset.invalid ?? '' : ''; } });
122
+ const summary = root.querySelector<HTMLElement>('[data-idb-error-summary]');
123
+ if (summary) summary.hidden = Object.keys(errors).length === 0;
124
+ if (Object.keys(errors).length > 0) clearResults();
125
+ return Object.keys(errors).length > 0;
126
+ };
127
+ const update = (): void => {
128
+ const input = readInput();
129
+ if (showErrors(input)) return;
130
+ const result = calculateInsuranceDeductibleBreakEven(input);
131
+ if (!result) return;
132
+ const recommendationKey = recommendationKeyFor(result.recommendation);
133
+ setText('[data-idb-recommendation]', root.dataset[recommendationKey] ?? '');
134
+ setText('[data-idb-difference]', formatSigned(result.difference));
135
+ setText('[data-idb-claim-cost]', formatMoney(result.claimCost));
136
+ setText('[data-idb-pay-cost]', formatMoney(result.payYourselfCost));
137
+ setText('[data-idb-surcharge]', formatMoney(result.premiumSurcharge));
138
+ setText('[data-idb-insurer-share]', formatMoney(result.insurerShare));
139
+ setText('[data-idb-break-even]', formatMoney(result.breakEvenDamage));
140
+ setText('[data-idb-result-difference]', formatSigned(result.difference));
141
+ const maxCost = Math.max(result.claimCost, result.payYourselfCost, 1);
142
+ root.querySelector<HTMLElement>('[data-idb-claim-bar]')?.style.setProperty('width', `${Math.max(8, result.claimCost / maxCost * 100)}%`);
143
+ root.querySelector<HTMLElement>('[data-idb-pay-bar]')?.style.setProperty('width', `${Math.max(8, result.payYourselfCost / maxCost * 100)}%`);
144
+ setText('[data-idb-claim-value]', formatMoney(result.claimCost));
145
+ setText('[data-idb-pay-value]', formatMoney(result.payYourselfCost));
146
+ const resultNote = result.insurerShare > 0 ? `${formatMoney(result.insurerShare)} is the estimated amount above the deductible. Confirm the covered amount, limits and reporting rules before acting.` : 'The damage is at or below the deductible in this model, so the estimated insurer contribution is zero.';
147
+ setText('[data-idb-result-note]', resultNote);
148
+ root.dataset.state = result.recommendation;
149
+ };
150
+ fields.forEach((field) => field.addEventListener('input', update));
151
+ const currencyMenu = root.querySelector<HTMLElement>('[data-idb-currency-menu]');
152
+ const currencyTrigger = root.querySelector<HTMLButtonElement>('[data-idb-currency-trigger]');
153
+ const closeCurrencyMenu = (): void => { if (currencyMenu && currencyTrigger) { currencyMenu.hidden = true; currencyTrigger.setAttribute('aria-expanded', 'false'); } };
154
+ currencyTrigger?.addEventListener('click', () => { if (currencyMenu) { currencyMenu.hidden = !currencyMenu.hidden; currencyTrigger.setAttribute('aria-expanded', String(!currencyMenu.hidden)); } });
155
+ root.querySelectorAll<HTMLButtonElement>('[data-idb-currency]').forEach((button) => button.addEventListener('click', () => {
156
+ const nextCurrency = button.dataset.idbCurrency as InsuranceCurrencyCode;
157
+ moneyKeys.forEach((key) => { const field = form.querySelector<HTMLInputElement>(`[data-field="${key}"]`); const value = Number(field?.value); if (field && Number.isFinite(value)) field.value = String(convertInsuranceCurrency(value, selectedCurrency, nextCurrency)); });
158
+ selectedCurrency = nextCurrency;
159
+ setCurrencyState();
160
+ closeCurrencyMenu();
161
+ update();
162
+ }));
163
+ form.querySelector('[data-idb-reset]')?.addEventListener('click', () => { fields.forEach((field) => { const key = field.dataset.field as keyof InsuranceDeductibleInput; field.value = String(config.initial[key]); }); selectedCurrency = config.currency; setCurrencyState(); closeCurrencyMenu(); update(); });
164
+ form.querySelector('[data-idb-copy]')?.addEventListener('click', () => { const result = calculateInsuranceDeductibleBreakEven(readInput()); if (!result) return; const summary = `${root.dataset[recommendationKeyFor(result.recommendation)] ?? ''}. Claim: ${formatMoney(result.claimCost)}. Pay yourself: ${formatMoney(result.payYourselfCost)}. Break-even damage: ${formatMoney(result.breakEvenDamage)}.`; navigator.clipboard?.writeText(summary).then(() => setText('[data-idb-copy-status]', root.dataset.copied ?? '')).catch(() => setText('[data-idb-copy-status]', root.dataset.copyFailed ?? '')); });
165
+ setCurrencyState();
166
+ update();
167
+ }
168
+ </script>
169
+
170
+ <style>
171
+ @import './insurance-deductible-break-even-calculator.css';
172
+ </style>
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { convertInsuranceCurrency, currencyOptions } from './currency';
3
+
4
+ describe('insurance currency display', () => {
5
+ it('offers every base locale currency', () => {
6
+ expect(currencyOptions.map(({ code }) => code)).toEqual(['USD', 'EUR', 'IDR', 'JPY', 'KRW', 'PLN', 'RUB', 'SEK', 'TRY', 'CNY']);
7
+ });
8
+
9
+ it('converts an entered amount with an explicitly approximate factor', () => {
10
+ expect(convertInsuranceCurrency(100, 'USD', 'EUR')).toBe(92);
11
+ expect(convertInsuranceCurrency(92, 'EUR', 'USD')).toBe(100);
12
+ });
13
+ });
@@ -0,0 +1,26 @@
1
+ export const currencyOptions = [
2
+ { code: 'USD', symbol: '$', name: 'US dollar' },
3
+ { code: 'EUR', symbol: '€', name: 'Euro' },
4
+ { code: 'IDR', symbol: 'Rp', name: 'Indonesian rupiah' },
5
+ { code: 'JPY', symbol: '¥', name: 'Japanese yen' },
6
+ { code: 'KRW', symbol: '₩', name: 'South Korean won' },
7
+ { code: 'PLN', symbol: 'zł', name: 'Polish złoty' },
8
+ { code: 'RUB', symbol: '₽', name: 'Russian ruble' },
9
+ { code: 'SEK', symbol: 'kr', name: 'Swedish krona' },
10
+ { code: 'TRY', symbol: '₺', name: 'Turkish lira' },
11
+ { code: 'CNY', symbol: '¥', name: 'Chinese yuan' },
12
+ ] as const;
13
+
14
+ export type InsuranceCurrencyCode = (typeof currencyOptions)[number]['code'];
15
+
16
+ export const defaultCurrencyByLocale: Record<string, InsuranceCurrencyCode> = {
17
+ de: 'EUR', en: 'USD', es: 'EUR', fr: 'EUR', id: 'IDR', it: 'EUR', ja: 'JPY', ko: 'KRW', nl: 'EUR', pl: 'PLN', pt: 'EUR', ru: 'RUB', sv: 'SEK', tr: 'TRY', zh: 'CNY',
18
+ };
19
+
20
+ const approximateUsdFactors: Record<InsuranceCurrencyCode, number> = {
21
+ USD: 1, EUR: 0.92, IDR: 16000, JPY: 150, KRW: 1380, PLN: 3.95, RUB: 90, SEK: 10.5, TRY: 34, CNY: 7.2,
22
+ };
23
+
24
+ export const isInsuranceCurrencyCode = (value: string): value is InsuranceCurrencyCode => currencyOptions.some(({ code }) => code === value);
25
+
26
+ export const convertInsuranceCurrency = (amount: number, from: InsuranceCurrencyCode, to: InsuranceCurrencyCode): number => Number((amount * approximateUsdFactors[to] / approximateUsdFactors[from]).toFixed(2));
@@ -0,0 +1,28 @@
1
+ import type { FinanceToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { InsuranceDeductibleBreakEvenCalculatorUI } from './ui';
3
+
4
+ export type InsuranceDeductibleBreakEvenCalculatorLocaleContent = ToolLocaleContent<InsuranceDeductibleBreakEvenCalculatorUI>;
5
+
6
+ export const insuranceDeductibleBreakEvenCalculator: FinanceToolEntry<InsuranceDeductibleBreakEvenCalculatorUI> = {
7
+ id: 'insurance-deductible-break-even-calculator',
8
+ icons: { bg: 'mdi:shield-home', fg: 'mdi:scale-balance' },
9
+ i18n: {
10
+ de: () => import('./i18n/de').then((module) => module.content),
11
+ en: () => import('./i18n/en').then((module) => module.content),
12
+ es: () => import('./i18n/es').then((module) => module.content),
13
+ fr: () => import('./i18n/fr').then((module) => module.content),
14
+ id: () => import('./i18n/id').then((module) => module.content),
15
+ it: () => import('./i18n/it').then((module) => module.content),
16
+ ja: () => import('./i18n/ja').then((module) => module.content),
17
+ ko: () => import('./i18n/ko').then((module) => module.content),
18
+ nl: () => import('./i18n/nl').then((module) => module.content),
19
+ pl: () => import('./i18n/pl').then((module) => module.content),
20
+ pt: () => import('./i18n/pt').then((module) => module.content),
21
+ ru: () => import('./i18n/ru').then((module) => module.content),
22
+ sv: () => import('./i18n/sv').then((module) => module.content),
23
+ tr: () => import('./i18n/tr').then((module) => module.content),
24
+ zh: () => import('./i18n/zh').then((module) => module.content),
25
+ },
26
+ };
27
+
28
+ export { bibliography } from './bibliography';