@jjlmoya/utils-finance 1.44.0 → 1.46.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 (38) hide show
  1. package/package.json +2 -2
  2. package/scripts/validate-icons.mjs +57 -0
  3. package/src/entries.ts +2 -1
  4. package/src/tests/locale_completeness.test.ts +2 -2
  5. package/src/tests/mfe_assets_contract.test.ts +48 -0
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/renterInsuranceCoverageGapCalculator/bibliography.astro +11 -0
  8. package/src/tool/renterInsuranceCoverageGapCalculator/bibliography.ts +6 -0
  9. package/src/tool/renterInsuranceCoverageGapCalculator/component.astro +80 -0
  10. package/src/tool/renterInsuranceCoverageGapCalculator/controller.ts +102 -0
  11. package/src/tool/renterInsuranceCoverageGapCalculator/currency.test.ts +17 -0
  12. package/src/tool/renterInsuranceCoverageGapCalculator/currency.ts +69 -0
  13. package/src/tool/renterInsuranceCoverageGapCalculator/dom-views.ts +68 -0
  14. package/src/tool/renterInsuranceCoverageGapCalculator/entry.ts +28 -0
  15. package/src/tool/renterInsuranceCoverageGapCalculator/evaluator.ts +17 -0
  16. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/de.ts +40 -0
  17. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/en.ts +98 -0
  18. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/es.ts +38 -0
  19. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/fr.ts +38 -0
  20. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/id.ts +38 -0
  21. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/it.ts +38 -0
  22. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ja.ts +38 -0
  23. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ko.ts +38 -0
  24. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/nl.ts +38 -0
  25. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/pl.ts +38 -0
  26. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/pt.ts +38 -0
  27. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ru.ts +38 -0
  28. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/sv.ts +38 -0
  29. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/tr.ts +38 -0
  30. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/zh.ts +38 -0
  31. package/src/tool/renterInsuranceCoverageGapCalculator/index.ts +11 -0
  32. package/src/tool/renterInsuranceCoverageGapCalculator/logic.test.ts +52 -0
  33. package/src/tool/renterInsuranceCoverageGapCalculator/logic.ts +108 -0
  34. package/src/tool/renterInsuranceCoverageGapCalculator/renter-insurance-coverage-gap-calculator.css +462 -0
  35. package/src/tool/renterInsuranceCoverageGapCalculator/seo.astro +11 -0
  36. package/src/tool/renterInsuranceCoverageGapCalculator/storage.ts +23 -0
  37. package/src/tool/renterInsuranceCoverageGapCalculator/ui.ts +48 -0
  38. 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.44.0",
3
+ "version": "1.46.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
+ }
package/src/entries.ts CHANGED
@@ -76,4 +76,5 @@ import { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
76
76
  import { rentalDepositReturn } from './tool/rentalDepositReturn/entry';
77
77
  import { capitalGainsTaxScenarioCalculator } from './tool/capitalGainsTaxScenarioCalculator/entry';
78
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];
79
+ import { renterInsuranceCoverageGapCalculator } from './tool/renterInsuranceCoverageGapCalculator/entry';
80
+ 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, renterInsuranceCoverageGapCalculator];
@@ -25,7 +25,7 @@ describe('Locale Completeness Validation', () => {
25
25
  });
26
26
  });
27
27
 
28
- it('all 26 tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(26);
28
+ it('all 27 tools registered', () => {
29
+ expect(ALL_TOOLS.length).toBe(27);
30
30
  });
31
31
  });
