@jjlmoya/utils-travel 1.19.0 → 1.20.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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +4 -12
  3. package/src/index.ts +1 -0
  4. package/src/pages/[locale]/[slug].astro +4 -2
  5. package/src/tests/locale_completeness.test.ts +2 -4
  6. package/src/tests/seo_translation_completeness.test.ts +69 -0
  7. package/src/tests/tool_validation.test.ts +2 -3
  8. package/src/tool/fuel-cost-calculator/bibliography.astro +6 -0
  9. package/src/tool/fuel-cost-calculator/bibliography.ts +14 -0
  10. package/src/tool/fuel-cost-calculator/component.astro +151 -0
  11. package/src/tool/fuel-cost-calculator/controller.ts +173 -0
  12. package/src/tool/fuel-cost-calculator/dom-views.ts +91 -0
  13. package/src/tool/fuel-cost-calculator/entry.ts +32 -0
  14. package/src/tool/fuel-cost-calculator/evaluator.ts +14 -0
  15. package/src/tool/fuel-cost-calculator/fuel-cost-calculator.css +407 -0
  16. package/src/tool/fuel-cost-calculator/i18n/de.ts +206 -0
  17. package/src/tool/fuel-cost-calculator/i18n/en.ts +120 -0
  18. package/src/tool/fuel-cost-calculator/i18n/es.ts +206 -0
  19. package/src/tool/fuel-cost-calculator/i18n/fr.ts +206 -0
  20. package/src/tool/fuel-cost-calculator/i18n/id.ts +206 -0
  21. package/src/tool/fuel-cost-calculator/i18n/it.ts +206 -0
  22. package/src/tool/fuel-cost-calculator/i18n/ja.ts +206 -0
  23. package/src/tool/fuel-cost-calculator/i18n/ko.ts +206 -0
  24. package/src/tool/fuel-cost-calculator/i18n/nl.ts +206 -0
  25. package/src/tool/fuel-cost-calculator/i18n/pl.ts +206 -0
  26. package/src/tool/fuel-cost-calculator/i18n/pt.ts +206 -0
  27. package/src/tool/fuel-cost-calculator/i18n/ru.ts +206 -0
  28. package/src/tool/fuel-cost-calculator/i18n/sv.ts +206 -0
  29. package/src/tool/fuel-cost-calculator/i18n/tr.ts +206 -0
  30. package/src/tool/fuel-cost-calculator/i18n/zh.ts +206 -0
  31. package/src/tool/fuel-cost-calculator/index.ts +11 -0
  32. package/src/tool/fuel-cost-calculator/logic.test.ts +60 -0
  33. package/src/tool/fuel-cost-calculator/logic.ts +54 -0
  34. package/src/tool/fuel-cost-calculator/seo.astro +14 -0
  35. package/src/tool/fuel-cost-calculator/storage.ts +30 -0
  36. package/src/tool/fuel-cost-calculator/ui.ts +45 -0
  37. package/src/tool/trip-expense-splitter/bibliography.astro +6 -0
  38. package/src/tool/trip-expense-splitter/bibliography.ts +8 -0
  39. package/src/tool/trip-expense-splitter/component.astro +41 -0
  40. package/src/tool/trip-expense-splitter/controller.ts +173 -0
  41. package/src/tool/trip-expense-splitter/dom-views.ts +72 -0
  42. package/src/tool/trip-expense-splitter/entry.ts +29 -0
  43. package/src/tool/trip-expense-splitter/evaluator.ts +8 -0
  44. package/src/tool/trip-expense-splitter/i18n/de.ts +204 -0
  45. package/src/tool/trip-expense-splitter/i18n/en.ts +41 -0
  46. package/src/tool/trip-expense-splitter/i18n/es.ts +204 -0
  47. package/src/tool/trip-expense-splitter/i18n/fr.ts +204 -0
  48. package/src/tool/trip-expense-splitter/i18n/id.ts +204 -0
  49. package/src/tool/trip-expense-splitter/i18n/it.ts +204 -0
  50. package/src/tool/trip-expense-splitter/i18n/ja.ts +208 -0
  51. package/src/tool/trip-expense-splitter/i18n/ko.ts +208 -0
  52. package/src/tool/trip-expense-splitter/i18n/nl.ts +204 -0
  53. package/src/tool/trip-expense-splitter/i18n/pl.ts +204 -0
  54. package/src/tool/trip-expense-splitter/i18n/pt.ts +204 -0
  55. package/src/tool/trip-expense-splitter/i18n/ru.ts +204 -0
  56. package/src/tool/trip-expense-splitter/i18n/sv.ts +204 -0
  57. package/src/tool/trip-expense-splitter/i18n/tr.ts +208 -0
  58. package/src/tool/trip-expense-splitter/i18n/zh.ts +208 -0
  59. package/src/tool/trip-expense-splitter/index.ts +11 -0
  60. package/src/tool/trip-expense-splitter/logic.test.ts +51 -0
  61. package/src/tool/trip-expense-splitter/logic.ts +117 -0
  62. package/src/tool/trip-expense-splitter/seo.astro +6 -0
  63. package/src/tool/trip-expense-splitter/storage.ts +24 -0
  64. package/src/tool/trip-expense-splitter/trip-expense-splitter.css +628 -0
  65. package/src/tool/trip-expense-splitter/ui.ts +43 -0
  66. package/src/tools.ts +21 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-travel",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/entries.ts CHANGED
