Make scheduling and dates timezone-safe

This commit is contained in:
2026-08-17 18:15:39 -04:00
parent 1853eaa963
commit 70c08054fb
63 changed files with 2515 additions and 779 deletions
+33 -13
View File
@@ -4,33 +4,48 @@ export type EffectiveInvoiceStatus = StoredInvoiceStatus | "overdue";
export function getEffectiveInvoiceStatus(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
timeZone = getLocalTimeZone(),
now = new Date(),
): EffectiveInvoiceStatus {
if (storedStatus === "paid" || storedStatus === "draft") return storedStatus;
const today = new Date();
const due = new Date(dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
return due < today ? "overdue" : "sent";
return calendarDateKey(dueDate) < zonedTodayKey(now, timeZone)
? "overdue"
: "sent";
}
export function isInvoiceOverdue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
timeZone = getLocalTimeZone(),
): boolean {
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
return (
getEffectiveInvoiceStatus(storedStatus, dueDate, timeZone) === "overdue"
);
}
export function getDaysPastDue(
storedStatus: StoredInvoiceStatus,
dueDate: Date | string,
timeZone = getLocalTimeZone(),
now = new Date(),
): number {
if (!isInvoiceOverdue(storedStatus, dueDate)) return 0;
const today = new Date();
const due = new Date(dueDate);
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
return Math.max(0, Math.ceil((today.getTime() - due.getTime()) / 86_400_000));
if (
getEffectiveInvoiceStatus(storedStatus, dueDate, timeZone, now) !==
"overdue"
)
return 0;
const dueKey = calendarDateKey(dueDate);
const todayKey = zonedTodayKey(now, timeZone);
return Math.max(
0,
Math.round((Date.parse(todayKey) - Date.parse(dueKey)) / 86_400_000),
);
}
function zonedTodayKey(now: Date, timeZone: string) {
const parts = getZonedDateTimeParts(now, timeZone);
const pad = (value: number) => String(value).padStart(2, "0");
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`;
}
export function getValidStatusTransitions(
@@ -52,3 +67,8 @@ export function isValidStatusTransition(
): boolean {
return getValidStatusTransitions(from).includes(to);
}
import {
calendarDateKey,
getLocalTimeZone,
getZonedDateTimeParts,
} from "./time-zone";
+245 -1
View File
@@ -1,4 +1,248 @@
const FALLBACK_TIME_ZONE = "UTC";
export const DEFAULT_TIME_ZONE = "America/New_York";
const FALLBACK_TIME_ZONE = DEFAULT_TIME_ZONE;
export type ZonedDateTimeDisambiguation = "earlier" | "later" | "reject";
type DateTimeParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
};
const WALL_TIME_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
function wallTimeFormatter(timeZone: string) {
let formatter = WALL_TIME_FORMATTERS.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
WALL_TIME_FORMATTERS.set(timeZone, formatter);
}
return formatter;
}
export function getZonedDateTimeParts(
value: Date | string | number,
timeZone: string,
): DateTimeParts {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) throw new RangeError("Invalid date");
if (!isValidTimeZone(timeZone)) throw new RangeError("Invalid time zone");
const parts = Object.fromEntries(
wallTimeFormatter(timeZone)
.formatToParts(date)
.filter((part) => part.type !== "literal")
.map((part) => [part.type, Number(part.value)]),
) as Record<string, number>;
return {
year: parts.year!,
month: parts.month!,
day: parts.day!,
hour: parts.hour!,
minute: parts.minute!,
second: parts.second!,
};
}
function sameWallTime(a: DateTimeParts, b: DateTimeParts) {
return (
a.year === b.year &&
a.month === b.month &&
a.day === b.day &&
a.hour === b.hour &&
a.minute === b.minute &&
a.second === b.second
);
}
function parseLocalDateTime(value: string): DateTimeParts {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(
value,
);
if (!match) throw new RangeError("Expected YYYY-MM-DDTHH:mm");
const parts = {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4]),
minute: Number(match[5]),
second: Number(match[6] ?? 0),
};
const check = new Date(
Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
),
);
if (
check.getUTCFullYear() !== parts.year ||
check.getUTCMonth() + 1 !== parts.month ||
check.getUTCDate() !== parts.day ||
parts.hour > 23 ||
parts.minute > 59 ||
parts.second > 59
) {
throw new RangeError("Invalid local date and time");
}
return parts;
}
export function zonedDateTimeToInstant(
localDateTime: string,
timeZone: string,
disambiguation: ZonedDateTimeDisambiguation = "reject",
): Date {
if (!isValidTimeZone(timeZone)) throw new RangeError("Invalid time zone");
const desired = parseLocalDateTime(localDateTime);
const wallAsUtc = Date.UTC(
desired.year,
desired.month - 1,
desired.day,
desired.hour,
desired.minute,
desired.second,
);
let candidateMs = wallAsUtc;
for (let attempt = 0; attempt < 3; attempt++) {
const observed = getZonedDateTimeParts(candidateMs, timeZone);
const observedAsUtc = Date.UTC(
observed.year,
observed.month - 1,
observed.day,
observed.hour,
observed.minute,
observed.second,
);
candidateMs += wallAsUtc - observedAsUtc;
}
const candidates = Array.from(
{ length: 25 },
(_, index) => candidateMs + (index - 12) * 15 * 60_000,
)
.filter((value, index, all) => all.indexOf(value) === index)
.filter((value) =>
sameWallTime(getZonedDateTimeParts(value, timeZone), desired),
)
.sort((a, b) => a - b);
if (candidates.length === 0)
throw new RangeError("That local time does not exist");
if (candidates.length > 1 && disambiguation === "reject") {
throw new RangeError(
"That local time occurs twice; choose earlier or later",
);
}
return new Date(
disambiguation === "later" ? candidates.at(-1)! : candidates[0]!,
);
}
export function toZonedDateTimeInputValue(
value: Date | string | number,
timeZone: string,
): string {
const parts = getZonedDateTimeParts(value, timeZone);
const pad = (part: number) => String(part).padStart(2, "0");
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}`;
}
export function addZonedCalendarInterval(
value: Date | string | number,
schedule: "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly",
timeZone: string,
): Date {
const source = getZonedDateTimeParts(value, timeZone);
const calendar = new Date(
Date.UTC(source.year, source.month - 1, source.day),
);
if (schedule === "weekly" || schedule === "biweekly") {
calendar.setUTCDate(
calendar.getUTCDate() + (schedule === "weekly" ? 7 : 14),
);
} else {
const months =
schedule === "monthly" ? 1 : schedule === "quarterly" ? 3 : 12;
const originalDay = calendar.getUTCDate();
calendar.setUTCDate(1);
calendar.setUTCMonth(calendar.getUTCMonth() + months);
const lastDay = new Date(
Date.UTC(calendar.getUTCFullYear(), calendar.getUTCMonth() + 1, 0),
).getUTCDate();
calendar.setUTCDate(Math.min(originalDay, lastDay));
}
const pad = (part: number) => String(part).padStart(2, "0");
return zonedDateTimeToInstant(
`${calendar.getUTCFullYear()}-${pad(calendar.getUTCMonth() + 1)}-${pad(calendar.getUTCDate())}T${pad(source.hour)}:${pad(source.minute)}:${pad(source.second)}`,
timeZone,
"earlier",
);
}
export function formatCalendarDate(
value: Date | string,
options: Intl.DateTimeFormatOptions = {},
): string {
const date =
value instanceof Date
? value
: new Date(`${value.slice(0, 10)}T12:00:00.000Z`);
if (Number.isNaN(date.getTime())) return "Invalid date";
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
timeZone: "UTC",
}).format(date);
}
export function calendarDateKey(value: Date | string): string {
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value))
return value.slice(0, 10);
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) throw new RangeError("Invalid date");
return date.toISOString().slice(0, 10);
}
export function calendarDateFromLocalDate(value: Date): Date {
return new Date(
Date.UTC(value.getFullYear(), value.getMonth(), value.getDate(), 12),
);
}
export function calendarDateToLocalDate(value: Date | string): Date {
const [year, month, day] = calendarDateKey(value).split("-").map(Number);
return new Date(year!, month! - 1, day!, 12);
}
export function calendarDateFromInstant(
value: Date | string | number,
timeZone: string,
): Date {
const parts = getZonedDateTimeParts(value, timeZone);
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day, 12));
}
export function addCalendarDays(value: Date | string, days: number): Date {
const [year, month, day] = calendarDateKey(value).split("-").map(Number);
return new Date(Date.UTC(year!, month! - 1, day! + days, 12));
}
export function isValidTimeZone(value: string): boolean {
if (!value.trim()) return false;
+77
View File
@@ -3,14 +3,17 @@
import { describe, expect, test } from "bun:test";
import {
formatZonedDateTime,
addZonedCalendarInterval,
getDefaultScheduledSendAt,
isValidTimeZone,
toLocalDateTimeInputValue,
zonedDateTimeToInstant,
} from "../src/time-zone";
import {
EXPENSE_CATEGORIES,
formatElapsedSeconds,
getEffectiveInvoiceStatus,
getDaysPastDue,
parseReceiptText,
} from "../src";
@@ -26,6 +29,17 @@ describe("shared domain behavior", () => {
expect(getEffectiveInvoiceStatus("paid", yesterday)).toBe("paid");
});
test("counts calendar days rather than 24-hour blocks across fall DST", () => {
expect(
getDaysPastDue(
"sent",
"2026-11-01",
"America/New_York",
new Date("2026-11-02T17:00:00.000Z"),
),
).toBe(1);
});
test("formats elapsed time", () => {
expect(formatElapsedSeconds(3_661)).toBe("01:01:01");
});
@@ -56,6 +70,69 @@ describe("time-zone helpers", () => {
expect(isValidTimeZone("not/a-zone")).toBe(false);
});
test("converts Eastern wall time to the correct absolute instant", () => {
expect(
zonedDateTimeToInstant(
"2026-08-17T09:00",
"America/New_York",
).toISOString(),
).toBe("2026-08-17T13:00:00.000Z");
});
test("rejects nonexistent spring-forward wall times", () => {
expect(() =>
zonedDateTimeToInstant("2026-03-08T02:30", "America/New_York"),
).toThrow("does not exist");
});
test("disambiguates both occurrences of a fall-back wall time", () => {
expect(
zonedDateTimeToInstant(
"2026-11-01T01:30",
"America/New_York",
"earlier",
).toISOString(),
).toBe("2026-11-01T05:30:00.000Z");
expect(
zonedDateTimeToInstant(
"2026-11-01T01:30",
"America/New_York",
"later",
).toISOString(),
).toBe("2026-11-01T06:30:00.000Z");
});
test("supports half-hour DST transitions", () => {
const earlier = zonedDateTimeToInstant(
"2026-04-05T01:45",
"Australia/Lord_Howe",
"earlier",
);
const later = zonedDateTimeToInstant(
"2026-04-05T01:45",
"Australia/Lord_Howe",
"later",
);
expect(later.getTime() - earlier.getTime()).toBe(30 * 60_000);
});
test("preserves Eastern wall time across DST and clamps month end", () => {
expect(
addZonedCalendarInterval(
new Date("2026-03-01T14:00:00.000Z"),
"weekly",
"America/New_York",
).toISOString(),
).toBe("2026-03-08T13:00:00.000Z");
expect(
addZonedCalendarInterval(
new Date("2026-01-31T14:00:00.000Z"),
"monthly",
"America/New_York",
).toISOString(),
).toBe("2026-02-28T14:00:00.000Z");
});
test("rounds the default schedule to the next local hour", () => {
const result = getDefaultScheduledSendAt(new Date(2026, 7, 17, 10, 42, 19));
expect(toLocalDateTimeInputValue(result)).toBe("2026-08-17T11:00");