Files
ihasmail/web/src/lib/dates.ts
T
jcoffey-dev c17887e48e ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
2026-08-23 01:07:13 -07:00

251 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const DAY_MS = 86_400_000;
export function startOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
export function endOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
export function addDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
export function addMonths(d: Date, n: number): Date {
const x = new Date(d);
const day = x.getDate();
x.setDate(1);
x.setMonth(x.getMonth() + n);
const dim = daysInMonth(x.getFullYear(), x.getMonth());
x.setDate(Math.min(day, dim));
return x;
}
export function addMinutes(d: Date, n: number): Date {
return new Date(d.getTime() + n * 60_000);
}
export function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
export function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
export function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
}
/** weekStart: 0 = Sunday, 1 = Monday */
export function startOfWeek(d: Date, weekStart = 1): Date {
const x = startOfDay(d);
const diff = (x.getDay() - weekStart + 7) % 7;
return addDays(x, -diff);
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
export function isToday(d: Date): boolean {
return isSameDay(d, new Date());
}
/** 6x7 grid of dates covering the month view. */
export function monthGrid(anchor: Date, weekStart = 1): Date[] {
const first = startOfWeek(startOfMonth(anchor), weekStart);
const out: Date[] = [];
for (let i = 0; i < 42; i++) out.push(addDays(first, i));
return out;
}
export function weekDays(anchor: Date, weekStart = 1, count = 7): Date[] {
const first = startOfWeek(anchor, weekStart);
const out: Date[] = [];
for (let i = 0; i < count; i++) out.push(addDays(first, i));
return out;
}
function pad(n: number, w = 2): string {
return String(n).padStart(w, "0");
}
/** Format a Date's wall-clock (browser local) as JSCalendar LocalDateTime. */
export function toLocalDateTime(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function toLocalDateOnly(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** Date → "YYYY-MM-DDTHH:MM:SSZ" (JMAP UTCDate, no millis). */
export function toUTCDate(d: Date): string {
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function parseLocalDateTime(s: string): { y: number; mo: number; d: number; h: number; mi: number; se: number } | null {
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(s);
if (!m) return null;
return { y: +m[1]!, mo: +m[2]! - 1, d: +m[3]!, h: +(m[4] ?? 0), mi: +(m[5] ?? 0), se: +(m[6] ?? 0) };
}
const dtfCache = new Map<string, Intl.DateTimeFormat>();
function dtf(tz: string): Intl.DateTimeFormat | null {
let f = dtfCache.get(tz);
if (f) return f;
try {
f = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
dtfCache.set(tz, f);
return f;
} catch {
return null;
}
}
/** Offset (ms) of timezone `tz` at instant `date`. */
export function tzOffsetMs(date: Date, tz: string): number {
const f = dtf(tz);
if (!f) return -date.getTimezoneOffset() * 60_000;
const parts = f.formatToParts(date);
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? "0");
const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
return asUTC - Math.floor(date.getTime() / 1000) * 1000;
}
/** Interpret a JSCalendar LocalDateTime in timezone `tz` (or browser local if null) as an instant. */
export function zonedToDate(local: string, tz: string | null | undefined): Date {
const p = parseLocalDateTime(local);
if (!p) return new Date(NaN);
if (!tz) {
return new Date(p.y, p.mo, p.d, p.h, p.mi, p.se);
}
const asUTC = Date.UTC(p.y, p.mo, p.d, p.h, p.mi, p.se);
// Two-pass offset resolution handles DST edges reasonably.
let off = tzOffsetMs(new Date(asUTC), tz);
off = tzOffsetMs(new Date(asUTC - off), tz);
return new Date(asUTC - off);
}
/** Format an instant as LocalDateTime in timezone `tz` (browser local if null). */
export function dateToZonedLocal(d: Date, tz: string | null | undefined): string {
if (!tz) return toLocalDateTime(d);
const f = dtf(tz);
if (!f) return toLocalDateTime(d);
const parts = f.formatToParts(d);
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "00";
return `${get("year")}-${get("month")}-${get("day")}T${String(Number(get("hour")) % 24).padStart(2, "0")}:${get("minute")}:${get("second")}`;
}
/** Parse ISO 8601 duration (e.g. "P1DT2H30M") into seconds. */
export function parseDuration(dur: string | null | undefined): number {
if (!dur) return 0;
const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(dur);
if (!m) return 0;
const sign = m[1] === "-" ? -1 : 1;
const w = Number(m[2] ?? 0), d = Number(m[3] ?? 0), h = Number(m[4] ?? 0), mi = Number(m[5] ?? 0), s = Number(m[6] ?? 0);
return sign * (w * 7 * 86400 + d * 86400 + h * 3600 + mi * 60 + s);
}
export function formatDuration(seconds: number): string {
const neg = seconds < 0;
let s = Math.abs(Math.round(seconds));
const d = Math.floor(s / 86400);
s -= d * 86400;
const h = Math.floor(s / 3600);
s -= h * 3600;
const m = Math.floor(s / 60);
s -= m * 60;
let out = "P";
if (d) out += `${d}D`;
if (h || m || s) {
out += "T";
if (h) out += `${h}H`;
if (m) out += `${m}M`;
if (s) out += `${s}S`;
}
if (out === "P") out = "PT0S";
return (neg ? "-" : "") + out;
}
export function humanDuration(seconds: number): string {
const abs = Math.abs(seconds);
if (abs === 0) return "at time of event";
const parts: string[] = [];
const d = Math.floor(abs / 86400);
const h = Math.floor((abs % 86400) / 3600);
const m = Math.floor((abs % 3600) / 60);
if (d) parts.push(`${d} day${d === 1 ? "" : "s"}`);
if (h) parts.push(`${h} hour${h === 1 ? "" : "s"}`);
if (m) parts.push(`${m} minute${m === 1 ? "" : "s"}`);
return parts.join(" ") || `${abs} seconds`;
}
export const browserTimeZone = (() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
})();
export function listTimeZones(): string[] {
try {
const sv = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf;
if (sv) return sv("timeZone");
} catch {
/* ignore */
}
return ["UTC", "Europe/London", "Europe/Paris", "Europe/Berlin", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "Asia/Tokyo", "Asia/Kolkata", "Australia/Sydney"];
}
export function formatTimeRange(start: Date, end: Date, allDay: boolean): string {
if (allDay) {
const lastDay = new Date(end.getTime() - 1);
if (isSameDay(start, lastDay)) return start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
return `${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${lastDay.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
}
const t = (d: Date) => d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
if (isSameDay(start, end)) {
return `${start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })} · ${t(start)} ${t(end)}`;
}
return `${start.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} ${end.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`;
}
/** For <input type="datetime-local"> */
export function toInputDateTime(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function fromInputDateTime(s: string): Date {
const p = parseLocalDateTime(s);
if (!p) return new Date(NaN);
return new Date(p.y, p.mo, p.d, p.h, p.mi, 0);
}
export function roundToNext(d: Date, minutes: number): Date {
const x = new Date(d);
x.setSeconds(0, 0);
const m = x.getMinutes();
const r = Math.ceil(m / minutes) * minutes;
x.setMinutes(r);
return x;
}