diff --git a/src/notifier.ts b/src/notifier.ts index 641fd92..b3079dd 100644 --- a/src/notifier.ts +++ b/src/notifier.ts @@ -17,44 +17,45 @@ interface NotifierOpts { } export async function newNotifier({ cfg, aborter, channels }: NotifierOpts): Promise { - const nextDigestAfter = (after: Temporal.ZonedDateTime) => - nextWeeklyOccurrence(cfg.weeklyNotifyDay, cfg.weeklyNotifyHour, cfg.weeklyNotifyMinute, after); - let tokens = await authenticate(cfg); return { + // Runs a loop that checks for both single-notify events and weekly digests async start() { - let seen: Set | null = null; // null until the first successful fetch primes it + const now = () => Temporal.Now.zonedDateTimeISO(TIMEZONE); + const nextDigestAfter = (after: Temporal.ZonedDateTime) => nextWeeklyOccurrence(cfg, after); // Schedule the first digest strictly after startup so an occurrence that // already passed today doesn't fire immediately. - let nextDigest = nextDigestAfter(Temporal.Now.zonedDateTimeISO(TIMEZONE)); + let nextDigest = nextDigestAfter(now()); console.log(`next weekly digest at ${nextDigest.toString({ timeZoneName: "never" })}`); + let seen: Set | null = null; // null until the first successful fetch primes it + while (!aborter.signal.aborted) { try { - const result = await getUpcomingEvents(cfg, tokens); + const { events, tokens: newTokens } = await getUpcomingEvents(cfg, tokens); // Tokens can be refreshed inside the above function, so make sure we update them. - tokens = result.tokens; + tokens = newTokens; // The first successful fetch only primes the cache; reporting nothing // keeps every restart from re-announcing all upcoming events as new. const prevSeen = seen; - const newEvents = prevSeen ? result.events.filter((e) => !prevSeen.has(e.slug)) : []; - if (!seen) console.log(`primed event cache with ${result.events.length} events`); - seen = new Set(result.events.map((e) => e.slug)); + const newEvents = prevSeen ? events.filter((e) => !prevSeen.has(e.slug)) : []; + + if (!seen) console.log(`primed event cache with ${events.length} events`); + seen = new Set(events.map((e) => e.slug)); for (const event of newEvents) { await notifyAll(channels, (ch) => ch.notifyOne(event)); } - const now = Temporal.Now.zonedDateTimeISO(TIMEZONE); - if (Temporal.ZonedDateTime.compare(now, nextDigest) >= 0) { - nextDigest = nextDigestAfter(now); + if (Temporal.ZonedDateTime.compare(now(), nextDigest) >= 0) { + nextDigest = nextDigestAfter(now()); console.log( `sending weekly digest; next at ${nextDigest.toString({ timeZoneName: "never" })}`, ); - await notifyAll(channels, (ch) => ch.notifyMany(result.events)); + await notifyAll(channels, (ch) => ch.notifyMany(events)); } } catch (err) { console.error("fetching events failed:", err); diff --git a/src/schedule.test.ts b/src/schedule.test.ts index 8fa2027..4998b77 100644 --- a/src/schedule.test.ts +++ b/src/schedule.test.ts @@ -8,29 +8,31 @@ const zdt = (s: string) => Temporal.ZonedDateTime.from(`${s}[America/Chicago]`); // 2026-06-08 is a Monday; 2026-06-10 is a Wednesday. +const cfg = { weeklyNotifyDay: MONDAY, weeklyNotifyHour: 7, weeklyNotifyMinute: 30 }; + test("mid-week rolls forward to the next scheduled weekday", () => { - const next = nextWeeklyOccurrence(MONDAY, 7, 30, zdt("2026-06-10T12:00:00")); + const next = nextWeeklyOccurrence(cfg, zdt("2026-06-10T12:00:00")); assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); }); test("same day before the scheduled time fires today", () => { - const next = nextWeeklyOccurrence(MONDAY, 7, 30, zdt("2026-06-08T06:00:00")); + const next = nextWeeklyOccurrence(cfg, zdt("2026-06-08T06:00:00")); assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-08T07:30:00-05:00"); }); test("exactly at the scheduled time rolls to next week (strictly after)", () => { - const next = nextWeeklyOccurrence(MONDAY, 7, 30, zdt("2026-06-08T07:30:00")); + const next = nextWeeklyOccurrence(cfg, zdt("2026-06-08T07:30:00")); assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); }); test("same day after the scheduled time rolls to next week", () => { - const next = nextWeeklyOccurrence(MONDAY, 7, 30, zdt("2026-06-08T08:00:00")); + const next = nextWeeklyOccurrence(cfg, zdt("2026-06-08T08:00:00")); assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); }); test("crosses the spring-forward DST boundary at the right wall-clock time", () => { // DST begins 2026-03-08 in America/Chicago; the following Monday's 07:30 // should be in CDT (-05:00) while "after" is still in CST (-06:00). - const next = nextWeeklyOccurrence(MONDAY, 7, 30, zdt("2026-03-06T12:00:00")); + const next = nextWeeklyOccurrence(cfg, zdt("2026-03-06T12:00:00")); assert.equal(next.toString({ timeZoneName: "never" }), "2026-03-09T07:30:00-05:00"); }); diff --git a/src/schedule.ts b/src/schedule.ts index 057a4c3..fe71f66 100644 --- a/src/schedule.ts +++ b/src/schedule.ts @@ -1,16 +1,29 @@ // Weekly digest scheduling, timezone-aware via the built-in Temporal API. +import type { Config } from "./config.ts"; + export const TIMEZONE = "America/Chicago"; +type WeeklyDigestConfig = Pick< + Config, + "weeklyNotifyDay" | "weeklyNotifyHour" | "weeklyNotifyMinute" +>; + // Returns the next occurrence of the given ISO weekday (Monday=1 .. Sunday=7) // at hour:minute, strictly after `after`, in `after`'s timezone. export function nextWeeklyOccurrence( - day: number, - hour: number, - minute: number, + cfg: WeeklyDigestConfig, after: Temporal.ZonedDateTime, ): Temporal.ZonedDateTime { - let next = after.with({ hour, minute, second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 }); + const { weeklyNotifyDay: day, weeklyNotifyMinute: minute, weeklyNotifyHour: hour } = cfg; + let next = after.with({ + hour, + minute, + second: 0, + millisecond: 0, + microsecond: 0, + nanosecond: 0, + }); while (next.dayOfWeek !== day || Temporal.ZonedDateTime.compare(next, after) <= 0) { next = next.add({ days: 1 }); }