Skip to content

Commit 8c22088

Browse files
authored
feat(web): exam ics (#655)
1 parent 9c7703a commit 8c22088

File tree

6 files changed

+152
-1
lines changed

6 files changed

+152
-1
lines changed

.changeset/wise-bottles-mate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'web': minor
3+
---
4+
5+
feat(web): download exam ics

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"escape-html": "^1.0.3",
7474
"framer-motion": "7.1.0",
7575
"html2canvas": "1.4.1",
76+
"ics": "^3.5.0",
7677
"isomorphic-dompurify": "0.20.0",
7778
"md5": "2.3.0",
7879
"mobx": "6.10.2",

apps/web/src/common/i18n/locales/th.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const shoppingPanel = {
4747
export const schedulePage = {
4848
title: 'จัดตารางเรียน',
4949
downloadPng: 'PNG',
50-
addToCalendar: 'Google Calendar',
50+
addToCalendar: 'ตารางเรียน (Soon)',
5151
showCR11: 'แสดง จท11',
5252
showCR11Mobile: 'จท11',
5353
sumCreditsDesc: 'หน่วยกิตรวมในตาราง',

apps/web/src/modules/Schedule/ics.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import toast from 'react-hot-toast'
2+
3+
import * as ics from 'ics'
4+
5+
import { CourseCartItem } from '@web/store'
6+
7+
import { ExamPeriod, Maybe } from '@cgr/codegen'
8+
9+
function createSchedule(
10+
course: CourseCartItem,
11+
period: Maybe<ExamPeriod> | undefined,
12+
name: string
13+
) {
14+
if (!period?.date || !period.period?.start || !period.period?.end) {
15+
return null
16+
}
17+
18+
const date = new Date(period.date)
19+
const start = period.period.start.split(':').map(Number)
20+
const end = period.period.end.split(':').map(Number)
21+
22+
if (isNaN(start[0]) || isNaN(start[1]) || isNaN(end[0]) || isNaN(end[1])) {
23+
console.error(
24+
`Invalid time format for ${course.courseNo}: ${period.period.start} or ${period.period.end}`
25+
)
26+
return null
27+
}
28+
29+
date.setUTCHours(start[0])
30+
date.setUTCMinutes(start[1])
31+
32+
// We save BE year in Database as UTC 💀
33+
date.setUTCFullYear(date.getUTCFullYear() - 543)
34+
// Subtract 7 hours to make it UTC
35+
date.setUTCHours(date.getUTCHours() - 7)
36+
37+
const minutes = (end[0] - start[0]) * 60 + (end[1] - start[1])
38+
39+
console.log({ utc: date.toISOString(), local: date.toLocaleString() })
40+
41+
return {
42+
title: course.abbrName,
43+
description: `${name} Exam ${course.courseNo} ${course.courseNameEn}`,
44+
// No exam start before 7AM
45+
start: [
46+
date.getUTCFullYear(),
47+
date.getUTCMonth() + 1,
48+
date.getUTCDate(),
49+
date.getUTCHours(),
50+
date.getUTCMinutes(),
51+
],
52+
startInputType: 'utc',
53+
duration: { minutes },
54+
// TODO add location
55+
} satisfies ics.EventAttributes
56+
}
57+
58+
function createSchedules(courses: CourseCartItem[]) {
59+
const events: ics.EventAttributes[] = []
60+
61+
for (const course of courses) {
62+
const { midterm, final } = course
63+
64+
const midtermExam = createSchedule(course, midterm, 'Midterm')
65+
const finalExam = createSchedule(course, final, 'Final')
66+
67+
if (midtermExam) {
68+
events.push(midtermExam)
69+
}
70+
71+
if (finalExam) {
72+
events.push(finalExam)
73+
}
74+
}
75+
76+
if (events.length === 0) {
77+
toast.error('ไม่สามารถสร้าง ics ได้เนื่องจากไม่มีการสอบ')
78+
return undefined
79+
}
80+
81+
const { value, error } = ics.createEvents(events)
82+
83+
if (error) {
84+
toast.error(`Failed to generate exam schedule: ${error}`)
85+
}
86+
87+
return value
88+
}
89+
90+
export function downloadExamSchedules(courses: CourseCartItem[]) {
91+
const value = createSchedules(courses)
92+
93+
if (!value) {
94+
return
95+
}
96+
97+
const blob = new Blob([value], { type: 'text/calendar' })
98+
const url = URL.createObjectURL(blob)
99+
100+
const anchor = document.createElement('a')
101+
anchor.href = url
102+
anchor.download = 'exam.ics'
103+
anchor.click()
104+
anchor.remove()
105+
}

apps/web/src/modules/Schedule/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
useTimetableClasses,
2929
} from './components/Schedule/utils'
3030
import { ScheduleTable } from './components/ScheduleTable'
31+
import { downloadExamSchedules } from './ics'
3132
import {
3233
ButtonBar,
3334
ExamContainer,
@@ -120,6 +121,9 @@ export const SchedulePage = observer(() => {
120121
<Button variant="outlined" disabled>
121122
{t('addToCalendar')}
122123
</Button>
124+
<Button variant="outlined" onClick={() => downloadExamSchedules(shopItems)}>
125+
ตารางสอบ (.ics)
126+
</Button>
123127
<LinkWithAnalytics href={buildLink(`/schedule/cr11`)} passHref elementName={CR11_BUTTON}>
124128
<Button style={{ marginRight: 0 }} variant="outlined">
125129
{isDesktop ? t('showCR11') : t('showCR11Mobile')}

pnpm-lock.yaml

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)