@jjlmoya/utils-motor 1.12.0 → 1.14.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/category/index.ts +2 -1
- package/src/entries.ts +4 -1
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/mfe_assets_contract.test.ts +53 -0
- package/src/tests/registry_contract.test.ts +23 -0
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/vehicleTotalCostPerKilometreCalculator/bibliography.astro +6 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/bibliography.ts +12 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/component.astro +97 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/controller.ts +198 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/dom-views.ts +47 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/entry.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/evaluator.ts +14 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/de.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/en.ts +48 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/es.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/factory.ts +102 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/fr.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/id.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/it.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/ja.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/ko.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/nl.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/pl.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/pt.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/ru.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/sv.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/tr.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/i18n/zh.ts +30 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/index.ts +10 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/logic.test.ts +63 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/logic.ts +132 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/seo.astro +15 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/storage.ts +38 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/ui.ts +54 -0
- package/src/tool/vehicleTotalCostPerKilometreCalculator/vehicle-total-cost-per-kilometre-calculator.css +417 -0
- package/src/tools.ts +4 -1
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -6,10 +6,11 @@ import { realFuelConsumptionCalculator } from '../tool/realFuelConsumptionCalcul
|
|
|
6
6
|
import { tirePressureConverter } from '../tool/tirePressureConverter/entry';
|
|
7
7
|
import { evChargingTimeCostPlanner } from '../tool/evChargingTimeCostPlanner/entry';
|
|
8
8
|
import { trailerTongueWeightCalculator } from '../tool/trailerTongueWeightCalculator/entry';
|
|
9
|
+
import { vehicleTotalCostPerKilometreCalculator } from '../tool/vehicleTotalCostPerKilometreCalculator/entry';
|
|
9
10
|
|
|
10
11
|
export const motorCategory: MotorCategoryEntry = {
|
|
11
12
|
icon: 'mdi:car-cog',
|
|
12
|
-
tools: [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner, trailerTongueWeightCalculator] as unknown as MotorToolEntry<Record<string, string>>[],
|
|
13
|
+
tools: [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner, trailerTongueWeightCalculator, vehicleTotalCostPerKilometreCalculator] as unknown as MotorToolEntry<Record<string, string>>[],
|
|
13
14
|
i18n: {
|
|
14
15
|
es: () => import('./i18n/es').then((module) => module.content),
|
|
15
16
|
en: () => import('./i18n/en').then((module) => module.content),
|
package/src/entries.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { tirePressureConverter } from './tool/tirePressureConverter/entry';
|
|
|
10
10
|
export type { TirePressureConverterLocaleContent } from './tool/tirePressureConverter/entry';
|
|
11
11
|
export { trailerTongueWeightCalculator } from './tool/trailerTongueWeightCalculator/entry';
|
|
12
12
|
export type { TrailerTongueWeightCalculatorLocaleContent } from './tool/trailerTongueWeightCalculator/entry';
|
|
13
|
+
export { vehicleTotalCostPerKilometreCalculator } from './tool/vehicleTotalCostPerKilometreCalculator/entry';
|
|
14
|
+
export type { VehicleTotalCostPerKilometreCalculatorLocaleContent } from './tool/vehicleTotalCostPerKilometreCalculator/entry';
|
|
13
15
|
export { motorCategory } from './category';
|
|
14
16
|
|
|
15
17
|
import { brakingDistanceCalculator } from './tool/brakingDistanceCalculator/entry';
|
|
@@ -19,5 +21,6 @@ import { realFuelConsumptionCalculator } from './tool/realFuelConsumptionCalcula
|
|
|
19
21
|
import { tirePressureConverter } from './tool/tirePressureConverter/entry';
|
|
20
22
|
import { evChargingTimeCostPlanner } from './tool/evChargingTimeCostPlanner/entry';
|
|
21
23
|
import { trailerTongueWeightCalculator } from './tool/trailerTongueWeightCalculator/entry';
|
|
24
|
+
import { vehicleTotalCostPerKilometreCalculator } from './tool/vehicleTotalCostPerKilometreCalculator/entry';
|
|
22
25
|
|
|
23
|
-
export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner, trailerTongueWeightCalculator];
|
|
26
|
+
export const ALL_ENTRIES = [brakingDistanceCalculator, realFuelConsumptionCalculator, carMotorcycleGearRatioCalculator, tirePressureConverter, motorcycleSkidMarkSpeedEstimator, evChargingTimeCostPlanner, trailerTongueWeightCalculator, vehicleTotalCostPerKilometreCalculator];
|
|
@@ -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(8);
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
ALL_TOOLS.forEach((tool) => {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { ALL_TOOLS } from '../tools';
|
|
5
|
+
import { CATEGORY_OG_IMAGE, getUtilityOgImage } from '../mfe/assets';
|
|
6
|
+
|
|
7
|
+
const categoryImageMatch = CATEGORY_OG_IMAGE.match(
|
|
8
|
+
/^(\/_utilities\/[^/]+\/images)\/([^/]+\.webp)\?version=(.+)$/,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
if (!categoryImageMatch) {
|
|
12
|
+
throw new Error(`Unexpected CATEGORY_OG_IMAGE format: ${CATEGORY_OG_IMAGE}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const imageUrlRoot = categoryImageMatch[1];
|
|
16
|
+
const categoryImage = categoryImageMatch[2];
|
|
17
|
+
const assetVersion = categoryImageMatch[3];
|
|
18
|
+
if (!imageUrlRoot || !categoryImage || !assetVersion) {
|
|
19
|
+
throw new Error(`Unexpected CATEGORY_OG_IMAGE capture groups: ${CATEGORY_OG_IMAGE}`);
|
|
20
|
+
}
|
|
21
|
+
const assetRoot = join(process.cwd(), 'public', imageUrlRoot.slice(1));
|
|
22
|
+
const categorySlug = basename(categoryImage, '.webp');
|
|
23
|
+
|
|
24
|
+
describe('MFE asset contract', () => {
|
|
25
|
+
it('has one non-empty English-slug OG image per category and registered tool', async () => {
|
|
26
|
+
const expectedSlugs = new Set([categorySlug]);
|
|
27
|
+
|
|
28
|
+
for (const { entry } of ALL_TOOLS) {
|
|
29
|
+
const englishLoader = entry.i18n.en;
|
|
30
|
+
if (!englishLoader) throw new Error(`Missing English locale for ${entry.id}`);
|
|
31
|
+
|
|
32
|
+
const englishContent = await englishLoader();
|
|
33
|
+
expectedSlugs.add(englishContent.slug);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const actualSlugs = new Set(
|
|
37
|
+
readdirSync(assetRoot)
|
|
38
|
+
.filter((filename) => filename.endsWith('.webp'))
|
|
39
|
+
.map((filename) => filename.slice(0, -'.webp'.length)),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(actualSlugs).toEqual(expectedSlugs);
|
|
43
|
+
|
|
44
|
+
for (const slug of expectedSlugs) {
|
|
45
|
+
const imagePath = join(assetRoot, `${slug}.webp`);
|
|
46
|
+
expect(existsSync(imagePath), `${imagePath} should exist`).toBe(true);
|
|
47
|
+
expect(statSync(imagePath).size, `${imagePath} should not be empty`).toBeGreaterThan(0);
|
|
48
|
+
expect(getUtilityOgImage(slug)).toBe(
|
|
49
|
+
`${imageUrlRoot}/${slug}.webp?version=${assetVersion}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}, 30000);
|
|
53
|
+
});
|
|
@@ -64,4 +64,27 @@ describe('Library registry contract', () => {
|
|
|
64
64
|
|
|
65
65
|
expect(failures).toEqual([]);
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
it('keeps the vehicle cost calculator consumer boundary localized and lazy', async () => {
|
|
69
|
+
const tool = ALL_TOOLS.find((candidate) => candidate.entry.id === 'vehicle-total-cost-per-kilometre-calculator');
|
|
70
|
+
expect(tool).toBeDefined();
|
|
71
|
+
if (!tool) return;
|
|
72
|
+
|
|
73
|
+
expect(Object.keys(tool.entry.i18n).sort()).toEqual([
|
|
74
|
+
'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const localizedContent = await tool.entry.i18n.de?.();
|
|
78
|
+
expect(localizedContent?.title).toContain('Fahrzeug');
|
|
79
|
+
expect(localizedContent?.ui.currencyLabel).toBeTruthy();
|
|
80
|
+
|
|
81
|
+
const [component, seo, bibliography] = await Promise.all([
|
|
82
|
+
tool.Component(),
|
|
83
|
+
tool.SEOComponent(),
|
|
84
|
+
tool.BibliographyComponent(),
|
|
85
|
+
]);
|
|
86
|
+
expect(component).toBeTruthy();
|
|
87
|
+
expect(seo).toBeTruthy();
|
|
88
|
+
expect(bibliography).toBeTruthy();
|
|
89
|
+
});
|
|
67
90
|
});
|
|
@@ -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(8);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('motorCategory should be defined', () => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliographyEntries: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'ADAC, Übersicht: Autokosten von A bis Z',
|
|
6
|
+
url: 'https://www.adac.de/rund-ums-fahrzeug/auto-kaufen-verkaufen/autokosten/uebersicht/',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'RAC Drive, Car running costs: a complete guide',
|
|
10
|
+
url: 'https://www.rac.co.uk/drive/advice/driving-advice/car-running-costs-a-complete-guide-to-help-you-save-money/',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './vehicle-total-cost-per-kilometre-calculator.css';
|
|
3
|
+
import { CURRENCY_OPTIONS, DEFAULT_CURRENCY } from './logic';
|
|
4
|
+
import type { VehicleTotalCostPerKilometreCalculatorUI } from './ui';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
ui: VehicleTotalCostPerKilometreCalculatorUI;
|
|
8
|
+
locale?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { ui, locale = 'en' } = Astro.props as Props;
|
|
12
|
+
const initialCurrency = DEFAULT_CURRENCY;
|
|
13
|
+
const currencyNames = (() => {
|
|
14
|
+
try {
|
|
15
|
+
return new Intl.DisplayNames([locale], { type: 'currency' });
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
20
|
+
const currencyName = (code: string, fallback: string): string => currencyNames?.of(code) ?? fallback;
|
|
21
|
+
const defaults = {
|
|
22
|
+
primary: { annualKm: 15000, fuelUsePer100Km: 6.4, fuelPrice: 1.75, insurance: 700, maintenance: 650, tax: 190, depreciation: 2600 },
|
|
23
|
+
secondary: { annualKm: 15000, fuelUsePer100Km: 7.2, fuelPrice: 1.75, insurance: 760, maintenance: 700, tax: 210, depreciation: 2300 },
|
|
24
|
+
comparisonEnabled: false,
|
|
25
|
+
currency: initialCurrency,
|
|
26
|
+
};
|
|
27
|
+
const config = JSON.stringify({ ui, defaults, locale });
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<div class="vehicle-cost-tool" data-vehicle-total-cost>
|
|
31
|
+
<script is:inline type="application/json" data-vehicle-cost-config set:html={config}></script>
|
|
32
|
+
<form class="vehicle-cost-surface" data-cost-form>
|
|
33
|
+
<div class="vehicle-cost-controls">
|
|
34
|
+
<div class="vehicle-cost-toolbar">
|
|
35
|
+
<div class="vehicle-cost-presets" aria-label={ui.presetLabel}>
|
|
36
|
+
<span class="vehicle-cost-toolbar-label">{ui.presetLabel}</span>
|
|
37
|
+
<button type="button" data-preset="city">{ui.presetCity}</button>
|
|
38
|
+
<button type="button" data-preset="balanced">{ui.presetBalanced}</button>
|
|
39
|
+
<button type="button" data-preset="distance">{ui.presetLongDistance}</button>
|
|
40
|
+
</div>
|
|
41
|
+
<label class="vehicle-cost-currency-picker">
|
|
42
|
+
<span class="vehicle-cost-toolbar-label">{ui.currencyLabel}</span>
|
|
43
|
+
<select data-currency-select aria-label={ui.currencyLabel}>
|
|
44
|
+
{CURRENCY_OPTIONS.map((option) => <option value={option.code} selected={option.code === initialCurrency}>{option.code} - {currencyName(option.code, option.name)} ({option.symbol})</option>)}
|
|
45
|
+
</select>
|
|
46
|
+
</label>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<section class="vehicle-cost-scenario" aria-labelledby="vehicle-cost-primary-label">
|
|
50
|
+
<div class="vehicle-cost-section-heading">
|
|
51
|
+
<h2 id="vehicle-cost-primary-label">{ui.primaryScenarioLabel}</h2>
|
|
52
|
+
<button type="button" class="vehicle-cost-compare-button" data-action="comparison" aria-pressed="false">{ui.compareOffLabel}</button>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="vehicle-cost-fields">
|
|
55
|
+
<label class="vehicle-cost-field vehicle-cost-field-wide"><span>{ui.distanceLabel}</span><small>{ui.distanceHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="primary" data-field="annualKm" type="number" min="1" step="100" inputmode="decimal" /><b>{ui.inputUnitDistance}</b></div></label>
|
|
56
|
+
<label class="vehicle-cost-field"><span>{ui.fuelUseLabel}</span><small>{ui.fuelUseHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="primary" data-field="fuelUsePer100Km" type="number" min="0" step="0.1" inputmode="decimal" /><b>{ui.inputUnitFuelUse}</b></div></label>
|
|
57
|
+
<label class="vehicle-cost-field"><span>{ui.fuelPriceLabel}</span><small>{ui.fuelPriceHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="primary" data-field="fuelPrice" type="number" min="0" step="0.01" inputmode="decimal" /><b data-currency-unit="price">{ui.inputUnitFuelPrice}</b></div></label>
|
|
58
|
+
<label class="vehicle-cost-field"><span>{ui.insuranceLabel}</span><small>{ui.insuranceHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="primary" data-field="insurance" type="number" min="0" step="10" inputmode="decimal" /><b data-currency-unit="annual">{ui.inputUnitAnnualCost}</b></div></label>
|
|
59
|
+
<label class="vehicle-cost-field"><span>{ui.maintenanceLabel}</span><small>{ui.maintenanceHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="primary" data-field="maintenance" type="number" min="0" step="10" inputmode="decimal" /><b data-currency-unit="annual">{ui.inputUnitAnnualCost}</b></div></label>
|
|
60
|
+
<label class="vehicle-cost-field"><span>{ui.taxLabel}</span><small>{ui.taxHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="primary" data-field="tax" type="number" min="0" step="10" inputmode="decimal" /><b data-currency-unit="annual">{ui.inputUnitAnnualCost}</b></div></label>
|
|
61
|
+
<label class="vehicle-cost-field"><span>{ui.depreciationLabel}</span><small>{ui.depreciationHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="primary" data-field="depreciation" type="number" min="0" step="50" inputmode="decimal" /><b data-currency-unit="annual">{ui.inputUnitAnnualCost}</b></div></label>
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
|
|
65
|
+
<section class="vehicle-cost-scenario vehicle-cost-comparison-panel" data-comparison-panel hidden aria-labelledby="vehicle-cost-comparison-label">
|
|
66
|
+
<div class="vehicle-cost-section-heading"><h2 id="vehicle-cost-comparison-label">{ui.comparisonScenarioLabel}</h2><button type="button" class="vehicle-cost-compare-button" data-action="comparison">{ui.removeComparisonLabel}</button></div>
|
|
67
|
+
<div class="vehicle-cost-fields">
|
|
68
|
+
<label class="vehicle-cost-field vehicle-cost-field-wide"><span>{ui.distanceLabel}</span><small>{ui.distanceHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="secondary" data-field="annualKm" type="number" min="1" step="100" inputmode="decimal" /><b>{ui.inputUnitDistance}</b></div></label>
|
|
69
|
+
<label class="vehicle-cost-field"><span>{ui.fuelUseLabel}</span><small>{ui.fuelUseHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="secondary" data-field="fuelUsePer100Km" type="number" min="0" step="0.1" inputmode="decimal" /><b>{ui.inputUnitFuelUse}</b></div></label>
|
|
70
|
+
<label class="vehicle-cost-field"><span>{ui.fuelPriceLabel}</span><small>{ui.fuelPriceHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="secondary" data-field="fuelPrice" type="number" min="0" step="0.01" inputmode="decimal" /><b data-currency-unit="price">{ui.inputUnitFuelPrice}</b></div></label>
|
|
71
|
+
<label class="vehicle-cost-field"><span>{ui.insuranceLabel}</span><small>{ui.insuranceHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="secondary" data-field="insurance" type="number" min="0" step="10" inputmode="decimal" /><b data-currency-unit="annual">{ui.inputUnitAnnualCost}</b></div></label>
|
|
72
|
+
<label class="vehicle-cost-field"><span>{ui.maintenanceLabel}</span><small>{ui.maintenanceHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="secondary" data-field="maintenance" type="number" min="0" step="10" inputmode="decimal" /><b data-currency-unit="annual">{ui.inputUnitAnnualCost}</b></div></label>
|
|
73
|
+
<label class="vehicle-cost-field"><span>{ui.taxLabel}</span><small>{ui.taxHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="secondary" data-field="tax" type="number" min="0" step="10" inputmode="decimal" /><b data-currency-unit="annual">{ui.inputUnitAnnualCost}</b></div></label>
|
|
74
|
+
<label class="vehicle-cost-field"><span>{ui.depreciationLabel}</span><small>{ui.depreciationHint}</small><div class="vehicle-cost-input-wrap"><input data-cost-input data-scenario="secondary" data-field="depreciation" type="number" min="0" step="50" inputmode="decimal" /><b data-currency-unit="annual">{ui.inputUnitAnnualCost}</b></div></label>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<section class="vehicle-cost-result" aria-labelledby="vehicle-cost-result-label">
|
|
80
|
+
<div class="vehicle-cost-result-heading"><span>{ui.resultLabel}</span><h2 id="vehicle-cost-result-label">{ui.resultTitle}</h2><button type="button" class="vehicle-cost-reset" data-action="reset">{ui.resetLabel}</button></div>
|
|
81
|
+
<p class="vehicle-cost-error" data-result-error hidden></p>
|
|
82
|
+
<div data-result-body>
|
|
83
|
+
<div class="vehicle-cost-scene" data-cost-scene role="img" aria-label={ui.resultSceneLabel}></div>
|
|
84
|
+
<div class="vehicle-cost-metrics">
|
|
85
|
+
<div class="vehicle-cost-primary-metric"><span>{ui.costPerKmLabel}</span><strong data-cost-per-km></strong><b>{ui.outputUnitDistance}</b><em data-status></em></div>
|
|
86
|
+
<div class="vehicle-cost-secondary-metrics"><div><span>{ui.monthlyCostLabel}</span><strong data-monthly-cost></strong></div><div><span>{ui.annualCostLabel}</span><strong data-annual-cost></strong></div><div><span>{ui.fuelCostLabel}</span><strong data-fuel-cost></strong><small data-fuel-litres></small></div><div><span>{ui.fixedCostLabel}</span><strong data-fixed-cost></strong></div></div>
|
|
87
|
+
</div>
|
|
88
|
+
<p class="vehicle-cost-comparison-summary" data-comparison-summary hidden></p>
|
|
89
|
+
</div>
|
|
90
|
+
</section>
|
|
91
|
+
</form>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<script>
|
|
95
|
+
import { createVehicleTotalCostController } from './controller';
|
|
96
|
+
document.querySelectorAll<HTMLElement>('[data-vehicle-total-cost]').forEach((root) => createVehicleTotalCostController(root));
|
|
97
|
+
</script>
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { calculateVehicleCost, compareVehicleCosts, convertVehicleCostInput, getCurrencyOption, isCurrencyCode, type CurrencyCode, type VehicleCostInput } from './logic';
|
|
2
|
+
import { renderCostScene, formatMoney, formatNumber } from './dom-views';
|
|
3
|
+
import { evaluateVehicleCost } from './evaluator';
|
|
4
|
+
import { loadVehicleCostState, saveVehicleCostState, type VehicleCostStorageState } from './storage';
|
|
5
|
+
import type { VehicleTotalCostPerKilometreCalculatorUI } from './ui';
|
|
6
|
+
|
|
7
|
+
interface ControllerConfig {
|
|
8
|
+
ui: VehicleTotalCostPerKilometreCalculatorUI;
|
|
9
|
+
defaults: VehicleCostStorageState;
|
|
10
|
+
locale: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface RenderContext {
|
|
14
|
+
currency: CurrencyCode;
|
|
15
|
+
locale: string;
|
|
16
|
+
ui: VehicleTotalCostPerKilometreCalculatorUI;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ControllerRuntime {
|
|
20
|
+
root: HTMLElement;
|
|
21
|
+
config: ControllerConfig;
|
|
22
|
+
currencySelect: HTMLSelectElement | null;
|
|
23
|
+
state: VehicleCostStorageState;
|
|
24
|
+
render: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const storageKey = 'jjlmoya:vehicle-total-cost-per-kilometre-calculator';
|
|
28
|
+
|
|
29
|
+
function readScenario(root: HTMLElement, prefix: string): VehicleCostInput {
|
|
30
|
+
const read = (field: string): number => Number(root.querySelector<HTMLInputElement>(`[data-scenario="${prefix}"][data-field="${field}"]`)?.value ?? 0);
|
|
31
|
+
return { annualKm: read('annualKm'), fuelUsePer100Km: read('fuelUsePer100Km'), fuelPrice: read('fuelPrice'), insurance: read('insurance'), maintenance: read('maintenance'), tax: read('tax'), depreciation: read('depreciation') };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeScenario(root: HTMLElement, prefix: string, input: VehicleCostInput): void {
|
|
35
|
+
Object.entries(input).forEach(([field, value]) => {
|
|
36
|
+
const element = root.querySelector<HTMLInputElement>(`[data-scenario="${prefix}"][data-field="${field}"]`);
|
|
37
|
+
if (element) element.value = String(value);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setText(root: HTMLElement, selector: string, text: string): void {
|
|
42
|
+
const element = root.querySelector<HTMLElement>(selector);
|
|
43
|
+
if (element) element.textContent = text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function updateComparisonVisibility(root: HTMLElement, enabled: boolean, ui: VehicleTotalCostPerKilometreCalculatorUI): void {
|
|
47
|
+
const panel = root.querySelector<HTMLElement>('[data-comparison-panel]');
|
|
48
|
+
const toggle = root.querySelector<HTMLButtonElement>('[data-action="comparison"]');
|
|
49
|
+
if (panel) panel.hidden = !enabled;
|
|
50
|
+
if (toggle) {
|
|
51
|
+
toggle.setAttribute('aria-pressed', String(enabled));
|
|
52
|
+
toggle.textContent = enabled ? ui.compareOnLabel : ui.compareOffLabel;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderPrimaryMetrics(root: HTMLElement, primary: ReturnType<typeof calculateVehicleCost>, context: RenderContext): void {
|
|
57
|
+
if (!primary) return;
|
|
58
|
+
const evaluation = evaluateVehicleCost(primary);
|
|
59
|
+
const status = root.querySelector<HTMLElement>('[data-status]');
|
|
60
|
+
if (status) { status.textContent = context.ui[evaluation.labelKey]; status.dataset.profile = evaluation.profile; }
|
|
61
|
+
setText(root, '[data-cost-per-km]', formatMoney(primary.costPerKm, context.currency, context.locale));
|
|
62
|
+
setText(root, '[data-monthly-cost]', formatMoney(primary.monthlyCost, context.currency, context.locale));
|
|
63
|
+
setText(root, '[data-annual-cost]', formatMoney(primary.annualCost, context.currency, context.locale));
|
|
64
|
+
setText(root, '[data-fuel-cost]', formatMoney(primary.annualFuelCost, context.currency, context.locale));
|
|
65
|
+
setText(root, '[data-fixed-cost]', formatMoney(primary.fixedCost, context.currency, context.locale));
|
|
66
|
+
setText(root, '[data-fuel-litres]', `${formatNumber(primary.annualFuelLitres, context.locale)} L`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderComparisonSummary(root: HTMLElement, comparison: ReturnType<typeof compareVehicleCosts>, context: RenderContext): void {
|
|
70
|
+
const comparisonText = root.querySelector<HTMLElement>('[data-comparison-summary]');
|
|
71
|
+
if (!comparisonText) return;
|
|
72
|
+
comparisonText.hidden = !comparison;
|
|
73
|
+
if (!comparison) return;
|
|
74
|
+
let template = context.ui.comparisonSame;
|
|
75
|
+
if (comparison.cheaper === 'primary') template = context.ui.comparisonPrimaryCheaper;
|
|
76
|
+
if (comparison.cheaper === 'secondary') template = context.ui.comparisonSecondaryCheaper;
|
|
77
|
+
comparisonText.textContent = `${context.ui.comparisonTitle}: ${template} ${formatMoney(Math.abs(comparison.differencePerKm), context.currency, context.locale)} / ${context.ui.outputUnitDistance}.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderCurrencyUnits(root: HTMLElement, currency: CurrencyCode, ui: VehicleTotalCostPerKilometreCalculatorUI): void {
|
|
81
|
+
const symbol = getCurrencyOption(currency).symbol;
|
|
82
|
+
root.querySelectorAll<HTMLElement>('[data-currency-unit="price"]').forEach((element) => { element.textContent = `${symbol} ${ui.inputUnitFuelPrice}`; });
|
|
83
|
+
root.querySelectorAll<HTMLElement>('[data-currency-unit="annual"]').forEach((element) => { element.textContent = `${symbol} ${ui.inputUnitAnnualCost}`; });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderInvalidResult(root: HTMLElement, message: string): void {
|
|
87
|
+
const error = root.querySelector<HTMLElement>('[data-result-error]');
|
|
88
|
+
const body = root.querySelector<HTMLElement>('[data-result-body]');
|
|
89
|
+
if (error) { error.hidden = false; error.textContent = message; }
|
|
90
|
+
if (body) body.hidden = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderValidResult(root: HTMLElement): void {
|
|
94
|
+
const error = root.querySelector<HTMLElement>('[data-result-error]');
|
|
95
|
+
const body = root.querySelector<HTMLElement>('[data-result-body]');
|
|
96
|
+
if (error) error.hidden = true;
|
|
97
|
+
if (body) body.hidden = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderResult(root: HTMLElement, state: VehicleCostStorageState, config: ControllerConfig): void {
|
|
101
|
+
const { ui, locale } = config;
|
|
102
|
+
const primary = calculateVehicleCost(state.primary);
|
|
103
|
+
const secondary = state.comparisonEnabled ? calculateVehicleCost(state.secondary) : null;
|
|
104
|
+
if (!primary || (state.comparisonEnabled && !secondary)) {
|
|
105
|
+
renderInvalidResult(root, ui.validationError);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
renderValidResult(root);
|
|
109
|
+
const context = { currency: state.currency, locale, ui };
|
|
110
|
+
renderPrimaryMetrics(root, primary, context);
|
|
111
|
+
renderCostScene({ container: root.querySelector<HTMLElement>('[data-cost-scene]') as HTMLElement, primary, secondary, ...context });
|
|
112
|
+
const comparison = secondary ? compareVehicleCosts(state.primary, state.secondary) : null;
|
|
113
|
+
renderComparisonSummary(root, comparison, context);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function makeState(root: HTMLElement, previous: VehicleCostStorageState): VehicleCostStorageState {
|
|
117
|
+
return { ...previous, primary: readScenario(root, 'primary'), secondary: readScenario(root, 'secondary') };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getPresetInput(preset: string): VehicleCostInput | null {
|
|
121
|
+
if (preset === 'city') return { annualKm: 10000, fuelUsePer100Km: 8.2, fuelPrice: 1.75, insurance: 620, maintenance: 520, tax: 160, depreciation: 1800 };
|
|
122
|
+
if (preset === 'balanced') return { annualKm: 15000, fuelUsePer100Km: 6.4, fuelPrice: 1.75, insurance: 700, maintenance: 650, tax: 190, depreciation: 2600 };
|
|
123
|
+
if (preset === 'distance') return { annualKm: 24000, fuelUsePer100Km: 5.4, fuelPrice: 1.72, insurance: 780, maintenance: 850, tax: 220, depreciation: 3200 };
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function bindCurrencyEvent(runtime: ControllerRuntime): void {
|
|
128
|
+
const { root, config, currencySelect } = runtime;
|
|
129
|
+
currencySelect?.addEventListener('change', () => {
|
|
130
|
+
const nextCurrency = currencySelect.value;
|
|
131
|
+
if (!isCurrencyCode(nextCurrency)) return;
|
|
132
|
+
runtime.state.primary = convertVehicleCostInput(runtime.state.primary, runtime.state.currency, nextCurrency);
|
|
133
|
+
runtime.state.secondary = convertVehicleCostInput(runtime.state.secondary, runtime.state.currency, nextCurrency);
|
|
134
|
+
runtime.state.currency = nextCurrency;
|
|
135
|
+
writeScenario(root, 'primary', runtime.state.primary);
|
|
136
|
+
writeScenario(root, 'secondary', runtime.state.secondary);
|
|
137
|
+
renderCurrencyUnits(root, runtime.state.currency, config.ui);
|
|
138
|
+
runtime.render();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function bindPresetEvents(runtime: ControllerRuntime): void {
|
|
143
|
+
const { root } = runtime;
|
|
144
|
+
root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => button.addEventListener('click', () => {
|
|
145
|
+
const preset = getPresetInput(button.dataset.preset ?? '');
|
|
146
|
+
if (!preset) return;
|
|
147
|
+
runtime.state.primary = convertVehicleCostInput(preset, 'EUR', runtime.state.currency);
|
|
148
|
+
writeScenario(root, 'primary', runtime.state.primary);
|
|
149
|
+
runtime.render();
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function bindScenarioEvents(runtime: ControllerRuntime): void {
|
|
154
|
+
const { root, config, currencySelect } = runtime;
|
|
155
|
+
root.querySelector<HTMLButtonElement>('[data-action="comparison"]')?.addEventListener('click', () => {
|
|
156
|
+
runtime.state.comparisonEnabled = !runtime.state.comparisonEnabled;
|
|
157
|
+
updateComparisonVisibility(root, runtime.state.comparisonEnabled, config.ui);
|
|
158
|
+
runtime.render();
|
|
159
|
+
});
|
|
160
|
+
root.querySelector<HTMLButtonElement>('[data-action="reset"]')?.addEventListener('click', () => {
|
|
161
|
+
runtime.state = { ...config.defaults, primary: { ...config.defaults.primary }, secondary: { ...config.defaults.secondary } };
|
|
162
|
+
writeScenario(root, 'primary', runtime.state.primary);
|
|
163
|
+
writeScenario(root, 'secondary', runtime.state.secondary);
|
|
164
|
+
if (currencySelect) currencySelect.value = runtime.state.currency;
|
|
165
|
+
renderCurrencyUnits(root, runtime.state.currency, config.ui);
|
|
166
|
+
updateComparisonVisibility(root, runtime.state.comparisonEnabled, config.ui);
|
|
167
|
+
runtime.render();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function bindControllerEvents(runtime: ControllerRuntime): void {
|
|
172
|
+
runtime.root.querySelectorAll<HTMLInputElement>('[data-cost-input]').forEach((input) => input.addEventListener('input', runtime.render));
|
|
173
|
+
bindCurrencyEvent(runtime);
|
|
174
|
+
bindPresetEvents(runtime);
|
|
175
|
+
bindScenarioEvents(runtime);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function createVehicleTotalCostController(root: HTMLElement): void {
|
|
179
|
+
const script = root.querySelector<HTMLScriptElement>('[data-vehicle-cost-config]');
|
|
180
|
+
if (!script) return;
|
|
181
|
+
const config = JSON.parse(script.textContent ?? '{}') as ControllerConfig;
|
|
182
|
+
const currencySelect = root.querySelector<HTMLSelectElement>('[data-currency-select]');
|
|
183
|
+
const runtime: ControllerRuntime = {
|
|
184
|
+
root, config, currencySelect, state: loadVehicleCostState(storageKey, config.defaults), render: () => {},
|
|
185
|
+
};
|
|
186
|
+
writeScenario(root, 'primary', runtime.state.primary);
|
|
187
|
+
writeScenario(root, 'secondary', runtime.state.secondary);
|
|
188
|
+
updateComparisonVisibility(root, runtime.state.comparisonEnabled, config.ui);
|
|
189
|
+
if (currencySelect) currencySelect.value = runtime.state.currency;
|
|
190
|
+
renderCurrencyUnits(root, runtime.state.currency, config.ui);
|
|
191
|
+
runtime.render = (): void => {
|
|
192
|
+
runtime.state = makeState(root, runtime.state);
|
|
193
|
+
saveVehicleCostState(storageKey, runtime.state);
|
|
194
|
+
renderResult(root, runtime.state, config);
|
|
195
|
+
};
|
|
196
|
+
bindControllerEvents(runtime);
|
|
197
|
+
runtime.render();
|
|
198
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { VehicleCostResult } from './logic';
|
|
2
|
+
import type { CurrencyCode } from './logic';
|
|
3
|
+
import type { VehicleTotalCostPerKilometreCalculatorUI } from './ui';
|
|
4
|
+
|
|
5
|
+
export function formatMoney(value: number, currency: CurrencyCode, locale = 'en'): string {
|
|
6
|
+
return new Intl.NumberFormat(locale, {
|
|
7
|
+
style: 'currency', currency, currencyDisplay: 'symbol', maximumFractionDigits: 2,
|
|
8
|
+
}).format(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatNumber(value: number, locale = 'en'): string {
|
|
12
|
+
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function escapeHtml(value: string): string {
|
|
16
|
+
return value.replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character] ?? character));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SceneRow {
|
|
20
|
+
result: VehicleCostResult;
|
|
21
|
+
label: string;
|
|
22
|
+
currency: CurrencyCode;
|
|
23
|
+
locale: string;
|
|
24
|
+
ui: VehicleTotalCostPerKilometreCalculatorUI;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rowMarkup({ result, label, currency, locale, ui, y }: SceneRow): string {
|
|
29
|
+
const fixedWidth = Math.max(26, result.fixedShare * 210);
|
|
30
|
+
const fuelWidth = Math.max(26, result.runningShare * 210);
|
|
31
|
+
return `<g transform="translate(0 ${y})"><text x="0" y="0" class="route-label">${escapeHtml(label)}</text><rect x="0" y="18" width="240" height="12" rx="6" class="route-base"/><rect x="0" y="18" width="${fixedWidth}" height="12" rx="6" class="route-fixed"/><rect x="${Math.max(0, fixedWidth - 4)}" y="18" width="${fuelWidth}" height="12" rx="6" class="route-fuel"/><circle cx="${Math.min(236, fixedWidth + fuelWidth)}" cy="24" r="7" class="route-end"/><text x="0" y="55" class="route-meta">${escapeHtml(ui.fixedLaneLabel)} ${escapeHtml(formatMoney(result.fixedCost, currency, locale))}</text><text x="240" y="55" text-anchor="end" class="route-meta">${escapeHtml(ui.fuelLaneLabel)} ${escapeHtml(formatMoney(result.annualFuelCost, currency, locale))}</text><text x="0" y="80" class="route-total">${escapeHtml(ui.totalLaneLabel)} ${escapeHtml(formatMoney(result.costPerKm, currency, locale))} / ${escapeHtml(ui.outputUnitDistance)}</text></g>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SceneConfig {
|
|
35
|
+
container: HTMLElement;
|
|
36
|
+
primary: VehicleCostResult;
|
|
37
|
+
secondary: VehicleCostResult | null;
|
|
38
|
+
currency: CurrencyCode;
|
|
39
|
+
locale: string;
|
|
40
|
+
ui: VehicleTotalCostPerKilometreCalculatorUI;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderCostScene({ container, primary, secondary, currency, locale, ui }: SceneConfig): void {
|
|
44
|
+
const height = secondary ? 250 : 150;
|
|
45
|
+
const rows = rowMarkup({ result: primary, label: ui.primaryScenarioLabel, currency, locale, ui, y: 18 }) + (secondary ? rowMarkup({ result: secondary, label: ui.comparisonScenarioLabel, currency, locale, ui, y: 132 }) : '');
|
|
46
|
+
container.innerHTML = `<svg viewBox="0 0 280 ${height}" role="img" aria-label="${escapeHtml(ui.resultSceneLabel)}"><path d="M0 106 C72 78 136 136 210 102 S260 76 280 88" class="route-path" fill="none"/><path d="M0 220 C72 192 136 250 210 216 S260 190 280 202" class="route-path route-path-secondary" fill="none"/>${rows}</svg>`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MotorToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { VehicleTotalCostPerKilometreCalculatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { VehicleTotalCostPerKilometreCalculatorUI };
|
|
5
|
+
export type VehicleTotalCostPerKilometreCalculatorLocaleContent = ToolLocaleContent<VehicleTotalCostPerKilometreCalculatorUI>;
|
|
6
|
+
|
|
7
|
+
export const vehicleTotalCostPerKilometreCalculator: MotorToolEntry<VehicleTotalCostPerKilometreCalculatorUI> = {
|
|
8
|
+
id: 'vehicle-total-cost-per-kilometre-calculator',
|
|
9
|
+
icons: {
|
|
10
|
+
bg: 'mdi:road-variant',
|
|
11
|
+
fg: 'mdi:cash-multiple',
|
|
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,14 @@
|
|
|
1
|
+
import type { VehicleCostResult } from './logic';
|
|
2
|
+
|
|
3
|
+
export type CostProfile = 'balanced' | 'fixed-heavy' | 'running-heavy';
|
|
4
|
+
|
|
5
|
+
export interface CostEvaluation {
|
|
6
|
+
profile: CostProfile;
|
|
7
|
+
labelKey: 'statusBalanced' | 'statusFixedHeavy' | 'statusRunningHeavy';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function evaluateVehicleCost(result: VehicleCostResult): CostEvaluation {
|
|
11
|
+
if (result.fixedShare >= 0.65) return { profile: 'fixed-heavy', labelKey: 'statusFixedHeavy' };
|
|
12
|
+
if (result.runningShare >= 0.65) return { profile: 'running-heavy', labelKey: 'statusRunningHeavy' };
|
|
13
|
+
return { profile: 'balanced', labelKey: 'statusBalanced' };
|
|
14
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FAQItem, HowToStep } from '../../../types';
|
|
2
|
+
import { createVehicleCostContent, createVehicleCostSeo } from './factory';
|
|
3
|
+
import type { VehicleTotalCostPerKilometreCalculatorUI } from '../ui';
|
|
4
|
+
|
|
5
|
+
const ui: VehicleTotalCostPerKilometreCalculatorUI = {
|
|
6
|
+
presetLabel: 'Mit einem realistischen Muster beginnen', presetCity: 'Überwiegend Stadt', presetBalanced: 'Gemischte Strecke', presetLongDistance: 'Lange Strecke', resetLabel: 'Werte zurücksetzen',
|
|
7
|
+
primaryScenarioLabel: 'Dein Fahrzeug', comparisonScenarioLabel: 'Zweites Szenario', distanceLabel: 'Kilometer pro Jahr', distanceHint: 'Gib die erwartete Jahresfahrleistung ein', fuelUseLabel: 'Verbrauch', fuelUseHint: 'Durchschnitt deines Fahrzeugs in Litern pro 100 km', fuelPriceLabel: 'Kraftstoffpreis', fuelPriceHint: 'Preis pro Liter', insuranceLabel: 'Versicherung pro Jahr', insuranceHint: 'Deine tatsächliche Jahresprämie', maintenanceLabel: 'Wartung pro Jahr', maintenanceHint: 'Service, Reparaturen, Reifen und Prüfungen', taxLabel: 'Steuern und Gebühren pro Jahr', taxHint: 'Kfz-Steuer, Zulassung und regelmäßige Gebühren', depreciationLabel: 'Wertverlust pro Jahr', depreciationHint: 'Geschätzter Wertverlust während eines Jahres', currencyLabel: 'Währung', compareLabel: 'Szenario vergleichen', compareOnLabel: 'Vergleich aktiv', compareOffLabel: 'Vergleich hinzufügen', removeComparisonLabel: 'Vergleich ausblenden', resultLabel: 'Die Kostenstrecke', resultTitle: 'Was jeder Kilometer wirklich kostet', resultSceneLabel: 'Eine Strecke zeigt feste Fahrzeugkosten und Kraftstoffkosten bis zum Kilometerpreis', costPerKmLabel: 'Kosten pro Kilometer', monthlyCostLabel: 'Monatlicher Durchschnitt', annualCostLabel: 'Gesamtes Jahr', fuelCostLabel: 'Kraftstoff pro Jahr', fixedCostLabel: 'Feste und laufende Kosten', statusBalanced: 'Die Kosten verteilen sich gleichmäßig', statusFixedHeavy: 'Die Besitzkosten tragen den größten Anteil', statusRunningHeavy: 'Der Kraftstoff trägt den größten Anteil', validationError: 'Gib eine positive Jahresfahrleistung und keine negativen Kosten ein.', comparisonTitle: 'Szenarienvergleich', comparisonPrimaryCheaper: 'Dein Fahrzeug ist günstiger um', comparisonSecondaryCheaper: 'Das zweite Szenario ist günstiger um', comparisonSame: 'Beide Szenarien kosten ungefähr gleich viel', fixedLaneLabel: 'Fix', fuelLaneLabel: 'Kraftstoff', totalLaneLabel: 'Gesamt', formulaLabel: 'Berechnung', formulaText: 'Jahreskosten = Jahreskilometer × Verbrauch ÷ 100 × Kraftstoffpreis + Versicherung + Wartung + Steuern + Wertverlust. Kosten pro Kilometer = Jahreskosten ÷ Jahreskilometer.', inputUnitDistance: 'km', inputUnitFuelUse: 'L / 100 km', inputUnitFuelPrice: '/ L', inputUnitAnnualCost: '/ Jahr', outputUnitDistance: 'km',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const faq: FAQItem[] = [
|
|
11
|
+
{ question: 'Welche Kosten berücksichtigt der Rechner?', answer: 'Er verbindet Kraftstoff mit Versicherung, Wartung, Steuern und Gebühren sowie dem geschätzten jährlichen Wertverlust. Du gibst deine eigenen Zahlen ein, damit das Ergebnis zu deinem Fahrzeug und deiner Nutzung passt.' },
|
|
12
|
+
{ question: 'Warum ist das Ergebnis höher als beim Kraftstoffrechner?', answer: 'Kraftstoff ist nur der variable Teil. Versicherung, Reparaturen, Gebühren und Wertverlust können einen niedrigen Verbrauch trotzdem teuer pro Kilometer machen.' },
|
|
13
|
+
{ question: 'Wie schätze ich den Wertverlust?', answer: 'Ziehe den erwarteten Jahresendwert vom Anfangswert ab. Wenn der Wiederverkaufswert unsicher ist, rechne mit einem niedrigen und einem hohen Szenario.' },
|
|
14
|
+
{ question: 'Kann ich zwei Fahrzeuge vergleichen?', answer: 'Ja. Aktiviere den Vergleich und verwende für beide Szenarien denselben Zeitraum, dieselbe Entfernung und dieselbe Währung.' },
|
|
15
|
+
{ question: 'Ist das Ergebnis eine exakte Prognose?', answer: 'Nein. Es ist eine transparente Planungshilfe. Kraftstoffpreise, Reparaturen, Fahrleistung und Wiederverkaufswert können sich ändern.' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const howTo: HowToStep[] = [
|
|
19
|
+
{ name: 'Jahresstrecke festlegen', text: 'Gib ein, wie viele Kilometer du voraussichtlich pro Jahr fährst.' },
|
|
20
|
+
{ name: 'Kraftstoff ergänzen', text: 'Trage den Verbrauch und den tatsächlichen Preis pro Liter ein.' },
|
|
21
|
+
{ name: 'Besitzkosten ergänzen', text: 'Füge Versicherung, Wartung, Gebühren und jährlichen Wertverlust hinzu.' },
|
|
22
|
+
{ name: 'Ergebnis vergleichen', text: 'Nutze die Kosten pro Kilometer und prüfe zusätzlich die festen und variablen Anteile.' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export const content = createVehicleCostContent({
|
|
26
|
+
locale: 'de', slug: 'fahrzeug-gesamtkosten-pro-kilometer-rechner', title: 'Rechner für die Gesamtkosten eines Fahrzeugs pro Kilometer', description: 'Berechne die echten Fahrzeugkosten pro Kilometer mit Kraftstoff, Besitzkosten und Jahresfahrleistung.', faqTitle: 'Häufige Fragen', bibliographyTitle: 'Bibliografische Quellen', ui, faq, howTo,
|
|
27
|
+
seo: createVehicleCostSeo({
|
|
28
|
+
introTitle: 'Die Kosten hinter jedem Kilometer sichtbar machen', introA: 'Ein Kraftstoffpreis allein zeigt nicht, was ein Fahrzeug wirklich kostet. Versicherung, Wartung, Reifen, Steuern und der Wertverlust gehören ebenfalls zur Entscheidung. Dieser Rechner verteilt die jährlichen Kosten auf deine erwartete Fahrleistung und stellt sie dem Kraftstoffverbrauch gegenüber.', introB: 'Trage deine eigenen Werte ein, statt dich auf einen allgemeinen Durchschnitt zu verlassen. Das wichtigste Ergebnis sind die Kosten pro Kilometer, ergänzt um Monatswert, Jahreswert und die visuelle Aufteilung zwischen Besitz und Betrieb.', costsTitle: 'Welche Kosten gehören in die Schätzung', costsText: 'Kraftstoff verändert sich mit der Entfernung. Versicherung, Wartung, Gebühren und Wertverlust werden als jährliche Beträge eingegeben. Bei geringer Fahrleistung wiegen feste Kosten pro Kilometer stärker.', costsHeaders: ['Eingabe', 'Verwendung im Rechner', 'Worauf achten'], costsRows: [['Jahresfahrleistung', 'Verteilt die Gesamtkosten auf Kilometer', 'Nutze eine realistische Entfernung'], ['Verbrauch und Preis', 'Erzeugen die variablen Kraftstoffkosten', 'Verwende dieselbe Grundlage in beiden Szenarien'], ['Versicherung, Wartung und Gebühren', 'Addieren wiederkehrende Jahreskosten', 'Vergiss Reifen und Prüfungen nicht'], ['Wertverlust', 'Fügt eine jährliche Abschreibung hinzu', 'Teste mehrere Wiederverkaufswerte']], readTitle: 'Die Kostenstrecke lesen', readText: 'Der feste Abschnitt zeigt Kosten, die nicht direkt mit jedem Kilometer steigen. Der Kraftstoffabschnitt zeigt den entfernungsabhängigen Anteil. Der Endpunkt ist der vollständige Kilometerpreis und eignet sich zum Vergleich.', list: ['Rechne bei unsicherem Wiederverkaufswert mit einer niedrigen und hohen Annahme.', 'Vergleiche Fahrzeuge mit derselben Jahresfahrleistung und Währung.', 'Ersetze Schätzungen nach einigen Monaten durch deine echten Belege.', 'Prüfe Monats- und Jahreswert gegen dein Budget.'], decisionTitle: 'Vergleiche Annahmen statt nur Fahrzeuge', decisionText: 'Ein Vergleich ist nur sinnvoll, wenn beide Szenarien denselben Besitzzeitraum und dieselben Regeln verwenden. Ändere jeweils nur eine Annahme, wenn du einen Kauf oder eine regelmäßige Strecke untersuchst.', tipTitle: 'Das ist ein Planungsmodell, kein Angebot', tipText: 'Der Rechner ruft keine Marktpreise ab, bewertet keine rechtlichen Erstattungen und kennt den zukünftigen Wiederverkaufswert nicht. Er macht deine eigenen Annahmen transparent.'
|
|
29
|
+
}),
|
|
30
|
+
});
|