Add scheduled invoice delivery

This commit is contained in:
2026-08-17 13:54:45 -04:00
parent 29589c1f32
commit 67ab6b78bd
26 changed files with 2099 additions and 835 deletions
+2 -1
View File
@@ -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",
+1
View File
@@ -2,3 +2,4 @@ export * from "./expense-categories";
export * from "./invoice-status";
export * from "./receipt-parse";
export * from "./time-clock";
export * from "./time-zone";
+44
View File
@@ -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())}`;
}
+28
View File
@@ -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");
});
});