@jjlmoya/utils-motor 1.4.0 → 1.6.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 +2 -2
- package/src/category/index.ts +2 -1
- package/src/entries.ts +2 -1
- package/src/index.ts +5 -0
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +76 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/evChargingTimeCostPlanner/bibliography.astro +6 -0
- package/src/tool/evChargingTimeCostPlanner/bibliography.ts +16 -0
- package/src/tool/evChargingTimeCostPlanner/component.astro +147 -0
- package/src/tool/evChargingTimeCostPlanner/controller.ts +214 -0
- package/src/tool/evChargingTimeCostPlanner/dom-views.ts +124 -0
- package/src/tool/evChargingTimeCostPlanner/entry.ts +30 -0
- package/src/tool/evChargingTimeCostPlanner/ev-charging-time-cost-planner.css +598 -0
- package/src/tool/evChargingTimeCostPlanner/evaluator.ts +10 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/de.ts +22 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/en.ts +161 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/es.ts +16 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/factory.ts +113 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/fr.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/id.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/it.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/ja.ts +13 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/ko.ts +13 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/nl.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/pl.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/pt.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/ru.ts +13 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/sv.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/tr.ts +9 -0
- package/src/tool/evChargingTimeCostPlanner/i18n/zh.ts +13 -0
- package/src/tool/evChargingTimeCostPlanner/index.ts +10 -0
- package/src/tool/evChargingTimeCostPlanner/logic.test.ts +71 -0
- package/src/tool/evChargingTimeCostPlanner/logic.ts +174 -0
- package/src/tool/evChargingTimeCostPlanner/seo.astro +15 -0
- package/src/tool/evChargingTimeCostPlanner/storage.ts +27 -0
- package/src/tool/evChargingTimeCostPlanner/ui.ts +50 -0
- package/src/tools.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-motor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"check": "astro check",
|
|
29
29
|
"type-check": "astro check",
|
|
30
30
|
"test": "vitest run",
|
|
31
|
-
"preversion": "npm run lint && npm run test
|
|
31
|
+
"preversion": "npm run lint && npm run test",
|
|
32
32
|
"postversion": "git push && git push --tags",
|
|
33
33
|
"patch": "npm version patch",
|
|
34
34
|
"minor": "npm version minor",
|
package/src/category/index.ts
CHANGED
|
@@ -4,10 +4,11 @@ import { carMotorcycleGearRatioCalculator } from '../tool/carMotorcycleGearRatio
|
|
|
4
4
|
import { motorcycleSkidMarkSpeedEstimator } from '../tool/motorcycleSkidMarkSpeedEstimator/entry';
|
|
5
5
|
import { realFuelConsumptionCalculator } from '../tool/realFuelConsumptionCalculator/entry';
|
|
6
6
|
import { tirePressureConverter } from '../tool/tirePressureConverter/entry';
|
|
7
|
+
import { evChargingTimeCostPlanner } from '../tool/evChargingTimeCostPlanner/entry';
|
|
7
8
|
|
|
8
9
|
export const motorCategory: MotorCategoryEntry = {
|
|
9
10
|
icon: 'mdi:car-cog',
|
|
10
|
-
tools: [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator] as unknown as MotorToolEntry<Record<string, string>>[],
|
|
11
|
+
tools: [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner] as unknown as MotorToolEntry<Record<string, string>>[],
|
|
11
12
|
i18n: {
|
|
12
13
|
es: () => import('./i18n/es').then((module) => module.content),
|
|
13
14
|
en: () => import('./i18n/en').then((module) => module.content),
|
package/src/entries.ts
CHANGED
|
@@ -15,5 +15,6 @@ import { carMotorcycleGearRatioCalculator } from './tool/carMotorcycleGearRatioC
|
|
|
15
15
|
import { motorcycleSkidMarkSpeedEstimator } from './tool/motorcycleSkidMarkSpeedEstimator/entry';
|
|
16
16
|
import { realFuelConsumptionCalculator } from './tool/realFuelConsumptionCalculator/entry';
|
|
17
17
|
import { tirePressureConverter } from './tool/tirePressureConverter/entry';
|
|
18
|
+
import { evChargingTimeCostPlanner } from './tool/evChargingTimeCostPlanner/entry';
|
|
18
19
|
|
|
19
|
-
export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator];
|
|
20
|
+
export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner];
|
package/src/index.ts
CHANGED
|
@@ -41,3 +41,8 @@ export type {
|
|
|
41
41
|
MotorcycleSkidMarkSpeedEstimatorUI,
|
|
42
42
|
MotorcycleSkidMarkSpeedEstimatorLocaleContent,
|
|
43
43
|
} from './tool/motorcycleSkidMarkSpeedEstimator';
|
|
44
|
+
export { EV_CHARGING_TIME_COST_PLANNER_TOOL, evChargingTimeCostPlanner } from './tool/evChargingTimeCostPlanner';
|
|
45
|
+
export type {
|
|
46
|
+
EVChargingTimeCostPlannerUI,
|
|
47
|
+
EVChargingTimeCostPlannerLocaleContent,
|
|
48
|
+
} from './tool/evChargingTimeCostPlanner';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
|
|
5
|
+
function findBibliographyAstroFiles(dir: string): string[] {
|
|
6
|
+
const files: string[] = [];
|
|
7
|
+
if (!existsSync(dir)) return files;
|
|
8
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
9
|
+
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const fullPath = join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
files.push(...findBibliographyAstroFiles(fullPath));
|
|
14
|
+
} else if (entry.isFile() && entry.name === 'bibliography.astro') {
|
|
15
|
+
files.push(fullPath);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const toolDir = join(process.cwd(), 'src', 'tool');
|
|
23
|
+
const bibliographyFiles = findBibliographyAstroFiles(toolDir);
|
|
24
|
+
|
|
25
|
+
describe('Bibliography Component Wellformed Export', () => {
|
|
26
|
+
it('found tool bibliography.astro components', () => {
|
|
27
|
+
expect(bibliographyFiles.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
bibliographyFiles.forEach((file) => {
|
|
31
|
+
const relativePath = relative(process.cwd(), file);
|
|
32
|
+
|
|
33
|
+
it(`${relativePath} should use SharedBibliography from @jjlmoya/utils-shared`, () => {
|
|
34
|
+
const content = readFileSync(file, 'utf-8');
|
|
35
|
+
|
|
36
|
+
const usesSharedComponent =
|
|
37
|
+
content.includes('@jjlmoya/utils-shared') &&
|
|
38
|
+
(content.includes('Bibliography') || content.includes('SharedBibliography'));
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
usesSharedComponent,
|
|
42
|
+
`File "${relativePath}" does not use the standard Bibliography component from @jjlmoya/utils-shared, resulting in unstyled or raw bibliography output.`,
|
|
43
|
+
).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { CategoryLocaleContent } from '../types';
|
|
3
|
+
|
|
4
|
+
const EXPECTED_LOCALES = [
|
|
5
|
+
'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
|
|
6
|
+
] as const;
|
|
7
|
+
const COMPACT_LOCALES = new Set(['ja', 'ko', 'zh']);
|
|
8
|
+
|
|
9
|
+
const localeModules = import.meta.glob('../category/i18n/*.ts', { eager: true }) as Record<
|
|
10
|
+
string,
|
|
11
|
+
{ content: CategoryLocaleContent }
|
|
12
|
+
>;
|
|
13
|
+
|
|
14
|
+
function textFrom(value: unknown): string {
|
|
15
|
+
if (typeof value === 'string') return value.replace(/<[^>]*>/g, ' ');
|
|
16
|
+
if (Array.isArray(value)) return value.map(textFrom).join(' ');
|
|
17
|
+
if (value && typeof value === 'object') return Object.values(value).map(textFrom).join(' ');
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STRONG_CLAIM = /\b(?:garantiz\w*|guarante\w*|validat(?:ed)|valid(?:ated|ado|ada|ados|adas|ée|ées)|actualizad\w*)\b/iu;
|
|
22
|
+
|
|
23
|
+
function getContents(): Map<string, CategoryLocaleContent> {
|
|
24
|
+
return new Map(
|
|
25
|
+
Object.entries(localeModules).map(([file, module]) => [file.match(/\/([a-z]{2})\.ts$/u)?.[1] ?? file, module.content]),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateContent(locale: string, content: CategoryLocaleContent): string[] {
|
|
30
|
+
const failures: string[] = [];
|
|
31
|
+
const check = (condition: boolean, message: string) => { if (!condition) failures.push(`${locale}: ${message}`); };
|
|
32
|
+
const minimumTitleLength = COMPACT_LOCALES.has(locale) ? 4 : 12;
|
|
33
|
+
const minimumDescriptionLength = COMPACT_LOCALES.has(locale) ? 20 : 60;
|
|
34
|
+
check(content.title.trim().length >= minimumTitleLength && content.title.trim().length <= 70, 'title must use a useful 4/12–70 character length');
|
|
35
|
+
check(content.description.trim().length >= minimumDescriptionLength && content.description.trim().length <= 180, 'description must use a useful 20/60–180 character length');
|
|
36
|
+
check(content.seo.length >= 2, 'SEO needs at least two sections');
|
|
37
|
+
|
|
38
|
+
const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
|
|
39
|
+
const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
|
|
40
|
+
check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
|
|
41
|
+
check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
|
|
42
|
+
check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
|
|
43
|
+
check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
|
|
44
|
+
check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
|
|
45
|
+
|
|
46
|
+
check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
|
|
47
|
+
return failures;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('Category SEO quality contract', () => {
|
|
51
|
+
it('has one content file for every configured locale', () => {
|
|
52
|
+
const contents = getContents();
|
|
53
|
+
expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
|
|
57
|
+
const contents = getContents();
|
|
58
|
+
const english = contents.get('en');
|
|
59
|
+
expect(english, 'English category content is required as the structural reference').toBeDefined();
|
|
60
|
+
|
|
61
|
+
const failures = EXPECTED_LOCALES.flatMap((locale) => {
|
|
62
|
+
const content = contents.get(locale);
|
|
63
|
+
return content ? validateContent(locale, content) : [];
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const englishShape = english?.seo.map((section) => section.type);
|
|
67
|
+
for (const locale of EXPECTED_LOCALES) {
|
|
68
|
+
const content = contents.get(locale);
|
|
69
|
+
if (content && JSON.stringify(content.seo.map((section) => section.type)) !== JSON.stringify(englishShape)) {
|
|
70
|
+
failures.push(`${locale}: SEO section types must match English`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
expect(failures, 'category SEO quality failures').toEqual([]);
|
|
75
|
+
}, 30000);
|
|
76
|
+
});
|
|
@@ -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(
|
|
7
|
+
expect(ALL_TOOLS.length).toBe(6);
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
ALL_TOOLS.forEach((tool) => {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
|
|
5
|
+
function findSeoAstroFiles(dir: string): string[] {
|
|
6
|
+
const files: string[] = [];
|
|
7
|
+
if (!existsSync(dir)) return files;
|
|
8
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
9
|
+
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const fullPath = join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
files.push(...findSeoAstroFiles(fullPath));
|
|
14
|
+
} else if (entry.isFile() && entry.name === 'seo.astro') {
|
|
15
|
+
files.push(fullPath);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const toolDir = join(process.cwd(), 'src', 'tool');
|
|
23
|
+
const seoFiles = findSeoAstroFiles(toolDir);
|
|
24
|
+
|
|
25
|
+
describe('SEO Component Wellformed Export', () => {
|
|
26
|
+
it('found tool seo.astro components', () => {
|
|
27
|
+
expect(seoFiles.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
seoFiles.forEach((file) => {
|
|
31
|
+
const relativePath = relative(process.cwd(), file);
|
|
32
|
+
|
|
33
|
+
it(`${relativePath} should dynamically load SEO sections and use SEORenderer`, () => {
|
|
34
|
+
const content = readFileSync(file, 'utf-8');
|
|
35
|
+
|
|
36
|
+
const usesSeoRenderer =
|
|
37
|
+
content.includes('@jjlmoya/utils-shared') &&
|
|
38
|
+
(content.includes('SEORenderer') || content.includes('SEOArticle'));
|
|
39
|
+
|
|
40
|
+
const acquiresDynamicContent =
|
|
41
|
+
content.includes('.i18n') ||
|
|
42
|
+
content.includes('loader') ||
|
|
43
|
+
content.includes('await loader') ||
|
|
44
|
+
content.includes('.seo');
|
|
45
|
+
|
|
46
|
+
const isBrokenPattern =
|
|
47
|
+
/sections\s*=\s*\[\s*\]/.test(content) && !acquiresDynamicContent;
|
|
48
|
+
|
|
49
|
+
expect(
|
|
50
|
+
usesSeoRenderer,
|
|
51
|
+
`File "${relativePath}" does not import or use SEORenderer from @jjlmoya/utils-shared.`,
|
|
52
|
+
).toBe(true);
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
isBrokenPattern,
|
|
56
|
+
`File "${relativePath}" relies on a static sections=[] prop default without fetching content from entry.i18n, resulting in empty SEO text on consumers.`,
|
|
57
|
+
).toBe(false);
|
|
58
|
+
|
|
59
|
+
expect(
|
|
60
|
+
acquiresDynamicContent,
|
|
61
|
+
`File "${relativePath}" does not fetch dynamic i18n content for SEO rendering.`,
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -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(
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(6);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('motorCategory should be defined', () => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliographyEntries: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'IEA, Electric vehicle charging, Global EV Outlook 2025',
|
|
6
|
+
url: 'https://www.iea.org/reports/global-ev-outlook-2025/electric-vehicle-charging',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'IDAE, Guía de movilidad eléctrica para las entidades locales',
|
|
10
|
+
url: 'https://www.idae.es/sites/default/files/movilidad/2019-10/Guia_movilidad_electrica_para_entidades_locales.pdf',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'European Central Bank, Euro foreign exchange reference rates',
|
|
14
|
+
url: 'https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html',
|
|
15
|
+
},
|
|
16
|
+
];
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './ev-charging-time-cost-planner.css';
|
|
3
|
+
import { CHARGING_PRESETS, CURRENCY_OPTIONS, convertCurrencyAmount, getCurrencyOption, isCurrencyCode } from './logic';
|
|
4
|
+
import type { EVChargingTimeCostPlannerUI } from './ui';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
ui: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ui = Astro.props.ui as EVChargingTimeCostPlannerUI;
|
|
11
|
+
const initialCurrency = isCurrencyCode(ui.defaultCurrency) ? ui.defaultCurrency : 'EUR';
|
|
12
|
+
const initialCurrencyOption = getCurrencyOption(initialCurrency);
|
|
13
|
+
const initialPreset = CHARGING_PRESETS[0]!;
|
|
14
|
+
const initialPrice = convertCurrencyAmount(initialPreset.inputs.pricePerKwh, initialPreset.inputs.currency, initialCurrency);
|
|
15
|
+
const formatPrice = (value: number): string => value.toFixed(2);
|
|
16
|
+
const initialCost = new Intl.NumberFormat('en-US', { style: 'currency', currency: initialCurrency, maximumFractionDigits: 2 }).format(32 * Number(formatPrice(initialPrice)));
|
|
17
|
+
const presetMeta = (preset: typeof initialPreset): string => ui.presetMetaTemplate
|
|
18
|
+
.replace('{power}', preset.inputs.chargerPowerKw.toFixed(1))
|
|
19
|
+
.replace('{symbol}', initialCurrencyOption.symbol)
|
|
20
|
+
.replace('{price}', formatPrice(convertCurrencyAmount(preset.inputs.pricePerKwh, preset.inputs.currency, initialCurrency)));
|
|
21
|
+
const getInitialSegmentLevel = (index: number): string => {
|
|
22
|
+
if (index < 4) return 'held';
|
|
23
|
+
if (index < 10) return 'added';
|
|
24
|
+
return 'empty';
|
|
25
|
+
};
|
|
26
|
+
const fields = [
|
|
27
|
+
{ id: 'capacityKwh', label: ui.capacityLabel, hint: ui.capacityHint, value: '60', min: '1', max: '250', step: '1', unit: 'kWh' },
|
|
28
|
+
{ id: 'chargerPowerKw', label: ui.chargerPowerLabel, hint: ui.chargerPowerHint, value: '7.4', min: '0.1', max: '350', step: '0.1', unit: 'kW' },
|
|
29
|
+
{ id: 'efficiency', label: ui.efficiencyLabel, hint: ui.efficiencyHint, value: '90', min: '50', max: '100', step: '1', unit: '%' },
|
|
30
|
+
{ id: 'pricePerKwh', label: ui.priceLabel, hint: ui.priceHint, value: formatPrice(initialPrice), min: '0', max: String(5 * initialCurrencyOption.unitsPerEur), step: '0.01', unit: `${initialCurrencyOption.symbol} / kWh` },
|
|
31
|
+
{ id: 'consumptionKwhPer100Km', label: ui.consumptionLabel, hint: ui.consumptionHint, value: '18', min: '1', max: '100', step: '0.5', unit: 'kWh / 100 km' },
|
|
32
|
+
];
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
<div class="evc-tool" lang="en-US" data-ev-charging-planner data-currency={initialCurrency} data-status="standard">
|
|
36
|
+
<section class="evc-surface">
|
|
37
|
+
<header class="evc-header">
|
|
38
|
+
<div class="evc-header-copy">
|
|
39
|
+
<p class="evc-overline">{ui.setupLabel}</p>
|
|
40
|
+
<p class="evc-intro">{ui.setupHint}</p>
|
|
41
|
+
</div>
|
|
42
|
+
<button class="evc-reset" type="button" data-ev-reset>{ui.resetLabel}</button>
|
|
43
|
+
</header>
|
|
44
|
+
|
|
45
|
+
<div class="evc-scenes" role="group" aria-label={ui.presetLabel}>
|
|
46
|
+
<span class="evc-scenes-label">{ui.presetLabel}</span>
|
|
47
|
+
<button type="button" class="evc-scene-button is-active" data-ev-preset="home" aria-pressed="true"><strong>{ui.homePreset}</strong><small data-ev-preset-meta="home">{presetMeta(CHARGING_PRESETS[0]!)}</small></button>
|
|
48
|
+
<button type="button" class="evc-scene-button" data-ev-preset="public" aria-pressed="false"><strong>{ui.publicPreset}</strong><small data-ev-preset-meta="public">{presetMeta(CHARGING_PRESETS[1]!)}</small></button>
|
|
49
|
+
<button type="button" class="evc-scene-button" data-ev-preset="fast" aria-pressed="false"><strong>{ui.fastPreset}</strong><small data-ev-preset-meta="fast">{presetMeta(CHARGING_PRESETS[2]!)}</small></button>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="evc-main-grid">
|
|
53
|
+
<section class="evc-inputs" aria-labelledby="evc-inputs-title">
|
|
54
|
+
<div class="evc-section-heading">
|
|
55
|
+
<div>
|
|
56
|
+
<p class="evc-overline">{ui.chargeWindowLabel}</p>
|
|
57
|
+
<h2 id="evc-inputs-title">{ui.chargeWindowHint}</h2>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="evc-soc-row">
|
|
62
|
+
<label class="evc-field evc-soc-field" for="evc-start-number">
|
|
63
|
+
<span>{ui.startSocLabel}</span><small>{ui.startSocHint}</small>
|
|
64
|
+
<strong><input id="evc-start-number" data-ev-field="startSoc" data-ev-number type="number" min="0" max="100" step="1" value="32" /><em>%</em></strong>
|
|
65
|
+
<input data-ev-field="startSoc" data-ev-range type="range" min="0" max="100" step="1" value="32" aria-label={ui.startSocLabel} />
|
|
66
|
+
</label>
|
|
67
|
+
<div class="evc-window-arrow" aria-hidden="true">→</div>
|
|
68
|
+
<label class="evc-field evc-soc-field" for="evc-end-number">
|
|
69
|
+
<span>{ui.endSocLabel}</span><small>{ui.endSocHint}</small>
|
|
70
|
+
<strong><input id="evc-end-number" data-ev-field="endSoc" data-ev-number type="number" min="0" max="100" step="1" value="80" /><em>%</em></strong>
|
|
71
|
+
<input data-ev-field="endSoc" data-ev-range type="range" min="0" max="100" step="1" value="80" aria-label={ui.endSocLabel} />
|
|
72
|
+
</label>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="evc-currency-panel" data-ev-currency-select>
|
|
76
|
+
<div>
|
|
77
|
+
<span>{ui.currencyLabel}</span>
|
|
78
|
+
<small>{ui.currencyHint}</small>
|
|
79
|
+
</div>
|
|
80
|
+
<button type="button" class="evc-currency-trigger" data-ev-currency-trigger aria-haspopup="listbox" aria-expanded="false" aria-label={ui.currencyMenuLabel}>
|
|
81
|
+
<strong data-ev-currency-code>{initialCurrency}</strong>
|
|
82
|
+
<span data-ev-currency-factor>{ui.currencyFactorTemplate.replace('{factor}', initialCurrencyOption.unitsPerEur.toString()).replace('{currency}', initialCurrency)}</span>
|
|
83
|
+
<i aria-hidden="true"></i>
|
|
84
|
+
</button>
|
|
85
|
+
<div class="evc-currency-menu" data-ev-currency-menu role="listbox" aria-label={ui.currencyMenuLabel} hidden>
|
|
86
|
+
{CURRENCY_OPTIONS.map((option) => (
|
|
87
|
+
<button type="button" role="option" data-ev-currency-option={option.code} aria-selected={option.code === initialCurrency ? 'true' : 'false'}>
|
|
88
|
+
<strong>{option.code}</strong><span>{option.symbol} · 1 EUR = {option.unitsPerEur}</span>
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div class="evc-field-stack">
|
|
95
|
+
{fields.map((field) => (
|
|
96
|
+
<label class="evc-field" for={`evc-${field.id}-number`}>
|
|
97
|
+
<span data-ev-price-label={field.id === 'pricePerKwh' ? '' : undefined}>{field.label}</span><small>{field.hint}</small>
|
|
98
|
+
<strong><input id={`evc-${field.id}-number`} data-ev-field={field.id} data-ev-number type={field.id === 'pricePerKwh' ? 'text' : 'number'} inputmode={field.id === 'pricePerKwh' ? 'decimal' : undefined} min={field.min} max={field.max} step={field.step} value={field.value} /><em data-ev-price-unit={field.id === 'pricePerKwh' ? '' : undefined}>{field.unit}</em></strong>
|
|
99
|
+
<input data-ev-field={field.id} data-ev-range type="range" min={field.min} max={field.max} step={field.step} value={field.value} aria-label={field.label} />
|
|
100
|
+
</label>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<section class="evc-runway" aria-labelledby="evc-result-title" aria-live="polite">
|
|
106
|
+
<div class="evc-runway-heading">
|
|
107
|
+
<div><p class="evc-overline">{ui.resultKicker}</p><h2 id="evc-result-title">{ui.resultTitle}</h2></div>
|
|
108
|
+
<span class="evc-status" data-ev-status>{ui.statusStandard}</span>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="evc-battery-wrap">
|
|
111
|
+
<svg class="evc-battery" viewBox="0 0 540 206" role="img" aria-labelledby="evc-battery-title">
|
|
112
|
+
<title id="evc-battery-title">{ui.batterySceneLabel}</title>
|
|
113
|
+
<rect class="evc-battery-shell" x="16" y="35" width="470" height="136" rx="28" />
|
|
114
|
+
<rect class="evc-battery-terminal" x="486" y="81" width="38" height="44" rx="12" />
|
|
115
|
+
<g class="evc-segments">
|
|
116
|
+
{Array.from({ length: 12 }, (_, index) => <rect data-ev-segment data-level={getInitialSegmentLevel(index)} x={38 + index * 36} y="56" width="25" height="94" rx="10" />)}
|
|
117
|
+
</g>
|
|
118
|
+
<path class="evc-energy-line" d="M74 184 C146 198 244 199 330 184 S444 164 500 190" />
|
|
119
|
+
<circle class="evc-energy-node" cx="74" cy="184" r="7" />
|
|
120
|
+
<circle class="evc-energy-node" cx="500" cy="190" r="7" />
|
|
121
|
+
</svg>
|
|
122
|
+
<div class="evc-battery-legend"><span><i class="evc-dot evc-dot-held"></i><span>{ui.startingChargeLabel}</span> <strong data-ev-start-soc>32%</strong></span><span><i class="evc-dot evc-dot-added"></i><span>{ui.targetChargeLabel}</span> <strong data-ev-end-soc>80%</strong></span></div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div class="evc-result-lead"><span>{ui.timeLabel}</span><strong data-ev-time>4 h 20 min</strong><small data-ev-power>7.4 kW</small></div>
|
|
126
|
+
<div class="evc-metrics">
|
|
127
|
+
<div><span>{ui.batteryEnergyLabel}</span><strong data-ev-battery-energy>28.8 kWh</strong></div>
|
|
128
|
+
<div><span>{ui.gridEnergyLabel}</span><strong data-ev-grid-energy>32 kWh</strong></div>
|
|
129
|
+
<div><span>{ui.lossesLabel}</span><strong data-ev-loss-energy>3.2 kWh</strong></div>
|
|
130
|
+
<div><span>{ui.costLabel}</span><strong data-ev-cost>{initialCost}</strong></div>
|
|
131
|
+
<div><span>{ui.rangeLabel}</span><strong data-ev-result-range>160 km</strong></div>
|
|
132
|
+
</div>
|
|
133
|
+
<p class="evc-summary" data-ev-summary>{ui.summaryTemplate.replace('{battery}', '28.8').replace('{range}', '160').replace('{power}', '7.4').replace('{time}', '4 h 20 min').replace('{cost}', initialCost)}</p>
|
|
134
|
+
</section>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<footer class="evc-formula"><strong>{ui.formulaLabel}</strong><span>{ui.formulaText}</span></footer>
|
|
138
|
+
</section>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<script is:inline id="evc-copy" type="application/json" set:html={JSON.stringify(ui)}></script>
|
|
142
|
+
<script>
|
|
143
|
+
import { initEVChargingPlanner } from './controller';
|
|
144
|
+
|
|
145
|
+
const copy = document.querySelector<HTMLScriptElement>('#evc-copy');
|
|
146
|
+
if (copy?.textContent) initEVChargingPlanner(JSON.parse(copy.textContent));
|
|
147
|
+
</script>
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHARGING_PRESETS,
|
|
3
|
+
calculateChargingPlan,
|
|
4
|
+
convertCurrencyAmount,
|
|
5
|
+
DEFAULT_CURRENCY,
|
|
6
|
+
getCurrencyOption,
|
|
7
|
+
getPriceInputStep,
|
|
8
|
+
isCurrencyCode,
|
|
9
|
+
type ChargingInputs,
|
|
10
|
+
type CurrencyCode,
|
|
11
|
+
} from './logic';
|
|
12
|
+
import { evaluateChargingStatus } from './evaluator';
|
|
13
|
+
import { clearChargingInputs, loadChargingInputs, saveChargingInputs } from './storage';
|
|
14
|
+
import { renderChargingPlan } from './dom-views';
|
|
15
|
+
import type { EVChargingTimeCostPlannerUI } from './ui';
|
|
16
|
+
|
|
17
|
+
type NumericField = Exclude<keyof ChargingInputs, 'currency'>;
|
|
18
|
+
|
|
19
|
+
const fields: NumericField[] = [
|
|
20
|
+
'capacityKwh',
|
|
21
|
+
'startSoc',
|
|
22
|
+
'endSoc',
|
|
23
|
+
'chargerPowerKw',
|
|
24
|
+
'efficiency',
|
|
25
|
+
'pricePerKwh',
|
|
26
|
+
'consumptionKwhPer100Km',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function getRootCurrency(root: HTMLElement): CurrencyCode {
|
|
30
|
+
return isCurrencyCode(root.dataset.evCurrency) ? root.dataset.evCurrency : DEFAULT_CURRENCY;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readInputs(root: HTMLElement): ChargingInputs {
|
|
34
|
+
const values = fields.map((field) => {
|
|
35
|
+
const input = root.querySelector<HTMLInputElement>(`[data-ev-field="${field}"][data-ev-number]`);
|
|
36
|
+
const rawValue = Number((input?.value ?? '0').replace(',', '.'));
|
|
37
|
+
const value = field === 'efficiency' ? rawValue / 100 : rawValue;
|
|
38
|
+
return [field, value] as const;
|
|
39
|
+
});
|
|
40
|
+
return { ...Object.fromEntries(values), currency: getRootCurrency(root) } as unknown as ChargingInputs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setField(root: HTMLElement, field: NumericField, value: number): void {
|
|
44
|
+
const displayValue = getDisplayValue(field, value);
|
|
45
|
+
root.querySelectorAll<HTMLInputElement>(`[data-ev-field="${field}"]`).forEach((input) => {
|
|
46
|
+
input.value = String(displayValue);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getDisplayValue(field: NumericField, value: number): number {
|
|
51
|
+
if (field === 'efficiency') return value * 100;
|
|
52
|
+
if (field === 'pricePerKwh') return Number(value.toFixed(2));
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setInputs(root: HTMLElement, inputs: ChargingInputs): void {
|
|
57
|
+
fields.forEach((field) => setField(root, field, inputs[field]));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setCurrencyMenuOpen(root: HTMLElement, open: boolean): void {
|
|
61
|
+
const menu = root.querySelector<HTMLElement>('[data-ev-currency-menu]');
|
|
62
|
+
const trigger = root.querySelector<HTMLButtonElement>('[data-ev-currency-trigger]');
|
|
63
|
+
if (menu) menu.hidden = !open;
|
|
64
|
+
if (trigger) trigger.setAttribute('aria-expanded', String(open));
|
|
65
|
+
root.classList.toggle('is-currency-open', open);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function updatePriceInputBounds(root: HTMLElement, currency: CurrencyCode): void {
|
|
69
|
+
const factor = getCurrencyOption(currency).unitsPerEur;
|
|
70
|
+
const max = String(Number((5 * factor).toFixed(2)));
|
|
71
|
+
const step = String(getPriceInputStep(currency));
|
|
72
|
+
root.querySelectorAll<HTMLInputElement>('[data-ev-field="pricePerKwh"]').forEach((input) => {
|
|
73
|
+
input.max = max;
|
|
74
|
+
input.step = step;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setCurrency(root: HTMLElement, currency: CurrencyCode): void {
|
|
79
|
+
root.dataset.evCurrency = currency;
|
|
80
|
+
updatePriceInputBounds(root, currency);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getInitialInputs(root: HTMLElement, stored: Partial<ChargingInputs> | null, defaultCurrency: CurrencyCode): ChargingInputs {
|
|
84
|
+
const preset = CHARGING_PRESETS[0]!.inputs;
|
|
85
|
+
if (stored) {
|
|
86
|
+
const currency = isCurrencyCode(stored.currency) ? stored.currency : preset.currency;
|
|
87
|
+
return { ...preset, ...stored, currency } as ChargingInputs;
|
|
88
|
+
}
|
|
89
|
+
const pricePerKwh = convertCurrencyAmount(preset.pricePerKwh, preset.currency, defaultCurrency);
|
|
90
|
+
setCurrency(root, defaultCurrency);
|
|
91
|
+
return { ...preset, currency: defaultCurrency, pricePerKwh };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getPresetInputs(currency: CurrencyCode, preset: ChargingInputs): ChargingInputs {
|
|
95
|
+
return {
|
|
96
|
+
...preset,
|
|
97
|
+
currency,
|
|
98
|
+
pricePerKwh: convertCurrencyAmount(preset.pricePerKwh, preset.currency, currency),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function syncFieldInputs(root: HTMLElement, target: HTMLInputElement): void {
|
|
103
|
+
const field = target.dataset.evField;
|
|
104
|
+
if (!field) return;
|
|
105
|
+
const normalizedValue = target.value.replace(',', '.');
|
|
106
|
+
root.querySelectorAll<HTMLInputElement>(`[data-ev-field="${field}"]`).forEach((input) => {
|
|
107
|
+
input.value = normalizedValue;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function updatePresetState(root: HTMLElement, activeId: string): void {
|
|
112
|
+
root.querySelectorAll<HTMLButtonElement>('[data-ev-preset]').forEach((button) => {
|
|
113
|
+
const active = button.dataset.evPreset === activeId;
|
|
114
|
+
button.classList.toggle('is-active', active);
|
|
115
|
+
button.setAttribute('aria-pressed', String(active));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function render(root: HTMLElement, ui: EVChargingTimeCostPlannerUI): void {
|
|
120
|
+
const inputs = readInputs(root);
|
|
121
|
+
const plan = calculateChargingPlan(inputs);
|
|
122
|
+
renderChargingPlan(root, { ui, inputs, plan, status: evaluateChargingStatus(plan) });
|
|
123
|
+
saveChargingInputs(inputs);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function attachInputHandlers(root: HTMLElement, ui: EVChargingTimeCostPlannerUI): void {
|
|
127
|
+
root.addEventListener('input', (event) => {
|
|
128
|
+
const target = event.target as HTMLInputElement;
|
|
129
|
+
if (!target.matches('[data-ev-field]')) return;
|
|
130
|
+
syncFieldInputs(root, target);
|
|
131
|
+
updatePresetState(root, 'custom');
|
|
132
|
+
render(root, ui);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function handleCurrencySelection(root: HTMLElement, ui: EVChargingTimeCostPlannerUI, currency: CurrencyCode): void {
|
|
137
|
+
const current = readInputs(root);
|
|
138
|
+
const convertedPrice = convertCurrencyAmount(current.pricePerKwh, current.currency, currency);
|
|
139
|
+
setCurrency(root, currency);
|
|
140
|
+
setField(root, 'pricePerKwh', convertedPrice);
|
|
141
|
+
updatePresetState(root, 'custom');
|
|
142
|
+
setCurrencyMenuOpen(root, false);
|
|
143
|
+
render(root, ui);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function attachCurrencyDismissal(root: HTMLElement): void {
|
|
147
|
+
document.addEventListener('click', (event) => {
|
|
148
|
+
if (!root.contains(event.target as Node)) setCurrencyMenuOpen(root, false);
|
|
149
|
+
});
|
|
150
|
+
root.addEventListener('keydown', (event) => {
|
|
151
|
+
if (event.key === 'Escape') setCurrencyMenuOpen(root, false);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function handleCurrencyClick(root: HTMLElement, ui: EVChargingTimeCostPlannerUI, target: HTMLElement): boolean {
|
|
156
|
+
const currencyOption = target.closest<HTMLButtonElement>('[data-ev-currency-option]');
|
|
157
|
+
if (currencyOption && isCurrencyCode(currencyOption.dataset.evCurrencyOption)) {
|
|
158
|
+
handleCurrencySelection(root, ui, currencyOption.dataset.evCurrencyOption);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
const currencyTrigger = target.closest<HTMLButtonElement>('[data-ev-currency-trigger]');
|
|
162
|
+
if (!currencyTrigger) return false;
|
|
163
|
+
const menu = root.querySelector<HTMLElement>('[data-ev-currency-menu]');
|
|
164
|
+
setCurrencyMenuOpen(root, Boolean(menu?.hidden));
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handlePresetClick(root: HTMLElement, ui: EVChargingTimeCostPlannerUI, target: HTMLElement): boolean {
|
|
169
|
+
const presetButton = target.closest<HTMLButtonElement>('[data-ev-preset]');
|
|
170
|
+
if (!presetButton?.dataset.evPreset) return false;
|
|
171
|
+
const preset = CHARGING_PRESETS.find((item) => item.id === presetButton.dataset.evPreset);
|
|
172
|
+
if (!preset) return true;
|
|
173
|
+
const currency = getRootCurrency(root);
|
|
174
|
+
setInputs(root, getPresetInputs(currency, preset.inputs));
|
|
175
|
+
updatePresetState(root, preset.id);
|
|
176
|
+
render(root, ui);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function handleResetClick(root: HTMLElement, ui: EVChargingTimeCostPlannerUI, target: HTMLElement): void {
|
|
181
|
+
const resetButton = target.closest<HTMLButtonElement>('[data-ev-reset]');
|
|
182
|
+
if (!resetButton) return;
|
|
183
|
+
clearChargingInputs();
|
|
184
|
+
const currency = isCurrencyCode(ui.defaultCurrency) ? ui.defaultCurrency : DEFAULT_CURRENCY;
|
|
185
|
+
setCurrency(root, currency);
|
|
186
|
+
setInputs(root, getPresetInputs(currency, CHARGING_PRESETS[0]!.inputs));
|
|
187
|
+
updatePresetState(root, 'home');
|
|
188
|
+
render(root, ui);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function attachClickHandlers(root: HTMLElement, ui: EVChargingTimeCostPlannerUI): void {
|
|
192
|
+
root.addEventListener('click', (event) => {
|
|
193
|
+
const target = event.target as HTMLElement;
|
|
194
|
+
if (handleCurrencyClick(root, ui, target)) return;
|
|
195
|
+
if (handlePresetClick(root, ui, target)) return;
|
|
196
|
+
handleResetClick(root, ui, target);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function initEVChargingPlanner(ui: EVChargingTimeCostPlannerUI): void {
|
|
201
|
+
const root = document.querySelector<HTMLElement>('[data-ev-charging-planner]');
|
|
202
|
+
if (!root || root.dataset.initialized === 'true') return;
|
|
203
|
+
root.dataset.initialized = 'true';
|
|
204
|
+
const stored = loadChargingInputs();
|
|
205
|
+
const defaultCurrency = isCurrencyCode(ui.defaultCurrency) ? ui.defaultCurrency : DEFAULT_CURRENCY;
|
|
206
|
+
const initial = getInitialInputs(root, stored, defaultCurrency);
|
|
207
|
+
setCurrency(root, initial.currency);
|
|
208
|
+
setInputs(root, initial);
|
|
209
|
+
updatePresetState(root, stored ? 'custom' : 'home');
|
|
210
|
+
render(root, ui);
|
|
211
|
+
attachInputHandlers(root, ui);
|
|
212
|
+
attachClickHandlers(root, ui);
|
|
213
|
+
attachCurrencyDismissal(root);
|
|
214
|
+
}
|