Calendario: si vedono tutti gli orari validi, e il PDF ne fa una pagina ciascuno

Il popup mostrava solo l'orario del momento: chi guarda ad agosto vuole sapere
com'e' settembre per intero, e una griglia sola gli fa credere che valga per
sempre. Ora ci sono tutti quelli non ancora scaduti, ognuno con la sua etichetta
- In vigore, oppure la data da cui parte - e quelli passati spariscono da soli.

Il bottone scarica /calendario/tutti.pdf: un orario per pagina, con le giornate
fuori griglia ripetute su ognuna, perche' un foglio stampato da solo deve bastare
a se stesso. Gli id singoli restano validi per mandarne uno solo.

calendarioDaMostrare (uno) diventa calendariDaMostrare (tutti): una funzione
sola, non due che dicono cose simili - e' lo stesso difetto dei due link della
pagina servizi, che erano due espressioni per lo stesso indirizzo e sono
divergute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:22:22 +02:00
parent d03c62641e
commit 69bd17a296
7 changed files with 207 additions and 147 deletions
+93 -74
View File
@@ -1,42 +1,40 @@
---
// Popup a pieno schermo con la griglia delle class in Sala Piccola.
// Popup a pieno schermo con gli orari delle class in Sala Piccola.
//
// Il calendario è ricostruito dal dato (`data/calendari.ts`) e non è l'immagine ricevuta dal
// cliente: si legge da telefono, si stampa, e per correggere una cella non serve rifare uno
// screenshot. Quale griglia compare lo decide la data — quella in vigore, o la prossima
// dichiarando da quando entra.
// I calendari sono ricostruiti dal dato (`data/calendari.ts`) e non sono le immagini ricevute
// dal cliente: si leggono da telefono, si scaricano in PDF, e per correggere una cella non
// serve rifare uno screenshot.
//
// Si vedono tutti gli orari non ancora scaduti, uno sotto l'altro: chi guarda ad agosto vuole
// sapere com'è settembre per intero. Quelli passati spariscono da soli.
//
// Si apre da qualunque elemento con `data-apri-calendario` nella stessa pagina.
import { calendarioDaMostrare, avvisiAttivi, GIORNI, DISCIPLINE, type Giorno } from '../data/calendari';
import { calendariDaMostrare, avvisiAttivi, GIORNI, DISCIPLINE, type Calendario, type Giorno } from '../data/calendari';
import { t } from '../lib/content';
const adesso = new Date();
const scelta = calendarioDaMostrare(adesso);
const mostrati = calendariDaMostrare(adesso);
// Giornate fuori griglia (eventi, chiusure): spariscono da sole quando sono passate.
const avvisi = avvisiAttivi(adesso);
const cal = scelta?.cal ?? null;
const dal = cal ? new Date(cal.from).toLocaleDateString('it-IT', {
const dataDi = (iso: string) => new Date(iso).toLocaleDateString('it-IT', {
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome',
}) : '';
// Discipline davvero presenti in questa griglia: una legenda che elenca anche ciò che non c'è
// fa cercare lezioni che non esistono.
const presenti = cal
? DISCIPLINE.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d)))
: [];
});
// Legenda per griglia: elencare discipline che lì non compaiono farebbe cercare lezioni
// che quel periodo non ha.
const disciplinePresenti = (cal: Calendario) =>
DISCIPLINE.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d)));
---
{cal && (
<dialog class="cal-dlg" aria-label={`${cal.sala} — ${cal.titolo}`}>
{mostrati.length > 0 && (
<dialog class="cal-dlg" aria-label="Calendario delle class">
<div class="cal-dlg__box">
<header class="cal-dlg__top">
<div>
<p class="cal-dlg__sala">{cal.sala}</p>
<h2 class="cal-dlg__titolo">{cal.titolo}</h2>
{scelta!.futuro && (
<p class="cal-dlg__futuro">{t('calendario.futuro-label')} {dal}</p>
)}
<p class="cal-dlg__sala">{mostrati[0].cal.sala}</p>
<h2 class="cal-dlg__titolo">Calendario delle class</h2>
</div>
<div class="cal-dlg__azioni">
<a class="cal-dlg__btn" href={`/calendario/${cal.id}.pdf`} download>{t('calendario.pdf-label')}</a>
<a class="cal-dlg__btn" href="/calendario/tutti.pdf" download>{t('calendario.pdf-label')}</a>
<button type="button" class="cal-dlg__chiudi" data-chiudi-calendario aria-label={t('calendario.chiudi-label')}>×</button>
</div>
</header>
@@ -50,43 +48,55 @@ const presenti = cal
)}
<div class="cal-dlg__scroll">
<table class="cal-tab">
<thead>
<tr>
<th scope="col" class="cal-tab__ora-h">{cal.sala}</th>
{GIORNI.map((g) => (
<th scope="col" class:list={['cal-tab__gg', cal.chiusi.includes(g) && 'is-chiuso']}>
{g}
{cal.noteGiorno?.[g] && <span class="cal-tab__nota">{cal.noteGiorno[g]}</span>}
</th>
{mostrati.map(({ cal, stato }) => (
<section class="cal-blocco">
<div class="cal-blocco__top">
<h3 class="cal-blocco__titolo">{cal.titolo}</h3>
<span class:list={['cal-tag', stato === 'in-corso' && 'is-ora']}>
{stato === 'in-corso' ? 'In vigore' : `${t('calendario.futuro-label')} ${dataDi(cal.from)}`}
</span>
</div>
<table class="cal-tab">
<thead>
<tr>
<th scope="col" class="cal-tab__ora-h">Ora</th>
{GIORNI.map((g) => (
<th scope="col" class:list={['cal-tab__gg', cal.chiusi.includes(g) && 'is-chiuso']}>
{g}
{cal.noteGiorno?.[g] && <span class="cal-tab__nota">{cal.noteGiorno[g]}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{cal.righe.map((r) => (
<tr class:list={[r.clou && 'is-clou']}>
<th scope="row" class="cal-tab__ora">{r.ora}</th>
{GIORNI.map((g: Giorno) => {
const chiuso = cal.chiusi.includes(g);
const lezione = r.celle[g];
return (
<td class:list={['cal-tab__cella', chiuso && 'is-chiuso', lezione && `is-${lezione.toLowerCase()}`]}>
{chiuso ? <span class="cal-tab__chiuso-txt">{t('calendario.chiuso-label')}</span> : lezione ?? ''}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<ul class="cal-legenda">
{disciplinePresenti(cal).map((d) => (
<li class={`is-${d.toLowerCase()}`}>{d}</li>
))}
</tr>
</thead>
<tbody>
{cal.righe.map((r) => (
<tr class:list={[r.clou && 'is-clou']}>
<th scope="row" class="cal-tab__ora">{r.ora}</th>
{GIORNI.map((g: Giorno) => {
const chiuso = cal.chiusi.includes(g);
const lezione = r.celle[g];
return (
<td class:list={['cal-tab__cella', chiuso && 'is-chiuso', lezione && `is-${lezione.toLowerCase()}`]}>
{chiuso ? <span class="cal-tab__chiuso-txt">{t('calendario.chiuso-label')}</span> : lezione ?? ''}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</ul>
</section>
))}
</div>
<footer class="cal-dlg__giu">
<ul class="cal-legenda">
{presenti.map((d) => (
<li class={`is-${d.toLowerCase()}`}>{d}</li>
))}
</ul>
<p class="cal-dlg__nota">{t('calendario.nota')}</p>
</footer>
</div>
@@ -104,9 +114,9 @@ const presenti = cal
dlg.querySelector('[data-chiudi-calendario]')?.addEventListener('click', () => dlg.close());
// Click sul fondale: il target è il dialog stesso solo quando si preme fuori dal riquadro.
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
// "Scarica in PDF" è un link a `/calendario/<id>.pdf`, cioè un file vero generato dal
// server: si salva sul telefono e si gira su WhatsApp. Il CSS di stampa più sotto resta
// per chi preme Ctrl+P col popup aperto.
// "Scarica in PDF" è un link a /calendario/tutti.pdf, cioè un file vero generato dal
// server, un orario per pagina: si salva sul telefono e si gira su WhatsApp. Il CSS di
// stampa più sotto resta per chi preme Ctrl+P col popup aperto.
}
</script>
@@ -126,10 +136,6 @@ const presenti = cal
letter-spacing: .25em; text-transform: uppercase; color: var(--c-accent-dark);
}
.cal-dlg__titolo { margin: 6px 0 0; font-size: 1.5rem; }
.cal-dlg__futuro {
margin: 6px 0 0; font-family: var(--font-heading); font-size: .72rem; letter-spacing: .12em;
text-transform: uppercase; color: var(--c-text-light);
}
.cal-dlg__azioni { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.cal-dlg__btn {
display: inline-block; font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
@@ -144,8 +150,8 @@ const presenti = cal
}
.cal-dlg__chiudi:hover { border-color: var(--c-accent); color: var(--c-accent-dark); }
/* Giornate fuori griglia: stanno sopra la tabella perché sono l'eccezione a ciò che la
tabella dice, e sotto passerebbero per una postilla. */
/* Giornate fuori griglia: stanno sopra le tabelle perché sono l'eccezione a ciò che le
tabelle dicono, e sotto passerebbero per una postilla. */
.cal-avvisi {
list-style: none; margin: 18px 0 0; padding: 12px 16px; background: var(--c-bg-alt);
border-left: 3px solid var(--c-accent); display: flex; flex-direction: column; gap: 4px;
@@ -153,13 +159,24 @@ const presenti = cal
.cal-avvisi li { font-size: .84rem; color: var(--c-text); }
.cal-avvisi strong { color: var(--c-heading); }
.cal-dlg__scroll { flex: 1; overflow: auto; margin: 18px 0 0; }
.cal-blocco + .cal-blocco { margin-top: 34px; padding-top: 26px; border-top: 1px solid #e4e0d9; }
.cal-blocco__top { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; margin-bottom: 10px; }
.cal-blocco__titolo { margin: 0; font-size: 1.05rem; }
.cal-tag {
font-family: var(--font-heading); font-size: .62rem; font-weight: 600; letter-spacing: .14em;
text-transform: uppercase; padding: 4px 9px; background: var(--c-bg-alt); color: var(--c-text-light);
}
/* L'orario valido adesso si distingue a colpo d'occhio da quello che comincerà dopo: senza,
due griglie affiancate si leggono come alternative fra cui scegliere. */
.cal-tag.is-ora { background: var(--c-accent); color: #fff; }
/* La griglia ha otto colonne: su telefono scorre in orizzontale invece di comprimersi
fino a diventare illeggibile, e la colonna delle ore resta ferma. */
.cal-dlg__scroll { flex: 1; overflow: auto; margin: 22px 0 0; }
.cal-tab { border-collapse: collapse; width: 100%; min-width: 720px; }
.cal-tab th, .cal-tab td { border: 1px solid #e4e0d9; padding: 9px 10px; text-align: left; }
.cal-tab thead th {
position: sticky; top: 0; z-index: 2; background: var(--c-bg-alt);
background: var(--c-bg-alt);
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .16em;
text-transform: uppercase; color: var(--c-heading);
}
@@ -169,7 +186,7 @@ const presenti = cal
}
.cal-tab__ora-h { color: var(--c-accent-dark) !important; }
.cal-tab__ora {
position: sticky; left: 0; z-index: 1; background: var(--c-bg);
background: var(--c-bg);
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; color: var(--c-heading);
white-space: nowrap;
}
@@ -185,8 +202,7 @@ const presenti = cal
.cal-tab__chiuso-txt { font-size: .62rem; letter-spacing: .12em; color: var(--c-text-light); }
.cal-tab thead th.is-chiuso { color: var(--c-text-light); }
.cal-dlg__giu { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 24px; padding-top: 16px; }
.cal-legenda { display: flex; flex-wrap: wrap; gap: 8px 16px; list-style: none; margin: 0; padding: 0; }
.cal-legenda { display: flex; flex-wrap: wrap; gap: 8px 16px; list-style: none; margin: 12px 0 0; padding: 0; }
.cal-legenda li {
font-family: var(--font-heading); font-size: .66rem; letter-spacing: .12em; padding: 5px 10px;
color: var(--c-heading);
@@ -195,11 +211,13 @@ const presenti = cal
.cal-legenda li.is-pilates { background: #f6efe0; box-shadow: inset 3px 0 0 #cbb98f; }
.cal-legenda li.is-yoga { background: #e3eadf; box-shadow: inset 3px 0 0 #8ba57f; }
.cal-legenda li.is-mobility { background: #f0e3e3; box-shadow: inset 3px 0 0 #b78e8e; }
.cal-dlg__giu { padding-top: 16px; }
.cal-dlg__nota { margin: 0; font-size: .8rem; color: var(--c-text-light); }
/* Stampa (ossia "Salva come PDF"): resta solo la griglia, in orizzontale e a piena pagina.
`visibility` invece di `display` perché il dialog è annidato nel corpo della pagina e
nascondere gli antenati nasconderebbe anche lui. */
/* Stampa dalla pagina (Ctrl+P col popup aperto): resta solo il calendario, un orario per
foglio. Il bottone scarica invece il PDF generato dal server, che è il documento buono da
girare. `visibility` invece di `display` perché il dialog è annidato nel corpo della
pagina e nascondere gli antenati nasconderebbe anche lui. */
@media print {
@page { size: A4 landscape; margin: 10mm; }
body { visibility: hidden; }
@@ -209,8 +227,9 @@ const presenti = cal
.cal-dlg__box { padding: 0; height: auto; }
.cal-dlg__azioni { display: none; }
.cal-dlg__scroll { overflow: visible; }
.cal-blocco { break-inside: avoid; }
.cal-blocco + .cal-blocco { break-before: page; border-top: 0; padding-top: 0; }
.cal-tab { min-width: 0; }
.cal-tab thead th, .cal-tab__ora { position: static; }
.cal-tab th, .cal-tab td { padding: 5px 6px; }
.cal-tab__cella { font-size: .62rem; }
}
+22 -16
View File
@@ -149,20 +149,26 @@ export function avvisiAttivi(now: Date, list: AvvisoCalendario[] = avvisi): Avvi
/** Servizi dalla cui pagina si apre il calendario della sala. */
export const SERVIZI_CON_CALENDARIO = ['performance-class', 'holistic-class'];
/**
* Il calendario da mostrare: quello in vigore adesso, oppure — se nessuno lo è ancora — il
* primo che entrerà in vigore, segnalato come futuro. `null` solo se non ce n'è nessuno da
* mostrare, e in quel caso la voce non compare in pagina.
*
* Serve il ripiego sul futuro perché il link esiste **prima** che il primo orario cominci: un
* "nessun calendario disponibile" ad agosto non direbbe niente a chi vuole sapere gli orari
* di settembre.
*/
export function calendarioDaMostrare(now: Date, list: Calendario[] = calendari):
{ cal: Calendario; futuro: boolean } | null {
const t = now.getTime();
const attivo = list.find((c) => t >= Date.parse(c.from) && (c.to === null || t <= Date.parse(c.to)));
if (attivo) return { cal: attivo, futuro: false };
const prossimi = list.filter((c) => Date.parse(c.from) > t).sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
return prossimi.length ? { cal: prossimi[0], futuro: true } : null;
export interface CalendarioMostrato {
cal: Calendario;
stato: 'in-corso' | 'futuro';
}
/**
* I calendari da mostrare: quello in vigore e quelli che verranno, in ordine di partenza.
*
* Si vedono **tutti insieme**, non solo quello del momento: chi guarda a fine agosto vuole
* sapere com'è settembre per intero, e vedere una griglia sola gli farebbe credere che sia
* quella per sempre. Ognuna porta il proprio stato, così l'orario in vigore adesso non si
* confonde con quello che comincerà dopo.
*
* Gli orari **scaduti spariscono da soli**: dal 21 settembre resta il solo foglio valido, e
* nessuno rischia di leggere le lezioni di un periodo finito.
*/
export function calendariDaMostrare(now: Date, list: Calendario[] = calendari): CalendarioMostrato[] {
const t = now.getTime();
return list
.filter((c) => c.to === null || t <= Date.parse(c.to))
.sort((a, b) => Date.parse(a.from) - Date.parse(b.from))
.map((cal) => ({ cal, stato: t >= Date.parse(cal.from) ? 'in-corso' : 'futuro' }));
}
+26 -10
View File
@@ -24,21 +24,29 @@ const C_BORDO = '#cfc7b8'; // più scuro del fondo delle celle: due lezioni ugua
const C_ACCENTO = '#9c8b70';
const C_ALT = '#f5f3f0';
export const nomeFilePdf = (cal: Calendario): string =>
`insanitylab-calendario-${cal.id}.pdf`;
/** Un solo orario: il nome porta il suo id. Più orari insieme: il nome è generico. */
export const nomeFilePdf = (cals: Calendario[]): string =>
cals.length === 1 ? `insanitylab-calendario-${cals[0].id}.pdf` : 'insanitylab-calendario-class.pdf';
/**
* Genera il PDF e restituisce il file completo. Il documento è una pagina sola: la griglia sta
* in A4 orizzontale, e spezzarla su due pagine renderebbe illeggibile proprio ciò che si va a
* cercare — l'incrocio fra un giorno e un'ora.
* Genera il PDF e restituisce il file completo: **un orario per pagina**.
*
* Ogni griglia sta in una pagina intera perché spezzarla renderebbe illeggibile proprio ciò
* che si va a cercare — l'incrocio fra un giorno e un'ora; e due orari sullo stesso foglio si
* confonderebbero fra loro. Le giornate fuori griglia si ripetono su ogni pagina: un foglio
* stampato da solo deve bastare a sé stesso.
*/
export function generaCalendarioPdf(cal: Calendario, avvisi: AvvisoCalendario[] = []): Promise<Buffer> {
export function generaCalendariPdf(cals: Calendario[], avvisi: AvvisoCalendario[] = []): Promise<Buffer> {
const doc = new PDFDocument({
size: 'A4',
layout: 'landscape',
margin: 32,
info: {
Title: `InsanityLab — ${cal.sala}, ${cal.titolo}`,
// Il titolo è quello che compare nella scheda del lettore PDF e nel nome suggerito dai
// telefoni: con un orario solo dice quale, con più orari dice il periodo coperto.
Title: cals.length === 1
? `InsanityLab — ${cals[0].sala}, ${cals[0].titolo}`
: `InsanityLab — Calendario delle class (${cals.map((c) => c.titolo).join('; ')})`,
Author: 'InsanityLab',
Subject: 'Calendario delle class',
},
@@ -51,6 +59,17 @@ export function generaCalendarioPdf(cal: Calendario, avvisi: AvvisoCalendario[]
doc.on('error', rifiuta);
});
cals.forEach((cal, indice) => {
if (indice > 0) doc.addPage();
disegnaPagina(doc, cal, avvisi);
});
doc.end();
return finito;
}
/** Una pagina: testata, griglia, legenda, giornate fuori griglia, piè di pagina. */
function disegnaPagina(doc: PDFKit.PDFDocument, cal: Calendario, avvisi: AvvisoCalendario[]): void {
const x0 = doc.page.margins.left;
const larghezzaUtile = doc.page.width - doc.page.margins.left - doc.page.margins.right;
@@ -165,7 +184,4 @@ export function generaCalendarioPdf(cal: Calendario, avvisi: AvvisoCalendario[]
);
doc.font('Helvetica').fontSize(7).fillColor(C_ACCENTO)
.text('insanitylab.it', x0, yAvvisi + 14, { width: larghezzaUtile, align: 'right' });
doc.end();
return finito;
}
+17 -9
View File
@@ -1,29 +1,37 @@
import type { APIRoute } from 'astro';
import { avvisiAttivi, calendari } from '../../data/calendari';
import { generaCalendarioPdf, nomeFilePdf } from '../../lib/calendario-pdf';
import { avvisiAttivi, calendari, calendariDaMostrare } from '../../data/calendari';
import { generaCalendariPdf, nomeFilePdf } from '../../lib/calendario-pdf';
export const prerender = false;
// Il PDF del calendario, come file scaricabile: `/calendario/<id>.pdf`.
// Il PDF degli orari, come file scaricabile.
//
// /calendario/tutti.pdf tutti gli orari validi, uno per pagina
// /calendario/settembre-1-18.pdf un orario solo
//
// Pubblico come il resto del sito — è lo stesso orario che chiunque legge nel popup, e un file
// dietro autenticazione non si potrebbe girare su WhatsApp, che è come questo centro
// comunica coi clienti.
// dietro autenticazione non si potrebbe girare su WhatsApp, che è come questo centro comunica
// coi clienti.
export const GET: APIRoute = async ({ params }) => {
const cal = calendari.find((c) => c.id === params.id);
// "tutti" segue la stessa regola della pagina: gli orari scaduti non ci sono. Un PDF che si
// porta dietro il periodo finito farebbe cercare lezioni che non esistono più.
const cals = params.id === 'tutti'
? calendariDaMostrare(new Date()).map((m) => m.cal)
: calendari.filter((c) => c.id === params.id);
// Un id sconosciuto è 404, non un PDF vuoto: un file che si apre e non contiene niente fa
// credere che il calendario sia vuoto.
if (!cal) return new Response('Calendario inesistente.', { status: 404 });
if (cals.length === 0) return new Response('Calendario inesistente.', { status: 404 });
// Le giornate fuori griglia stanno anche nel PDF: è il file che gira su WhatsApp, e chi lo
// riceve non ha davanti la pagina dove sono scritte.
const pdf = await generaCalendarioPdf(cal, avvisiAttivi(new Date()));
const pdf = await generaCalendariPdf(cals, avvisiAttivi(new Date()));
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
// `attachment` perché il bottone dice "scarica": senza, il browser lo aprirebbe in una
// scheda e su telefono resterebbe lì, senza finire fra i file.
'Content-Disposition': `attachment; filename="${nomeFilePdf(cal)}"`,
'Content-Disposition': `attachment; filename="${nomeFilePdf(cals)}"`,
'Content-Length': String(pdf.length),
// Un'ora: basta a non rigenerarlo a ogni click, e non abbastanza perché un avviso ormai
// scaduto (le giornate fuori griglia spariscono da sole) resti in giro un giorno intero.
+2 -2
View File
@@ -8,7 +8,7 @@ import { serviceIcons } from '../../data/service-icons';
import T from '../../components/content/T.astro';
import TImg from '../../components/content/TImg.astro';
import CalendarioModal from '../../components/CalendarioModal.astro';
import { calendarioDaMostrare, SERVIZI_CON_CALENDARIO } from '../../data/calendari';
import { calendariDaMostrare, SERVIZI_CON_CALENDARIO } from '../../data/calendari';
import { t } from '../../lib/content';
export const prerender = false;
@@ -18,7 +18,7 @@ const slug = service.slug;
// Il calendario della sala si apre dalle pagine delle class. La voce compare solo se c'è
// davvero una griglia da mostrare (in vigore o prossima): un link che apre un popup vuoto è
// peggio di nessun link.
const conCalendario = SERVIZI_CON_CALENDARIO.includes(slug) && calendarioDaMostrare(new Date()) !== null;
const conCalendario = SERVIZI_CON_CALENDARIO.includes(slug) && calendariDaMostrare(new Date()).length > 0;
const others = services.filter((p) => p.slug !== service.slug && p.grid !== false);
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
+31 -32
View File
@@ -1,48 +1,47 @@
import { describe, it, expect } from 'vitest';
import { avvisi, avvisiAttivi, calendari, calendarioDaMostrare, DISCIPLINE, GIORNI, type Giorno } from '../src/data/calendari';
import { avvisi, avvisiAttivi, calendari, calendariDaMostrare, DISCIPLINE, GIORNI, type Giorno } from '../src/data/calendari';
const d = (iso: string) => new Date(iso);
describe('calendarioDaMostrare', () => {
it('in vigore: mostra quello del periodo', () => {
expect(calendarioDaMostrare(d('2026-09-05T10:00:00+02:00'))).toMatchObject({
cal: { id: 'settembre-1-18' }, futuro: false,
});
expect(calendarioDaMostrare(d('2026-09-25T10:00:00+02:00'))).toMatchObject({
cal: { id: 'settembre-21-in-poi' }, futuro: false,
});
// `to: null` = a tempo indeterminato: fra un anno vale ancora quello.
expect(calendarioDaMostrare(d('2027-03-01T10:00:00+02:00'))).toMatchObject({
cal: { id: 'settembre-21-in-poi' }, futuro: false,
});
describe('calendariDaMostrare', () => {
const idDi = (quando: string) => calendariDaMostrare(d(quando)).map((m) => `${m.cal.id}:${m.stato}`);
// Si vedono tutti insieme, non solo quello del momento: chi guarda ad agosto vuole sapere
// com'è settembre per intero, e una griglia sola gli farebbe credere che valga per sempre.
it('prima dell\'inizio: entrambi, marcati futuri', () => {
expect(idDi('2026-08-17T10:00:00+02:00'))
.toEqual(['settembre-1-18:futuro', 'settembre-21-in-poi:futuro']);
});
// Il link esiste prima che il primo orario cominci: ad agosto deve mostrare gli orari di
// settembre dichiarando che entrano dopo, non un "nessun calendario" che non dice niente.
it('prima dell\'inizio: mostra il prossimo, marcato come futuro', () => {
expect(calendarioDaMostrare(d('2026-08-17T10:00:00+02:00'))).toMatchObject({
cal: { id: 'settembre-1-18' }, futuro: true,
});
it('durante il primo periodo: il primo è in corso, il secondo resta annunciato', () => {
expect(idDi('2026-09-05T10:00:00+02:00'))
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
});
// Un orario scaduto non deve restare in pagina: farebbe cercare lezioni che non ci sono più.
it('finito il primo periodo, il primo orario sparisce', () => {
expect(idDi('2026-09-25T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
expect(idDi('2027-03-01T10:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
});
// 19 e 20 settembre non stanno in nessuna griglia perché quei due giorni non hanno class:
// il 19 c'è la presentazione della nuova apertura, il 20 si sta chiusi (Adriano, 17/08).
// La griglia mostrata è quindi quella del 21, marcata futura, e l'informazione sui due
// giorni arriva dagli avvisi qui sotto.
it('nei due giorni fuori griglia (19-20 settembre) mostra il prossimo', () => {
expect(calendarioDaMostrare(d('2026-09-19T10:00:00+02:00'))).toMatchObject({
cal: { id: 'settembre-21-in-poi' }, futuro: true,
});
expect(calendarioDaMostrare(d('2026-09-20T23:00:00+02:00'))).toMatchObject({
cal: { id: 'settembre-21-in-poi' }, futuro: true,
});
it('nei due giorni fuori griglia resta il solo orario del 21, annunciato', () => {
expect(idDi('2026-09-19T10:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
expect(idDi('2026-09-20T23:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
});
it('confini al secondo, in ora italiana', () => {
expect(calendarioDaMostrare(d('2026-09-18T23:59:59+02:00'))?.cal.id).toBe('settembre-1-18');
expect(calendarioDaMostrare(d('2026-09-21T00:00:00+02:00'))).toMatchObject({
cal: { id: 'settembre-21-in-poi' }, futuro: false,
});
expect(idDi('2026-09-18T23:59:59+02:00'))
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
expect(idDi('2026-09-19T00:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
expect(idDi('2026-09-21T00:00:00+02:00')).toEqual(['settembre-21-in-poi:in-corso']);
});
it('l\'ordine dell\'elenco di partenza non cambia il risultato', () => {
const rovescio = [...calendari].reverse();
expect(calendariDaMostrare(d('2026-09-05T10:00:00+02:00'), rovescio).map((m) => m.cal.id))
.toEqual(['settembre-1-18', 'settembre-21-in-poi']);
});
});
+16 -4
View File
@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest';
import { avvisi, calendari } from '../src/data/calendari';
import { generaCalendarioPdf, nomeFilePdf } from '../src/lib/calendario-pdf';
import { generaCalendariPdf, nomeFilePdf } from '../src/lib/calendario-pdf';
describe('PDF del calendario', () => {
for (const cal of calendari) {
it(`${cal.id}: produce un PDF valido`, async () => {
const pdf = await generaCalendarioPdf(cal);
const pdf = await generaCalendariPdf([cal]);
// Un PDF comincia sempre con %PDF- e finisce con %%EOF: se il documento non venisse
// chiuso (`doc.end()` mancante, o un errore a metà) il file arriverebbe monco al
// browser, che mostrerebbe una pagina bianca invece di un errore.
@@ -20,7 +20,7 @@ describe('PDF del calendario', () => {
// /Type /Pages, che dichiara quante pagine contiene.
it('sta in una pagina sola, anche con le giornate fuori griglia in fondo', async () => {
for (const cal of calendari) {
const pdf = await generaCalendarioPdf(cal, avvisi);
const pdf = await generaCalendariPdf([cal], avvisi);
const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1'));
expect(conteggio, `${cal.id}: numero di pagine non dichiarato`).not.toBeNull();
expect(Number(conteggio![1]), `${cal.id}: il calendario occupa più di una pagina`).toBe(1);
@@ -28,8 +28,20 @@ describe('PDF del calendario', () => {
});
it('il nome del file distingue i due periodi', () => {
const nomi = calendari.map(nomeFilePdf);
const nomi = calendari.map((c) => nomeFilePdf([c]));
expect(new Set(nomi).size).toBe(nomi.length);
for (const n of nomi) expect(n).toMatch(/^insanitylab-calendario-[a-z0-9-]+\.pdf$/);
// Più orari insieme: un nome che nominasse solo il primo farebbe credere che dentro ci
// sia quello e basta.
expect(nomeFilePdf(calendari)).toBe('insanitylab-calendario-class.pdf');
});
// Un orario per pagina: due griglie sullo stesso foglio si confonderebbero fra loro, e
// spezzarne una a metà toglie proprio ciò che si va a cercare, l'incrocio giorno/ora.
it('con più orari fa una pagina per ciascuno', async () => {
const pdf = await generaCalendariPdf(calendari, avvisi);
const conteggio = /\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/s.exec(pdf.toString('latin1'));
expect(conteggio).not.toBeNull();
expect(Number(conteggio![1])).toBe(calendari.length);
});
});