@jjlmoya/utils-motor 1.1.0 → 1.2.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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +5 -0
  5. package/src/tests/i18n_coverage.test.ts +5 -3
  6. package/src/tests/locale_completeness.test.ts +1 -1
  7. package/src/tests/tool_validation.test.ts +1 -2
  8. package/src/tool/realFuelConsumptionCalculator/bibliography.astro +6 -0
  9. package/src/tool/realFuelConsumptionCalculator/bibliography.ts +12 -0
  10. package/src/tool/realFuelConsumptionCalculator/component.astro +111 -0
  11. package/src/tool/realFuelConsumptionCalculator/controller.ts +175 -0
  12. package/src/tool/realFuelConsumptionCalculator/dom-views.ts +132 -0
  13. package/src/tool/realFuelConsumptionCalculator/entry.ts +30 -0
  14. package/src/tool/realFuelConsumptionCalculator/evaluator.ts +15 -0
  15. package/src/tool/realFuelConsumptionCalculator/i18n/de.ts +119 -0
  16. package/src/tool/realFuelConsumptionCalculator/i18n/en.ts +119 -0
  17. package/src/tool/realFuelConsumptionCalculator/i18n/es.ts +119 -0
  18. package/src/tool/realFuelConsumptionCalculator/i18n/factory.ts +110 -0
  19. package/src/tool/realFuelConsumptionCalculator/i18n/fr.ts +119 -0
  20. package/src/tool/realFuelConsumptionCalculator/i18n/id.ts +119 -0
  21. package/src/tool/realFuelConsumptionCalculator/i18n/it.ts +119 -0
  22. package/src/tool/realFuelConsumptionCalculator/i18n/ja.ts +119 -0
  23. package/src/tool/realFuelConsumptionCalculator/i18n/ko.ts +119 -0
  24. package/src/tool/realFuelConsumptionCalculator/i18n/nl.ts +119 -0
  25. package/src/tool/realFuelConsumptionCalculator/i18n/pl.ts +119 -0
  26. package/src/tool/realFuelConsumptionCalculator/i18n/pt.ts +119 -0
  27. package/src/tool/realFuelConsumptionCalculator/i18n/ru.ts +119 -0
  28. package/src/tool/realFuelConsumptionCalculator/i18n/sv.ts +119 -0
  29. package/src/tool/realFuelConsumptionCalculator/i18n/tr.ts +119 -0
  30. package/src/tool/realFuelConsumptionCalculator/i18n/zh.ts +119 -0
  31. package/src/tool/realFuelConsumptionCalculator/index.ts +10 -0
  32. package/src/tool/realFuelConsumptionCalculator/logic.test.ts +72 -0
  33. package/src/tool/realFuelConsumptionCalculator/logic.ts +142 -0
  34. package/src/tool/realFuelConsumptionCalculator/real-fuel-consumption-calculator.css +692 -0
  35. package/src/tool/realFuelConsumptionCalculator/seo.astro +15 -0
  36. package/src/tool/realFuelConsumptionCalculator/storage.ts +54 -0
  37. package/src/tool/realFuelConsumptionCalculator/ui.ts +72 -0
  38. package/src/tools.ts +2 -1
  39. package/src/tests/faq_count.test.ts +0 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-motor",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,9 +1,10 @@
1
1
  import type { MotorCategoryEntry, MotorToolEntry } from '../types';
2
2
  import { brakingDistanceCalculator } from '../tool/brakingDistanceCalculator/entry';
3
+ import { realFuelConsumptionCalculator } from '../tool/realFuelConsumptionCalculator/entry';
3
4
 