@@ -1,17 +1,9 @@
1
- export { luggageCalculator } from './tool/luggage-calculator/entry';
2
- export type { Airline, LuggageCalculatorUI, LuggageCalculatorLocaleContent } from './tool/luggage-calculator/entry';
3
- export { miniAdventures } from './tool/mini-adventures/entry';
4
- export type { AdventureCategory, Adventure, Achievement, MiniAdventuresUI } from './tool/mini-adventures/entry';
5
- export { suitcaseChecklist } from './tool/suitcase-checklist/entry';
6
- export type { ChecklistItem, ChecklistCategory, SuitcaseChecklistUI } from './tool/suitcase-checklist/entry';
7
- export { tipCalculator } from './tool/tip-calculator/entry';
8
- export type { TipCountry, TipCalculatorUI } from './tool/tip-calculator/entry';
9
- export { schengenCalculator } from './tool/schengen-calculator/entry';
10
- export type { SchengenCalculatorUI } from './tool/schengen-calculator/entry';
11
- export { travelCategory } from './category';
12
1
  import { luggageCalculator } from './tool/luggage-calculator/entry';
13
2
  import { miniAdventures } from './tool/mini-adventures/entry';
14
3
  import { suitcaseChecklist } from './tool/suitcase-checklist/entry';
15
4
  import { tipCalculator } from './tool/tip-calculator/entry';
16
5
  import { schengenCalculator } from './tool/schengen-calculator/entry';
17
- export const ALL_ENTRIES = [luggageCalculator, miniAdventures, suitcaseChecklist, tipCalculator, schengenCalculator];
6
+ import { fuelCostCalculator } from './tool/fuel-cost-calculator/entry';
7
+ import { tripExpenseSplitter } from './tool/trip-expense-splitter/entry';
8
+
9
+ export const ALL_ENTRIES = [luggageCalculator, miniAdventures, suitcaseChecklist, tipCalculator, schengenCalculator, fuelCostCalculator, tripExpenseSplitter];
package/src/index.ts CHANGED
@@ -21,3 +21,4 @@ export { tipCalculator, TIP_CALCULATOR_TOOL } from './tool/tip-calculator';
21
21
  export { suitcaseChecklist, SUITCASE_CHECKLIST_TOOL } from './tool/suitcase-checklist';
22
22
  export { miniAdventures, MINI_ADVENTURES_TOOL } from './tool/mini-adventures';
23
23
  export { schengenCalculator, SCHENGEN_CALCULATOR_TOOL } from './tool/schengen-calculator';
24
+ export { fuelCostCalculator, FUEL_COST_CALCULATOR_TOOL } from './tool/fuel-cost-calculator';
@@ -71,7 +71,7 @@ interface NavItem {
71
71
  }
72
72
 
