Author SHA1 Message Date
Fran314 3ddf325c03 prototipo eventi custom 2023-10-16 10:34:51 +02:00
Fran314 973acad17a small fix on dynamic viewport height 2023-10-09 15:36:32 +02:00
23 changed files with 420 additions and 858 deletions
-9
View File
@@ -1,9 +0,0 @@
{
"printWidth": 90,
"singleQuote": true,
"quoteProps": "consistent",
"tabWidth": 4,
"semi": false,
"arrowParens": "avoid",
"proseWrap": "always"
}
+1 -58
View File
@@ -28,62 +28,5 @@ To start the development server run `npm run dev`.
### Production
To build the ViteJS project run `npm run build`, for deployment a `.env` file can be used to set the `BASE_URL` variable.
## Semester Timetable Data & Automated Updates
The timetable IDs and semester dates are stored in `semester-data.json`.
The client application loads `semester-data.json` with a **Stale-While-Revalidate / Fallback** pattern:
- The bundled `src/semester-data.json` is used as an immediate fallback (ensuring instant render and offline availability).
- On startup, the client dynamically fetches `/semester-data.json` (hourly cache-busted). If new IDs or dates are published, the UI updates automatically without needing to rebuild or redeploy the frontend bundle.
### Updating Semester Data
To manually check and update semester data:
```bash
# Update both src/semester-data.json and public/semester-data.json
npm run update-data
# Or run directly with custom output path(s)
node src/scripts/update-semester-data.js --output /var/www/orario/semester-data.json
# Check without modifying files (exits with code 1 if updates found, 0 if up to date)
node src/scripts/update-semester-data.js --check
```
### Automated Daily Host Updates
To ensure the production site always has up-to-date timetable IDs each semester without manual commits, set up a daily check on the server hosting `/var/www/orario`.
#### Option 1: Systemd Timer (Recommended)
1. Copy the systemd unit files to `/etc/systemd/system/`:
```bash
sudo cp systemd/orario-update.service /etc/systemd/system/
sudo cp systemd/orario-update.timer /etc/systemd/system/
```
2. In `/etc/systemd/system/orario-update.service`, ensure the `ExecStart` path points to your repository's update script and your webroot target, for example:
```ini
ExecStart=/usr/bin/node /home/fra/tech/phc/orario/src/scripts/update-semester-data.js --output /var/www/orario/semester-data.json --quiet
```
3. Enable and start the timer:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now orario-update.timer
```
4. Verify status:
```bash
systemctl list-timers | grep orario
```
#### Option 2: Standard Cron (`crontab -e`)
Add a daily cron entry to the `caddy` (or deployment) user's crontab:
```bash
crontab -e
```
Add the following line (runs daily at 04:30 AM):
```cron
30 4 * * * /usr/bin/node /home/fra/tech/phc/orario/src/scripts/update-semester-data.js --output /var/www/orario/semester-data.json --quiet >> /var/log/orario-update.log 2>&1
```
To build the ViteJS project run `npm run build`, for deployment a `.env` files can be used to set the `BASE_URL` variable.
BIN
View File
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
[
"ALGEBRA 1",
"ANALISI ARMONICA - ANALISI ARMONICA/a",
"ANALISI MATEMATICA 1",
"ANALISI MATEMATICA 2",
"ANALISI MATEMATICA 3",
"ANALISI NUMERICA CON LABORATORIO - ANALISI NUMERICA",
"ARITMETICA",
"ASPETTI MATEMATICI NELLA COMPUTAZIONE QUANTISTICA",
"CALCOLO SCIENTIFICO",
"COMBINATORIA ALGEBRICA",
"DETERMINAZIONE ORBITALE",
"DINAMICA DEL SISTEMA SOLARE",
"ELEMENTI DI GEOMETRIA ALGEBRICA",
"ELEMENTI DI TOPOLOGIA ALGEBRICA",
"FISICA II",
"FONDAMENTI DI PROGRAMMAZIONE CON LABORATORIO - FONDAMENTI DI PROGRAMMAZIONE ",
"GEOMETRIA 1",
"GEOMETRIA 2 - GEOMETRIA 2 A",
"GEOMETRIA E TOPOLOGIA DIFFERENZIALE",
"INGLESE SCIENTIFICO",
"ISTITUZIONI DI ALGEBRA",
"ISTITUZIONI DI ANALISI MATEMATICA",
"ISTITUZIONI DI DIDATTICA DELLA MATEMATICA",
"ISTITUZIONI DI FISICA MATEMATICA",
"LABORATORIO COMPUTAZIONALE",
"LABORATORIO DI INTRODUZIONE ALLA MATEMATICA COMPUTAZIONALE",
"LOGICA MATEMATICA",
"MECCANICA SUPERIORE - MECCANICA SUPERIORE/a",
"METODI NUMERICI PER CATENE DI MARKOV - METODI NUMERICI PER CATENE DI MARKOV/a",
"METODI NUMERICI PER LA GRAFICA - METODI NUMERICI PER LA GRAFICA/a",
"PROBABILITÀ",
"RICERCA OPERATIVA",
"SISTEMI DINAMICI",
"STORIA DELLA MATEMATICA",
"TECNOLOGIE PER LA DIDATTICA",
"TEORIA ANALITICA DEI NUMERI A - TEORIA ANALITICA DEI NUMERI A/a",
"TEORIA E METODI DELL'OTTIMIZZAZIONE",
"TOPOLOGIA DIFFERENZIALE - TOPOLOGIA DIFFERENZIALE/a"
]
+3 -4
View File
@@ -5,9 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"update-data": "node src/scripts/update-semester-data.js",
"test": "node --test 'tests/**/*.test.js'"
"build": "vite build"
},
"keywords": [],
"author": "",
@@ -18,7 +16,8 @@
"lodash-es": "^4.17.21",
"preact": "^10.10.6",
"sass": "^1.54.8",
"vite": "^3.0.9"
"vite": "^3.0.9",
"yaml": "^2.3.2"
},
"devDependencies": {
"@babel/core": "^7.18.13",
+8
View File
@@ -23,6 +23,9 @@ dependencies:
vite:
specifier: ^3.0.9
version: 3.0.9(sass@1.54.8)
yaml:
specifier: ^2.3.2
version: 2.3.2
devDependencies:
'@babel/core':
@@ -937,3 +940,8 @@ packages:
sass: 1.54.8
optionalDependencies:
fsevents: 2.3.2
/yaml@2.3.2:
resolution: {integrity: sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==}
engines: {node: '>= 14'}
dev: false
-16
View File
@@ -1,16 +0,0 @@
{
"timetableIds": {
"anno-1": "687e66d62d13e6001edd366d",
"anno-2": "687e681301ee9300198320e6",
"anno-3": "687e68bb01ee9300198320eb",
"magistrale": "687e6a9f96ea080019d439d5"
},
"semesterStartDateStr": "23 settembre 2026",
"firstMondayDate": "2026-09-28T00:00:00.000Z",
"buttonText": "Vai al 28 settembre! 🚀",
"lastUpdated": "2026-09-02T20:10:56.118Z",
"academicYear": {
"startYear": 2026,
"endYear": 2027
}
}
+1 -8
View File
@@ -1,19 +1,12 @@
import { useRef } from 'preact/hooks'
import { Icon } from './Icon.jsx'
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
export const DatePicker = ({ date, setDate }) => {
const input = useRef()
const [year, month, day] = date.split('T')[0].split('-')
return (
<div
class="date-picker"
onClick={() =>
isSafari ? input.current.focus() : input.current.showPicker()
}
>
<div class="date-picker" onClick={() => input.current.showPicker()}>
<input
ref={input}
type="date"
+1
View File
@@ -24,6 +24,7 @@ const viewModeMap = {
export const EventsView = ({ mode, source, ...viewProps }) => {
// const Mode = viewModeMap[mode]
if (source === 'orario') {
return (
+15 -13
View File
@@ -4,31 +4,33 @@ export const Help = ({}) => (
<>
<h3>Visualizzazione Corsi</h3>
<p>
Per visualizzare i corsi che ti interessano nella tabella dell'orario, devi
prima selezionarli. Puoi selezionare dei corsi cliccandoli, cercandoli nelle
sezioni Primo anno (<b>I</b>), Secondo anno (<b>II</b>), Terzo anno (
<b>III</b>), Magistrale (<b>M</b>), o Tutti.
Per visualizzare i corsi che ti interessano nella tabella
dell'orario, devi prima selezionarli. Puoi selezionare dei corsi
cliccandoli, cercandoli nelle sezioni Primo anno (<b>I</b>), Secondo
anno (<b>II</b>), Terzo anno (<b>III</b>), Magistrale (<b>M</b>), o
Tutti.
</p>
<p>
Una volta compiuta la selezione, è possibile vedere la tabella delle lezioni
andando nella visualizzazione Orario (
Una volta compiuta la selezione, è possibile vedere la tabella delle
lezioni andando nella visualizzazione Orario (
<Icon name="calendar_view_month" />)
</p>
<p>
Per via di eventuali preferenze personali, è possibile cambiare l'orientazione
della tabella Orario trasponendola, utilizzando il pulsante Trasponi (
Per via di eventuali preferenze personali, è possibile cambiare
l'orientazione della tabella Orario trasponendola, utilizzando il
pulsante Trasponi (
<Icon name="switch_left" style="transform: rotate(-45deg)" />)
</p>
<p>
È anche possibile visualizzare in uno specchietto riassuntivo soltanto i corsi
selezionati, andando nella visualizzazione Lista (
È anche possibile visualizzare in uno specchietto riassuntivo
soltanto i corsi selezionati, andando nella visualizzazione Lista (
<Icon name="list" />)
</p>
<h3>Stampa</h3>
<p>
Da desktop puoi stampare l'orario attualmente visibile con il bottone{' '}
<Icon name="print" /> (è consigliato controllare le opzioni di stampa per
ottenere un risultato soddisfacente).
Da desktop puoi stampare l'orario attualmente visibile con il
bottone <Icon name="print" /> (è consigliato controllare le opzioni
di stampa per ottenere un risultato soddisfacente).
</p>
<h3>Bug &amp; Contatti</h3>
<p>
+16 -2
View File
@@ -7,9 +7,23 @@ export const SettingsBar = ({ theme, setTheme, date, setDate }) => {
<div class="settings-group">
<button
class="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
onClick={() =>
setTheme(theme === 'dark' ? 'light' : 'dark')
}
>
<Icon name={theme === 'dark' ? 'dark_mode' : 'light_mode'} />
<Icon
name={theme === 'dark' ? 'dark_mode' : 'light_mode'}
/>
</button>
<button
class="icon"
onClick={() =>
setTheme(theme === 'dark' ? 'light' : 'dark')
}
>
<Icon
name="add"
/>
</button>
<DatePicker date={date} setDate={setDate} />
</div>
+13 -6
View File
@@ -24,7 +24,8 @@ export const Toolbar = ({
</button>
</div>
<div class="item logo">
<img src="logo-circuit-board.svg" alt="logo" /> / <span>Orario</span>
<img src="logo-circuit-board.svg" alt="logo" /> /{' '}
<span>Orario</span>
</div>
<div class="option-group">
<div class="item option">
@@ -35,6 +36,7 @@ export const Toolbar = ({
{ value: 'anno-3', label: 'III' },
{ value: 'magistrale', label: 'Magistrale' },
{ value: 'tutti', label: 'Tutti' },
{ value: 'custom', label: 'Custom' },
]}
value={source}
setValue={setSource}
@@ -50,9 +52,10 @@ export const Toolbar = ({
setValue={setSource}
/>
</div>
</div>
<div class="option-group">
<DatePicker date={date} setDate={setDate} />
<div class="empty"></div>
<div class="item option">
<DatePicker date={date} setDate={setDate} />
</div>
</div>
<div class="option-group">
<div class="item option">
@@ -63,9 +66,13 @@ export const Toolbar = ({
<div class="item option">
<button
class="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
onClick={() =>
setTheme(theme === 'dark' ? 'light' : 'dark')
}
>
<Icon name={theme === 'dark' ? 'dark_mode' : 'light_mode'} />
<Icon
name={theme === 'dark' ? 'dark_mode' : 'light_mode'}
/>
</button>
</div>
<div class="item option">
+36 -15
View File
@@ -2,7 +2,7 @@ import { format } from 'date-fns'
import _ from 'lodash'
import { useEffect, useRef, useState } from 'preact/hooks'
import { prettyCourseName, WEEK_DAYS } from '../../utils.jsx'
import { parseCustomEvents, prettyCourseName, WEEK_DAYS } from '../../utils.jsx'
import { Icon } from '../Icon.jsx'
export const Courses = ({
@@ -10,13 +10,18 @@ export const Courses = ({
timetables,
selection,
setSelection,
hideOtherCourses,
custom,
isRestrictedList,
}) => {
const events = timetables[source]
const events = isRestrictedList ? timetables['tutti'] : timetables[source]
const selectionSet = new Set(selection)
const visibleEvents = hideOtherCourses
? events.filter(e => selectionSet.has(e.id))
console.log(events)
const visibleEvents = isRestrictedList
? events
.filter(e => selectionSet.has(e.id))
.concat(parseCustomEvents(custom))
: events
const eventsByCourse = _.groupBy(_.sortBy(visibleEvents, 'id'), 'id')
@@ -53,7 +58,7 @@ export const Courses = ({
return (
<div class="course-view" ref={element}>
{hideOtherCourses && selection.length === 0 && (
{isRestrictedList && selection.length === 0 && (
<div class="warning">
<p>Non hai ancora selezionato nessun corso.</p>
<p>
@@ -67,23 +72,39 @@ export const Courses = ({
<div
class={
'course' +
(currentlyHovered === id ? ' highlight' : '') +
(selectionSet.has(id) ? ' selected' : '')
(currentlyHovered === id && !isRestrictedList
? ' highlight'
: '') +
(selectionSet.has(id) && !isRestrictedList
? ' selected'
: '')
}
data-course-id={id}
onClick={() => {
if (!selectionSet.has(id)) setSelection([...selection, id])
else setSelection(selection.filter(selId => selId !== id))
if (isRestrictedList) return
if (!selectionSet.has(id))
setSelection([...selection, id])
else
setSelection(
selection.filter(selId => selId !== id)
)
}}
>
<div class="title">{prettyCourseName(courseEvents[0].name)}</div>
<div class="docenti">{profsPerCourse[id].join(', ')}</div>
<div class="title">
{prettyCourseName(courseEvents[0].name)}
</div>
<div class="docenti">
{profsPerCourse[id].join(', ')}
</div>
<div class="events">
{courseEvents.map(course => (
<div>
{WEEK_DAYS[course.start.getDay()]}{' '}
{format(course.start, 'H:mm')}&ndash;
{format(course.end, 'H:mm')} {course.aule.join(', ')}
{WEEK_DAYS[course.day]} {course.start / 60}:
{String(course.start % 60).padStart(2, '0')}
&ndash;
{course.end / 60}:
{String(course.end % 60).padStart(2, '0')}{' '}
{course.aule.join(', ')}
</div>
))}
</div>
+52 -28
View File
@@ -1,9 +1,15 @@
import { useEffect, useRef, useState } from 'preact/hooks'
import _ from 'lodash'
import _, { parseInt } from 'lodash'
import { differenceInMinutes, startOfDay } from 'date-fns'
import { parse } from 'yaml'
import { parseCustomEvents } from '../../utils.jsx'
import { WEEK_DAYS_SHORT, prettyCourseName, usePersistentState } from '../../utils.jsx'
import {
WEEK_DAYS_SHORT,
prettyCourseName,
usePersistentState,
} from '../../utils.jsx'
import { layoutEvents, layoutIntervals } from '../../interval-layout.js'
import { Popup } from '../Popup.jsx'
import { Icon } from '../Icon.jsx'
@@ -13,23 +19,28 @@ const TransposePopup = ({ onClose }) => {
<Popup
title={
<>
<Icon name="info" /> Attenzione! La tabella è stata trasposta!
<Icon name="info" /> Attenzione! La tabella è stata
trasposta!
</>
}
onClose={onClose}
>
<p>A grande richiesta popolare abbiamo trasposto la tabella dell'orario!</p>
<p>
Assicurati quindi di leggerla correttamente (dall'alto verso il basso
invece che da sinistra verso destra).
A grande richiesta popolare abbiamo trasposto la tabella
dell'orario!
</p>
<p>
Se preferisci utilizzare la versione vecchia, puoi utilizzare il pulsante
Trasponi <Icon name="switch_left" style="transform: rotate(-45deg)" />{' '}
nell'origine della tabella per trasporla. Questa scelta verrà salvata nei
cookie e verrà ricordata in futuro
Assicurati quindi di leggerla correttamente (dall'alto verso il
basso invece che da sinistra verso destra).
</p>
<p>
Se preferisci utilizzare la versione vecchia, puoi utilizzare il
pulsante Trasponi{' '}
<Icon name="switch_left" style="transform: rotate(-45deg)" />{' '}
nell'origine della tabella per trasporla. Questa scelta verrà
salvata nei cookie e verrà ricordata in futuro
</p>
</Popup>
)
@@ -40,8 +51,8 @@ const NoCourseWarning = () => {
<div class="warning">
<p>Non hai ancora selezionato nessun corso.</p>
<p>
Clicca sui corsi nelle altre visuali per selezionarli e visualizzarli
nell'orario
Clicca sui corsi nelle altre visuali per selezionarli e
visualizzarli nell'orario
</p>
</div>
)
@@ -66,10 +77,13 @@ const Layout = ({ layout, day, colors }) => {
style={{
'--block-size': block.end - block.start,
'--size': event.end - event.start,
'--relative-start': event.start - block.start,
'--relative-start':
event.start - block.start,
'--index': event.index,
'--of': block.layers,
'--color': `var(--event-${colors[event.id]})`,
'--color': `var(--event-${
colors[event.id]
})`,
}}
>
<div class="event">
@@ -106,11 +120,16 @@ const ScheduleGrid = ({
class="small"
onClick={() =>
setOrientation(
orientation === 'original' ? 'transposed' : 'original'
orientation === 'original'
? 'transposed'
: 'original'
)
}
>
<Icon name="switch_left" style="transform: rotate(-45deg)" />
<Icon
name="switch_left"
style="transform: rotate(-45deg)"
/>
</button>
</div>
{[1, 2, 3, 4, 5].map(n => (
@@ -193,25 +212,30 @@ const ScheduleCard = ({
)
}
export const Schedule = ({ timetables, selection }) => {
export const Schedule = ({ timetables, selection, custom }) => {
const [hasSeenTranspose, setHasSeenTranspose] = usePersistentState(
'transpose_info',
'false'
)
const [orientation, setOrientation] = usePersistentState('orientation', 'original')
const [orientation, setOrientation] = usePersistentState(
'orientation',
'original'
)
const colorList = ['red', 'purple', 'blue', 'yellow', 'green', 'orange', 'lightblue']
const colorList = [
'red',
'purple',
'blue',
'yellow',
'green',
'orange',
'lightblue',
]
const allEvents = timetables['tutti']
const selectionSet = new Set(selection)
const events = allEvents
.filter(e => selectionSet.has(e.id))
.map(e => ({
...e,
day: e.start.getDay(),
start: differenceInMinutes(e.start, startOfDay(e.start)),
end: differenceInMinutes(e.end, startOfDay(e.start)),
}))
const events = allEvents.filter(e => selectionSet.has(e.id))
parseCustomEvents(custom).forEach(event => events.push(event))
const weekStart = Math.min(...events.map(e => e.start), 9 * 60) / 30
const weekEnd = Math.max(...events.map(e => e.end), 18 * 60) / 30
+10 -3
View File
@@ -42,7 +42,10 @@ function layoutBlockEvents(events) {
let viableIndex = 0
while (
result.filter(
e => e.index === viableIndex && e.start < event.end && event.start < e.end
e =>
e.index === viableIndex &&
e.start < event.end &&
event.start < e.end
).length !== 0
) {
viableIndex += 1
@@ -54,7 +57,8 @@ function layoutBlockEvents(events) {
return result
}
export function layoutEvents(events) {
const overlap = (event, block) => event.start < block.end && block.start < event.end
const overlap = (event, block) =>
event.start < block.end && block.start < event.end
events.sort((a, b) => a.start - b.start)
@@ -64,7 +68,10 @@ export function layoutEvents(events) {
if (blocks.length > 0) {
layout = layout.filter(block => !overlap(event, block))
layout.push({
start: Math.min(event.start, ...blocks.map(block => block.start)),
start: Math.min(
event.start,
...blocks.map(block => block.start)
),
end: Math.max(event.end, ...blocks.map(block => block.end)),
events: blocks.flatMap(block => block.events).concat([event]),
})
+106 -102
View File
@@ -1,7 +1,7 @@
import _ from 'lodash'
import { render } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import defaultSemesterData from './semester-data.json'
import { differenceInMinutes, startOfDay } from 'date-fns'
// import { ToolOverlay } from './components/ToolOverlay.jsx'
//
@@ -11,36 +11,50 @@ import defaultSemesterData from './semester-data.json'
// MODE_SCHEDULE,
// } from './components/EventsView.jsx'
import { Courses } from './components/view/Courses.jsx'
import { Schedule } from './components/view/Schedule.jsx'
import { HamburgerMenu } from './components/HamburgerMenu.jsx'
import { Help } from './components/Help.jsx'
import { Icon } from './components/Icon.jsx'
import { OptionBar } from './components/OptionBar.jsx'
import { Popup } from './components/Popup.jsx'
import { Toolbar } from './components/Toolbar.jsx'
import { Courses } from './components/view/Courses.jsx'
import { Schedule } from './components/view/Schedule.jsx'
import { OptionBar } from './components/OptionBar.jsx'
import { prettyAulaName, prettyProfName, usePersistentState } from './utils.jsx'
import { SettingsBar } from './components/SettingsBar.jsx'
// Che fanno queste due righe?
window._ = _
window.dataBuffer = {}
function specialEventPatches(eventi) {
// Il laboratorio del primo anno in realtà è in due gruppi separati
eventi.forEach(evento => {
console.log(evento.id, evento.nome, evento.dataInizio, evento.dataFine)
const TIMETABLE_IDS = {
'anno-1': '64a7c1c651f079001d52e9c8',
'anno-2': '6308e2dc09352a0208fefdd9',
'anno-3': '6308e42a1df5cb026699ced4',
'magistrale': '64a7c7091ab813002c5d9ede',
}
// const DEFAULT_DATE_RANGE = {
// from: '2023-10-09T00:00:00.000Z',
// to: '2023-10-14T00:00:00.000Z',
// }
// const DATE_RANGES = {
// '64a7c1c651f079001d52e9c8': DEFAULT_DATE_RANGE,
// '6308e2dc09352a0208fefdd9': DEFAULT_DATE_RANGE,
// '6308e42a1df5cb026699ced4': DEFAULT_DATE_RANGE,
// '64a7c7091ab813002c5d9ede': DEFAULT_DATE_RANGE,
// }
function specialEventPatches(eventi) {
// Il laboratorio del primo anno in realtà è in due canali separati
let i = 1
eventi.forEach(evento => {
if (
evento.nome === 'LABORATORIO DI INTRODUZIONE ALLA MATEMATICA COMPUTAZIONALE'
evento.nome ===
'LABORATORIO DI INTRODUZIONE ALLA MATEMATICA COMPUTAZIONALE'
) {
if (evento.evento.dettagliDidattici[0].partizione.descrizione === 'CORSO A') {
evento.nome += ' (A)'
}
if (evento.evento.dettagliDidattici[0].partizione.descrizione === 'CORSO B') {
evento.nome += ' (B)'
}
evento.nome += ` (${i})`
i++
}
})
@@ -49,18 +63,23 @@ function specialEventPatches(eventi) {
function formatEvents(timetable) {
return timetable.map(({ nome, dataInizio, dataFine, docenti, aule }) => {
const start = new Date(dataInizio)
const end = new Date(dataFine)
return {
id: nome,
name: _.split(nome, '-', 1)[0].trim(),
start: new Date(dataInizio),
end: new Date(dataFine),
docenti: docenti.map(({ nome, cognome }) => prettyProfName(nome, cognome)),
day: start.getDay(),
start: differenceInMinutes(start, startOfDay(start)),
end: differenceInMinutes(end, startOfDay(start)),
docenti: docenti.map(({ nome, cognome }) =>
prettyProfName(nome, cognome)
),
aule: aule.map(aula => prettyAulaName(aula.codice)),
}
})
}
async function loadCalendari(date, timetableIds) {
async function loadCalendari(date) {
function getMonday(d) {
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
@@ -95,23 +114,23 @@ async function loadCalendari(date, timetableIds) {
method: 'POST',
mode: 'cors',
credentials: 'omit',
},
}
)
return await req.json()
}
const requests = [
req(timetableIds['anno-1']),
req(timetableIds['anno-2']),
req(timetableIds['anno-3']),
req(timetableIds['magistrale']),
req(TIMETABLE_IDS['anno-1']),
req(TIMETABLE_IDS['anno-2']),
req(TIMETABLE_IDS['anno-3']),
req(TIMETABLE_IDS['magistrale']),
]
const results = await Promise.all(requests)
const timetablesRaw = results.map(timetable =>
specialEventPatches(_.uniqBy(timetable, 'id')),
specialEventPatches(_.uniqBy(timetable, 'id'))
)
const allRaw = _.uniqBy(specialEventPatches(_.concat(...results)), 'id')
const allRaw = specialEventPatches(_.uniqBy(_.concat(...results), 'id'))
return {
'anno-1': formatEvents(timetablesRaw[0]),
@@ -122,19 +141,50 @@ async function loadCalendari(date, timetableIds) {
}
}
const View = ({ view, selection, setSelection, timetables }) => {
const View = ({
view,
selection,
setSelection,
timetables,
custom,
setCustom,
}) => {
if (view === 'orario') {
return <Schedule selection={selection} timetables={timetables} />
return (
<Schedule
selection={selection}
timetables={timetables}
custom={custom}
/>
)
} else if (view === 'lista') {
return (
<Courses
selection={selection}
setSelection={setSelection}
source={'tutti'}
timetables={timetables}
hideOtherCourses={true}
custom={custom}
isRestrictedList={true}
/>
)
} else if (view === 'custom') {
return (
<div class="custom-events-view">
<textarea
value={custom}
onChange={e => setCustom(e.target.value)}
/>
<p>Esempio di evento personalizzato</p>
<p>
Nome evento [label globale]:
<br />
- Lun 9-11
<br />
- Mar 9:00-11:00
<br />- Gio 8:00-12:00 [label locale]
</p>
</div>
)
} else {
return (
<Courses
@@ -142,7 +192,7 @@ const View = ({ view, selection, setSelection, timetables }) => {
setSelection={setSelection}
source={view}
timetables={timetables}
hideOtherCourses={false}
isRestrictedList={false}
/>
)
}
@@ -156,70 +206,33 @@ const App = ({}) => {
// clearOldPersistentStates('e73cba02')
const [date, setDate] = useState(new Date().toISOString())
const [semesterData, setSemesterData] = useState(defaultSemesterData)
// Data Sources
const [view, setView] = usePersistentState('view', 'tutti')
const [view, setView] = usePersistentState('view', 'magistrale')
const [timetables, setTimetables] = useState(null)
// Dynamically fetch latest semester data at runtime (hourly cache-busted)
useEffect(() => {
const cacheBust = Math.floor(Date.now() / 3600000)
const baseUrl = (import.meta.env.BASE_URL || './').replace(/\/?$/, '/')
fetch(`${baseUrl}semester-data.json?t=${cacheBust}`)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then(data => {
if (data && data.timetableIds && data.firstMondayDate) {
setSemesterData(prev => {
const idsEqual =
JSON.stringify(prev.timetableIds) ===
JSON.stringify(data.timetableIds)
const dateEqual = prev.firstMondayDate === data.firstMondayDate
const btnEqual = prev.buttonText === data.buttonText
if (!idsEqual || !dateEqual || !btnEqual) {
return { ...prev, ...data }
}
return prev
})
}
})
.catch(err => {
console.warn(
'Could not fetch latest semester-data.json, using bundled fallback:',
err,
)
})
}, [])
useEffect(() => {
let isMounted = true
loadCalendari(new Date(date), semesterData.timetableIds).then(data => {
if (isMounted) {
setTimetables(data)
}
})
return () => {
isMounted = false
}
}, [date, semesterData.timetableIds])
// View Modes
// const [mode, setMode] = usePersistentState('orario.mode', MODE_COURSES)
useEffect(async () => {
setTimetables(await loadCalendari(new Date(date)))
}, [date])
// Selection
const [selectedCourses, setSelectedCourses] = usePersistentState('selection', [])
const [selectedCourses, setSelectedCourses] = usePersistentState(
'selection',
[]
)
// Custom Events
const [custom, setCustom] = usePersistentState('custom', [])
// Menus
const [helpVisible, setHelpVisible] = useState(false)
const [showMobileMenu, setShowMobileMenu] = useState(false)
// const groupIds = new Set(eventi.map(e => e.nome))
const toolOverlayVisible = selectedCourses.length > 0
const [theme, setTheme] = usePersistentState(
'theme',
'light',
'light'
// window.matchMedia('(prefers-color-scheme: dark)').matches
// ? 'dark'
// : 'light'
@@ -279,29 +292,18 @@ const App = ({}) => {
) : timetables['tutti'].length === 0 ? (
<div class="warning">
<p>
Non esistono corsi per la settimana selezionata: buone
vacanze! 🎉
Non esistono corsi per la settimana selezionata:
buone vacanze! 🎉
</p>
<p>
Clicca sul bottone qua sotto per vedere la prima settimana
completa di lezioni:
</p>
<button
onClick={() => {
setDate(semesterData.firstMondayDate)
}}
>
{semesterData.buttonText}
</button>
{/* <p>
Per cambiare settimana puoi usare il widget Calendario (
Per cambiare settimana puoi usare il widget
Calendario (
<Icon name="calendar_month" />) in alto a destra
<br />
In versione mobile, il widget Calendario è situato dentro
il Menu (
In versione mobile, il widget Calendario è
situato dentro il Menu (
<Icon name="menu" />)
</p> */}
</p>
</div>
) : (
<View
@@ -309,6 +311,8 @@ const App = ({}) => {
setSelection={setSelectedCourses}
view={view}
timetables={timetables}
custom={custom}
setCustom={setCustom}
/>
))}
</div>
-369
View File
@@ -1,369 +0,0 @@
#!/usr/bin/env node
// Automated & manual update script for semester timetable data
// Usage:
// node src/scripts/update-semester-data.js
// node src/scripts/update-semester-data.js --output /var/www/orario/semester-data.json
// node src/scripts/update-semester-data.js --check
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
function parseArgs() {
const args = process.argv.slice(2)
const options = {
outputs: [],
checkOnly: false,
quiet: false,
}
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === '--output' || arg === '-o') {
const next = args[++i]
if (next) {
options.outputs.push(
...next
.split(',')
.map(s => s.trim())
.filter(Boolean),
)
}
} else if (arg.startsWith('--output=')) {
options.outputs.push(
...arg
.slice('--output='.length)
.split(',')
.map(s => s.trim())
.filter(Boolean),
)
} else if (arg === '--check') {
options.checkOnly = true
} else if (arg === '--quiet' || arg === '-q') {
options.quiet = true
} else if (arg === '--help' || arg === '-h') {
console.log(`
Usage: node src/scripts/update-semester-data.js [options]
Options:
-o, --output <path> Target output file path(s) (comma-separated or multiple flags).
Default: src/semester-data.json, public/semester-data.json
--check Dry-run check: exit 0 if up to date, exit 1 if updates found.
-q, --quiet Suppress verbose output (ideal for cron jobs).
-h, --help Show this help message.
`)
process.exit(0)
}
}
if (options.outputs.length === 0) {
options.outputs = ['src/semester-data.json', 'public/semester-data.json']
}
return options
}
// Get current academic year
function getCurrentAcademicYear() {
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() // 0-based
// Academic year starts in September, but August belongs to the new year
const startYear = currentMonth >= 7 ? currentYear : currentYear - 1
const endYear = startYear + 1
return { startYear, endYear }
}
// Fetch webpage content with redirect support and timeout
async function fetchPage(url) {
const response = await fetch(url, {
headers: {
'User-Agent': 'PHC-Orario-Bot/1.0 (+https://lab.phc.dm.unipi.it/orario)',
'Accept': 'text/html,application/xhtml+xml',
},
redirect: 'follow',
signal: AbortSignal.timeout(15000),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText} fetching ${url}`)
}
return await response.text()
}
// Parse timetable IDs from the schedule page
async function parseTimetableIds() {
try {
const html = await fetchPage(
'https://www.dm.unipi.it/didattica/lezioni-esami/orario-delle-lezioni/',
)
const ids = {}
// Extract links with their text to match correctly
const linkPattern =
/<a[^>]*href="[^"]*linkCalendarioId=([a-f0-9]{24})"[^>]*>([^<]+)<\/a>/g
const links = [...html.matchAll(linkPattern)]
for (const [, id, text] of links) {
const cleanText = text.trim()
if (cleanText === 'Triennale I anno') {
ids['anno-1'] = id
} else if (cleanText === 'Triennale II anno') {
ids['anno-2'] = id
} else if (cleanText === 'Triennale III anno') {
ids['anno-3'] = id
} else if (cleanText === 'Magistrale') {
ids['magistrale'] = id
}
}
const expectedKeys = ['anno-1', 'anno-2', 'anno-3', 'magistrale']
const missing = expectedKeys.filter(k => !ids[k])
if (missing.length > 0) {
console.warn(`Warning: Missing timetable IDs for: ${missing.join(', ')}`)
}
return Object.keys(ids).length > 0 ? ids : null
} catch (error) {
console.error('Error parsing timetable IDs:', error.message)
return null
}
}
// Parse semester start date from calendar page
async function parseSemesterStartDate() {
try {
const { startYear, endYear } = getCurrentAcademicYear()
const currentMonth = new Date().getMonth()
const currentSemester = currentMonth >= 0 && currentMonth <= 6 ? 2 : 1
let url = `https://www.dm.unipi.it/didattica/lezioni-esami/calendario-delle-attivita-didattiche/calendario-delle-attivita-didattiche-a-a-${startYear}-${endYear.toString().slice(-2)}/`
let html
try {
html = await fetchPage(url)
} catch {
// Fallback: check index page to discover link
const indexHtml = await fetchPage(
'https://www.dm.unipi.it/didattica/lezioni-esami/calendario-delle-attivita-didattiche/',
)
const linkMatch = indexHtml.match(
/href="([^"]*calendario-delle-attivita-didattiche-a-a-[^"]*)"/i,
)
if (linkMatch) {
url = linkMatch[1]
html = await fetchPage(url)
} else {
throw new Error('Could not find link to academic calendar')
}
}
// Look for the appropriate semester start date
const semesterText = currentSemester === 1 ? 'I semestre' : 'II semestre'
const semesterRegex = new RegExp(
`Lezioni\\s+${semesterText}.*?(\\d{1,2}\\s+\\w+\\s+\\d{4})`,
'si',
)
const match = html.match(semesterRegex)
if (match) {
return match[1]
} else {
console.error(`Could not find ${semesterText} start date in calendar`)
return null
}
} catch (error) {
console.error('Error parsing semester start date:', error.message)
return null
}
}
// Find the first Monday after a given date string
function getFirstMondayAfter(dateStr) {
const [day, monthName, year] = dateStr.split(' ')
const monthMap = {
gennaio: 0,
febbraio: 1,
marzo: 2,
aprile: 3,
maggio: 4,
giugno: 5,
luglio: 6,
agosto: 7,
settembre: 8,
ottobre: 9,
novembre: 10,
dicembre: 11,
}
const month = monthMap[monthName.toLowerCase()]
if (month === undefined) {
throw new Error(`Unknown month: ${monthName}`)
}
const startDate = new Date(parseInt(year), month, parseInt(day))
const dayOfWeek = startDate.getDay() // 0=Sunday, 1=Monday, etc.
let daysToAdd
if (dayOfWeek === 0) {
daysToAdd = 1 // Sunday -> Monday
} else if (dayOfWeek === 1) {
daysToAdd = 7 // Monday -> Next Monday
} else {
daysToAdd = 8 - dayOfWeek
}
const result = new Date(startDate)
result.setDate(startDate.getDate() + daysToAdd)
return new Date(Date.UTC(result.getFullYear(), result.getMonth(), result.getDate()))
}
// Format date for button text (Italian format)
function formatDateForButton(date) {
const day = date.getUTCDate()
const monthNames = [
'gennaio',
'febbraio',
'marzo',
'aprile',
'maggio',
'giugno',
'luglio',
'agosto',
'settembre',
'ottobre',
'novembre',
'dicembre',
]
const month = monthNames[date.getUTCMonth()]
return `${day} ${month}`
}
// Safe atomic file write
function atomicWriteFileSync(filePath, content) {
const resolvedPath = path.resolve(process.cwd(), filePath)
const dir = path.dirname(resolvedPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
const tmpPath = `${resolvedPath}.tmp.${Date.now()}`
fs.writeFileSync(tmpPath, content, { encoding: 'utf8', mode: 0o644 })
try {
fs.chmodSync(tmpPath, 0o644)
} catch {
// Ignore chmod errors if not supported or not owner
}
fs.renameSync(tmpPath, resolvedPath)
}
// Main execution function
async function main() {
const options = parseArgs()
const [timetableIds, semesterStartDateStr] = await Promise.all([
parseTimetableIds(),
parseSemesterStartDate(),
])
if (!timetableIds) {
throw new Error('Failed to parse timetable IDs from dm.unipi.it')
}
if (!semesterStartDateStr) {
throw new Error('Failed to parse semester start date from dm.unipi.it')
}
const firstMonday = getFirstMondayAfter(semesterStartDateStr)
const academicYear = getCurrentAcademicYear()
const newSemesterData = {
timetableIds,
semesterStartDateStr,
firstMondayDate: firstMonday.toISOString(),
buttonText: `Vai al ${formatDateForButton(firstMonday)}! 🚀`,
lastUpdated: new Date().toISOString(),
academicYear,
}
// Check against existing data to see if anything meaningful changed
let existingData = null
for (const outPath of options.outputs) {
const resolved = path.resolve(process.cwd(), outPath)
if (fs.existsSync(resolved)) {
try {
existingData = JSON.parse(fs.readFileSync(resolved, 'utf8'))
break
} catch {
// Ignore parse errors on old file
}
}
}
const idsChanged =
!existingData ||
JSON.stringify(existingData.timetableIds) !==
JSON.stringify(newSemesterData.timetableIds)
const dateChanged =
!existingData || existingData.firstMondayDate !== newSemesterData.firstMondayDate
const hasMeaningfulChanges = idsChanged || dateChanged
if (options.checkOnly) {
if (hasMeaningfulChanges) {
if (!options.quiet) {
console.log('🔄 New semester data detected.')
console.log(JSON.stringify(newSemesterData, null, 2))
}
process.exit(1)
} else {
if (!options.quiet) {
console.log('✅ Semester data is up to date.')
}
process.exit(0)
}
}
const jsonString = JSON.stringify(newSemesterData, null, 2) + '\n'
for (const outPath of options.outputs) {
atomicWriteFileSync(outPath, jsonString)
if (!options.quiet) {
console.log(`💾 Saved updated data to ${outPath}`)
}
}
if (!options.quiet) {
if (hasMeaningfulChanges) {
console.log('🎉 Semester data successfully updated with new IDs/dates!')
} else {
console.log(
'✅ Timetable IDs and dates unchanged (lastUpdated timestamp refreshed).',
)
}
}
}
export {
getCurrentAcademicYear,
parseTimetableIds,
parseSemesterStartDate,
getFirstMondayAfter,
formatDateForButton,
atomicWriteFileSync,
main,
}
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main().catch(error => {
console.error('💥 Script failed:', error.message)
process.exit(1)
})
}
-16
View File
@@ -1,16 +0,0 @@
{
"timetableIds": {
"anno-1": "687e66d62d13e6001edd366d",
"anno-2": "687e681301ee9300198320e6",
"anno-3": "687e68bb01ee9300198320eb",
"magistrale": "687e6a9f96ea080019d439d5"
},
"semesterStartDateStr": "23 settembre 2026",
"firstMondayDate": "2026-09-28T00:00:00.000Z",
"buttonText": "Vai al 28 settembre! 🚀",
"lastUpdated": "2026-09-02T20:29:22.054Z",
"academicYear": {
"startYear": 2026,
"endYear": 2027
}
}
+50 -11
View File
@@ -530,9 +530,9 @@ body {
}
.content {
height: calc(100vh - 4rem);
height: calc(100dvh - 4rem);
@media screen and (max-width: $device-s-width), (pointer: coarse) {
height: calc(100vh - 8rem);
height: calc(100dvh - 8rem);
}
overflow-y: scroll;
@@ -624,7 +624,7 @@ body {
.schedule-view {
min-height: 100%;
width: 100%;
max-width: 57rem;
max-width: 55rem;
margin: auto;
padding: 0rem 0.5rem;
@@ -644,7 +644,8 @@ body {
border: 1px solid var(--border-600);
border-radius: 10px 10px 0 0;
@media screen and (max-width: $device-s-width), (pointer: coarse) {
@media screen and (max-width: $device-s-width),
(pointer: coarse) {
font-size: 12px;
}
@@ -699,7 +700,10 @@ body {
&.original {
grid-template-columns: auto repeat(5, 1fr);
grid-template-rows: min-content repeat(var(--time-slots), 1fr);
grid-template-rows: min-content repeat(
var(--time-slots),
1fr
);
.transpose-button,
.day-label {
@@ -726,16 +730,22 @@ body {
}
.event-block-wrapper {
grid-row: calc(var(--time-start) + 2) / calc(var(--time-end) + 2);
grid-row: calc(var(--time-start) + 2) /
calc(var(--time-end) + 2);
grid-column: calc(var(--day-position) + 1);
.event-block {
.event-wrapper {
width: calc(100% / var(--of));
height: calc(100% * var(--size) / var(--block-size));
height: calc(
100% * var(--size) / var(--block-size)
);
transform: translateX(calc(100% * var(--index)))
translateY(
calc(100% * var(--relative-start) / var(--size))
calc(
100% * var(--relative-start) /
var(--size)
)
);
}
}
@@ -743,7 +753,10 @@ body {
}
&.transposed {
grid-template-rows: auto repeat(5, 1fr);
grid-template-columns: min-content repeat(var(--time-slots), 1fr);
grid-template-columns: min-content repeat(
var(--time-slots),
1fr
);
.transpose-button,
.day-label {
@@ -779,10 +792,15 @@ body {
.event-block {
.event-wrapper {
height: calc(100% / var(--of));
width: calc(100% * var(--size) / var(--block-size));
width: calc(
100% * var(--size) / var(--block-size)
);
transform: translateY(calc(100% * var(--index)))
translateX(
calc(100% * var(--relative-start) / var(--size))
calc(
100% * var(--relative-start) /
var(--size)
)
);
}
}
@@ -879,6 +897,27 @@ body {
overflow-y: scroll;
}
}
.custom-events-view {
min-height: 100%;
width: 100%;
max-width: 30rem;
margin: auto;
padding: 1rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
textarea {
width: 100%;
min-height: 20rem;
font-size: 20px;
}
}
}
.overlay {
+68 -6
View File
@@ -1,6 +1,7 @@
// Calendar
import { useEffect, useState } from 'preact/hooks'
import { parse } from 'yaml'
// Calendar
export const WEEK_DAYS = [
'Domenica',
@@ -24,8 +25,12 @@ export function hashString(str, seed = 0) {
h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677)
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
h1 =
Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 =
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
Math.imul(h1 ^ (h1 >>> 13), 3266489909)
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
}
@@ -38,7 +43,9 @@ export function prettyCourseName(name) {
.map(word => {
if (word.trim().length === 0) return word
return /(^del|^nel|^di$|^dei$|^con$|^alla$|^per$|^e$|^la$)/.test(word)
return /(^del|^nel|^di$|^dei$|^con$|^alla$|^per$|^e$|^la$)/.test(
word
)
? word
: word[0].toUpperCase() + word.slice(1)
})
@@ -47,7 +54,6 @@ export function prettyCourseName(name) {
.replaceAll('IIi', 'III')
.replaceAll('Iii', 'III')
.replaceAll(/'(.)/g, ({}, letter) => "'" + letter.toUpperCase())
.replaceAll(/\((a|b)\)/g, ({}, letter) => '(' + letter.toUpperCase() + ')')
}
export function prettyAulaName(name) {
@@ -64,6 +70,62 @@ export function prettyProfName(nome, cognome) {
.join('')
}
// Custom events
export function parseCustomEvents(customEventsString) {
try {
const customEvents = []
const parsedEvents = parse(customEventsString)
for (const customCourse in parsedEvents) {
const [_, name, teachers] = /([^\[]*)(?:\[(.*)\])?/.exec(
customCourse
)
const docenti = teachers ? teachers.split(',') : []
for (const customEvent of parsedEvents[customCourse]) {
const [_b, day, startH, startM, endH, endM, label] =
/(lun|lunedì|mar|martedì|mer|mercoledì|gio|giovedì|ven|venerdì)\s*(\d{1,2})(?:\:(\d\d))?\s*-\s*(\d{1,2})(?:\:(\d\d))?\s*(.*)?/i.exec(
customEvent
)
const dayNumber = {
lun: 1,
lunedì: 1,
mar: 2,
martedì: 2,
mer: 3,
mercoledì: 3,
gio: 4,
giovedì: 4,
ven: 5,
venerdì: 5,
}[day.toLowerCase()]
const start = startM
? parseInt(startH) * 60 + parseInt(startM)
: parseInt(startH) * 60
const end = endM
? parseInt(endH) * 60 + parseInt(endM)
: parseInt(endH) * 60
const aule = label ? label.split(',') : []
customEvents.push({
id: name,
name,
docenti,
start,
end,
day: dayNumber,
aule,
})
}
}
return customEvents
} catch (e) {
console.log('ehi', e)
return []
}
}
// JSX
export const withClasses = classes =>
-17
View File
@@ -1,17 +0,0 @@
[Unit]
Description=Daily update of Orario semester timetable IDs and dates
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# NOTE: If your webroot (e.g. orario.volume) is created by Docker (owned by root:root),
# run this service as root by leaving User/Group unset (or User=root).
# The script automatically writes files with world-readable 0644 permissions so Caddy can read them.
# If you prefer running as caddy, ensure 'chown -R caddy:caddy <webroot>' is run after docker builds.
# User=caddy
# Group=caddy
WorkingDirectory=/srv/services/orario
ExecStart=/usr/bin/node /srv/services/orario/src/scripts/update-semester-data.js --output /srv/services/orario/orario.volume/semester-data.json --quiet
StandardOutput=journal
StandardError=journal
-12
View File
@@ -1,12 +0,0 @@
[Unit]
Description=Run Orario semester timetable update daily
[Timer]
# Run every day at 04:30 AM
OnCalendar=*-*-* 04:30:00
# Run on boot if system was off during scheduled time
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
-163
View File
@@ -1,163 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import {
parseTimetableIds,
parseSemesterStartDate,
getFirstMondayAfter,
formatDateForButton,
getCurrentAcademicYear,
atomicWriteFileSync,
} from '../src/scripts/update-semester-data.js';
const execFileAsync = promisify(execFile);
test('Academic Year calculation', () => {
const year = getCurrentAcademicYear();
assert.ok(typeof year.startYear === 'number');
assert.ok(typeof year.endYear === 'number');
assert.equal(year.endYear, year.startYear + 1);
});
test('Live HTML parsing from dm.unipi.it', async t => {
await t.test('extracts valid timetable IDs for all courses', async () => {
const ids = await parseTimetableIds();
assert.ok(ids, 'Timetable IDs should not be null');
const expectedKeys = ['anno-1', 'anno-2', 'anno-3', 'magistrale'];
for (const key of expectedKeys) {
assert.ok(ids[key], `Missing timetable ID for ${key}`);
assert.match(
ids[key],
/^[a-f0-9]{24}$/,
`Timetable ID for ${key} must be a 24-char hex string: ${ids[key]}`
);
}
});
await t.test('extracts valid semester start date from calendar page', async () => {
const startDateStr = await parseSemesterStartDate();
assert.ok(startDateStr, 'Semester start date string should not be null');
assert.match(
startDateStr,
/^\d{1,2}\s+[a-zA-Z]+\s+\d{4}$/,
`Date should match Italian format, got: ${startDateStr}`
);
const firstMonday = getFirstMondayAfter(startDateStr);
assert.ok(firstMonday instanceof Date);
assert.equal(firstMonday.getUTCDay(), 1, 'First Monday must be day 1 (Monday)');
const buttonText = formatDateForButton(firstMonday);
assert.match(buttonText, /^\d{1,2}\s+[a-zA-Z]+$/);
});
});
test('Cineca API verifies live parsed timetable IDs', async () => {
const ids = await parseTimetableIds();
assert.ok(ids, 'Need valid IDs to test Cineca API');
// Test the first year ID against Cineca API
const testId = ids['anno-1'];
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const res = await fetch('https://apache.prod.up.cineca.it/api/Impegni/getImpegniCalendarioPubblico', {
method: 'POST',
headers: {
'content-type': 'application/json;charset=UTF-8',
},
body: JSON.stringify({
mostraImpegniAnnullati: true,
mostraIndisponibilitaTotali: false,
linkCalendarioId: testId,
clienteId: '628de8b9b63679f193b87046',
pianificazioneTemplate: false,
dataInizio: now.toISOString(),
dataFine: nextWeek.toISOString(),
}),
});
assert.equal(res.status, 200, `Cineca API should respond with HTTP 200 for ID ${testId}`);
const data = await res.json();
assert.ok(Array.isArray(data), 'Cineca response should be a JSON array of events');
});
test('Atomic file write safety and schema', async () => {
const tmpPath = path.resolve(process.cwd(), 'tests/scratch-semester-test.json');
try {
const sampleData = {
timetableIds: {
'anno-1': '687e66d62d13e6001edd366d',
'anno-2': '687e681301ee9300198320e6',
'anno-3': '687e68bb01ee9300198320eb',
'magistrale': '687e6a9f96ea080019d439d5',
},
semesterStartDateStr: '23 settembre 2026',
firstMondayDate: '2026-09-28T00:00:00.000Z',
buttonText: 'Vai al 28 settembre! 🚀',
lastUpdated: new Date().toISOString(),
academicYear: { startYear: 2026, endYear: 2027 },
};
atomicWriteFileSync(tmpPath, JSON.stringify(sampleData, null, 2));
assert.ok(fs.existsSync(tmpPath), 'Output file should exist');
const readBack = JSON.parse(fs.readFileSync(tmpPath, 'utf8'));
assert.deepEqual(readBack.timetableIds, sampleData.timetableIds);
assert.equal(readBack.buttonText, sampleData.buttonText);
} finally {
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
});
test('CLI command execution', async t => {
await t.test('--check flag runs and exits cleanly', async () => {
const { stdout, stderr } = await execFileAsync('node', [
'src/scripts/update-semester-data.js',
'--check',
]);
assert.match(stdout, /Semester data is up to date/);
assert.equal(stderr, '');
});
await t.test('--output flag writes to custom path', async () => {
const tmpPath = path.resolve(process.cwd(), 'tests/cli-output-test.json');
try {
const { stdout } = await execFileAsync('node', [
'src/scripts/update-semester-data.js',
'--output',
tmpPath,
'--quiet',
]);
assert.ok(fs.existsSync(tmpPath), 'Target output file was created');
const data = JSON.parse(fs.readFileSync(tmpPath, 'utf8'));
assert.ok(data.timetableIds['anno-1']);
} finally {
if (fs.existsSync(tmpPath)) {
fs.unlinkSync(tmpPath);
}
}
});
});
test('Dev server serves semester-data.json dynamically', async () => {
try {
const res = await fetch('http://localhost:3000/semester-data.json');
if (res.ok) {
const data = await res.json();
assert.ok(data.timetableIds);
assert.ok(data.timetableIds['anno-1']);
assert.ok(data.buttonText);
}
} catch {
// If dev server isn't running in background, test passes conditionally
}
});