Skip to content

Commit 4627450

Browse files
authored
Add calendar endpoint (#23)
* Add calendar endpoint * Add calendar name and description * Add panel to subscribe to calendar * Add calendar to README * Crop calendar screenshot
1 parent c19760c commit 4627450

File tree

9 files changed

+278
-26
lines changed

9 files changed

+278
-26
lines changed

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,18 @@ or use `node index.js` to run the app locally without docker. Don't forget to ru
1515

1616
## API
1717

18-
Visit https://lasvecka.nu/data for raw data.
18+
Visit <https://lasvecka.nu/data> for raw data.
19+
20+
### Calendar
21+
22+
At <webcal://lasvecka.nu/cal.ics> there is a calendar containing the study weeks.
23+
24+
The events are full-day events on every Monday with the study week as the summary (see screenshot below).
25+
26+
The calendar covers a time period which defaults to 8 weeks before and after the current date. This period can be customized by using the `before` and `after` query parameters which can be between 0 and 20. The maximum value for before and after can be overriden by the environment variables `MAX_BEFORE` and `MAX_AFTER` respectively.
27+
28+
![Google Calendar with LV 5, LV 6 and LV 7 shown as events on Mondays in December](./docs/calendar.png)
1929

2030
## Are the weeks not updating correctly?
2131

22-
It should update automatically, but it can of course break since it's web scraped from Chalmers' website. Check out `lasveckor_scraper.js` for the scraping logic.
32+
It should update automatically, but it can of course break since it's web scraped from Chalmers' website. Check out `lasveckor_scraper.js` for the scraping logic.

calc_date.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,10 @@ function readDatePeriod(currDate) {
4848
return { date: soughtDate, type: dateDict[soughtDate] };
4949
}
5050

51-
function computeTime() {
51+
function computeTime(currentDate = moment()) {
5252
// Find date where value is easter_start, easter_end and ord_cont in json file
5353
const EASTER_START = dateDict.easter_start;
5454
const ORD_CONT = dateDict.ord_cont;
55-
const currentDate = moment();
5655
const easterEndCheck = currentDate.diff(ORD_CONT, "days");
5756
const easterStartCheck = currentDate.diff(EASTER_START, "days");
5857
const { date: dat, type: typ } = readDatePeriod(currentDate);

create_calendar.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const { CalendarDate, CalendarEvent, Calendar } = require("iamcal");
2+
const computeTime = require("./calc_date");
3+
const moment = require("moment");
4+
5+
function weeksToEvents(weeks, dtstamp=Date()) {
6+
const events = [];
7+
weeks.forEach(([date, studyweek]) => {
8+
const uid = date.format("yyyy-ww");
9+
const day = new CalendarDate(date.toDate());
10+
const event = new CalendarEvent(uid, dtstamp, day)
11+
.setEnd(day)
12+
.setSummary(studyweek);
13+
events.push(event);
14+
})
15+
return events;
16+
}
17+
18+
function computeWeeks(now, before, after) {
19+
const weeks = [];
20+
for (let i = -before; i <= after; i++) {
21+
const date = moment(now).add(i, "weeks");
22+
weeks.push([date, computeTime(date)]);
23+
}
24+
return weeks;
25+
}
26+
27+
function startOfWeek(now) {
28+
return moment(now)
29+
.subtract(moment.duration(now.isoWeekday() - 1, "days"))
30+
.startOf("day")
31+
.add(12, "hours");
32+
}
33+
34+
function createCalendar(now, before, after) {
35+
const monday = startOfWeek(now);
36+
const weeks = computeWeeks(monday, before, after);
37+
38+
const calendar = new Calendar("-//gud//lasvecka")
39+
.setCalendarName("Läsvecka.nu")
40+
.setCalendarDescription("Kommande läsveckor från läsvecka.nu");
41+
42+
const dtstamp = new Date();
43+
const events = weeksToEvents(weeks, dtstamp);
44+
calendar.addComponents(events);
45+
46+
return calendar;
47+
}
48+
49+
module.exports = createCalendar;

docs/calendar.png

16.1 KB
Loading

index.js

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ const express = require("express");
22
const moment = require("moment");
33
const app = express();
44
const computeTime = require("./calc_date.js");
5+
const createCalendar = require("./create_calendar.js");
56
const port = process.env.PORT || 3000;
67

8+
const maxBefore = process.env.MAX_BEFORE ? parseInt(process.env.MAX_BEFORE) : 20;
9+
const maxAfter = process.env.AFTER ? parseInt(process.env.AFTER) : 20;
10+
711
let studyweek = "";
812
let studyweekNum = "";
913

@@ -30,6 +34,26 @@ app.get("/data", (req, res) => {
3034
res.send(computeTime());
3135
});
3236

37+
app.get("/cal.ics", (req, res) => {
38+
const before = req.query.before ? parseInt(req.query.before) : 8;
39+
if (isNaN(before) || before < 0 || before > maxBefore) {
40+
res.status(400).end(`Invalid before, must be an integer between 0 and ${maxBefore}`);
41+
return;
42+
}
43+
44+
let after = req.query.after ? parseInt(req.query.after) : 8;
45+
if (isNaN(after) || after < 0 || after > maxAfter) {
46+
res.status(400).end(`Invalid after, must be an integer between 0 and ${maxAfter}`);
47+
return;
48+
}
49+
50+
const now = moment();
51+
const calendar = createCalendar(now, before, after);
52+
53+
res.setHeader("Content-Type", "text/calendar")
54+
.end(calendar.serialize());
55+
})
56+
3357
app.get("/favicon.ico", (req, res) => {
3458
const studyweek = computeTime();
3559
const studyweekNum = studyweek
@@ -89,31 +113,28 @@ const render = (data) => {
89113
<meta property="og:type" content="website">
90114
<meta property="og:url" content="https://lasvecka.nu/">
91115
<meta property="og:title" content="läsvecka.nu | ${data.studyweek}">
92-
<style>
93-
html, body { height: 100%; background-color: #90c0de; overflow: hidden; }
94-
time {
95-
position: absolute;
96-
top: 50%;
97-
left: 0;
98-
right: 0;
99-
margin: -110px 0 0 0;
100-
height: 220px;
101-
text-align: center;
102-
color: #1c7bb7;
103-
font-family: Arial, sans-serif;
104-
font-size: 260px;
105-
line-height: 227px;
106-
font-weight: bold;
107-
}
108-
</style>
116+
<link rel="stylesheet" href="/style.css">
109117
</head>
110118
<body>
111119
<time datetime="${data.week}">${data.studyweek}</time>
112-
<script>
113-
console.log('Powered by G.U.D. https://gud.chs.chalmers.se/');
114-
console.log('Source code: https://github.com/gudchalmers/lasvecka');
115-
setTimeout(function () { location.reload(); }, 25886000);
116-
</script>
120+
121+
<div class="corner">
122+
<button id="toggle-panel-button" aria-expanded="false" aria-haspopup="dialog" aria-controls="calendar-panel">
123+
<svg viewBox="0 0 25 25" aria-hidden="true">
124+
<path d="M10 3h5v19h-5zM3 10h19v5H3z" />
125+
</svg>
126+
</button>
127+
128+
<dialog id="calendar-panel" class="calendar-panel hidden" closedby="any">
129+
<h3>Prenumerera</h3>
130+
<div class="calendars">
131+
<a id="cal-google">Google Kalender</a>
132+
<a id="cal-apple">Apple Kalender</a>
133+
<a id="cal-outlook">Outlook</a>
134+
</div>
135+
</dialog>
136+
</div>
137+
<script src="/script.js"></script>
117138
</body>
118139
</html>`;
119140
return html

package-lock.json

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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"dependencies": {
1212
"axios": "1.8.4",
1313
"express": "5.0.1",
14+
"iamcal": "^3.0.3",
1415
"moment": "^2.30.1"
1516
}
1617
}

public/script.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
console.log("Powered by G.U.D. https://gud.chs.chalmers.se/");
2+
console.log("Source code: https://github.com/gudchalmers/lasvecka");
3+
setTimeout(function () {
4+
location.reload();
5+
}, 25886000);
6+
7+
// Set calendar links in panel
8+
const origin = window.location.origin.replace(/^\w+:\/\//, "");
9+
const calendarUrl = `webcal://${origin}/cal.ics`;
10+
document
11+
.getElementById("cal-google")
12+
.setAttribute(
13+
"href",
14+
`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(calendarUrl)}`,
15+
);
16+
document.getElementById("cal-apple").setAttribute("href", calendarUrl);
17+
document
18+
.getElementById("cal-outlook")
19+
.setAttribute(
20+
"href",
21+
`https://outlook.office.com/calendar/addcalendar?url=${encodeURIComponent(calendarUrl)}`,
22+
);
23+
24+
// Open panel
25+
const panel = document.getElementById("calendar-panel");
26+
const toggleButton = document.getElementById("toggle-panel-button");
27+
28+
function openPanel() {
29+
if (panel.hasAttribute("open")) return;
30+
panel.show();
31+
toggleButton.ariaExpanded = true;
32+
}
33+
34+
toggleButton.addEventListener("click", openPanel);
35+
toggleButton.addEventListener("mousedown", function (event) {
36+
if (event.button === 0) openPanel();
37+
});
38+
39+
// Close panel
40+
panel.addEventListener("close", function () {
41+
toggleButton.ariaExpanded = false;
42+
});

public/style.css

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
:root {
2+
--primary-color: #1c7bb7;
3+
--on-primary-color: #ffffff;
4+
5+
--background-color: #90c0de;
6+
}
7+
8+
html, body {
9+
height: 100%;
10+
background-color: var(--background-color);
11+
overflow: hidden;
12+
font-family: Arial, sans-serif;
13+
}
14+
15+
@media screen and (orientation: portrait) {
16+
html {
17+
font-size: 2rem !important;
18+
}
19+
}
20+
21+
@media (prefers-reduced-motion: reduce) {
22+
* {
23+
animation: none !important;
24+
transition: none !important;
25+
}
26+
}
27+
28+
time {
29+
position: absolute;
30+
top: 50%;
31+
left: 0;
32+
right: 0;
33+
margin: -110px 0 0 0;
34+
height: 220px;
35+
text-align: center;
36+
color: var(--primary-color);
37+
font-size: 260px;
38+
line-height: 227px;
39+
font-weight: bold;
40+
}
41+
42+
.corner {
43+
position: fixed;
44+
bottom: 1rem;
45+
right: 1rem;
46+
}
47+
48+
/* Panel button */
49+
#toggle-panel-button {
50+
position: absolute;
51+
bottom: 0;
52+
right: 0;
53+
54+
font-size: 1rem;
55+
height: 5em;
56+
aspect-ratio: 1;
57+
background-color: var(--primary-color);
58+
fill: var(--on-primary-color);
59+
cursor: pointer;
60+
border: 0.75em solid var(--primary-color);
61+
border-radius: 50%;
62+
63+
transition: background-color 0.25s;
64+
65+
display: flex;
66+
justify-content: center;
67+
align-items: center;
68+
69+
&:hover,
70+
&:focus-visible {
71+
background-color: var(--on-primary-color);
72+
fill: var(--primary-color);
73+
outline: none;
74+
}
75+
76+
&:active,
77+
&:has(+[open]) {
78+
cursor: default;
79+
background-color: var(--primary-color) !important;
80+
fill: var(--on-primary-color) !important;
81+
}
82+
}
83+
84+
/* Panel */
85+
dialog {
86+
height: fit-content;
87+
border: none;
88+
outline: none;
89+
border-radius: 3rem;
90+
}
91+
92+
.calendar-panel {
93+
position: relative;
94+
95+
color: var(--on-primary-color);
96+
background-color: var(--primary-color);
97+
98+
padding: 2em 2.5em;
99+
font-size: 2em;
100+
}
101+
102+
.calendar-panel h3 {
103+
color: hsl(0, 0%, 100%);
104+
font-size: 1.5em;
105+
font-weight: bold;
106+
margin-block: 0.1em 0.5em;
107+
}
108+
109+
.calendars a {
110+
color: var(--on-primary-color);
111+
display: block;
112+
line-height: 1.75em;
113+
outline: none;
114+
text-decoration: none;
115+
text-align: center;
116+
117+
&:hover,
118+
&:focus-visible {
119+
color: var(--background-color);
120+
text-decoration: underline;
121+
}
122+
}
123+

0 commit comments

Comments
 (0)