73
73
  interface Props {
74
- Component: unknown;
74
+ Component: ToolComponent;
75
75
  locale: KnownLocale;
76
76
  content: ToolLocaleContent;
77
77
  localeUrls: Partial<Record<KnownLocale, string>>;
@@ -79,7 +79,9 @@ interface Props {
79
79
  englishSlug: string;
80
80
  }
81
81
 
82
- const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props;
82
+ type ToolComponent = (props: { ui: Record<string, unknown> }) => unknown;
83
+
84
+ const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props as Props;
83
85
 
84
86
  // eslint-disable-next-line custom/no-css-comments
85
87
  const cssFiles = import.meta.glob("../../tool/**/*.css", { query: "?raw", import: "default" });
@@ -10,7 +10,6 @@ describe('Locale Completeness Validation', () => {
10
10
  it('should have mandatory properties', async () => {
11
11
  const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
12
12
  const content = (await loader?.()) as ToolLocaleContent;
13
-
14
13
  expect(content.slug).toBeTruthy();
15
14
  expect(typeof content.slug).toBe('string');
16
15
  expect(content.title).toBeTruthy();
@@ -23,8 +22,7 @@ describe('Locale Completeness Validation', () => {
23
22
  });
24
23
  });
25
24
 
26
- it('all 5 tools registered', () => {
27
- expect(ALL_TOOLS.length).toBe(5);
25
+ it('all 7 tools registered', () => {
26
+ expect(ALL_TOOLS.length).toBe(7);
28
27
  });
29
28
  });
30
-
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const ASIAN_LOCALES = ['ja', 'ko', 'zh'];
5
+
6
+ function getSimpleSectionLength(section: any): number {
7
+ if (section.type === 'title') return section.text ? section.text.length : 0;
8
+ if (section.type === 'paragraph') return section.html ? section.html.replace(/<[^>]*>/g, '').length : 0;
9
+ if (section.type === 'list') return section.items ? section.items.join('').length : 0;
10
+ return 0;
11
+ }
12
+
13
+ function getComplexSectionLength(section: any): number {
14
+ if (section.type === 'diagnostic' || section.type === 'tip') {
15
+ return (section.title ? section.title.length : 0) + (section.html ? section.html.replace(/<[^>]*>/g, '').length : 0);
16
+ }
17
+ if (section.type === 'table') {
18
+ return ((section.headers || []).join('').length) + ((section.rows || []).flat().join('').length);
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ function getSectionLength(section: any): number {
24
+ return getSimpleSectionLength(section) || getComplexSectionLength(section);
25
+ }
26
+
27
+ function calculateSeoTextLength(seoSections: any[]): number {
28
+ return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
29
+ }
30
+
31
+ function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
32
+ const isAsian = ASIAN_LOCALES.includes(locale);
33
+ const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
34
+ const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
35
+
36
+ expect(
37
+ localeLen,
38
+ `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`,
39
+ ).toBeGreaterThanOrEqual(minLength);
40
+ }
41
+
42
+ async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
43
+ if (locale === 'en' || !loader) return;
44
+ const content = await loader();
45
+ if (!content.seo || !Array.isArray(content.seo)) return;
46
+
47
+ checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
48
+ }
49
+
50
+ describe('SEO Translation Completeness & Laziness Audit', () => {
51
+ ALL_TOOLS.forEach(({ entry }) => {
52
+ describe(`Tool: ${entry.id}`, () => {
53
+ it('should not have lazy or truncated SEO content compared to English reference', async () => {
54
+ const enLoader = entry.i18n['en' as keyof typeof entry.i18n];
55
+ if (!enLoader) return;
56
+
57
+ const enContent = await enLoader();
58
+ if (!enContent.seo || !Array.isArray(enContent.seo)) return;
59
+
60
+ const enSeoLength = calculateSeoTextLength(enContent.seo);
61
+
62
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
63
+ await auditSingleLocale(entry.id, locale, loader, enSeoLength);
64
+ }
65
+ });
66
+ });
67
+ });
68
+ });
69
+
@@ -4,8 +4,8 @@ import { travelCategory } from '../category';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 5 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(5);
7
+ it('should have 7 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(7);
9
9
  });
10
10
 
11
11
  it('travelCategory should be defined', () => {
@@ -14,4 +14,3 @@ describe('Tool Validation Suite', () => {
14
14
  });
15
15
  });
16
16
  });
