Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddf325c03 | ||
|
|
973acad17a |
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"printWidth": 90,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "consistent",
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"arrowParens": "avoid",
|
||||
"proseWrap": "always"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
+40
@@ -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
@@ -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",
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -24,6 +24,7 @@ const viewModeMap = {
|
||||
|
||||
export const EventsView = ({ mode, source, ...viewProps }) => {
|
||||
// const Mode = viewModeMap[mode]
|
||||
|
||||
|
||||
if (source === 'orario') {
|
||||
return (
|
||||
|
||||
+15
-13
@@ -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 & Contatti</h3>
|
||||
<p>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')}–
|
||||
{format(course.end, 'H:mm')} {course.aule.join(', ')}
|
||||
{WEEK_DAYS[course.day]} {course.start / 60}:
|
||||
{String(course.start % 60).padStart(2, '0')}
|
||||
–
|
||||
{course.end / 60}:
|
||||
{String(course.end % 60).padStart(2, '0')}{' '}
|
||||
{course.aule.join(', ')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user