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:
@@ -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
|
// I calendari sono ricostruiti dal dato (`data/calendari.ts`) e non sono le immagini ricevute
|
||||||
// cliente: si legge da telefono, si stampa, e per correggere una cella non serve rifare uno
|
// dal cliente: si leggono da telefono, si scaricano in PDF, e per correggere una cella non
|
||||||
// screenshot. Quale griglia compare lo decide la data — quella in vigore, o la prossima
|
// serve rifare uno screenshot.
|
||||||
// dichiarando da quando entra.
|
//
|
||||||
|
// 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.
|
// 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';
|
import { t } from '../lib/content';
|
||||||
|
|
||||||
const adesso = new Date();
|
const adesso = new Date();
|
||||||
const scelta = calendarioDaMostrare(adesso);
|
const mostrati = calendariDaMostrare(adesso);
|
||||||
// Giornate fuori griglia (eventi, chiusure): spariscono da sole quando sono passate.
|
// Giornate fuori griglia (eventi, chiusure): spariscono da sole quando sono passate.
|
||||||
const avvisi = avvisiAttivi(adesso);
|
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',
|
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome',
|
||||||
}) : '';
|
});
|
||||||
// Discipline davvero presenti in questa griglia: una legenda che elenca anche ciò che non c'è
|
// Legenda per griglia: elencare discipline che lì non compaiono farebbe cercare lezioni
|
||||||
// fa cercare lezioni che non esistono.
|
// che quel periodo non ha.
|
||||||
const presenti = cal
|
const disciplinePresenti = (cal: Calendario) =>
|
||||||
? DISCIPLINE.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d)))
|
DISCIPLINE.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d)));
|
||||||
: [];
|
|
||||||
---
|
---
|
||||||
{cal && (
|
{mostrati.length > 0 && (
|
||||||
<dialog class="cal-dlg" aria-label={`${cal.sala} — ${cal.titolo}`}>
|
<dialog class="cal-dlg" aria-label="Calendario delle class">
|
||||||
<div class="cal-dlg__box">
|
<div class="cal-dlg__box">
|
||||||
<header class="cal-dlg__top">
|
<header class="cal-dlg__top">
|
||||||
<div>
|
<div>
|
||||||
<p class="cal-dlg__sala">{cal.sala}</p>
|
<p class="cal-dlg__sala">{mostrati[0].cal.sala}</p>
|
||||||
<h2 class="cal-dlg__titolo">{cal.titolo}</h2>
|
<h2 class="cal-dlg__titolo">Calendario delle class</h2>
|
||||||
{scelta!.futuro && (
|
|
||||||
<p class="cal-dlg__futuro">{t('calendario.futuro-label')} {dal}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="cal-dlg__azioni">
|
<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>
|
<button type="button" class="cal-dlg__chiudi" data-chiudi-calendario aria-label={t('calendario.chiudi-label')}>×</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -50,43 +48,55 @@ const presenti = cal
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div class="cal-dlg__scroll">
|
<div class="cal-dlg__scroll">
|
||||||
<table class="cal-tab">
|
{mostrati.map(({ cal, stato }) => (
|
||||||
<thead>
|
<section class="cal-blocco">
|
||||||
<tr>
|
<div class="cal-blocco__top">
|
||||||
<th scope="col" class="cal-tab__ora-h">{cal.sala}</th>
|
<h3 class="cal-blocco__titolo">{cal.titolo}</h3>
|
||||||
{GIORNI.map((g) => (
|
<span class:list={['cal-tag', stato === 'in-corso' && 'is-ora']}>
|
||||||
<th scope="col" class:list={['cal-tab__gg', cal.chiusi.includes(g) && 'is-chiuso']}>
|
{stato === 'in-corso' ? 'In vigore' : `${t('calendario.futuro-label')} ${dataDi(cal.from)}`}
|
||||||
{g}
|
</span>
|
||||||
{cal.noteGiorno?.[g] && <span class="cal-tab__nota">{cal.noteGiorno[g]}</span>}
|
</div>
|
||||||
</th>
|
|
||||||
|
<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>
|
</ul>
|
||||||
</thead>
|
</section>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="cal-dlg__giu">
|
<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>
|
<p class="cal-dlg__nota">{t('calendario.nota')}</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,9 +114,9 @@ const presenti = cal
|
|||||||
dlg.querySelector('[data-chiudi-calendario]')?.addEventListener('click', () => dlg.close());
|
dlg.querySelector('[data-chiudi-calendario]')?.addEventListener('click', () => dlg.close());
|
||||||
// Click sul fondale: il target è il dialog stesso solo quando si preme fuori dal riquadro.
|
// 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(); });
|
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
|
// "Scarica in PDF" è un link a /calendario/tutti.pdf, cioè un file vero generato dal
|
||||||
// server: si salva sul telefono e si gira su WhatsApp. Il CSS di stampa più sotto resta
|
// server, un orario per pagina: si salva sul telefono e si gira su WhatsApp. Il CSS di
|
||||||
// per chi preme Ctrl+P col popup aperto.
|
// stampa più sotto resta per chi preme Ctrl+P col popup aperto.
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -126,10 +136,6 @@ const presenti = cal
|
|||||||
letter-spacing: .25em; text-transform: uppercase; color: var(--c-accent-dark);
|
letter-spacing: .25em; text-transform: uppercase; color: var(--c-accent-dark);
|
||||||
}
|
}
|
||||||
.cal-dlg__titolo { margin: 6px 0 0; font-size: 1.5rem; }
|
.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__azioni { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||||
.cal-dlg__btn {
|
.cal-dlg__btn {
|
||||||
display: inline-block; font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
|
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); }
|
.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
|
/* Giornate fuori griglia: stanno sopra le tabelle perché sono l'eccezione a ciò che le
|
||||||
tabella dice, e sotto passerebbero per una postilla. */
|
tabelle dicono, e sotto passerebbero per una postilla. */
|
||||||
.cal-avvisi {
|
.cal-avvisi {
|
||||||
list-style: none; margin: 18px 0 0; padding: 12px 16px; background: var(--c-bg-alt);
|
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;
|
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 li { font-size: .84rem; color: var(--c-text); }
|
||||||
.cal-avvisi strong { color: var(--c-heading); }
|
.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
|
/* La griglia ha otto colonne: su telefono scorre in orizzontale invece di comprimersi
|
||||||
fino a diventare illeggibile, e la colonna delle ore resta ferma. */
|
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 { 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 th, .cal-tab td { border: 1px solid #e4e0d9; padding: 9px 10px; text-align: left; }
|
||||||
.cal-tab thead th {
|
.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;
|
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .16em;
|
||||||
text-transform: uppercase; color: var(--c-heading);
|
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-h { color: var(--c-accent-dark) !important; }
|
||||||
.cal-tab__ora {
|
.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);
|
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; color: var(--c-heading);
|
||||||
white-space: nowrap;
|
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__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-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: 12px 0 0; padding: 0; }
|
||||||
.cal-legenda { display: flex; flex-wrap: wrap; gap: 8px 16px; list-style: none; margin: 0; padding: 0; }
|
|
||||||
.cal-legenda li {
|
.cal-legenda li {
|
||||||
font-family: var(--font-heading); font-size: .66rem; letter-spacing: .12em; padding: 5px 10px;
|
font-family: var(--font-heading); font-size: .66rem; letter-spacing: .12em; padding: 5px 10px;
|
||||||
color: var(--c-heading);
|
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-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-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-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); }
|
.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.
|
/* Stampa dalla pagina (Ctrl+P col popup aperto): resta solo il calendario, un orario per
|
||||||
`visibility` invece di `display` perché il dialog è annidato nel corpo della pagina e
|
foglio. Il bottone scarica invece il PDF generato dal server, che è il documento buono da
|
||||||
nascondere gli antenati nasconderebbe anche lui. */
|
girare. `visibility` invece di `display` perché il dialog è annidato nel corpo della
|
||||||
|
pagina e nascondere gli antenati nasconderebbe anche lui. */
|
||||||
@media print {
|
@media print {
|
||||||
@page { size: A4 landscape; margin: 10mm; }
|
@page { size: A4 landscape; margin: 10mm; }
|
||||||
body { visibility: hidden; }
|
body { visibility: hidden; }
|
||||||
@@ -209,8 +227,9 @@ const presenti = cal
|
|||||||
.cal-dlg__box { padding: 0; height: auto; }
|
.cal-dlg__box { padding: 0; height: auto; }
|
||||||
.cal-dlg__azioni { display: none; }
|
.cal-dlg__azioni { display: none; }
|
||||||
.cal-dlg__scroll { overflow: visible; }
|
.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 { min-width: 0; }
|
||||||
.cal-tab thead th, .cal-tab__ora { position: static; }
|
|
||||||
.cal-tab th, .cal-tab td { padding: 5px 6px; }
|
.cal-tab th, .cal-tab td { padding: 5px 6px; }
|
||||||
.cal-tab__cella { font-size: .62rem; }
|
.cal-tab__cella { font-size: .62rem; }
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-16
@@ -149,20 +149,26 @@ export function avvisiAttivi(now: Date, list: AvvisoCalendario[] = avvisi): Avvi
|
|||||||
/** Servizi dalla cui pagina si apre il calendario della sala. */
|
/** Servizi dalla cui pagina si apre il calendario della sala. */
|
||||||
export const SERVIZI_CON_CALENDARIO = ['performance-class', 'holistic-class'];
|
export const SERVIZI_CON_CALENDARIO = ['performance-class', 'holistic-class'];
|
||||||
|
|
||||||
/**
|
export interface CalendarioMostrato {
|
||||||
* Il calendario da mostrare: quello in vigore adesso, oppure — se nessuno lo è ancora — il
|
cal: Calendario;
|
||||||
* primo che entrerà in vigore, segnalato come futuro. `null` solo se non ce n'è nessuno da
|
stato: 'in-corso' | 'futuro';
|
||||||
* 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
|
* I calendari da mostrare: quello in vigore e quelli che verranno, in ordine di partenza.
|
||||||
* di settembre.
|
*
|
||||||
*/
|
* Si vedono **tutti insieme**, non solo quello del momento: chi guarda a fine agosto vuole
|
||||||
export function calendarioDaMostrare(now: Date, list: Calendario[] = calendari):
|
* sapere com'è settembre per intero, e vedere una griglia sola gli farebbe credere che sia
|
||||||
{ cal: Calendario; futuro: boolean } | null {
|
* quella per sempre. Ognuna porta il proprio stato, così l'orario in vigore adesso non si
|
||||||
const t = now.getTime();
|
* confonde con quello che comincerà dopo.
|
||||||
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 };
|
* Gli orari **scaduti spariscono da soli**: dal 21 settembre resta il solo foglio valido, e
|
||||||
const prossimi = list.filter((c) => Date.parse(c.from) > t).sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
|
* nessuno rischia di leggere le lezioni di un periodo finito.
|
||||||
return prossimi.length ? { cal: prossimi[0], futuro: true } : null;
|
*/
|
||||||
|
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
@@ -24,21 +24,29 @@ const C_BORDO = '#cfc7b8'; // più scuro del fondo delle celle: due lezioni ugua
|
|||||||
const C_ACCENTO = '#9c8b70';
|
const C_ACCENTO = '#9c8b70';
|
||||||
const C_ALT = '#f5f3f0';
|
const C_ALT = '#f5f3f0';
|
||||||
|
|
||||||
export const nomeFilePdf = (cal: Calendario): string =>
|
/** Un solo orario: il nome porta il suo id. Più orari insieme: il nome è generico. */
|
||||||
`insanitylab-calendario-${cal.id}.pdf`;
|
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
|
* Genera il PDF e restituisce il file completo: **un orario per pagina**.
|
||||||
* 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.
|
* 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({
|
const doc = new PDFDocument({
|
||||||
size: 'A4',
|
size: 'A4',
|
||||||
layout: 'landscape',
|
layout: 'landscape',
|
||||||
margin: 32,
|
margin: 32,
|
||||||
info: {
|
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',
|
Author: 'InsanityLab',
|
||||||
Subject: 'Calendario delle class',
|
Subject: 'Calendario delle class',
|
||||||
},
|
},
|
||||||
@@ -51,6 +59,17 @@ export function generaCalendarioPdf(cal: Calendario, avvisi: AvvisoCalendario[]
|
|||||||
doc.on('error', rifiuta);
|
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 x0 = doc.page.margins.left;
|
||||||
const larghezzaUtile = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
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)
|
doc.font('Helvetica').fontSize(7).fillColor(C_ACCENTO)
|
||||||
.text('insanitylab.it', x0, yAvvisi + 14, { width: larghezzaUtile, align: 'right' });
|
.text('insanitylab.it', x0, yAvvisi + 14, { width: larghezzaUtile, align: 'right' });
|
||||||
|
|
||||||
doc.end();
|
|
||||||
return finito;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { avvisiAttivi, calendari } from '../../data/calendari';
|
import { avvisiAttivi, calendari, calendariDaMostrare } from '../../data/calendari';
|
||||||
import { generaCalendarioPdf, nomeFilePdf } from '../../lib/calendario-pdf';
|
import { generaCalendariPdf, nomeFilePdf } from '../../lib/calendario-pdf';
|
||||||
|
|
||||||
export const prerender = false;
|
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
|
// 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
|
// dietro autenticazione non si potrebbe girare su WhatsApp, che è come questo centro comunica
|
||||||
// comunica coi clienti.
|
// coi clienti.
|
||||||
export const GET: APIRoute = async ({ params }) => {
|
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
|
// Un id sconosciuto è 404, non un PDF vuoto: un file che si apre e non contiene niente fa
|
||||||
// credere che il calendario sia vuoto.
|
// 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
|
// 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.
|
// 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), {
|
return new Response(new Uint8Array(pdf), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/pdf',
|
'Content-Type': 'application/pdf',
|
||||||
// `attachment` perché il bottone dice "scarica": senza, il browser lo aprirebbe in una
|
// `attachment` perché il bottone dice "scarica": senza, il browser lo aprirebbe in una
|
||||||
// scheda e su telefono resterebbe lì, senza finire fra i file.
|
// 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),
|
'Content-Length': String(pdf.length),
|
||||||
// Un'ora: basta a non rigenerarlo a ogni click, e non abbastanza perché un avviso ormai
|
// 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.
|
// scaduto (le giornate fuori griglia spariscono da sole) resti in giro un giorno intero.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { serviceIcons } from '../../data/service-icons';
|
|||||||
import T from '../../components/content/T.astro';
|
import T from '../../components/content/T.astro';
|
||||||
import TImg from '../../components/content/TImg.astro';
|
import TImg from '../../components/content/TImg.astro';
|
||||||
import CalendarioModal from '../../components/CalendarioModal.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';
|
import { t } from '../../lib/content';
|
||||||
export const prerender = false;
|
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'è
|
// 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 è
|
// davvero una griglia da mostrare (in vigore o prossima): un link che apre un popup vuoto è
|
||||||
// peggio di nessun link.
|
// 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 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 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;
|
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
|
||||||
|
|||||||
+31
-32
@@ -1,48 +1,47 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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);
|
const d = (iso: string) => new Date(iso);
|
||||||
|
|
||||||
describe('calendarioDaMostrare', () => {
|
describe('calendariDaMostrare', () => {
|
||||||
it('in vigore: mostra quello del periodo', () => {
|
const idDi = (quando: string) => calendariDaMostrare(d(quando)).map((m) => `${m.cal.id}:${m.stato}`);
|
||||||
expect(calendarioDaMostrare(d('2026-09-05T10:00:00+02:00'))).toMatchObject({
|
|
||||||
cal: { id: 'settembre-1-18' }, futuro: false,
|
// 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.
|
||||||
expect(calendarioDaMostrare(d('2026-09-25T10:00:00+02:00'))).toMatchObject({
|
it('prima dell\'inizio: entrambi, marcati futuri', () => {
|
||||||
cal: { id: 'settembre-21-in-poi' }, futuro: false,
|
expect(idDi('2026-08-17T10:00:00+02:00'))
|
||||||
});
|
.toEqual(['settembre-1-18:futuro', 'settembre-21-in-poi:futuro']);
|
||||||
// `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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Il link esiste prima che il primo orario cominci: ad agosto deve mostrare gli orari di
|
it('durante il primo periodo: il primo è in corso, il secondo resta annunciato', () => {
|
||||||
// settembre dichiarando che entrano dopo, non un "nessun calendario" che non dice niente.
|
expect(idDi('2026-09-05T10:00:00+02:00'))
|
||||||
it('prima dell\'inizio: mostra il prossimo, marcato come futuro', () => {
|
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
|
||||||
expect(calendarioDaMostrare(d('2026-08-17T10:00:00+02:00'))).toMatchObject({
|
});
|
||||||
cal: { id: 'settembre-1-18' }, futuro: true,
|
|
||||||
});
|
// 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:
|
// 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).
|
// 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
|
it('nei due giorni fuori griglia resta il solo orario del 21, annunciato', () => {
|
||||||
// giorni arriva dagli avvisi qui sotto.
|
expect(idDi('2026-09-19T10:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
|
||||||
it('nei due giorni fuori griglia (19-20 settembre) mostra il prossimo', () => {
|
expect(idDi('2026-09-20T23:00:00+02:00')).toEqual(['settembre-21-in-poi:futuro']);
|
||||||
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('confini al secondo, in ora italiana', () => {
|
it('confini al secondo, in ora italiana', () => {
|
||||||
expect(calendarioDaMostrare(d('2026-09-18T23:59:59+02:00'))?.cal.id).toBe('settembre-1-18');
|
expect(idDi('2026-09-18T23:59:59+02:00'))
|
||||||
expect(calendarioDaMostrare(d('2026-09-21T00:00:00+02:00'))).toMatchObject({
|
.toEqual(['settembre-1-18:in-corso', 'settembre-21-in-poi:futuro']);
|
||||||
cal: { id: 'settembre-21-in-poi' }, futuro: false,
|
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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { avvisi, calendari } from '../src/data/calendari';
|
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', () => {
|
describe('PDF del calendario', () => {
|
||||||
for (const cal of calendari) {
|
for (const cal of calendari) {
|
||||||
it(`${cal.id}: produce un PDF valido`, async () => {
|
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
|
// 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
|
// chiuso (`doc.end()` mancante, o un errore a metà) il file arriverebbe monco al
|
||||||
// browser, che mostrerebbe una pagina bianca invece di un errore.
|
// 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.
|
// /Type /Pages, che dichiara quante pagine contiene.
|
||||||
it('sta in una pagina sola, anche con le giornate fuori griglia in fondo', async () => {
|
it('sta in una pagina sola, anche con le giornate fuori griglia in fondo', async () => {
|
||||||
for (const cal of calendari) {
|
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'));
|
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(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);
|
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', () => {
|
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);
|
expect(new Set(nomi).size).toBe(nomi.length);
|
||||||
for (const n of nomi) expect(n).toMatch(/^insanitylab-calendario-[a-z0-9-]+\.pdf$/);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user