Make scheduling and dates timezone-safe
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user