@jjlmoya/utils-motor 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +2 -2
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +2 -1
  4. package/src/index.ts +5 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/evChargingTimeCostPlanner/bibliography.astro +6 -0
  8. package/src/tool/evChargingTimeCostPlanner/bibliography.ts +16 -0
  9. package/src/tool/evChargingTimeCostPlanner/component.astro +147 -0
  10. package/src/tool/evChargingTimeCostPlanner/controller.ts +214 -0
  11. package/src/tool/evChargingTimeCostPlanner/dom-views.ts +124 -0
  12. package/src/tool/evChargingTimeCostPlanner/entry.ts +30 -0
  13. package/src/tool/evChargingTimeCostPlanner/ev-charging-time-cost-planner.css +598 -0
  14. package/src/tool/evChargingTimeCostPlanner/evaluator.ts +10 -0
  15. package/src/tool/evChargingTimeCostPlanner/i18n/de.ts +22 -0
  16. package/src/tool/evChargingTimeCostPlanner/i18n/en.ts +161 -0
  17. package/src/tool/evChargingTimeCostPlanner/i18n/es.ts +16 -0
  18. package/src/tool/evChargingTimeCostPlanner/i18n/factory.ts +113 -0
  19. package/src/tool/evChargingTimeCostPlanner/i18n/fr.ts +9 -0
  20. package/src/tool/evChargingTimeCostPlanner/i18n/id.ts +9 -0
  21. package/src/tool/evChargingTimeCostPlanner/i18n/it.ts +9 -0
  22. package/src/tool/evChargingTimeCostPlanner/i18n/ja.ts +13 -0
  23. package/src/tool/evChargingTimeCostPlanner/i18n/ko.ts +13 -0
  24. package/src/tool/evChargingTimeCostPlanner/i18n/nl.ts +9 -0
  25. package/src/tool/evChargingTimeCostPlanner/i18n/pl.ts +9 -0
  26. package/src/tool/evChargingTimeCostPlanner/i18n/pt.ts +9 -0
  27. package/src/tool/evChargingTimeCostPlanner/i18n/ru.ts +13 -0
  28. package/src/tool/evChargingTimeCostPlanner/i18n/sv.ts +9 -0
  29. package/src/tool/evChargingTimeCostPlanner/i18n/tr.ts +9 -0
  30. package/src/tool/evChargingTimeCostPlanner/i18n/zh.ts +13 -0
  31. package/src/tool/evChargingTimeCostPlanner/index.ts +10 -0
  32. package/src/tool/evChargingTimeCostPlanner/logic.test.ts +71 -0
  33. package/src/tool/evChargingTimeCostPlanner/logic.ts +174 -0
  34. package/src/tool/evChargingTimeCostPlanner/seo.astro +15 -0
  35. package/src/tool/evChargingTimeCostPlanner/storage.ts +27 -0
  36. package/src/tool/evChargingTimeCostPlanner/ui.ts +50 -0
  37. package/src/tools.ts +4 -1
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CHARGING_PRESETS, calculateChargingPlan } from './logic';
3
+ import { evaluateChargingStatus } from './evaluator';
4
+
5
+ describe('EV charging planner', () => {
6
+ it('calculates battery energy, grid energy, time, cost, losses, and range', () => {
7
+ const plan = calculateChargingPlan(CHARGING_PRESETS[0]!.inputs);
8
+
9
+ expect(plan.chargeFraction).toBeCloseTo(0.48);
10
+ expect(plan.batteryEnergyKwh).toBeCloseTo(28.8);
11
+ expect(plan.gridEnergyKwh).toBeCloseTo(32);
12
+ expect(plan.lossEnergyKwh).toBeCloseTo(3.2);
13
+ expect(plan.timeMinutes).toBe(260);
14
+ expect(plan.cost).toBeCloseTo(8);
15
+ expect(plan.recoveredRangeKm).toBeCloseTo(160);
16
+ expect(evaluateChargingStatus(plan)).toBe('long');
17
+ });
18
+
19
+ it('returns no charge when the target is below the starting level', () => {
20
+ const plan = calculateChargingPlan({ ...CHARGING_PRESETS[0]!.inputs, startSoc: 80, endSoc: 40 });
21
+
22
+ expect(plan.chargeFraction).toBe(0);
23
+ expect(plan.timeMinutes).toBe(0);
24
+ expect(plan.cost).toBe(0);
25
+ expect(evaluateChargingStatus(plan)).toBe('empty');
26
+ });
27
+
28
+ it('calculates the recovered range from the exact charge window', () => {
29
+ const plan = calculateChargingPlan({ ...CHARGING_PRESETS[1]!.inputs, endSoc: 90 });
30
+
31
+ expect(plan.batteryEnergyKwh).toBeCloseTo(34.8);
32
+ expect(plan.gridEnergyKwh).toBeCloseTo(37.8261, 3);
33
+ expect(plan.recoveredRangeKm).toBeCloseTo(193.3333, 3);
34
+ });
35
+
36
+ it('clamps invalid values to safe calculation bounds', () => {
37
+ const plan = calculateChargingPlan({
38
+ capacityKwh: Number.NaN,
39
+ startSoc: -10,
40
+ endSoc: 140,
41
+ chargerPowerKw: 0,
42
+ efficiency: 0,
43
+ pricePerKwh: -1,
44
+ consumptionKwhPer100Km: 0,
45
+ currency: 'USD',
46
+ });
47
+
48
+ expect(plan.batteryEnergyKwh).toBe(60);
49
+ expect(plan.gridEnergyKwh).toBe(120);
50
+ expect(plan.timeMinutes).toBe(72000);
51
+ expect(plan.cost).toBe(0);
52
+ expect(plan.recoveredRangeKm).toBe(6000);
53
+ });
54
+
55
+ it('converts the same tariff through the selected currency factor', () => {
56
+ const euroPlan = calculateChargingPlan({ ...CHARGING_PRESETS[0]!.inputs, currency: 'EUR' });
57
+ const dollarPlan = calculateChargingPlan({ ...CHARGING_PRESETS[0]!.inputs, currency: 'USD', pricePerKwh: 0.25 * 1.1643 });
58
+
59
+ expect(dollarPlan.exchangeFactor).toBeCloseTo(1.1643);
60
+ expect(dollarPlan.pricePerKwhEur).toBeCloseTo(0.25);
61
+ expect(dollarPlan.cost).toBeCloseTo(euroPlan.cost * 1.1643);
62
+ });
63
+
64
+ it('classifies short and standard charge windows', () => {
65
+ const shortPlan = calculateChargingPlan({ ...CHARGING_PRESETS[0]!.inputs, startSoc: 50, endSoc: 52, chargerPowerKw: 100 });
66
+ const standardPlan = calculateChargingPlan({ ...CHARGING_PRESETS[0]!.inputs, startSoc: 40, endSoc: 60, chargerPowerKw: 7.4 });
67
+
68
+ expect(evaluateChargingStatus(shortPlan)).toBe('short');
69
+ expect(evaluateChargingStatus(standardPlan)).toBe('standard');
70
+ });
71
+ });
@@ -0,0 +1,174 @@
1
+ export interface CurrencyOption {
2
+ code: string;
3
+ symbol: string;
4
+ name: string;
5
+ unitsPerEur: number;
6
+ }
7
+
8
+ export const CURRENCY_OPTIONS = [
9
+ { code: 'EUR', symbol: '€', name: 'Euro', unitsPerEur: 1 },
10
+ { code: 'USD', symbol: '$', name: 'US dollar', unitsPerEur: 1.1643 },
11
+ { code: 'GBP', symbol: '£', name: 'Pound sterling', unitsPerEur: 0.8572 },
12
+ { code: 'JPY', symbol: '¥', name: 'Japanese yen', unitsPerEur: 185.92 },
13
+ { code: 'CNY', symbol: 'CN¥', name: 'Chinese yuan', unitsPerEur: 7.8251 },
14
+ { code: 'INR', symbol: '₹', name: 'Indian rupee', unitsPerEur: 111.0585 },
15
+ { code: 'BRL', symbol: 'R$', name: 'Brazilian real', unitsPerEur: 6.0126 },
16
+ { code: 'MXN', symbol: 'MX$', name: 'Mexican peso', unitsPerEur: 19.7327 },
17
+ { code: 'CAD', symbol: 'CA$', name: 'Canadian dollar', unitsPerEur: 1.613 },
18
+ { code: 'AUD', symbol: 'A$', name: 'Australian dollar', unitsPerEur: 1.6183 },
19
+ { code: 'CHF', symbol: 'CHF', name: 'Swiss franc', unitsPerEur: 0.9364 },
20
+ { code: 'SEK', symbol: 'kr', name: 'Swedish krona', unitsPerEur: 11.0885 },
21
+ { code: 'PLN', symbol: 'zł', name: 'Polish zloty', unitsPerEur: 4.3365 },
22
+ { code: 'TRY', symbol: '₺', name: 'Turkish lira', unitsPerEur: 56.1718 },
23
+ { code: 'KRW', symbol: '₩', name: 'South Korean won', unitsPerEur: 1600.39 },
24
+ { code: 'IDR', symbol: 'Rp', name: 'Indonesian rupiah', unitsPerEur: 20628.08 },
25
+ ] as const;
26
+
27
+ export type CurrencyCode = typeof CURRENCY_OPTIONS[number]['code'];
28
+
29
+ export const DEFAULT_CURRENCY: CurrencyCode = 'EUR';
30
+
31
+ export function isCurrencyCode(value: unknown): value is CurrencyCode {
32
+ return typeof value === 'string' && CURRENCY_OPTIONS.some((option) => option.code === value);
33
+ }
34
+
35
+ export function getCurrencyOption(code: CurrencyCode): CurrencyOption {
36
+ return CURRENCY_OPTIONS.find((option) => option.code === code) ?? CURRENCY_OPTIONS[0]!;
37
+ }
38
+
39
+ export function convertCurrencyAmount(amount: number, from: CurrencyCode, to: CurrencyCode): number {
40
+ const fromFactor = getCurrencyOption(from).unitsPerEur;
41
+ const toFactor = getCurrencyOption(to).unitsPerEur;
42
+ return (amount / fromFactor) * toFactor;
43
+ }
44
+
45
+ export function getPriceInputStep(currency: CurrencyCode): number {
46
+ const factor = getCurrencyOption(currency).unitsPerEur;
47
+ if (factor >= 1000) return 10;
48
+ if (factor >= 100) return 1;
49
+ if (factor >= 10) return 0.1;
50
+ return 0.01;
51
+ }
52
+
53
+ export interface ChargingInputs {
54
+ capacityKwh: number;
55
+ startSoc: number;
56
+ endSoc: number;
57
+ chargerPowerKw: number;
58
+ efficiency: number;
59
+ pricePerKwh: number;
60
+ consumptionKwhPer100Km: number;
61
+ currency: CurrencyCode;
62
+ }
63
+
64
+ export interface ChargingPlan {
65
+ chargeFraction: number;
66
+ batteryEnergyKwh: number;
67
+ gridEnergyKwh: number;
68
+ lossEnergyKwh: number;
69
+ timeHours: number;
70
+ timeMinutes: number;
71
+ cost: number;
72
+ recoveredRangeKm: number;
73
+ currency: CurrencyCode;
74
+ exchangeFactor: number;
75
+ pricePerKwhEur: number;
76
+ }
77
+
78
+ export interface ChargingPreset {
79
+ id: 'home' | 'public' | 'fast';
80
+ inputs: ChargingInputs;
81
+ }
82
+
83
+ export const CHARGING_PRESETS: ChargingPreset[] = [
84
+ {
85
+ id: 'home',
86
+ inputs: {
87
+ capacityKwh: 60,
88
+ startSoc: 32,
89
+ endSoc: 80,
90
+ chargerPowerKw: 7.4,
91
+ efficiency: 0.9,
92
+ pricePerKwh: 0.25,
93
+ consumptionKwhPer100Km: 18,
94
+ currency: 'EUR',
95
+ },
96
+ },
97
+ {
98
+ id: 'public',
99
+ inputs: {
100
+ capacityKwh: 60,
101
+ startSoc: 32,
102
+ endSoc: 80,
103
+ chargerPowerKw: 11,
104
+ efficiency: 0.92,
105
+ pricePerKwh: 0.39,
106
+ consumptionKwhPer100Km: 18,
107
+ currency: 'EUR',
108
+ },
109
+ },
110
+ {
111
+ id: 'fast',
112
+ inputs: {
113
+ capacityKwh: 60,
114
+ startSoc: 32,
115
+ endSoc: 80,
116
+ chargerPowerKw: 100,
117
+ efficiency: 0.94,
118
+ pricePerKwh: 0.55,
119
+ consumptionKwhPer100Km: 18,
120
+ currency: 'EUR',
121
+ },
122
+ },
123
+ ];
124
+
125
+ const finiteOr = (value: number, fallback: number): number => (
126
+ Number.isFinite(value) ? value : fallback
127
+ );
128
+
129
+ const clamp = (value: number, minimum: number, maximum: number): number => (
130
+ Math.min(maximum, Math.max(minimum, value))
131
+ );
132
+
133
+ const safeInputs = (inputs: ChargingInputs): ChargingInputs => ({
134
+ currency: isCurrencyCode(inputs.currency) ? inputs.currency : DEFAULT_CURRENCY,
135
+ capacityKwh: clamp(finiteOr(inputs.capacityKwh, 60), 1, 250),
136
+ startSoc: clamp(finiteOr(inputs.startSoc, 32), 0, 100),
137
+ endSoc: clamp(finiteOr(inputs.endSoc, 80), 0, 100),
138
+ chargerPowerKw: clamp(finiteOr(inputs.chargerPowerKw, 7.4), 0.1, 350),
139
+ efficiency: clamp(finiteOr(inputs.efficiency, 0.9), 0.5, 1),
140
+ pricePerKwh: clamp(
141
+ finiteOr(inputs.pricePerKwh, 0.25 * getCurrencyOption(isCurrencyCode(inputs.currency) ? inputs.currency : DEFAULT_CURRENCY).unitsPerEur),
142
+ 0,
143
+ 5 * getCurrencyOption(isCurrencyCode(inputs.currency) ? inputs.currency : DEFAULT_CURRENCY).unitsPerEur,
144
+ ),
145
+ consumptionKwhPer100Km: clamp(finiteOr(inputs.consumptionKwhPer100Km, 18), 1, 100),
146
+ });
147
+
148
+ export function calculateChargingPlan(inputs: ChargingInputs): ChargingPlan {
149
+ const values = safeInputs(inputs);
150
+ const chargeFraction = Math.max(0, values.endSoc - values.startSoc) / 100;
151
+ const batteryEnergyKwh = values.capacityKwh * chargeFraction;
152
+ const gridEnergyKwh = batteryEnergyKwh / values.efficiency;
153
+ const lossEnergyKwh = Math.max(0, gridEnergyKwh - batteryEnergyKwh);
154
+ const timeHours = gridEnergyKwh / values.chargerPowerKw;
155
+ const timeMinutes = Math.ceil(timeHours * 60);
156
+ const exchangeFactor = getCurrencyOption(values.currency).unitsPerEur;
157
+ const pricePerKwhEur = values.pricePerKwh / exchangeFactor;
158
+ const cost = gridEnergyKwh * pricePerKwhEur * exchangeFactor;
159
+ const recoveredRangeKm = (batteryEnergyKwh / values.consumptionKwhPer100Km) * 100;
160
+
161
+ return {
162
+ chargeFraction,
163
+ batteryEnergyKwh,
164
+ gridEnergyKwh,
165
+ lossEnergyKwh,
166
+ timeHours,
167
+ timeMinutes,
168
+ cost,
169
+ recoveredRangeKm,
170
+ currency: values.currency,
171
+ exchangeFactor,
172
+ pricePerKwhEur,
173
+ };
174
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { evChargingTimeCostPlanner } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const loader = evChargingTimeCostPlanner.i18n[locale] ?? evChargingTimeCostPlanner.i18n.en;
12
+ const content = await loader?.();
13
+ ---
14
+
15
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,27 @@
1
+ import type { ChargingInputs } from './logic';
2
+
3
+ const STORAGE_KEY = 'jjlmoya-ev-charging-time-cost-planner';
4
+
5
+ export function loadChargingInputs(): Partial<ChargingInputs> | null {
6
+ try {
7
+ const raw = localStorage.getItem(STORAGE_KEY);
8
+ if (!raw) return null;
9
+ const value: unknown = JSON.parse(raw);
10
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
11
+ return value as Partial<ChargingInputs>;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ export function saveChargingInputs(inputs: ChargingInputs): void {
18
+ try {
19
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(inputs));
20
+ } catch {}
21
+ }
22
+
23
+ export function clearChargingInputs(): void {
24
+ try {
25
+ localStorage.removeItem(STORAGE_KEY);
26
+ } catch {}
27
+ }
@@ -0,0 +1,50 @@
1
+ export interface EVChargingTimeCostPlannerUI extends Record<string, string> {
2
+ defaultCurrency: string;
3
+ currencyLabel: string;
4
+ currencyHint: string;
5
+ currencyMenuLabel: string;
6
+ currencyFactorTemplate: string;
7
+ presetMetaTemplate: string;
8
+ presetLabel: string;
9
+ homePreset: string;
10
+ publicPreset: string;
11
+ fastPreset: string;
12
+ resetLabel: string;
13
+ setupLabel: string;
14
+ setupHint: string;
15
+ chargeWindowLabel: string;
16
+ chargeWindowHint: string;
17
+ startSocLabel: string;
18
+ startSocHint: string;
19
+ endSocLabel: string;
20
+ endSocHint: string;
21
+ capacityLabel: string;
22
+ capacityHint: string;
23
+ chargerPowerLabel: string;
24
+ chargerPowerHint: string;
25
+ efficiencyLabel: string;
26
+ efficiencyHint: string;
27
+ priceLabel: string;
28
+ priceHint: string;
29
+ consumptionLabel: string;
30
+ consumptionHint: string;
31
+ resultKicker: string;
32
+ resultTitle: string;
33
+ batterySceneLabel: string;
34
+ startingChargeLabel: string;
35
+ targetChargeLabel: string;
36
+ batteryEnergyLabel: string;
37
+ gridEnergyLabel: string;
38
+ lossesLabel: string;
39
+ timeLabel: string;
40
+ costLabel: string;
41
+ rangeLabel: string;
42
+ statusEmpty: string;
43
+ statusShort: string;
44
+ statusStandard: string;
45
+ statusLong: string;
46
+ summaryTemplate: string;
47
+ formulaLabel: string;
48
+ formulaText: string;
49
+ rangeUnit: string;
50
+ }
package/src/tools.ts CHANGED
@@ -4,5 +4,8 @@ import { CAR_MOTORCYCLE_GEAR_RATIO_CALCULATOR_TOOL } from './tool/carMotorcycleG
4
4
  import { MOTORCYCLE_SKID_MARK_SPEED_ESTIMATOR_TOOL } from './tool/motorcycleSkidMarkSpeedEstimator';
5
5
  import { REAL_FUEL_CONSUMPTION_CALCULATOR_TOOL } from './tool/realFuelConsumptionCalculator';
6
6
  import { TIRE_PRESSURE_CONVERTER_TOOL } from './tool/tirePressureConverter';
7
+ import { EV_CHARGING_TIME_COST_PLANNER_TOOL } from './tool/evChargingTimeCostPlanner';
7
8
 
8
- export const ALL_TOOLS = [BRAKING_DISTANCE_CALCULATOR_TOOL, REAL_FUEL_CONSUMPTION_CALCULATOR_TOOL, CAR_MOTORCYCLE_GEAR_RATIO_CALCULATOR_TOOL, TIRE_PRESSURE_CONVERTER_TOOL, MOTORCYCLE_SKID_MARK_SPEED_ESTIMATOR_TOOL];
9
+ export const ALL_TOOLS = [BRAKING_DISTANCE_CALCULATOR_TOOL, REAL_FUEL_CONSUMPTION_CALCULATOR_TOOL, CAR_MOTORCYCLE_GEAR_RATIO_CALCULATOR_TOOL, TIRE_PRESSURE_CONVERTER_TOOL, MOTORCYCLE_SKID_MARK_SPEED_ESTIMATOR_TOOL, EV_CHARGING_TIME_COST_PLANNER_TOOL];
10
+ export { evChargingTimeCostPlanner } from './tool/evChargingTimeCostPlanner/entry';
11
+ export type { EVChargingTimeCostPlannerLocaleContent } from './tool/evChargingTimeCostPlanner/entry';