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
8 changes: 8 additions & 0 deletions src/email/templates/matchTeamIntro.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 94 additions & 2 deletions src/email/templates/matchTeamIntro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return `matchTeamIntro`;
Expand All @@ -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<string, Interval[]> };

function isValidTimezone(tz: string){
const dt = DateTime.now().setZone(tz);
return dt.isValid;
}

function localIntervalToUtc(day: string, interval: Interval, tz: string) {
const weekdays: Record<string, number> = { 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<string, Record<string, string[]>> {
const days = ["monday","tuesday","wednesday","thursday","friday","saturday","sunday"];
const result: Record<string, Record<string, string[]>> = {};
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,
};
});
}
Loading