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:
parent
c9a7e6f089
commit
7039d57a41
2
packages/db/migrations/0008_greedy_matthew_murdock.sql
Normal file
2
packages/db/migrations/0008_greedy_matthew_murdock.sql
Normal 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;
|
||||
1044
packages/db/migrations/meta/0008_snapshot.json
Normal file
1044
packages/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -57,6 +57,13 @@
|
||||
"when": 1778386591494,
|
||||
"tag": "0007_overconfident_menace",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1778395584234,
|
||||
"tag": "0008_greedy_matthew_murdock",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -87,6 +87,11 @@ export const reminders = pgTable("reminders", {
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
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(
|
||||
|
||||
50
packages/shared/src/delivery-window.test.ts
Normal file
50
packages/shared/src/delivery-window.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
36
packages/shared/src/delivery-window.ts
Normal file
36
packages/shared/src/delivery-window.ts
Normal 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();
|
||||
}
|
||||
@ -2,3 +2,4 @@ export * from "./rrule.js";
|
||||
export * from "./media-paths.js";
|
||||
export * from "./timezones.js";
|
||||
export * from "./whatsapp-media.js";
|
||||
export * from "./delivery-window.js";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user