Calendario delle class: popup a pieno schermo dalle pagine Performance e Holistic

Le due griglie fornite dal cliente (1-18 settembre, dal 21 in poi) sono
trascritte come dato e ridisegnate nello stile del sito: si leggono da telefono,
si stampano in PDF e per correggere una cella non serve rifare uno screenshot.

Quale griglia compare lo decide la data, come per le edizioni della promo: quella
in vigore, oppure - se nessuna lo e' ancora - la prossima, dichiarando da quando
entra. Serve perche' il link esiste da subito e ad agosto un "nessun calendario"
non direbbe niente a chi vuole gli orari di settembre.

Il popup e' un <dialog> nativo: fondale, Esc e trappola del focus senza
librerie. "Scarica in PDF" stampa la sola griglia, gia' impaginata in orizzontale
dal CSS di stampa. Le discipline hanno una barra colorata a sinistra oltre al
fondo, cosi' restano distinguibili anche quando il browser stampa senza sfondi.

Quattordici test sul dato: confini al secondo in ora italiana, fasce orarie
uniche e in ordine, nessuna lezione in un giorno di chiusura, e il buco vero fra
il 19 e il 20 settembre resta dichiarato invece che nascosto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:58:47 +02:00
parent a18a505254
commit 01f49ab6d2
5 changed files with 453 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
---
// Popup a pieno schermo con la griglia 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.
//
// Si apre da qualunque elemento con `data-apri-calendario` nella stessa pagina.
import { calendarioDaMostrare, GIORNI, DISCIPLINE, type Giorno } from '../data/calendari';
import { t } from '../lib/content';
const scelta = calendarioDaMostrare(new Date());
const cal = scelta?.cal ?? null;
const dal = cal ? new Date(cal.from).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)))
: [];
---
{cal && (
<dialog class="cal-dlg" aria-label={`${cal.sala} — ${cal.titolo}`}>
<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>
)}
</div>
<div class="cal-dlg__azioni">
<button type="button" class="cal-dlg__btn" data-stampa-calendario>{t('calendario.pdf-label')}</button>
<button type="button" class="cal-dlg__chiudi" data-chiudi-calendario aria-label={t('calendario.chiudi-label')}>×</button>
</div>
</header>
<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>
))}
</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>
</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>
</dialog>
)}
<script>
// Apertura/chiusura del popup. Nessuna libreria: <dialog> nativo dà da solo il fondale, il
// focus trap e la chiusura con Esc.
const dlg = document.querySelector<HTMLDialogElement>('.cal-dlg');
if (dlg) {
document.querySelectorAll<HTMLElement>('[data-apri-calendario]').forEach((el) => {
el.addEventListener('click', (e) => { e.preventDefault(); dlg.showModal(); });
});
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" = stampa con la destinazione "Salva come PDF": la pagina di stampa è
// già impaginata per la sola griglia (vedi @media print), quindi non serve generare nulla
// lato server né caricare una libreria.
dlg.querySelector('[data-stampa-calendario]')?.addEventListener('click', () => window.print());
}
</script>
<style is:global>
.cal-dlg {
width: 100%; max-width: none; height: 100%; max-height: none; margin: 0; padding: 0;
border: 0; background: transparent; color: var(--c-text);
}
.cal-dlg::backdrop { background: rgba(43,43,43,.75); }
.cal-dlg__box {
display: flex; flex-direction: column; height: 100%; background: var(--c-bg);
padding: 28px clamp(16px, 4vw, 48px) 24px;
}
.cal-dlg__top { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; }
.cal-dlg__sala {
margin: 0; font-family: var(--font-heading); font-size: .72rem; font-weight: 600;
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 {
font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .2em;
text-transform: uppercase; border: 0; cursor: pointer; padding: 13px 22px;
background: var(--c-accent); color: #fff; transition: background .2s;
}
.cal-dlg__btn:hover { background: var(--c-accent-dark); }
.cal-dlg__chiudi {
width: 42px; height: 42px; border: 1px solid #ddd; background: #fff; cursor: pointer;
font-size: 1.4rem; line-height: 1; color: var(--c-heading);
}
.cal-dlg__chiudi:hover { border-color: var(--c-accent); color: var(--c-accent-dark); }
/* 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);
font-family: var(--font-heading); font-size: .7rem; font-weight: 600; letter-spacing: .16em;
text-transform: uppercase; color: var(--c-heading);
}
.cal-tab__nota {
display: block; font-size: .62rem; letter-spacing: .06em; text-transform: none;
color: var(--c-text-light); font-weight: 400;
}
.cal-tab__ora-h { color: var(--c-accent-dark) !important; }
.cal-tab__ora {
position: sticky; left: 0; z-index: 1; background: var(--c-bg);
font-family: var(--font-heading); font-size: .78rem; font-weight: 600; color: var(--c-heading);
white-space: nowrap;
}
.cal-tab tr.is-clou .cal-tab__ora { background: #f7f2e6; }
.cal-tab__cella { font-family: var(--font-heading); font-size: .7rem; letter-spacing: .1em; color: var(--c-heading); }
/* Ogni disciplina ha un fondo tenue e una barra a sinistra: la barra si vede anche quando il
browser stampa senza sfondi. */
.cal-tab__cella.is-reformer { background: #ece5d7; box-shadow: inset 3px 0 0 var(--c-accent-dark); }
.cal-tab__cella.is-pilates { background: #f6efe0; box-shadow: inset 3px 0 0 #cbb98f; }
.cal-tab__cella.is-yoga { background: #e3eadf; box-shadow: inset 3px 0 0 #8ba57f; }
.cal-tab__cella.is-mobility { background: #f0e3e3; box-shadow: inset 3px 0 0 #b78e8e; }
.cal-tab__cella.is-chiuso { background: repeating-linear-gradient(135deg, #f5f3f0, #f5f3f0 6px, #ece8e2 6px, #ece8e2 12px); }
.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 li {
font-family: var(--font-heading); font-size: .66rem; letter-spacing: .12em; padding: 5px 10px;
color: var(--c-heading);
}
.cal-legenda li.is-reformer { background: #ece5d7; box-shadow: inset 3px 0 0 var(--c-accent-dark); }
.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__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. */
@media print {
@page { size: A4 landscape; margin: 10mm; }
body { visibility: hidden; }
.cal-dlg, .cal-dlg * { visibility: visible; }
.cal-dlg { position: absolute; inset: 0; height: auto; }
.cal-dlg::backdrop { background: transparent; }
.cal-dlg__box { padding: 0; height: auto; }
.cal-dlg__azioni { display: none; }
.cal-dlg__scroll { overflow: visible; }
.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; }
}
@media (max-width: 600px) {
.cal-dlg__box { padding: 20px 14px 18px; }
.cal-dlg__titolo { font-size: 1.2rem; }
.cal-dlg__btn { padding: 11px 16px; letter-spacing: .12em; }
}
</style>