Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/backend/src/jobs/event-alert.job.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <!date^…> 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)
Expand All @@ -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([
{
Expand Down
79 changes: 63 additions & 16 deletions packages/backend/src/jobs/event-alert.job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) =>
Expand Down
Loading