feat(db,shared): delivery window columns + windowEndAt helper

Adds two integer columns to the reminders table:
* delivery_window_start_hour (default 6)
* delivery_window_end_hour   (default 18)

Both are documented in the operator's timezone. End hour will gate
the runtime fire-reminder loop in a later phase; this commit just
lands the data model and the pure window-end calculator.

windowEndAt(timezone, endHour, fireAt) lives in @cmbot/shared so
both bot (window enforcement) and web (ETA preview) can import it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 14:48:36 +08:00
parent c9a7e6f089
commit 7039d57a41
7 changed files with 1145 additions and 0 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE "reminders" ADD COLUMN "delivery_window_start_hour" integer DEFAULT 6 NOT NULL;--> statement-breakpoint
ALTER TABLE "reminders" ADD COLUMN "delivery_window_end_hour" integer DEFAULT 18 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,13 @@
"when": 1778386591494, "when": 1778386591494,
"tag": "0007_overconfident_menace", "tag": "0007_overconfident_menace",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1778395584234,
"tag": "0008_greedy_matthew_murdock",
"breakpoints": true
} }
] ]
} }

View File

@ -87,6 +87,11 @@ export const reminders = pgTable("reminders", {
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
lastFiredAt: timestamp("last_fired_at", { withTimezone: true }), lastFiredAt: timestamp("last_fired_at", { withTimezone: true }),
// Delivery window (operator timezone). End hour is enforced at runtime
// by fire-reminder when window enforcement lands; start hour is documented
// here but not gated in v1.
deliveryWindowStartHour: integer("delivery_window_start_hour").notNull().default(6),
deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(18),
}); });
export const reminderTargets = pgTable( export const reminderTargets = pgTable(

View File

@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { windowEndAt } from "./delivery-window.js";
const TZ = "Asia/Kuala_Lumpur"; // UTC+8 (no DST)
describe("windowEndAt", () => {
it("returns today's end-hour boundary in the given timezone", () => {
// Fire at 2026-05-10 10:00 KL == 02:00 UTC. End hour 18 == 18:00 KL == 10:00 UTC.
const fireAt = new Date("2026-05-10T02:00:00.000Z");
const out = windowEndAt(TZ, 18, fireAt);
expect(out.toISOString()).toBe("2026-05-10T10:00:00.000Z");
});
it("returns a past timestamp when fireAt is already after the end hour", () => {
// Fire at 2026-05-10 19:00 KL == 11:00 UTC. End hour 18 → today's 18:00 KL == 10:00 UTC.
const fireAt = new Date("2026-05-10T11:00:00.000Z");
const out = windowEndAt(TZ, 18, fireAt);
expect(out.toISOString()).toBe("2026-05-10T10:00:00.000Z");
expect(out.getTime()).toBeLessThan(fireAt.getTime());
});
it("respects the timezone (UTC vs UTC+8)", () => {
const fireAt = new Date("2026-05-10T02:00:00.000Z");
const inUtc = windowEndAt("UTC", 18, fireAt);
expect(inUtc.toISOString()).toBe("2026-05-10T18:00:00.000Z");
const inKl = windowEndAt("Asia/Kuala_Lumpur", 18, fireAt);
expect(inKl.toISOString()).toBe("2026-05-10T10:00:00.000Z");
});
it("handles end hour 24 as midnight at the calendar day boundary", () => {
// 2026-05-10 in KL ends at 2026-05-11 00:00 KL == 2026-05-10 16:00 UTC.
const fireAt = new Date("2026-05-10T02:00:00.000Z");
const out = windowEndAt(TZ, 24, fireAt);
expect(out.toISOString()).toBe("2026-05-10T16:00:00.000Z");
});
it("DST transition day stays on the same calendar day", () => {
// US/Eastern starts DST on 2026-03-08; 18:00 EDT is real time.
// Fire at 2026-03-08 10:00 EST (15:00 UTC). End at 2026-03-08 18:00 EDT (22:00 UTC).
const fireAt = new Date("2026-03-08T15:00:00.000Z");
const out = windowEndAt("America/New_York", 18, fireAt);
expect(out.toISOString()).toBe("2026-03-08T22:00:00.000Z");
});
it("rejects end hour outside 0..24", () => {
const fireAt = new Date("2026-05-10T00:00:00Z");
expect(() => windowEndAt(TZ, -1, fireAt)).toThrow();
expect(() => windowEndAt(TZ, 25, fireAt)).toThrow();
});
});

View File

@ -0,0 +1,36 @@
import { DateTime } from "luxon";
/**
* Returns the end-of-window timestamp for the calendar day `fireAt`
* falls on, in the operator's timezone.
*
* windowEndAt("Asia/Kuala_Lumpur", 18, fireAt)
* today's 18:00 KL (which may be in the past if fireAt is already
* past 18:00 KL caller's first window-gate fires immediately).
*
* `endHour` is 0..24. Hour 24 is treated as midnight of the next
* calendar day (i.e. "end of today" inclusive).
*
* Pure: no I/O, no Date.now() reads, no clock dependency. Easy to
* test with fixture inputs.
*/
export function windowEndAt(
timezone: string,
endHour: number,
fireAt: Date,
): Date {
if (!Number.isInteger(endHour) || endHour < 0 || endHour > 24) {
throw new Error(`windowEndAt: endHour must be 0..24, got ${endHour}`);
}
const dt = DateTime.fromJSDate(fireAt).setZone(timezone);
if (!dt.isValid) {
throw new Error(`windowEndAt: invalid timezone "${timezone}"`);
}
// For hour 24, "end of day" is the next midnight. Luxon's `set` with
// hour=24 normalises into hour=0 of the next day, which is exactly
// what we want.
const end = dt.set({ hour: endHour, minute: 0, second: 0, millisecond: 0 });
return end.toJSDate();
}

View File

@ -2,3 +2,4 @@ export * from "./rrule.js";
export * from "./media-paths.js"; export * from "./media-paths.js";
export * from "./timezones.js"; export * from "./timezones.js";
export * from "./whatsapp-media.js"; export * from "./whatsapp-media.js";
export * from "./delivery-window.js";