The panel snapshot now carries the admin-saved `timezone` ("" when unset);
the coordinator threads it onto the household due-today sensor, and the
Party Board and Quest Log cards format their kicker, header and dock
date and clock in it, falling back to Home Assistant's zone only when
none is stored. The admin app loads the stored zone once
(AppTimezoneProvider) and uses it for completion clocks, History day
grouping, clocks and lateness, and "today" in Schedule, History and new
task defaults; Settings publishes a saved zone app-wide at once.
Resolves review finding TZ-001 (task 7e173881).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
57 lines
2.1 KiB
TypeScript
57 lines
2.1 KiB
TypeScript
/**
|
||
* Date/time formatting for the Today tab.
|
||
*
|
||
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
|
||
* in UTC, so the printed day never shifts with the viewer's offset — unlike
|
||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
||
* day in negative-offset zones. Instants are shown in the application
|
||
* timezone (time/zone.ts).
|
||
*/
|
||
import { zoneOption } from "../time/zone";
|
||
|
||
const LOCALE = "en-US";
|
||
|
||
function calendarDate(iso: string): Date {
|
||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
|
||
const [, year, month, day] = match;
|
||
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
||
}
|
||
|
||
function format(date: Date, options: Intl.DateTimeFormatOptions): string {
|
||
// Newer ICU puts a narrow no-break space before AM/PM; normalise it.
|
||
return new Intl.DateTimeFormat(LOCALE, options)
|
||
.format(date)
|
||
.replace(/ /g, " ");
|
||
}
|
||
|
||
/** "Wednesday, September 23" */
|
||
export function formatLongDate(iso: string): string {
|
||
const date = calendarDate(iso);
|
||
const weekday = format(date, { weekday: "long", timeZone: "UTC" });
|
||
const monthDay = format(date, { month: "long", day: "numeric", timeZone: "UTC" });
|
||
return `${weekday}, ${monthDay}`;
|
||
}
|
||
|
||
/** "Sep 23" */
|
||
export function formatShortDate(iso: string): string {
|
||
return format(calendarDate(iso), { month: "short", day: "numeric", timeZone: "UTC" });
|
||
}
|
||
|
||
/** "09:00" -> "9:00 AM" (a wall-clock time, no zone conversion). */
|
||
export function formatDueTime(hhmm: string): string {
|
||
const match = /^(\d{2}):(\d{2})/.exec(hhmm);
|
||
if (!match) return hhmm;
|
||
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])));
|
||
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
|
||
}
|
||
|
||
/** A UTC ISO timestamp shown as wall-clock time in `timeZone` ("" = browser), "7:04 AM". */
|
||
export function formatClockTime(isoTimestamp: string, timeZone: string): string {
|
||
return format(new Date(isoTimestamp), {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
timeZone: zoneOption(timeZone),
|
||
});
|
||
}
|