diff --git a/packages/backend/src/jobs/event-alert.job.spec.ts b/packages/backend/src/jobs/event-alert.job.spec.ts index 975514cd..6d4919c6 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,47 @@ 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).toHaveBeenCalledOnce(); + 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 () => { listUpcomingOccurrencesMock.mockResolvedValue([ { diff --git a/packages/backend/src/jobs/event-alert.job.ts b/packages/backend/src/jobs/event-alert.job.ts index d35bece3..0fc9af51 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,31 @@ 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: 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).` : ''; - 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); + 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) =>