From 2a023a7917da329970a2f3cc2bb165aceeb127d1 Mon Sep 17 00:00:00 2001 From: Timothy Mukaibo Date: Tue, 11 Aug 2026 20:27:04 +1000 Subject: [PATCH 1/2] feat: Implement adhoc charging Adhoc-charging allows a special "one-off" charge. Blackout period is still enforced. Adhoc is designed for people who mostly charge with solar, but need an extra boost from time-to-time. A cost estimate will be displayed. This is calculated from your electricity tariffs that you have configured. --- docs/charge-controller.md | 28 +- drizzle/0007_dusty_cassandra_nova.sql | 1 + drizzle/meta/0007_snapshot.json | 991 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + .../OneOffChargeDialog/CostEstimate.tsx | 62 ++ .../OneOffChargeDialog.module.css | 64 ++ .../OneOffChargeDialog.test.tsx | 382 +++++++ .../OneOffChargeDialog/OneOffChargeDialog.tsx | 252 +++++ .../OneOffChargeDialog/oneOffWarnings.test.ts | 189 ++++ .../OneOffChargeDialog/oneOffWarnings.ts | 122 +++ .../OneOffChargeDialog/useOneOffForm.ts | 142 +++ .../ScheduleCard/ScheduleCard.test.tsx | 1 + .../components/ScheduleCard/ScheduleCard.tsx | 39 +- .../ScheduleDialog/ChargeSettings.tsx | 20 +- .../VehicleCard/VehicleCard.test.tsx | 53 + .../components/VehicleCard/VehicleCard.tsx | 61 +- .../pages/Dashboard/Dashboard.test.tsx | 24 + .../pages/Dashboard/VehicleList.tsx | 66 +- .../pages/Schedules/scheduleGapUtils.test.ts | 1 + .../Schedules/test-helpers/setupSchedules.ts | 1 + packages/client/src/hooks/useOneOffCharge.ts | 68 ++ packages/client/src/hooks/useSchedules.ts | 3 + packages/client/src/lib/demo/demoState.ts | 2 + .../demo/handlers/mutations/schedule.test.ts | 115 ++ .../lib/demo/handlers/mutations/schedule.ts | 42 +- .../client/src/lib/demo/handlers/schedule.ts | 47 +- .../src/lib/test-helpers/solarFactories.ts | 1 + packages/client/src/utils/Format.ts | 15 + packages/server/src/bootstrap/bootstrap.ts | 1 + packages/server/src/db/Schema.ts | 3 + .../src/db/repositories/ScheduleRepository.ts | 2 + packages/server/src/db/types.ts | 3 + .../server/src/services/ChargeController.ts | 15 +- .../services/ScheduleService.oneOff.test.ts | 263 +++++ .../server/src/services/ScheduleService.ts | 99 +- packages/server/src/services/TariffService.ts | 2 +- .../test-helpers/ChargeControllerHarness.ts | 2 + packages/server/src/trpc/routers/schedules.ts | 8 + packages/shared/chargeCostEstimate.test.ts | 300 ++++++ packages/shared/chargeCostEstimate.ts | 155 +++ packages/shared/deno.json | 4 + .../shared/engine/Schedules.oneOff.test.ts | 190 ++++ packages/shared/engine/Schedules.ts | 88 +- packages/shared/engine/SolarAllocator.ts | 16 +- packages/shared/engine/mod.ts | 2 +- packages/shared/engine/types.ts | 3 + packages/shared/localTime.ts | 108 ++ packages/shared/oneOffCharge.test.ts | 140 +++ packages/shared/oneOffCharge.ts | 90 ++ packages/shared/schemas.ts | 28 + .../tariffs.test.ts} | 14 +- .../src/lib/Tariffs.ts => shared/tariffs.ts} | 20 +- packages/shared/test-factories.ts | 1 + packages/shared/types.ts | 14 + 54 files changed, 4248 insertions(+), 122 deletions(-) create mode 100644 drizzle/0007_dusty_cassandra_nova.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 packages/client/src/components/OneOffChargeDialog/CostEstimate.tsx create mode 100644 packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.module.css create mode 100644 packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.test.tsx create mode 100644 packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.tsx create mode 100644 packages/client/src/components/OneOffChargeDialog/oneOffWarnings.test.ts create mode 100644 packages/client/src/components/OneOffChargeDialog/oneOffWarnings.ts create mode 100644 packages/client/src/components/OneOffChargeDialog/useOneOffForm.ts create mode 100644 packages/client/src/hooks/useOneOffCharge.ts create mode 100644 packages/client/src/lib/demo/handlers/mutations/schedule.test.ts create mode 100644 packages/server/src/services/ScheduleService.oneOff.test.ts create mode 100644 packages/shared/chargeCostEstimate.test.ts create mode 100644 packages/shared/chargeCostEstimate.ts create mode 100644 packages/shared/engine/Schedules.oneOff.test.ts create mode 100644 packages/shared/localTime.ts create mode 100644 packages/shared/oneOffCharge.test.ts create mode 100644 packages/shared/oneOffCharge.ts rename packages/{server/src/lib/Tariffs.test.ts => shared/tariffs.test.ts} (97%) rename packages/{server/src/lib/Tariffs.ts => shared/tariffs.ts} (82%) diff --git a/docs/charge-controller.md b/docs/charge-controller.md index 0f7e383d..9b025f08 100644 --- a/docs/charge-controller.md +++ b/docs/charge-controller.md @@ -126,25 +126,27 @@ In excess mode two adjustments are applied to `-gridPowerW`: - **Battery discharge is subtracted.** Power leaving the home battery (`batteryPowerW > 0`) is not solar. Without this, a battery that is operating - in self-consumption mode and not drawing from the grid makes the vehicle's own - draw reappear as available solar through the add-back below, and the car charges - off the house battery - probably not what you want!. Battery _charging_ is not - added back — we'll try to charge a home battery first from any solar surplus - (see battery priority for SoC-based control). + in self-consumption mode and not drawing from the grid makes the vehicle's own + draw reappear as available solar through the add-back below, and the car + charges off the house battery - probably not what you want!. Battery + _charging_ is not added back — we'll try to charge a home battery first from + any solar surplus (see battery priority for SoC-based control). - **The vehicle's charge power is added back** when the energy meter includes EV - charging in its consumption reading (that's the most common setup), since the car's own - draw suppresses export. Skipped if `consumptionExcludesCharging` is enabled. + charging in its consumption reading (that's the most common setup), since the + car's own draw suppresses export. Skipped if `consumptionExcludesCharging` is + enabled. -The result is capped at current solar production — obviously a solar surplus can never -exceed what the panels are making. Gross mode takes production directly and applies no -add-back, since panel output never had the vehicle's draw subtracted from it. +The result is capped at current solar production — obviously a solar surplus can +never exceed what the panels are making. Gross mode takes production directly +and applies no add-back, since panel output never had the vehicle's draw +subtracted from it. A configurable safety margin (`solarMarginKw`) is subtracted from the result. > Note: the simulator replays recorded `solarW` / `gridW` but passes -> `batteryPowerW: null`, so it does not simulate a home battery in self consumption mode. -> Simulated results with a battery are a little optimistic relative to how a real home -> setup would behave. +> `batteryPowerW: null`, so it does not simulate a home battery in self +> consumption mode. Simulated results with a battery are a little optimistic +> relative to how a real home setup would behave. ### Amps conversion diff --git a/drizzle/0007_dusty_cassandra_nova.sql b/drizzle/0007_dusty_cassandra_nova.sql new file mode 100644 index 00000000..bdcb2357 --- /dev/null +++ b/drizzle/0007_dusty_cassandra_nova.sql @@ -0,0 +1 @@ +ALTER TABLE `schedules` ADD `one_off_date` text; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000..c0416c5e --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,991 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "386fa8cf-2d5e-4278-bb10-686b9bf5e25f", + "prevId": "f4e27d12-1e8f-49b6-9377-636e49faadb0", + "tables": { + "auth_local": { + "name": "auth_local", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_auth_local_username": { + "name": "idx_auth_local_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_oidc": { + "name": "auth_oidc", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_encrypted": { + "name": "is_encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_encrypted": { + "name": "is_encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "controller_logs": { + "name": "controller_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "vehicle_id": { + "name": "vehicle_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vehicle_name": { + "name": "vehicle_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inputs_json": { + "name": "inputs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_detail": { + "name": "action_detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_amps": { + "name": "target_amps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_controller_logs_ts": { + "name": "idx_controller_logs_ts", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_controller_logs_trace": { + "name": "idx_controller_logs_trace", + "columns": [ + "trace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "energy_readings": { + "name": "energy_readings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "solar_production_w": { + "name": "solar_production_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grid_power_w": { + "name": "grid_power_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "home_consumption_w": { + "name": "home_consumption_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "battery_power_w": { + "name": "battery_power_w", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "battery_soc": { + "name": "battery_soc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate_per_kwh": { + "name": "rate_per_kwh", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poll_failed": { + "name": "poll_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_energy_readings_timestamp": { + "name": "idx_energy_readings_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_logs": { + "name": "plugin_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_plugin_logs_plugin_ts": { + "name": "idx_plugin_logs_plugin_ts", + "columns": [ + "plugin_id", + "timestamp" + ], + "isUnique": false + }, + "idx_plugin_logs_ts": { + "name": "idx_plugin_logs_ts", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_plugin_logs_trace": { + "name": "idx_plugin_logs_trace", + "columns": [ + "trace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "vehicle_id": { + "name": "vehicle_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "days_json": { + "name": "days_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charge_amps": { + "name": "charge_amps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "charge_limit_pct": { + "name": "charge_limit_pct", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "one_off_date": { + "name": "one_off_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tariff_periods": { + "name": "tariff_periods", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "days": { + "name": "days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate_per_kwh": { + "name": "rate_per_kwh", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vehicle_charge_readings": { + "name": "vehicle_charge_readings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "vehicle_id": { + "name": "vehicle_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charge_power_w": { + "name": "charge_power_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charge_amps": { + "name": "charge_amps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "battery_level": { + "name": "battery_level", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "solar_contribution_w": { + "name": "solar_contribution_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grid_contribution_w": { + "name": "grid_contribution_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_home": { + "name": "is_home", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "rate_per_kwh": { + "name": "rate_per_kwh", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vcr_vehicle_ts": { + "name": "idx_vcr_vehicle_ts", + "columns": [ + "vehicle_id", + "timestamp" + ], + "isUnique": false + }, + "idx_vcr_timestamp": { + "name": "idx_vcr_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vehicle_poll_logs": { + "name": "vehicle_poll_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "vehicle_id": { + "name": "vehicle_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vehicle_name": { + "name": "vehicle_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_online": { + "name": "is_online", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_plugged_in": { + "name": "is_plugged_in", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_charging": { + "name": "is_charging", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "battery_level": { + "name": "battery_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charge_limit": { + "name": "charge_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charge_amps": { + "name": "charge_amps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charge_amps_max": { + "name": "charge_amps_max", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charge_power_kw": { + "name": "charge_power_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charger_voltage": { + "name": "charger_voltage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "energy_added_kwh": { + "name": "energy_added_kwh", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minutes_to_full": { + "name": "minutes_to_full", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_home": { + "name": "is_home", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_vpl_vehicle_ts": { + "name": "idx_vpl_vehicle_ts", + "columns": [ + "vehicle_id", + "timestamp" + ], + "isUnique": false + }, + "idx_vpl_timestamp": { + "name": "idx_vpl_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vehicles": { + "name": "vehicles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 790cde34..937f83e1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1777672530975, "tag": "0006_lonely_leech", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1786427166950, + "tag": "0007_dusty_cassandra_nova", + "breakpoints": true } ] } diff --git a/packages/client/src/components/OneOffChargeDialog/CostEstimate.tsx b/packages/client/src/components/OneOffChargeDialog/CostEstimate.tsx new file mode 100644 index 00000000..e9a4d460 --- /dev/null +++ b/packages/client/src/components/OneOffChargeDialog/CostEstimate.tsx @@ -0,0 +1,62 @@ +import { Text } from "@radix-ui/themes"; +import type { ChargeCostEstimate } from "@chargeha/shared/chargeCostEstimate"; +import { formatDurationMinutes } from "@chargeha/shared/oneOffCharge"; +import { formatRate } from "../../utils/Format.ts"; +import styles from "./OneOffChargeDialog.module.css"; + +interface CostEstimateProps { + estimate: ChargeCostEstimate | null; + currencySymbol: string; +} + +const money = (amount: number, symbol: string) => + `${symbol}${amount.toFixed(2)}`; + +/** Tariff-based cost estimate for the proposed window. + * + * Deliberately labelled "up to": the estimate assumes grid import for the + * whole window at a constant rate and no early stop at the charge limit, all + * of which can only make the real cost lower. */ +export function CostEstimate( + { estimate, currencySymbol }: CostEstimateProps, +) { + if (!estimate) { + return ( +
+ Loading tariffs… +
+ ); + } + + const multipleRates = estimate.segments.length > 1; + + return ( +
+
+ Estimated cost + + up to {money(estimate.cost, currencySymbol)} + +
+ + {estimate.kwh.toFixed(1)} kWh at {estimate.powerKw.toFixed(1)} kW + + + {multipleRates && + estimate.segments.map((s) => ( +
+ + {s.label} · {formatRate(s.ratePerKwh, currencySymbol)}/kWh ·{" "} + {formatDurationMinutes(s.minutes)} + + {money(s.cost, currencySymbol)} +
+ ))} + + + Assumes grid import for the full window and no early stop at the charge + limit — solar, tapering, or hitting the limit will cost less. + +
+ ); +} diff --git a/packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.module.css b/packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.module.css new file mode 100644 index 00000000..bcf938fe --- /dev/null +++ b/packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.module.css @@ -0,0 +1,64 @@ +.form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.timeRow { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.stepperRow { + display: flex; + align-items: center; + gap: 12px; +} + +.stepperValue { + min-width: 48px; + text-align: center; +} + +.estimate { + padding: 10px 12px; + border-radius: 6px; + background: var(--gray-a2); + border: 1px solid var(--gray-a5); + display: flex; + flex-direction: column; + gap: 6px; +} + +.estimateTotal { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.segmentRow { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.footerSpacer { + flex: 1; +} diff --git a/packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.test.tsx b/packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.test.tsx new file mode 100644 index 00000000..93e3aeaa --- /dev/null +++ b/packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.test.tsx @@ -0,0 +1,382 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import type { + ChargeSchedule, + Schedule, + VehicleChargeState, +} from "@chargeha/shared"; +import { renderWithProviders } from "../../test-utils.tsx"; +import { OneOffChargeDialog } from "./OneOffChargeDialog.tsx"; + +// Captured TimePicker onChange so tests can drive the start time. +const mocks = vi.hoisted(() => ({ + timePickerOnChange: { value: null as ((v: string) => void) | null }, +})); + +vi.mock("../TimePicker/TimePicker.tsx", () => ({ + TimePicker: (props: { value: string; onChange: (v: string) => void }) => { + mocks.timePickerOnChange.value = props.onChange; + return ; + }, +})); + +vi.mock("../../hooks/useSectionConfig.ts", () => ({ + useSystemConfig: vi.fn(() => ({ data: { timezone: "UTC" } })), + useSolarConfig: vi.fn(() => ({ + data: { gridVoltage: 230, threePhaseCharger: false }, + })), +})); + +vi.mock("../../trpc.ts", () => ({ + trpc: { + tariff: { + list: { + useQuery: vi.fn(() => ({ + data: { + periods: [ + { + id: 1, + label: "EV", + startTime: "22:00", + endTime: "07:00", + days: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + ratePerKwh: 0.10, + enabled: true, + }, + ], + defaultRatePerKwh: 0.30, + currencySymbol: "$", + currencyCode: "AUD", + }, + isLoading: false, + error: null, + })), + }, + }, + }, +})); + +describe("OneOffChargeDialog", () => { + const state: VehicleChargeState = { + vehicleId: "v1", + isOnline: true, + isPluggedIn: true, + isCharging: false, + batteryLevel: 40, + chargeLimit: 80, + chargeAmps: 0, + chargeAmpsMin: 5, + chargeAmpsMax: 16, + chargePowerKw: 0, + chargerVoltage: 230, + chargerPhases: 1, + energyAddedKwh: 0, + minutesToFull: 0, + chargePortOpen: false, + vehicleName: "Test Car", + lastUpdated: "2026-08-11T04:00:00.000Z", + latitude: null, + longitude: null, + isHome: null, + }; + + const renderDialog = ( + overrides: { + mode?: "auto" | "stop" | "charge_now"; + schedules?: Schedule[]; + state?: Partial; + onSchedule?: (data: unknown) => Promise; + onCancelPending?: (id: string) => Promise; + } = {}, + ) => { + const onSchedule = overrides.onSchedule ?? + vi.fn(() => Promise.resolve(null)); + const onOpenChange = vi.fn(); + const result = renderWithProviders( + , + ); + return { ...result, onSchedule, onOpenChange }; + }; + + /** Press an amps stepper button n times. The amps steppers render before the + * charge-limit ones, so index 0 is the amps control. */ + const stepAmps = async ( + user: ReturnType, + label: "−" | "+", + times: number, + ) => { + await Array.from({ length: times }).reduce>( + (prev) => + prev.then(() => + user.click(screen.getAllByRole("button", { name: label })[0]) + ), + Promise.resolve(), + ); + }; + + beforeEach(() => { + // Tuesday 2026-08-11 04:00 UTC, so 23:30 resolves to today + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date("2026-08-11T04:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.clearAllMocks(); + }); + + it("defaults to 23:30, 3h, max amps and the vehicle's charge limit", () => { + renderDialog(); + expect(screen.getByTestId("time-picker")).toHaveValue("23:30"); + expect(screen.getByRole("combobox")).toHaveTextContent("3h"); + expect(screen.getByText("16A")).toBeInTheDocument(); + expect(screen.getByText("80%")).toBeInTheDocument(); + }); + + it("shows the resolved window, including the day it ends on", () => { + renderDialog(); + // 23:30 today → 02:30 the next day (Wednesday) + expect(screen.getByText(/today/)).toBeInTheDocument(); + expect(screen.getByText(/11:30 PM/)).toBeInTheDocument(); + expect(screen.getByText(/2:30 AM/)).toBeInTheDocument(); + expect(screen.getByText(/Wed/)).toBeInTheDocument(); + }); + + it("says tomorrow when the start time has already passed", () => { + vi.setSystemTime(new Date("2026-08-11T23:45:00Z")); + renderDialog(); + expect(screen.getByText(/tomorrow/)).toBeInTheDocument(); + }); + + it("estimates the cost from the tariff for the window", () => { + renderDialog(); + // 16A × 230V × 1 phase = 3.68 kW × 3h = 11.04 kWh at $0.10 = $1.10 + expect(screen.getByText("up to $1.10")).toBeInTheDocument(); + expect(screen.getByText(/11\.0 kWh at 3\.7 kW/)).toBeInTheDocument(); + }); + + it("re-estimates when the amps change", async () => { + const user = userEvent.setup(); + renderDialog(); + expect(screen.getByText("up to $1.10")).toBeInTheDocument(); + + // One press of "−" drops 16A to 15A → 3.45 kW × 3h × $0.10 = $1.035 + await user.click(screen.getAllByRole("button", { name: "−" })[0]); + await waitFor(() => { + expect(screen.getByText("15A")).toBeInTheDocument(); + }); + expect(screen.getByText("up to $1.04")).toBeInTheDocument(); + }); + + it("states the assumptions behind the estimate", () => { + renderDialog(); + expect(screen.getByText(/Assumes grid import for the full window/)) + .toBeInTheDocument(); + }); + + describe("amps bounds come from the vehicle's configured range", () => { + it("defaults to the configured maximum", () => { + renderDialog({ state: { chargeAmpsMin: 6, chargeAmpsMax: 24 } }); + expect(screen.getByText("24A")).toBeInTheDocument(); + }); + + it("cannot be stepped above the configured maximum", () => { + renderDialog({ state: { chargeAmpsMin: 5, chargeAmpsMax: 16 } }); + // Already at the max, so "+" is disabled + expect(screen.getAllByRole("button", { name: "+" })[0]).toBeDisabled(); + }); + + it("stops at the configured minimum rather than 1A", async () => { + const user = userEvent.setup(); + renderDialog({ state: { chargeAmpsMin: 8, chargeAmpsMax: 16 } }); + + // 16 → 8 needs 8 presses; try 12 to prove it clamps instead of reaching 4 + await stepAmps(user, "−", 12); + + await waitFor(() => { + expect(screen.getByText("8A")).toBeInTheDocument(); + }); + expect(screen.getAllByRole("button", { name: "−" })[0]).toBeDisabled(); + }); + + it("clamps a pending charge saved below the configured minimum", () => { + const staleLow: ChargeSchedule = { + id: "pending-low", + vehicleId: "v1", + scheduleType: "charge", + startTime: "23:30", + endTime: "02:30", + days: ["tue"], + chargeAmps: 2, + chargeLimitPct: 80, + oneOffDate: "2026-08-11", + enabled: true, + }; + renderDialog({ + schedules: [staleLow], + state: { chargeAmpsMin: 6, chargeAmpsMax: 16 }, + }); + expect(screen.getByText("6A")).toBeInTheDocument(); + }); + + it("clamps a pending charge saved above the configured maximum", () => { + const staleHigh: ChargeSchedule = { + id: "pending-high", + vehicleId: "v1", + scheduleType: "charge", + startTime: "23:30", + endTime: "02:30", + days: ["tue"], + chargeAmps: 32, + chargeLimitPct: 80, + oneOffDate: "2026-08-11", + enabled: true, + }; + renderDialog({ + schedules: [staleHigh], + state: { chargeAmpsMin: 6, chargeAmpsMax: 16 }, + }); + expect(screen.getByText("16A")).toBeInTheDocument(); + }); + + it("submits an amps value inside the configured range", async () => { + const user = userEvent.setup(); + const { onSchedule } = renderDialog({ + state: { chargeAmpsMin: 10, chargeAmpsMax: 20 }, + }); + + await stepAmps(user, "−", 3); + await user.click(screen.getByRole("button", { name: "Schedule charge" })); + + await waitFor(() => { + expect(onSchedule).toHaveBeenCalledWith( + expect.objectContaining({ chargeAmps: 17 }), + ); + }); + }); + }); + + it("warns and offers the auto switch when the vehicle is stopped", () => { + renderDialog({ mode: "stop" }); + expect(screen.getByText(/only run in Auto/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Switch this vehicle to Auto/)) + .toBeInTheDocument(); + }); + + it("hides the auto switch in auto mode", () => { + renderDialog({ mode: "auto" }); + expect(screen.queryByText(/Switch this vehicle to Auto/)).toBeNull(); + }); + + it("warns about an overlapping blockout", () => { + const blockout: Schedule = { + id: "b1", + vehicleId: null, + scheduleType: "blockout", + startTime: "22:00", + endTime: "06:00", + days: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + enabled: true, + }; + renderDialog({ schedules: [blockout] }); + expect(screen.getByText(/Blockouts take priority/)).toBeInTheDocument(); + }); + + it("submits the form values and closes", async () => { + const user = userEvent.setup(); + const { onSchedule, onOpenChange } = renderDialog(); + + await user.click(screen.getByRole("button", { name: "Schedule charge" })); + + await waitFor(() => { + expect(onSchedule).toHaveBeenCalledWith({ + startTime: "23:30", + durationMinutes: 180, + chargeAmps: 16, + chargeLimitPct: 80, + switchToAuto: true, + }); + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("surfaces a save error and stays open", async () => { + const user = userEvent.setup(); + const { onOpenChange } = renderDialog({ + onSchedule: vi.fn(() => Promise.resolve("Vehicle not found")), + }); + + await user.click(screen.getByRole("button", { name: "Schedule charge" })); + + await waitFor(() => { + expect(screen.getByText("Vehicle not found")).toBeInTheDocument(); + }); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + + describe("with a pending one-off", () => { + const pending: ChargeSchedule = { + id: "pending-1", + vehicleId: "v1", + scheduleType: "charge", + startTime: "22:00", + endTime: "23:00", + days: ["tue"], + chargeAmps: 10, + chargeLimitPct: 90, + oneOffDate: "2026-08-11", + enabled: true, + }; + + it("pre-fills from the pending charge", () => { + renderDialog({ schedules: [pending] }); + expect(screen.getByTestId("time-picker")).toHaveValue("22:00"); + expect(screen.getByRole("combobox")).toHaveTextContent("1h"); + expect(screen.getByText("10A")).toBeInTheDocument(); + expect(screen.getByText("90%")).toBeInTheDocument(); + }); + + it("frames itself as a replacement", () => { + renderDialog({ schedules: [pending] }); + expect(screen.getByText(/Replaces the charge already scheduled/)) + .toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Update charge" })) + .toBeInTheDocument(); + }); + + it("cancels the pending charge and closes", async () => { + const user = userEvent.setup(); + const onCancelPending = vi.fn(() => Promise.resolve()); + const { onOpenChange } = renderDialog({ + schedules: [pending], + onCancelPending, + }); + + await user.click(screen.getByRole("button", { name: "Cancel charge" })); + + await waitFor(() => { + expect(onCancelPending).toHaveBeenCalledWith("pending-1"); + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("has no cancel button when nothing is pending", () => { + renderDialog(); + expect(screen.queryByRole("button", { name: "Cancel charge" })) + .toBeNull(); + }); + }); +}); diff --git a/packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.tsx b/packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.tsx new file mode 100644 index 00000000..d05604c4 --- /dev/null +++ b/packages/client/src/components/OneOffChargeDialog/OneOffChargeDialog.tsx @@ -0,0 +1,252 @@ +import { useState } from "react"; +import { CalendarClock, TriangleAlert } from "lucide-react"; +import { + Button, + Callout, + Checkbox, + Dialog, + Select, + Text, +} from "@radix-ui/themes"; +import type { + OneOffChargeFormData, + Schedule, + VehicleChargeState, + VehicleMode, +} from "@chargeha/shared"; +import { + formatDurationMinutes, + ONE_OFF_DURATION_OPTIONS, +} from "@chargeha/shared/oneOffCharge"; +import type { OneOffWindow } from "@chargeha/shared/oneOffCharge"; +import { dayOfWeekForDate } from "@chargeha/shared/localTime"; +import { TimePicker } from "../TimePicker/TimePicker.tsx"; +import { ChargeSettings } from "../ScheduleDialog/ChargeSettings.tsx"; +import { formatTime12h } from "../../utils/Format.ts"; +import type { OneOffWarning } from "./oneOffWarnings.ts"; +import { CostEstimate } from "./CostEstimate.tsx"; +import { useOneOffForm } from "./useOneOffForm.ts"; +import styles from "./OneOffChargeDialog.module.css"; + +interface OneOffChargeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + vehicleId: string; + vehicleName: string; + state: VehicleChargeState; + mode: VehicleMode; + /** All schedules, for clash warnings and finding the pending one-off. */ + schedules: Schedule[]; + onSchedule: ( + data: OneOffChargeFormData, + ) => Promise | string | null; + /** Cancel the pending one-off, when there is one. */ + onCancelPending?: (id: string) => Promise; +} + +const DAY_ABBRS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; +const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +const dayLabel = (date: string) => + DAY_NAMES[DAY_ABBRS.indexOf(dayOfWeekForDate(date))]; + +/** Start time picker plus the resolved window, so it's obvious whether "11:30 + * PM" means tonight or tomorrow. */ +function StartField( + { startTime, window, onChange }: { + startTime: string; + window: OneOffWindow; + onChange: (value: string) => void; + }, +) { + return ( +
+ Start +
+ + + {window.isTomorrow ? "tomorrow" : "today"} ·{" "} + {formatTime12h(startTime)} → {formatTime12h(window.endTime)} + {window.wrapsMidnight && ` (${dayLabel(window.endDate)})`} + +
+
+ ); +} + +function DurationField( + { durationMinutes, onChange }: { + durationMinutes: number; + onChange: (value: number) => void; + }, +) { + return ( +
+ Duration + onChange(Number(v))} + > + + + {ONE_OFF_DURATION_OPTIONS.map((m) => ( + + {formatDurationMinutes(m)} + + ))} + + +
+ ); +} + +function WarningList({ warnings }: { warnings: OneOffWarning[] }) { + return ( + <> + {warnings.map((w) => ( + + + + + {w.text} + + ))} + + ); +} + +function DialogFooter( + { pendingId, saving, onCancelPending, onClose }: { + pendingId?: string; + saving: boolean; + onCancelPending?: (id: string) => Promise; + onClose: () => void; + }, +) { + return ( +
+ {pendingId && onCancelPending && ( + <> + + + + )} + + +
+ ); +} + +export function OneOffChargeDialog({ + open, + onOpenChange, + vehicleId, + vehicleName, + state, + mode, + schedules, + onSchedule, + onCancelPending, +}: OneOffChargeDialogProps) { + const { + form, + updateField, + window, + estimate, + warnings, + pending, + currencySymbol, + } = useOneOffForm({ open, vehicleId, state, mode, schedules }); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + try { + const err = await onSchedule(form); + if (err) setError(err); + else onOpenChange(false); + } finally { + setSaving(false); + } + }; + + return ( + + + + + Schedule a charge + + + {pending + ? `Replaces the charge already scheduled for ${vehicleName}.` + : `A one-off charge for ${vehicleName}. Runs once, then clears itself.`} + + +
+ updateField("startTime", v)} + /> + updateField("durationMinutes", v)} + /> + + + + + {mode !== "auto" && ( + + + updateField("switchToAuto", checked === true)} + mr="2" + /> + Switch this vehicle to Auto when saving + + )} + + + + {error && {error}} + + onOpenChange(false)} + /> + +
+
+ ); +} diff --git a/packages/client/src/components/OneOffChargeDialog/oneOffWarnings.test.ts b/packages/client/src/components/OneOffChargeDialog/oneOffWarnings.test.ts new file mode 100644 index 00000000..a6954e06 --- /dev/null +++ b/packages/client/src/components/OneOffChargeDialog/oneOffWarnings.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import type { + BlockoutSchedule, + ChargeSchedule, + Schedule, +} from "@chargeha/shared"; +import { resolveOneOffWindow } from "@chargeha/shared/oneOffCharge"; +import { getOneOffWarnings } from "./oneOffWarnings.ts"; + +describe("getOneOffWarnings", () => { + // Tuesday 2026-08-11, 14:00 UTC — the window resolves to that date + const NOW = new Date("2026-08-11T14:00:00Z"); + + const window = (startTime: string, durationMinutes: number) => + resolveOneOffWindow(startTime, durationMinutes, NOW, "UTC"); + + const charge = (o: Partial = {}): ChargeSchedule => ({ + id: "charge-1", + vehicleId: "v1", + scheduleType: "charge", + startTime: "08:00", + endTime: "12:00", + days: ["mon", "tue", "wed", "thu", "fri"], + chargeAmps: 16, + chargeLimitPct: 80, + oneOffDate: null, + enabled: true, + ...o, + }); + + const blockout = (o: Partial = {}): BlockoutSchedule => ({ + id: "blockout-1", + vehicleId: null, + scheduleType: "blockout", + startTime: "16:00", + endTime: "21:00", + days: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + enabled: true, + ...o, + }); + + const warn = ( + { + startTime = "23:30", + durationMinutes = 180, + mode = "auto" as const, + schedules = [] as Schedule[], + excludeId, + }: { + startTime?: string; + durationMinutes?: number; + mode?: "auto" | "stop" | "charge_now"; + schedules?: Schedule[]; + excludeId?: string; + } = {}, + ) => + getOneOffWarnings({ + window: window(startTime, durationMinutes), + startTime, + durationMinutes, + mode, + schedules, + excludeId, + }); + + it("returns nothing for an auto-mode vehicle with no clashes", () => { + expect(warn()).toEqual([]); + }); + + describe("mode warning", () => { + it("warns when the vehicle is stopped", () => { + const warnings = warn({ mode: "stop" }); + expect(warnings.map((w) => w.id)).toContain("mode"); + expect(warnings[0].text).toContain("Stopped"); + expect(warnings[0].text).toContain("only run in Auto"); + }); + + it("warns when the vehicle is in charge-now mode", () => { + expect(warn({ mode: "charge_now" })[0].text).toContain("Charge Now"); + }); + + it("does not warn in auto mode", () => { + expect(warn({ mode: "auto" }).map((w) => w.id)).not.toContain("mode"); + }); + }); + + describe("blockout warning", () => { + it("warns when a blockout covers the window", () => { + const warnings = warn({ + schedules: [blockout({ startTime: "22:00", endTime: "06:00" })], + }); + expect(warnings.map((w) => w.id)).toEqual(["blockout"]); + expect(warnings[0].text).toContain("Blockouts take priority"); + }); + + it("warns when the blockout only overlaps the post-midnight part", () => { + // Window 23:30–02:30; blockout 01:00–03:00 on the following (Wed) day + const warnings = warn({ + schedules: [blockout({ startTime: "01:00", endTime: "03:00" })], + }); + expect(warnings.map((w) => w.id)).toEqual(["blockout"]); + }); + + it("does not warn for a non-overlapping blockout", () => { + const warnings = warn({ + schedules: [blockout({ startTime: "16:00", endTime: "21:00" })], + }); + expect(warnings).toEqual([]); + }); + + it("does not warn for a blockout on unrelated days", () => { + const warnings = warn({ + schedules: [ + blockout({ startTime: "22:00", endTime: "06:00", days: ["sat"] }), + ], + }); + expect(warnings).toEqual([]); + }); + + it("ignores disabled blockouts", () => { + const warnings = warn({ + schedules: [ + blockout({ startTime: "22:00", endTime: "06:00", enabled: false }), + ], + }); + expect(warnings).toEqual([]); + }); + }); + + describe("recurring-overlap warning", () => { + it("warns when an existing charge schedule overlaps", () => { + const warnings = warn({ + schedules: [charge({ startTime: "22:00", endTime: "06:00" })], + }); + expect(warnings.map((w) => w.id)).toEqual(["overlap"]); + expect(warnings[0].text).toContain("takes precedence"); + }); + + it("does not warn for a non-overlapping charge schedule", () => { + expect(warn({ schedules: [charge()] })).toEqual([]); + }); + + it("does not warn about the pending one-off being replaced", () => { + const pending = charge({ + id: "pending", + startTime: "23:30", + endTime: "02:30", + oneOffDate: "2026-08-11", + days: ["tue"], + }); + expect(warn({ schedules: [pending], excludeId: "pending" })).toEqual([]); + }); + + it("does warn about another vehicle's overlapping one-off", () => { + const other = charge({ + id: "other", + vehicleId: "v2", + startTime: "23:30", + endTime: "02:30", + oneOffDate: "2026-08-11", + days: ["tue"], + }); + // A one-off is not a recurring schedule, so no "overlap" warning fires + expect(warn({ schedules: [other] })).toEqual([]); + }); + + it("ignores a one-off dated outside the window", () => { + const stale = charge({ + id: "stale", + startTime: "23:30", + endTime: "02:30", + oneOffDate: "2026-09-01", + days: ["tue"], + }); + expect(warn({ schedules: [stale] })).toEqual([]); + }); + }); + + it("reports mode, blockout and overlap together, in that order", () => { + const warnings = warn({ + mode: "stop", + schedules: [ + blockout({ startTime: "22:00", endTime: "06:00" }), + charge({ startTime: "23:00", endTime: "01:00" }), + ], + }); + expect(warnings.map((w) => w.id)).toEqual(["mode", "blockout", "overlap"]); + }); +}); diff --git a/packages/client/src/components/OneOffChargeDialog/oneOffWarnings.ts b/packages/client/src/components/OneOffChargeDialog/oneOffWarnings.ts new file mode 100644 index 00000000..689641a1 --- /dev/null +++ b/packages/client/src/components/OneOffChargeDialog/oneOffWarnings.ts @@ -0,0 +1,122 @@ +import type { Schedule, VehicleMode } from "@chargeha/shared"; +import { formatDurationMinutes } from "@chargeha/shared/oneOffCharge"; +import type { OneOffWindow } from "@chargeha/shared/oneOffCharge"; +import { dayOfWeekForDate } from "@chargeha/shared/localTime"; +import { timeRangesOverlap } from "../../hooks/useSchedules.ts"; +import { formatTime12h } from "../../utils/Format.ts"; + +export interface OneOffWarning { + id: "mode" | "blockout" | "overlap"; + text: string; +} + +const MODE_LABELS: Record = { + auto: "Auto", + charge_now: "Charge Now", + stop: "Stopped", +}; + +const range = (startTime: string, endTime: string) => + `${formatTime12h(startTime)}–${formatTime12h(endTime)}`; + +const oneOffDateOf = (s: Schedule): string | null => + s.scheduleType === "charge" ? s.oneOffDate : null; + +/** Schedules whose window overlaps the proposed one-off, on a day it runs. */ +function findClashes( + schedules: Schedule[], + startTime: string, + window: OneOffWindow, + excludeId?: string, +): Schedule[] { + // The dates the window touches — two when it wraps past midnight + const dates = window.wrapsMidnight + ? [window.oneOffDate, window.endDate] + : [window.oneOffDate]; + const weekdays = dates.map(dayOfWeekForDate); + + return schedules.filter((s) => { + if (!s.enabled || s.id === excludeId) return false; + if (!timeRangesOverlap(startTime, window.endTime, s.startTime, s.endTime)) { + return false; + } + // A one-off applies on its date; a recurring schedule on its weekdays. + const otherDate = oneOffDateOf(s); + return otherDate + ? dates.includes(otherDate) + : s.days.some((d) => weekdays.includes(d)); + }); +} + +/** + * Warnings for a proposed one-off charge. All are advisory — the charge is + * still created, because the user may be about to fix the cause (switching + * mode, removing a blockout) or may simply not care. + * + * Each corresponds to a way the window can silently fail to charge: + * - mode: charge schedules are only evaluated in auto mode + * - blockout: blockouts are evaluated before charge schedules, so they win + * - overlap: schedules are matched in creation order, so an existing + * recurring charge schedule takes precedence and its amps apply instead + */ +export function getOneOffWarnings( + { window, startTime, durationMinutes, mode, schedules, excludeId }: { + window: OneOffWindow; + startTime: string; + durationMinutes: number; + mode: VehicleMode; + schedules: Schedule[]; + /** The pending one-off being replaced, excluded from overlap checks. */ + excludeId?: string; + }, +): OneOffWarning[] { + const clashes = findClashes(schedules, startTime, window, excludeId); + return [ + ...modeWarning(mode), + ...blockoutWarning(clashes), + ...overlapWarning(clashes, durationMinutes), + ]; +} + +/** Charge schedules are only evaluated in auto mode. */ +function modeWarning(mode: VehicleMode): OneOffWarning[] { + if (mode === "auto") return []; + return [{ + id: "mode", + text: `This vehicle is in ${ + MODE_LABELS[mode] + } mode. Scheduled charges only run in Auto.`, + }]; +} + +/** Blockouts are evaluated before charge schedules, so they win outright. */ +function blockoutWarning(clashes: Schedule[]): OneOffWarning[] { + const blockout = clashes.find((s) => s.scheduleType === "blockout"); + if (!blockout) return []; + return [{ + id: "blockout", + text: `Overlaps a blockout (${ + range(blockout.startTime, blockout.endTime) + }). Blockouts take priority, so this charge won't run during it.`, + }]; +} + +/** Schedules are matched in creation order, so an existing recurring schedule + * is found first and its amps apply for the overlap. */ +function overlapWarning( + clashes: Schedule[], + durationMinutes: number, +): OneOffWarning[] { + const recurring = clashes.find((s) => + s.scheduleType === "charge" && !oneOffDateOf(s) + ); + if (!recurring) return []; + return [{ + id: "overlap", + text: `Overlaps an existing charge schedule (${ + range(recurring.startTime, recurring.endTime) + }), which takes precedence for the ${ + formatDurationMinutes(durationMinutes) + } window.`, + }]; +} diff --git a/packages/client/src/components/OneOffChargeDialog/useOneOffForm.ts b/packages/client/src/components/OneOffChargeDialog/useOneOffForm.ts new file mode 100644 index 00000000..5c5de367 --- /dev/null +++ b/packages/client/src/components/OneOffChargeDialog/useOneOffForm.ts @@ -0,0 +1,142 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + ChargeSchedule, + OneOffChargeFormData, + Schedule, + VehicleChargeState, + VehicleMode, +} from "@chargeha/shared"; +import { + ONE_OFF_DEFAULT_MINUTES, + ONE_OFF_DEFAULT_START, + oneOffDurationMinutes, + resolveOneOffWindow, +} from "@chargeha/shared/oneOffCharge"; +import { + estimateChargeCost, + resolveChargePhases, + resolveChargeVoltage, +} from "@chargeha/shared/chargeCostEstimate"; +import { + useSolarConfig, + useSystemConfig, +} from "../../hooks/useSectionConfig.ts"; +import { trpc } from "../../trpc.ts"; +import { getOneOffWarnings } from "./oneOffWarnings.ts"; + +const DEFAULT_GRID_VOLTAGE = 230; + +/** Hold a current inside the vehicle's configured amp range. */ +function clampAmps( + amps: number, + { chargeAmpsMin, chargeAmpsMax }: VehicleChargeState, +): number { + return Math.min(Math.max(amps, chargeAmpsMin), chargeAmpsMax); +} + +/** The pending one-off charge for a vehicle, if it has one. */ +export function findPendingOneOff( + schedules: Schedule[], + vehicleId: string, +): ChargeSchedule | undefined { + return schedules.find((s): s is ChargeSchedule => + s.scheduleType === "charge" && s.vehicleId === vehicleId && + !!s.oneOffDate + ); +} + +/** Form state for the one-off charge dialog, plus the derived window, cost + * estimate and clash warnings that follow from it. */ +export function useOneOffForm( + { open, vehicleId, state, mode, schedules }: { + open: boolean; + vehicleId: string; + state: VehicleChargeState; + mode: VehicleMode; + schedules: Schedule[]; + }, +) { + const { data: systemConfig } = useSystemConfig(); + const { data: solarConfig } = useSolarConfig(); + const { data: tariffs } = trpc.tariff.list.useQuery(); + const timezone = systemConfig?.timezone ?? ""; + + const pending = findPendingOneOff(schedules, vehicleId); + + const defaults = (): OneOffChargeFormData => ({ + startTime: pending?.startTime ?? ONE_OFF_DEFAULT_START, + durationMinutes: pending + ? oneOffDurationMinutes(pending.startTime, pending.endTime) + : ONE_OFF_DEFAULT_MINUTES, + // A pending charge may have been saved when the vehicle's configured amp + // range was wider, so clamp rather than seeding an out-of-range value. + chargeAmps: clampAmps( + pending?.chargeAmps ?? state.chargeAmpsMax, + state, + ), + chargeLimitPct: pending?.chargeLimitPct ?? Math.round(state.chargeLimit), + switchToAuto: true, + }); + + const [form, setForm] = useState(defaults); + + // Re-seed from the pending charge (or defaults) each time the dialog opens + useEffect(() => { + if (open) setForm(defaults()); + }, [open, pending?.id]); + + const window = useMemo( + () => + resolveOneOffWindow( + form.startTime, + form.durationMinutes, + new Date(), + timezone, + ), + [form.startTime, form.durationMinutes, timezone], + ); + + const estimate = useMemo(() => { + if (!tariffs) return null; + return estimateChargeCost({ + amps: form.chargeAmps, + volts: resolveChargeVoltage( + state, + solarConfig?.gridVoltage ?? DEFAULT_GRID_VOLTAGE, + ), + phases: resolveChargePhases( + state, + solarConfig?.threePhaseCharger ?? false, + ), + durationMinutes: form.durationMinutes, + startDate: window.oneOffDate, + startTime: form.startTime, + tariffPeriods: tariffs.periods, + defaultRatePerKwh: tariffs.defaultRatePerKwh, + }); + }, [tariffs, solarConfig, state, form, window.oneOffDate]); + + const warnings = getOneOffWarnings({ + window, + startTime: form.startTime, + durationMinutes: form.durationMinutes, + mode, + schedules, + excludeId: pending?.id, + }); + + const updateField = ( + key: K, + value: OneOffChargeFormData[K], + ) => setForm((prev) => ({ ...prev, [key]: value })); + + return { + form, + updateField, + window, + estimate, + warnings, + pending, + currencySymbol: tariffs?.currencySymbol ?? "$", + }; +} diff --git a/packages/client/src/components/ScheduleCard/ScheduleCard.test.tsx b/packages/client/src/components/ScheduleCard/ScheduleCard.test.tsx index 3b9341e1..5423c6d5 100644 --- a/packages/client/src/components/ScheduleCard/ScheduleCard.test.tsx +++ b/packages/client/src/components/ScheduleCard/ScheduleCard.test.tsx @@ -19,6 +19,7 @@ describe("ScheduleCard", () => { days: ["mon", "tue", "wed", "thu", "fri"], chargeAmps: 16, chargeLimitPct: 80, + oneOffDate: null, enabled: true, }; diff --git a/packages/client/src/components/ScheduleCard/ScheduleCard.tsx b/packages/client/src/components/ScheduleCard/ScheduleCard.tsx index 4ed51ac8..cec63b76 100644 --- a/packages/client/src/components/ScheduleCard/ScheduleCard.tsx +++ b/packages/client/src/components/ScheduleCard/ScheduleCard.tsx @@ -1,7 +1,11 @@ import { Pencil, Trash2 } from "lucide-react"; -import { Card, IconButton, Switch, Text } from "@radix-ui/themes"; +import { Badge, Card, IconButton, Switch, Text } from "@radix-ui/themes"; import type { Schedule } from "@chargeha/shared"; -import { formatDays, formatTime12h } from "../../utils/Format.ts"; +import { + formatCalendarDate, + formatDays, + formatTime12h, +} from "../../utils/Format.ts"; import styles from "./ScheduleCard.module.css"; interface ScheduleCardProps { @@ -42,7 +46,11 @@ export function ScheduleCard({ return `${durM}m`; })(); - const daysText = formatDays(schedule.days); + // One-off charges run on a single date rather than a weekly pattern + const oneOffDate = isCharge ? schedule.oneOffDate : null; + const daysText = oneOffDate + ? formatCalendarDate(oneOffDate) + : formatDays(schedule.days); const detailText = isCharge ? `Charge at ${schedule.chargeAmps}A to ${schedule.chargeLimitPct}%` @@ -65,18 +73,27 @@ export function ScheduleCard({ {timeText} ({durationText}) {daysText} + {oneOffDate && ( + One-off + )} {detailText}
- onEdit(schedule)} - > - - + { + /* One-offs are edited from the vehicle card's schedule dialog — the + recurring form would let you give them a weekly day pattern. */ + } + {!oneOffDate && ( + onEdit(schedule)} + > + + + )} ( + /** Lowest selectable current, from the vehicle's configured minimum. + * Defaults to 1A for callers that don't know the vehicle's floor. */ + minAmps?: number; + updateField: ( key: K, - value: ScheduleFormData[K], + value: ChargeFields[K], ) => void; } @@ -17,6 +26,7 @@ export function ChargeSettings({ chargeAmps, chargeLimitPct, maxAmps, + minAmps = 1, updateField, }: ChargeSettingsProps) { return ( @@ -28,11 +38,11 @@ export function ChargeSettings({ type="button" variant="ghost" size="1" - disabled={chargeAmps <= 1} + disabled={chargeAmps <= minAmps} onClick={() => updateField( "chargeAmps", - Math.max(1, chargeAmps - 1), + Math.max(minAmps, chargeAmps - 1), )} > − diff --git a/packages/client/src/components/VehicleCard/VehicleCard.test.tsx b/packages/client/src/components/VehicleCard/VehicleCard.test.tsx index 0a83626c..2c432f90 100644 --- a/packages/client/src/components/VehicleCard/VehicleCard.test.tsx +++ b/packages/client/src/components/VehicleCard/VehicleCard.test.tsx @@ -391,4 +391,57 @@ describe("VehicleCard", () => { renderVC({ state: makeVehicleState({ isCharging: false }) }); expect(screen.getByText("Not Charging")).toBeInTheDocument(); }); + + // --- one-off charge scheduling --- + + describe("ADHOC CHARGE button", () => { + it("is hidden when no handler is supplied", () => { + renderVC(); + expect(screen.queryByText("ADHOC CHARGE")).toBeNull(); + }); + + it("opens the dialog via the handler", () => { + const onScheduleCharge = vi.fn(); + renderVC({ onScheduleCharge }); + fireEvent.click(screen.getByText("ADHOC CHARGE")); + expect(onScheduleCharge).toHaveBeenCalledTimes(1); + }); + + it("is disabled while unplugged", () => { + renderVC({ + onScheduleCharge: vi.fn(), + state: makeVehicleState({ isPluggedIn: false }), + }); + expect(screen.getByText("ADHOC CHARGE").closest("button")).toBeDisabled(); + }); + + it("is disabled while a command is pending", () => { + renderVC({ onScheduleCharge: vi.fn(), commandPending: "start" }); + expect(screen.getByText("ADHOC CHARGE").closest("button")).toBeDisabled(); + }); + + it("does not change the vehicle mode", () => { + const onChangeMode = vi.fn(); + renderVC({ onScheduleCharge: vi.fn(), onChangeMode }); + fireEvent.click(screen.getByText("ADHOC CHARGE")); + expect(onChangeMode).not.toHaveBeenCalled(); + }); + }); + + describe("scheduled charge badge", () => { + it("is absent when nothing is scheduled", () => { + renderVC({ onScheduleCharge: vi.fn(), scheduledCharge: null }); + expect(screen.queryByText(/Charge scheduled/)).toBeNull(); + }); + + it("shows the pending window in 12-hour time", () => { + renderVC({ + onScheduleCharge: vi.fn(), + scheduledCharge: { startTime: "23:30", endTime: "02:30" }, + }); + expect(screen.getByText(/Charge scheduled/)).toBeInTheDocument(); + expect(screen.getByText(/11:30 PM/)).toBeInTheDocument(); + expect(screen.getByText(/2:30 AM/)).toBeInTheDocument(); + }); + }); }); diff --git a/packages/client/src/components/VehicleCard/VehicleCard.tsx b/packages/client/src/components/VehicleCard/VehicleCard.tsx index 2641a981..53e91889 100644 --- a/packages/client/src/components/VehicleCard/VehicleCard.tsx +++ b/packages/client/src/components/VehicleCard/VehicleCard.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { BatteryCharging, + CalendarClock, Car, Key, Plug, @@ -10,7 +11,7 @@ import { } from "lucide-react"; import { Badge, Button, Callout, Card, Skeleton, Text } from "@radix-ui/themes"; import type { VehicleChargeState, VehicleMode } from "@chargeha/shared"; -import { formatRelativeTime } from "../../utils/Format.ts"; +import { formatRelativeTime, formatTime12h } from "../../utils/Format.ts"; import { StaticMap } from "../StaticMap/StaticMap.tsx"; import { Spinner } from "../ui/Spinner.tsx"; import { ErrorBanner } from "../ui/ErrorBanner.tsx"; @@ -27,6 +28,10 @@ interface VehicleCardProps { onStopCharging: () => void; onSetAmps: (amps: number) => void; onChangeMode: (mode: VehicleMode) => void; + /** Opens the one-off charge dialog. Omitted hides the ADHOC CHARGE button. */ + onScheduleCharge?: () => void; + /** The vehicle's pending one-off charge, if any. */ + scheduledCharge?: { startTime: string; endTime: string } | null; onNavigateSettings?: () => void; solarPowerW?: number; gridPowerW?: number; @@ -204,12 +209,22 @@ function VehicleCardBanners( } function VehicleModeToggle( - { mode, disabled, isPluggedIn, pending, onChangeMode }: { + { + mode, + disabled, + isPluggedIn, + pending, + onChangeMode, + onScheduleCharge, + hasScheduledCharge, + }: { mode: VehicleMode; disabled: boolean; isPluggedIn: boolean; pending: string; onChangeMode: (mode: VehicleMode) => void; + onScheduleCharge?: () => void; + hasScheduledCharge?: boolean; }, ) { return ( @@ -228,6 +243,23 @@ function VehicleModeToggle( {btn.label} ))} + { + /* Not a mode — opens the one-off charge dialog. Sits alongside the + mode buttons because it's the same kind of "what should this car do" + decision. */ + } + {onScheduleCharge && ( + + )}
{mode === "charge_now" && ( @@ -243,6 +275,26 @@ function VehicleModeToggle( ); } +/** The pending one-off charge window, shown under the mode toggle. */ +function ScheduledChargeRow( + { scheduledCharge }: { + scheduledCharge?: { startTime: string; endTime: string } | null; + }, +) { + if (!scheduledCharge) return null; + return ( +
+ + + Charge scheduled{" "} + {formatTime12h(scheduledCharge.startTime)}–{formatTime12h( + scheduledCharge.endTime, + )} + +
+ ); +} + function VehicleBatterySection( { batteryPercent, chargeLimitPercent, isCharging }: { batteryPercent: number; @@ -285,6 +337,8 @@ export function VehicleCard({ onStopCharging, onSetAmps, onChangeMode, + onScheduleCharge, + scheduledCharge, onNavigateSettings, solarPowerW = 0, gridPowerW = 0, @@ -350,7 +404,10 @@ export function VehicleCard({ isPluggedIn={state.isPluggedIn} pending={pending} onChangeMode={onChangeMode} + onScheduleCharge={onScheduleCharge} + hasScheduledCharge={!!scheduledCharge} /> + ({ active: { useQuery: vi.fn(() => ({ data: [], isLoading: false, error: null })), }, + list: { + useQuery: vi.fn(() => ({ + data: { schedules: [] }, + isLoading: false, + error: null, + })), + }, + createOneOff: { + useMutation: vi.fn(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + })), + }, + delete: { + useMutation: vi.fn(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + })), + }, }, vehicle: { command: { @@ -164,6 +185,9 @@ vi.mock("../../../trpc.ts", () => ({ invalidate: dashboardMocks.invalidateConfig, }, }, + schedule: { + list: { invalidate: vi.fn() }, + }, })), }, })); diff --git a/packages/client/src/components/pages/Dashboard/VehicleList.tsx b/packages/client/src/components/pages/Dashboard/VehicleList.tsx index 159fafcd..e27e6b3d 100644 --- a/packages/client/src/components/pages/Dashboard/VehicleList.tsx +++ b/packages/client/src/components/pages/Dashboard/VehicleList.tsx @@ -1,7 +1,7 @@ -import { type ComponentProps, useMemo } from "react"; +import { type ComponentProps, useMemo, useState } from "react"; import { Car, Settings, Zap } from "lucide-react"; import { Button, Card, Text } from "@radix-ui/themes"; -import type { VehicleMode } from "@chargeha/shared"; +import type { VehicleChargeState, VehicleMode } from "@chargeha/shared"; import { isHome } from "@chargeha/shared/geo"; import { useChargingConfig, @@ -11,7 +11,9 @@ import { useEnergyData } from "../../../hooks/useEnergyData.ts"; import { useVehicles } from "../../../hooks/useVehicles.ts"; import { useToast } from "../../../hooks/useToast.tsx"; import { useControllerStatuses } from "../../../hooks/controllerStatusStore.ts"; +import { useOneOffCharges } from "../../../hooks/useOneOffCharge.ts"; import { VehicleCard } from "../../VehicleCard/VehicleCard.tsx"; +import { OneOffChargeDialog } from "../../OneOffChargeDialog/OneOffChargeDialog.tsx"; import { trpc } from "../../../trpc.ts"; import { useVehicleSolarGrid } from "./energyHelpers.ts"; @@ -174,6 +176,8 @@ function VehicleCards( stopCharging, setAmps, changeMode, + oneOffByVehicle, + onScheduleCharge, onNavigateSettings, }: { vehicles: ReturnType["vehicles"]; @@ -190,6 +194,8 @@ function VehicleCards( stopCharging: (id: string) => void; setAmps: (id: string, amps: number) => void; changeMode: (id: string, mode: VehicleMode) => void; + oneOffByVehicle: ReturnType["oneOffByVehicle"]; + onScheduleCharge: (id: string) => void; onNavigateSettings?: () => void; }, ) { @@ -210,6 +216,8 @@ function VehicleCards( onStopCharging={() => stopCharging(v.id)} onSetAmps={(amps) => setAmps(v.id, amps)} onChangeMode={(mode) => changeMode(v.id, mode)} + onScheduleCharge={() => onScheduleCharge(v.id)} + scheduledCharge={oneOffByVehicle[v.id] ?? null} solarPowerW={vehicleSolarGrid[v.id]?.solarW ?? 0} gridPowerW={vehicleSolarGrid[v.id]?.gridW ?? 0} loading={vehiclesLoading} @@ -242,6 +250,44 @@ function VehicleCards( ); } +/** The one-off charge dialog, wired to the schedule mutations and toasts. */ +function ConnectedOneOffDialog( + { vehicle, state, onClose, changeMode }: { + vehicle: { id: string; name: string; mode: string }; + state: VehicleChargeState; + onClose: () => void; + changeMode: (id: string, mode: VehicleMode) => void; + }, +) { + const { addToast } = useToast(); + const { schedules, scheduleOneOff, cancelOneOff } = useOneOffCharges(); + + return ( + !open && onClose()} + vehicleId={vehicle.id} + vehicleName={vehicle.name || state.vehicleName} + state={state} + mode={vehicle.mode as VehicleMode} + schedules={schedules} + onSchedule={async (form) => { + const err = await scheduleOneOff(vehicle.id, form); + if (err) return err; + if (form.switchToAuto && vehicle.mode !== "auto") { + changeMode(vehicle.id, "auto"); + } + addToast("Charge scheduled", "success"); + return null; + }} + onCancelPending={async (id) => { + await cancelOneOff(id); + addToast("Scheduled charge cancelled", "success"); + }} + /> + ); +} + export function VehicleList( { onNavigateSettings }: VehicleListProps, ) { @@ -288,6 +334,11 @@ export function VehicleList( controllerStatuses, ); + // Which vehicle's one-off charge dialog is open, if any + const [scheduleFor, setScheduleFor] = useState(null); + const { oneOffByVehicle } = useOneOffCharges(); + const scheduleVehicle = vehicles.find((v) => v.id === scheduleFor); + return (
{/* Vehicle section — one card per configured vehicle */} @@ -314,9 +365,20 @@ export function VehicleList( stopCharging={stopCharging} setAmps={setAmps} changeMode={changeMode} + oneOffByVehicle={oneOffByVehicle} + onScheduleCharge={setScheduleFor} onNavigateSettings={onNavigateSettings} /> + {scheduleVehicle?.state && ( + setScheduleFor(null)} + changeMode={changeMode} + /> + )} + {!vehiclesLoading && vehicles.length === 0 && vehiclesError && ( { days: [...ALL_DAYS], chargeAmps: 16, chargeLimitPct: 80, + oneOffDate: null, enabled: true, }); diff --git a/packages/client/src/components/pages/Schedules/test-helpers/setupSchedules.ts b/packages/client/src/components/pages/Schedules/test-helpers/setupSchedules.ts index 4b12d7b9..01cb53ce 100644 --- a/packages/client/src/components/pages/Schedules/test-helpers/setupSchedules.ts +++ b/packages/client/src/components/pages/Schedules/test-helpers/setupSchedules.ts @@ -66,6 +66,7 @@ export const chargeSchedule: ChargeSchedule = { days: ["mon", "tue", "wed"] as DayOfWeek[], chargeAmps: 16, chargeLimitPct: 80, + oneOffDate: null, enabled: true, }; diff --git a/packages/client/src/hooks/useOneOffCharge.ts b/packages/client/src/hooks/useOneOffCharge.ts new file mode 100644 index 00000000..4da887a8 --- /dev/null +++ b/packages/client/src/hooks/useOneOffCharge.ts @@ -0,0 +1,68 @@ +import { useMemo } from "react"; +import type { ChargeSchedule, OneOffChargeFormData } from "@chargeha/shared"; +import { trpc } from "../trpc.ts"; + +/** + * The pending one-off charges (keyed by vehicle) plus create/cancel actions. + * + * One-off charges are stored as dated charge schedules, so they arrive on the + * same `schedule.list` query as everything else — no extra request. + */ +export function useOneOffCharges() { + const utils = trpc.useUtils(); + const { data } = trpc.schedule.list.useQuery(); + + const createMutation = trpc.schedule.createOneOff.useMutation({ + onSuccess: () => utils.schedule.list.invalidate(), + }); + const deleteMutation = trpc.schedule.delete.useMutation({ + onSuccess: () => utils.schedule.list.invalidate(), + }); + + const oneOffByVehicle = useMemo(() => { + const entries = (data?.schedules ?? []) + .filter((s): s is ChargeSchedule => + s.scheduleType === "charge" && !!s.oneOffDate + ) + .map((s) => [s.vehicleId, s] as const); + return Object.fromEntries(entries) as Record; + }, [data?.schedules]); + + const scheduleOneOff = useMemo( + () => + async ( + vehicleId: string, + form: OneOffChargeFormData, + ): Promise => { + try { + await createMutation.mutateAsync({ + vehicleId, + startTime: form.startTime, + durationMinutes: form.durationMinutes, + chargeAmps: form.chargeAmps, + chargeLimitPct: form.chargeLimitPct, + }); + return null; + } catch (e) { + const msg = e instanceof Error + ? e.message + : "Failed to schedule charge"; + console.error("[useOneOffCharges] Create failed:", msg); + return msg; + } + }, + [createMutation.mutateAsync], + ); + + const cancelOneOff = useMemo( + () => (id: string) => deleteMutation.mutateAsync({ id }), + [deleteMutation.mutateAsync], + ); + + return { + schedules: data?.schedules ?? [], + oneOffByVehicle, + scheduleOneOff, + cancelOneOff, + }; +} diff --git a/packages/client/src/hooks/useSchedules.ts b/packages/client/src/hooks/useSchedules.ts index 6cfaae2e..0d9527c7 100644 --- a/packages/client/src/hooks/useSchedules.ts +++ b/packages/client/src/hooks/useSchedules.ts @@ -60,6 +60,9 @@ export function validateScheduleOverlap( (s) => s.id !== excludeId && s.scheduleType === "charge" && + // One-offs are transient and warn about clashes in their own dialog — + // they shouldn't block editing a recurring schedule. + !s.oneOffDate && s.vehicleId === data.vehicleId, ); diff --git a/packages/client/src/lib/demo/demoState.ts b/packages/client/src/lib/demo/demoState.ts index 6e8153e6..cb12c1f8 100644 --- a/packages/client/src/lib/demo/demoState.ts +++ b/packages/client/src/lib/demo/demoState.ts @@ -27,6 +27,8 @@ export interface DemoVehicle { export interface DemoSchedule { id: string; + /** Set for one-off charges: the calendar date the window starts on. */ + oneOffDate?: string | null; vehicleId: string | null; scheduleType: string; startTime: string; diff --git a/packages/client/src/lib/demo/handlers/mutations/schedule.test.ts b/packages/client/src/lib/demo/handlers/mutations/schedule.test.ts new file mode 100644 index 00000000..db583649 --- /dev/null +++ b/packages/client/src/lib/demo/handlers/mutations/schedule.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getDemoState, + initDemoState, + resetDemoState, + updateDemoState, +} from "../../demoState.ts"; +import { clearPersisted } from "../../demoPersistence.ts"; +import { scheduleMutations } from "./schedule.ts"; +import { isActiveNow } from "../schedule.ts"; + +describe("demo schedule.createOneOff", () => { + // Tuesday 2026-08-11 14:00 local — the demo clock reads host-local time + const TUESDAY_AFTERNOON = new Date(2026, 7, 11, 14, 0); + + beforeEach(async () => { + resetDemoState(); + clearPersisted(); + vi.useFakeTimers(); + vi.setSystemTime(TUESDAY_AFTERNOON); + await initDemoState(); + }); + + afterEach(() => { + vi.useRealTimers(); + resetDemoState(); + clearPersisted(); + }); + + const createOneOff = ( + overrides: Partial<{ + vehicleId: string; + startTime: string; + durationMinutes: number; + chargeAmps: number; + chargeLimitPct: number; + }> = {}, + ) => + scheduleMutations["schedule.createOneOff"]({ + vehicleId: "v1", + startTime: "23:30", + durationMinutes: 180, + chargeAmps: 16, + chargeLimitPct: 80, + ...overrides, + }); + + it("stores a dated charge schedule with the derived end time", () => { + const { schedule } = createOneOff(); + + expect(schedule.scheduleType).toBe("charge"); + expect(schedule.startTime).toBe("23:30"); + expect(schedule.endTime).toBe("02:30"); + if (schedule.scheduleType !== "charge") throw new Error("expected charge"); + expect(schedule.oneOffDate).toBe("2026-08-11"); + expect(getDemoState().schedules).toHaveLength(1); + }); + + it("replaces an existing pending one-off for the same vehicle", () => { + createOneOff(); + const second = createOneOff({ durationMinutes: 60 }); + + const stored = getDemoState().schedules; + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe(second.schedule.id); + expect(stored[0].endTime).toBe("00:30"); + }); + + it("leaves another vehicle's one-off in place", () => { + createOneOff(); + createOneOff({ vehicleId: "v2" }); + expect(getDemoState().schedules).toHaveLength(2); + }); + + it("leaves recurring schedules in place", () => { + updateDemoState((m) => ({ + ...m, + schedules: [{ + id: "recurring", + vehicleId: "v1", + scheduleType: "charge", + startTime: "08:00", + endTime: "12:00", + days: ["mon"], + chargeAmps: 10, + chargeLimitPct: 70, + enabled: true, + }], + })); + + createOneOff(); + createOneOff(); + + const stored = getDemoState().schedules; + expect(stored).toHaveLength(2); + expect(stored.some((s) => s.id === "recurring")).toBe(true); + }); + + it("is inactive before its window and active inside it", () => { + createOneOff(); + const stored = getDemoState().schedules[0]; + + expect(isActiveNow(stored, new Date(2026, 7, 11, 22, 0))).toBe(false); + expect(isActiveNow(stored, new Date(2026, 7, 11, 23, 45))).toBe(true); + // Past midnight, when the weekday no longer matches its days + expect(isActiveNow(stored, new Date(2026, 7, 12, 1, 0))).toBe(true); + expect(isActiveNow(stored, new Date(2026, 7, 12, 3, 0))).toBe(false); + }); + + it("does not recur the following week", () => { + createOneOff(); + const stored = getDemoState().schedules[0]; + expect(isActiveNow(stored, new Date(2026, 7, 18, 23, 45))).toBe(false); + }); +}); diff --git a/packages/client/src/lib/demo/handlers/mutations/schedule.ts b/packages/client/src/lib/demo/handlers/mutations/schedule.ts index df4d94dc..a0e1786d 100644 --- a/packages/client/src/lib/demo/handlers/mutations/schedule.ts +++ b/packages/client/src/lib/demo/handlers/mutations/schedule.ts @@ -2,10 +2,16 @@ import type { MutationHandlers } from "../types.ts"; import type { DemoSchedule } from "../../demoState.ts"; import { updateDemoState } from "../../demoState.ts"; import { toSchedule } from "../schedule.ts"; +import { demoNow } from "../../demoClock.ts"; +import { resolveOneOffWindow } from "@chargeha/shared/oneOffCharge"; +import { dayOfWeekForDate } from "@chargeha/shared/localTime"; type ScheduleMutations = Pick< MutationHandlers, - "schedule.create" | "schedule.update" | "schedule.delete" + | "schedule.create" + | "schedule.update" + | "schedule.delete" + | "schedule.createOneOff" >; export const scheduleMutations: ScheduleMutations = { @@ -59,4 +65,38 @@ export const scheduleMutations: ScheduleMutations = { })); return { success: true }; }, + + // Mirrors ScheduleService.createOneOff: resolve the next occurrence of the + // start time and replace any pending one-off for the vehicle. + "schedule.createOneOff": (input) => { + const window = resolveOneOffWindow( + input.startTime, + input.durationMinutes, + demoNow(), + "", + ); + const created: DemoSchedule = { + id: crypto.randomUUID(), + vehicleId: input.vehicleId, + scheduleType: "charge", + startTime: input.startTime, + endTime: window.endTime, + days: [dayOfWeekForDate(window.oneOffDate)], + chargeAmps: input.chargeAmps, + chargeLimitPct: input.chargeLimitPct, + oneOffDate: window.oneOffDate, + enabled: true, + }; + updateDemoState((m) => ({ + ...m, + schedules: [ + ...m.schedules.filter((s) => + !(s.scheduleType === "charge" && s.vehicleId === input.vehicleId && + s.oneOffDate) + ), + created, + ], + })); + return { schedule: toSchedule(created) }; + }, }; diff --git a/packages/client/src/lib/demo/handlers/schedule.ts b/packages/client/src/lib/demo/handlers/schedule.ts index 309b9e01..5c944134 100644 --- a/packages/client/src/lib/demo/handlers/schedule.ts +++ b/packages/client/src/lib/demo/handlers/schedule.ts @@ -1,18 +1,7 @@ -import type { DayOfWeek } from "@chargeha/shared"; import type { QueryHandler } from "./types.ts"; import type { DemoSchedule } from "../demoState.ts"; -import { minuteOfDay } from "../demoDates.ts"; import { demoNow } from "../demoClock.ts"; - -const DAY_ABBRS: DayOfWeek[] = [ - "sun", - "mon", - "tue", - "wed", - "thu", - "fri", - "sat", -]; +import { isScheduleActiveNow } from "@chargeha/shared/engine"; /** Map a stored schedule to the server's discriminated charge/blockout shape. */ export const toSchedule = (r: DemoSchedule) => { @@ -30,24 +19,32 @@ export const toSchedule = (r: DemoSchedule) => { scheduleType: "charge" as const, chargeAmps: r.chargeAmps ?? 0, chargeLimitPct: r.chargeLimitPct ?? 0, + oneOffDate: r.oneOffDate ?? null, }; } return { ...base, vehicleId: null, scheduleType: "blockout" as const }; }; -const minutesOf = (t: string): number => { - const [h, m] = t.split(":").map(Number); - return h * 60 + m; -}; - -/** True if the schedule is enabled and its window contains `now` (handles wrap). */ -export const isActiveNow = (r: DemoSchedule, now: Date): boolean => { - if (!r.enabled || !r.days.includes(DAY_ABBRS[now.getDay()])) return false; - const cur = minuteOfDay(now); - const start = minutesOf(r.startTime); - const end = minutesOf(r.endTime); - return start <= end ? cur >= start && cur < end : cur >= start || cur < end; -}; +/** True if the schedule is enabled and its window contains `now`. + * Delegates to the real engine predicate with an empty timezone, so the demo + * matches against browser-local time the way the rest of the demo clock does. */ +export const isActiveNow = (r: DemoSchedule, now: Date): boolean => + r.enabled && isScheduleActiveNow( + { + id: r.id, + vehicleId: r.vehicleId, + scheduleType: r.scheduleType === "charge" ? "charge" : "blockout", + startTime: r.startTime, + endTime: r.endTime, + days: r.days, + chargeAmps: r.chargeAmps, + chargeLimitPct: r.chargeLimitPct, + oneOffDate: r.oneOffDate ?? null, + enabled: r.enabled, + }, + now, + "", + ); export const scheduleHandlers: Record = { "schedule.list": (_i, s) => ({ schedules: s.schedules.map(toSchedule) }), diff --git a/packages/client/src/lib/test-helpers/solarFactories.ts b/packages/client/src/lib/test-helpers/solarFactories.ts index 6b7230b1..4d1c5ca0 100644 --- a/packages/client/src/lib/test-helpers/solarFactories.ts +++ b/packages/client/src/lib/test-helpers/solarFactories.ts @@ -44,6 +44,7 @@ export const makeChargeSchedule = ( days: ["mon", "tue", "wed", "thu", "fri"], chargeAmps: 10, chargeLimitPct: 80, + oneOffDate: null, enabled: true, ...overrides, }); diff --git a/packages/client/src/utils/Format.ts b/packages/client/src/utils/Format.ts index 93ae91de..92f0af1f 100644 --- a/packages/client/src/utils/Format.ts +++ b/packages/client/src/utils/Format.ts @@ -73,6 +73,21 @@ export function formatDays(days: string[]): string { return sorted.map((d) => DAY_LABELS[d]).join(", "); } +/** + * Format a "YYYY-MM-DD" calendar date for display. + * e.g. "2026-08-12" → "Wed 12 Aug" + */ +export function formatCalendarDate(date: string): string { + const [y, m, d] = date.split("-").map(Number); + // Built as UTC so the calendar date is never shifted by the host's offset + return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-GB", { + timeZone: "UTC", + weekday: "short", + day: "numeric", + month: "short", + }); +} + /** * Format cents to a currency string. * e.g. formatCost(1250, '$') → '$12.50' diff --git a/packages/server/src/bootstrap/bootstrap.ts b/packages/server/src/bootstrap/bootstrap.ts index 5640d608..b3c32b2d 100644 --- a/packages/server/src/bootstrap/bootstrap.ts +++ b/packages/server/src/bootstrap/bootstrap.ts @@ -267,6 +267,7 @@ function buildServices( poller, db, configService, + scheduleService, eventEmitter, new Logger("ChargeController", logLevel), ); diff --git a/packages/server/src/db/Schema.ts b/packages/server/src/db/Schema.ts index ebef2464..8873ee52 100644 --- a/packages/server/src/db/Schema.ts +++ b/packages/server/src/db/Schema.ts @@ -105,6 +105,9 @@ export const schedules = sqliteTable("schedules", { chargeAmps: integer("charge_amps"), chargeLimitPct: integer("charge_limit_pct"), enabled: integer("enabled").notNull().default(1), + // Set for one-off charges: the calendar date (user's timezone) the window + // starts on. Null for recurring schedules. + oneOffDate: text("one_off_date"), createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), }); diff --git a/packages/server/src/db/repositories/ScheduleRepository.ts b/packages/server/src/db/repositories/ScheduleRepository.ts index 346c82c4..6e8471c2 100644 --- a/packages/server/src/db/repositories/ScheduleRepository.ts +++ b/packages/server/src/db/repositories/ScheduleRepository.ts @@ -44,6 +44,7 @@ export class ScheduleRepository { daysJson: JSON.stringify(input.days), chargeAmps: input.chargeAmps, chargeLimitPct: input.chargeLimitPct, + oneOffDate: input.oneOffDate ?? null, enabled: input.enabled !== false ? 1 : 0, }); } @@ -63,6 +64,7 @@ export class ScheduleRepository { if (input.chargeLimitPct !== undefined) { set.chargeLimitPct = input.chargeLimitPct; } + if (input.oneOffDate !== undefined) set.oneOffDate = input.oneOffDate; if (input.enabled !== undefined) set.enabled = input.enabled ? 1 : 0; if (Object.keys(set).length === 0) return; diff --git a/packages/server/src/db/types.ts b/packages/server/src/db/types.ts index be06aa24..02017b39 100644 --- a/packages/server/src/db/types.ts +++ b/packages/server/src/db/types.ts @@ -43,6 +43,8 @@ export interface ScheduleRow { days: DayOfWeek[]; chargeAmps: number | null; chargeLimitPct: number | null; + /** Calendar date ("YYYY-MM-DD") for a one-off charge; null when recurring. */ + oneOffDate: string | null; enabled: boolean; createdAt: string; updatedAt: string; @@ -57,6 +59,7 @@ export interface CreateScheduleInput { days: DayOfWeek[]; chargeAmps: number | null; chargeLimitPct: number | null; + oneOffDate?: string | null; enabled?: boolean; } diff --git a/packages/server/src/services/ChargeController.ts b/packages/server/src/services/ChargeController.ts index 7935ad24..1a6d416a 100644 --- a/packages/server/src/services/ChargeController.ts +++ b/packages/server/src/services/ChargeController.ts @@ -28,6 +28,7 @@ import type { VehicleManager } from "./VehicleManager.ts"; import type { EnergyPoller } from "./EnergyPoller.ts"; import type { TypedEventEmitter } from "./TypedEventEmitter.ts"; import type { ConfigService } from "./ConfigService.ts"; +import type { ScheduleService } from "./ScheduleService.ts"; import type { Logger } from "../lib/Logger.ts"; // Default loop interval (overridden by config) @@ -87,6 +88,7 @@ export class ChargeController { private readonly poller: EnergyPoller; private readonly db: AppDatabase; private readonly configService: ConfigService; + private readonly scheduleService: ScheduleService; private readonly eventEmitter: TypedEventEmitter; private readonly logger: Logger; private readonly engine = new ControllerEngine(); @@ -98,6 +100,7 @@ export class ChargeController { poller: EnergyPoller, db: AppDatabase, configService: ConfigService, + scheduleService: ScheduleService, eventEmitter: TypedEventEmitter, logger: Logger, ) { @@ -105,6 +108,7 @@ export class ChargeController { this.poller = poller; this.db = db; this.configService = configService; + this.scheduleService = scheduleService; this.eventEmitter = eventEmitter; this.logger = logger; this.start(); @@ -137,9 +141,18 @@ export class ChargeController { const traceId = createTraceId(); const config = await this.loadConfig(); const vehicles = await this.db.getVehicles(); - const schedules = await this.db.getSchedules(); + const loadedSchedules = await this.db.getSchedules(); const energySnapshot = this.poller.tryGetRealtimeSnapshot(); const now = new Date(); + + // Drop one-off charges whose window has elapsed, so they neither linger in + // the UI nor get re-evaluated. Reuses the schedules already loaded. + const schedules = await this.scheduleService.deleteExpiredOneOffs( + loadedSchedules, + now, + config.timezone, + ); + const energy = energySnapshot?.realtime ?? null; const solarW = energy && Math.round(energy.solarProductionW); const gridW = energy && Math.round(energy.gridPowerW); diff --git a/packages/server/src/services/ScheduleService.oneOff.test.ts b/packages/server/src/services/ScheduleService.oneOff.test.ts new file mode 100644 index 00000000..f12c76e9 --- /dev/null +++ b/packages/server/src/services/ScheduleService.oneOff.test.ts @@ -0,0 +1,263 @@ +import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import { FakeTime } from "@std/testing/time"; +import { ServiceError } from "../lib/ServiceError.ts"; +import { AppDatabase } from "../db/AppDatabase.ts"; +import { ScheduleService } from "./ScheduleService.ts"; +import { Logger } from "../lib/Logger.ts"; + +describe("ScheduleService — one-off charges", () => { + const SYDNEY = "Australia/Sydney"; + /** 2026-08-11 04:00Z = Tuesday 14:00 in Sydney (AEST, UTC+10). */ + const TUESDAY_AFTERNOON = "2026-08-11T04:00:00Z"; + const testLogger = new Logger("ScheduleService", "error"); + let db: AppDatabase; + let service: ScheduleService; + let time: FakeTime; + + beforeEach(async () => { + db = new AppDatabase(":memory:"); + await db.init(); + service = new ScheduleService(db, testLogger); + await db.setConfig("timezone", SYDNEY); + // Default "now" for every test; individual tests move it with `time.now` + time = new FakeTime(new Date(TUESDAY_AFTERNOON)); + await db.upsertVehicle({ + id: "v1", + name: "Test Car", + adapterType: "simulated", + priority: 1, + config: "{}", + mode: "auto", + }); + }); + + afterEach(() => { + time.restore(); + db.close(); + }); + + const createOneOff = ( + overrides: Partial[0]> = {}, + ) => + service.createOneOff({ + vehicleId: "v1", + startTime: "23:30", + durationMinutes: 180, + chargeAmps: 16, + chargeLimitPct: 80, + ...overrides, + }); + + describe("createOneOff", () => { + it("stores a dated charge schedule with the derived end time", async () => { + const { schedule } = await createOneOff(); + + expect(schedule.scheduleType).toBe("charge"); + expect(schedule.vehicleId).toBe("v1"); + expect(schedule.startTime).toBe("23:30"); + expect(schedule.endTime).toBe("02:30"); + expect(schedule.enabled).toBe(true); + if (schedule.scheduleType !== "charge") { + throw new Error("expected charge"); + } + expect(schedule.oneOffDate).toBe("2026-08-11"); + expect(schedule.chargeAmps).toBe(16); + expect(schedule.chargeLimitPct).toBe(80); + }); + + it("resolves the start time in the configured timezone", async () => { + // 20:00Z on the 11th is already 06:00 on the 12th in Sydney, so the next + // 23:30 there is on the 12th + time.now = new Date("2026-08-11T20:00:00Z").getTime(); + const { schedule } = await createOneOff(); + if (schedule.scheduleType !== "charge") { + throw new Error("expected charge"); + } + expect(schedule.oneOffDate).toBe("2026-08-12"); + }); + + it("uses tomorrow when the start time has already passed today", async () => { + // Sydney 14:00 — a 10:00 start has gone + const { schedule } = await createOneOff({ startTime: "10:00" }); + if (schedule.scheduleType !== "charge") { + throw new Error("expected charge"); + } + expect(schedule.oneOffDate).toBe("2026-08-12"); + }); + + it("sets days to the start date's weekday", async () => { + const { schedule } = await createOneOff(); + expect(schedule.days).toEqual(["tue"]); + }); + + it("replaces an existing pending one-off for the same vehicle", async () => { + const first = await createOneOff(); + const second = await createOneOff({ durationMinutes: 60 }); + + const { schedules } = await service.list(); + expect(schedules).toHaveLength(1); + expect(schedules[0].id).toBe(second.schedule.id); + expect(schedules[0].id).not.toBe(first.schedule.id); + expect(schedules[0].endTime).toBe("00:30"); + }); + + it("leaves recurring schedules and other vehicles alone", async () => { + await db.upsertVehicle({ + id: "v2", + name: "Other Car", + adapterType: "simulated", + priority: 2, + config: "{}", + mode: "auto", + }); + await service.create({ + scheduleType: "charge", + vehicleId: "v1", + startTime: "08:00", + endTime: "12:00", + days: ["mon"], + chargeAmps: 10, + chargeLimitPct: 70, + }); + await createOneOff({ vehicleId: "v2" }); + + await createOneOff(); + await createOneOff(); + + const { schedules } = await service.list(); + // recurring + v2's one-off + v1's single one-off + expect(schedules).toHaveLength(3); + const oneOffs = schedules.filter((s) => + s.scheduleType === "charge" && s.oneOffDate + ); + expect(oneOffs).toHaveLength(2); + }); + + it("rejects an unknown vehicle", async () => { + await expect(createOneOff({ vehicleId: "nope" })).rejects.toBeInstanceOf( + ServiceError, + ); + }); + }); + + describe("getActiveSchedules", () => { + it("reports the one-off active inside its window", async () => { + await createOneOff(); + + // Sydney 2026-08-12 01:00 — past midnight, weekday no longer "tue" + time.now = new Date("2026-08-11T15:00:00Z").getTime(); + const active = await service.getActiveSchedules(); + expect(active).toHaveLength(1); + expect(active[0].startTime).toBe("23:30"); + }); + + it("does not report it before the window opens", async () => { + await createOneOff(); + + // Sydney 2026-08-11 22:00 + time.now = new Date("2026-08-11T12:00:00Z").getTime(); + expect(await service.getActiveSchedules()).toHaveLength(0); + }); + + it("does not report it a week later", async () => { + await createOneOff(); + + // Sydney 2026-08-18 23:45 — same clock reading, next Tuesday + time.now = new Date("2026-08-18T13:45:00Z").getTime(); + expect(await service.getActiveSchedules()).toHaveLength(0); + }); + }); + + describe("deleteExpiredOneOffs", () => { + it("deletes a one-off whose window has elapsed", async () => { + await createOneOff(); + + const rows = await db.getSchedules(); + // Sydney 2026-08-12 03:00 — window ended at 02:30 + const remaining = await service.deleteExpiredOneOffs( + rows, + new Date("2026-08-11T17:00:00Z"), + SYDNEY, + ); + + expect(remaining).toHaveLength(0); + expect((await service.list()).schedules).toHaveLength(0); + }); + + it("keeps a one-off that is still running", async () => { + await createOneOff(); + + const rows = await db.getSchedules(); + // Sydney 2026-08-12 01:00 + const remaining = await service.deleteExpiredOneOffs( + rows, + new Date("2026-08-11T15:00:00Z"), + SYDNEY, + ); + + expect(remaining).toHaveLength(1); + expect((await service.list()).schedules).toHaveLength(1); + }); + + it("keeps a one-off that has not started", async () => { + await createOneOff(); + + const rows = await db.getSchedules(); + const remaining = await service.deleteExpiredOneOffs( + rows, + new Date(TUESDAY_AFTERNOON), + SYDNEY, + ); + + expect(remaining).toHaveLength(1); + }); + + it("never deletes recurring schedules", async () => { + await service.create({ + scheduleType: "charge", + vehicleId: "v1", + startTime: "08:00", + endTime: "12:00", + days: ["mon"], + chargeAmps: 10, + chargeLimitPct: 70, + }); + await service.create({ + scheduleType: "blockout", + startTime: "16:00", + endTime: "21:00", + days: ["mon", "tue"], + }); + + const rows = await db.getSchedules(); + const remaining = await service.deleteExpiredOneOffs( + rows, + new Date("2030-01-01T00:00:00Z"), + SYDNEY, + ); + + expect(remaining).toHaveLength(2); + expect((await service.list()).schedules).toHaveLength(2); + }); + }); + + describe("list", () => { + it("reports oneOffDate as null for recurring charge schedules", async () => { + await service.create({ + scheduleType: "charge", + vehicleId: "v1", + startTime: "08:00", + endTime: "12:00", + days: ["mon"], + chargeAmps: 10, + chargeLimitPct: 70, + }); + + const { schedules } = await service.list(); + const s = schedules[0]; + if (s.scheduleType !== "charge") throw new Error("expected charge"); + expect(s.oneOffDate).toBeNull(); + }); + }); +}); diff --git a/packages/server/src/services/ScheduleService.ts b/packages/server/src/services/ScheduleService.ts index 89e8d075..11d07a89 100644 --- a/packages/server/src/services/ScheduleService.ts +++ b/packages/server/src/services/ScheduleService.ts @@ -1,8 +1,11 @@ import { ServiceError } from "../lib/ServiceError.ts"; import type { DayOfWeek } from "@chargeha/shared"; import type { AppDatabase } from "../db/AppDatabase.ts"; +import type { ScheduleRow } from "../db/types.ts"; import type { Logger } from "../lib/Logger.ts"; -import { isScheduleActiveNow } from "@chargeha/shared/engine"; +import { isOneOffExpired, isScheduleActiveNow } from "@chargeha/shared/engine"; +import { dayOfWeekForDate } from "@chargeha/shared/localTime"; +import { resolveOneOffWindow } from "@chargeha/shared/oneOffCharge"; function rowToSchedule( row: { @@ -14,6 +17,7 @@ function rowToSchedule( days: string[]; chargeAmps: number | null; chargeLimitPct: number | null; + oneOffDate?: string | null; enabled: boolean; }, ) { @@ -27,6 +31,7 @@ function rowToSchedule( days: row.days as DayOfWeek[], chargeAmps: row.chargeAmps as number, chargeLimitPct: row.chargeLimitPct as number, + oneOffDate: row.oneOffDate ?? null, enabled: row.enabled, }; } @@ -76,6 +81,98 @@ export class ScheduleService { return (await this.db.getConfig("timezone")) ?? "UTC"; } + /** + * Schedule a one-off charge on the next occurrence of `startTime`. + * + * Stored as a normal charge schedule carrying a calendar date, so the + * controller, wake logic and notifications treat it like any other charge + * window. A vehicle holds at most one pending one-off — creating another + * replaces it. + */ + async createOneOff(input: { + vehicleId: string; + startTime: string; + durationMinutes: number; + chargeAmps: number; + chargeLimitPct: number; + }) { + const vehicle = await this.db.getVehicle(input.vehicleId); + if (!vehicle) { + throw new ServiceError("Vehicle not found", "NOT_FOUND"); + } + + const timezone = await this.getTimezone(); + const window = resolveOneOffWindow( + input.startTime, + input.durationMinutes, + new Date(), + timezone, + ); + + // Replace any pending one-off for this vehicle + const existing = await this.db.getSchedules(); + await Promise.all( + existing + .filter((s) => + s.scheduleType === "charge" && s.vehicleId === input.vehicleId && + !!s.oneOffDate + ) + .map((s) => this.db.deleteSchedule(s.id)), + ); + + const id = crypto.randomUUID(); + await this.db.createSchedule({ + id, + vehicleId: input.vehicleId, + scheduleType: "charge", + startTime: input.startTime, + endTime: window.endTime, + // Day-of-week isn't consulted for one-offs, but the column is NOT NULL + // and keeps the row readable alongside recurring schedules. + days: [dayOfWeekForDate(window.oneOffDate)], + chargeAmps: input.chargeAmps, + chargeLimitPct: input.chargeLimitPct, + oneOffDate: window.oneOffDate, + }); + + const row = await this.db.getSchedule(id); + if (!row) { + throw new ServiceError( + "Failed to create one-off charge", + "INTERNAL_SERVER_ERROR", + ); + } + + this.logger.info( + `One-off charge created for ${vehicle.name}: ${window.oneOffDate} ${input.startTime}-${window.endTime} at ${input.chargeAmps}A to ${input.chargeLimitPct}% (${id})`, + ); + return { schedule: rowToSchedule(row) }; + } + + /** + * Delete one-off charges whose window has fully elapsed, returning the + * schedules that survive. + * + * Called from the controller loop with the schedules it already loaded, so it + * costs no extra read, and the caller can carry on with the returned list. + */ + async deleteExpiredOneOffs( + schedules: ScheduleRow[], + now: Date, + timezone: string, + ): Promise { + const expired = schedules.filter((s) => isOneOffExpired(s, now, timezone)); + if (expired.length === 0) return schedules; + + await Promise.all(expired.map((s) => this.db.deleteSchedule(s.id))); + this.logger.info( + `Removed ${expired.length} expired one-off charge${ + expired.length === 1 ? "" : "s" + }`, + ); + return schedules.filter((s) => !expired.includes(s)); + } + async create(input: { scheduleType: "charge" | "blockout"; vehicleId?: string | null; diff --git a/packages/server/src/services/TariffService.ts b/packages/server/src/services/TariffService.ts index be0f155c..6950a656 100644 --- a/packages/server/src/services/TariffService.ts +++ b/packages/server/src/services/TariffService.ts @@ -6,7 +6,7 @@ import type { CreateTariffPeriodInput, TariffPeriodRow } from "../db/types.ts"; import { getApplicablePeriodForTime, parseTimeToMinutes, -} from "../lib/Tariffs.ts"; +} from "@chargeha/shared/tariffs"; import type { Logger } from "../lib/Logger.ts"; const DAY_ABBRS: DayOfWeek[] = [ diff --git a/packages/server/src/test-helpers/ChargeControllerHarness.ts b/packages/server/src/test-helpers/ChargeControllerHarness.ts index ed909491..2c131fb2 100644 --- a/packages/server/src/test-helpers/ChargeControllerHarness.ts +++ b/packages/server/src/test-helpers/ChargeControllerHarness.ts @@ -28,6 +28,7 @@ import { VehicleManager } from "../services/VehicleManager.ts"; import type { EnergyPoller } from "../services/EnergyPoller.ts"; import { ChargeController } from "../services/ChargeController.ts"; import { ConfigService } from "../services/ConfigService.ts"; +import { ScheduleService } from "../services/ScheduleService.ts"; import type { EnergyAdapterManager } from "../services/EnergyAdapterManager.ts"; import { Logger } from "../lib/Logger.ts"; import { testable } from "./Testable.ts"; @@ -236,6 +237,7 @@ async function buildControllerStack( poller as unknown as EnergyPoller, db, configService, + new ScheduleService(db, new Logger("ScheduleService", "error")), trackingEmitter, testControllerLogger, ); diff --git a/packages/server/src/trpc/routers/schedules.ts b/packages/server/src/trpc/routers/schedules.ts index 29281b81..27935d0e 100644 --- a/packages/server/src/trpc/routers/schedules.ts +++ b/packages/server/src/trpc/routers/schedules.ts @@ -1,5 +1,6 @@ import { publicProcedure, router } from "../trpc.ts"; import { + oneOffChargeCreateInput, scheduleCreateInput, scheduleDeleteInput, scheduleUpdateInput, @@ -36,4 +37,11 @@ export const schedulesRouter = router({ .mutation(async ({ ctx, input }) => { return await ctx.scheduleService.delete(input.id); }), + + // Schedule a one-off charge on the next occurrence of a start time + createOneOff: publicProcedure + .input(oneOffChargeCreateInput) + .mutation(async ({ ctx, input }) => { + return await ctx.scheduleService.createOneOff(input); + }), }); diff --git a/packages/shared/chargeCostEstimate.test.ts b/packages/shared/chargeCostEstimate.test.ts new file mode 100644 index 00000000..f35beab8 --- /dev/null +++ b/packages/shared/chargeCostEstimate.test.ts @@ -0,0 +1,300 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import { + chargePowerKw, + estimateChargeCost, + resolveChargePhases, + resolveChargeVoltage, +} from "./chargeCostEstimate.ts"; +import type { TariffPeriodLike } from "./tariffs.ts"; + +describe("charge cost estimation", () => { + const ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; + + const period = ( + o: Partial & { + startTime: string; + endTime: string; + ratePerKwh: number; + }, + ): TariffPeriodLike => ({ + label: "Test", + days: ALL_DAYS, + enabled: true, + ...o, + }); + + // 16A × 230V × 1 phase = 3.68 kW + const BASE = { + amps: 16, + volts: 230, + phases: 1, + startDate: "2026-08-11", // Tuesday + defaultRatePerKwh: 0.30, + }; + + describe("chargePowerKw", () => { + it("computes single- and three-phase power", () => { + expect(chargePowerKw(16, 230, 1)).toBeCloseTo(3.68, 5); + expect(chargePowerKw(16, 230, 3)).toBeCloseTo(11.04, 5); + }); + }); + + describe("resolveChargeVoltage", () => { + const state = { chargerVoltage: 0, chargerPhases: 1, isCharging: false }; + + it("trusts the vehicle reading when it's a plausible mains voltage", () => { + expect(resolveChargeVoltage({ ...state, chargerVoltage: 241 }, 230)) + .toBe(241); + }); + + it("falls back to the measured grid voltage, then the configured one", () => { + expect(resolveChargeVoltage(state, 230, 238)).toBe(238); + expect(resolveChargeVoltage(state, 230)).toBe(230); + expect(resolveChargeVoltage(state, 230, null)).toBe(230); + }); + }); + + describe("resolveChargePhases", () => { + it("honours a live single-phase reading over the three-phase flag", () => { + const charging = { + chargerVoltage: 230, + chargerPhases: 1, + isCharging: true, + }; + expect(resolveChargePhases(charging, true)).toBe(1); + }); + + it("uses the configured flag when not charging", () => { + const idle = { chargerVoltage: 230, chargerPhases: 1, isCharging: false }; + expect(resolveChargePhases(idle, true)).toBe(3); + expect(resolveChargePhases(idle, false)).toBe(1); + }); + }); + + describe("estimateChargeCost", () => { + it("uses a single rate for a window inside one tariff period", () => { + const result = estimateChargeCost({ + ...BASE, + startTime: "23:30", + durationMinutes: 180, + tariffPeriods: [ + period({ + startTime: "22:00", + endTime: "07:00", + ratePerKwh: 0.08, + label: "EV", + }), + ], + }); + + // 3.68 kW × 3h = 11.04 kWh at $0.08 + expect(result.kwh).toBeCloseTo(11.04, 5); + expect(result.cost).toBeCloseTo(11.04 * 0.08, 5); + expect(result.segments.length).toBe(1); + expect(result.segments[0].label).toBe("EV"); + expect(result.segments[0].minutes).toBe(180); + }); + + it("splits the window across a tariff boundary", () => { + const result = estimateChargeCost({ + ...BASE, + startTime: "20:00", + durationMinutes: 240, // 20:00–00:00 + tariffPeriods: [ + period({ + startTime: "20:00", + endTime: "22:00", + ratePerKwh: 0.25, + label: "Shoulder", + }), + period({ + startTime: "22:00", + endTime: "07:00", + ratePerKwh: 0.08, + label: "EV", + }), + ], + }); + + expect(result.segments.length).toBe(2); + const byLabel = Object.fromEntries( + result.segments.map((s) => [s.label, s]), + ); + expect(byLabel.Shoulder.minutes).toBe(120); + expect(byLabel.EV.minutes).toBe(120); + // 3.68 kW × 2h = 7.36 kWh in each half + expect(byLabel.Shoulder.cost).toBeCloseTo(7.36 * 0.25, 5); + expect(byLabel.EV.cost).toBeCloseTo(7.36 * 0.08, 5); + expect(result.cost).toBeCloseTo(7.36 * 0.25 + 7.36 * 0.08, 5); + }); + + it("orders segments by cost, largest first", () => { + const result = estimateChargeCost({ + ...BASE, + startTime: "20:00", + durationMinutes: 240, + tariffPeriods: [ + period({ + startTime: "20:00", + endTime: "22:00", + ratePerKwh: 0.25, + label: "Shoulder", + }), + period({ + startTime: "22:00", + endTime: "07:00", + ratePerKwh: 0.08, + label: "EV", + }), + ], + }); + expect(result.segments.map((s) => s.label)).toEqual(["Shoulder", "EV"]); + }); + + it("advances the day-of-week when the window crosses midnight", () => { + // Tuesday 23:30 + 3h → 02:30 Wednesday. A Wednesday-only cheap rate must + // apply to the post-midnight portion. + const result = estimateChargeCost({ + ...BASE, + startTime: "23:30", + durationMinutes: 180, + tariffPeriods: [ + period({ + startTime: "22:00", + endTime: "00:00", + ratePerKwh: 0.40, + label: "Tue Peak", + days: ["tue"], + }), + period({ + startTime: "00:00", + endTime: "07:00", + ratePerKwh: 0.05, + label: "Wed EV", + days: ["wed"], + }), + ], + }); + + const byLabel = Object.fromEntries( + result.segments.map((s) => [s.label, s]), + ); + // 23:30–00:00 on Tuesday = 30 min; 00:00–02:30 on Wednesday = 150 min + expect(byLabel["Tue Peak"].minutes).toBe(30); + expect(byLabel["Wed EV"].minutes).toBe(150); + }); + + it("falls back to the default rate for uncovered minutes", () => { + const result = estimateChargeCost({ + ...BASE, + startTime: "10:00", + durationMinutes: 60, + tariffPeriods: [], + }); + + expect(result.segments.length).toBe(1); + expect(result.segments[0].label).toBe("Default"); + expect(result.segments[0].ratePerKwh).toBe(0.30); + expect(result.cost).toBeCloseTo(3.68 * 0.30, 5); + }); + + it("merges a split period with the same label and rate into one line", () => { + // Shoulder either side of peak — one line, not two + const result = estimateChargeCost({ + ...BASE, + startTime: "15:00", + durationMinutes: 420, // 15:00–22:00 + tariffPeriods: [ + period({ + startTime: "14:00", + endTime: "16:00", + ratePerKwh: 0.25, + label: "Shoulder", + }), + period({ + startTime: "16:00", + endTime: "21:00", + ratePerKwh: 0.45, + label: "Peak", + }), + period({ + startTime: "21:00", + endTime: "23:00", + ratePerKwh: 0.25, + label: "Shoulder", + }), + ], + }); + + const shoulder = result.segments.filter((s) => s.label === "Shoulder"); + expect(shoulder.length).toBe(1); + expect(shoulder[0].minutes).toBe(120); // 60 before peak + 60 after + }); + + it("ignores disabled tariff periods", () => { + const result = estimateChargeCost({ + ...BASE, + startTime: "23:30", + durationMinutes: 60, + tariffPeriods: [ + period({ + startTime: "22:00", + endTime: "07:00", + ratePerKwh: 0.08, + label: "EV", + enabled: false, + }), + ], + }); + expect(result.segments[0].label).toBe("Default"); + }); + + it("scales with amps and phases", () => { + const single = estimateChargeCost({ + ...BASE, + startTime: "23:30", + durationMinutes: 60, + tariffPeriods: [], + }); + const three = estimateChargeCost({ + ...BASE, + phases: 3, + startTime: "23:30", + durationMinutes: 60, + tariffPeriods: [], + }); + + expect(three.kwh).toBeCloseTo(single.kwh * 3, 5); + expect(three.cost).toBeCloseTo(single.cost * 3, 5); + expect(three.powerKw).toBeCloseTo(11.04, 5); + }); + + it("totals segment costs exactly", () => { + const result = estimateChargeCost({ + ...BASE, + startTime: "20:00", + durationMinutes: 480, + tariffPeriods: [ + period({ + startTime: "16:00", + endTime: "21:00", + ratePerKwh: 0.45, + label: "Peak", + }), + period({ + startTime: "21:00", + endTime: "07:00", + ratePerKwh: 0.08, + label: "EV", + }), + ], + }); + + const summed = result.segments.reduce((n, s) => n + s.cost, 0); + expect(result.cost).toBeCloseTo(summed, 10); + const summedMinutes = result.segments.reduce((n, s) => n + s.minutes, 0); + expect(summedMinutes).toBe(480); + }); + }); +}); diff --git a/packages/shared/chargeCostEstimate.ts b/packages/shared/chargeCostEstimate.ts new file mode 100644 index 00000000..84affb39 --- /dev/null +++ b/packages/shared/chargeCostEstimate.ts @@ -0,0 +1,155 @@ +import { addDaysToDate, dayOfWeekForDate, timeToMinutes } from "./localTime.ts"; +import { getApplicablePeriodForTime } from "./tariffs.ts"; +import type { TariffPeriodLike } from "./tariffs.ts"; + +/** One tariff period's share of an estimated charging session. */ +export interface CostEstimateSegment { + label: string; + ratePerKwh: number; + minutes: number; + kwh: number; + cost: number; +} + +export interface ChargeCostEstimate { + /** Total energy delivered if the session runs the full window. */ + kwh: number; + /** Total cost in the configured currency's major unit (e.g. dollars). */ + cost: number; + powerKw: number; + /** Per-tariff-period breakdown, largest cost first. */ + segments: CostEstimateSegment[]; +} + +export interface ChargeCostEstimateInput { + /** Charge current in amps. */ + amps: number; + /** Supply voltage. */ + volts: number; + /** Number of active phases (1 or 3). */ + phases: number; + /** Window length in minutes. */ + durationMinutes: number; + /** Local calendar date the window starts on ("YYYY-MM-DD"). */ + startDate: string; + /** Local wall-clock start time ("HH:MM"). */ + startTime: string; + tariffPeriods: TariffPeriodLike[]; + /** Rate applied to any minute no tariff period covers. */ + defaultRatePerKwh: number; +} + +const MINUTES_PER_DAY = 1440; + +/** The vehicle-reported electrical readings needed to resolve charge power. */ +export interface ChargerReadings { + chargerVoltage: number; + chargerPhases: number; + isCharging: boolean; +} + +/** Resolve charger voltage: trust the vehicle if >= 100V, otherwise fall back + * to the inverter grid reading, then the user's configured value. */ +export function resolveChargeVoltage( + state: ChargerReadings, + gridVoltage: number, + measuredGridVoltageV?: number | null, +): number { + if (state.chargerVoltage >= 100) return state.chargerVoltage; + return measuredGridVoltageV ?? gridVoltage; +} + +/** Resolve charger phases: a live single-phase reading while charging overrides + * the threePhaseCharger flag (e.g. a three-phase install charging from a + * regular wall socket). Vehicles only report phases while charging, so the + * flag stands until a real reading arrives. */ +export function resolveChargePhases( + state: ChargerReadings, + threePhaseCharger: boolean, +): number { + if (state.isCharging && state.chargerPhases === 1) return 1; + return threePhaseCharger ? 3 : state.chargerPhases; +} + +/** Charging power in kW for a given current, voltage and phase count. */ +export function chargePowerKw( + amps: number, + volts: number, + phases: number, +): number { + return (amps * volts * phases) / 1000; +} + +/** + * Estimate the cost of a charging session against the configured tariffs. + * + * Walks the window a minute at a time (capped at 8h, so ≤480 iterations), + * resolving the applicable tariff for each minute and advancing the + * day-of-week when the window crosses midnight. Minute granularity means + * tariff boundaries land exactly where the recorder would put them. + * + * This is an upper bound: it assumes full grid import for the whole window, + * a constant charge rate, and that the vehicle does not reach its charge + * limit early. + */ +export function estimateChargeCost( + input: ChargeCostEstimateInput, +): ChargeCostEstimate { + const { + amps, + volts, + phases, + durationMinutes, + startDate, + startTime, + tariffPeriods, + defaultRatePerKwh, + } = input; + + const powerKw = chargePowerKw(amps, volts, phases); + const kwhPerMinute = powerKw / 60; + const startMinutes = timeToMinutes(startTime); + + const rateForOffset = (offset: number) => { + const absolute = startMinutes + offset; + const dayOffset = Math.floor(absolute / MINUTES_PER_DAY); + const date = dayOffset === 0 + ? startDate + : addDaysToDate(startDate, dayOffset); + const period = getApplicablePeriodForTime( + absolute % MINUTES_PER_DAY, + dayOfWeekForDate(date), + tariffPeriods, + ); + return { + label: period?.label ?? "Default", + ratePerKwh: period?.ratePerKwh ?? defaultRatePerKwh, + }; + }; + + // Accumulate per (label, rate) so a split period — e.g. two "Shoulder" blocks + // either side of peak — reports as one line. + const byKey = Array.from({ length: durationMinutes }, (_, i) => i) + .map(rateForOffset) + .reduce((acc, { label, ratePerKwh }) => { + const key = `${label}|${ratePerKwh}`; + const existing = acc.get(key); + acc.set(key, { + label, + ratePerKwh, + minutes: (existing?.minutes ?? 0) + 1, + kwh: (existing?.kwh ?? 0) + kwhPerMinute, + cost: (existing?.cost ?? 0) + kwhPerMinute * ratePerKwh, + }); + return acc; + }, new Map()); + + const segments = [...byKey.values()].sort((a, b) => b.cost - a.cost); + + return { + kwh: kwhPerMinute * durationMinutes, + cost: segments.reduce((sum, s) => sum + s.cost, 0), + powerKw, + segments, + }; +} diff --git a/packages/shared/deno.json b/packages/shared/deno.json index 590f1c1f..724fcedd 100644 --- a/packages/shared/deno.json +++ b/packages/shared/deno.json @@ -12,6 +12,10 @@ "./engine": "./engine/mod.ts", "./async": "./async.ts", "./geo": "./geo.ts", + "./tariffs": "./tariffs.ts", + "./localTime": "./localTime.ts", + "./oneOffCharge": "./oneOffCharge.ts", + "./chargeCostEstimate": "./chargeCostEstimate.ts", "./solarAttribution": "./solarAttribution.ts", "./geocode": "./geocode.ts", "./notifications": "./notificationProviders.ts" diff --git a/packages/shared/engine/Schedules.oneOff.test.ts b/packages/shared/engine/Schedules.oneOff.test.ts new file mode 100644 index 00000000..b09db63a --- /dev/null +++ b/packages/shared/engine/Schedules.oneOff.test.ts @@ -0,0 +1,190 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import { isOneOffExpired, isScheduleActiveNow } from "./Schedules.ts"; +import type { EngineSchedule } from "./types.ts"; + +describe("one-off charge windows", () => { + const SYDNEY = "Australia/Sydney"; + + /** A one-off charge on Tuesday 2026-08-11, 23:30 for 3h (ends 02:30 Wed). */ + const makeOneOff = ( + overrides: Partial = {}, + ): EngineSchedule => ({ + id: "one-off-1", + vehicleId: "v1", + scheduleType: "charge", + startTime: "23:30", + endTime: "02:30", + // Only the start date's weekday — deliberately wrong for the wrap day, + // which is why one-offs must not consult days at all. + days: ["tue"], + chargeAmps: 16, + chargeLimitPct: 80, + oneOffDate: "2026-08-11", + enabled: true, + ...overrides, + }); + + /** An instant from a Sydney wall-clock reading (AEST, UTC+10 in August). */ + const sydney = (date: string, time: string) => + new Date(`${date}T${time}:00+10:00`); + + describe("isScheduleActiveNow", () => { + describe("window that wraps past midnight", () => { + it("is inactive before the start time on its date", () => { + expect( + isScheduleActiveNow( + makeOneOff(), + sydney("2026-08-11", "23:29"), + SYDNEY, + ), + ).toBe(false); + }); + + it("is active from the start time on its date", () => { + const s = makeOneOff(); + expect(isScheduleActiveNow(s, sydney("2026-08-11", "23:30"), SYDNEY)) + .toBe(true); + expect(isScheduleActiveNow(s, sydney("2026-08-11", "23:59"), SYDNEY)) + .toBe(true); + }); + + it("stays active after midnight, when the weekday no longer matches", () => { + // Wednesday 00:00 and 01:00 — days is ["tue"], so a day-of-week check + // would wrongly report inactive here + const s = makeOneOff(); + expect(isScheduleActiveNow(s, sydney("2026-08-12", "00:00"), SYDNEY)) + .toBe(true); + expect(isScheduleActiveNow(s, sydney("2026-08-12", "01:00"), SYDNEY)) + .toBe(true); + }); + + it("is inactive from the end time on the following date", () => { + const s = makeOneOff(); + expect(isScheduleActiveNow(s, sydney("2026-08-12", "02:30"), SYDNEY)) + .toBe(false); + expect(isScheduleActiveNow(s, sydney("2026-08-12", "03:00"), SYDNEY)) + .toBe(false); + }); + + it("does not recur the following week", () => { + expect( + isScheduleActiveNow( + makeOneOff(), + sydney("2026-08-18", "23:45"), + SYDNEY, + ), + ).toBe(false); + }); + + it("is inactive on the day before its date", () => { + expect( + isScheduleActiveNow( + makeOneOff(), + sydney("2026-08-10", "23:45"), + SYDNEY, + ), + ).toBe(false); + }); + + it("is inactive two days later in the same clock window", () => { + expect( + isScheduleActiveNow( + makeOneOff(), + sydney("2026-08-13", "01:00"), + SYDNEY, + ), + ).toBe(false); + }); + }); + + describe("window inside a single day", () => { + const sameDay = () => + makeOneOff({ startTime: "13:00", endTime: "16:00", days: ["tue"] }); + + it("is active inside the window", () => { + expect( + isScheduleActiveNow(sameDay(), sydney("2026-08-11", "14:00"), SYDNEY), + ).toBe(true); + }); + + it("is inactive before the start and at the end", () => { + expect( + isScheduleActiveNow(sameDay(), sydney("2026-08-11", "12:59"), SYDNEY), + ).toBe(false); + expect( + isScheduleActiveNow(sameDay(), sydney("2026-08-11", "16:00"), SYDNEY), + ).toBe(false); + }); + + it("is inactive on the next date at the same clock time", () => { + expect( + isScheduleActiveNow(sameDay(), sydney("2026-08-12", "14:00"), SYDNEY), + ).toBe(false); + }); + }); + + it("resolves the date in the configured timezone", () => { + // 2026-08-11T14:00Z is 2026-08-12 00:00 in Sydney — inside the window + // there, but a UTC reading would call it the 11th at 14:00 and say no. + expect( + isScheduleActiveNow( + makeOneOff(), + new Date("2026-08-11T14:00:00Z"), + SYDNEY, + ), + ).toBe(true); + }); + + it("leaves recurring schedules on the day-of-week path", () => { + const recurring = makeOneOff({ oneOffDate: null, days: ["tue"] }); + // Tuesday 23:45 matches; the following Tuesday matches too + expect( + isScheduleActiveNow(recurring, sydney("2026-08-11", "23:45"), SYDNEY), + ).toBe(true); + expect( + isScheduleActiveNow(recurring, sydney("2026-08-18", "23:45"), SYDNEY), + ).toBe(true); + }); + }); + + describe("isOneOffExpired", () => { + it("is false before the window opens", () => { + expect( + isOneOffExpired(makeOneOff(), sydney("2026-08-11", "20:00"), SYDNEY), + ).toBe(false); + }); + + it("is false while the window is running", () => { + expect( + isOneOffExpired(makeOneOff(), sydney("2026-08-12", "01:00"), SYDNEY), + ).toBe(false); + }); + + it("is true from the end time onward", () => { + expect( + isOneOffExpired(makeOneOff(), sydney("2026-08-12", "02:30"), SYDNEY), + ).toBe(true); + }); + + it("is true on later dates", () => { + expect( + isOneOffExpired(makeOneOff(), sydney("2026-08-20", "09:00"), SYDNEY), + ).toBe(true); + }); + + it("handles a same-day window", () => { + const s = makeOneOff({ startTime: "13:00", endTime: "16:00" }); + expect(isOneOffExpired(s, sydney("2026-08-11", "15:59"), SYDNEY)) + .toBe(false); + expect(isOneOffExpired(s, sydney("2026-08-11", "16:00"), SYDNEY)) + .toBe(true); + }); + + it("never expires a recurring schedule", () => { + const recurring = makeOneOff({ oneOffDate: null }); + expect(isOneOffExpired(recurring, sydney("2030-01-01", "12:00"), SYDNEY)) + .toBe(false); + }); + }); +}); diff --git a/packages/shared/engine/Schedules.ts b/packages/shared/engine/Schedules.ts index ffad98be..5918420c 100644 --- a/packages/shared/engine/Schedules.ts +++ b/packages/shared/engine/Schedules.ts @@ -1,4 +1,9 @@ import type { DayOfWeek } from "../types.ts"; +import { + addDaysToDate, + getLocalDateTime, + timeToMinutes, +} from "../localTime.ts"; import type { EngineSchedule } from "./types.ts"; const DAY_MAP: Record = { @@ -11,37 +16,6 @@ const DAY_MAP: Record = { "6": "sat", }; -const WEEKDAY_TO_DAY: Record = { - Sun: 0, - Mon: 1, - Tue: 2, - Wed: 3, - Thu: 4, - Fri: 5, - Sat: 6, -}; - -function parseTimezone( - now: Date, - timezone: string, -): { day: number; hours: number; minutes: number } { - const fmt = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - hour: "numeric", - minute: "numeric", - weekday: "short", - hour12: false, - }); - const parts = fmt.formatToParts(now); - return { - day: WEEKDAY_TO_DAY[ - parts.find((p) => p.type === "weekday")?.value ?? "" - ] ?? now.getDay(), - hours: Number(parts.find((p) => p.type === "hour")?.value ?? 0), - minutes: Number(parts.find((p) => p.type === "minute")?.value ?? 0), - }; -} - /** Check whether a schedule is active at the given time. */ export function isScheduleActiveNow( schedule: EngineSchedule, @@ -50,21 +24,30 @@ export function isScheduleActiveNow( ): boolean { // Get the current time in the configured timezone (schedules are defined // in the user's timezone, not the server's local time) - const { day, hours, minutes } = timezone - ? parseTimezone(now, timezone) - : { day: now.getDay(), hours: now.getHours(), minutes: now.getMinutes() }; + const local = getLocalDateTime(now, timezone); + const currentMinutes = local.minutesSinceMidnight; + const startMinutes = timeToMinutes(schedule.startTime); + const endMinutes = timeToMinutes(schedule.endTime); + + // One-off charges are anchored to a calendar date, so they match on date + // rather than day-of-week: a window that wraps past midnight runs into the + // next date, where the day-of-week no longer matches `days`. + if (schedule.oneOffDate) { + if (startMinutes <= endMinutes) { + return local.date === schedule.oneOffDate && + currentMinutes >= startMinutes && currentMinutes < endMinutes; + } + if (local.date === schedule.oneOffDate) { + return currentMinutes >= startMinutes; + } + return local.date === addDaysToDate(schedule.oneOffDate, 1) && + currentMinutes < endMinutes; + } // Check day of week - const dayKey = DAY_MAP[String(day)]; + const dayKey = DAY_MAP[String(local.day)]; if (!schedule.days.includes(dayKey)) return false; - // Parse time strings - const currentMinutes = hours * 60 + minutes; - const [startH, startM] = schedule.startTime.split(":").map(Number); - const [endH, endM] = schedule.endTime.split(":").map(Number); - const startMinutes = startH * 60 + startM; - const endMinutes = endH * 60 + endM; - if (startMinutes <= endMinutes) { // Normal range (e.g. 09:00 - 17:00) return currentMinutes >= startMinutes && currentMinutes < endMinutes; @@ -73,3 +56,24 @@ export function isScheduleActiveNow( return currentMinutes >= startMinutes || currentMinutes < endMinutes; } } + +/** True once a one-off charge's window has fully elapsed. Recurring schedules + * never expire, so they always return false. */ +export function isOneOffExpired( + schedule: EngineSchedule, + now: Date, + timezone: string, +): boolean { + if (!schedule.oneOffDate) return false; + + const local = getLocalDateTime(now, timezone); + const startMinutes = timeToMinutes(schedule.startTime); + const endMinutes = timeToMinutes(schedule.endTime); + const endDate = startMinutes > endMinutes + ? addDaysToDate(schedule.oneOffDate, 1) + : schedule.oneOffDate; + + if (local.date > endDate) return true; + return local.date === endDate && + local.minutesSinceMidnight >= endMinutes; +} diff --git a/packages/shared/engine/SolarAllocator.ts b/packages/shared/engine/SolarAllocator.ts index 3070a680..54b47d19 100644 --- a/packages/shared/engine/SolarAllocator.ts +++ b/packages/shared/engine/SolarAllocator.ts @@ -1,4 +1,8 @@ import type { EnergyData, VehicleChargeState } from "../types.ts"; +import { + resolveChargePhases, + resolveChargeVoltage, +} from "../chargeCostEstimate.ts"; import type { ControllerConfig, EngineVehicleInput } from "./types.ts"; /** Eligible vehicle enriched with resolved electrical parameters. */ @@ -26,8 +30,11 @@ export class SolarAllocator { energy: EnergyData | null, config: ControllerConfig, ): number { - if (state.chargerVoltage >= 100) return state.chargerVoltage; - return energy?.gridVoltageV ?? config.gridVoltage; + return resolveChargeVoltage( + state, + config.gridVoltage, + energy?.gridVoltageV, + ); } /** Resolve charger phases: a live single-phase reading while charging @@ -38,8 +45,7 @@ export class SolarAllocator { state: VehicleChargeState, config: ControllerConfig, ): number { - if (state.isCharging && state.chargerPhases === 1) return 1; - return config.threePhaseCharger ? 3 : state.chargerPhases; + return resolveChargePhases(state, config.threePhaseCharger); } /** Surplus solar in watts, before the safety margin. @@ -47,7 +53,7 @@ export class SolarAllocator { * Starts from grid export, then: * - Subtracts home battery discharge. Power leaving the battery is not * solar. Without this, a battery operating in self-consumption won't be - * drawing from the grid, and would makes the EV's own draw reappear as + * drawing from the grid, and would makes the EV's own draw reappear as * "available solar" through the add-back below, and the car would charge * off the home battery. * - Adds back the EV's charge power when the meter includes EV load in diff --git a/packages/shared/engine/mod.ts b/packages/shared/engine/mod.ts index 65a68f4c..6affba39 100644 --- a/packages/shared/engine/mod.ts +++ b/packages/shared/engine/mod.ts @@ -2,7 +2,7 @@ export { SolarAllocator } from "./SolarAllocator.ts"; export { DecisionChecks } from "./DecisionChecks.ts"; export type { CheckName, DecisionCheck } from "./DecisionChecks.ts"; export { ControllerEngine } from "./ControllerEngine.ts"; -export { isScheduleActiveNow } from "./Schedules.ts"; +export { isOneOffExpired, isScheduleActiveNow } from "./Schedules.ts"; export { createControlState } from "./types.ts"; export type { ControllerConfig, diff --git a/packages/shared/engine/types.ts b/packages/shared/engine/types.ts index 52654186..f4801b20 100644 --- a/packages/shared/engine/types.ts +++ b/packages/shared/engine/types.ts @@ -59,6 +59,9 @@ export interface EngineSchedule { chargeAmps: number | null; chargeLimitPct: number | null; enabled: boolean; + /** Calendar date ("YYYY-MM-DD", user's timezone) for a one-off charge. + * Null/absent for recurring schedules. */ + oneOffDate?: string | null; } /** Everything the engine needs to make decisions for one loop iteration. */ diff --git a/packages/shared/localTime.ts b/packages/shared/localTime.ts new file mode 100644 index 00000000..df38ac38 --- /dev/null +++ b/packages/shared/localTime.ts @@ -0,0 +1,108 @@ +import type { DayOfWeek } from "./types.ts"; + +/** Schedules and tariffs are wall-clock times in the user's configured + * timezone, not UTC. These helpers convert an instant into that wall clock + * and do calendar-date arithmetic on the "YYYY-MM-DD" strings we store. */ + +const DAY_ABBRS: DayOfWeek[] = [ + "sun", + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", +]; + +const WEEKDAY_TO_DAY: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, +}; + +/** Wall-clock date and time in a specific timezone. */ +export interface LocalDateTime { + /** 0 = Sunday. */ + day: number; + hours: number; + minutes: number; + /** Local calendar date as "YYYY-MM-DD". */ + date: string; + /** Minutes since local midnight. */ + minutesSinceMidnight: number; +} + +const pad = (n: number): string => String(n).padStart(2, "0"); + +function parseInTimezone(now: Date, timezone: string): LocalDateTime { + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "numeric", + minute: "numeric", + weekday: "short", + hour12: false, + }); + const parts = fmt.formatToParts(now); + const part = (type: string) => + parts.find((p) => p.type === type)?.value ?? ""; + // hour12: false yields "24" for midnight in some ICU versions + const hours = Number(part("hour")) % 24; + const minutes = Number(part("minute") || 0); + return { + day: WEEKDAY_TO_DAY[part("weekday")] ?? now.getDay(), + hours, + minutes, + date: `${part("year")}-${part("month")}-${part("day")}`, + minutesSinceMidnight: hours * 60 + minutes, + }; +} + +/** Resolve the wall-clock date/time in the given timezone. Falls back to the + * host's local time when no timezone is configured. */ +export function getLocalDateTime(now: Date, timezone: string): LocalDateTime { + if (timezone) return parseInTimezone(now, timezone); + return { + day: now.getDay(), + hours: now.getHours(), + minutes: now.getMinutes(), + date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${ + pad(now.getDate()) + }`, + minutesSinceMidnight: now.getHours() * 60 + now.getMinutes(), + }; +} + +/** Shift a "YYYY-MM-DD" date string by whole days. */ +export function addDaysToDate(date: string, days: number): string { + const [y, m, d] = date.split("-").map(Number); + // UTC arithmetic — these are calendar dates, so no DST shift applies + const shifted = new Date(Date.UTC(y, m - 1, d + days)); + return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${ + pad(shifted.getUTCDate()) + }`; +} + +/** Day-of-week abbreviation for a "YYYY-MM-DD" date string. */ +export function dayOfWeekForDate(date: string): DayOfWeek { + const [y, m, d] = date.split("-").map(Number); + return DAY_ABBRS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()]; +} + +/** Parse "HH:MM" into minutes since midnight. */ +export function timeToMinutes(time: string): number { + const [h, m] = time.split(":").map(Number); + return h * 60 + m; +} + +/** Format minutes since midnight as "HH:MM", wrapping past 24h. */ +export function minutesToTime(minutes: number): string { + const wrapped = ((minutes % 1440) + 1440) % 1440; + return `${pad(Math.floor(wrapped / 60))}:${pad(wrapped % 60)}`; +} diff --git a/packages/shared/oneOffCharge.test.ts b/packages/shared/oneOffCharge.test.ts new file mode 100644 index 00000000..577887bf --- /dev/null +++ b/packages/shared/oneOffCharge.test.ts @@ -0,0 +1,140 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import { + formatDurationMinutes, + ONE_OFF_DURATION_OPTIONS, + oneOffDurationMinutes, + resolveOneOffWindow, +} from "./oneOffCharge.ts"; + +describe("oneOffCharge", () => { + const SYDNEY = "Australia/Sydney"; + // 2026-08-11 is a Tuesday. 04:00Z = 14:00 Sydney (AEST, UTC+10). + const tuesdayAfternoon = () => new Date("2026-08-11T04:00:00Z"); + + describe("ONE_OFF_DURATION_OPTIONS", () => { + it("runs 30m to 8h in 30-minute steps", () => { + expect(ONE_OFF_DURATION_OPTIONS[0]).toBe(30); + expect(ONE_OFF_DURATION_OPTIONS.at(-1)).toBe(480); + expect(ONE_OFF_DURATION_OPTIONS.length).toBe(16); + expect(ONE_OFF_DURATION_OPTIONS.every((m) => m % 30 === 0)).toBe(true); + }); + }); + + describe("resolveOneOffWindow", () => { + it("resolves to today when the start time is still ahead", () => { + const w = resolveOneOffWindow("23:30", 180, tuesdayAfternoon(), SYDNEY); + expect(w.oneOffDate).toBe("2026-08-11"); + expect(w.isTomorrow).toBe(false); + expect(w.endTime).toBe("02:30"); + }); + + it("resolves to tomorrow when the start time has already passed", () => { + // 13:00Z = 23:00 Sydney, so 22:00 is behind us + const lateEvening = new Date("2026-08-11T13:00:00Z"); + const w = resolveOneOffWindow("22:00", 120, lateEvening, SYDNEY); + expect(w.oneOffDate).toBe("2026-08-12"); + expect(w.isTomorrow).toBe(true); + }); + + it("treats a start time equal to the current minute as tomorrow", () => { + // Exactly 14:00 Sydney — "now" has passed, so the next 14:00 is tomorrow + const w = resolveOneOffWindow("14:00", 60, tuesdayAfternoon(), SYDNEY); + expect(w.oneOffDate).toBe("2026-08-12"); + expect(w.isTomorrow).toBe(true); + }); + + it("flags a window that runs past midnight and dates its end", () => { + const w = resolveOneOffWindow("23:30", 180, tuesdayAfternoon(), SYDNEY); + expect(w.wrapsMidnight).toBe(true); + expect(w.endDate).toBe("2026-08-12"); + }); + + it("does not flag a window that ends the same day", () => { + const w = resolveOneOffWindow("18:00", 120, tuesdayAfternoon(), SYDNEY); + expect(w.wrapsMidnight).toBe(false); + expect(w.endDate).toBe("2026-08-11"); + expect(w.endTime).toBe("20:00"); + }); + + it("treats a window ending exactly at midnight as wrapping", () => { + const w = resolveOneOffWindow("23:30", 30, tuesdayAfternoon(), SYDNEY); + expect(w.endTime).toBe("00:00"); + expect(w.wrapsMidnight).toBe(true); + expect(w.endDate).toBe("2026-08-12"); + }); + + it("rolls the date across a month boundary", () => { + // 2026-08-31 23:00Z = 2026-09-01 09:00 Sydney, so the next 08:00 there + // is on 2026-09-02 + const w = resolveOneOffWindow( + "08:00", + 60, + new Date("2026-08-31T23:00:00Z"), + SYDNEY, + ); + expect(w.oneOffDate).toBe("2026-09-02"); + }); + + it("resolves against the configured timezone, not the host's", () => { + // 20:00Z on the 11th is already 06:00 on the 12th in Sydney, so a 23:30 + // start belongs to the 12th there — a UTC reading would say the 11th. + const w = resolveOneOffWindow( + "23:30", + 180, + new Date("2026-08-11T20:00:00Z"), + SYDNEY, + ); + expect(w.oneOffDate).toBe("2026-08-12"); + }); + + it("falls back to host local time when no timezone is configured", () => { + const now = new Date(); + const w = resolveOneOffWindow("23:30", 60, now, ""); + const pad = (n: number) => String(n).padStart(2, "0"); + const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${ + pad(now.getDate()) + }`; + // Either tonight's 23:30 or tomorrow's, depending on the host clock + expect(w.isTomorrow ? "next-day" : w.oneOffDate).toBe( + w.isTomorrow ? "next-day" : today, + ); + }); + }); + + describe("oneOffDurationMinutes", () => { + it("measures a same-day window", () => { + expect(oneOffDurationMinutes("18:00", "20:30")).toBe(150); + }); + + it("measures a window that wraps past midnight", () => { + expect(oneOffDurationMinutes("23:30", "02:30")).toBe(180); + }); + + it("measures a window ending exactly at midnight", () => { + expect(oneOffDurationMinutes("23:30", "00:00")).toBe(30); + }); + + it("round-trips every selectable duration", () => { + const roundTrips = ONE_OFF_DURATION_OPTIONS.map((minutes) => { + const w = resolveOneOffWindow( + "23:30", + minutes, + tuesdayAfternoon(), + SYDNEY, + ); + return oneOffDurationMinutes("23:30", w.endTime); + }); + expect(roundTrips).toEqual(ONE_OFF_DURATION_OPTIONS); + }); + }); + + describe("formatDurationMinutes", () => { + it("formats hours, minutes, and both", () => { + expect(formatDurationMinutes(30)).toBe("30m"); + expect(formatDurationMinutes(180)).toBe("3h"); + expect(formatDurationMinutes(210)).toBe("3h 30m"); + expect(formatDurationMinutes(480)).toBe("8h"); + }); + }); +}); diff --git a/packages/shared/oneOffCharge.ts b/packages/shared/oneOffCharge.ts new file mode 100644 index 00000000..ca99d1f8 --- /dev/null +++ b/packages/shared/oneOffCharge.ts @@ -0,0 +1,90 @@ +import { + addDaysToDate, + getLocalDateTime, + minutesToTime, + timeToMinutes, +} from "./localTime.ts"; + +/** Duration bounds for a one-off charge, in minutes. */ +export const ONE_OFF_MIN_MINUTES = 30; +export const ONE_OFF_MAX_MINUTES = 8 * 60; +export const ONE_OFF_STEP_MINUTES = 30; + +/** Default one-off start time (wall clock, user's timezone). */ +export const ONE_OFF_DEFAULT_START = "23:30"; +/** Default one-off duration in minutes. */ +export const ONE_OFF_DEFAULT_MINUTES = 3 * 60; + +/** Selectable durations: 30m to 8h in 30-minute steps. */ +export const ONE_OFF_DURATION_OPTIONS: number[] = Array.from( + { + length: (ONE_OFF_MAX_MINUTES - ONE_OFF_MIN_MINUTES) / + ONE_OFF_STEP_MINUTES + 1, + }, + (_, i) => ONE_OFF_MIN_MINUTES + i * ONE_OFF_STEP_MINUTES, +); + +/** The resolved calendar window a one-off charge will run in. */ +export interface OneOffWindow { + /** Calendar date the window starts on ("YYYY-MM-DD", user's timezone). */ + oneOffDate: string; + /** Wall-clock end time ("HH:MM"). */ + endTime: string; + /** Calendar date the window ends on. */ + endDate: string; + /** True when the window runs past midnight into the next date. */ + wrapsMidnight: boolean; + /** True when the start resolves to tomorrow because it has already passed. */ + isTomorrow: boolean; +} + +/** + * Resolve the next occurrence of a wall-clock start time. + * + * A one-off charge is always the *next* time that clock reading comes around: + * still ahead today means tonight, already passed means tomorrow. Times are + * resolved against the user's configured timezone, not the server's. + */ +export function resolveOneOffWindow( + startTime: string, + durationMinutes: number, + now: Date, + timezone: string, +): OneOffWindow { + const local = getLocalDateTime(now, timezone); + const startMinutes = timeToMinutes(startTime); + const isTomorrow = startMinutes <= local.minutesSinceMidnight; + const oneOffDate = isTomorrow ? addDaysToDate(local.date, 1) : local.date; + + const endAbsolute = startMinutes + durationMinutes; + const wrapsMidnight = endAbsolute >= 1440; + + return { + oneOffDate, + endTime: minutesToTime(endAbsolute), + endDate: wrapsMidnight + ? addDaysToDate(oneOffDate, Math.floor(endAbsolute / 1440)) + : oneOffDate, + wrapsMidnight, + isTomorrow, + }; +} + +/** Round-trip a stored window back to a duration in minutes. */ +export function oneOffDurationMinutes( + startTime: string, + endTime: string, +): number { + const start = timeToMinutes(startTime); + const end = timeToMinutes(endTime); + return end > start ? end - start : 1440 - start + end; +} + +/** Format a duration in minutes as "3h", "30m", or "1h 30m". */ +export function formatDurationMinutes(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h > 0 && m > 0) return `${h}h ${m}m`; + if (h > 0) return `${h}h`; + return `${m}m`; +} diff --git a/packages/shared/schemas.ts b/packages/shared/schemas.ts index 87bb1e0b..d7b08d36 100644 --- a/packages/shared/schemas.ts +++ b/packages/shared/schemas.ts @@ -4,6 +4,11 @@ import { CORE_CONFIG_KEYS, type CoreConfigKey, } from "./configSections.ts"; +import { + ONE_OFF_MAX_MINUTES, + ONE_OFF_MIN_MINUTES, + ONE_OFF_STEP_MINUTES, +} from "./oneOffCharge.ts"; // Re-export config types from configSections (single source of truth) export { type ConfigKey, type CoreConfigKey }; @@ -334,6 +339,29 @@ export const scheduleDeleteInput: z.ZodType<{ }); export type ScheduleDeleteInput = z.infer; +/** Create a one-off charge. The server resolves the start time's next + * occurrence and derives the end time, so callers send a duration. */ +export const oneOffChargeCreateInput: z.ZodType<{ + vehicleId: string; + startTime: string; + durationMinutes: number; + chargeAmps: number; + chargeLimitPct: number; +}> = z.object({ + vehicleId: z.string().min(1), + startTime: timeStringSchema, + durationMinutes: z.number().int() + .min(ONE_OFF_MIN_MINUTES) + .max(ONE_OFF_MAX_MINUTES) + .refine( + (m) => m % ONE_OFF_STEP_MINUTES === 0, + `Must be a multiple of ${ONE_OFF_STEP_MINUTES} minutes`, + ), + chargeAmps: z.number().int().min(1), + chargeLimitPct: z.number().int().min(1).max(100), +}); +export type OneOffChargeCreateInput = z.infer; + // ---- Wizard inputs ---- export const wizardDemoSetupInput: z.ZodType<{ diff --git a/packages/server/src/lib/Tariffs.test.ts b/packages/shared/tariffs.test.ts similarity index 97% rename from packages/server/src/lib/Tariffs.test.ts rename to packages/shared/tariffs.test.ts index d20b326f..e48b251f 100644 --- a/packages/server/src/lib/Tariffs.test.ts +++ b/packages/shared/tariffs.test.ts @@ -1,24 +1,24 @@ import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { assertExists } from "@std/assert"; -import { getApplicablePeriodForTime } from "./Tariffs.ts"; -import type { TariffPeriodRow } from "../db/types.ts"; +import { getApplicablePeriodForTime } from "./tariffs.ts"; +import type { TariffPeriodLike } from "./tariffs.ts"; + +type TestPeriod = TariffPeriodLike & { id: number }; describe("getApplicablePeriodForTime", () => { - /** Helper to create a minimal TariffPeriodRow for testing. */ + /** Helper to create a minimal tariff period for testing. */ const makePeriod = ( - overrides: Partial & { + overrides: Partial & { startTime: string; endTime: string; ratePerKwh: number; days: string[]; }, - ): TariffPeriodRow => ({ + ): TestPeriod => ({ id: 1, label: "Test", enabled: true, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", ...overrides, }); diff --git a/packages/server/src/lib/Tariffs.ts b/packages/shared/tariffs.ts similarity index 82% rename from packages/server/src/lib/Tariffs.ts rename to packages/shared/tariffs.ts index 39c3ff9a..707fee33 100644 --- a/packages/server/src/lib/Tariffs.ts +++ b/packages/shared/tariffs.ts @@ -1,5 +1,15 @@ -import type { DayOfWeek } from "@chargeha/shared"; -import type { TariffPeriodRow } from "../db/types.ts"; +import type { DayOfWeek } from "./types.ts"; + +/** The tariff period fields needed to resolve a rate. Structurally satisfied + * by the server's TariffPeriodRow and the client's tariff.list response. */ +export interface TariffPeriodLike { + label: string; + startTime: string; + endTime: string; + days: string[]; + ratePerKwh: number; + enabled: boolean; +} const ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; const WEEKDAYS = ["mon", "tue", "wed", "thu", "fri"]; @@ -57,11 +67,11 @@ export function parseTimeToMinutes(time: string): number { * conversion before calling. Returns the matching period, or null if no * period matches (meaning the default rate applies). */ -export function getApplicablePeriodForTime( +export function getApplicablePeriodForTime( minutesSinceMidnight: number, dayAbbr: DayOfWeek, - tariffPeriods: TariffPeriodRow[], -): TariffPeriodRow | null { + tariffPeriods: T[], +): T | null { const matches = tariffPeriods .filter((p) => p.enabled) .filter((p) => p.days.includes(dayAbbr)) diff --git a/packages/shared/test-factories.ts b/packages/shared/test-factories.ts index 1f4cb62d..9043c9e0 100644 --- a/packages/shared/test-factories.ts +++ b/packages/shared/test-factories.ts @@ -64,6 +64,7 @@ export function buildChargeSchedule( days: ["mon", "tue", "wed", "thu", "fri"], chargeAmps: 16, chargeLimitPct: 80, + oneOffDate: null, enabled: true, ...overrides, }; diff --git a/packages/shared/types.ts b/packages/shared/types.ts index 854812a7..a26450e4 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -178,6 +178,9 @@ export interface ChargeSchedule { chargeAmps: number; chargeLimitPct: number; enabled: boolean; + /** Set for a one-off charge: the calendar date ("YYYY-MM-DD", user's + * timezone) the window starts on. Null for recurring schedules. */ + oneOffDate: string | null; } export interface BlockoutSchedule { @@ -202,6 +205,17 @@ export interface ScheduleFormData { chargeLimitPct: number; } +/** Modal form state for scheduling a one-off charge. */ +export interface OneOffChargeFormData { + startTime: string; + durationMinutes: number; + chargeAmps: number; + chargeLimitPct: number; + /** Flip the vehicle to auto mode on save (one-off charges, like all charge + * schedules, only run in auto mode). */ + switchToAuto: boolean; +} + // ---- Notification Types ---- export type NotificationEventType = From 09ca87ae8bf0258d8e9fea82e6662edd71c59a47 Mon Sep 17 00:00:00 2001 From: Timothy Mukaibo Date: Sat, 15 Aug 2026 12:45:03 +1000 Subject: [PATCH 2/2] feat: implement "free grid charging" Will automatically charge the car when grid tariff is free. Home battery priority is still respected. --- README.md | 3 + docs/charge-controller.md | 62 ++++- .../VehicleCard/VehicleCardDetails.tsx | 10 +- .../Settings/FreeTariffSettings.test.tsx | 140 +++++++++++ .../pages/Settings/FreeTariffSettings.tsx | 101 ++++++++ .../pages/Settings/Settings.test.tsx | 4 + .../components/pages/Settings/Settings.tsx | 4 + packages/plugins/types.ts | 3 + .../vehicles/simulated/server/router.ts | 1 + .../tesla/server/TeslaApiStrategy.test.ts | 15 +- .../vehicles/tesla/server/TeslaApiStrategy.ts | 19 +- .../server/TeslaVehicleMiddleware.test.ts | 1 + packages/server/src/bootstrap/bootstrap.ts | 1 + .../ChargeController.test/free-tariff.test.ts | 139 ++++++++++ .../server/src/services/ChargeController.ts | 107 ++++++-- .../src/services/NotificationListener.ts | 1 + packages/server/src/services/TariffService.ts | 5 +- .../src/services/VehicleManager.test.ts | 1 + .../server/src/services/VehicleService.ts | 2 + .../test-helpers/ChargeControllerHarness.ts | 4 + .../server/src/trpc/routers/vehicles.test.ts | 1 + packages/shared/configSections.ts | 10 + .../shared/engine/ControllerEngine.test.ts | 3 + .../ControllerEngine.test/free-tariff.test.ts | 238 ++++++++++++++++++ packages/shared/engine/ControllerEngine.ts | 125 ++++++++- packages/shared/engine/DecisionChecks.ts | 30 +++ packages/shared/engine/SolarAllocator.test.ts | 2 + .../engine/test-helpers/controller-engine.ts | 3 + packages/shared/engine/types.ts | 10 + packages/shared/simulation/run.ts | 5 + 30 files changed, 1013 insertions(+), 37 deletions(-) create mode 100644 packages/client/src/components/pages/Settings/FreeTariffSettings.test.tsx create mode 100644 packages/client/src/components/pages/Settings/FreeTariffSettings.tsx create mode 100644 packages/server/src/services/ChargeController.test/free-tariff.test.ts create mode 100644 packages/shared/engine/ControllerEngine.test/free-tariff.test.ts diff --git a/README.md b/README.md index 0a82dc0c..45aa9a06 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,9 @@ ChargeHA is not affiliated with, endorsed by, or associated with ChargeHQ. solar is insufficient, instead of stopping entirely - **Home battery priority** — hold EV charging until your home battery reaches a configured state-of-charge threshold +- **Free grid charging** — charge from the grid whenever your tariff is free (or + under a cheap-rate threshold you set), independent of solar, while still + respecting home battery priority - **Charge scheduling** — time-based schedules with day-of-week selection, per-vehicle amperage, and target charge limits - **Blockout schedules** — prevent charging during peak tariff windows diff --git a/docs/charge-controller.md b/docs/charge-controller.md index 9b025f08..cf3e32d7 100644 --- a/docs/charge-controller.md +++ b/docs/charge-controller.md @@ -91,11 +91,65 @@ This is where the main logic lives. The checks run in priority order: 3. **Battery priority** — If enabled and the home battery SoC is below the configured threshold, stop charging to let the home battery charge first. -4. **Solar tracking** — The main solar-following logic (see below). +4. **Free tariff** — If enabled and the active tariff rate is at or below + `free_tariff_max_rate_per_kwh`, charge from the grid at maximum amps + regardless of solar (see below). -5. **Fallback** — If nothing above applied, stop charging (or do nothing if +5. **Solar tracking** — The main solar-following logic (see below). + +6. **Fallback** — If nothing above applied, stop charging (or do nothing if already stopped). Marks the vehicle as suspendable. +## Free tariff charging + +When `free_tariff_charging_enabled` is set, the controller charges from the grid +during any period where electricity is free — typically an overnight or +utility-sponsored free window. The target SoC is the vehicle's own charge limit, +which the pre-checks already enforce, so no separate target is configured. + +The rate is resolved each loop by `TariffService.resolveCurrentRate()` and +passed into the engine as `currentRatePerKwh`. The engine itself stays pure — it +never reads the tariff tables. + +### Placement in the pipeline + +The step sits deliberately **after battery priority and before solar tracking**: + +- **After battery priority** so the home battery still wins. Battery priority + short-circuits with its own decision when the home battery is below its limit, + so the free-tariff step is never reached in that case. +- **Before solar tracking**, because solar tracking terminates with a decision + once production hits zero (see _Min solar generation_ below). Anything placed + after it would be unreachable at night — exactly when free windows fall. + +### Safety rules + +- **A null rate is not free.** `resolveCurrentRate()` returns `null` when no + tariff periods are configured, which means _unknown_, never zero. The engine + never starts a grid charge on an unresolved rate. Note this also means a free + window cannot be expressed through the default rate alone — configure a tariff + period with a rate of `0`. +- **Unverifiable home battery means hold.** If battery priority is enabled but + there is no energy snapshot, or the inverter reports no SoC, the battery + priority check never actually ran. Rather than charge against an unknown + battery state, the free-tariff step holds (and stops an in-progress charge). +- **Negative rates count as free**, since the default threshold is `0` and the + comparison is `rate <= threshold`. + +### Cheap-rate windows + +Raising `free_tariff_max_rate_per_kwh` above `0` turns the feature into "charge +whenever the grid is cheap" — useful for an off-peak EV tariff where charging is +worth doing even though it isn't literally free. + +### Waking the vehicle + +A free window at night has no solar and no schedule, which is precisely the +condition under which cost-aware middleware keeps a sleeping car asleep. The +controller therefore passes a `hasFreeTariff` flag in the middleware request +context, and the Tesla strategy treats it like a schedule for both wake +eligibility and cache staleness. + ## Solar tracking Solar tracking dynamically adjusts charging amps based on available solar power. @@ -237,7 +291,7 @@ for the notification system: | Event | Trigger | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `controller_charge_started` | Vehicle was not charging → now charging (by controller) | +| `controller_charge_started` | Vehicle was not charging → now charging (by controller). A `free_tariff` reason drives the "Free Grid Charging Started" notification | | `controller_charge_stopped` | Vehicle was charging → now stopped. Carries a `reason` field; `battery_at_limit` drives the "charge complete" notification, all other reasons drive "charging stopped" | | `controller_external_charge` | Vehicle started charging outside the controller | | `controller_low_solar` | Grace period started (solar dropped) | @@ -290,3 +344,5 @@ and debounce state will simply reset. | `battery_priority_enabled` | `false` | Whether to prioritize home battery charging | | `battery_priority_limit` | `80` | Home battery SoC threshold (%) | | `priority_charging_enabled` | `false` | Use waterfall allocation instead of equal split | +| `free_tariff_charging_enabled` | `false` | Charge from the grid while the tariff rate is free | +| `free_tariff_max_rate_per_kwh` | `0` | Rate at or below which the grid counts as "free" | diff --git a/packages/client/src/components/VehicleCard/VehicleCardDetails.tsx b/packages/client/src/components/VehicleCard/VehicleCardDetails.tsx index 73cd6886..4af0299a 100644 --- a/packages/client/src/components/VehicleCard/VehicleCardDetails.tsx +++ b/packages/client/src/components/VehicleCard/VehicleCardDetails.tsx @@ -3,6 +3,7 @@ import { BatteryCharging, Calendar, CloudSun, + Gift, Plug, ShieldBan, Sun, @@ -22,6 +23,7 @@ const VISIBLE_REASONS = new Set([ "grace_period", "cooldown", "battery_priority", + "free_tariff", ]); const REASON_ICONS: Record = { @@ -30,14 +32,16 @@ const REASON_ICONS: Record = { grace_period: CloudSun, cooldown: CloudSun, battery_priority: BatteryCharging, + free_tariff: Gift, }; -const REASON_COLORS: Record = { +const REASON_COLORS: Record = { schedule: "blue", blockout: "orange", grace_period: "orange", cooldown: "orange", battery_priority: "orange", + free_tariff: "green", }; /** User-friendly label formatters per reason. */ @@ -65,6 +69,10 @@ const REASON_LABELS: Record string> = { ? `Home battery priority (${match[1]}% < ${match[2]}%)` : "Waiting for home battery"; }, + free_tariff: (detail) => + detail.includes("home battery") + ? "Grid is free — waiting for home battery" + : "Charging while the grid is free", }; interface VehicleCardDetailsProps { diff --git a/packages/client/src/components/pages/Settings/FreeTariffSettings.test.tsx b/packages/client/src/components/pages/Settings/FreeTariffSettings.test.tsx new file mode 100644 index 00000000..edaaca8f --- /dev/null +++ b/packages/client/src/components/pages/Settings/FreeTariffSettings.test.tsx @@ -0,0 +1,140 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, screen } from "@testing-library/react"; +import { renderWithProviders } from "../../../test-utils.tsx"; +import { FreeTariffSettings } from "./FreeTariffSettings.tsx"; + +const { mockChargingMutate, state } = vi.hoisted(() => ({ + mockChargingMutate: vi.fn(), + state: { + chargingConfigData: null as Record | null, + currentRateData: null as Record | null, + }, +})); + +vi.mock("../../../hooks/useSectionConfig.ts", () => ({ + useChargingConfig: () => ({ data: state.chargingConfigData }), + useChargingConfigMutation: () => ({ + mutate: mockChargingMutate, + saveStatus: { state: "idle", tick: 0 }, + }), +})); + +vi.mock("../../../trpc.ts", () => ({ + trpc: { + tariff: { + currentRate: { useQuery: () => ({ data: state.currentRateData }) }, + }, + }, +})); + +vi.mock("./SettingsLayout.tsx", () => ({ + SettingsSection: ( + { children, title, action }: { + children: React.ReactNode; + title: string; + action?: React.ReactNode; + }, + ) => ( +
+

{title}

+ {action &&
{action}
} + {children} +
+ ), + SettingsRow: ( + { children, label }: { children: React.ReactNode; label: string }, + ) => ( +
+ + {children} +
+ ), + NumberInput: ( + { value, suffix }: { value: string; suffix: string }, + ) => {value}{suffix}, +})); + +describe("FreeTariffSettings", () => { + beforeEach(() => { + state.chargingConfigData = { + chargingEnabled: true, + priorityChargingEnabled: false, + freeTariffChargingEnabled: false, + freeTariffMaxRatePerKwh: 0, + }; + state.currentRateData = null; + mockChargingMutate.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it("returns null when config not loaded", () => { + state.chargingConfigData = null; + renderWithProviders(); + expect(screen.queryByText("Free Grid Charging")).not.toBeInTheDocument(); + }); + + it("renders the section title and both rows", () => { + renderWithProviders(); + expect(screen.getByText("Free Grid Charging")).toBeInTheDocument(); + expect(screen.getByText("Charge when grid is free")).toBeInTheDocument(); + expect(screen.getByText("Treat as free at or below")).toBeInTheDocument(); + }); + + it("shows the configured threshold", () => { + state.chargingConfigData = { + freeTariffChargingEnabled: true, + freeTariffMaxRatePerKwh: 0.12, + }; + renderWithProviders(); + expect(screen.getByTestId("number-input")).toHaveTextContent("0.12/kWh"); + }); + + it("says no tariff is configured when the rate can't be resolved", () => { + renderWithProviders(); + expect(screen.getByText("No tariff configured")).toBeInTheDocument(); + }); + + it("shows a free badge when the rate is at or below the threshold", () => { + state.chargingConfigData = { + freeTariffChargingEnabled: true, + freeTariffMaxRatePerKwh: 0, + }; + state.currentRateData = { + ratePerKwh: 0, + label: "Free", + currencySymbol: "$", + }; + renderWithProviders(); + expect(screen.getByText(/Free now/)).toBeInTheDocument(); + expect(screen.getByText(/\$0\/kWh/)).toBeInTheDocument(); + }); + + it("shows the plain rate when the tariff is not free", () => { + state.chargingConfigData = { + freeTariffChargingEnabled: true, + freeTariffMaxRatePerKwh: 0, + }; + state.currentRateData = { + ratePerKwh: 0.45, + label: "Peak", + currencySymbol: "$", + }; + renderWithProviders(); + expect(screen.queryByText(/Free now/)).not.toBeInTheDocument(); + expect(screen.getByText(/Peak/)).toBeInTheDocument(); + }); + + it("does not claim free while the feature is disabled", () => { + state.currentRateData = { + ratePerKwh: 0, + label: "Free", + currencySymbol: "$", + }; + renderWithProviders(); + expect(screen.queryByText(/Free now/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/client/src/components/pages/Settings/FreeTariffSettings.tsx b/packages/client/src/components/pages/Settings/FreeTariffSettings.tsx new file mode 100644 index 00000000..be2b6654 --- /dev/null +++ b/packages/client/src/components/pages/Settings/FreeTariffSettings.tsx @@ -0,0 +1,101 @@ +import { CheckCircle, Gift } from "lucide-react"; +import { Badge, Switch } from "@radix-ui/themes"; +import { trpc } from "../../../trpc.ts"; +import { + useChargingConfig, + useChargingConfigMutation, +} from "../../../hooks/useSectionConfig.ts"; +import { useDraftConfig } from "../../../hooks/useDraftConfig.ts"; +import { + NumberInput, + SettingsRow, + SettingsSection, +} from "./SettingsLayout.tsx"; + +/** Live badge showing whether the grid currently counts as free. */ +function CurrentRateBadge( + { enabled, maxRate }: { enabled: boolean; maxRate: number }, +) { + const { data: currentRate } = trpc.tariff.currentRate.useQuery(); + + if (!currentRate) { + return ( + No tariff configured + ); + } + + const symbol = currentRate.currencySymbol; + const rateLabel = `${symbol}${currentRate.ratePerKwh}/kWh`; + + if (enabled && currentRate.ratePerKwh <= maxRate) { + return ( + + Free now — {rateLabel} + + ); + } + return ( + + {currentRate.label} — {rateLabel} + + ); +} + +export function FreeTariffSettings() { + const { data: config } = useChargingConfig(); + const mutation = useChargingConfigMutation(); + const { fields, setField, isDirty, save, saveStatus } = useDraftConfig( + config, + mutation, + ); + + if (!fields) return null; + + return ( + } + title="Free Grid Charging" + description="Charge from the grid whenever your electricity is free, regardless of solar. Charging runs until the rate stops being free or the vehicle reaches its charge limit. Home battery priority still applies — if it's enabled and your home battery is below its limit (or its charge level can't be read), the vehicle waits. Requires tariff periods to be configured under Electricity Tariffs." + saveStatus={saveStatus} + isDirty={isDirty} + onSave={save} + action={ + + } + > + + setField("freeTariffChargingEnabled", v)} + /> + + + +
+ + setField("freeTariffMaxRatePerKwh", parseFloat(v) || 0)} + step={0.01} + suffix="/kWh" + /> +
+
+
+ ); +} diff --git a/packages/client/src/components/pages/Settings/Settings.test.tsx b/packages/client/src/components/pages/Settings/Settings.test.tsx index 6b4b969d..68dc97ce 100644 --- a/packages/client/src/components/pages/Settings/Settings.test.tsx +++ b/packages/client/src/components/pages/Settings/Settings.test.tsx @@ -50,6 +50,10 @@ vi.mock("./TariffSettings.tsx", () => ({ TariffSettings: () =>
, })); +vi.mock("./FreeTariffSettings.tsx", () => ({ + FreeTariffSettings: () =>
, +})); + vi.mock("./VehicleSettings.tsx", () => ({ VehicleSettings: () =>
, })); diff --git a/packages/client/src/components/pages/Settings/Settings.tsx b/packages/client/src/components/pages/Settings/Settings.tsx index dfe6c55f..5ddf22bf 100644 --- a/packages/client/src/components/pages/Settings/Settings.tsx +++ b/packages/client/src/components/pages/Settings/Settings.tsx @@ -14,6 +14,7 @@ import { VehicleSettings } from "./VehicleSettings.tsx"; import { SolarTrackingSettings } from "./SolarTrackingSettings.tsx"; import { BatterySettings } from "./BatterySettings.tsx"; import { TariffSettings } from "./TariffSettings.tsx"; +import { FreeTariffSettings } from "./FreeTariffSettings.tsx"; import { GeneralSettings } from "./GeneralSettings.tsx"; import { NotificationSettings } from "./NotificationSettings.tsx"; @@ -123,6 +124,9 @@ export function Settings() { {/* ═══ Electricity Tariffs ═══ */} + {/* ═══ Free Grid Charging ═══ */} + + {/* ═══ Battery ═══ */} diff --git a/packages/plugins/types.ts b/packages/plugins/types.ts index 3206637f..e5b68ace 100644 --- a/packages/plugins/types.ts +++ b/packages/plugins/types.ts @@ -84,6 +84,9 @@ export interface VehicleRequestContext extends CallContext { hasSolar: boolean; hasSchedule: boolean; hasBlockout: boolean; + /** The active tariff is free (or under the configured cheap threshold), so + * the vehicle can charge right now even with no sun and no schedule. */ + hasFreeTariff: boolean; scheduleChargeLimitPct?: number | null; /** When true, skip cache and wake if needed. Used for user-initiated * refresh/wake commands from the dashboard. */ diff --git a/packages/plugins/vehicles/simulated/server/router.ts b/packages/plugins/vehicles/simulated/server/router.ts index 33204f64..8a847910 100644 --- a/packages/plugins/vehicles/simulated/server/router.ts +++ b/packages/plugins/vehicles/simulated/server/router.ts @@ -68,6 +68,7 @@ export function createSimulatedRouter( hasSolar: false, hasSchedule: false, hasBlockout: false, + hasFreeTariff: false, forceRefresh: true, }); return { success: true, state }; diff --git a/packages/plugins/vehicles/tesla/server/TeslaApiStrategy.test.ts b/packages/plugins/vehicles/tesla/server/TeslaApiStrategy.test.ts index 6919d076..c9d8231d 100644 --- a/packages/plugins/vehicles/tesla/server/TeslaApiStrategy.test.ts +++ b/packages/plugins/vehicles/tesla/server/TeslaApiStrategy.test.ts @@ -16,6 +16,7 @@ describe("TeslaApiStrategy", () => { hasSolar: false, hasSchedule: false, hasBlockout: false, + hasFreeTariff: false, ...overrides, }); @@ -27,6 +28,7 @@ describe("TeslaApiStrategy", () => { ([ ["solar", { hasSolar: true }], ["schedule", { hasSchedule: true }], + ["free tariff", { hasFreeTariff: true }], ] as const).forEach(([label, overrides]) => { it(`returns 10 min when ${label} is active`, () => { const state = buildVehicleChargeState(); @@ -116,13 +118,24 @@ describe("TeslaApiStrategy", () => { ).toBeNull(); }); - it("returns null when no schedule and no solar", () => { + it("returns null when no schedule, no solar, and no free tariff", () => { expect(strategy.shouldWake(ctx(), null, 0)).toBeNull(); }); + it("prefers the schedule label over a concurrent free tariff", () => { + expect( + strategy.shouldWake( + ctx({ hasSchedule: true, hasFreeTariff: true }), + null, + 0, + ), + ).toBe("schedule"); + }); + ([ ["schedule", { hasSchedule: true }], ["solar", { hasSolar: true }], + ["free_tariff", { hasFreeTariff: true }], ] as const).forEach(([label, overrides]) => { it(`returns ${label} when cooldown expired`, () => { using time = new FakeTime(); diff --git a/packages/plugins/vehicles/tesla/server/TeslaApiStrategy.ts b/packages/plugins/vehicles/tesla/server/TeslaApiStrategy.ts index 7a123f94..af49916f 100644 --- a/packages/plugins/vehicles/tesla/server/TeslaApiStrategy.ts +++ b/packages/plugins/vehicles/tesla/server/TeslaApiStrategy.ts @@ -25,7 +25,11 @@ const WAKE_COOLDOWN_MS = 60 * 60 * 1000; /** Why a wake was triggered. Surfaces in plugin log origins so wakes can be * attributed to their cause when investigating cost or behavior. */ -export type WakeReason = "schedule" | "solar" | "force_refresh"; +export type WakeReason = + | "schedule" + | "solar" + | "free_tariff" + | "force_refresh"; /** Pure decision logic for Tesla Fleet API usage. No I/O — takes state, * returns decisions. Keeps all cost-aware reasoning in one testable place. */ @@ -43,7 +47,7 @@ export class TeslaApiStrategy { /** Whether a wake call ($0.02) is justified given the current context. * - Always wakes for user-initiated forceRefresh - * - Allowed for schedules or solar (not blockouts) + * - Allowed for schedules, solar, or a free tariff window (not blockouts) * - Skipped when cached state shows the car isn't plugged in * (Tesla wakes itself on plug-in, so the free /vehicles online check will * catch it — no reason to spend $0.02 waking an unplugged car) @@ -59,7 +63,9 @@ export class TeslaApiStrategy { if (context.forceRefresh) return "force_refresh"; // Blockout active — vehicle can't charge anyway, don't pay $0.02 to wake. if (context.hasBlockout) return null; - if (!context.hasSchedule && !context.hasSolar) return null; + if (!context.hasSchedule && !context.hasSolar && !context.hasFreeTariff) { + return null; + } // Not plugged in — Tesla wakes itself on plug-in, free /vehicles check catches it if (cachedState && !cachedState.isPluggedIn) return null; // Effective limit = min(vehicle chargeLimit, active schedule's @@ -73,8 +79,9 @@ export class TeslaApiStrategy { if (cachedState.batteryLevel >= effectiveLimit) return null; } if ((Date.now() - lastWakeAtMs) < WAKE_COOLDOWN_MS) return null; - // Schedule takes precedence in the reason label when both are active + // Most specific cause wins in the reason label when several are active if (context.hasSchedule) return "schedule"; + if (context.hasFreeTariff) return "free_tariff"; return "solar"; } @@ -88,7 +95,9 @@ export class TeslaApiStrategy { if (cachedState.isOnline && !cachedState.isPluggedIn) { return ONLINE_UNPLUGGED_MS; } - if (context.hasSolar || context.hasSchedule) return CAN_CHARGE_MS; + if (context.hasSolar || context.hasSchedule || context.hasFreeTariff) { + return CAN_CHARGE_MS; + } return CANT_CHARGE_MS; } } diff --git a/packages/plugins/vehicles/tesla/server/TeslaVehicleMiddleware.test.ts b/packages/plugins/vehicles/tesla/server/TeslaVehicleMiddleware.test.ts index 62cd2a86..b065a38b 100644 --- a/packages/plugins/vehicles/tesla/server/TeslaVehicleMiddleware.test.ts +++ b/packages/plugins/vehicles/tesla/server/TeslaVehicleMiddleware.test.ts @@ -19,6 +19,7 @@ describe("TeslaVehicleMiddleware", () => { hasSolar: false, hasSchedule: false, hasBlockout: false, + hasFreeTariff: false, ...overrides, }); diff --git a/packages/server/src/bootstrap/bootstrap.ts b/packages/server/src/bootstrap/bootstrap.ts index b3c32b2d..614686fe 100644 --- a/packages/server/src/bootstrap/bootstrap.ts +++ b/packages/server/src/bootstrap/bootstrap.ts @@ -268,6 +268,7 @@ function buildServices( db, configService, scheduleService, + tariffService, eventEmitter, new Logger("ChargeController", logLevel), ); diff --git a/packages/server/src/services/ChargeController.test/free-tariff.test.ts b/packages/server/src/services/ChargeController.test/free-tariff.test.ts new file mode 100644 index 00000000..d91f5cd5 --- /dev/null +++ b/packages/server/src/services/ChargeController.test/free-tariff.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import { + BASE_ENERGY, + type ControllerCtx, + currentScheduleWindow, + setupController, +} from "../../test-helpers/ChargeControllerHarness.ts"; +import type { AppDatabase } from "../../db/AppDatabase.ts"; + +describe("ChargeController — free tariff charging", () => { + /** Night-time energy — no solar, importing. Solar tracking bows out here, + * so a charge can only have come from the free-tariff path. */ + const NIGHT = { ...BASE_ENERGY, solarProductionW: 0, gridPowerW: 2000 }; + const FREE_TARIFF_ON = { free_tariff_charging_enabled: "true" }; + + /** Insert a tariff period at `ratePerKwh` that is active right now. */ + const seedActiveTariff = async ( + db: AppDatabase, + ratePerKwh: number, + ): Promise => { + const { today, startTime, endTime } = currentScheduleWindow(); + await db.createTariffPeriod({ + label: "Test", + startTime, + endTime, + days: [today], + ratePerKwh, + }); + }; + + let ctx: ControllerCtx | undefined; + + afterEach(() => { + ctx?.controller.stop(); + ctx?.db.close(); + }); + + it("starts a grid charge when the active tariff is free", async () => { + ctx = await setupController({}, "auto", NIGHT, FREE_TARIFF_ON); + await seedActiveTariff(ctx.db, 0); + await ctx.runOneLoop(); + + const log = await ctx.getLastLogParsed(); + expect(log?.action).toBe("start"); + expect(log?.actionDetail).toContain("grid is free"); + expect(ctx.adapter.commands).toContainEqual({ cmd: "start" }); + }); + + it("does not charge when the active tariff costs money", async () => { + ctx = await setupController({}, "auto", NIGHT, FREE_TARIFF_ON); + await seedActiveTariff(ctx.db, 0.32); + await ctx.runOneLoop(); + + const log = await ctx.getLastLogParsed(); + expect(log?.action).toBe("none"); + expect(log?.checks).toContainEqual({ + check: "free_tariff", + result: "not free (0.32 > 0/kWh)", + }); + }); + + it("does not charge when the feature is disabled", async () => { + ctx = await setupController({}, "auto", NIGHT, {}); + await seedActiveTariff(ctx.db, 0); + await ctx.runOneLoop(); + + const log = await ctx.getLastLogParsed(); + expect(log?.action).toBe("none"); + expect(log?.checks).toContainEqual({ + check: "free_tariff", + result: "skip (disabled)", + }); + }); + + it("does not charge when no tariff periods are configured", async () => { + ctx = await setupController({}, "auto", NIGHT, FREE_TARIFF_ON); + await ctx.runOneLoop(); + + const log = await ctx.getLastLogParsed(); + expect(log?.action).toBe("none"); + expect(log?.checks).toContainEqual({ + check: "free_tariff", + result: "skip (rate unknown)", + }); + }); + + it("holds while the home battery is below its priority limit", async () => { + ctx = await setupController( + {}, + "auto", + { ...NIGHT, batterySoc: 40 }, + { + ...FREE_TARIFF_ON, + battery_priority_enabled: "true", + battery_priority_limit: "80", + }, + ); + await seedActiveTariff(ctx.db, 0); + await ctx.runOneLoop(); + + const log = await ctx.getLastLogParsed(); + expect(log?.action).toBe("none"); + expect(log?.actionDetail).toContain("Waiting for home battery"); + expect(ctx.adapter.commands).not.toContainEqual({ cmd: "start" }); + }); + + it("charges once the home battery is at its priority limit", async () => { + ctx = await setupController( + {}, + "auto", + { ...NIGHT, batterySoc: 90 }, + { + ...FREE_TARIFF_ON, + battery_priority_enabled: "true", + battery_priority_limit: "80", + }, + ); + await seedActiveTariff(ctx.db, 0); + await ctx.runOneLoop(); + + const log = await ctx.getLastLogParsed(); + expect(log?.action).toBe("start"); + expect(log?.actionDetail).toContain("grid is free"); + }); + + it("charges through a cheap window when a threshold is set", async () => { + ctx = await setupController({}, "auto", NIGHT, { + ...FREE_TARIFF_ON, + free_tariff_max_rate_per_kwh: "0.1", + }); + await seedActiveTariff(ctx.db, 0.08); + await ctx.runOneLoop(); + + const log = await ctx.getLastLogParsed(); + expect(log?.action).toBe("start"); + expect(log?.actionDetail).toContain("grid rate is 0.08/kWh"); + }); +}); diff --git a/packages/server/src/services/ChargeController.ts b/packages/server/src/services/ChargeController.ts index 1a6d416a..f8f04427 100644 --- a/packages/server/src/services/ChargeController.ts +++ b/packages/server/src/services/ChargeController.ts @@ -29,6 +29,7 @@ import type { EnergyPoller } from "./EnergyPoller.ts"; import type { TypedEventEmitter } from "./TypedEventEmitter.ts"; import type { ConfigService } from "./ConfigService.ts"; import type { ScheduleService } from "./ScheduleService.ts"; +import type { TariffService } from "./TariffService.ts"; import type { Logger } from "../lib/Logger.ts"; // Default loop interval (overridden by config) @@ -89,6 +90,7 @@ export class ChargeController { private readonly db: AppDatabase; private readonly configService: ConfigService; private readonly scheduleService: ScheduleService; + private readonly tariffService: TariffService; private readonly eventEmitter: TypedEventEmitter; private readonly logger: Logger; private readonly engine = new ControllerEngine(); @@ -101,6 +103,7 @@ export class ChargeController { db: AppDatabase, configService: ConfigService, scheduleService: ScheduleService, + tariffService: TariffService, eventEmitter: TypedEventEmitter, logger: Logger, ) { @@ -109,6 +112,7 @@ export class ChargeController { this.db = db; this.configService = configService; this.scheduleService = scheduleService; + this.tariffService = tariffService; this.eventEmitter = eventEmitter; this.logger = logger; this.start(); @@ -161,39 +165,27 @@ export class ChargeController { `Loop: ${vehicles.length} vehicles, ${schedules.length} schedules, energy=${energySummary}`, ); + const currentRatePerKwh = await this.resolveCurrentRate(config); + // Compute context for middleware requests const hasSolar = energy !== null && energy.solarProductionW >= config.minSolarGenerationKw * 1000; const hasBlockout = schedules.some( (s) => this.isActiveBlockout(s, now, config.timezone), ); + // Tells the per-plugin middleware the vehicle can charge right now even + // with no sun and no schedule — otherwise it keeps a sleeping car asleep + // and the free window passes unused. + const hasFreeTariff = this.isFreeTariff(config, currentRatePerKwh); // Request fresh state for each vehicle via middleware - const engineVehicles: EngineVehicleInput[] = await Promise.all( - vehicles.map(async (v) => { - const applicable = schedules.filter((s) => - this.isScheduleApplicable(s, v.id, now, config.timezone) - ); - const activeChargeSchedule = applicable.find( - (s) => s.scheduleType === "charge", - ); - await this.vehicleManager.requestState(v.id, { - origin: "controller", - traceId, - hasSolar, - hasSchedule: applicable.length > 0, - hasBlockout, - scheduleChargeLimitPct: activeChargeSchedule?.chargeLimitPct ?? null, - }); - const state = await this.vehicleManager.getState(v.id); - return { - id: v.id, - name: v.name, - mode: v.mode, - priority: v.priority, - state, - }; - }), + const engineVehicles = await this.requestVehicleStates( + vehicles, + schedules, + config, + now, + traceId, + { hasSolar, hasBlockout, hasFreeTariff }, ); // Run the pure decision engine @@ -204,6 +196,7 @@ export class ChargeController { energy, now, timestamp: Date.now(), + currentRatePerKwh, }); // Execute decisions, build log entries, emit events @@ -564,6 +557,68 @@ export class ChargeController { s.vehicleId === null; } + /** Ask the middleware for fresh state for every vehicle, passing the + * loop-wide context flags plus each vehicle's own schedule situation so it + * can make cost-aware fetch / cache / wake decisions. */ + private requestVehicleStates( + vehicles: VehicleRow[], + schedules: ScheduleRow[], + config: ControllerConfig, + now: Date, + traceId: string, + flags: { + hasSolar: boolean; + hasBlockout: boolean; + hasFreeTariff: boolean; + }, + ): Promise { + return Promise.all( + vehicles.map(async (v) => { + const applicable = schedules.filter((s) => + this.isScheduleApplicable(s, v.id, now, config.timezone) + ); + const activeChargeSchedule = applicable.find( + (s) => s.scheduleType === "charge", + ); + await this.vehicleManager.requestState(v.id, { + origin: "controller", + traceId, + ...flags, + hasSchedule: applicable.length > 0, + scheduleChargeLimitPct: activeChargeSchedule?.chargeLimitPct ?? null, + }); + const state = await this.vehicleManager.getState(v.id); + return { + id: v.id, + name: v.name, + mode: v.mode, + priority: v.priority, + state, + }; + }), + ); + } + + /** Resolve the active tariff rate for this cycle. Only needed when + * free-tariff charging is on, so the lookup is skipped otherwise. + * Returns null when the rate can't be resolved — which the engine treats + * as "unknown", never as free. */ + private async resolveCurrentRate( + config: ControllerConfig, + ): Promise { + if (!config.freeTariffChargingEnabled) return null; + return await this.tariffService.resolveCurrentRate(); + } + + /** Whether the resolved rate clears the configured "free" threshold. */ + private isFreeTariff( + config: ControllerConfig, + ratePerKwh: number | null, + ): boolean { + if (!config.freeTariffChargingEnabled || ratePerKwh === null) return false; + return ratePerKwh <= config.freeTariffMaxRatePerKwh; + } + private isActiveBlockout( s: ScheduleRow, now: Date, @@ -600,6 +655,8 @@ export class ChargeController { batteryPriorityEnabled: battery.batteryPriorityEnabled, batteryPriorityLimit: battery.batteryPriorityLimit, priorityChargingEnabled: charging.priorityChargingEnabled, + freeTariffChargingEnabled: charging.freeTariffChargingEnabled, + freeTariffMaxRatePerKwh: charging.freeTariffMaxRatePerKwh, timezone: system.timezone, }; } diff --git a/packages/server/src/services/NotificationListener.ts b/packages/server/src/services/NotificationListener.ts index ad7fb138..3f5ef853 100644 --- a/packages/server/src/services/NotificationListener.ts +++ b/packages/server/src/services/NotificationListener.ts @@ -345,6 +345,7 @@ function modeNotification( function chargeStartTitle(reason: DecisionReason): string { if (reason === "schedule") return "Scheduled Charging Started"; if (reason === "solar_tracking") return "Solar Charging Started"; + if (reason === "free_tariff") return "Free Grid Charging Started"; return "Charging Started"; } diff --git a/packages/server/src/services/TariffService.ts b/packages/server/src/services/TariffService.ts index 6950a656..6c4eaa7b 100644 --- a/packages/server/src/services/TariffService.ts +++ b/packages/server/src/services/TariffService.ts @@ -161,7 +161,10 @@ const PRESETS: Record = { ], }; -const TARIFF_CACHE_REFRESH_MS = 5 * 60 * 1000; // 5 minutes +// Kept short because the charge controller resolves the rate on every loop +// (default 30s) to decide free-tariff charging — a longer cache would let it +// run past a tariff boundary, or start late, by up to the cache lifetime. +const TARIFF_CACHE_REFRESH_MS = 60 * 1000; // 1 minute export class TariffService { private cachedTariffPeriods: TariffPeriodRow[] = []; diff --git a/packages/server/src/services/VehicleManager.test.ts b/packages/server/src/services/VehicleManager.test.ts index 732d552c..591ec737 100644 --- a/packages/server/src/services/VehicleManager.test.ts +++ b/packages/server/src/services/VehicleManager.test.ts @@ -75,6 +75,7 @@ describe("VehicleManager", () => { hasSolar: false, hasSchedule: false, hasBlockout: false, + hasFreeTariff: false, }; const CMD_CTX = { origin: "test", traceId: "test" }; diff --git a/packages/server/src/services/VehicleService.ts b/packages/server/src/services/VehicleService.ts index 2a876e31..a717419d 100644 --- a/packages/server/src/services/VehicleService.ts +++ b/packages/server/src/services/VehicleService.ts @@ -246,6 +246,7 @@ export class VehicleService { hasSolar: false, hasSchedule: false, hasBlockout: false, + hasFreeTariff: false, forceRefresh: true, }, ); @@ -298,6 +299,7 @@ export class VehicleService { hasSolar: false, hasSchedule: false, hasBlockout: false, + hasFreeTariff: false, forceRefresh: true, }, ); diff --git a/packages/server/src/test-helpers/ChargeControllerHarness.ts b/packages/server/src/test-helpers/ChargeControllerHarness.ts index 2c131fb2..46b3cf1c 100644 --- a/packages/server/src/test-helpers/ChargeControllerHarness.ts +++ b/packages/server/src/test-helpers/ChargeControllerHarness.ts @@ -29,6 +29,7 @@ import type { EnergyPoller } from "../services/EnergyPoller.ts"; import { ChargeController } from "../services/ChargeController.ts"; import { ConfigService } from "../services/ConfigService.ts"; import { ScheduleService } from "../services/ScheduleService.ts"; +import { TariffService } from "../services/TariffService.ts"; import type { EnergyAdapterManager } from "../services/EnergyAdapterManager.ts"; import { Logger } from "../lib/Logger.ts"; import { testable } from "./Testable.ts"; @@ -96,6 +97,7 @@ export const REQUEST_CONTEXT = { hasSolar: false, hasSchedule: false, hasBlockout: false, + hasFreeTariff: false, forceRefresh: true, } as const; @@ -132,6 +134,7 @@ const SETUP_REQUEST_CONTEXT = { hasSolar: false, hasSchedule: false, hasBlockout: false, + hasFreeTariff: false, }; const testVehicleManagerLogger = new Logger("VehicleManager", "error"); @@ -238,6 +241,7 @@ async function buildControllerStack( db, configService, new ScheduleService(db, new Logger("ScheduleService", "error")), + new TariffService(db, new Logger("TariffService", "error")), trackingEmitter, testControllerLogger, ); diff --git a/packages/server/src/trpc/routers/vehicles.test.ts b/packages/server/src/trpc/routers/vehicles.test.ts index aff683a2..dce9f077 100644 --- a/packages/server/src/trpc/routers/vehicles.test.ts +++ b/packages/server/src/trpc/routers/vehicles.test.ts @@ -58,6 +58,7 @@ describe("Vehicles tRPC Router", () => { hasSolar: false, hasSchedule: false, hasBlockout: false, + hasFreeTariff: false, } as const; const makeRegistry = (): VehiclePluginRegistry => diff --git a/packages/shared/configSections.ts b/packages/shared/configSections.ts index 06f30677..96d9e805 100644 --- a/packages/shared/configSections.ts +++ b/packages/shared/configSections.ts @@ -40,6 +40,16 @@ export const chargingConfigDef = defineSection({ schema: z.boolean(), default: false, }, + freeTariffChargingEnabled: { + key: "free_tariff_charging_enabled", + schema: z.boolean(), + default: false, + }, + freeTariffMaxRatePerKwh: { + key: "free_tariff_max_rate_per_kwh", + schema: z.number(), + default: 0, + }, }); export type ChargingConfig = SectionType; diff --git a/packages/shared/engine/ControllerEngine.test.ts b/packages/shared/engine/ControllerEngine.test.ts index ae04185c..e0d33096 100644 --- a/packages/shared/engine/ControllerEngine.test.ts +++ b/packages/shared/engine/ControllerEngine.test.ts @@ -489,6 +489,7 @@ describe("ControllerEngine", () => { energy: makeEnergy({ gridPowerW: -5000 }), now: new Date("2026-01-01T12:00:00Z"), timestamp: Date.now(), + currentRatePerKwh: null, }); const d1 = output.decisions.get("V1"); const d2 = output.decisions.get("V2"); @@ -511,6 +512,7 @@ describe("ControllerEngine", () => { energy: makeEnergy({ gridPowerW: -2000 }), now: new Date("2026-01-01T12:00:00Z"), timestamp: Date.now(), + currentRatePerKwh: null, }); const d1 = output.decisions.get("V1"); const d2 = output.decisions.get("V2"); @@ -798,6 +800,7 @@ describe("ControllerEngine", () => { energy: makeEnergy({ solarProductionW: 5000, gridPowerW: 500 }), now: new Date("2026-01-01T12:00:00Z"), timestamp: Date.now(), + currentRatePerKwh: null, }); expect(output.decisions.get("V1")?.action).toBe("start"); }); diff --git a/packages/shared/engine/ControllerEngine.test/free-tariff.test.ts b/packages/shared/engine/ControllerEngine.test/free-tariff.test.ts new file mode 100644 index 00000000..008b46d2 --- /dev/null +++ b/packages/shared/engine/ControllerEngine.test/free-tariff.test.ts @@ -0,0 +1,238 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import { ControllerEngine } from "../ControllerEngine.ts"; +import { makeInput } from "../test-helpers/controller-engine.ts"; + +describe("ControllerEngine — free tariff charging", () => { + /** Night-time energy: no solar, importing from the grid. Solar tracking + * terminates on its own here, so anything that still charges is proof the + * free-tariff step ran before it. */ + const NIGHT = { solarProductionW: 0, gridPowerW: 2000 }; + const FREE = { freeTariffChargingEnabled: true, freeTariffMaxRatePerKwh: 0 }; + + it("starts at max amps when the grid is free and there is no sun", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + configOverrides: FREE, + energyOverrides: NIGHT, + currentRatePerKwh: 0, + })); + const d = output.decisions.get("V1"); + expect(d?.action).toBe("start"); + expect(d?.reason).toBe("free_tariff"); + expect(d?.targetAmps).toBe(32); + expect(d?.detail).toContain("grid is free"); + }); + + it("treats a negative rate as free", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + configOverrides: FREE, + energyOverrides: NIGHT, + currentRatePerKwh: -0.05, + })); + expect(output.decisions.get("V1")?.reason).toBe("free_tariff"); + }); + + it("charges through a cheap window when a threshold is configured", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + configOverrides: { + freeTariffChargingEnabled: true, + freeTariffMaxRatePerKwh: 0.10, + }, + energyOverrides: NIGHT, + currentRatePerKwh: 0.08, + })); + const d = output.decisions.get("V1"); + expect(d?.action).toBe("start"); + expect(d?.detail).toContain("grid rate is 0.08/kWh"); + }); + + it("adjusts up to max amps when already charging lower", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + vehicle: { state: { isCharging: true, chargeAmps: 8 } }, + configOverrides: FREE, + energyOverrides: NIGHT, + currentRatePerKwh: 0, + })); + const d = output.decisions.get("V1"); + expect(d?.action).toBe("adjust_amps"); + expect(d?.targetAmps).toBe(32); + }); + + it("holds steady when already charging at max amps", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + vehicle: { state: { isCharging: true, chargeAmps: 32 } }, + configOverrides: FREE, + energyOverrides: NIGHT, + currentRatePerKwh: 0, + })); + const d = output.decisions.get("V1"); + expect(d?.action).toBe("none"); + expect(d?.reason).toBe("free_tariff"); + }); + + it("charges with no energy snapshot when battery priority is off", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + configOverrides: FREE, + energy: null, + currentRatePerKwh: 0, + })); + expect(output.decisions.get("V1")?.reason).toBe("free_tariff"); + }); +}); + +describe("ControllerEngine — free tariff does not apply", () => { + /** Night-time energy: no solar, importing from the grid. Solar tracking + * terminates on its own here, so anything that still charges is proof the + * free-tariff step ran before it. */ + const NIGHT = { solarProductionW: 0, gridPowerW: 2000 }; + const FREE = { freeTariffChargingEnabled: true, freeTariffMaxRatePerKwh: 0 }; + + it("falls through to solar tracking when disabled", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + energyOverrides: NIGHT, + currentRatePerKwh: 0, + })); + expect(output.decisions.get("V1")?.reason).toBe("no_solar"); + }); + + it("never charges on an unresolved rate", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + configOverrides: FREE, + energyOverrides: NIGHT, + currentRatePerKwh: null, + })); + const d = output.decisions.get("V1"); + expect(d?.reason).toBe("no_solar"); + expect(d?.checks).toContainEqual({ + check: "free_tariff", + result: "skip (rate unknown)", + }); + }); + + it("falls through when the rate is above the free threshold", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + configOverrides: FREE, + energyOverrides: NIGHT, + currentRatePerKwh: 0.30, + })); + const d = output.decisions.get("V1"); + expect(d?.reason).toBe("no_solar"); + expect(d?.checks).toContainEqual({ + check: "free_tariff", + result: "not free (0.3 > 0/kWh)", + }); + }); + + it("stops a running free charge once the rate stops being free", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + vehicle: { state: { isCharging: true, chargeAmps: 32 } }, + configOverrides: FREE, + energyOverrides: NIGHT, + currentRatePerKwh: 0.30, + })); + const d = output.decisions.get("V1"); + expect(d?.action).toBe("stop"); + expect(d?.reason).toBe("no_solar"); + }); + + it("stops at the vehicle's charge limit even while the grid is free", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + vehicle: { + state: { isCharging: true, batteryLevel: 80, chargeLimit: 80 }, + }, + configOverrides: FREE, + energyOverrides: NIGHT, + currentRatePerKwh: 0, + })); + const d = output.decisions.get("V1"); + expect(d?.action).toBe("stop"); + expect(d?.reason).toBe("battery_at_limit"); + }); +}); + +describe("ControllerEngine — free tariff respects home battery priority", () => { + /** Night-time energy: no solar, importing from the grid. Solar tracking + * terminates on its own here, so anything that still charges is proof the + * free-tariff step ran before it. */ + const NIGHT = { solarProductionW: 0, gridPowerW: 2000 }; + const FREE = { freeTariffChargingEnabled: true, freeTariffMaxRatePerKwh: 0 }; + + const WITH_PRIORITY = { + ...FREE, + batteryPriorityEnabled: true, + batteryPriorityLimit: 80, + }; + + it("holds while the home battery is below its priority limit", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + configOverrides: WITH_PRIORITY, + energyOverrides: { ...NIGHT, batterySoc: 50 }, + currentRatePerKwh: 0, + })); + const d = output.decisions.get("V1"); + expect(d?.action).toBe("none"); + expect(d?.reason).toBe("battery_priority"); + }); + + it("stops a running free charge when the home battery drops below", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + vehicle: { state: { isCharging: true, chargeAmps: 32 } }, + configOverrides: WITH_PRIORITY, + energyOverrides: { ...NIGHT, batterySoc: 50 }, + currentRatePerKwh: 0, + })); + const d = output.decisions.get("V1"); + expect(d?.action).toBe("stop"); + expect(d?.reason).toBe("battery_priority"); + }); + + it("charges once the home battery has reached its priority limit", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + configOverrides: WITH_PRIORITY, + energyOverrides: { ...NIGHT, batterySoc: 85 }, + currentRatePerKwh: 0, + })); + const d = output.decisions.get("V1"); + expect(d?.action).toBe("start"); + expect(d?.reason).toBe("free_tariff"); + }); + + it("holds when the inverter reports no home battery SoC", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + configOverrides: WITH_PRIORITY, + energyOverrides: { ...NIGHT, batterySoc: null }, + currentRatePerKwh: 0, + })); + const d = output.decisions.get("V1"); + expect(d?.action).toBe("none"); + expect(d?.reason).toBe("free_tariff"); + expect(d?.detail).toContain("home battery SoC is unknown"); + }); + + it("holds when there is no energy snapshot at all", () => { + const engine = new ControllerEngine(); + const output = engine.decide(makeInput({ + configOverrides: WITH_PRIORITY, + energy: null, + currentRatePerKwh: 0, + })); + const d = output.decisions.get("V1"); + expect(d?.reason).toBe("free_tariff"); + expect(d?.detail).toContain("home battery SoC is unknown"); + }); +}); diff --git a/packages/shared/engine/ControllerEngine.ts b/packages/shared/engine/ControllerEngine.ts index 692db6cb..782eaf6c 100644 --- a/packages/shared/engine/ControllerEngine.ts +++ b/packages/shared/engine/ControllerEngine.ts @@ -32,6 +32,7 @@ export class ControllerEngine { /** Make decisions for all vehicles in a single loop iteration. */ decide(input: EngineInput): EngineOutput { const { config, vehicles, schedules, energy, now, timestamp } = input; + const { currentRatePerKwh } = input; if (!config.chargingEnabled) { const decisions = new Map( vehicles.map((vehicle): [string, VehicleDecision] => [vehicle.id, { @@ -55,7 +56,15 @@ export class ControllerEngine { const decisions = new Map( vehicles.map((vehicle): [string, VehicleDecision] => [ vehicle.id, - this.decideVehicle(vehicle, config, schedules, energy, now, timestamp), + this.decideVehicle( + vehicle, + config, + schedules, + energy, + now, + timestamp, + currentRatePerKwh, + ), ]), ); @@ -80,6 +89,7 @@ export class ControllerEngine { energy: EnergyData | null, now: Date, timestamp: number, + currentRatePerKwh: number | null, ): VehicleDecision { const precondition = this.checkPreconditions(vehicle); if (precondition.decision) { @@ -111,6 +121,7 @@ export class ControllerEngine { now, timestamp, checks, + currentRatePerKwh, ); } } @@ -250,6 +261,7 @@ export class ControllerEngine { now: Date, timestamp: number, outerChecks: DecisionCheck[], + currentRatePerKwh: number | null, ): VehicleDecision { const cs = this.getControlState(vehicle.id); const allChecks = [...outerChecks]; @@ -285,6 +297,25 @@ export class ControllerEngine { return { ...battery.decision, checks: allChecks, scheduleLimitContext }; } + // Free-tariff charging sits after battery priority (so the home battery + // still wins) and before solar tracking — solar tracking terminates with + // a decision once production hits zero, which would make this step + // unreachable at night, exactly when free windows usually fall. + const freeTariff = this.evaluateFreeTariff( + state, + config, + energy, + currentRatePerKwh, + ); + allChecks.push(...freeTariff.checks); + if (freeTariff.decision) { + return { + ...freeTariff.decision, + checks: allChecks, + scheduleLimitContext, + }; + } + const solar = this.evaluateSolarTracking( state, config, @@ -430,6 +461,98 @@ export class ControllerEngine { }; } + /** Charge from the grid while the active tariff is free (or under the + * configured cheap-rate threshold). The vehicle's own charge limit is the + * target SoC — preconditions already stop the charge once it's reached. */ + private evaluateFreeTariff( + state: VehicleChargeState, + config: ControllerConfig, + energy: EnergyData | null, + currentRatePerKwh: number | null, + ): EvalResult { + if (!config.freeTariffChargingEnabled || currentRatePerKwh === null) { + // A null rate means the tariff couldn't be resolved, not that it's + // free — never start a grid charge on an unknown rate. + return { + decision: null, + checks: [ + DecisionChecks.freeTariffSkip(config.freeTariffChargingEnabled), + ], + }; + } + + const isFree = currentRatePerKwh <= config.freeTariffMaxRatePerKwh; + const checks: DecisionCheck[] = [ + DecisionChecks.freeTariff( + currentRatePerKwh, + config.freeTariffMaxRatePerKwh, + isFree, + ), + ]; + // Not free — fall through to solar tracking. + if (!isFree) return { decision: null, checks }; + + // Battery priority is enabled but evaluateBatteryPriority couldn't run + // (no energy snapshot, or the inverter reports no SoC), so its limit was + // never actually checked. Hold rather than charge on an unverified battery. + if ( + config.batteryPriorityEnabled && + (energy === null || energy.batterySoc === null) + ) { + checks.push(DecisionChecks.freeTariffBatteryUnknown()); + return { + decision: { + action: state.isCharging ? "stop" : "none", + reason: "free_tariff", + detail: state.isCharging + ? "Stop — grid is free but home battery SoC is unknown" + : "Waiting — grid is free but home battery SoC is unknown", + targetAmps: null, + }, + checks, + }; + } + + return { + decision: this.freeTariffCharge(state, currentRatePerKwh), + checks, + }; + } + + /** Start/adjust/hold a free-tariff charge at the vehicle's maximum amps. */ + private freeTariffCharge( + state: VehicleChargeState, + ratePerKwh: number, + ): PipelineDecision { + const amps = state.chargeAmpsMax; + const suffix = ratePerKwh <= 0 + ? "grid is free" + : `grid rate is ${ratePerKwh}/kWh`; + + if (!state.isCharging) { + return { + action: "start", + reason: "free_tariff", + detail: `Start charging at ${amps}A — ${suffix}`, + targetAmps: amps, + }; + } + if (state.chargeAmps !== amps) { + return { + action: "adjust_amps", + reason: "free_tariff", + detail: `Adjust to ${amps}A — ${suffix}`, + targetAmps: amps, + }; + } + return { + action: "none", + reason: "free_tariff", + detail: `Already charging at ${amps}A — ${suffix}`, + targetAmps: amps, + }; + } + private evaluateSolarTracking( state: VehicleChargeState, config: ControllerConfig, diff --git a/packages/shared/engine/DecisionChecks.ts b/packages/shared/engine/DecisionChecks.ts index c28e0211..2873ae6f 100644 --- a/packages/shared/engine/DecisionChecks.ts +++ b/packages/shared/engine/DecisionChecks.ts @@ -6,6 +6,7 @@ export type CheckName = | "location" | "battery_at_limit" | "battery_priority" + | "free_tariff" | "solar_tracking" | "blockout_schedule" | "charge_schedule" @@ -98,6 +99,35 @@ export class DecisionChecks { return { check: "battery_priority", result: "no battery data" }; } + static freeTariffSkip(enabled: boolean): DecisionCheck { + const result = enabled ? "skip (rate unknown)" : "skip (disabled)"; + return { check: "free_tariff", result }; + } + + /** Rate is known — record whether it clears the "free" threshold. */ + static freeTariff( + ratePerKwh: number, + maxRatePerKwh: number, + isFree: boolean, + ): DecisionCheck { + const comparison = isFree ? "<=" : ">"; + return { + check: "free_tariff", + result: `${ + isFree ? "free" : "not free" + } (${ratePerKwh} ${comparison} ${maxRatePerKwh}/kWh)`, + }; + } + + /** Grid is free but the home battery's SoC can't be read, so the battery + * priority limit can't be honoured — hold rather than guess. */ + static freeTariffBatteryUnknown(): DecisionCheck { + return { + check: "free_tariff", + result: "hold (battery priority on, home battery SoC unknown)", + }; + } + static solarTrackingSkip(enabled: boolean): DecisionCheck { const result = enabled ? "skip (no energy data)" : "disabled"; return { check: "solar_tracking", result }; diff --git a/packages/shared/engine/SolarAllocator.test.ts b/packages/shared/engine/SolarAllocator.test.ts index 6b8b8afd..cbfaa036 100644 --- a/packages/shared/engine/SolarAllocator.test.ts +++ b/packages/shared/engine/SolarAllocator.test.ts @@ -55,6 +55,8 @@ describe("SolarAllocator", () => { batteryPriorityEnabled: false, batteryPriorityLimit: 0, priorityChargingEnabled: true, + freeTariffChargingEnabled: false, + freeTariffMaxRatePerKwh: 0, timezone: "", ampDebounceThreshold: 2, ampDebounceSettleMinutes: 3, diff --git a/packages/shared/engine/test-helpers/controller-engine.ts b/packages/shared/engine/test-helpers/controller-engine.ts index b3bf348c..4de39ad2 100644 --- a/packages/shared/engine/test-helpers/controller-engine.ts +++ b/packages/shared/engine/test-helpers/controller-engine.ts @@ -24,6 +24,8 @@ export const makeConfig = ( batteryPriorityEnabled: false, batteryPriorityLimit: 0, priorityChargingEnabled: false, + freeTariffChargingEnabled: false, + freeTariffMaxRatePerKwh: 0, timezone: "", ampDebounceThreshold: 2, ampDebounceSettleMinutes: 3, @@ -115,6 +117,7 @@ export const makeInput = ( energy: makeEnergy(energyOverrides), now: new Date("2026-01-01T12:00:00Z"), timestamp: Date.now(), + currentRatePerKwh: null, ...inputOverrides, }; }; diff --git a/packages/shared/engine/types.ts b/packages/shared/engine/types.ts index f4801b20..dff64d12 100644 --- a/packages/shared/engine/types.ts +++ b/packages/shared/engine/types.ts @@ -32,6 +32,12 @@ export interface ControllerConfig { batteryPriorityEnabled: boolean; batteryPriorityLimit: number; priorityChargingEnabled: boolean; + /** Charge from the grid whenever the active tariff rate is free (or cheap + * enough — see freeTariffMaxRatePerKwh), regardless of solar. */ + freeTariffChargingEnabled: boolean; + /** Rate at or below which the grid counts as "free". Default 0, so only a + * genuinely zero (or negative) rate qualifies. */ + freeTariffMaxRatePerKwh: number; timezone: string; ampDebounceThreshold: number; ampDebounceSettleMinutes: number; @@ -73,6 +79,9 @@ export interface EngineInput { now: Date; /** Monotonic timestamp in ms (replaces Date.now() calls inside the engine). */ timestamp: number; + /** The active tariff rate in currency/kWh, or null when it can't be + * resolved (no tariffs configured). Null means *unknown*, never free. */ + currentRatePerKwh: number | null; } // ---- Per-vehicle runtime state ---- @@ -115,6 +124,7 @@ export type DecisionReason = | "charge_now" | "mode_stop" | "battery_priority" + | "free_tariff" | "grace_period" | "cooldown" | "no_solar" diff --git a/packages/shared/simulation/run.ts b/packages/shared/simulation/run.ts index 63918f58..2a1425c3 100644 --- a/packages/shared/simulation/run.ts +++ b/packages/shared/simulation/run.ts @@ -60,6 +60,10 @@ function buildControllerConfig(opts: SimulationOptions): ControllerConfig { batteryPriorityEnabled: opts.batteryPriorityEnabled, batteryPriorityLimit: opts.batteryPriorityLimit, priorityChargingEnabled: opts.waterfall, + // The simulator replays a solar day, not a tariff schedule — free-tariff + // charging stays off so simulated results reflect solar behaviour only. + freeTariffChargingEnabled: false, + freeTariffMaxRatePerKwh: 0, timezone: "", }; } @@ -290,6 +294,7 @@ export function runSimulation( }, now: new Date(simTimestamp), timestamp: simTimestamp, + currentRatePerKwh: null, }); events.push(