Files
classroomio/packages/email/src/ics.ts
T
Rotimi Best 81294a47f9 feat: live class sessions, CE workflow, and docs (#683)
Adds live-class session scheduling on lessons (join link, date/time,
timezone) with automatic 24h and 1h reminder emails plus a calendar
invite (.ics), a time-change notification with an updated invite, a
teacher-authored welcome email and session invite on enrollment, and a
"Notify students" quiz nudge.

LMS gets an upcoming live-sessions card and Join buttons, plus a
reusable live-session card component (3 states) in @cio/ui with a
Storybook story. The sidebar content tree now reflects sequential
progression locking like the content list, and the course-completion
confetti regression is fixed.

Org-based emails no longer reference ClassroomIO by name. Docs add
live-class, welcome-email, and course-progression guides, expand the
certificates guide (design, signatures, corrected labels), move
plan-required callouts to the top, flatten how-to-guides to the docs
root with redirects, and add screenshots. Includes an add-docs-image
skill.
2026-06-22 05:43:48 +01:00

92 lines
2.5 KiB
TypeScript

/**
* Minimal hand-rolled iCalendar (.ics) builder for live-session invites.
* No dependency — emits a single VEVENT (times in UTC; calendars localize for
* the viewer) with optional DISPLAY alarms. A stable `uid` + incrementing
* `sequence` lets a later send (METHOD:REQUEST) update the same calendar event
* instead of creating a duplicate.
*/
export interface SessionIcsInput {
uid: string;
/** Increment on each update so calendars supersede the prior version. */
sequence?: number;
method?: 'PUBLISH' | 'REQUEST' | 'CANCEL';
/** Event start as an ISO string / absolute instant. */
start: string;
durationMinutes?: number;
title: string;
description?: string;
url?: string;
/** Minutes before start for DISPLAY alarms (e.g. [1440, 60]). */
alarmsBeforeMinutes?: number[];
}
function toIcsUtc(date: Date): string {
// 2026-06-20T18:00:00.000Z -> 20260620T180000Z
return date
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
}
function escapeText(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
}
function triggerFromMinutes(minutes: number): string {
if (minutes % 1440 === 0) return `-P${minutes / 1440}D`;
if (minutes % 60 === 0) return `-PT${minutes / 60}H`;
return `-PT${minutes}M`;
}
export function buildSessionIcs(input: SessionIcsInput): string {
const {
uid,
sequence = 0,
method = 'PUBLISH',
start,
durationMinutes = 60,
title,
description,
url,
alarmsBeforeMinutes = []
} = input;
const startDate = new Date(start);
const endDate = new Date(startDate.getTime() + durationMinutes * 60 * 1000);
const stamp = toIcsUtc(startDate);
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//ClassroomIO//Live Session//EN',
'CALSCALE:GREGORIAN',
`METHOD:${method}`,
'BEGIN:VEVENT',
`UID:${uid}`,
`SEQUENCE:${sequence}`,
`DTSTAMP:${stamp}`,
`DTSTART:${toIcsUtc(startDate)}`,
`DTEND:${toIcsUtc(endDate)}`,
`SUMMARY:${escapeText(title)}`
];
if (description) lines.push(`DESCRIPTION:${escapeText(description)}`);
if (url) lines.push(`URL:${escapeText(url)}`);
if (method === 'CANCEL') lines.push('STATUS:CANCELLED');
for (const minutes of alarmsBeforeMinutes) {
lines.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`DESCRIPTION:${escapeText(title)}`,
`TRIGGER:${triggerFromMinutes(minutes)}`,
'END:VALARM'
);
}
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines.join('\r\n');
}