From bc713ede4df015999302209a5ee7d27bbaf63a0f Mon Sep 17 00:00:00 2001 From: Steve Freeman Date: Fri, 5 Jun 2026 14:25:27 -0400 Subject: [PATCH 1/5] Updated alerts to be cleaner --- .../backend/src/jobs/event-alert.job.spec.ts | 48 ++++++++++++ packages/backend/src/jobs/event-alert.job.ts | 77 +++++++++++++++---- 2 files changed, 109 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/jobs/event-alert.job.spec.ts b/packages/backend/src/jobs/event-alert.job.spec.ts index 975514cd..fe4cf478 100644 --- a/packages/backend/src/jobs/event-alert.job.spec.ts +++ b/packages/backend/src/jobs/event-alert.job.spec.ts @@ -59,6 +59,7 @@ describe('EventAlertJob', () => { await job.run(new Date('2026-04-21T12:00:00.000Z')); expect(sendMessageMock).toHaveBeenCalledOnce(); + expect(sendMessageMock).toHaveBeenCalledWith('#events', expect.stringContaining(':sunny: Happening today:\n')); expect(sendMessageMock).toHaveBeenCalledWith('#events', expect.stringContaining('Planning 🚀 @ HQ')); // Times should use Slack's format so each user sees them in their own Slack timezone. // Unix seconds: start=1776787200 (2026-04-21T16:00Z), end=1776790800 (2026-04-21T17:00Z) @@ -77,6 +78,53 @@ describe('EventAlertJob', () => { expect(setValueWithExpireMock).toHaveBeenCalledOnce(); }); + it('splits today events from later upcoming events in the same 24 hour window', async () => { + listUpcomingOccurrencesMock.mockResolvedValue([ + { + teamId: 'T123', + occurrences: [ + { + occurrenceId: '1:2026-04-21T16:00:00.000Z', + seriesId: '1', + title: 'Today Planning', + location: null, + startsAt: '2026-04-21T16:00:00.000Z', + endsAt: '2026-04-21T17:00:00.000Z', + isAllDay: false, + isRecurring: false, + }, + { + occurrenceId: '2:2026-04-22T06:00:00.000Z', + seriesId: '2', + title: 'Tomorrow Standup', + location: 'Zoom', + startsAt: '2026-04-22T06:00:00.000Z', + endsAt: '2026-04-22T06:30:00.000Z', + isAllDay: false, + isRecurring: false, + }, + ], + }, + ]); + + const job = new EventAlertJob(); + await job.run(new Date('2026-04-21T12:00:00.000Z')); + + expect(sendMessageMock).toHaveBeenCalledTimes(2); + expect(sendMessageMock).toHaveBeenNthCalledWith( + 1, + '#events', + expect.stringContaining(':hourglass_flowing_sand: Upcoming in the next 24 hours:\n- '), + ); + expect(sendMessageMock).toHaveBeenNthCalledWith( + 2, + '#events', + expect.stringContaining(':sunny: Happening today:\n- '), + ); + expect(sendMessageMock).toHaveBeenNthCalledWith(1, '#events', expect.stringContaining('Tomorrow Standup @ Zoom')); + expect(sendMessageMock).toHaveBeenNthCalledWith(2, '#events', expect.stringContaining('Today Planning')); + }); + it('formats all-day multi-day events as date ranges', async () => { listUpcomingOccurrencesMock.mockResolvedValue([ { diff --git a/packages/backend/src/jobs/event-alert.job.ts b/packages/backend/src/jobs/event-alert.job.ts index d35bece3..c425954b 100644 --- a/packages/backend/src/jobs/event-alert.job.ts +++ b/packages/backend/src/jobs/event-alert.job.ts @@ -3,6 +3,7 @@ import { logger } from '../shared/logger/logger'; import { logError } from '../shared/logger/error-logging'; import { RedisPersistenceService } from '../shared/services/redis.persistence.service'; import { WebService } from '../shared/services/web/web.service'; +import type { CalendarEventOccurrence } from '../calendar/calendar.model'; const ALERT_CHANNEL = process.env.EVENTS_ALERT_CHANNEL ?? '#events'; const ALERT_LOOKAHEAD_MS = 24 * 60 * 60 * 1000; @@ -28,6 +29,23 @@ const subtractOneDayUtc = (value: Date): Date => { return next; }; +const startOfUtcDay = (value: Date): Date => + new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); + +const addOneDayUtc = (value: Date): Date => { + const next = new Date(value); + next.setUTCDate(next.getUTCDate() + 1); + return next; +}; + +const occursTodayUtc = (occurrence: CalendarEventOccurrence, now: Date): boolean => { + const dayStart = startOfUtcDay(now); + const nextDayStart = addOneDayUtc(dayStart); + const start = new Date(occurrence.startsAt); + const end = new Date(occurrence.endsAt); + return end > dayStart && start < nextDayStart; +}; + const formatOccurrenceWindow = (startsAt: string, endsAt: string, isAllDay: boolean): string => { if (isAllDay) { // All-day events are stored as midnight UTC boundaries; use plain UTC date labels. @@ -58,6 +76,28 @@ const formatOccurrenceWindow = (startsAt: string, endsAt: string, isAllDay: bool return `${slackTimestamp(start, '{date_short} at {time}', startFallback)} - ${slackTimestamp(end, '{date_short} at {time}', endFallback)}`; }; +const formatOccurrenceLine = (occurrence: CalendarEventOccurrence): string => { + const locationSuffix = occurrence.location ? ` @ ${occurrence.location}` : ''; + const occurrenceWindow = formatOccurrenceWindow(occurrence.startsAt, occurrence.endsAt, occurrence.isAllDay); + return `- ${occurrenceWindow} - ${occurrence.title}${locationSuffix}`; +}; + +const formatAlertSection = (title: string, occurrences: CalendarEventOccurrence[]): string | null => { + if (!occurrences.length) { + return null; + } + + return `${title}:\n${occurrences.map(formatOccurrenceLine).join('\n')}`; +}; + +const formatAlertMessage = (emoji: string, title: string, occurrences: CalendarEventOccurrence[]): string | null => { + const section = formatAlertSection(title, occurrences); + if (!section) { + return null; + } + return `${emoji} ${section}`; +}; + export class EventAlertJob { private calendarPersistenceService = new CalendarPersistenceService(); private webService = new WebService(); @@ -104,24 +144,29 @@ export class EventAlertJob { return; } - const lines = unsentOccurrences - .slice(0, 20) - .map((occurrence) => { - const locationSuffix = occurrence.location ? ` @ ${occurrence.location}` : ''; - const occurrenceWindow = formatOccurrenceWindow( - occurrence.startsAt, - occurrence.endsAt, - occurrence.isAllDay, - ); - return `- ${occurrenceWindow} - ${occurrence.title}${locationSuffix}`; - }) - .join('\n'); - - const overflowCount = unsentOccurrences.length - Math.min(20, unsentOccurrences.length); + const displayedOccurrences = unsentOccurrences.slice(0, 20); + const todayOccurrences = displayedOccurrences.filter((occurrence) => occursTodayUtc(occurrence, now)); + const upcomingOccurrencesLater = displayedOccurrences.filter( + (occurrence) => !occursTodayUtc(occurrence, now), + ); + + const overflowCount = unsentOccurrences.length - displayedOccurrences.length; const overflowLine = overflowCount > 0 ? `\n...and ${overflowCount} more event(s).` : ''; - const text = `:calendar: Upcoming events in the next 24 hours:\n${lines}${overflowLine}`; + const upcomingMessage = formatAlertMessage( + ':hourglass_flowing_sand:', + 'Upcoming in the next 24 hours', + upcomingOccurrencesLater, + ); + const todayMessage = formatAlertMessage(':sunny:', 'Happening today', todayOccurrences); - await this.webService.sendMessage(ALERT_CHANNEL, text); + if (upcomingMessage) { + await this.webService.sendMessage(ALERT_CHANNEL, `${upcomingMessage}${overflowLine}`); + } + + // Always send today alerts last when both buckets have events. + if (todayMessage) { + await this.webService.sendMessage(ALERT_CHANNEL, `${todayMessage}${overflowLine}`); + } await Promise.all( unsentOccurrences.map((occurrence) => From 90e946012768b3f3b6aae02a9da7a788fb62212c Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 8 Jun 2026 21:48:19 -0400 Subject: [PATCH 2/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/backend/src/jobs/event-alert.job.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/jobs/event-alert.job.ts b/packages/backend/src/jobs/event-alert.job.ts index c425954b..b082d069 100644 --- a/packages/backend/src/jobs/event-alert.job.ts +++ b/packages/backend/src/jobs/event-alert.job.ts @@ -159,13 +159,12 @@ export class EventAlertJob { ); const todayMessage = formatAlertMessage(':sunny:', 'Happening today', todayOccurrences); - if (upcomingMessage) { - await this.webService.sendMessage(ALERT_CHANNEL, `${upcomingMessage}${overflowLine}`); - } - - // Always send today alerts last when both buckets have events. - if (todayMessage) { - await this.webService.sendMessage(ALERT_CHANNEL, `${todayMessage}${overflowLine}`); + const messages = [upcomingMessage, todayMessage].filter( + (message): message is string => Boolean(message), + ); + for (let index = 0; index < messages.length; index += 1) { + const suffix = index === messages.length - 1 ? overflowLine : ''; + await this.webService.sendMessage(ALERT_CHANNEL, `${messages[index]}${suffix}`); } await Promise.all( From 4141cd836583886ffa4bf6ad8bb6bc42837e1793 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:51:51 +0000 Subject: [PATCH 3/5] Combine event alert sections into single Slack send --- packages/backend/src/jobs/event-alert.job.spec.ts | 15 +++++---------- packages/backend/src/jobs/event-alert.job.ts | 10 ++++------ 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/jobs/event-alert.job.spec.ts b/packages/backend/src/jobs/event-alert.job.spec.ts index fe4cf478..b1cd81ec 100644 --- a/packages/backend/src/jobs/event-alert.job.spec.ts +++ b/packages/backend/src/jobs/event-alert.job.spec.ts @@ -110,19 +110,14 @@ describe('EventAlertJob', () => { const job = new EventAlertJob(); await job.run(new Date('2026-04-21T12:00:00.000Z')); - expect(sendMessageMock).toHaveBeenCalledTimes(2); - expect(sendMessageMock).toHaveBeenNthCalledWith( - 1, + expect(sendMessageMock).toHaveBeenCalledOnce(); + expect(sendMessageMock).toHaveBeenCalledWith( '#events', expect.stringContaining(':hourglass_flowing_sand: Upcoming in the next 24 hours:\n- '), ); - expect(sendMessageMock).toHaveBeenNthCalledWith( - 2, - '#events', - expect.stringContaining(':sunny: Happening today:\n- '), - ); - expect(sendMessageMock).toHaveBeenNthCalledWith(1, '#events', expect.stringContaining('Tomorrow Standup @ Zoom')); - expect(sendMessageMock).toHaveBeenNthCalledWith(2, '#events', expect.stringContaining('Today Planning')); + expect(sendMessageMock).toHaveBeenCalledWith('#events', expect.stringContaining('Tomorrow Standup @ Zoom')); + expect(sendMessageMock).toHaveBeenCalledWith('#events', expect.stringContaining(':sunny: Happening today:\n- ')); + expect(sendMessageMock).toHaveBeenCalledWith('#events', expect.stringContaining('Today Planning')); }); it('formats all-day multi-day events as date ranges', async () => { diff --git a/packages/backend/src/jobs/event-alert.job.ts b/packages/backend/src/jobs/event-alert.job.ts index b082d069..5bc7ba85 100644 --- a/packages/backend/src/jobs/event-alert.job.ts +++ b/packages/backend/src/jobs/event-alert.job.ts @@ -159,13 +159,11 @@ export class EventAlertJob { ); const todayMessage = formatAlertMessage(':sunny:', 'Happening today', todayOccurrences); - const messages = [upcomingMessage, todayMessage].filter( - (message): message is string => Boolean(message), - ); - for (let index = 0; index < messages.length; index += 1) { - const suffix = index === messages.length - 1 ? overflowLine : ''; - await this.webService.sendMessage(ALERT_CHANNEL, `${messages[index]}${suffix}`); + const messages = [upcomingMessage, todayMessage].filter((message): message is string => Boolean(message)); + if (!messages.length) { + return; } + await this.webService.sendMessage(ALERT_CHANNEL, `${messages.join('\n\n')}${overflowLine}`); await Promise.all( unsentOccurrences.map((occurrence) => From 33b56bcdc7de73a95d276be2a3b6e9b8085e8fd4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:55:11 +0000 Subject: [PATCH 4/5] Clarify combined-alert test assertions --- packages/backend/src/jobs/event-alert.job.spec.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/jobs/event-alert.job.spec.ts b/packages/backend/src/jobs/event-alert.job.spec.ts index b1cd81ec..6d4919c6 100644 --- a/packages/backend/src/jobs/event-alert.job.spec.ts +++ b/packages/backend/src/jobs/event-alert.job.spec.ts @@ -111,13 +111,12 @@ describe('EventAlertJob', () => { await job.run(new Date('2026-04-21T12:00:00.000Z')); expect(sendMessageMock).toHaveBeenCalledOnce(); - expect(sendMessageMock).toHaveBeenCalledWith( - '#events', - expect.stringContaining(':hourglass_flowing_sand: Upcoming in the next 24 hours:\n- '), - ); - expect(sendMessageMock).toHaveBeenCalledWith('#events', expect.stringContaining('Tomorrow Standup @ Zoom')); - expect(sendMessageMock).toHaveBeenCalledWith('#events', expect.stringContaining(':sunny: Happening today:\n- ')); - expect(sendMessageMock).toHaveBeenCalledWith('#events', expect.stringContaining('Today Planning')); + const [channel, message] = sendMessageMock.mock.calls[0]; + expect(channel).toBe('#events'); + expect(message).toContain(':hourglass_flowing_sand: Upcoming in the next 24 hours:\n- '); + expect(message).toContain('Tomorrow Standup @ Zoom'); + expect(message).toContain(':sunny: Happening today:\n- '); + expect(message).toContain('Today Planning'); }); it('formats all-day multi-day events as date ranges', async () => { From 4997522b058641e71551f2989086ae77b781a566 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Mon, 8 Jun 2026 22:21:22 -0400 Subject: [PATCH 5/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/backend/src/jobs/event-alert.job.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/jobs/event-alert.job.ts b/packages/backend/src/jobs/event-alert.job.ts index 5bc7ba85..0fc9af51 100644 --- a/packages/backend/src/jobs/event-alert.job.ts +++ b/packages/backend/src/jobs/event-alert.job.ts @@ -145,10 +145,15 @@ export class EventAlertJob { } const displayedOccurrences = unsentOccurrences.slice(0, 20); - const todayOccurrences = displayedOccurrences.filter((occurrence) => occursTodayUtc(occurrence, now)); - const upcomingOccurrencesLater = displayedOccurrences.filter( - (occurrence) => !occursTodayUtc(occurrence, now), - ); + const todayOccurrences: CalendarEventOccurrence[] = []; + const upcomingOccurrencesLater: CalendarEventOccurrence[] = []; + for (const occurrence of displayedOccurrences) { + if (occursTodayUtc(occurrence, now)) { + todayOccurrences.push(occurrence); + } else { + upcomingOccurrencesLater.push(occurrence); + } + } const overflowCount = unsentOccurrences.length - displayedOccurrences.length; const overflowLine = overflowCount > 0 ? `\n...and ${overflowCount} more event(s).` : '';