From d506a845514897a9c81387b649cd2f1e526fdec8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 17:21:22 +0200 Subject: [PATCH] feat(ev): show the ready time on the household clock Read time_of_day_min when the box sends it, so a phone in another time zone shows and saves the time at home. Send it with the UTC minute, so an older box still works. The sim keeps the household clock the way the box does. The old note that a DST change moves the deadline was wrong since srcfl/ftw#1582. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-ready-time-household-clock.md | 5 ++ src/lib/format/ev.test.ts | 36 +++++++++++++ src/lib/format/ev.ts | 58 ++++++++++++++++----- src/lib/sim/api.ts | 33 ++++++++++-- src/views/EvPanel.svelte | 16 +++--- src/views/EvPanel.svelte.test.ts | 3 ++ 6 files changed, 127 insertions(+), 24 deletions(-) create mode 100644 .changeset/ev-ready-time-household-clock.md diff --git a/.changeset/ev-ready-time-household-clock.md b/.changeset/ev-ready-time-household-clock.md new file mode 100644 index 0000000..eadf76f --- /dev/null +++ b/.changeset/ev-ready-time-household-clock.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +The car's ready time shows and saves on the household clock when the box sends it, so a phone in another time zone shows the time at home. The app still sends the UTC minute, so an older box keeps working. Pair with the Core change that adds `time_of_day_min`. diff --git a/src/lib/format/ev.test.ts b/src/lib/format/ev.test.ts index 021a7d6..696c5bf 100644 --- a/src/lib/format/ev.test.ts +++ b/src/lib/format/ev.test.ts @@ -12,6 +12,9 @@ import { daysWord, localInputToUtcMinutes, utcMinutesToLocalInput, + inputMinutes, + readyClock, + readyInput, chargeCurrent, ampsToWatts, wattsToAmps, @@ -127,6 +130,39 @@ describe('a charger described in words', () => { expect(localInputToUtcMinutes('25:99')).toBeNull() }) + it('shows the household clock when the box sends it, in any zone', () => { + // 07:00 at home is 05:00 UTC in Swedish summer. A phone abroad reads + // that UTC minute as another time; the household minute it keeps. + const lp = toLoadpoint({ + ...WIRE, + schedule: { soc: 0.8, time_of_day_min: 420, time_of_day_min_utc: 300, recurring: true }, + }) + expect(lp.schedule?.timeOfDayMin).toBe(420) + expect(readyInput(lp.schedule!)).toBe('07:00') + expect(readyClock(lp.schedule!)).toMatch(/^07:00/) + expect(evScheduleSentence(lp)).toContain(`Ready by ${readyClock(lp.schedule!)}`) + }) + + it('reads the UTC minute in this zone from a box without the household clock', () => { + const lp = toLoadpoint(WIRE) + expect(lp.schedule?.timeOfDayMin).toBeNull() + expect(readyInput(lp.schedule!)).toBe(utcMinutesToLocalInput(360)) + expect(readyClock(lp.schedule!)).toBe(localClock(360)) + }) + + it('ignores a household minute out of range', () => { + const lp = toLoadpoint({ ...WIRE, schedule: { soc: 0.8, time_of_day_min: 1440, time_of_day_min_utc: 300 } }) + expect(lp.schedule?.timeOfDayMin).toBeNull() + }) + + it('reads a time input as minutes after midnight', () => { + expect(inputMinutes('00:00')).toBe(0) + expect(inputMinutes('06:30')).toBe(390) + expect(inputMinutes('23:59')).toBe(1439) + expect(inputMinutes('24:00')).toBeNull() + expect(inputMinutes('7')).toBeNull() + }) + it('holds every sentence to the no-minus rule', () => { // A driver mid-fault can report a small negative power; the wire sign // convention must not reach the panel. diff --git a/src/lib/format/ev.ts b/src/lib/format/ev.ts index d20e5bb..33b46b3 100644 --- a/src/lib/format/ev.ts +++ b/src/lib/format/ev.ts @@ -63,6 +63,7 @@ export interface WireLoadpoint { schedule?: { soc?: unknown soc_pct?: unknown + time_of_day_min?: unknown time_of_day_min_utc?: unknown surplus_unlock_bat_soc?: unknown recurring?: unknown @@ -137,6 +138,11 @@ export interface Loadpoint { boostStopReason: string | null schedule: { socPct: number | null + /** + * The ready time on the household clock: minutes after midnight in + * the box's zone. Null from a box that sends only the UTC minute. + */ + timeOfDayMin: number | null timeOfDayMinUtc: number surplusUnlockPct?: number recurring: boolean @@ -164,6 +170,7 @@ const pct = (v: unknown): number | null => { export function toLoadpoint(w: WireLoadpoint): Loadpoint { const sched = w.schedule const schedMin = sched ? num(sched.time_of_day_min_utc) : null + const schedClock = sched ? num(sched.time_of_day_min) : null const schedPct = pct(sched?.soc) ?? num(sched?.soc_pct) const socFraction = num(w.current_soc) const boost = w.battery_boost @@ -214,6 +221,7 @@ export function toLoadpoint(w: WireLoadpoint): Loadpoint { ? null : { socPct: schedPct, + timeOfDayMin: schedClock !== null && schedClock >= 0 && schedClock < 1440 ? schedClock : null, timeOfDayMinUtc: schedMin, recurring: sched!.recurring === true, surplusUnlockPct: typeof sched!.surplus_unlock_bat_soc === 'number' ? sched!.surplus_unlock_bat_soc * 100 : 0, @@ -222,6 +230,26 @@ export function toLoadpoint(w: WireLoadpoint): Loadpoint { } } +type Schedule = NonNullable + +/** + * The ready time on the kitchen clock. The household clock when the box + * sends it, so a phone abroad shows the time at home; an older box sends + * only the UTC minute, read in this phone's zone. + */ +export function readyClock(s: Schedule, at: Date = new Date()): string { + if (s.timeOfDayMin == null) return localClock(s.timeOfDayMinUtc, at) + // Printed in UTC, so no zone of the phone's can move it. + const d = new Date(Date.UTC(2000, 0, 1, Math.floor(s.timeOfDayMin / 60), s.timeOfDayMin % 60)) + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }) +} + +/** The same time for an : "HH:MM". */ +export function readyInput(s: Schedule, at: Date = new Date()): string { + if (s.timeOfDayMin == null) return utcMinutesToLocalInput(s.timeOfDayMinUtc, at) + return `${String(Math.floor(s.timeOfDayMin / 60)).padStart(2, '0')}:${String(s.timeOfDayMin % 60).padStart(2, '0')}` +} + /** Minutes-of-day in UTC, printed on the kitchen clock. */ export function localClock(minUtc: number, at: Date = new Date()): string { const d = new Date(at) @@ -236,23 +264,27 @@ export function utcMinutesToLocalInput(minUtc: number, at: Date = new Date()): s return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` } -/** - * A local "HH:MM" back to the wire's UTC minutes-of-day. - * - * Anchored on `at`, so it is exact for today's offset — the same rule the - * box's own page uses when it saves. The stored minute is fixed; a DST - * change shifts the local deadline an hour until re-saved, a known drift - * the box documents where it computes the deadline. - */ -export function localInputToUtcMinutes(hhmm: string, at: Date = new Date()): number | null { +/** "HH:MM" as minutes after midnight, the wire's time_of_day_min. */ +export function inputMinutes(hhmm: string): number | null { const m = hhmm.match(/^(\d{1,2}):(\d{2})$/) if (!m) return null const h = Number(m[1]) const min = Number(m[2]) - // setHours would roll "25:99" into tomorrow rather than refuse it. - if (h > 23 || min > 59) return null + return h > 23 || min > 59 ? null : h * 60 + min +} + +/** + * A local "HH:MM" as the UTC minute an older box reads. + * + * Anchored on `at`, so it is exact for today's offset in this phone's + * zone. A box that knows time_of_day_min ignores it and keeps the + * household clock, so a DST change does not move the ready time. + */ +export function localInputToUtcMinutes(hhmm: string, at: Date = new Date()): number | null { + const min = inputMinutes(hhmm) + if (min === null) return null const d = new Date(at) - d.setHours(h, min, 0, 0) + d.setHours(Math.floor(min / 60), min % 60, 0, 0) return d.getUTCHours() * 60 + d.getUTCMinutes() } @@ -366,7 +398,7 @@ export function evPlanSentence(lp: Loadpoint, now = Date.now(), canControl = tru export function evScheduleSentence(lp: Loadpoint, at: Date = new Date()): string | null { const s = lp.schedule if (!s) return null - const when = localClock(s.timeOfDayMinUtc, at) + const when = readyClock(s, at) const cadence = s.recurring ? daysWord(s.days) : 'once' const target = s.socPct !== null ? `${Math.round(s.socPct)} % ` : '' return `${target}Ready by ${when} · ${cadence}` diff --git a/src/lib/sim/api.ts b/src/lib/sim/api.ts index 8579197..228f004 100644 --- a/src/lib/sim/api.ts +++ b/src/lib/sim/api.ts @@ -325,7 +325,7 @@ export class SimApi { */ #schedule: Record | null = { soc: 0.84, - time_of_day_min_utc: 360, + time_of_day_min: 420, recurring: true, } @@ -756,7 +756,9 @@ export class SimApi { } : { state: 'inactive', active: false }, surplus_only: door.surplusOnly, - schedule: this.#schedule ?? { soc: 0, time_of_day_min_utc: 0, recurring: false }, + schedule: this.#schedule + ? { ...this.#schedule, time_of_day_min_utc: this.#utcMinute(this.#schedule['time_of_day_min'] as number) } + : { soc: 0, time_of_day_min_utc: 0, recurring: false }, }, ], }) @@ -784,21 +786,42 @@ export class SimApi { if (typeof parsed !== 'object') return json(400, { error: 'invalid schedule' }) const s = parsed as Record - const min = s['time_of_day_min_utc'] + const min = s['time_of_day_min_utc'] ?? 0 if (typeof min !== 'number' || min < 0 || min >= 1440) { return json(400, { error: 'time_of_day_min_utc must be 0..1439' }) } + // The household clock wins; an older client sends only the UTC minute. + const clock = s['time_of_day_min'] ?? this.#clock(min) + if (typeof clock !== 'number' || clock < 0 || clock >= 1440) { + return json(400, { error: 'time_of_day_min must be 0..1439' }) + } const days = s['days'] if (days !== undefined && (typeof days !== 'number' || days < 0 || days > 127)) { return json(400, { error: 'days must be a 7-bit weekday mask (0..127, bit 0 = Monday)' }) } const soc = typeof s['soc'] === 'number' ? s['soc'] : typeof s['soc_pct'] === 'number' ? s['soc_pct'] / 100 : 0 if (soc < 0 || soc > 1) return json(400, { error: 'soc must be between 0 and 1' }) - const { soc_pct: _legacy, ...fields } = s - this.#schedule = { ...fields, soc } + const { soc_pct: _legacy, time_of_day_min_utc: _utc, ...fields } = s + this.#schedule = { ...fields, soc, time_of_day_min: clock } return json(200, { ok: true }) } + /** + * The household clock and its UTC minute today, as the box converts + * them. The sim's house keeps this browser's zone. + */ + #clock(minUtc: number): number { + const d = new Date(this.#opts.now()) + d.setUTCHours(Math.floor(minUtc / 60), minUtc % 60, 0, 0) + return d.getHours() * 60 + d.getMinutes() + } + + #utcMinute(clock: number): number { + const d = new Date(this.#opts.now()) + d.setHours(Math.floor(clock / 60), clock % 60, 0, 0) + return d.getUTCHours() * 60 + d.getUTCMinutes() + } + #clearSchedule(id: string): ApiAnswer { if (id !== 'carport') return json(404, { error: 'loadpoint not found' }) this.#schedule = null diff --git a/src/views/EvPanel.svelte b/src/views/EvPanel.svelte index 541ceb2..74df94e 100644 --- a/src/views/EvPanel.svelte +++ b/src/views/EvPanel.svelte @@ -24,7 +24,8 @@ evPlanSentence, evScheduleSentence, evSessionSentence, - utcMinutesToLocalInput, + readyInput, + inputMinutes, localInputToUtcMinutes, chargeCurrent, wattsToAmps, @@ -297,7 +298,7 @@ const wireDays = lp.schedule?.days ?? 0 draft = { lpId: lp.id, - time: lp.schedule ? utcMinutesToLocalInput(lp.schedule.timeOfDayMinUtc) : '07:00', + time: lp.schedule ? readyInput(lp.schedule) : '07:00', days: wireDays === 0 ? 0x7f : wireDays & 0x7f, socPct: Math.round(lp.schedule?.socPct ?? (lp.targetSocPct != null && lp.targetSocPct >= 10 ? lp.targetSocPct : 80)), recurring: lp.schedule?.recurring ?? false, @@ -341,11 +342,11 @@ if (!draft || saving) return const next = { ...draft } const revision = scheduleRevision - const minUtc = localInputToUtcMinutes(next.time) + const min = inputMinutes(next.time) pendingSchedule = false if (!Number.isFinite(next.surplusUnlockPct) || next.surplusUnlockPct < 0 || next.surplusUnlockPct > 100) { saveError = 'Choose a home battery level from 1 to 100 %.'; return } - if (minUtc === null || (next.recurring && next.days === 0) || !Number.isFinite(next.socPct) || next.socPct < 10 || next.socPct > 100) { - saveError = minUtc === null ? 'Choose a valid ready time.' : next.recurring && next.days === 0 ? 'Choose at least one day.' : 'Choose a charge target from 10 to 100 %.' + if (min === null || (next.recurring && next.days === 0) || !Number.isFinite(next.socPct) || next.socPct < 10 || next.socPct > 100) { + saveError = min === null ? 'Choose a valid ready time.' : next.recurring && next.days === 0 ? 'Choose at least one day.' : 'Choose a charge target from 10 to 100 %.' return } saving = true @@ -356,7 +357,10 @@ path: `/api/loadpoints/${encodeURIComponent(next.lpId)}/schedule`, body: { soc: next.socPct / 100, - time_of_day_min_utc: minUtc, + // The household clock. An older box reads only the UTC minute, + // worked out in this phone's zone. + time_of_day_min: min, + time_of_day_min_utc: localInputToUtcMinutes(next.time), recurring: next.recurring, days: !next.recurring || next.days === 0x7f ? 0 : next.days & 0x7f, surplus_unlock_bat_soc: next.surplusUnlockPct / 100, diff --git a/src/views/EvPanel.svelte.test.ts b/src/views/EvPanel.svelte.test.ts index f590809..21c4d3d 100644 --- a/src/views/EvPanel.svelte.test.ts +++ b/src/views/EvPanel.svelte.test.ts @@ -267,6 +267,8 @@ describe('the charger behind its bubble', () => { ) expect(saved, 'no schedule PUT reached the box').toBeDefined() const bodyOnWire = JSON.parse(new TextDecoder().decode(saved![0].body!)) + // The household clock, and the UTC minute an older box reads. + expect(bodyOnWire.time_of_day_min).toBe(8 * 60) expect(bodyOnWire.time_of_day_min_utc).toBe(localInputToUtcMinutes('08:00')) expect(bodyOnWire.days).toBe(0b0011111) expect(bodyOnWire.recurring).toBe(true) @@ -520,6 +522,7 @@ describe('the charger behind its bubble', () => { expect(writes).toHaveLength(1) const selected = JSON.parse(new TextDecoder().decode(writes[0]![0].body!)) expect(selected.soc).toBe(expectedSoc) + expect(selected.time_of_day_min).toBe(7 * 60) expect(selected.time_of_day_min_utc).toBe(localInputToUtcMinutes('07:00')) expect(document.body.textContent).toContain('Schedule saved') })