diff --git a/src/email/templates/matchTeamIntro.md b/src/email/templates/matchTeamIntro.md index 5df9abc..3971ca6 100644 --- a/src/email/templates/matchTeamIntro.md +++ b/src/email/templates/matchTeamIntro.md @@ -17,7 +17,15 @@ subject: "[Action Required] {{ event.name }} Team Intro: {{{ join (mapToKey proj **ACTION REQUIRED -- NEXT STEPS:** +{{#if commonTimeslots}} +**Suggested Meeting Times (all students available):** +{{#each commonTimeslots}} + - {{@key}}: {{#each this}}{{#each this}}{{this}} ({{@../key}}){{#unless @last}} || {{/unless}}{{/each}}{{/each}} +{{/each}} +{{else}} - **Mentors:** Send a [When2meet](https://www.when2meet.com/) for recurring meeting availability +{{/if}} + - **Students:**{{# if project.issueUrl }} 1. Exactly one member of your team should post "I'm working on this" in [the issue]({{project.issueUrl}}) AS SOON AS POSSIBLE to claim it. (If someone else from your team has already done this, you don't need to do it.){{/if}} 1. reply to this email and introduce yourself (e.g. where you go to school, career goals, or anything else you want to share with your mentor) diff --git a/src/email/templates/matchTeamIntro.ts b/src/email/templates/matchTeamIntro.ts index d46d0c6..a9c7994 100644 --- a/src/email/templates/matchTeamIntro.ts +++ b/src/email/templates/matchTeamIntro.ts @@ -2,6 +2,7 @@ import { MentorStatus, StudentStatus, PrismaClient } from '@prisma/client'; import { ProjectStatus } from '../../enums'; import { EmailContext } from '../spec'; import { PartialEvent } from '../loader'; +import { DateTime } from 'luxon'; export async function getId(): Promise { return `matchTeamIntro`; @@ -18,8 +19,99 @@ export async function getList(prisma: PrismaClient, event: PartialEvent): Promis }, include: { mentors: { where: { status: MentorStatus.ACCEPTED } }, - students: { where: { status: StudentStatus.ACCEPTED } }, + students: { where: { status: StudentStatus.ACCEPTED }, select: { timezone: true, timeManagementPlan: true } }, }, }); - return projects.map((project): EmailContext => ({ project })); + + // Helper types and functions for finding common student timeslots + type Interval = { start: number; end: number }; + type Person = { timezone: string; timeManagementPlan?: Record }; + + function isValidTimezone(tz: string){ + const dt = DateTime.now().setZone(tz); + return dt.isValid; +} + + function localIntervalToUtc(day: string, interval: Interval, tz: string) { + const weekdays: Record = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 7 }; + const baseDay = weekdays[day.toLowerCase()] ?? 1; + const start = DateTime.fromObject({ + year: 2024, + month: 1, + day: baseDay, + hour: Math.floor(interval.start / 60), + minute: interval.start % 60, + zone: tz + }).toUTC(); + const end = DateTime.fromObject({ + year: 2024, + month: 1, + day: baseDay, + hour: Math.floor(interval.end / 60), + minute: interval.end % 60, + zone: tz + }).toUTC(); + return { start, end }; + } + + function intersectIntervals(lists: { start: DateTime; end: DateTime }[][]): { start: DateTime; end: DateTime }[] { + if (!lists.length) return []; + let result = lists[0]; + for (let i = 1; i < lists.length; i++) { + const next: { start: DateTime; end: DateTime }[] = []; + for (const a of result) { + for (const b of lists[i]) { + const start = a.start > b.start ? a.start : b.start; + const end = a.end < b.end ? a.end : b.end; + if (start < end) next.push({ start, end }); + } + } + result = next; + if (!result.length) break; + } + return result; + } + + function formatInterval(interval: { start: DateTime; end: DateTime }, tz: string) { + return `${interval.start.setZone(tz).toFormat("h:mm a")} - ${interval.end.setZone(tz).toFormat("h:mm a")}`; + } + + function findCommonTimeslots(students: Person[]): Record> { + const days = ["monday","tuesday","wednesday","thursday","friday","saturday","sunday"]; + const result: Record> = {}; + try { + const validStudents = students.filter(student => isValidTimezone(student.timezone)); + + if (validStudents.length === 0) { + return result; + } + + for (const day of days) { + const intervalsUTCPerStudent = validStudents.map(s => + (s.timeManagementPlan?.[day] ?? []).map(iv => localIntervalToUtc(day, iv, s.timezone)) + ); + const overlapUTC = intersectIntervals(intervalsUTCPerStudent); + if (overlapUTC.length > 0) { + result[day] = {}; + validStudents.forEach(s => { + result[day][s.timezone] = overlapUTC.map(iv => formatInterval(iv, s.timezone)); + }); + } + } + } catch (error) { + console.warn('Error occurred while finding common timeslots:', error); + return {}; + } + + return result; + } + + return projects.map((project: any) => { + const studentsWithTimezone = project.students.filter((s: any) => s.timezone); + const commonTimeslots = findCommonTimeslots(studentsWithTimezone); + return { + project, + commonTimeslots: Object.keys(commonTimeslots).length ? commonTimeslots : undefined, + }; + }); }