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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
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 "./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";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user