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') })