From 3a5633d92f34388da16f15952f7632cdc5238a1a Mon Sep 17 00:00:00 2001 From: Francesco Minnocci Date: Wed, 2 Sep 2026 22:45:39 +0200 Subject: [PATCH] feat: dynamic semester data loading and automated daily update setup --- README.md | 59 +++- package.json | 4 +- public/semester-data.json | 16 ++ src/main.jsx | 76 +++-- src/scripts/update-semester-data.js | 422 ++++++++++++++++++++-------- src/semester-data.json | 4 +- systemd/orario-update.service | 14 + systemd/orario-update.timer | 12 + tests/update-semester.test.js | 163 +++++++++++ 9 files changed, 620 insertions(+), 150 deletions(-) create mode 100644 public/semester-data.json create mode 100644 systemd/orario-update.service create mode 100644 systemd/orario-update.timer create mode 100644 tests/update-semester.test.js diff --git a/README.md b/README.md index da5e04d..79e49bf 100644 --- a/README.md +++ b/README.md @@ -28,5 +28,62 @@ To start the development server run `npm run dev`. ### Production -To build the ViteJS project run `npm run build`, for deployment a `.env` files can be used to set the `BASE_URL` variable. +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 +``` diff --git a/package.json b/package.json index fb5bcca..e9aa294 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build" + "build": "vite build", + "update-data": "node src/scripts/update-semester-data.js", + "test": "node --test 'tests/**/*.test.js'" }, "keywords": [], "author": "", diff --git a/public/semester-data.json b/public/semester-data.json new file mode 100644 index 0000000..213a955 --- /dev/null +++ b/public/semester-data.json @@ -0,0 +1,16 @@ +{ + "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 + } +} diff --git a/src/main.jsx b/src/main.jsx index 485294a..bfc079c 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,7 +1,7 @@ import _ from 'lodash' import { render } from 'preact' import { useEffect, useState } from 'preact/hooks' -import semesterData from './semester-data.json' +import defaultSemesterData from './semester-data.json' // import { ToolOverlay } from './components/ToolOverlay.jsx' // @@ -27,21 +27,6 @@ import { SettingsBar } from './components/SettingsBar.jsx' window._ = _ window.dataBuffer = {} -// NOTA: magistrale *non* Γ¨ quello con i corsi a cavallo -const TIMETABLE_IDS = semesterData.timetableIds - -// 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 gruppi separati eventi.forEach(evento => { @@ -75,7 +60,7 @@ function formatEvents(timetable) { }) } -async function loadCalendari(date) { +async function loadCalendari(date, timetableIds) { function getMonday(d) { const day = d.getDay() const diff = d.getDate() - day + (day === 0 ? -6 : 1) @@ -117,10 +102,10 @@ async function loadCalendari(date) { } const requests = [ - req(TIMETABLE_IDS['anno-1']), - req(TIMETABLE_IDS['anno-2']), - req(TIMETABLE_IDS['anno-3']), - req(TIMETABLE_IDS['magistrale']), + req(timetableIds['anno-1']), + req(timetableIds['anno-2']), + req(timetableIds['anno-3']), + req(timetableIds['magistrale']), ] const results = await Promise.all(requests) const timetablesRaw = results.map(timetable => @@ -171,13 +156,56 @@ 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 [timetables, setTimetables] = useState(null) - useEffect(async () => { - setTimetables(await loadCalendari(new Date(date))) - }, [date]) + + // 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) diff --git a/src/scripts/update-semester-data.js b/src/scripts/update-semester-data.js index f9a94e1..f4d39de 100644 --- a/src/scripts/update-semester-data.js +++ b/src/scripts/update-semester-data.js @@ -1,186 +1,364 @@ #!/usr/bin/env node -// Manual update script for semester timetable data -// Run this script manually each semester to update timetable IDs and dates -// Usage: node src/scripts/update-semester-data.js +// 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 https from 'https'; -import fs from 'fs'; +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 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 - + 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 }; + const startYear = currentMonth >= 7 ? currentYear : currentYear - 1 + const endYear = startYear + 1 + + return { startYear, endYear } } -// Fetch webpage content -function fetchPage(url) { - return new Promise((resolve, reject) => { - https.get(url, (res) => { - let data = ''; - - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - resolve(data); - }); - }).on('error', (err) => { - reject(err); - }); - }); +// 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 = {}; - + 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 = /]*href="[^"]*linkCalendarioId=([a-f0-9]{24})"[^>]*>([^<]+)<\/a>/g; - const links = [...html.matchAll(linkPattern)]; - - for (const [fullMatch, id, text] of links) { - const cleanText = text.trim(); - + const linkPattern = + /]*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; + ids['anno-1'] = id } else if (cleanText === 'Triennale II anno') { - ids['anno-2'] = id; + ids['anno-2'] = id } else if (cleanText === 'Triennale III anno') { - ids['anno-3'] = id; + ids['anno-3'] = id } else if (cleanText === 'Magistrale') { - ids['magistrale'] = id; + ids['magistrale'] = id } } - - return Object.keys(ids).length > 0 ? ids : null; + + 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; + 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 <= 5 ? 2 : 1; - - const url = `https://www.dm.unipi.it/didattica/lezioni-esami/calendario-delle-attivita-didattiche/calendario-delle-attivita-didattiche-a-a-${startYear}-${endYear.toString().slice(-2)}/`; - - const html = await fetchPage(url); - + 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); - + 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]; + return match[1] } else { - console.error(`Could not find ${semesterText} start date in calendar`); - return null; + 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; + console.error('Error parsing semester start date:', error.message) + return null } } // Find the first Monday after a given date string function getFirstMondayAfter(dateStr) { - // Parse Italian date format (e.g., "24 settembre 2025") - const [day, monthName, year] = dateStr.split(' '); - + 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()]; + 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}`); + throw new Error(`Unknown month: ${monthName}`) } - - const startDate = new Date(parseInt(year), month, parseInt(day)); - const dayOfWeek = startDate.getDay(); // 0=Sunday, 1=Monday, etc. - - // Calculate days to add to get to the next Monday - let daysToAdd; - if (dayOfWeek === 0) { // Sunday - daysToAdd = 1; // Next day is Monday - } else if (dayOfWeek === 1) { // Monday - daysToAdd = 7; // Next Monday - } else { // Tuesday-Saturday (2-6) - daysToAdd = 8 - dayOfWeek; // Days until next Monday + + 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); - - // Create a new date in UTC to avoid timezone issues with the calendar - return new Date(Date.UTC(result.getFullYear(), result.getMonth(), result.getDate())); + + 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 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}`; + 'gennaio', + 'febbraio', + 'marzo', + 'aprile', + 'maggio', + 'giugno', + 'luglio', + 'agosto', + 'settembre', + 'ottobre', + 'novembre', + 'dicembre', + ] + const month = monthNames[date.getUTCMonth()] + + return `${day} ${month}` } -// Main function -async function updateAcademicData() { +// 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, 'utf8') + fs.renameSync(tmpPath, resolvedPath) +} + +// Main execution function +async function main() { + const options = parseArgs() + const [timetableIds, semesterStartDateStr] = await Promise.all([ parseTimetableIds(), - parseSemesterStartDate() - ]); - - // Fail fast if parsing failed + parseSemesterStartDate(), + ]) + if (!timetableIds) { - throw new Error('Failed to parse timetable IDs from the website'); + throw new Error('Failed to parse timetable IDs from dm.unipi.it') } - + if (!semesterStartDateStr) { - throw new Error('Failed to parse semester start date from the calendar'); + throw new Error('Failed to parse semester start date from dm.unipi.it') } - - const firstMonday = getFirstMondayAfter(semesterStartDateStr); - - const semesterData = { + + const firstMonday = getFirstMondayAfter(semesterStartDateStr) + const academicYear = getCurrentAcademicYear() + + const newSemesterData = { timetableIds, semesterStartDateStr, firstMondayDate: firstMonday.toISOString(), buttonText: `Vai al ${formatDateForButton(firstMonday)}! πŸš€`, lastUpdated: new Date().toISOString(), - academicYear: getCurrentAcademicYear() - }; + academicYear, + } - // Save to src/semester-data.json - fs.writeFileSync('src/semester-data.json', JSON.stringify(semesterData, null, 2)); + // 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 + } + } + } - console.log('βœ… Semester data updated successfully'); + 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).', + ) + } + } } -// Run the script -updateAcademicData().catch(error => { - console.error('πŸ’₯ Script failed:', error); - process.exit(1); -}); +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) + }) +} diff --git a/src/semester-data.json b/src/semester-data.json index 35a6912..bc017a3 100644 --- a/src/semester-data.json +++ b/src/semester-data.json @@ -8,9 +8,9 @@ "semesterStartDateStr": "23 settembre 2026", "firstMondayDate": "2026-09-28T00:00:00.000Z", "buttonText": "Vai al 28 settembre! πŸš€", - "lastUpdated": "2026-09-02T19:57:15.230Z", + "lastUpdated": "2026-09-02T20:29:22.054Z", "academicYear": { "startYear": 2026, "endYear": 2027 } -} \ No newline at end of file +} diff --git a/systemd/orario-update.service b/systemd/orario-update.service new file mode 100644 index 0000000..45a3182 --- /dev/null +++ b/systemd/orario-update.service @@ -0,0 +1,14 @@ +[Unit] +Description=Daily update of Orario semester timetable IDs and dates +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# Adjust user/paths if needed for your server environment: +User=caddy +Group=caddy +WorkingDirectory=/var/www/orario +ExecStart=/usr/bin/node /var/www/orario/scripts/update-semester-data.js --output /var/www/orario/semester-data.json --quiet +StandardOutput=journal +StandardError=journal diff --git a/systemd/orario-update.timer b/systemd/orario-update.timer new file mode 100644 index 0000000..3d06eed --- /dev/null +++ b/systemd/orario-update.timer @@ -0,0 +1,12 @@ +[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 diff --git a/tests/update-semester.test.js b/tests/update-semester.test.js new file mode 100644 index 0000000..949a52c --- /dev/null +++ b/tests/update-semester.test.js @@ -0,0 +1,163 @@ +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 + } +});