4
5
  export const motorCategory: MotorCategoryEntry = {
5
6
  icon: 'mdi:car-cog',
6
- tools: [brakingDistanceCalculator] as unknown as MotorToolEntry<Record<string, string>>[],
7
+ tools: [brakingDistanceCalculator, realFuelConsumptionCalculator] as unknown as MotorToolEntry<Record<string, string>>[],
7
8
  i18n: {
8
9
  es: () => import('./i18n/es').then((module) => module.content),
9
10
  en: () => import('./i18n/en').then((module) => module.content),
package/src/entries.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export { brakingDistanceCalculator } from './tool/brakingDistanceCalculator/entry';
2
2
  export type { BrakingDistanceCalculatorLocaleContent } from './tool/brakingDistanceCalculator/entry';
3
+ export { realFuelConsumptionCalculator } from './tool/realFuelConsumptionCalculator/entry';
4
+ export type { RealFuelConsumptionCalculatorLocaleContent } from './tool/realFuelConsumptionCalculator/entry';
3
5
  export { motorCategory } from './category';
4
6
 
5
7
  import { brakingDistanceCalculator } from './tool/brakingDistanceCalculator/entry';
8
+ import { realFuelConsumptionCalculator } from './tool/realFuelConsumptionCalculator/entry';
6
9
 
7
- export const ALL_ENTRIES = [brakingDistanceCalculator];
10
+ export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator];
package/src/index.ts CHANGED
@@ -21,3 +21,8 @@ export type {
21
21
  BrakingDistanceCalculatorUI,
22
22
  BrakingDistanceCalculatorLocaleContent,
23
23
  } from './tool/brakingDistanceCalculator';
24
+ export { REAL_FUEL_CONSUMPTION_CALCULATOR_TOOL, realFuelConsumptionCalculator } from './tool/realFuelConsumptionCalculator';
25
+ export type {
26
+ FuelLogUI,
27
+ RealFuelConsumptionCalculatorLocaleContent,
28
+ } from './tool/realFuelConsumptionCalculator';
@@ -12,9 +12,10 @@ describe('I18n Coverage Validation', () => {
12
12
 
13
13
  ALL_TOOLS.forEach(({ entry }: { entry: any }) => {
14
14
  describe(`Tool: ${entry.id}`, () => {
15
- it('should have all 15 required locales', () => {
15
+ it('should have English content during the English first phase', () => {
16
16
  const registeredLocales = Object.keys(entry.i18n);
17
- EXPECTED_LOCALES.forEach((locale) => {
17
+ const requiredLocales = registeredLocales.length === EXPECTED_LOCALES.length ? EXPECTED_LOCALES : ['en'];
18
+ requiredLocales.forEach((locale) => {
18
19
  expect(
19
20
  registeredLocales,
20
21
  `Tool "${entry.id}" is missing locale "${locale}"`,
@@ -23,7 +24,8 @@ describe('I18n Coverage Validation', () => {
23
24
  });
24
25
 
25
26
  it('all locale loaders should be functions', () => {
26
- EXPECTED_LOCALES.forEach((locale) => {
27
+ const requiredLocales = Object.keys(entry.i18n).length === EXPECTED_LOCALES.length ? EXPECTED_LOCALES : ['en'];
28
+ requiredLocales.forEach((locale) => {
27
29
  const loader = entry.i18n[locale as keyof typeof entry.i18n];
28
30
  expect(
29
31
  typeof loader,
@@ -4,7 +4,7 @@ import type { ToolLocaleContent } from '../types';
4
4
 
5
5
  describe('Locale Completeness and Slug Validation', () => {
6
6
  it('all tools registered in ALL_TOOLS', () => {
7
- expect(ALL_TOOLS.length).toBe(1);
7
+ expect(ALL_TOOLS.length).toBe(2);
8
8
  });
9
9
 
10
10
  ALL_TOOLS.forEach((tool) => {
@@ -5,7 +5,7 @@ import { motorCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools registered in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(1);
8
+ expect(ALL_TOOLS.length).toBe(2);
9
9
  });
10
10
 
11
11
  it('motorCategory 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,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ {
5
+ name: 'Precios de carburantes y recargas eléctricas',
6
+ url: 'https://www.miteco.gob.es/es/energia/servicios/consultas-de-carburantes.html',
7
+ },
8
+ {
9
+ name: 'U.S. EPA fuel economy and EV range testing',
10
+ url: 'https://www.epa.gov/greenvehicles/fuel-economy-and-ev-range-testing',
11
+ },
12
+ ];
@@ -0,0 +1,111 @@
1
+ ---
2
+ import './real-fuel-consumption-calculator.css';
3
+
4
+ interface Props {
5
+ ui: Record<string, string>;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ const currencies = [
10
+ { code: 'EUR', symbol: '€' }, { code: 'USD', symbol: '$' }, { code: 'IDR', symbol: 'Rp' }, { code: 'JPY', symbol: '¥' }, { code: 'KRW', symbol: '₩' },
11
+ { code: 'PLN', symbol: 'zł' }, { code: 'RUB', symbol: '₽' }, { code: 'SEK', symbol: 'kr' }, { code: 'TRY', symbol: '₺' }, { code: 'CNY', symbol: '¥' },
12
+ ];
13
+ const comparisonModes = [
14
+ { value: 'gasoline', label: ui.gasolineLabel },
15
+ { value: 'diesel', label: ui.dieselLabel },
16
+ { value: 'electric', label: ui.electricLabel },
17
+ ];
18
+ ---
19
+
20
+ <div class="rfc-shell" data-fuel-log-calculator data-rfc-mode="gasoline" data-rfc-currency="EUR" data-status="close">
21
+ <section class="rfc-console">
22
+ <header class="rfc-toolbar">
23
+ <div class="rfc-choice-group" role="group" aria-label={ui.fuelTypeLabel}>
24
+ <span>{ui.fuelTypeLabel}</span>
25
+ <button type="button" data-rfc-mode data-value="gasoline" aria-pressed="true">{ui.gasolineLabel}</button>
26
+ <button type="button" data-rfc-mode data-value="diesel" aria-pressed="false">{ui.dieselLabel}</button>
27
+ <button type="button" data-rfc-mode data-value="electric" aria-pressed="false">{ui.electricLabel}</button>
28
+ </div>
29
+ <div class="rfc-currency-picker">
30
+ <span>{ui.currencyLabel}</span>
31
+ <button type="button" class="rfc-currency-trigger" data-rfc-currency-trigger aria-expanded="false" aria-haspopup="listbox">
32
+ <span data-rfc-currency-current>€</span><span class="rfc-currency-chevron" aria-hidden="true"></span>
33
+ </button>
34
+ <small class="rfc-currency-rate" data-rfc-currency-rate>1 EUR = 1 EUR</small>
35
+ <div class="rfc-currency-menu" data-rfc-currency-menu role="listbox" hidden>
36
+ {currencies.map((currency) => <button type="button" data-rfc-currency-button data-value={currency.code} role="option" aria-selected={currency.code === 'EUR'}>{currency.code} {currency.symbol}</button>)}
37
+ </div>
38
+ </div>
39
+ <button type="button" class="rfc-reset" data-rfc-reset>{ui.resetLabel}</button>
40
+ </header>
41
+
42
+ <section class="rfc-intake">
43
+ <div class="rfc-section-heading">
44
+ <div><p class="rfc-eyebrow">{ui.measurementTitle}</p><h2>{ui.intakeQuestion}</h2></div>
45
+ </div>
46
+ <div class="rfc-main-fields">
47
+ <label class="rfc-field" for="rfc-distance-number">
48
+ <span>{ui.distanceLabel}</span><small>{ui.distanceHint}</small>
49
+ <strong><input id="rfc-distance-number" data-rfc-field="distance" data-rfc-number type="number" min="1" max="2000" step="1" value="100" /> <em>{ui.distanceUnit}</em></strong>
50
+ <input data-rfc-field="distance" data-rfc-range type="range" min="1" max="2000" step="1" value="100" aria-label={ui.distanceLabel} />
51
+ </label>
52
+ <label class="rfc-field" for="rfc-used-number">
53
+ <span data-rfc-used-label>{ui.gasolineUsedLabel}</span><small data-rfc-used-hint>{ui.gasolineUsedHint}</small>
54
+ <strong><input id="rfc-used-number" data-rfc-field="used" data-rfc-number type="number" min="0.1" max="160" step="0.1" value="6.5" /> <em data-rfc-used-unit>{ui.fuelUnit}</em></strong>
55
+ <input data-rfc-field="used" data-rfc-range type="range" min="0.1" max="160" step="0.1" value="6.5" aria-label={ui.usedLabel} />
56
+ </label>
57
+ <label class="rfc-field rfc-price-field" for="rfc-price-number">
58
+ <span data-rfc-price-label>{ui.gasolinePriceLabel}</span><small>{ui.priceHint}</small>
59
+ <strong><input id="rfc-price-number" data-rfc-field="price" data-rfc-number type="number" min="0" max="20" step="0.01" value="1.8" /> <em data-rfc-price-unit>€ / {ui.fuelUnit}</em></strong>
60
+ <input data-rfc-field="price" data-rfc-range type="range" min="0" max="20" step="0.01" value="1.8" aria-label={ui.priceLabel} />
61
+ </label>
62
+ </div>
63
+ </section>
64
+
65
+ <section class="rfc-results" aria-live="polite">
66
+ <div class="rfc-result-lead">
67
+ <div><p class="rfc-eyebrow">{ui.readingTitle}</p><strong data-rfc-real>6.5 L/100 km</strong><span data-rfc-mode-label>{ui.gasolineLabel}</span></div>
68
+ <div class="rfc-result-cost"><span>{ui.tripCostLabel}</span><strong data-rfc-trip-cost>€11.70</strong><small data-rfc-cost-100>€11.70 / 100 km</small></div>
69
+ <p class="rfc-status" data-rfc-status>{ui.gasolineStatusClose}</p>
70
+ </div>
71
+ <div class="rfc-comparison-box">
72
+ <div class="rfc-comparison-heading"><div><p class="rfc-eyebrow">{ui.comparisonTitle}</p><h2>{ui.comparisonQuestion}</h2></div><span>{ui.comparisonHint}</span></div>
73
+ <div class="rfc-price-table" role="table" aria-label={ui.comparisonTitle}>
74
+ {comparisonModes.map((mode) => <article class="rfc-price-row" data-rfc-comparison-row={mode.value} role="row">
75
+ <div class="rfc-row-identity"><span class={`rfc-fuel-dot rfc-dot-${mode.value}`} aria-hidden="true"></span><h3>{mode.label}</h3><small data-rfc-comparison-state={mode.value}>{ui.comparisonNotSet}</small></div>
76
+ <div class="rfc-row-bar-area"><div class="rfc-row-bar-track"><div class="rfc-row-bar" data-rfc-comparison-fill={mode.value}></div></div><div class="rfc-row-cost"><small>{ui.comparisonCostLabel}</small><strong data-rfc-comparison-cost={mode.value}>—</strong></div></div>
77
+ <div class="rfc-row-consumption"><small>{ui.compareConsumptionLabel}</small><div><strong data-rfc-comparison-consumption={mode.value}>—</strong><span>{mode.value === 'electric' ? ui.energyConsumptionUnit : ui.fuelConsumptionUnit}</span></div></div>
78
+ </article>)}
79
+ </div>
80
+ <p class="rfc-comparison-punchline" data-rfc-comparison-punchline>{ui.comparisonHint}</p>
81
+ </div>
82
+ </section>
83
+
84
+ <details class="rfc-details">
85
+ <summary>{ui.optionalDetails}</summary>
86
+ <div class="rfc-details-body">
87
+ <div class="rfc-official-field">
88
+ <label class="rfc-field" for="rfc-rated-number"><span data-rfc-rated-label>{ui.gasolineRatedLabel}</span><small>{ui.ratedHint}</small><strong><input id="rfc-rated-number" data-rfc-field="rated" data-rfc-number type="number" min="0.1" max="40" step="0.1" value="5.8" /> <em data-rfc-rated-unit>{ui.fuelConsumptionUnit}</em></strong><input data-rfc-field="rated" data-rfc-range type="range" min="0.1" max="40" step="0.1" value="5.8" aria-label={ui.ratedLabel} /></label>
89
+ </div>
90
+ <div class="rfc-comparison-settings">
91
+ <div class="rfc-setting-heading"><div><p class="rfc-eyebrow">{ui.comparisonSettingsTitle}</p><span>{ui.comparisonSettingsHint}</span></div><div class="rfc-setting-actions"><label><input type="checkbox" data-rfc-remember-comparisons />{ui.rememberComparisonsLabel}</label><button type="button" data-rfc-reset-comparisons>{ui.resetComparisonsLabel}</button></div></div>
92
+ {comparisonModes.map((mode) => <div class="rfc-setting-row">
93
+ <strong><span class={`rfc-fuel-dot rfc-dot-${mode.value}`} aria-hidden="true"></span>{mode.label}</strong>
94
+ <label for={`rfc-${mode.value}-ratio-number`}><span>{ui.compareRatioLabel}</span><input id={`rfc-${mode.value}-ratio-number`} data-rfc-field={`comparison-${mode.value}-ratio`} data-rfc-number type="number" min="0.1" max="5" step="0.01" placeholder="—" /><em>×</em></label>
95
+ <label for={`rfc-${mode.value}-price-number`}><span>{ui.comparePriceLabel}</span><input id={`rfc-${mode.value}-price-number`} data-rfc-field={`comparison-${mode.value}-price`} data-rfc-number type="number" min="0" max="20" step="0.01" placeholder="—" /><em data-rfc-setting-price-unit>€ / {mode.value === 'electric' ? ui.energyUnit : ui.fuelUnit}</em></label>
96
+ </div>)}
97
+ </div>
98
+ </div>
99
+ </details>
100
+
101
+ <footer class="rfc-footer"><span>{ui.efficiencyLabel}: <b data-rfc-efficiency>15.38 km/L</b></span><span>{ui.mpgLabel}: <b data-rfc-mpg>36.2 mpg</b></span><span>{ui.formulaLabel}: <b data-rfc-formula>{ui.fuelFormula}</b></span><span>{ui.localNotice}</span></footer>
102
+ </section>
103
+ </div>
104
+
105
+ <script is:inline id="rfc-copy" type="application/json" set:html={JSON.stringify(ui)}></script>
106
+ <script>
107
+ import { initFuelLog } from './controller';
108
+
109
+ const copy = document.querySelector<HTMLScriptElement>('#rfc-copy');
110
+ if (copy?.textContent) initFuelLog(JSON.parse(copy.textContent));
111
+ </script>
@@ -0,0 +1,175 @@
1
+ import { renderFuelLog, renderFuelLogControls } from './dom-views';
2
+ import { evaluateFuelLog } from './evaluator';
3
+ import { calculateFuelLog, currencyConversionFactor, type Currency, type EnergyMode } from './logic';
4
+ import { DEFAULT_FUEL_LOG_STATE, loadFuelLogState, saveFuelLogState, type FuelLogState } from './storage';
5
+ import type { FuelLogUI } from './ui';
6
+
7
+ function fieldValue(root: HTMLElement, field: string): number {
8
+ return Number(root.querySelector<HTMLInputElement>(`input[data-rfc-field="${field}"][data-rfc-number]`)?.value ?? 0);
9
+ }
10
+
11
+ function readState(root: HTMLElement): FuelLogState {
12
+ return {
13
+ distance: fieldValue(root, 'distance'),
14
+ used: fieldValue(root, 'used'),
15
+ price: fieldValue(root, 'price'),
16
+ rated: fieldValue(root, 'rated'),
17
+ mode: (root.dataset.rfcMode as EnergyMode) ?? 'gasoline',
18
+ currency: (root.dataset.rfcCurrency as Currency) ?? 'EUR',
19
+ rememberComparisons: root.querySelector<HTMLInputElement>('[data-rfc-remember-comparisons]')?.checked ?? false,
20
+ comparisons: {
21
+ gasoline: { ratio: fieldValue(root, 'comparison-gasoline-ratio'), price: fieldValue(root, 'comparison-gasoline-price') },
22
+ diesel: { ratio: fieldValue(root, 'comparison-diesel-ratio'), price: fieldValue(root, 'comparison-diesel-price') },
23
+ electric: { ratio: fieldValue(root, 'comparison-electric-ratio'), price: fieldValue(root, 'comparison-electric-price') },
24
+ },
25
+ };
26
+ }
27
+
28
+ function setField(root: HTMLElement, field: string, value: number): void {
29
+ root.querySelectorAll<HTMLInputElement>(`input[data-rfc-field="${field}"]`).forEach((input) => { input.value = String(value); });
30
+ }
31
+
32
+ function setMode(root: HTMLElement, mode: EnergyMode): void {
33
+ root.dataset.rfcMode = mode;
34
+ root.querySelectorAll<HTMLButtonElement>('[data-rfc-mode]').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.value === mode)));
35
+ }
36
+
37
+ function modeRatio(root: HTMLElement, mode: EnergyMode): number {
38
+ if (mode === 'gasoline') return 1;
39
+ return Math.max(0.01, fieldValue(root, `comparison-${mode}-ratio`));
40
+ }
41
+
42
+ function switchMode(root: HTMLElement, mode: EnergyMode): void {
43
+ const previous = (root.dataset.rfcMode as EnergyMode) ?? 'gasoline';
44
+ if (previous === mode) return;
45
+ const distance = Math.max(1, fieldValue(root, 'distance'));
46
+ const used = Math.max(0.001, fieldValue(root, 'used'));
47
+ const gasolineBase = ((used / distance) * 100) / modeRatio(root, previous);
48
+ const nextUsed = (gasolineBase * modeRatio(root, mode) * distance) / 100;
49
+ setField(root, 'used', Math.round(nextUsed * 100) / 100);
50
+ const nextPrice = fieldValue(root, `comparison-${mode}-price`);
51
+ if (nextPrice > 0) setField(root, 'price', nextPrice);
52
+ setMode(root, mode);
53
+ }
54
+
55
+ function closeCurrencyMenu(root: HTMLElement): void {
56
+ const menu = root.querySelector<HTMLElement>('[data-rfc-currency-menu]');
57
+ const trigger = root.querySelector<HTMLButtonElement>('[data-rfc-currency-trigger]');
58
+ if (menu) menu.hidden = true;
59
+ trigger?.setAttribute('aria-expanded', 'false');
60
+ }
61
+
62
+ function convertPrice(value: number, from: Currency, to: Currency): number {
63
+ const inEuro = value / currencyConversionFactor(from);
64
+ return Math.round(inEuro * currencyConversionFactor(to) * 100) / 100;
65
+ }
66
+
67
+ function convertCurrencyFields(root: HTMLElement, from: Currency, to: Currency): void {
68
+ ['price', 'comparison-gasoline-price', 'comparison-diesel-price', 'comparison-electric-price'].forEach((field) => {
69
+ const input = root.querySelector<HTMLInputElement>(`input[data-rfc-field="${field}"][data-rfc-number]`);
70
+ const value = Number(input?.value ?? 0);
71
+ if (input && Number.isFinite(value) && value > 0) setField(root, field, convertPrice(value, from, to));
72
+ });
73
+ }
74
+
75
+ function setCurrency(root: HTMLElement, currency: Currency): void {
76
+ root.dataset.rfcCurrency = currency;
77
+ root.querySelectorAll<HTMLButtonElement>('[data-rfc-currency-button]').forEach((button) => button.setAttribute('aria-selected', String(button.dataset.value === currency)));
78
+ closeCurrencyMenu(root);
79
+ }
80
+
81
+ function applyState(root: HTMLElement, state: FuelLogState): void {
82
+ setField(root, 'distance', state.distance);
83
+ setField(root, 'used', state.used);
84
+ setField(root, 'price', state.price);
85
+ setField(root, 'rated', state.rated);
86
+ (['gasoline', 'diesel', 'electric'] as EnergyMode[]).forEach((mode) => {
87
+ setField(root, `comparison-${mode}-ratio`, state.comparisons?.[mode]?.ratio ?? DEFAULT_FUEL_LOG_STATE.comparisons[mode].ratio);
88
+ setField(root, `comparison-${mode}-price`, state.comparisons?.[mode]?.price ?? DEFAULT_FUEL_LOG_STATE.comparisons[mode].price);
89
+ });
90
+ setMode(root, state.mode);
91
+ setCurrency(root, state.currency);
92
+ const remember = root.querySelector<HTMLInputElement>('[data-rfc-remember-comparisons]');
93
+ if (remember) remember.checked = state.rememberComparisons;
94
+ }
95
+
96
+ function update(root: HTMLElement, ui: FuelLogUI): void {
97
+ const state = readState(root);
98
+ const result = calculateFuelLog(state);
99
+ renderFuelLogControls(root, ui);
100
+ renderFuelLog(root, { result, evaluation: evaluateFuelLog(result) }, ui);
101
+ saveFuelLogState(state);
102
+ }
103
+
104
+ function syncInputValue(root: HTMLElement, input: HTMLInputElement, value: number): void {
105
+ if (!Number.isFinite(value)) return;
106
+ const field = input.dataset.rfcField ?? '';
107
+ if ('rfcRange' in input.dataset) {
108
+ setField(root, field, value);
109
+ return;
110
+ }
111
+ if ('rfcNumber' in input.dataset && input.value !== '') setField(root, field, value);
112
+ }
113
+
114
+ function syncLinkedPrice(root: HTMLElement, field: string, value: number): void {
115
+ const activeMode = (root.dataset.rfcMode as EnergyMode) ?? 'gasoline';
116
+ if (field === 'price') {
117
+ setField(root, `comparison-${activeMode}-price`, value);
118
+ return;
119
+ }
120
+ if (field === `comparison-${activeMode}-price` && Number.isFinite(value)) setField(root, 'price', value);
121
+ }
122
+
123
+ function handleFieldInput(root: HTMLElement, ui: FuelLogUI, input: HTMLInputElement): void {
124
+ const value = Number(input.value);
125
+ const field = input.dataset.rfcField ?? '';
126
+ syncInputValue(root, input, value);
127
+ syncLinkedPrice(root, field, value);
128
+ update(root, ui);
129
+ }
130
+
131
+ function handleCurrencyChange(root: HTMLElement, ui: FuelLogUI, button: HTMLButtonElement): void {
132
+ const next = button.dataset.value as Currency;
133
+ const previous = (root.dataset.rfcCurrency as Currency) ?? 'EUR';
134
+ convertCurrencyFields(root, previous, next);
135
+ setCurrency(root, next);
136
+ update(root, ui);
137
+ }
138
+
139
+ function bindFuelLogEvents(root: HTMLElement, ui: FuelLogUI): void {
140
+ root.querySelectorAll<HTMLInputElement>('[data-rfc-field]').forEach((input) => input.addEventListener('input', () => handleFieldInput(root, ui, input)));
141
+ root.querySelectorAll<HTMLButtonElement>('[data-rfc-mode]').forEach((button) => button.addEventListener('click', () => { switchMode(root, button.dataset.value as EnergyMode); update(root, ui); }));
142
+ root.querySelectorAll<HTMLButtonElement>('[data-rfc-currency-button]').forEach((button) => button.addEventListener('click', () => handleCurrencyChange(root, ui, button)));
143
+ root.querySelectorAll<HTMLButtonElement>('[data-rfc-reset]').forEach((button) => button.addEventListener('click', () => { applyState(root, DEFAULT_FUEL_LOG_STATE); update(root, ui); }));
144
+ root.querySelector<HTMLInputElement>('[data-rfc-remember-comparisons]')?.addEventListener('change', () => update(root, ui));
145
+ root.querySelector<HTMLButtonElement>('[data-rfc-reset-comparisons]')?.addEventListener('click', () => { resetComparisons(root); update(root, ui); });
146
+ }
147
+
148
+ function resetComparisons(root: HTMLElement): void {
149
+ const currency = (root.dataset.rfcCurrency as Currency) ?? 'EUR';
150
+ (['gasoline', 'diesel', 'electric'] as EnergyMode[]).forEach((mode) => {
151
+ const defaults = DEFAULT_FUEL_LOG_STATE.comparisons[mode];
152
+ setField(root, `comparison-${mode}-ratio`, defaults.ratio);
153
+ setField(root, `comparison-${mode}-price`, convertPrice(defaults.price, 'EUR', currency));
154
+ });
155
+ const activeMode = (root.dataset.rfcMode as EnergyMode) ?? 'gasoline';
156
+ setField(root, 'price', fieldValue(root, `comparison-${activeMode}-price`));
157
+ }
158
+
159
+ export function initFuelLog(ui: FuelLogUI): void {
160
+ const root = document.querySelector<HTMLElement>('[data-fuel-log-calculator]');
161
+ if (!root) return;
162
+ const saved = loadFuelLogState();
163
+ applyState(root, saved ?? DEFAULT_FUEL_LOG_STATE);
164
+ bindFuelLogEvents(root, ui);
165
+ const trigger = root.querySelector<HTMLButtonElement>('[data-rfc-currency-trigger]');
166
+ const menu = root.querySelector<HTMLElement>('[data-rfc-currency-menu]');
167
+ trigger?.addEventListener('click', () => {
168
+ if (!menu) return;
169
+ menu.hidden = !menu.hidden;
170
+ trigger.setAttribute('aria-expanded', String(!menu.hidden));
171
+ });
172
+ document.addEventListener('click', (event) => { if (!root.contains(event.target as Node)) closeCurrencyMenu(root); });
173
+ document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeCurrencyMenu(root); });
174
+ update(root, ui);
175
+ }
@@ -0,0 +1,132 @@
1
+ import { consumptionUnit, currencyConversionFactor, currencySymbol, type Currency, type EnergyMode, type FuelLogResult } from './logic';
2
+ import type { FuelLogEvaluation } from './evaluator';
3
+ import type { FuelLogUI } from './ui';
4
+
5
+ interface FuelLogRenderModel {
6
+ result: FuelLogResult;
7
+ evaluation: FuelLogEvaluation;
8
+ }
9
+
10
+ function setText(root: HTMLElement, selector: string, value: string): void {
11
+ const element = root.querySelector<HTMLElement>(selector);
12
+ if (element) element.textContent = value;
13
+ }
14
+
15
+ function number(value: number, digits = 1): string {
16
+ return value.toFixed(digits);
17
+ }
18
+
19
+ function modeCopy(mode: EnergyMode, ui: FuelLogUI): { usedLabel: string; usedHint: string; priceLabel: string; ratedLabel: string } {
20
+ if (mode === 'gasoline') return { usedLabel: ui.gasolineUsedLabel, usedHint: ui.gasolineUsedHint, priceLabel: ui.gasolinePriceLabel, ratedLabel: ui.gasolineRatedLabel };
21
+ if (mode === 'diesel') return { usedLabel: ui.dieselUsedLabel, usedHint: ui.dieselUsedHint, priceLabel: ui.dieselPriceLabel, ratedLabel: ui.dieselRatedLabel };
22
+ return { usedLabel: ui.electricUsedLabel, usedHint: ui.electricUsedHint, priceLabel: ui.electricPriceLabel, ratedLabel: ui.electricRatedLabel };
23
+ }
24
+
25
+ function modeLabel(mode: EnergyMode, ui: FuelLogUI): string {
26
+ if (mode === 'gasoline') return ui.gasolineLabel;
27
+ if (mode === 'diesel') return ui.dieselLabel;
28
+ return ui.electricLabel;
29
+ }
30
+
31
+ function comparisonState(cost: number, cheapest: number | null, highest: number | null, ui: FuelLogUI): string {
32
+ if (cost === cheapest) return ui.comparisonCheapest;
33
+ if (cost === highest && highest !== cheapest) return ui.comparisonMostExpensive;
34
+ return ui.comparisonReady;
35
+ }
36
+
37
+ function comparisonRank(cost: number, cheapest: number | null, highest: number | null): string {
38
+ if (cost === cheapest) return 'cheapest';
39
+ if (cost === highest && highest !== cheapest) return 'highest';
40
+ return 'middle';
41
+ }
42
+
43
+ function clearComparisonRow(row: HTMLElement, mode: EnergyMode, ui: FuelLogUI): void {
44
+ setText(row, `[data-rfc-comparison-consumption="${mode}"]`, '—');
45
+ setText(row, `[data-rfc-comparison-cost="${mode}"]`, '—');
46
+ setText(row, `[data-rfc-comparison-state="${mode}"]`, ui.comparisonNotSet);
47
+ row.style.setProperty('--rfc-bar', '0%');
48
+ row.dataset.ready = 'false';
49
+ row.dataset.rank = 'unset';
50
+ }
51
+
52
+ function syncPriceInputs(root: HTMLElement, currency: Currency): void {
53
+ const priceMax = Math.max(20, currencyConversionFactor(currency) * 20);
54
+ root.querySelectorAll<HTMLInputElement>('[data-rfc-field="price"], [data-rfc-field$="-price"]').forEach((input) => {
55
+ input.max = String(priceMax);
56
+ if ('rfcRange' in input.dataset) {
57
+ const numberInput = root.querySelector<HTMLInputElement>(`input[data-rfc-field="${input.dataset.rfcField}"][data-rfc-number]`);
58
+ if (numberInput) input.value = numberInput.value;
59
+ }
60
+ });
61
+ }
62
+
63
+ function comparisonText(root: HTMLElement, result: FuelLogResult, ui: FuelLogUI): void {
64
+ const symbol = currencySymbol(result.currency);
65
+ const values = Object.values(result.comparisons).filter((value): value is NonNullable<typeof value> => value !== null);
66
+ const maxCost = Math.max(...values.map((value) => value.costPer100), 1);
67
+ const cheapest = values.length ? Math.min(...values.map((value) => value.costPer100)) : null;
68
+ const highest = values.length ? Math.max(...values.map((value) => value.costPer100)) : null;
69
+ let cheapestMode: EnergyMode | null = null;
70
+ if (cheapest !== null) cheapestMode = (['gasoline', 'diesel', 'electric'] as EnergyMode[]).find((mode) => result.comparisons[mode]?.costPer100 === cheapest) ?? null;
71
+ (['gasoline', 'diesel', 'electric'] as EnergyMode[]).forEach((mode) => {
72
+ const comparison = result.comparisons[mode];
73
+ const row = root.querySelector<HTMLElement>(`[data-rfc-comparison-row="${mode}"]`);
74
+ if (!row) return;
75
+ if (!comparison) {
76
+ clearComparisonRow(row, mode, ui);
77
+ return;
78
+ }
79
+ const state = comparisonState(comparison.costPer100, cheapest, highest, ui);
80
+ setText(row, `[data-rfc-comparison-consumption="${mode}"]`, number(comparison.consumption));
81
+ setText(row, `[data-rfc-comparison-cost="${mode}"]`, `${symbol}${number(comparison.costPer100, 2)}`);
82
+ setText(row, `[data-rfc-comparison-state="${mode}"]`, state);
83
+ row.style.setProperty('--rfc-bar', `${Math.round(10 + (comparison.costPer100 / maxCost) * 90)}%`);
84
+ row.dataset.ready = 'true';
85
+ row.dataset.rank = comparisonRank(comparison.costPer100, cheapest, highest);
86
+ });
87
+ setText(root, '[data-rfc-comparison-punchline]', cheapestMode ? `${modeLabel(cheapestMode, ui)}: ${ui.comparisonCheapest} at ${symbol}${number(cheapest ?? 0, 2)} / 100 km` : ui.comparisonPunchline);
88
+ }
89
+
90
+ export function renderFuelLog(root: HTMLElement, model: FuelLogRenderModel, ui: FuelLogUI): void {
91
+ const { result, evaluation } = model;
92
+ const symbol = currencySymbol(result.currency);
93
+ const resultUnit = consumptionUnit(result.mode);
94
+ setText(root, '[data-rfc-mode-label]', modeLabel(result.mode, ui));
95
+ setText(root, '[data-rfc-real]', `${number(result.realPer100)} ${resultUnit}`);
96
+ setText(root, '[data-rfc-efficiency]', `${number(result.distancePerUnit, 2)} ${result.mode === 'electric' ? 'km/kWh' : 'km/L'}`);
97
+ setText(root, '[data-rfc-mpg]', result.usMpg === null ? 'Not used for EV' : `${number(result.usMpg)} mpg`);
98
+ setText(root, '[data-rfc-trip-cost]', `${symbol}${number(result.tripCost, 2)}`);
99
+ setText(root, '[data-rfc-cost-100]', `${symbol}${number(result.costPer100, 2)} / 100 km`);
100
+ setText(root, '[data-rfc-status]', ui[evaluation.messageKey]);
101
+ root.dataset.status = evaluation.status;
102
+ root.dataset.rfcMode = result.mode;
103
+ root.dataset.rfcCurrency = result.currency;
104
+ comparisonText(root, result, ui);
105
+ }
106
+
107
+ export function renderFuelLogControls(root: HTMLElement, ui: FuelLogUI): void {
108
+ const mode = root.dataset.rfcMode as EnergyMode;
109
+ const copy = modeCopy(mode, ui);
110
+ const isElectric = mode === 'electric';
111
+ const amount = isElectric ? ui.energyUnit : ui.fuelUnit;
112
+ const consumption = isElectric ? ui.energyConsumptionUnit : ui.fuelConsumptionUnit;
113
+ const currency = (root.dataset.rfcCurrency as Currency) ?? 'EUR';
114
+ const symbol = currencySymbol(currency);
115
+ setText(root, '[data-rfc-used-label]', copy.usedLabel);
116
+ setText(root, '[data-rfc-used-hint]', copy.usedHint);
117
+ setText(root, '[data-rfc-price-label]', copy.priceLabel);
118
+ setText(root, '[data-rfc-rated-label]', copy.ratedLabel);
119
+ setText(root, '[data-rfc-used-unit]', amount);
120
+ setText(root, '[data-rfc-price-unit]', `${symbol} / ${amount}`);
121
+ setText(root, '[data-rfc-rated-unit]', consumption);
122
+ syncPriceInputs(root, currency);
123
+ root.querySelectorAll<HTMLElement>('[data-rfc-setting-price-unit]').forEach((element) => {
124
+ element.textContent = `${symbol} / ${element.parentElement?.querySelector('input')?.id.includes('electric') ? ui.energyUnit : ui.fuelUnit}`;
125
+ });
126
+ setText(root, '[data-rfc-currency-current]', symbol);
127
+ const factor = currencyConversionFactor(currency);
128
+ setText(root, '[data-rfc-currency-rate]', `${ui.currencyRateLabel} ${number(factor, factor >= 100 ? 0 : 2)} ${currency}`);
129
+ root.querySelectorAll<HTMLButtonElement>('[data-rfc-currency-button]').forEach((button) => {
130
+ button.setAttribute('aria-selected', String(button.dataset.value === currency));
131
+ });
132
+ }
@@ -0,0 +1,30 @@
1
+ import type { MotorToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { FuelLogUI } from './ui';
3
+
4
+ export type { FuelLogUI };
5
+ export type RealFuelConsumptionCalculatorLocaleContent = ToolLocaleContent<FuelLogUI>;
6
+
7
+ export const realFuelConsumptionCalculator: MotorToolEntry<FuelLogUI> = {
8
+ id: 'real-fuel-consumption-calculator',
9
+ icons: {
10
+ bg: 'mdi:gas-station-outline',
11
+ fg: 'mdi:chart-donut-variant',
12
+ },
13
+ i18n: {
14
+ de: () => import('./i18n/de').then((module) => module.content),
15
+ en: () => import('./i18n/en').then((module) => module.content),
16
+ es: () => import('./i18n/es').then((module) => module.content),
17
+ fr: () => import('./i18n/fr').then((module) => module.content),
18
+ id: () => import('./i18n/id').then((module) => module.content),
19
+ it: () => import('./i18n/it').then((module) => module.content),
20
+ ja: () => import('./i18n/ja').then((module) => module.content),
21
+ ko: () => import('./i18n/ko').then((module) => module.content),
22
+ nl: () => import('./i18n/nl').then((module) => module.content),
23
+ pl: () => import('./i18n/pl').then((module) => module.content),
24
+ pt: () => import('./i18n/pt').then((module) => module.content),
25
+ ru: () => import('./i18n/ru').then((module) => module.content),
26
+ sv: () => import('./i18n/sv').then((module) => module.content),
27
+ tr: () => import('./i18n/tr').then((module) => module.content),
28
+ zh: () => import('./i18n/zh').then((module) => module.content),
29
+ },
30
+ };
@@ -0,0 +1,15 @@
1
+ import type { FuelLogResult } from './logic';
2
+
3
+ export type FuelLogStatus = 'better' | 'close' | 'higher';
4
+
5
+ export interface FuelLogEvaluation {
6
+ status: FuelLogStatus;
7
+ messageKey: 'gasolineStatusBetter' | 'gasolineStatusClose' | 'gasolineStatusHigher' | 'dieselStatusBetter' | 'dieselStatusClose' | 'dieselStatusHigher' | 'electricStatusBetter' | 'electricStatusClose' | 'electricStatusHigher';
8
+ }
9
+
10
+ export function evaluateFuelLog(result: FuelLogResult): FuelLogEvaluation {
11
+ const prefix = result.mode;
12
+ if (result.differencePercent <= -5) return { status: 'better', messageKey: `${prefix}StatusBetter` as FuelLogEvaluation['messageKey'] };
13
+ if (result.differencePercent <= 8) return { status: 'close', messageKey: `${prefix}StatusClose` as FuelLogEvaluation['messageKey'] };
14
+ return { status: 'higher', messageKey: `${prefix}StatusHigher` as FuelLogEvaluation['messageKey'] };
15
+ }