@@ -0,0 +1,48 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import { CATEGORY_OG_IMAGE, getUtilityOgImage } from '../mfe/assets';
6
+
7
+ const categoryImageMatch = CATEGORY_OG_IMAGE.match(
8
+ /^(\/_utilities\/[^/]+\/images)\/([^/]+\.webp)\?version=(.+)$/,
9
+ );
10
+
11
+ if (!categoryImageMatch) {
12
+ throw new Error(`Unexpected CATEGORY_OG_IMAGE format: ${CATEGORY_OG_IMAGE}`);
13
+ }
14
+
15
+ const [, imageUrlRoot, categoryImage, assetVersion] = categoryImageMatch;
16
+ const assetRoot = join(process.cwd(), 'public', imageUrlRoot.slice(1));
17
+ const categorySlug = basename(categoryImage, '.webp');
18
+
19
+ describe('MFE asset contract', () => {
20
+ it('has one non-empty English-slug OG image per category and registered tool', async () => {
21
+ const expectedSlugs = new Set([categorySlug]);
22
+
23
+ for (const { entry } of ALL_TOOLS) {
24
+ const englishLoader = entry.i18n.en;
25
+ if (!englishLoader) throw new Error(`Missing English locale for ${entry.id}`);
26
+
27
+ const englishContent = await englishLoader();
28
+ expectedSlugs.add(englishContent.slug);
29
+ }
30
+
31
+ const actualSlugs = new Set(
32
+ readdirSync(assetRoot)
33
+ .filter((filename) => filename.endsWith('.webp'))
34
+ .map((filename) => filename.slice(0, -'.webp'.length)),
35
+ );
36
+
37
+ expect(actualSlugs).toEqual(expectedSlugs);
38
+
39
+ for (const slug of expectedSlugs) {
40
+ const imagePath = join(assetRoot, `${slug}.webp`);
41
+ expect(existsSync(imagePath), `${imagePath} should exist`).toBe(true);
42
+ expect(statSync(imagePath).size, `${imagePath} should not be empty`).toBeGreaterThan(0);
43
+ expect(getUtilityOgImage(slug)).toBe(
44
+ `${imageUrlRoot}/${slug}.webp?version=${assetVersion}`,
45
+ );
46
+ }
47
+ }, 30000);
48
+ });
@@ -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 26 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(26);
7
+ it('should have 27 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(27);
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 { renterInsuranceCoverageGapCalculator } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props { locale?: KnownLocale; }
7
+ const { locale = 'en' } = Astro.props;
8
+ const content = await renterInsuranceCoverageGapCalculator.i18n[locale]?.() ?? await renterInsuranceCoverageGapCalculator.i18n.en?.();
9
+ ---
10
+
11
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,6 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'Financial Ombudsman Service: Underinsurance in home insurance complaints', url: 'https://www.financial-ombudsman.org.uk/consumers/complaints-can-help/insurance/home-insurance/underinsurance' },
5
+ { name: 'BOE: Ley 50/1980, de 8 de octubre, de Contrato de Seguro', url: 'https://www.boe.es/buscar/pdf/1980/BOE-A-1980-22501-consolidado.pdf' },
6
+ ];
@@ -0,0 +1,80 @@
1
+ ---
2
+ import type { KnownLocale } from '../../types';
3
+ import { INVENTORY_CATEGORIES, type RenterInsuranceInput } from './logic';
4
+ import { currencyOptions, defaultCurrencyByLocale } from './currency';
5
+ import type { RenterInsuranceCoverageGapCalculatorUI } from './ui';
6
+
7
+ interface Props { locale?: KnownLocale; ui?: RenterInsuranceCoverageGapCalculatorUI; }
8
+ const { ui } = Astro.props;
9
+ const t = ui as RenterInsuranceCoverageGapCalculatorUI;
10
+ const locale = Astro.props.locale ?? 'en';
11
+ const defaultCurrency = defaultCurrencyByLocale[locale] ?? 'USD';
12
+ const defaultSymbol = currencyOptions.find(({ code }) => code === defaultCurrency)?.symbol ?? defaultCurrency;
13
+ const initial: RenterInsuranceInput = {
14
+ currency: defaultCurrency,
15
+ policyLimit: 18000,
16
+ deductible: 250,
17
+ sampleLoss: 5000,
18
+ inventory: [
19
+ { category: 'furniture', value: 7000 },
20
+ { category: 'electronics', value: 6000 },
21
+ { category: 'clothing', value: 2500 },
22
+ { category: 'kitchen', value: 1500 },
23
+ { category: 'personal', value: 1000 },
24
+ { category: 'other', value: 1000 },
25
+ ],
26
+ };
27
+ const config = JSON.stringify({ ui: t, initial, locale, currencies: currencyOptions }).replace(/</g, '\\u003c');
28
+ const categoryLabels: Record<(typeof INVENTORY_CATEGORIES)[number], string> = {
29
+ furniture: t.labelCategoryFurniture,
30
+ electronics: t.labelCategoryElectronics,
31
+ clothing: t.labelCategoryClothing,
32
+ kitchen: t.labelCategoryKitchen,
33
+ personal: t.labelCategoryPersonal,
34
+ other: t.labelCategoryOther,
35
+ };
36
+ ---
37
+
38
+ <div class="ric-tool" data-ric-tool>
39
+ <form class="ric-card" data-ric-form novalidate>
40
+ <div class="ric-workspace">
41
+ <section class="ric-inventory-panel" aria-labelledby="ric-inventory-title">
42
+ <div class="ric-panel-heading"><span class="ric-eyebrow">01 / {t.labelInventory}</span><h2 id="ric-inventory-title">{t.labelInventory}</h2><p>{t.labelInventoryHint}</p></div>
43
+ <div class="ric-shelf-scene" data-ric-visual role="img" aria-label={t.labelCoverageLine}>
44
+ <div class="ric-shelf-top"><span>{t.labelReplacementValue}</span><span data-ric-loss-label>{t.labelLossScenario}</span></div>
45
+ <div class="ric-shelf-track"><span class="ric-contents-fill" data-ric-contents-fill></span><span class="ric-limit-line" data-ric-limit-line aria-label={t.labelLimitMarker}></span><span class="ric-loss-mark" data-ric-loss-mark aria-hidden="true"></span></div>
46
+ <div class="ric-shelf-legend"><span><i class="ric-dot ric-dot-contents"></i>{t.labelReplacementValue}</span><span><i class="ric-dot ric-dot-limit"></i>{t.labelCoverageLine}</span></div>
47
+ </div>
48
+ <div class="ric-inventory-list">
49
+ {INVENTORY_CATEGORIES.map((category) => <label class="ric-inventory-row" for={`ric-${category}`}><span>{categoryLabels[category]}</span><span class="ric-money-input"><input id={`ric-${category}`} data-category={category} type="number" min="0" max="1000000000" step="0.01" value={initial.inventory.find((item) => item.category === category)?.value ?? 0} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><b data-ric-unit>{defaultSymbol}</b></span></label>)}
50
+ </div>
51
+ <ol class="ric-largest-list" data-ric-largest-list aria-label={t.labelLargestCategories}></ol>
52
+ </section>
53
+
54
+ <section class="ric-policy-panel" aria-labelledby="ric-policy-title">
55
+ <div class="ric-panel-heading"><span class="ric-eyebrow">02 / {t.labelPolicyBoundary}</span><h2 id="ric-policy-title">{t.labelSetLimit}</h2><p>{t.labelPolicyBoundaryHint}</p></div>
56
+ <div class="ric-field-stack">
57
+ <div class="ric-field ric-currency-field"><span class="ric-label" id="ric-currency-label">{t.labelCurrency}</span><div class="ric-select"><button type="button" class="ric-select-trigger" data-ric-currency-trigger aria-expanded="false" aria-haspopup="listbox" aria-labelledby="ric-currency-label"><span class="ric-currency-trigger-main"><strong data-ric-selected-symbol>{defaultSymbol}</strong><span data-ric-selected-code>{defaultCurrency}</span></span><span class="ric-currency-arrow" aria-hidden="true"></span></button><div class="ric-select-menu" data-ric-currency-menu role="listbox" aria-label={t.labelCurrency} hidden>{currencyOptions.map(({ code, symbol, name }) => <button type="button" class="ric-currency-option" data-ric-currency-option data-currency={code} role="option" aria-selected={code === defaultCurrency} aria-label={`${symbol} ${name}`}><strong>{symbol}</strong><span>{code}</span><small>{name}</small></button>)}</div></div><small>{t.labelApproximateRates}</small></div>
58
+ <div class="ric-field"><label for="ric-policy-limit">{t.labelPolicyLimit}</label><div class="ric-money-input"><input id="ric-policy-limit" data-field="policyLimit" type="number" min="0.01" max="1000000000" step="0.01" value={initial.policyLimit} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><b data-ric-unit>{defaultSymbol}</b></div><small>{t.labelPolicyLimitHelp}</small></div>
59
+ <div class="ric-field"><label for="ric-deductible">{t.labelDeductible}</label><div class="ric-money-input"><input id="ric-deductible" data-field="deductible" type="number" min="0" max="1000000000" step="0.01" value={initial.deductible} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><b data-ric-unit>{defaultSymbol}</b></div><small>{t.labelDeductibleHelp}</small></div>
60
+ <div class="ric-field"><label for="ric-sample-loss">{t.labelSampleLoss}</label><div class="ric-money-input"><input id="ric-sample-loss" data-field="sampleLoss" type="number" min="0.01" max="1000000000" step="0.01" value={initial.sampleLoss} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><b data-ric-unit>{defaultSymbol}</b></div><small>{t.labelSampleLossHelp}</small></div>
61
+ </div>
62
+ <div class="ric-result-block" aria-live="polite" aria-atomic="true"><span class="ric-eyebrow">03 / {t.labelStatus}</span><strong class="ric-status" data-ric-status>{t.labelStatusReview}</strong><p data-ric-status-text></p><p class="ric-error" data-ric-error hidden>{t.labelInvalidInput}</p><div class="ric-metric-main"><span>{t.labelCoverageGap}</span><strong data-ric-gap>—</strong></div><div class="ric-metric-line"><span>{t.labelTotalContents}</span><b data-ric-total>—</b></div><div class="ric-metric-line"><span>{t.labelCoverageRatio}</span><b data-ric-ratio>—</b></div><div class="ric-metric-line"><span>{t.labelEstimatedPayout}</span><b data-ric-payout>—</b></div><div class="ric-metric-line"><span>{t.labelOutOfPocket}</span><b data-ric-out-of-pocket>—</b></div><p class="ric-result-note">{t.labelIllustrativePayout}</p></div>
63
+ <div class="ric-actions"><button type="button" class="ric-button ric-button-secondary" data-ric-reset>{t.labelReset}</button><button type="button" class="ric-button ric-button-primary" data-ric-copy>{t.labelCopy}</button><span data-ric-copy-status role="status" aria-live="polite"></span></div>
64
+ </section>
65
+ </div>
66
+ <p class="ric-privacy">{t.labelPrivacyNote}</p><details class="ric-method"><summary>{t.labelMethodTitle}</summary><p>{t.labelMethodText}</p></details>
67
+ </form>
68
+ </div>
69
+
70
+ <script is:inline type="application/json" data-ric-config set:html={config}></script>
71
+ <script>
72
+ import { mountRenterInsuranceTool } from './controller';
73
+ const configElement = document.querySelector<HTMLScriptElement>('[data-ric-config]');
74
+ const root = document.querySelector<HTMLElement>('[data-ric-tool]');
75
+ if (root && configElement?.textContent) mountRenterInsuranceTool(root, JSON.parse(configElement.textContent));
76
+ </script>
77
+
78
+ <style>
79
+ @import './renter-insurance-coverage-gap-calculator.css';
80
+ </style>
@@ -0,0 +1,102 @@
1
+ import { calculateRenterInsuranceCoverageGap, validateRenterInsuranceInput, type RenterInsuranceInput } from './logic';
2
+ import { convertRenterInsuranceCurrency, isRenterInsuranceCurrencyCode, type RenterInsuranceCurrencyCode } from './currency';
3
+ import { clearRenterInsuranceResult, formatMoney, renderRenterInsuranceResult, type ViewConfig } from './dom-views';
4
+ import { readSavedRenterInsuranceInput, saveRenterInsuranceInput } from './storage';
5
+ import type { RenterInsuranceCoverageGapCalculatorUI } from './ui';
6
+
7
+ interface ControllerConfig {
8
+ initial: RenterInsuranceInput;
9
+ locale: string;
10
+ ui: RenterInsuranceCoverageGapCalculatorUI;
11
+ }
12
+
13
+ const getNumber = (form: HTMLFormElement, field: string): number => Number(form.querySelector<HTMLInputElement>(`[data-field="${field}"]`)?.value ?? Number.NaN);
14
+
15
+ const readInput = (form: HTMLFormElement, currency: string): RenterInsuranceInput => ({
16
+ currency,
17
+ policyLimit: getNumber(form, 'policyLimit'),
18
+ deductible: getNumber(form, 'deductible'),
19
+ sampleLoss: getNumber(form, 'sampleLoss'),
20
+ inventory: Array.from(form.querySelectorAll<HTMLInputElement>('[data-category]')).map((input) => ({ category: input.dataset.category as RenterInsuranceInput['inventory'][number]['category'], value: Number(input.value) })),
21
+ });
22
+
23
+ const setFieldValues = (form: HTMLFormElement, input: RenterInsuranceInput): void => {
24
+ ['policyLimit', 'deductible', 'sampleLoss'].forEach((field) => {
25
+ const element = form.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
26
+ if (element) element.value = String(input[field as 'policyLimit' | 'deductible' | 'sampleLoss']);
27
+ });
28
+ input.inventory.forEach((item) => {
29
+ const element = form.querySelector<HTMLInputElement>(`[data-category="${item.category}"]`);
30
+ if (element) element.value = String(item.value);
31
+ });
32
+ };
33
+
34
+ const updateFieldErrors = (root: HTMLElement, form: HTMLFormElement, input: RenterInsuranceInput): boolean => {
35
+ const errors = validateRenterInsuranceInput(input);
36
+ form.querySelectorAll<HTMLInputElement>('[data-field], [data-category]').forEach((field) => field.setAttribute('aria-invalid', 'false'));
37
+ Object.keys(errors).forEach((key) => form.querySelector<HTMLInputElement>(`[data-field="${key}"]`)?.setAttribute('aria-invalid', 'true'));
38
+ const summary = root.querySelector<HTMLElement>('[data-ric-error]');
39
+ if (summary) summary.hidden = Object.keys(errors).length === 0;
40
+ if (errors.inventory) form.querySelectorAll<HTMLInputElement>('[data-category]').forEach((field) => field.setAttribute('aria-invalid', 'true'));
41
+ return Object.keys(errors).length > 0;
42
+ };
43
+
44
+ const setCurrency = (root: HTMLElement, currency: RenterInsuranceCurrencyCode): void => {
45
+ const selectedOption = root.querySelector<HTMLButtonElement>(`[data-ric-currency-option][data-currency="${currency}"]`);
46
+ const symbol = selectedOption?.querySelector('strong')?.textContent ?? currency;
47
+ root.querySelectorAll<HTMLElement>('[data-ric-unit]').forEach((unit) => { unit.textContent = symbol; });
48
+ root.querySelectorAll<HTMLButtonElement>('[data-ric-currency-option]').forEach((option) => option.setAttribute('aria-selected', String(option.dataset.currency === currency)));
49
+ const selectedCode = root.querySelector<HTMLElement>('[data-ric-selected-code]');
50
+ if (selectedCode) selectedCode.textContent = currency;
51
+ const selectedSymbol = root.querySelector<HTMLElement>('[data-ric-selected-symbol]');
52
+ if (selectedSymbol) selectedSymbol.textContent = symbol;
53
+ };
54
+
55
+ const mountCurrencyMenu = (root: HTMLElement, onSelect: (currency: RenterInsuranceCurrencyCode) => void): void => {
56
+ const trigger = root.querySelector<HTMLButtonElement>('[data-ric-currency-trigger]');
57
+ const menu = root.querySelector<HTMLElement>('[data-ric-currency-menu]');
58
+ const close = (): void => { if (menu && trigger) { menu.hidden = true; trigger.setAttribute('aria-expanded', 'false'); } };
59
+ trigger?.addEventListener('click', () => { if (menu) { menu.hidden = !menu.hidden; trigger.setAttribute('aria-expanded', String(!menu.hidden)); } });
60
+ root.querySelectorAll<HTMLButtonElement>('[data-ric-currency-option]').forEach((option) => option.addEventListener('click', () => { const nextCurrency = option.dataset.currency; if (nextCurrency && isRenterInsuranceCurrencyCode(nextCurrency)) onSelect(nextCurrency); close(); }));
61
+ document.addEventListener('click', (event) => { if (!root.contains(event.target as Node)) close(); });
62
+ };
63
+
64
+ const mountCopyAction = (root: HTMLElement, form: HTMLFormElement, config: ControllerConfig, getCurrency: () => RenterInsuranceCurrencyCode): void => {
65
+ form.querySelector('[data-ric-copy]')?.addEventListener('click', () => {
66
+ const result = calculateRenterInsuranceCoverageGap(readInput(form, getCurrency()));
67
+ if (!result) return;
68
+ const summary = `${config.ui.labelCoverageGap}: ${formatMoney(config.locale, result.currency, result.coverageGap)}. ${config.ui.labelCoverageRatio}: ${Math.round(result.coverageRatio * 100)}%. ${config.ui.labelLargestCategories}: ${result.largestCategories.map((item) => item.category).join(', ')}.`;
69
+ navigator.clipboard?.writeText(summary).then(() => { const status = root.querySelector<HTMLElement>('[data-ric-copy-status]'); if (status) status.textContent = config.ui.labelCopied; }).catch(() => { const status = root.querySelector<HTMLElement>('[data-ric-copy-status]'); if (status) status.textContent = config.ui.labelCopyFailed; });
70
+ });
71
+ };
72
+
73
+ export function mountRenterInsuranceTool(root: HTMLElement, config: ControllerConfig): void {
74
+ const form = root.querySelector<HTMLFormElement>('[data-ric-form]');
75
+ if (!form) return;
76
+ const saved = readSavedRenterInsuranceInput(config.initial);
77
+ let currency: RenterInsuranceCurrencyCode = isRenterInsuranceCurrencyCode(saved.currency) ? saved.currency : config.initial.currency as RenterInsuranceCurrencyCode;
78
+ setFieldValues(form, saved);
79
+ const viewConfig: ViewConfig = { root, locale: config.locale, ui: config.ui };
80
+ const update = (): void => {
81
+ const input = readInput(form, currency);
82
+ if (updateFieldErrors(root, form, input)) { clearRenterInsuranceResult(root, config.ui); return; }
83
+ const result = calculateRenterInsuranceCoverageGap(input);
84
+ if (!result) return;
85
+ saveRenterInsuranceInput(input);
86
+ renderRenterInsuranceResult(viewConfig, result);
87
+ };
88
+ const reset = (): void => { currency = config.initial.currency as RenterInsuranceCurrencyCode; setFieldValues(form, config.initial); setCurrency(root, currency); update(); };
89
+ form.querySelectorAll<HTMLInputElement>('[data-field], [data-category]').forEach((field) => field.addEventListener('input', update));
90
+ form.querySelector('[data-ric-reset]')?.addEventListener('click', reset);
91
+ mountCopyAction(root, form, config, () => currency);
92
+ mountCurrencyMenu(root, (nextCurrency) => {
93
+ if (nextCurrency === currency) return;
94
+ const moneyFields = [...Array.from(form.querySelectorAll<HTMLInputElement>('[data-field]')), ...Array.from(form.querySelectorAll<HTMLInputElement>('[data-category]'))];
95
+ moneyFields.forEach((field) => { const value = Number(field.value); if (Number.isFinite(value)) field.value = String(convertRenterInsuranceCurrency(value, currency, nextCurrency)); });
96
+ currency = nextCurrency;
97
+ setCurrency(root, currency);
98
+ update();
99
+ });
100
+ setCurrency(root, currency);
101
+ update();
102
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { convertRenterInsuranceCurrency, currencyOptions, defaultCurrencyByLocale } from './currency';
3
+
4
+ describe('renter insurance currency support', () => {
5
+ it('offers the international currency set used by insurance tools', () => {
6
+ expect(currencyOptions.map(({ code }) => code)).toEqual(['AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'IDR', 'INR', 'JPY', 'KRW', 'MXN', 'NOK', 'PLN', 'RUB', 'SEK', 'TRY', 'USD']);
7
+ });
8
+
9
+ it('selects a useful default for each supported locale family', () => {
10
+ expect(defaultCurrencyByLocale).toMatchObject({ en: 'USD', es: 'EUR', ja: 'JPY', zh: 'CNY' });
11
+ });
12
+
13
+ it('converts monetary inputs with reversible planning factors', () => {
14
+ expect(convertRenterInsuranceCurrency(100, 'EUR', 'USD')).toBe(108.7);
15
+ expect(convertRenterInsuranceCurrency(108.7, 'USD', 'EUR')).toBe(100);
16
+ });
17
+ });
@@ -0,0 +1,69 @@
1
+ export const currencyOptions = [
2
+ { code: 'AUD', symbol: 'A$', name: 'Australian dollar' },
3
+ { code: 'BRL', symbol: 'R$', name: 'Brazilian real' },
4
+ { code: 'CAD', symbol: 'C$', name: 'Canadian dollar' },
5
+ { code: 'CHF', symbol: 'CHF', name: 'Swiss franc' },
6
+ { code: 'CNY', symbol: '¥', name: 'Chinese yuan' },
7
+ { code: 'CZK', symbol: 'Kč', name: 'Czech koruna' },
8
+ { code: 'DKK', symbol: 'kr', name: 'Danish krone' },
9
+ { code: 'EUR', symbol: '€', name: 'Euro' },
10
+ { code: 'GBP', symbol: '£', name: 'British pound' },
11
+ { code: 'IDR', symbol: 'Rp', name: 'Indonesian rupiah' },
12
+ { code: 'INR', symbol: '₹', name: 'Indian rupee' },
13
+ { code: 'JPY', symbol: '¥', name: 'Japanese yen' },
14
+ { code: 'KRW', symbol: '₩', name: 'South Korean won' },
15
+ { code: 'MXN', symbol: 'MX$', name: 'Mexican peso' },
16
+ { code: 'NOK', symbol: 'kr', name: 'Norwegian krone' },
17
+ { code: 'PLN', symbol: 'zł', name: 'Polish złoty' },
18
+ { code: 'RUB', symbol: '₽', name: 'Russian ruble' },
19
+ { code: 'SEK', symbol: 'kr', name: 'Swedish krona' },
20
+ { code: 'TRY', symbol: '₺', name: 'Turkish lira' },
21
+ { code: 'USD', symbol: '$', name: 'US dollar' },
22
+ ] as const;
23
+
24
+ export type RenterInsuranceCurrencyCode = (typeof currencyOptions)[number]['code'];
25
+
26
+ export const defaultCurrencyByLocale: Record<string, RenterInsuranceCurrencyCode> = {
27
+ de: 'EUR',
28
+ en: 'USD',
29
+ es: 'EUR',
30
+ fr: 'EUR',
31
+ id: 'IDR',
32
+ it: 'EUR',
33
+ ja: 'JPY',
34
+ ko: 'KRW',
35
+ nl: 'EUR',
36
+ pl: 'PLN',
37
+ pt: 'EUR',
38
+ ru: 'RUB',
39
+ sv: 'SEK',
40
+ tr: 'TRY',
41
+ zh: 'CNY',
42
+ };
43
+
44
+ const approximateUsdFactors: Record<RenterInsuranceCurrencyCode, number> = {
45
+ AUD: 1.52,
46
+ BRL: 5.1,
47
+ CAD: 1.38,
48
+ CHF: 0.88,
49
+ CNY: 7.2,
50
+ CZK: 23,
51
+ DKK: 6.85,
52
+ USD: 1,
53
+ EUR: 0.92,
54
+ GBP: 0.79,
55
+ IDR: 16000,
56
+ INR: 84,
57
+ JPY: 150,
58
+ KRW: 1380,
59
+ MXN: 18,
60
+ NOK: 10.7,
61
+ PLN: 3.95,
62
+ RUB: 90,
63
+ SEK: 10.5,
64
+ TRY: 34,
65
+ };
66
+
67
+ export const isRenterInsuranceCurrencyCode = (value: string): value is RenterInsuranceCurrencyCode => currencyOptions.some(({ code }) => code === value);
68
+
69
+ export const convertRenterInsuranceCurrency = (amount: number, from: RenterInsuranceCurrencyCode, to: RenterInsuranceCurrencyCode): number => Number((amount * approximateUsdFactors[to] / approximateUsdFactors[from]).toFixed(2));
@@ -0,0 +1,68 @@
1
+ import type { CoverageCategoryResult, RenterInsuranceResult } from './logic';
2
+ import type { RenterInsuranceCoverageGapCalculatorUI } from './ui';
3
+ import { evaluateCoverage } from './evaluator';
4
+
5
+ export interface ViewConfig {
6
+ root: HTMLElement;
7
+ locale: string;
8
+ ui: RenterInsuranceCoverageGapCalculatorUI;
9
+ }
10
+
11
+ const setText = (root: HTMLElement, selector: string, value: string): void => {
12
+ const element = root.querySelector<HTMLElement>(selector);
13
+ if (element) element.textContent = value;
14
+ };
15
+
16
+ const formatMoney = (locale: string, currency: string, value: number): string => new Intl.NumberFormat(locale, {
17
+ style: 'currency',
18
+ currency,
19
+ maximumFractionDigits: 2,
20
+ }).format(value);
21
+
22
+ const renderCategoryList = (root: HTMLElement, categories: CoverageCategoryResult[], locale: string, currency: string): void => {
23
+ const list = root.querySelector<HTMLOListElement>('[data-ric-largest-list]');
24
+ if (!list) return;
25
+ list.replaceChildren(...categories.map((category) => {
26
+ const item = document.createElement('li');
27
+ item.textContent = `${category.category}: ${formatMoney(locale, currency, category.value)}`;
28
+ return item;
29
+ }));
30
+ };
31
+
32
+ const renderShelf = (root: HTMLElement, result: RenterInsuranceResult): void => {
33
+ const total = Math.max(result.totalContents, 1);
34
+ const contentWidth = Math.min(100, result.totalContents / total * 100);
35
+ const limitWidth = Math.min(100, result.policyLimit / total * 100);
36
+ root.querySelector<HTMLElement>('[data-ric-contents-fill]')?.style.setProperty('width', `${contentWidth}%`);
37
+ root.querySelector<HTMLElement>('[data-ric-limit-line]')?.style.setProperty('left', `${limitWidth}%`);
38
+ root.querySelector<HTMLElement>('[data-ric-loss-mark]')?.style.setProperty('left', `${Math.min(100, result.sampleLoss / total * 100)}%`);
39
+ };
40
+
41
+ export function renderRenterInsuranceResult(config: ViewConfig, result: RenterInsuranceResult): void {
42
+ const { root, locale, ui } = config;
43
+ const evaluation = evaluateCoverage(result, ui);
44
+ root.dataset.status = evaluation.status;
45
+ setText(root, '[data-ric-status]', evaluation.label);
46
+ setText(root, '[data-ric-status-text]', evaluation.text);
47
+ setText(root, '[data-ric-total]', formatMoney(locale, result.currency, result.totalContents));
48
+ setText(root, '[data-ric-gap]', formatMoney(locale, result.currency, result.coverageGap));
49
+ setText(root, '[data-ric-ratio]', `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(result.coverageRatio * 100)}%`);
50
+ setText(root, '[data-ric-payout]', formatMoney(locale, result.currency, result.estimatedPayout));
51
+ setText(root, '[data-ric-out-of-pocket]', formatMoney(locale, result.currency, result.estimatedOutOfPocket));
52
+ setText(root, '[data-ric-loss-label]', `${ui.labelLossScenario}: ${formatMoney(locale, result.currency, result.sampleLoss)}`);
53
+ renderCategoryList(root, result.largestCategories, locale, result.currency);
54
+ renderShelf(root, result);
55
+ }
56
+
57
+ export function clearRenterInsuranceResult(root: HTMLElement, ui: RenterInsuranceCoverageGapCalculatorUI): void {
58
+ root.dataset.status = 'invalid';
59
+ ['total', 'gap', 'ratio', 'payout', 'out-of-pocket'].forEach((key) => setText(root, `[data-ric-${key}]`, '—'));
60
+ setText(root, '[data-ric-status]', ui.labelInvalidInput);
61
+ setText(root, '[data-ric-status-text]', '');
62
+ root.querySelector<HTMLElement>('[data-ric-contents-fill]')?.style.setProperty('width', '0%');
63
+ root.querySelector<HTMLElement>('[data-ric-limit-line]')?.style.setProperty('left', '0%');
64
+ root.querySelector<HTMLElement>('[data-ric-loss-mark]')?.style.setProperty('left', '0%');
65
+ root.querySelector('[data-ric-largest-list]')?.replaceChildren();
66
+ }
67
+
68
+ export { formatMoney };
@@ -0,0 +1,28 @@
1
+ import type { FinanceToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { RenterInsuranceCoverageGapCalculatorUI } from './ui';
3
+
4
+ export type RenterInsuranceCoverageGapCalculatorLocaleContent = ToolLocaleContent<RenterInsuranceCoverageGapCalculatorUI>;
5
+
6
+ export const renterInsuranceCoverageGapCalculator: FinanceToolEntry<RenterInsuranceCoverageGapCalculatorUI> = {
7
+ id: 'renter-insurance-coverage-gap-calculator',
8
+ icons: { bg: 'mdi:home-alert', fg: 'mdi:shield-home' },
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';
@@ -0,0 +1,17 @@
1
+ import type { CoverageStatus, RenterInsuranceResult } from './logic';
2
+ import type { RenterInsuranceCoverageGapCalculatorUI } from './ui';
3
+
4
+ export interface CoverageEvaluation {
5
+ status: CoverageStatus;
6
+ label: string;
7
+ text: string;
8
+ }
9
+
10
+ export function evaluateCoverage(result: RenterInsuranceResult, ui: RenterInsuranceCoverageGapCalculatorUI): CoverageEvaluation {
11
+ const content: Record<CoverageStatus, { label: string; text: string }> = {
12
+ safe: { label: ui.labelStatusSafe, text: ui.labelStatusSafeText },
13
+ review: { label: ui.labelStatusReview, text: ui.labelStatusReviewText },
14
+ exposed: { label: ui.labelStatusExposed, text: ui.labelStatusExposedText },
15
+ };
16
+ return { status: result.status, ...content[result.status] };
17
+ }
@@ -0,0 +1,40 @@
1
+ import type { ToolLocaleContent } from '../../../types';
2
+ import { bibliography } from '../bibliography';
3
+ import type { RenterInsuranceCoverageGapCalculatorUI } from '../ui';
4
+
5
+ export const ui: RenterInsuranceCoverageGapCalculatorUI = {
6
+ labelPolicyLimit: 'Deckungssumme Hausrat', labelDeductible: 'Selbstbeteiligung', labelSampleLoss: 'Beispielschaden', labelCurrency: 'Währung', labelApproximateRates: 'Die Umrechnung nutzt ungefähre Planungsfaktoren und keine Live-Wechselkurse.', labelPolicyBoundary: 'Versicherungsgrenze', labelSetLimit: 'Grenze festlegen', labelPolicyBoundaryHint: 'Übernimm die Hausratwerte aus deiner Police und prüfe anschließend einen möglichen Schaden.', labelInventory: 'Ersatzwert-Inventar', labelInventoryHint: 'Schätze je Kategorie, was der komplette Ersatz heute kosten würde.', labelPolicyLimitHelp: 'Nutze die Hausratdeckung aus der Police, nicht die Gebäudesumme.', labelDeductibleHelp: 'Trage die Selbstbeteiligung für einen Hausratschaden ein.', labelSampleLossHelp: 'Dient nur zur Darstellung eines beispielhaften Schadenfalls nach der Lücke.', labelTotalContents: 'Geschätzter Hausratwert', labelCoverageGap: 'Mögliche Deckungslücke', labelCoverageRatio: 'Geschätzter Deckungsgrad', labelEstimatedPayout: 'Beispielauszahlung', labelOutOfPocket: 'Beispielhafte Eigenzahlung', labelStatus: 'Deckungseinschätzung', labelStatusSafe: 'Grenze deckt Inventar', labelStatusReview: 'Kleine Lücke prüfen', labelStatusExposed: 'Hausrat ist unterversichert', labelStatusSafeText: 'Die eingetragene Hausratgrenze erreicht mindestens den Ersatzwert. Prüfe Sonderlimits für einzelne Gegenstände zusätzlich.', labelStatusReviewText: 'Die Grenze liegt nahe am Inventarwert, aber eine kleine Lücke bleibt. Prüfe teure Gegenstände und neue Anschaffungen.', labelStatusExposedText: 'Die Grenze liegt unter dem Inventarwert. Prüfe die Police, bevor du dich bei einem Totalschaden darauf verlässt.', labelLargestCategories: 'Größte Inventarkategorien', labelCoverageLine: 'Deckungssumme', labelReplacementValue: 'Ersatzwert', labelLimitMarker: 'Markierung der Grenze', labelLossScenario: 'Schadenszenario', labelIllustrativePayout: 'Nur ein Beispiel: Eine Unterversicherungsregel muss auf deine Police nicht zutreffen.', labelReset: 'Beispiel zurücksetzen', labelCopy: 'Zusammenfassung kopieren', labelCopied: 'Zusammenfassung kopiert', labelCopyFailed: 'Ergebnis manuell aus dem Feld kopieren', labelInvalidInput: 'Prüfe den markierten Wert', labelMethodTitle: 'So funktioniert die Schätzung', labelMethodText: 'Das Tool addiert deine Kategoriewerte und vergleicht sie mit der Hausratdeckung. Die Lücke ist die positive Differenz. Die Beispielauszahlung wendet den Deckungsgrad auf den Schaden an und zieht die Selbstbeteiligung ab. Das ist ein Planungsmodell und keine Vorhersage einer Regulierung. Deine Police kann Einzelgrenzen, Neuwertregeln, Ausschlüsse, Erstes-Risiko-Deckung oder eine andere Behandlung von Unterversicherung vorsehen.', labelPrivacyNote: 'Dein Inventar bleibt in diesem Browser. Konto, Upload und Serverberechnung sind nicht erforderlich.', labelAmountPlaceholder: '0,00', labelCategoryFurniture: 'Möbel und Dekoration', labelCategoryElectronics: 'Elektronik', labelCategoryClothing: 'Kleidung und Schuhe', labelCategoryKitchen: 'Küche und Geräte', labelCategoryPersonal: 'Persönliche Gegenstände', labelCategoryOther: 'Sonstiger Hausrat',
7
+ };
8
+
9
+ const slug = 'mieterversicherung-unterversicherung-hausrat-rechner';
10
+ const title = 'Rechner für die Deckungslücke bei der Hausratversicherung';
11
+ const description = 'Prüfe, ob die Hausratdeckung deiner Mieterversicherung den Ersatzwert deiner Sachen erreicht. Vergleiche Inventar, Versicherungssumme, Selbstbeteiligung und einen Beispielschaden direkt im Browser.';
12
+ const faq = [
13
+ { question: 'Was misst dieser Rechner für die Hausratversicherung?', answer: 'Er addiert die von dir eingetragenen Ersatzwerte und vergleicht die Summe mit der Hausratdeckung. Die Differenz zeigt als Planungsschätzung, welcher Betrag möglicherweise außerhalb der Grenze liegt.' },
14
+ { question: 'Welchen Wert soll ich für mein Inventar eintragen?', answer: 'Nutze realistische aktuelle Ersatzkosten für Möbel, Elektronik, Kleidung, Geräte und persönliche Gegenstände. Prüfe, ob deine Police den Neuwert oder eine andere Bewertungsgrundlage verlangt.' },
15
+ { question: 'Beweist das Ergebnis eine Unterversicherung?', answer: 'Nein. Das Ergebnis hängt von deinem Inventar, dem Vertrag und deinen Bewertungsannahmen ab. Einzelgrenzen, Ausschlüsse, besondere Vereinbarungen und Unterversicherungsklauseln können einen Schaden anders behandeln.' },
16
+ { question: 'Warum zeigt der Rechner eine Beispielauszahlung?', answer: 'Sie macht sichtbar, was ein einfaches proportionales Modell für den eingetragenen Schaden nach Abzug der Selbstbeteiligung ergeben würde. Sie sagt nicht voraus, was der Versicherer tatsächlich zahlt.' },
17
+ { question: 'Soll ich Einbauten und Ausstattung des Vermieters einrechnen?', answer: 'Nur Gegenstände, die laut Police zu deiner Hausratdeckung gehören. Haftpflicht, Gebäude, Einbauten und Hausrat können getrennte Bereiche sein und sollten nicht künstlich zusammengezählt werden.' },
18
+ ];
19
+ const howTo = [
20
+ { name: 'Ersatzwert-Inventar erstellen', text: 'Gehe durch alle Räume und trage ein, was ein Ersatz des Hausrats heute kosten würde. Ordne die Werte den sechs Kategorien zu, damit die größten Posten sichtbar bleiben.' },
21
+ { name: 'Hausratgrenze eintragen', text: 'Übernimm die Versicherungssumme aus dem Versicherungsschein oder Angebot. Verwende nicht die Gebäude- oder Haftpflichtsumme.' },
22
+ { name: 'Selbstbeteiligung und Schaden ergänzen', text: 'Trage die Selbstbeteiligung und einen Beispielschaden ein, um eine beispielhafte Eigenzahlung zu sehen.' },
23
+ { name: 'Entscheidungsgrenze prüfen', text: 'Bei einer Lücke solltest du teure Gegenstände, Einzelgrenzen, Wertsachen und neue Anschaffungen mit Versicherer oder Makler prüfen.' },
24
+ ];
25
+ export const content: ToolLocaleContent<RenterInsuranceCoverageGapCalculatorUI> = { slug, title, description, ui, seo: [
26
+ { type: 'title', text: 'Vergleiche die Grenze mit dem Wert deines Hausrats', level: 2 },
27
+ { type: 'paragraph', html: '<p>Eine Hausratversicherung wird leicht zu niedrig angesetzt, wenn die Summe nur grob gewählt statt Raum für Raum ermittelt wird. Dieser Rechner macht deine eigenen Ersatzschätzungen als Deckungsgrenze sichtbar, bevor du einen Schaden melden musst.</p>' },
28
+ { type: 'title', text: 'Das solltest du vor der Berechnung sammeln', level: 2 },
29
+ { type: 'list', items: ['Ersatzwerte für Möbel, Elektronik, Kleidung, Geräte und persönliche Gegenstände.', 'Die Hausratdeckung aus dem Versicherungsschein, getrennt von Gebäude und Haftpflicht.', 'Die Selbstbeteiligung für einen Hausratschaden.', 'Einen realistischen Beispielschaden, wenn du die Beispielauszahlung prüfen möchtest.'] },
30
+ { type: 'title', text: 'So liest du die Deckungslücke', level: 2 },
31
+ { type: 'paragraph', html: '<p>Die Lücke ist der Inventarwert oberhalb der Grenze. Null bedeutet, dass beide Summen unter deinen Annahmen übereinstimmen; es bestätigt nicht die Deckung jedes einzelnen Gegenstands. Eine positive Lücke ist ein Anlass, Bewertungsgrundlage, Einzelgrenzen, Ausschlüsse und Unterversicherungsklauseln zu prüfen.</p>' },
32
+ { type: 'table', headers: ['Signal', 'Aussage', 'Nächster sinnvoller Schritt'], rows: [['Grenze deckt Inventar', 'Die Grenze erreicht mindestens die eingetragene Ersatzsumme.', 'Wertsachen, Einzelgrenzen und Ausschlüsse prüfen.'], ['Kleine Lücke prüfen', 'Die Grenze liegt nahe am Wert, doch neue Käufe oder Rundungen können zählen.', 'Die größten Kategorien neu bewerten und die Summe anpassen.'], ['Hausrat ist unterversichert', 'Die Grenze liegt deutlich unter dem Ersatzwert.', 'Neues Angebot anfordern oder die Unterversicherungsregel klären.']] },
33
+ { type: 'title', text: 'Grenzen der Schätzung', level: 2 },
34
+ { type: 'paragraph', html: '<p>Das Tool liest keine Police, entscheidet nicht über die Deckung und berechnet keine verbindliche Entschädigung. Das Schadenbeispiel nutzt ein einfaches proportionales Modell und zieht die Selbstbeteiligung ab. Verträge können andere Klauseln, Wertansätze, Ausschlüsse oder Erstes-Risiko-Regeln enthalten.</p>' },
35
+ { type: 'tip', title: 'Tipp zur Dokumentation', html: '<p>Bewahre Rechnungen, Fotos und ein datiertes Inventar für wertvolle Sachen auf. Der Financial Ombudsman Service erläutert, warum fehlende Ersatzwertberechnung zu Unterversicherung führen kann; das spanische Versicherungsvertragsgesetz beschreibt die Versicherungssumme als maximale Entschädigung und eine proportionale Regel bei Unterversicherung, sofern die Police nichts anderes bestimmt.</p>' },
36
+ ], faq, bibliography, howTo, schemas: [
37
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
38
+ { '@context': 'https://schema.org', '@type': 'HowTo', name: title, step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) },
39
+ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'FinanceApplication', operatingSystem: 'All', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' } },
40
+ ] };