/opt/mawid/apps/api/src/agent
Edit: /opt/mawid/apps/api/src/agent/tz.ts (2882B)
/**
* Timezone helpers built on Intl — no extra dependency (PROJECT_PLAN §7.7).
* Discipline: DB stores UTC; conversion happens only at these edges (§7.5).
*/
export interface WallTime {
year: number;
month: number; // 1-12
day: number;
hour: number;
minute: number;
weekday: WeekdayKey;
}
export type WeekdayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
const WEEKDAY_MAP: Record
= {
Mon: 'mon',
Tue: 'tue',
Wed: 'wed',
Thu: 'thu',
Fri: 'fri',
Sat: 'sat',
Sun: 'sun',
};
const dtfCache = new Map();
function formatter(timeZone: string): Intl.DateTimeFormat {
let dtf = dtfCache.get(timeZone);
if (!dtf) {
dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
weekday: 'short',
hour12: false,
});
dtfCache.set(timeZone, dtf);
}
return dtf;
}
/** Offset of `timeZone` from UTC at the given instant, in milliseconds. */
export function tzOffsetMs(timeZone: string, utc: Date): number {
const parts = Object.fromEntries(
formatter(timeZone)
.formatToParts(utc)
.map((p) => [p.type, p.value]),
);
const asUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
parts.hour === '24' ? 0 : Number(parts.hour),
Number(parts.minute),
Number(parts.second),
);
return asUtc - Math.floor(utc.getTime() / 1000) * 1000;
}
/** Convert a clinic-local wall time to the UTC instant. Two-pass handles DST. */
export function wallTimeToUtc(
timeZone: string,
year: number,
month: number,
day: number,
hour: number,
minute: number,
): Date {
const guess = Date.UTC(year, month - 1, day, hour, minute);
const first = guess - tzOffsetMs(timeZone, new Date(guess));
const second = guess - tzOffsetMs(timeZone, new Date(first));
return new Date(second);
}
/** Local wall-clock parts of a UTC instant in the given timezone. */
export function utcToWallTime(timeZone: string, utc: Date): WallTime {
const parts = Object.fromEntries(
formatter(timeZone)
.formatToParts(utc)
.map((p) => [p.type, p.value]),
);
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour: parts.hour === '24' ? 0 : Number(parts.hour),
minute: Number(parts.minute),
weekday: WEEKDAY_MAP[parts.weekday],
};
}
/** Human label for patient-facing slot lists, in the clinic's local time. */
export function formatLocal(timeZone: string, utc: Date, locale = 'en-GB'): string {
return new Intl.DateTimeFormat(locale, {
timeZone,
weekday: 'short',
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(utc);
}