@jjlmoya/utils-travel 1.18.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.
- package/package.json +1 -1
- package/src/entries.ts +4 -12
- package/src/index.ts +1 -0
- package/src/pages/[locale]/[slug].astro +4 -2
- package/src/tests/locale_completeness.test.ts +2 -4
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/spanish_leakage.test.ts +25 -3
- package/src/tests/tool_validation.test.ts +2 -3
- package/src/tool/fuel-cost-calculator/bibliography.astro +6 -0
- package/src/tool/fuel-cost-calculator/bibliography.ts +14 -0
- package/src/tool/fuel-cost-calculator/component.astro +151 -0
- package/src/tool/fuel-cost-calculator/controller.ts +173 -0
- package/src/tool/fuel-cost-calculator/dom-views.ts +91 -0
- package/src/tool/fuel-cost-calculator/entry.ts +32 -0
- package/src/tool/fuel-cost-calculator/evaluator.ts +14 -0
- package/src/tool/fuel-cost-calculator/fuel-cost-calculator.css +407 -0
- package/src/tool/fuel-cost-calculator/i18n/de.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/en.ts +120 -0
- package/src/tool/fuel-cost-calculator/i18n/es.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/fr.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/id.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/it.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/ja.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/ko.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/nl.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/pl.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/pt.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/ru.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/sv.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/tr.ts +206 -0
- package/src/tool/fuel-cost-calculator/i18n/zh.ts +206 -0
- package/src/tool/fuel-cost-calculator/index.ts +11 -0
- package/src/tool/fuel-cost-calculator/logic.test.ts +60 -0
- package/src/tool/fuel-cost-calculator/logic.ts +54 -0
- package/src/tool/fuel-cost-calculator/seo.astro +14 -0
- package/src/tool/fuel-cost-calculator/storage.ts +30 -0
- package/src/tool/fuel-cost-calculator/ui.ts +45 -0
- package/src/tool/trip-expense-splitter/bibliography.astro +6 -0
- package/src/tool/trip-expense-splitter/bibliography.ts +8 -0
- package/src/tool/trip-expense-splitter/component.astro +41 -0
- package/src/tool/trip-expense-splitter/controller.ts +173 -0
- package/src/tool/trip-expense-splitter/dom-views.ts +72 -0
- package/src/tool/trip-expense-splitter/entry.ts +29 -0
- package/src/tool/trip-expense-splitter/evaluator.ts +8 -0
- package/src/tool/trip-expense-splitter/i18n/de.ts +204 -0
- package/src/tool/trip-expense-splitter/i18n/en.ts +41 -0
- package/src/tool/trip-expense-splitter/i18n/es.ts +204 -0
- package/src/tool/trip-expense-splitter/i18n/fr.ts +204 -0
- package/src/tool/trip-expense-splitter/i18n/id.ts +204 -0
- package/src/tool/trip-expense-splitter/i18n/it.ts +204 -0
- package/src/tool/trip-expense-splitter/i18n/ja.ts +208 -0
- package/src/tool/trip-expense-splitter/i18n/ko.ts +208 -0
- package/src/tool/trip-expense-splitter/i18n/nl.ts +204 -0
- package/src/tool/trip-expense-splitter/i18n/pl.ts +204 -0
- package/src/tool/trip-expense-splitter/i18n/pt.ts +204 -0
- package/src/tool/trip-expense-splitter/i18n/ru.ts +204 -0
- package/src/tool/trip-expense-splitter/i18n/sv.ts +204 -0
- package/src/tool/trip-expense-splitter/i18n/tr.ts +208 -0
- package/src/tool/trip-expense-splitter/i18n/zh.ts +208 -0
- package/src/tool/trip-expense-splitter/index.ts +11 -0
- package/src/tool/trip-expense-splitter/logic.test.ts +51 -0
- package/src/tool/trip-expense-splitter/logic.ts +117 -0
- package/src/tool/trip-expense-splitter/seo.astro +6 -0
- package/src/tool/trip-expense-splitter/storage.ts +24 -0
- package/src/tool/trip-expense-splitter/trip-expense-splitter.css +628 -0
- package/src/tool/trip-expense-splitter/ui.ts +43 -0
- package/src/tools.ts +21 -2
package/package.json
CHANGED
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
27
|
-
expect(ALL_TOOLS.length).toBe(
|
|
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
|
+
|
|
@@ -28,6 +28,8 @@ const TRANSLATABLE_KEYS = [
|
|
|
28
28
|
'schemas',
|
|
29
29
|
] as const;
|
|
30
30
|
|
|
31
|
+
const COPY_THRESHOLD = 0.9;
|
|
32
|
+
|
|
31
33
|
const SPANISH_MARKERS = [
|
|
32
34
|
['sangre', /\bsangre\b/gi],
|
|
33
35
|
['molino', /\bmolino\b/gi],
|
|
@@ -65,12 +67,16 @@ function normalize(value: string): string {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
function isTechnicalInvariant(text: string): boolean {
|
|
70
|
+
const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
|
|
68
71
|
return [
|
|
69
72
|
'data:image/svg+xml;base64',
|
|
70
73
|
'background-image: url',
|
|
71
74
|
'.layout-playground {',
|
|
72
75
|
'const samplerate =',
|
|
73
|
-
].some((pattern) => text.includes(pattern)) ||
|
|
76
|
+
].some((pattern) => text.includes(pattern)) ||
|
|
77
|
+
(text.includes('presets') && text.includes('hz')) ||
|
|
78
|
+
(text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
|
|
79
|
+
(text.includes('led') && ledUnitMatches.length >= 3);
|
|
74
80
|
}
|
|
75
81
|
|
|
76
82
|
function collectString(value: string, output: string[]): void {
|
|
@@ -111,10 +117,26 @@ function findSpanishMarkers(text: string[]): string[] {
|
|
|
111
117
|
);
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
function similarity(left: string, right: string): number {
|
|
121
|
+
const leftTokens = left.split(/\s+/);
|
|
122
|
+
const rightCounts = new Map<string, number>();
|
|
123
|
+
right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
|
|
124
|
+
const matches = leftTokens.reduce((total, token) => {
|
|
125
|
+
const count = rightCounts.get(token) ?? 0;
|
|
126
|
+
if (count > 0) rightCounts.set(token, count - 1);
|
|
127
|
+
return total + (count > 0 ? 1 : 0);
|
|
128
|
+
}, 0);
|
|
129
|
+
return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
|
|
130
|
+
}
|
|
131
|
+
|
|
114
132
|
function findCopiedFragments(spanish: string[], translated: string[]): string[] {
|
|
115
|
-
const corpus = translated.join(' ');
|
|
116
133
|
return spanish
|
|
117
|
-
.filter((fragment) => fragment.length >= 80
|
|
134
|
+
.filter((fragment) => fragment.length >= 80)
|
|
135
|
+
.filter((fragment) => translated.some((candidate) =>
|
|
136
|
+
candidate.length >= 80 &&
|
|
137
|
+
Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
|
|
138
|
+
similarity(fragment, candidate) >= COPY_THRESHOLD,
|
|
139
|
+
))
|
|
118
140
|
.sort((a, b) => b.length - a.length)
|
|
119
141
|
.slice(0, 3);
|
|
120
142
|
}
|
|
@@ -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
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
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,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
|
+
}
|