Calendario: il 19 e il 20 settembre non sono un buco, sono un evento e una chiusura
Quei due giorni non stanno in nessuna delle due griglie perche' non hanno class: il 19 c'e' la presentazione della nuova apertura, il 20 si sta chiusi. Scritto come avviso con la sua finestra, il vuoto smette di sembrare una trascrizione incompleta e diventa un'informazione. Gli avvisi compaiono in cima al popup e in fondo al PDF - e' il file che gira su WhatsApp, e chi lo riceve non ha la pagina davanti. Spariscono da soli quando sono passati: un avviso scaduto dice che il centro e' chiuso in un giorno gia' trascorso, e chi legge non sa piu' se valga per quest'anno o per il prossimo. Lo spazio riservato in fondo alla pagina cresce con gli avvisi, altrimenti la griglia si allunga fino a spingerli su una seconda pagina. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,13 @@
|
|||||||
// dichiarando da quando entra.
|
// dichiarando da quando entra.
|
||||||
//
|
//
|
||||||
// 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, GIORNI, DISCIPLINE, type Giorno } from '../data/calendari';
|
import { calendarioDaMostrare, avvisiAttivi, GIORNI, DISCIPLINE, type Giorno } from '../data/calendari';
|
||||||
import { t } from '../lib/content';
|
import { t } from '../lib/content';
|
||||||
|
|
||||||
const scelta = calendarioDaMostrare(new Date());
|
const adesso = new Date();
|
||||||
|
const scelta = calendarioDaMostrare(adesso);
|
||||||
|
// Giornate fuori griglia (eventi, chiusure): spariscono da sole quando sono passate.
|
||||||
|
const avvisi = avvisiAttivi(adesso);
|
||||||
const cal = scelta?.cal ?? null;
|
const cal = scelta?.cal ?? null;
|
||||||
const dal = cal ? new Date(cal.from).toLocaleDateString('it-IT', {
|
const dal = cal ? new Date(cal.from).toLocaleDateString('it-IT', {
|
||||||
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome',
|
day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome',
|
||||||
@@ -38,6 +41,14 @@ const presenti = cal
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{avvisi.length > 0 && (
|
||||||
|
<ul class="cal-avvisi">
|
||||||
|
{avvisi.map((a) => (
|
||||||
|
<li><strong>{a.quando}</strong> — {a.testo}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
<div class="cal-dlg__scroll">
|
<div class="cal-dlg__scroll">
|
||||||
<table class="cal-tab">
|
<table class="cal-tab">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -133,6 +144,15 @@ 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
|
||||||
|
tabella dice, 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;
|
||||||
|
}
|
||||||
|
.cal-avvisi li { font-size: .84rem; color: var(--c-text); }
|
||||||
|
.cal-avvisi strong { color: var(--c-heading); }
|
||||||
|
|
||||||
/* 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-dlg__scroll { flex: 1; overflow: auto; margin: 22px 0 0; }
|
||||||
|
|||||||
@@ -103,6 +103,49 @@ export const calendari: Calendario[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Giornate che non stanno in nessuna griglia: eventi e chiusure.
|
||||||
|
*
|
||||||
|
* Il 19 e il 20 settembre non compaiono in nessuno dei due fogli, e la ragione non è una
|
||||||
|
* dimenticanza — il sabato c'è la presentazione della nuova apertura e la domenica si sta
|
||||||
|
* chiusi (detto da Adriano il 17/08/2026). Scritto qui, quel vuoto smette di sembrare un
|
||||||
|
* errore di trascrizione e diventa un'informazione per chi legge gli orari.
|
||||||
|
*/
|
||||||
|
export interface AvvisoCalendario {
|
||||||
|
id: string;
|
||||||
|
/** `null` = visibile da subito. */
|
||||||
|
from: string | null;
|
||||||
|
/** Ultimo istante in cui ha senso mostrarlo: dopo, sparisce da solo. */
|
||||||
|
to: string;
|
||||||
|
quando: string;
|
||||||
|
testo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const avvisi: AvvisoCalendario[] = [
|
||||||
|
{
|
||||||
|
id: 'presentazione-19-settembre',
|
||||||
|
from: null,
|
||||||
|
to: '2026-09-19T23:59:59+02:00',
|
||||||
|
quando: 'Sabato 19 settembre',
|
||||||
|
testo: 'Presentazione della nuova apertura: nessuna class in programma.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chiusura-20-settembre',
|
||||||
|
from: null,
|
||||||
|
to: '2026-09-20T23:59:59+02:00',
|
||||||
|
quando: 'Domenica 20 settembre',
|
||||||
|
testo: 'Chiuso.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Gli avvisi ancora validi, in ordine di scadenza: i più imminenti per primi. */
|
||||||
|
export function avvisiAttivi(now: Date, list: AvvisoCalendario[] = avvisi): AvvisoCalendario[] {
|
||||||
|
const t = now.getTime();
|
||||||
|
return list
|
||||||
|
.filter((a) => (a.from === null || t >= Date.parse(a.from)) && t <= Date.parse(a.to))
|
||||||
|
.sort((x, y) => Date.parse(x.to) - Date.parse(y.to));
|
||||||
|
}
|
||||||
|
|
||||||
/** 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'];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import { GIORNI, type Calendario, type Disciplina, type Giorno } from '../data/calendari';
|
import { GIORNI, type AvvisoCalendario, type Calendario, type Disciplina, type Giorno } from '../data/calendari';
|
||||||
|
|
||||||
// PDF del calendario, disegnato dal dato con PDFKit.
|
// PDF del calendario, disegnato dal dato con PDFKit.
|
||||||
//
|
//
|
||||||
@@ -32,7 +32,7 @@ export const nomeFilePdf = (cal: Calendario): string =>
|
|||||||
* in A4 orizzontale, e spezzarla su due pagine renderebbe illeggibile proprio ciò che si va a
|
* 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.
|
* cercare — l'incrocio fra un giorno e un'ora.
|
||||||
*/
|
*/
|
||||||
export function generaCalendarioPdf(cal: Calendario): Promise<Buffer> {
|
export function generaCalendarioPdf(cal: Calendario, avvisi: AvvisoCalendario[] = []): Promise<Buffer> {
|
||||||
const doc = new PDFDocument({
|
const doc = new PDFDocument({
|
||||||
size: 'A4',
|
size: 'A4',
|
||||||
layout: 'landscape',
|
layout: 'landscape',
|
||||||
@@ -88,7 +88,9 @@ export function generaCalendarioPdf(cal: Calendario): Promise<Buffer> {
|
|||||||
|
|
||||||
// Righe: l'altezza si ricava dallo spazio che resta, così la griglia riempie la pagina
|
// Righe: l'altezza si ricava dallo spazio che resta, così la griglia riempie la pagina
|
||||||
// qualunque sia il numero di fasce e non serve ritoccarla a mano quando ne cambiano.
|
// qualunque sia il numero di fasce e non serve ritoccarla a mano quando ne cambiano.
|
||||||
const spazioSotto = 52; // legenda + nota + piè di pagina
|
// legenda + nota + piè di pagina, più una riga per ogni giornata fuori griglia: senza
|
||||||
|
// tenerne conto la griglia si allungherebbe fino a spingere gli avvisi in una seconda pagina.
|
||||||
|
const spazioSotto = 52 + avvisi.length * 11;
|
||||||
const altezzaRiga = Math.min(
|
const altezzaRiga = Math.min(
|
||||||
22,
|
22,
|
||||||
(doc.page.height - doc.page.margins.bottom - spazioSotto - y) / cal.righe.length,
|
(doc.page.height - doc.page.margins.bottom - spazioSotto - y) / cal.righe.length,
|
||||||
@@ -147,12 +149,22 @@ export function generaCalendarioPdf(cal: Calendario): Promise<Buffer> {
|
|||||||
xLeg += 13 + doc.widthOfString(d) + 18;
|
xLeg += 13 + doc.widthOfString(d) + 18;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Giornate fuori griglia (eventi, chiusure) ---
|
||||||
|
let yAvvisi = yLeg + 16;
|
||||||
|
for (const a of avvisi) {
|
||||||
|
doc.rect(x0, yAvvisi - 1, 2, 9).fill(C_ACCENTO);
|
||||||
|
doc.font('Helvetica-Bold').fontSize(7).fillColor(C_TESTO)
|
||||||
|
.text(a.quando, x0 + 7, yAvvisi, { continued: true })
|
||||||
|
.font('Helvetica').fillColor(C_TENUE).text(` — ${a.testo}`);
|
||||||
|
yAvvisi += 11;
|
||||||
|
}
|
||||||
|
|
||||||
doc.font('Helvetica').fontSize(7).fillColor(C_TENUE).text(
|
doc.font('Helvetica').fontSize(7).fillColor(C_TENUE).text(
|
||||||
"Gli orari possono subire variazioni. Le lezioni si prenotano dall'app Bsport.",
|
"Gli orari possono subire variazioni. Le lezioni si prenotano dall'app Bsport.",
|
||||||
x0, yLeg + 16, { width: larghezzaUtile },
|
x0, yAvvisi + 3, { width: larghezzaUtile },
|
||||||
);
|
);
|
||||||
doc.font('Helvetica').fontSize(7).fillColor(C_ACCENTO)
|
doc.font('Helvetica').fontSize(7).fillColor(C_ACCENTO)
|
||||||
.text('insanitylab.it', x0, yLeg + 27, { width: larghezzaUtile, align: 'right' });
|
.text('insanitylab.it', x0, yAvvisi + 14, { width: larghezzaUtile, align: 'right' });
|
||||||
|
|
||||||
doc.end();
|
doc.end();
|
||||||
return finito;
|
return finito;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { calendari } from '../../data/calendari';
|
import { avvisiAttivi, calendari } from '../../data/calendari';
|
||||||
import { generaCalendarioPdf, nomeFilePdf } from '../../lib/calendario-pdf';
|
import { generaCalendarioPdf, nomeFilePdf } from '../../lib/calendario-pdf';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -15,7 +15,9 @@ export const GET: APIRoute = async ({ params }) => {
|
|||||||
// credere che il calendario sia vuoto.
|
// credere che il calendario sia vuoto.
|
||||||
if (!cal) return new Response('Calendario inesistente.', { status: 404 });
|
if (!cal) return new Response('Calendario inesistente.', { status: 404 });
|
||||||
|
|
||||||
const pdf = await generaCalendarioPdf(cal);
|
// 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()));
|
||||||
return new Response(new Uint8Array(pdf), {
|
return new Response(new Uint8Array(pdf), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/pdf',
|
'Content-Type': 'application/pdf',
|
||||||
@@ -23,8 +25,8 @@ export const GET: APIRoute = async ({ params }) => {
|
|||||||
// 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(cal)}"`,
|
||||||
'Content-Length': String(pdf.length),
|
'Content-Length': String(pdf.length),
|
||||||
// Il calendario cambia solo quando cambia il dato, cioè con un deploy: un'ora di cache
|
// Un'ora: basta a non rigenerarlo a ogni click, e non abbastanza perché un avviso ormai
|
||||||
// basta a non rigenerarlo a ogni click, e non abbastanza da servire un orario vecchio.
|
// scaduto (le giornate fuori griglia spariscono da sole) resti in giro un giorno intero.
|
||||||
'Cache-Control': 'public, max-age=3600',
|
'Cache-Control': 'public, max-age=3600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+32
-4
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { calendari, calendarioDaMostrare, DISCIPLINE, GIORNI, type Giorno } from '../src/data/calendari';
|
import { avvisi, avvisiAttivi, calendari, calendarioDaMostrare, DISCIPLINE, GIORNI, type Giorno } from '../src/data/calendari';
|
||||||
|
|
||||||
const d = (iso: string) => new Date(iso);
|
const d = (iso: string) => new Date(iso);
|
||||||
|
|
||||||
@@ -25,9 +25,11 @@ describe('calendarioDaMostrare', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 19 e 20 settembre non sono coperti da nessuno dei due fogli del cliente. Il buco è vero e
|
// 19 e 20 settembre non stanno in nessuna griglia perché quei due giorni non hanno class:
|
||||||
// resta dichiarato qui: se un giorno verrà colmato, questo test lo dirà.
|
// il 19 c'è la presentazione della nuova apertura, il 20 si sta chiusi (Adriano, 17/08).
|
||||||
it('nei due giorni scoperti (19-20 settembre) mostra il prossimo', () => {
|
// 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({
|
expect(calendarioDaMostrare(d('2026-09-19T10:00:00+02:00'))).toMatchObject({
|
||||||
cal: { id: 'settembre-21-in-poi' }, futuro: true,
|
cal: { id: 'settembre-21-in-poi' }, futuro: true,
|
||||||
});
|
});
|
||||||
@@ -44,6 +46,32 @@ describe('calendarioDaMostrare', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('giornate fuori griglia', () => {
|
||||||
|
it('la presentazione e la chiusura si vedono prima di arrivarci', () => {
|
||||||
|
const oggi = avvisiAttivi(d('2026-08-17T12:00:00+02:00')).map((a) => a.id);
|
||||||
|
expect(oggi).toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un avviso passato è peggio di nessun avviso: dice che il centro è chiuso un giorno che è
|
||||||
|
// già trascorso, e chi legge non sa più se valga per quest'anno o per il prossimo.
|
||||||
|
it('ogni avviso sparisce da solo quando è passato', () => {
|
||||||
|
expect(avvisiAttivi(d('2026-09-19T23:59:59+02:00')).map((a) => a.id))
|
||||||
|
.toEqual(['presentazione-19-settembre', 'chiusura-20-settembre']);
|
||||||
|
expect(avvisiAttivi(d('2026-09-20T00:00:00+02:00')).map((a) => a.id))
|
||||||
|
.toEqual(['chiusura-20-settembre']);
|
||||||
|
expect(avvisiAttivi(d('2026-09-21T00:00:00+02:00'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ogni avviso ha una scadenza leggibile e un testo', () => {
|
||||||
|
for (const a of avvisi) {
|
||||||
|
expect(Number.isNaN(Date.parse(a.to)), `${a.id}: scadenza illeggibile`).toBe(false);
|
||||||
|
if (a.from !== null) expect(Date.parse(a.to)).toBeGreaterThanOrEqual(Date.parse(a.from));
|
||||||
|
expect(a.quando.length).toBeGreaterThan(0);
|
||||||
|
expect(a.testo.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('coerenza dei calendari trascritti', () => {
|
describe('coerenza dei calendari trascritti', () => {
|
||||||
for (const c of calendari) {
|
for (const c of calendari) {
|
||||||
describe(c.id, () => {
|
describe(c.id, () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { calendari } from '../src/data/calendari';
|
import { avvisi, calendari } from '../src/data/calendari';
|
||||||
import { generaCalendarioPdf, nomeFilePdf } from '../src/lib/calendario-pdf';
|
import { generaCalendarioPdf, nomeFilePdf } from '../src/lib/calendario-pdf';
|
||||||
|
|
||||||
describe('PDF del calendario', () => {
|
describe('PDF del calendario', () => {
|
||||||
@@ -18,9 +18,9 @@ describe('PDF del calendario', () => {
|
|||||||
// La griglia deve stare in una pagina sola: l'incrocio giorno/ora è proprio ciò che si va a
|
// La griglia deve stare in una pagina sola: l'incrocio giorno/ora è proprio ciò che si va a
|
||||||
// cercare, e spezzarlo a metà rende il documento inutile. Il conteggio si legge dall'oggetto
|
// cercare, e spezzarlo a metà rende il documento inutile. Il conteggio si legge dall'oggetto
|
||||||
// /Type /Pages, che dichiara quante pagine contiene.
|
// /Type /Pages, che dichiara quante pagine contiene.
|
||||||
it('sta in una pagina sola', 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);
|
const pdf = await generaCalendarioPdf(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);
|
||||||
|
|||||||
Reference in New Issue
Block a user