@jjlmoya/utils-finance 1.35.0 → 1.37.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 (80) hide show
  1. package/package.json +17 -5
  2. package/scripts/postinstall.mjs +2 -4
  3. package/src/category/index.ts +2 -1
  4. package/src/components/ProductionBreadcrumb.astro +134 -0
  5. package/src/components/ProductionStructuredData.astro +46 -0
  6. package/src/components/ProductionWidget.astro +182 -0
  7. package/src/entries.ts +4 -1
  8. package/src/i18n/header-ui.ts +15 -0
  9. package/src/i18n/language-ui.ts +9 -0
  10. package/src/i18n/languages.ts +24 -0
  11. package/src/identity/brands.ts +5 -0
  12. package/src/layouts/ProductionCategoryPage.astro +184 -0
  13. package/src/layouts/ProductionPage.astro +218 -0
  14. package/src/layouts/ProductionUtilityPage.astro +283 -0
  15. package/src/mfe/assets.ts +39 -0
  16. package/src/mfe/category-ui.ts +34 -0
  17. package/src/mfe/manifest.ts +45 -0
  18. package/src/mfe/routes.ts +50 -0
  19. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug]/manifest.json.ts +49 -0
  20. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
  21. package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
  22. package/src/pages/index.astro +1 -2
  23. package/src/pages/utilidades/[slug]/manifest.json.ts +28 -0
  24. package/src/pages/utilidades/[slug].astro +90 -0
  25. package/src/pages/utilidades/categorias/[category].astro +38 -0
  26. package/src/tests/diacritics_density.test.ts +1 -1
  27. package/src/tests/inverted_punctuation.test.ts +1 -1
  28. package/src/tests/locale_completeness.test.ts +2 -2
  29. package/src/tests/mfe_cache_contract.test.ts +11 -0
  30. package/src/tests/mfe_manifest.test.ts +28 -0
  31. package/src/tests/registry_contract.test.ts +67 -0
  32. package/src/tests/script_density.test.ts +94 -94
  33. package/src/tests/tool_validation.test.ts +2 -2
  34. package/src/tests/translation_copy.test.ts +2 -2
  35. package/src/tool/compoundInterest/index.ts +1 -0
  36. package/src/tool/courtFeeCalculator/index.ts +1 -0
  37. package/src/tool/debtSnowball/index.ts +1 -0
  38. package/src/tool/fireCalculator/index.ts +1 -0
  39. package/src/tool/ibanBicSwiftConverter/index.ts +1 -0
  40. package/src/tool/inflation/index.ts +1 -0
  41. package/src/tool/inflationPurchasingPower/bibliography.astro +14 -0
  42. package/src/tool/inflationPurchasingPower/bibliography.ts +12 -0
  43. package/src/tool/inflationPurchasingPower/component.astro +79 -0
  44. package/src/tool/inflationPurchasingPower/controller.ts +82 -0
  45. package/src/tool/inflationPurchasingPower/dom-views.ts +99 -0
  46. package/src/tool/inflationPurchasingPower/entry.ts +31 -0
  47. package/src/tool/inflationPurchasingPower/evaluator.ts +18 -0
  48. package/src/tool/inflationPurchasingPower/i18n/de.ts +56 -0
  49. package/src/tool/inflationPurchasingPower/i18n/en.ts +131 -0
  50. package/src/tool/inflationPurchasingPower/i18n/es.ts +44 -0
  51. package/src/tool/inflationPurchasingPower/i18n/fr.ts +44 -0
  52. package/src/tool/inflationPurchasingPower/i18n/id.ts +44 -0
  53. package/src/tool/inflationPurchasingPower/i18n/it.ts +44 -0
  54. package/src/tool/inflationPurchasingPower/i18n/ja.ts +44 -0
  55. package/src/tool/inflationPurchasingPower/i18n/ko.ts +44 -0
  56. package/src/tool/inflationPurchasingPower/i18n/nl.ts +44 -0
  57. package/src/tool/inflationPurchasingPower/i18n/pl.ts +44 -0
  58. package/src/tool/inflationPurchasingPower/i18n/pt.ts +44 -0
  59. package/src/tool/inflationPurchasingPower/i18n/ru.ts +44 -0
  60. package/src/tool/inflationPurchasingPower/i18n/sv.ts +44 -0
  61. package/src/tool/inflationPurchasingPower/i18n/tr.ts +44 -0
  62. package/src/tool/inflationPurchasingPower/i18n/zh.ts +44 -0
  63. package/src/tool/inflationPurchasingPower/index.ts +11 -0
  64. package/src/tool/inflationPurchasingPower/inflation-purchasing-power-timeline.css +402 -0
  65. package/src/tool/inflationPurchasingPower/logic.test.ts +35 -0
  66. package/src/tool/inflationPurchasingPower/logic.ts +92 -0
  67. package/src/tool/inflationPurchasingPower/seo.astro +14 -0
  68. package/src/tool/inflationPurchasingPower/storage.ts +29 -0
  69. package/src/tool/inflationPurchasingPower/ui.ts +42 -0
  70. package/src/tool/lateInterest/index.ts +1 -0
  71. package/src/tool/legalInterestRate/index.ts +1 -0
  72. package/src/tool/lotteryOptimizer/index.ts +1 -0
  73. package/src/tool/mortgage/index.ts +1 -0
  74. package/src/tool/percentageCalculator/index.ts +1 -0
  75. package/src/tool/rentIncreaseCalculator/index.ts +1 -0
  76. package/src/tools.ts +2 -1
  77. package/src/types.ts +2 -4
  78. package/src/worker.ts +25 -0
  79. package/src/pages/[locale]/[slug].astro +0 -161
  80. package/src/pages/[locale].astro +0 -251
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { existsSync, readdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import { ALL_ENTRIES } from '../entries';
6
+ import { ALL_TOOLS } from '../tools';
7
+ import type { ToolDefinition } from '../types';
8
+
9
+ function isToolDefinition(value: unknown): value is ToolDefinition {
10
+ if (!value || typeof value !== 'object') return false;
11
+
12
+ const candidate = value as Record<string, unknown>;
13
+ return Boolean(
14
+ candidate.entry &&
15
+ typeof candidate.entry === 'object' &&
16
+ typeof candidate.Component === 'function' &&
17
+ typeof candidate.SEOComponent === 'function' &&
18
+ typeof candidate.BibliographyComponent === 'function',
19
+ );
20
+ }
21
+
22
+ const toolRoot = join(process.cwd(), 'src', 'tool');
23
+ const toolDirectories = readdirSync(toolRoot, { withFileTypes: true })
24
+ .filter((entry) => entry.isDirectory() && existsSync(join(toolRoot, entry.name, 'index.ts')))
25
+ .map((entry) => entry.name)
26
+ .sort();
27
+
28
+ describe('Library registry contract', () => {
29
+ it('keeps ALL_ENTRIES and ALL_TOOLS synchronized by id', () => {
30
+ const entryIds = ALL_ENTRIES.map((entry) => entry.id);
31
+ const toolIds = ALL_TOOLS.map((tool) => tool.entry.id);
32
+
33
+ expect(new Set(entryIds).size).toBe(entryIds.length);
34
+ expect(new Set(toolIds).size).toBe(toolIds.length);
35
+ expect([...toolIds].sort()).toEqual([...entryIds].sort());
36
+ });
37
+
38
+ it('registers every tool runtime index in both public registries', async () => {
39
+ const failures: string[] = [];
40
+
41
+ for (const directory of toolDirectories) {
42
+ const runtimeModule = await import(
43
+ pathToFileURL(join(toolRoot, directory, 'index.ts')).href,
44
+ );
45
+ const definitions = [...new Set(Object.values(runtimeModule).filter(isToolDefinition))];
46
+
47
+ if (definitions.length !== 1) {
48
+ failures.push(`${directory}: expected exactly one ToolDefinition export, found ${definitions.length}`);
49
+ continue;
50
+ }
51
+
52
+ const [definition] = definitions;
53
+ if (!definition) {
54
+ failures.push(`${directory}: runtime definition is undefined`);
55
+ continue;
56
+ }
57
+ if (!ALL_TOOLS.some((tool) => tool.entry.id === definition.entry.id)) {
58
+ failures.push(`${directory}: runtime definition is missing from ALL_TOOLS`);
59
+ }
60
+ if (!ALL_ENTRIES.some((entry) => entry.id === definition.entry.id)) {
61
+ failures.push(`${directory}: runtime entry is missing from ALL_ENTRIES`);
62
+ }
63
+ }
64
+
65
+ expect(failures).toEqual([]);
66
+ });
67
+ });
@@ -1,94 +1,94 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { ALL_TOOLS } from '../tools';
3
-
4
- type ScriptLocale = keyof typeof SCRIPT_RULES;
5
-
6
- const SCRIPT_RULES = {
7
- ja: {
8
- language: 'Japanese',
9
- scriptName: 'kana/kanji',
10
- scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
11
- minScriptRatio: 0.45,
12
- },
13
- ko: {
14
- language: 'Korean',
15
- scriptName: 'hangul',
16
- scriptCharacters: /\p{Script=Hangul}/gu,
17
- minScriptRatio: 0.55,
18
- },
19
- ru: {
20
- language: 'Russian',
21
- scriptName: 'cyrillic',
22
- scriptCharacters: /\p{Script=Cyrillic}/gu,
23
- minScriptRatio: 0.65,
24
- },
25
- zh: {
26
- language: 'Chinese',
27
- scriptName: 'han',
28
- scriptCharacters: /\p{Script=Han}/gu,
29
- minScriptRatio: 0.45,
30
- },
31
- } as const;
32
-
33
- const LETTERS = /\p{L}/gu;
34
- const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
35
-
36
- function collectStrings(value: unknown): string[] {
37
- if (typeof value === 'string') return [value];
38
- if (!value || typeof value !== 'object') return [];
39
- if (Array.isArray(value)) return value.flatMap(collectStrings);
40
- return Object.values(value).flatMap(collectStrings);
41
- }
42
-
43
- function normalizeText(value: unknown): string {
44
- return collectStrings(value).join(' ').normalize('NFC');
45
- }
46
-
47
- function translatableContent(content: Record<string, unknown>) {
48
- return TRANSLATABLE_KEYS.map((key) => content[key]);
49
- }
50
-
51
- function letterCount(text: string): number {
52
- return text.match(LETTERS)?.length ?? 0;
53
- }
54
-
55
- function scriptCount(text: string, locale: ScriptLocale): number {
56
- return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
57
- }
58
-
59
- function scriptRatio(text: string, locale: ScriptLocale): number {
60
- const letters = letterCount(text);
61
- if (letters === 0) return 0;
62
- return scriptCount(text, locale) / letters;
63
- }
64
-
65
- describe('Native script density validation', () => {
66
- ALL_TOOLS.forEach((tool) => {
67
- describe(`Tool: ${tool.entry.id}`, () => {
68
- Object.keys(SCRIPT_RULES).forEach((locale) => {
69
- it(`${locale} keeps most translated text in its native script`, async () => {
70
- const typedLocale = locale as ScriptLocale;
71
- const loader = tool.entry.i18n[typedLocale];
72
- if (!loader) return;
73
-
74
- const content = await loader();
75
- const rule = SCRIPT_RULES[typedLocale];
76
- const text = normalizeText(translatableContent(content as Record<string, unknown>));
77
- const letters = letterCount(text);
78
- const matches = scriptCount(text, typedLocale);
79
- const ratio = scriptRatio(text, typedLocale);
80
-
81
- expect(
82
- ratio,
83
- [
84
- `Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
85
- `The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
86
- `Most translatable content should be written in ${rule.scriptName} script.`,
87
- 'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
88
- ].join(' '),
89
- ).toBeGreaterThanOrEqual(rule.minScriptRatio);
90
- });
91
- });
92
- });
93
- });
94
- });
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ type ScriptLocale = keyof typeof SCRIPT_RULES;
5
+
6
+ const SCRIPT_RULES = {
7
+ ja: {
8
+ language: 'Japanese',
9
+ scriptName: 'kana/kanji',
10
+ scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
11
+ minScriptRatio: 0.45,
12
+ },
13
+ ko: {
14
+ language: 'Korean',
15
+ scriptName: 'hangul',
16
+ scriptCharacters: /\p{Script=Hangul}/gu,
17
+ minScriptRatio: 0.55,
18
+ },
19
+ ru: {
20
+ language: 'Russian',
21
+ scriptName: 'cyrillic',
22
+ scriptCharacters: /\p{Script=Cyrillic}/gu,
23
+ minScriptRatio: 0.65,
24
+ },
25
+ zh: {
26
+ language: 'Chinese',
27
+ scriptName: 'han',
28
+ scriptCharacters: /\p{Script=Han}/gu,
29
+ minScriptRatio: 0.45,
30
+ },
31
+ } as const;
32
+
33
+ const LETTERS = /\p{L}/gu;
34
+ const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
35
+
36
+ function collectStrings(value: unknown): string[] {
37
+ if (typeof value === 'string') return [value];
38
+ if (!value || typeof value !== 'object') return [];
39
+ if (Array.isArray(value)) return value.flatMap(collectStrings);
40
+ return Object.values(value).flatMap(collectStrings);
41
+ }
42
+
43
+ function normalizeText(value: unknown): string {
44
+ return collectStrings(value).join(' ').normalize('NFC');
45
+ }
46
+
47
+ function translatableContent(content: Record<string, unknown>) {
48
+ return TRANSLATABLE_KEYS.map((key) => content[key]);
49
+ }
50
+
51
+ function letterCount(text: string): number {
52
+ return text.match(LETTERS)?.length ?? 0;
53
+ }
54
+
55
+ function scriptCount(text: string, locale: ScriptLocale): number {
56
+ return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
57
+ }
58
+
59
+ function scriptRatio(text: string, locale: ScriptLocale): number {
60
+ const letters = letterCount(text);
61
+ if (letters === 0) return 0;
62
+ return scriptCount(text, locale) / letters;
63
+ }
64
+
65
+ describe('Native script density validation', () => {
66
+ ALL_TOOLS.forEach((tool) => {
67
+ describe(`Tool: ${tool.entry.id}`, () => {
68
+ Object.keys(SCRIPT_RULES).forEach((locale) => {
69
+ it(`${locale} keeps most translated text in its native script`, async () => {
70
+ const typedLocale = locale as ScriptLocale;
71
+ const loader = tool.entry.i18n[typedLocale];
72
+ if (!loader) return;
73
+
74
+ const content = await loader();
75
+ const rule = SCRIPT_RULES[typedLocale];
76
+ const text = normalizeText(translatableContent(content as unknown as Record<string, unknown>));
77
+ const letters = letterCount(text);
78
+ const matches = scriptCount(text, typedLocale);
79
+ const ratio = scriptRatio(text, typedLocale);
80
+
81
+ expect(
82
+ ratio,
83
+ [
84
+ `Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
85
+ `The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
86
+ `Most translatable content should be written in ${rule.scriptName} script.`,
87
+ 'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
88
+ ].join(' '),
89
+ ).toBeGreaterThanOrEqual(rule.minScriptRatio);
90
+ });
91
+ });
92
+ });
93
+ });
94
+ });
@@ -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 19 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(19);
7
+ it('should have 20 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(20);
9
9
  });