17
-
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliographyEntries } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliographyEntries} />
@@ -0,0 +1,14 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ {
5
+ name: 'U.S. Department of Energy - Fuel economy conversion guide',
6
+ url: 'https://www.fueleconomy.gov/feg/convert.shtml',
7
+ },
8
+ {
9
+ name: 'NIST - SI units and conversion factors',
10
+ url: 'https://www.nist.gov/pml/owm/metric-si/si-units',
11
+ },
12
+ ];
13
+
14
+ export const bibliography = bibliographyEntries;
@@ -0,0 +1,151 @@
1
+ ---
2
+ import type { FuelCostCalculatorUI } from './ui';
3
+ import './fuel-cost-calculator.css';
4
+
5
+ interface Props {
6
+ ui?: FuelCostCalculatorUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ const defaultUI: FuelCostCalculatorUI = {
11
+ metricLabel: 'Metric',
12
+ imperialLabel: 'Imperial',
13
+ routeHeading: 'Tune your trip',
14
+ distanceLabel: 'One-way distance',
15
+ distanceHint: 'How far is the destination?',
16
+ consumptionLabel: 'Vehicle consumption',
17
+ consumptionHint: 'Use the figure from your car',
18
+ priceLabel: 'Fuel price',
19
+ priceHint: 'Enter the current pump price',
20
+ passengersLabel: 'Passengers',
21
+ roundTripLabel: 'Round trip',
22
+ currencyLabel: 'Currency',
23
+ presetsLabel: 'Quick routes',
24
+ presetCommute: 'Daily run',
25
+ presetWeekend: 'Weekend escape',
26
+ presetRoadTrip: 'Road trip',
27
+ resultHeading: 'Trip fuel forecast',
28
+ totalDistanceLabel: 'Total distance',
29
+ fuelUsedLabel: 'Fuel used',
30
+ totalCostLabel: 'Total fuel cost',
31
+ perPersonLabel: 'Each traveler pays',
32
+ routeStatement: '{distance} {unit} there and back, shared by {passengers}',
33
+ routeOneWayStatement: '{distance} {unit} one way, shared by {passengers}',
34
+ statusLight: 'Light lift',
35
+ statusMedium: 'Worth sharing',
36
+ statusHigh: 'Big journey',
37
+ sampleButton: 'Load a sample',
38
+ resetButton: 'Reset',
39
+ litersUnit: 'L',
40
+ gallonsUnit: 'gal',
41
+ distanceMetricUnit: 'km',
42
+ distanceImperialUnit: 'mi',
43
+ consumptionMetricUnit: 'L/100 km',
44
+ consumptionImperialUnit: 'mpg',
45
+ priceMetricUnit: 'per litre',
46
+ priceImperialUnit: 'per gallon',
47
+ currencyOptions: [
48
+ { code: 'EUR', symbol: '€' },
49
+ { code: 'USD', symbol: '$' },
50
+ { code: 'GBP', symbol: '£' },
51
+ { code: 'JPY', symbol: '¥' },
52
+ ],
53
+ };
54
+
55
+ const content = { ...defaultUI, ...(ui ?? {}) };
56
+ const uiJson = JSON.stringify(content);
57
+ ---
58
+
59
+ <div class="fuel-cost-card" id="fc-app">
60
+ <div class="fc-stage">
61
+ <div class="fc-route-art" aria-hidden="true">
62
+ <svg viewBox="0 0 420 220" role="presentation">
63
+ <path class="fc-road-shadow" d="M-25 184 C80 70 150 228 250 115 S350 40 450 82" />
64
+ <path class="fc-road" d="M-25 184 C80 70 150 228 250 115 S350 40 450 82" />
65
+ <path id="fc-road-progress" class="fc-road-progress" pathLength="340" d="M-25 184 C80 70 150 228 250 115 S350 40 450 82" />
66
+ <circle class="fc-map-pin fc-pin-start" cx="38" cy="165" r="8" />
67
+ <circle class="fc-map-pin fc-pin-end" cx="378" cy="65" r="8" />
68
+ <circle class="fc-route-orb" cx="245" cy="118" r="4" />
69
+ </svg>
70
+ </div>
71
+ <div class="fc-stage-copy">
72
+ <span class="fc-kicker">{content.resultHeading}</span>
73
+ <span id="fc-cost-status" class="fc-cost-status" data-level="medium">{content.statusMedium}</span>
74
+ <strong id="fc-total-cost" class="fc-total-cost">€71.55</strong>
75
+ <p id="fc-route-statement" class="fc-route-statement">640 km there and back, shared by 2</p>
76
+ </div>
77
+ </div>
78
+
79
+ <div class="fc-result-strip">
80
+ <div><span>{content.totalDistanceLabel}</span><strong id="fc-total-distance">640 km</strong></div>
81
+ <div><span>{content.fuelUsedLabel}</span><strong id="fc-fuel-used">41.6 L</strong></div>
82
+ <div><span>{content.perPersonLabel}</span><strong id="fc-cost-person">€35.78</strong></div>
83
+ </div>
84
+
85
+ <div class="fc-controls">
86
+ <div class="fc-controls-head">
87
+ <span class="fc-section-label">{content.routeHeading}</span>
88
+ <div class="fc-unit-toggle" role="group" aria-label="Unit system">
89
+ <button type="button" data-fc-unit="metric">{content.metricLabel}</button>
90
+ <button type="button" data-fc-unit="imperial">{content.imperialLabel}</button>
91
+ </div>
92
+ </div>
93
+
94
+ <div class="fc-input-grid">
95
+ <label class="fc-field" for="fc-distance">
96
+ <span>{content.distanceLabel}</span>
97
+ <small>{content.distanceHint}</small>
98
+ <div class="fc-input-wrap"><input id="fc-distance" type="number" min="0" step="1" inputmode="decimal" /><b id="fc-distance-unit">km</b></div>
99
+ </label>
100
+ <label class="fc-field" for="fc-consumption">
101
+ <span>{content.consumptionLabel}</span>
102
+ <small>{content.consumptionHint}</small>
103
+ <div class="fc-input-wrap"><input id="fc-consumption" type="number" min="0" step="0.1" inputmode="decimal" /><b id="fc-consumption-unit">L/100 km</b></div>
104
+ </label>
105
+ <label class="fc-field" for="fc-price">
106
+ <span>{content.priceLabel}</span>
107
+ <small>{content.priceHint}</small>
108
+ <div class="fc-input-wrap"><input id="fc-price" type="number" min="0" step="0.01" inputmode="decimal" /><b id="fc-price-unit">per litre</b></div>
109
+ </label>
110
+ <label class="fc-field" for="fc-passengers">
111
+ <span>{content.passengersLabel}</span>
112
+ <small>Split the cost fairly</small>
113
+ <div class="fc-input-wrap"><input id="fc-passengers" type="number" min="1" max="99" step="1" inputmode="numeric" /><b>people</b></div>
114
+ </label>
115
+ </div>
116
+
117
+ <div class="fc-option-row">
118
+ <label class="fc-switch-label" for="fc-round-trip"><input id="fc-round-trip" type="checkbox" /><span class="fc-switch"></span><span>{content.roundTripLabel}</span></label>
119
+ <div class="fc-currency-group" role="group" aria-label={content.currencyLabel}>
120
+ {content.currencyOptions.map((option) => <button type="button" data-fc-currency={option.code}>{option.symbol} {option.code}</button>)}
121
+ </div>
122
+ </div>
123
+
124
+ <div class="fc-presets">
125
+ <span>{content.presetsLabel}</span>
126
+ <button type="button" data-fc-preset="18">{content.presetCommute}<b>18 km</b></button>
127
+ <button type="button" data-fc-preset="160">{content.presetWeekend}<b>160 km</b></button>
128
+ <button type="button" data-fc-preset="680">{content.presetRoadTrip}<b>680 km</b></button>
129
+ </div>
130
+
131
+ <div class="fc-actions"><button type="button" id="fc-sample" class="fc-action-secondary">{content.sampleButton}</button><button type="button" id="fc-reset" class="fc-action-primary">{content.resetButton}</button></div>
132
+ </div>
133
+ </div>
134
+
135
+ <script is:inline type="application/json" id="fc-i18n" set:html={uiJson}></script>
136
+ <script>
137
+ import { FuelCostController } from './controller';
138
+ import type { FuelCostCalculatorUI } from './ui';
139
+
140
+ function initFuelCostCalculator(): void {
141
+ const data = document.getElementById('fc-i18n')?.textContent;
142
+ if (!data) return;
143
+ try {
144
+ const ui = JSON.parse(data) as FuelCostCalculatorUI;
145
+ new FuelCostController(ui).init();
146
+ } catch {}
147
+ }
148
+
149
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initFuelCostCalculator);
150
+ else initFuelCostCalculator();
151
+ </script>
@@ -0,0 +1,173 @@
1
+ import {
2
+ calculateFuelCost,
3
+ kilometersToMiles,
4
+ litersPer100KmToMpg,
5
+ milesToKilometers,
6
+ mpgToLitersPer100Km,
7
+ type FuelInputs,
8
+ type FuelUnitSystem,
9
+ } from './logic';
10
+ import { loadFuelState, saveFuelState, type FuelStorageState } from './storage';
11
+ import { renderResult, syncUnitLabels } from './dom-views';
12
+ import type { FuelCostCalculatorUI } from './ui';
13
+
14
+ const DEFAULT_STATE: FuelStorageState = {
15
+ distanceKm: 320,
16
+ consumptionLPer100Km: 6.5,
17
+ pricePerLiter: 1.72,
18
+ passengers: 2,
19
+ roundTrip: true,
20
+ currency: 'EUR',
21
+ unitSystem: 'metric',
22
+ };
23
+
24
+ function numberValue(id: string, fallback: number): number {
25
+ const input = document.getElementById(id) as HTMLInputElement | null;
26
+ const value = Number(input?.value);
27
+ return Number.isFinite(value) ? value : fallback;
28
+ }
29
+
30
+ export class FuelCostController {
31
+ private state: FuelStorageState;
32
+ private readonly ui: FuelCostCalculatorUI;
33
+
34
+ constructor(ui: FuelCostCalculatorUI) {
35
+ this.ui = ui;
36
+ this.state = loadFuelState(DEFAULT_STATE);
37
+ }
38
+
39
+ public init(): void {
40
+ this.bindInputs();
41
+ this.bindUnitToggle();
42
+ this.bindCurrencyChips();
43
+ this.bindPresets();
44
+ this.bindActions();
45
+ this.syncInputs();
46
+ this.update();
47
+ }
48
+
49
+ private bindInputs(): void {
50
+ document.getElementById('fc-distance')?.addEventListener('input', () => this.readDistance());
51
+ document.getElementById('fc-consumption')?.addEventListener('input', () => this.readConsumption());
52
+ document.getElementById('fc-price')?.addEventListener('input', () => {
53
+ const price = numberValue('fc-price', this.displayedPrice());
54
+ this.state.pricePerLiter = this.state.unitSystem === 'metric' ? price : price / 3.785411784;
55
+ this.update();
56
+ });
57
+ document.getElementById('fc-passengers')?.addEventListener('input', () => {
58
+ this.state.passengers = Math.max(1, Math.round(numberValue('fc-passengers', this.state.passengers)));
59
+ this.update();
60
+ });
61
+ document.getElementById('fc-round-trip')?.addEventListener('change', (event) => {
62
+ this.state.roundTrip = (event.target as HTMLInputElement).checked;
63
+ this.update();
64
+ });
65
+ }
66
+
67
+ private bindUnitToggle(): void {
68
+ document.querySelectorAll<HTMLButtonElement>('[data-fc-unit]').forEach((button) => {
69
+ button.addEventListener('click', () => {
70
+ const next = button.dataset.fcUnit as FuelUnitSystem;
71
+ if (next === this.state.unitSystem) return;
72
+ this.state.unitSystem = next;
73
+ this.syncInputs();
74
+ this.updateUnitButtons();
75
+ this.update();
76
+ });
77
+ });
78
+ this.updateUnitButtons();
79
+ }
80
+
81
+ private bindCurrencyChips(): void {
82
+ document.querySelectorAll<HTMLButtonElement>('[data-fc-currency]').forEach((button) => {
83
+ button.addEventListener('click', () => {
84
+ this.state.currency = button.dataset.fcCurrency ?? 'EUR';
85
+ this.updateCurrencyButtons();
86
+ this.update();
87
+ });
88
+ });
89
+ this.updateCurrencyButtons();
90
+ }
91
+
92
+ private bindPresets(): void {
93
+ document.querySelectorAll<HTMLButtonElement>('[data-fc-preset]').forEach((button) => {
94
+ button.addEventListener('click', () => {
95
+ this.state.distanceKm = Number(button.dataset.fcPreset ?? this.state.distanceKm);
96
+ this.syncInputs();
97
+ this.update();
98
+ });
99
+ });
100
+ }
101
+
102
+ private bindActions(): void {
103
+ document.getElementById('fc-sample')?.addEventListener('click', () => {
104
+ this.state = { ...DEFAULT_STATE, distanceKm: 680, consumptionLPer100Km: 7.2, pricePerLiter: 1.89, passengers: 3 };
105
+ this.syncInputs();
106
+ this.updateCurrencyButtons();
107
+ this.update();
108
+ });
109
+ document.getElementById('fc-reset')?.addEventListener('click', () => {
110
+ this.state = { ...DEFAULT_STATE };
111
+ this.syncInputs();
112
+ this.updateCurrencyButtons();
113
+ this.update();
114
+ });
115
+ }
116
+
117
+ private readDistance(): void {
118
+ const value = numberValue('fc-distance', 0);
119
+ this.state.distanceKm = this.state.unitSystem === 'metric' ? value : milesToKilometers(value);
120
+ this.update();
121
+ }
122
+
123
+ private readConsumption(): void {
124
+ const value = numberValue('fc-consumption', 0);
125
+ this.state.consumptionLPer100Km = this.state.unitSystem === 'metric' ? value : mpgToLitersPer100Km(value);
126
+ this.update();
127
+ }
128
+
129
+ private syncInputs(): void {
130
+ const distance = this.state.unitSystem === 'metric' ? this.state.distanceKm : kilometersToMiles(this.state.distanceKm);
131
+ const consumption = this.state.unitSystem === 'metric'
132
+ ? this.state.consumptionLPer100Km
133
+ : litersPer100KmToMpg(this.state.consumptionLPer100Km);
134
+ this.setInputValue('fc-distance', distance, 0);
135
+ this.setInputValue('fc-consumption', consumption, 1);
136
+ this.setInputValue('fc-price', this.displayedPrice(), 2);
137
+ this.setInputValue('fc-passengers', this.state.passengers, 0);
138
+ const roundTrip = document.getElementById('fc-round-trip') as HTMLInputElement | null;
139
+ if (roundTrip) roundTrip.checked = this.state.roundTrip;
140
+ syncUnitLabels(this.state.unitSystem, this.ui);
141
+ }
142
+
143
+ private setInputValue(id: string, value: number, digits: number): void {
144
+ const input = document.getElementById(id) as HTMLInputElement | null;
145
+ if (input) input.value = value.toFixed(digits);
146
+ }
147
+
148
+ private displayedPrice(): number {
149
+ return this.state.unitSystem === 'metric' ? this.state.pricePerLiter : this.state.pricePerLiter * 3.785411784;
150
+ }
151
+
152
+ private updateUnitButtons(): void {
153
+ document.querySelectorAll<HTMLButtonElement>('[data-fc-unit]').forEach((button) => {
154
+ const active = button.dataset.fcUnit === this.state.unitSystem;
155
+ button.classList.toggle('is-active', active);
156
+ button.setAttribute('aria-pressed', String(active));
157
+ });
158
+ }
159
+
160
+ private updateCurrencyButtons(): void {
161
+ document.querySelectorAll<HTMLButtonElement>('[data-fc-currency]').forEach((button) => {
162
+ const active = button.dataset.fcCurrency === this.state.currency;
163
+ button.classList.toggle('is-active', active);
164
+ button.setAttribute('aria-pressed', String(active));
165
+ });
166
+ }
167
+
168
+ private update(): void {
169
+ const inputs: FuelInputs = { ...this.state };
170
+ renderResult(calculateFuelCost(inputs), this.state, this.ui);
171
+ saveFuelState(this.state);
172
+ }
173
+ }
@@ -0,0 +1,91 @@
1
+ import type { FuelResult, FuelUnitSystem } from './logic';
2
+ import { kilometersToMiles } from './logic';
3
+ import { evaluateCost } from './evaluator';
4
+ import type { FuelCostCalculatorUI } from './ui';
5
+
6
+ function getElement<T extends Element>(id: string): T | null {
7
+ return document.getElementById(id) as T | null;
8
+ }
9
+
10
+ function formatNumber(value: number, digits = 1): string {
11
+ return new Intl.NumberFormat('en', { maximumFractionDigits: digits }).format(value);
12
+ }
13
+
14
+ function formatMoney(value: number, symbol: string, code: string): string {
15
+ const digits = code === 'JPY' ? 0 : 2;
16
+ return `${symbol}${value.toFixed(digits)}`;
17
+ }
18
+
19
+ interface ResultView {
20
+ distance: string;
21
+ fuel: string;
22
+ total: string;
23
+ perPerson: string;
24
+ route: string;
25
+ status: string;
26
+ level: string;
27
+ progress: string;
28
+ }
29
+
30
+ type ResultState = { unitSystem: FuelUnitSystem; currency: string; roundTrip: boolean; passengers: number };
31
+
32
+ function buildResultView(result: FuelResult, state: ResultState, ui: FuelCostCalculatorUI): ResultView {
33
+ const currency = ui.currencyOptions.find((option) => option.code === state.currency) ?? ui.currencyOptions[0];
34
+ const metric = state.unitSystem === 'metric';
35
+ const distance = metric ? result.totalDistanceKm : kilometersToMiles(result.totalDistanceKm);
36
+ const fuel = metric ? result.fuelLiters : result.fuelGallons;
37
+ const distanceUnit = metric ? ui.distanceMetricUnit : ui.distanceImperialUnit;
38
+ const fuelUnit = metric ? ui.litersUnit : ui.gallonsUnit;
39
+ const status = evaluateCost(result.costPerPerson, ui);
40
+ const routeTemplate = state.roundTrip ? ui.routeStatement : ui.routeOneWayStatement;
41
+ const route = routeTemplate
42
+ .replace('{distance}', formatNumber(distance))
43
+ .replace('{unit}', distanceUnit)
44
+ .replace('{passengers}', String(Math.max(1, Math.round(state.passengers))));
45
+ return {
46
+ distance: `${formatNumber(distance)} ${distanceUnit}`,
47
+ fuel: `${formatNumber(fuel)} ${fuelUnit}`,
48
+ total: formatMoney(result.totalCost, currency.symbol, currency.code),
49
+ perPerson: formatMoney(result.costPerPerson, currency.symbol, currency.code),
50
+ route,
51
+ status: status.label,
52
+ level: status.level,
53
+ progress: String(Math.max(0, 340 - Math.min(1, result.totalDistanceKm / 1600) * 340)),
54
+ };
55
+ }
56
+
57
+ function setText(id: string, value: string): void {
58
+ const element = getElement<HTMLElement>(id);
59
+ if (element) element.textContent = value;
60
+ }
61
+
62
+ export function renderResult(
63
+ result: FuelResult,
64
+ state: ResultState,
65
+ ui: FuelCostCalculatorUI,
66
+ ): void {
67
+ const view = buildResultView(result, state, ui);
68
+ setText('fc-total-distance', view.distance);
69
+ setText('fc-fuel-used', view.fuel);
70
+ setText('fc-total-cost', view.total);
71
+ setText('fc-cost-person', view.perPerson);
72
+ setText('fc-route-statement', view.route);
73
+ const statusEl = getElement<HTMLSpanElement>('fc-cost-status');
74
+ if (statusEl) {
75
+ statusEl.textContent = view.status;
76
+ statusEl.dataset.level = view.level;
77
+ }
78
+ const road = getElement<SVGPathElement>('fc-road-progress');
79
+ if (road) road.style.strokeDashoffset = view.progress;
80
+ }
81
+
82
+ export function syncUnitLabels(unitSystem: FuelUnitSystem, ui: FuelCostCalculatorUI): void {
83
+ const distanceUnit = unitSystem === 'metric' ? ui.distanceMetricUnit : ui.distanceImperialUnit;
84
+ const consumptionUnit = unitSystem === 'metric' ? ui.consumptionMetricUnit : ui.consumptionImperialUnit;
85
+ const distanceHint = getElement<HTMLSpanElement>('fc-distance-unit');
86
+ const consumptionHint = getElement<HTMLSpanElement>('fc-consumption-unit');
87
+ const priceHint = getElement<HTMLSpanElement>('fc-price-unit');
88
+ if (distanceHint) distanceHint.textContent = distanceUnit;
89
+ if (consumptionHint) consumptionHint.textContent = consumptionUnit;
90
+ if (priceHint) priceHint.textContent = unitSystem === 'metric' ? ui.priceMetricUnit : ui.priceImperialUnit;
91
+ }
@@ -0,0 +1,32 @@
1
+ import type { TravelToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { FuelCostCalculatorUI } from './ui';
3
+
4
+ export type { FuelCostCalculatorUI };
5
+ export type FuelCostCalculatorLocaleContent = ToolLocaleContent<FuelCostCalculatorUI>;
6
+
7
+ export const fuelCostCalculator: TravelToolEntry<FuelCostCalculatorUI> = {
8
+ id: 'fuel-cost-calculator',
9
+ icons: {
10
+ bg: 'mdi:gas-station',
11
+ fg: 'mdi:road-variant',
12
+ },
13
+ i18n: {
14
+ de: () => import('./i18n/de').then((m) => m.content),
15
+ en: () => import('./i18n/en').then((m) => m.content),
16
+ es: () => import('./i18n/es').then((m) => m.content),
17
+ fr: () => import('./i18n/fr').then((m) => m.content),
18
+ id: () => import('./i18n/id').then((m) => m.content),
19
+ it: () => import('./i18n/it').then((m) => m.content),
20
+ ja: () => import('./i18n/ja').then((m) => m.content),
21
+ ko: () => import('./i18n/ko').then((m) => m.content),
22
+ nl: () => import('./i18n/nl').then((m) => m.content),
23
+ pl: () => import('./i18n/pl').then((m) => m.content),
24
+ pt: () => import('./i18n/pt').then((m) => m.content),
25
+ ru: () => import('./i18n/ru').then((m) => m.content),
26
+ sv: () => import('./i18n/sv').then((m) => m.content),
27
+ tr: () => import('./i18n/tr').then((m) => m.content),
28
+ zh: () => import('./i18n/zh').then((m) => m.content),
29
+ },
30
+ };
31
+
32
+ export { bibliography } from './bibliography';
@@ -0,0 +1,14 @@
1
+ import type { FuelCostCalculatorUI } from './ui';
2
+
3
+ export type CostLevel = 'light' | 'medium' | 'high';
4
+
5
+ export interface CostStatus {
6
+ level: CostLevel;
7
+ label: string;
8
+ }
9
+
10
+ export function evaluateCost(costPerPerson: number, ui: FuelCostCalculatorUI): CostStatus {
11
+ if (costPerPerson < 25) return { level: 'light', label: ui.statusLight };
12
+ if (costPerPerson < 75) return { level: 'medium', label: ui.statusMedium };
13
+ return { level: 'high', label: ui.statusHigh };
14
+ }