Add scheduled invoice delivery
This commit is contained in:
@@ -8,7 +8,8 @@
|
||||
"./expense-categories": "./src/expense-categories.ts",
|
||||
"./invoice-status": "./src/invoice-status.ts",
|
||||
"./receipt-parse": "./src/receipt-parse.ts",
|
||||
"./time-clock": "./src/time-clock.ts"
|
||||
"./time-clock": "./src/time-clock.ts",
|
||||
"./time-zone": "./src/time-zone.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./expense-categories";
|
||||
export * from "./invoice-status";
|
||||
export * from "./receipt-parse";
|
||||
export * from "./time-clock";
|
||||
export * from "./time-zone";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
const FALLBACK_TIME_ZONE = "UTC";
|
||||
|
||||
export function isValidTimeZone(value: string): boolean {
|
||||
if (!value.trim()) return false;
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalTimeZone(): string {
|
||||
const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return resolved && isValidTimeZone(resolved) ? resolved : FALLBACK_TIME_ZONE;
|
||||
}
|
||||
|
||||
export function formatZonedDateTime(
|
||||
value: Date | string | number,
|
||||
timeZone = getLocalTimeZone(),
|
||||
options: Intl.DateTimeFormatOptions = {},
|
||||
): string {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "Invalid date";
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
...options,
|
||||
timeZone: isValidTimeZone(timeZone) ? timeZone : FALLBACK_TIME_ZONE,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function getDefaultScheduledSendAt(now = new Date()): Date {
|
||||
const result = new Date(now);
|
||||
result.setMinutes(0, 0, 0);
|
||||
result.setHours(result.getHours() + 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function toLocalDateTimeInputValue(value: Date): string {
|
||||
const pad = (part: number) => String(part).padStart(2, "0");
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
formatZonedDateTime,
|
||||
getDefaultScheduledSendAt,
|
||||
isValidTimeZone,
|
||||
toLocalDateTimeInputValue,
|
||||
} from "../src/time-zone";
|
||||
import {
|
||||
EXPENSE_CATEGORIES,
|
||||
formatElapsedSeconds,
|
||||
@@ -33,3 +39,25 @@ describe("shared domain behavior", () => {
|
||||
expect(receipt.items[0]?.name).toBe("Coffee");
|
||||
});
|
||||
});
|
||||
|
||||
describe("time-zone helpers", () => {
|
||||
test("formats the same instant in the selected IANA time zone", () => {
|
||||
const instant = new Date("2026-08-17T16:30:00.000Z");
|
||||
expect(formatZonedDateTime(instant, "America/New_York")).toContain(
|
||||
"12:30 PM",
|
||||
);
|
||||
expect(formatZonedDateTime(instant, "America/Los_Angeles")).toContain(
|
||||
"9:30 AM",
|
||||
);
|
||||
});
|
||||
|
||||
test("validates IANA time zones", () => {
|
||||
expect(isValidTimeZone("America/New_York")).toBe(true);
|
||||
expect(isValidTimeZone("not/a-zone")).toBe(false);
|
||||
});
|
||||
|
||||
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