10
10
 
11
11
  it('financeCategory should be defined', () => {
@@ -106,8 +106,8 @@ describe('Locales must not copy another locale wholesale', () => {
106
106
 
107
107
  for (let leftIndex = 0; leftIndex < locales.length; leftIndex += 1) {
108
108
  for (let rightIndex = leftIndex + 1; rightIndex < locales.length; rightIndex += 1) {
109
- const left = locales[leftIndex];
110
- const right = locales[rightIndex];
109
+ const left = locales[leftIndex]!;
110
+ const right = locales[rightIndex]!;
111
111
  const similarity = copySimilarity(corpora.get(left) ?? '', corpora.get(right) ?? '');
112
112
 
113
113
  if (similarity >= COPY_THRESHOLD) {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { compoundInterest } from './entry';
2
3
  export * from './entry';
3
4
  export const COMPOUND_INTEREST_TOOL: ToolDefinition = {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { courtFeeCalculator } from './entry';
2
3
  export * from './entry';
3
4
  export const COURT_FEE_CALCULATOR_TOOL: ToolDefinition = {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { debtSnowball } from './entry';
2
3
  import { DebtSnowballLogic } from './logic';
3
4
  export * from './entry';
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { fireCalculator } from './entry';
2
3
  export * from './entry';
3
4
  export const FIRE_CALCULATOR_TOOL: ToolDefinition = {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { ibanBicSwift } from './entry';
2
3
  export * from './entry';
3
4
  export const IBAN_BIC_SWIFT_TOOL: ToolDefinition = {
@@ -1,3 +1,4 @@
1
+ import type { ToolDefinition } from '../../types';
1
2
  import { inflation } from './entry';
2
3
  export * from './entry';
3
4
  export const INFLATION_TOOL: ToolDefinition = {
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { inflationPurchasingPower } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const content = await inflationPurchasingPower.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Banco de España, ¿Qué es la inflación?',
6
+ url: 'https://www.bde.es/wbe/es/areas-actuacion/politica-monetaria/preguntas-frecuentes/politica-monetaria-y-estabilidad-precios/que-es-la-inflacion.html',
7
+ },
8
+ {
9
+ name: 'Bank of Canada, Inflation Calculator',
10
+ url: 'https://www.bankofcanada.ca/rates/related/inflation-calculator/',
11
+ },
12
+ ];
@@ -0,0 +1,79 @@
1
+ ---
2
+ import type { InflationPurchasingPowerUI } from './ui';
3
+ import type { InflationPurchasingPowerInput } from './logic';
4
+ import { calculateInflationTimeline } from './logic';
5
+ import { renderTimelineResults, renderTimelineScene } from './dom-views';
6
+ import './inflation-purchasing-power-timeline.css';
7
+
8
+ interface Props {
9
+ ui: InflationPurchasingPowerUI;
10
+ locale?: string;
11
+ }
12
+
13
+ const { ui } = Astro.props;
14
+ const initial: InflationPurchasingPowerInput = {
15
+ startingAmount: 1000,
16
+ annualInflationRate: 2.5,
17
+ years: 20,
18
+ annualCashFlow: 0,
19
+ cashFlowMode: 'none',
20
+ };
21
+ const initialResult = calculateInflationTimeline(initial);
22
+ const config = JSON.stringify({ ui, initial });
23
+ ---
24
+
25
+ <div class="inflation-purchasing-power-tool" data-ipp-tool>
26
+ <form class="ipp-controls" aria-label="Inflation purchasing power inputs">
27
+ <div class="ipp-field">
28
+ <label for="ipp-amount">{ui.amountLabel}</label>
29
+ <input id="ipp-amount" data-ipp-input="amount" type="number" min="0.01" step="0.01" value={initial.startingAmount} inputmode="decimal" />
30
+ <small>{ui.amountHelp}</small>
31
+ </div>
32
+ <div class="ipp-field">
33
+ <label for="ipp-inflation">{ui.inflationLabel}</label>
34
+ <input id="ipp-inflation" data-ipp-input="inflation" type="number" min="-99.99" max="100" step="0.1" value={initial.annualInflationRate} inputmode="decimal" />
35
+ <small>{ui.inflationHelp}</small>
36
+ </div>
37
+ <div class="ipp-field">
38
+ <label for="ipp-years">{ui.yearsLabel}</label>
39
+ <input id="ipp-years" data-ipp-input="years" type="number" min="1" max="80" step="1" value={initial.years} inputmode="numeric" />
40
+ <small>{ui.yearsHelp}</small>
41
+ </div>
42
+ <div class="ipp-field">
43
+ <label for="ipp-cash-flow">{ui.cashFlowLabel}</label>
44
+ <input id="ipp-cash-flow" data-ipp-input="cash-flow" type="number" min="0" step="0.01" value={initial.annualCashFlow} inputmode="decimal" disabled />
45
+ <small>{ui.cashFlowHelp}</small>
46
+ </div>
47
+ <fieldset class="ipp-flow">
48
+ <legend>{ui.cashFlowModeLabel}</legend>
49
+ <div class="ipp-flow-options">
50
+ <button type="button" data-ipp-mode="none" class="is-active" aria-pressed="true">{ui.noCashFlowOption}</button>
51
+ <button type="button" data-ipp-mode="contribution" aria-pressed="false">{ui.contributionOption}</button>
52
+ <button type="button" data-ipp-mode="withdrawal" aria-pressed="false">{ui.withdrawalOption}</button>
53
+ </div>
54
+ </fieldset>
55
+ </form>
56
+
57
+ <section class="ipp-output" aria-live="polite">
58
+ <div class="ipp-scene-wrap" data-ipp-scene set:html={initialResult ? renderTimelineScene(initialResult, ui) : ''}></div>
59
+ <div class="ipp-result-wrap" data-ipp-results set:html={initialResult ? renderTimelineResults(initialResult, ui) : ''}></div>
60
+ <p class="ipp-error" data-ipp-error hidden>{ui.emptyMessage}</p>
61
+ </section>
62
+
63
+ <details class="ipp-method">
64
+ <summary>{ui.methodTitle}</summary>
65
+ <p>{ui.methodText}</p>
66
+ <p>{ui.limitationText}</p>
67
+ </details>
68
+ </div>
69
+
70
+ <script is:inline type="application/json" data-ipp-config set:html={config}></script>
71
+ <script>
72
+ import { initInflationPurchasingPower } from './controller';
73
+
74
+ const root = document.querySelector<HTMLElement>('[data-ipp-tool]');
75
+ const configElement = document.querySelector<HTMLScriptElement>('[data-ipp-config]');
76
+ if (root && configElement?.textContent) {
77
+ initInflationPurchasingPower(root, JSON.parse(configElement.textContent));
78
+ }
79
+ </script>
@@ -0,0 +1,82 @@
1
+ import { calculateInflationTimeline, type CashFlowMode, type InflationPurchasingPowerInput } from './logic';
2
+ import { renderTimelineResults, renderTimelineScene } from './dom-views';
3
+ import { loadInflationPurchasingPowerState, saveInflationPurchasingPowerState } from './storage';
4
+ import type { InflationPurchasingPowerUI } from './ui';
5
+
6
+ interface Config {
7
+ ui: InflationPurchasingPowerUI;
8
+ initial: InflationPurchasingPowerInput;
9
+ }
10
+
11
+ function readNumber(root: HTMLElement, name: string): number {
12
+ const field = root.querySelector<HTMLInputElement>(`[data-ipp-input="${name}"]`);
13
+ return field?.value === '' ? Number.NaN : Number(field?.value);
14
+ }
15
+
16
+ function collectState(root: HTMLElement): InflationPurchasingPowerInput {
17
+ const activeMode = root.querySelector<HTMLButtonElement>('[data-ipp-mode].is-active')?.dataset.ippMode;
18
+ return {
19
+ startingAmount: readNumber(root, 'amount'),
20
+ annualInflationRate: readNumber(root, 'inflation'),
21
+ years: readNumber(root, 'years'),
22
+ annualCashFlow: readNumber(root, 'cash-flow'),
23
+ cashFlowMode: activeMode === 'contribution' || activeMode === 'withdrawal' ? activeMode : 'none',
24
+ };
25
+ }
26
+
27
+ function setMode(root: HTMLElement, mode: CashFlowMode): void {
28
+ root.querySelectorAll<HTMLButtonElement>('[data-ipp-mode]').forEach((button) => {
29
+ const isActive = button.dataset.ippMode === mode;
30
+ button.classList.toggle('is-active', isActive);
31
+ button.setAttribute('aria-pressed', String(isActive));
32
+ });
33
+ const cashFlowField = root.querySelector<HTMLInputElement>('[data-ipp-input="cash-flow"]');
34
+ if (cashFlowField) cashFlowField.disabled = mode === 'none';
35
+ }
36
+
37
+ function setInputValues(root: HTMLElement, state: InflationPurchasingPowerInput): void {
38
+ const values: Record<string, number> = { amount: state.startingAmount, inflation: state.annualInflationRate, years: state.years, 'cash-flow': state.annualCashFlow };
39
+ Object.entries(values).forEach(([name, value]) => {
40
+ const field = root.querySelector<HTMLInputElement>(`[data-ipp-input="${name}"]`);
41
+ if (field) field.value = String(value);
42
+ });
43
+ setMode(root, state.cashFlowMode);
44
+ }
45
+
46
+ function render(root: HTMLElement, state: InflationPurchasingPowerInput, ui: InflationPurchasingPowerUI): void {
47
+ const result = calculateInflationTimeline(state);
48
+ const scene = root.querySelector<HTMLElement>('[data-ipp-scene]');
49
+ const results = root.querySelector<HTMLElement>('[data-ipp-results]');
50
+ const error = root.querySelector<HTMLElement>('[data-ipp-error]');
51
+ if (!scene || !results || !error) return;
52
+ if (!result) {
53
+ scene.innerHTML = '';
54
+ results.innerHTML = '';
55
+ error.hidden = false;
56
+ return;
57
+ }
58
+ error.hidden = true;
59
+ scene.innerHTML = renderTimelineScene(result, ui);
60
+ results.innerHTML = renderTimelineResults(result, ui);
61
+ }
62
+
63
+ export function initInflationPurchasingPower(root: HTMLElement, config: Config): void {
64
+ const initialState = loadInflationPurchasingPowerState(config.initial);
65
+ setInputValues(root, initialState);
66
+ render(root, initialState, config.ui);
67
+ root.addEventListener('input', () => {
68
+ const state = collectState(root);
69
+ if (calculateInflationTimeline(state)) saveInflationPurchasingPowerState(state);
70
+ render(root, state, config.ui);
71
+ });
72
+ root.addEventListener('click', (event) => {
73
+ const target = event.target as HTMLElement;
74
+ const button = target.closest<HTMLButtonElement>('[data-ipp-mode]');
75
+ const mode = button?.dataset.ippMode as CashFlowMode | undefined;
76
+ if (!button || !mode) return;
77
+ setMode(root, mode);
78
+ const state = collectState(root);
79
+ saveInflationPurchasingPowerState(state);
80
+ render(root, state, config.ui);
81
+ });
82
+ }
@@ -0,0 +1,99 @@
1
+ import type { InflationPurchasingPowerResult, InflationTimelinePoint } from './logic';
2
+ import { evaluateInflationTimeline } from './evaluator';
3
+ import type { InflationPurchasingPowerUI } from './ui';
4
+
5
+ const numberFormat = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
6
+
7
+ export function formatAmount(value: number): string {
8
+ return numberFormat.format(value);
9
+ }
10
+
11
+ function escapeHtml(value: string): string {
12
+ return value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] ?? character);
13
+ }
14
+
15
+ function getChartBounds(result: InflationPurchasingPowerResult): { min: number; max: number } {
16
+ const values = result.points.flatMap((point) => [point.costEquivalent, point.unchangedRealValue, point.realBalance]);
17
+ const max = Math.max(...values, result.input.startingAmount) * 1.08;
18
+ const min = Math.max(0, Math.min(...values) * 0.92);
19
+ return { min, max: max === min ? min + 1 : max };
20
+ }
21
+
22
+ function createPath(points: InflationTimelinePoint[], value: (point: InflationTimelinePoint) => number, bounds: { min: number; max: number }): string {
23
+ const chartWidth = 560;
24
+ const chartHeight = 220;
25
+ return points.map((point, index) => {
26
+ const x = 70 + (index / Math.max(points.length - 1, 1)) * chartWidth;
27
+ const y = 275 - ((value(point) - bounds.min) / (bounds.max - bounds.min)) * chartHeight;
28
+ return `${index === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${Math.max(40, Math.min(275, y)).toFixed(1)}`;
29
+ }).join(' ');
30
+ }
31
+
32
+ function renderLegend(label: string, className: string): string {
33
+ return `<span class="ipp-legend-item"><i class="ipp-legend-line ${className}"></i>${escapeHtml(label)}</span>`;
34
+ }
35
+
36
+ function getEndY(value: number, bounds: { min: number; max: number }): string {
37
+ return Math.max(40, Math.min(275, 275 - ((value - bounds.min) / (bounds.max - bounds.min)) * 220)).toFixed(1);
38
+ }
39
+
40
+ function renderChart(result: InflationPurchasingPowerResult, ui: InflationPurchasingPowerUI): string {
41
+ const bounds = getChartBounds(result);
42
+ const costPath = createPath(result.points, (point) => point.costEquivalent, bounds);
43
+ const realPath = createPath(result.points, (point) => point.unchangedRealValue, bounds);
44
+ const balancePath = createPath(result.points, (point) => point.realBalance, bounds);
45
+ const end = result.points.at(-1);
46
+ if (!end) return '';
47
+ return `<svg class="ipp-chart" viewBox="0 0 700 340" role="img" aria-label="${escapeHtml(ui.chartDescription)}">
48
+ <line class="ipp-grid-line" x1="70" y1="95" x2="630" y2="95" />
49
+ <line class="ipp-grid-line" x1="70" y1="185" x2="630" y2="185" />
50
+ <line class="ipp-axis" x1="70" y1="275" x2="630" y2="275" />
51
+ <path class="ipp-line ipp-line-cost" d="${costPath}" />
52
+ <path class="ipp-line ipp-line-real" d="${realPath}" />
53
+ <path class="ipp-line ipp-line-balance" d="${balancePath}" />
54
+ <circle class="ipp-end-dot ipp-dot-cost" cx="630" cy="${getEndY(end.costEquivalent, bounds)}" r="6" />
55
+ <circle class="ipp-end-dot ipp-dot-real" cx="630" cy="${getEndY(end.unchangedRealValue, bounds)}" r="6" />
56
+ <text class="ipp-axis-label" x="70" y="310">${escapeHtml(ui.todayLabel)}</text>
57
+ <text class="ipp-axis-label" x="630" y="310" text-anchor="end">${escapeHtml(ui.endLabel)}</text>
58
+ <text class="ipp-chart-caption" x="70" y="28">${escapeHtml(ui.chartNote)}</text>
59
+ </svg>`;
60
+ }
61
+
62
+ export function renderTimelineScene(result: InflationPurchasingPowerResult, ui: InflationPurchasingPowerUI): string {
63
+ const evaluation = evaluateInflationTimeline(result, ui);
64
+ return `<div class="ipp-scene" data-ipp-tone="${evaluation.tone}">
65
+ <div class="ipp-scene-heading">
66
+ <span>${escapeHtml(ui.todayLabel)}</span>
67
+ <span class="ipp-scene-rule"></span>
68
+ <span>${escapeHtml(ui.endLabel)}</span>
69
+ </div>
70
+ ${renderChart(result, ui)}
71
+ <div class="ipp-legend" aria-label="${escapeHtml(ui.chartDescription)}">
72
+ ${renderLegend(ui.costLineLabel, 'ipp-legend-cost')}
73
+ ${renderLegend(ui.realLineLabel, 'ipp-legend-real')}
74
+ ${renderLegend(ui.balanceLineLabel, 'ipp-legend-balance')}
75
+ </div>
76
+ </div>`;
77
+ }
78
+
79
+ function renderMetric(label: string, value: string, className = ''): string {
80
+ return `<div class="ipp-metric ${className}"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
81
+ }
82
+
83
+ export function renderTimelineResults(result: InflationPurchasingPowerResult, ui: InflationPurchasingPowerUI): string {
84
+ const end = result.points.at(-1);
85
+ if (!end) return '';
86
+ const evaluation = evaluateInflationTimeline(result, ui);
87
+ const rows = result.milestones.map((point) => `<tr><th scope="row">${point.year}</th><td>${formatAmount(point.costEquivalent)}</td><td>${formatAmount(point.unchangedRealValue)}</td><td>${formatAmount(point.realBalance)}</td></tr>`).join('');
88
+ return `<div class="ipp-result-content">
89
+ <div class="ipp-primary-metric"><span>${escapeHtml(ui.costEquivalentLabel)}</span><strong>${formatAmount(end.costEquivalent)}</strong><small>${escapeHtml(ui.amountSuffix)}</small></div>
90
+ <div class="ipp-metrics">
91
+ ${renderMetric(ui.realValueLabel, formatAmount(end.unchangedRealValue))}
92
+ ${renderMetric(ui.nominalBalanceLabel, formatAmount(end.nominalBalance))}
93
+ ${renderMetric(ui.purchasingPowerLossLabel, `${result.purchasingPowerLossPercent.toFixed(1)}%`, 'ipp-metric-accent')}
94
+ </div>
95
+ <p class="ipp-scenario" data-ipp-tone="${evaluation.tone}">${escapeHtml(evaluation.message)}</p>
96
+ <h3>${escapeHtml(ui.milestoneTitle)}</h3>
97
+ <div class="ipp-table-wrap"><table><thead><tr><th scope="col">${escapeHtml(ui.yearHeader)}</th><th scope="col">${escapeHtml(ui.costHeader)}</th><th scope="col">${escapeHtml(ui.realHeader)}</th><th scope="col">${escapeHtml(ui.balanceHeader)}</th></tr></thead><tbody>${rows}</tbody></table></div>
98
+ </div>`;
99
+ }
@@ -0,0 +1,31 @@
1
+ import type { FinanceToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { InflationPurchasingPowerUI } from './ui';
3
+
4
+ export type InflationPurchasingPowerLocaleContent = ToolLocaleContent<InflationPurchasingPowerUI>;
5
+
6
+ export const inflationPurchasingPower: FinanceToolEntry<InflationPurchasingPowerUI> = {
7
+ id: 'inflation-purchasing-power-timeline',
8
+ icons: {
9
+ bg: 'mdi:chart-timeline-variant-shimmer',
10
+ fg: 'mdi:cash-sync',
11
+ },
12
+ i18n: {
13
+ en: () => import('./i18n/en').then((m) => m.content),
14
+ de: () => import('./i18n/de').then((m) => m.content),
15
+ es: () => import('./i18n/es').then((m) => m.content),
16
+ fr: () => import('./i18n/fr').then((m) => m.content),
17
+ id: () => import('./i18n/id').then((m) => m.content),
18
+ it: () => import('./i18n/it').then((m) => m.content),
19
+ ja: () => import('./i18n/ja').then((m) => m.content),
20
+ ko: () => import('./i18n/ko').then((m) => m.content),
21
+ nl: () => import('./i18n/nl').then((m) => m.content),
22
+ pl: () => import('./i18n/pl').then((m) => m.content),
23
+ pt: () => import('./i18n/pt').then((m) => m.content),
24
+ ru: () => import('./i18n/ru').then((m) => m.content),
25
+ sv: () => import('./i18n/sv').then((m) => m.content),
26
+ tr: () => import('./i18n/tr').then((m) => m.content),
27
+ zh: () => import('./i18n/zh').then((m) => m.content),
28
+ },
29
+ };
30
+
31
+ export { bibliography } from './bibliography';