Calendario: il PDF e' un file vero, generato dal server
"Scarica in PDF" non apre piu' la stampa del browser: e' un link a /calendario/<id>.pdf che restituisce un documento disegnato dal dato con PDFKit, con Content-Disposition attachment. Serve perche' il calendario si gira su WhatsApp, ed e' cosi' che questo centro parla coi clienti: chi lo riceve non ha il sito aperto davanti. Niente browser headless: Chromium in un container da 2 vCPU costa centinaia di megabyte e un processo per richiesta, per una griglia di sette colonne. Le linee della griglia si tracciano in una passata a parte, dopo i riempimenti: disegnandole riga per riga, il riempimento della riga seguente copriva quella appena fatta e due lezioni uguali in fasce consecutive si leggevano come un blocco unico lungo il doppio. Quattro test: intestazione %PDF- e coda %%EOF (un documento non chiuso arriva monco e il browser mostra una pagina bianca invece di un errore), una pagina sola, nomi di file distinti. Un id sconosciuto e' 404, non un PDF vuoto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ const presenti = cal
|
||||
)}
|
||||
</div>
|
||||
<div class="cal-dlg__azioni">
|
||||
<button type="button" class="cal-dlg__btn" data-stampa-calendario>{t('calendario.pdf-label')}</button>
|
||||
<a class="cal-dlg__btn" href={`/calendario/${cal.id}.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>
|
||||
@@ -93,10 +93,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" = 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());
|
||||
// "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.
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -122,9 +121,10 @@ const presenti = cal
|
||||
}
|
||||
.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;
|
||||
display: inline-block; font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
|
||||
letter-spacing: .2em; text-transform: uppercase; text-decoration: none; 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 {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { GIORNI, type Calendario, type Disciplina, type Giorno } from '../data/calendari';
|
||||
|
||||
// PDF del calendario, disegnato dal dato con PDFKit.
|
||||
//
|
||||
// Non è la stampa del browser: è un file vero, che si scarica da un link e si può girare su
|
||||
// WhatsApp. Serve un documento, non uno screenshot della pagina — chi lo riceve non ha il
|
||||
// sito aperto davanti.
|
||||
//
|
||||
// Niente browser headless di mezzo: Chromium in un container da 2 vCPU costa centinaia di
|
||||
// megabyte e un processo per ogni richiesta, per una griglia di sette colonne.
|
||||
|
||||
/** Colori delle discipline, con la barra a sinistra più scura del fondo (come in pagina). */
|
||||
const COLORI: Record<Disciplina, { fondo: string; barra: string }> = {
|
||||
REFORMER: { fondo: '#ece5d7', barra: '#9c8b70' },
|
||||
PILATES: { fondo: '#f6efe0', barra: '#cbb98f' },
|
||||
YOGA: { fondo: '#e3eadf', barra: '#8ba57f' },
|
||||
MOBILITY: { fondo: '#f0e3e3', barra: '#b78e8e' },
|
||||
};
|
||||
const C_TESTO = '#2b2b2b';
|
||||
const C_TENUE = '#8a8a8a';
|
||||
const C_BORDO = '#cfc7b8'; // più scuro del fondo delle celle: due lezioni uguali in fasce
|
||||
// consecutive devono restare due, non sembrare un blocco unico
|
||||
const C_ACCENTO = '#9c8b70';
|
||||
const C_ALT = '#f5f3f0';
|
||||
|
||||
export const nomeFilePdf = (cal: Calendario): string =>
|
||||
`insanitylab-calendario-${cal.id}.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.
|
||||
*/
|
||||
export function generaCalendarioPdf(cal: Calendario): Promise<Buffer> {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
layout: 'landscape',
|
||||
margin: 32,
|
||||
info: {
|
||||
Title: `InsanityLab — ${cal.sala}, ${cal.titolo}`,
|
||||
Author: 'InsanityLab',
|
||||
Subject: 'Calendario delle class',
|
||||
},
|
||||
});
|
||||
|
||||
const pezzi: Buffer[] = [];
|
||||
const finito = new Promise<Buffer>((risolvi, rifiuta) => {
|
||||
doc.on('data', (p: Buffer) => pezzi.push(p));
|
||||
doc.on('end', () => risolvi(Buffer.concat(pezzi)));
|
||||
doc.on('error', rifiuta);
|
||||
});
|
||||
|
||||
const x0 = doc.page.margins.left;
|
||||
const larghezzaUtile = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
|
||||
// --- Testata ---
|
||||
doc.font('Helvetica-Bold').fontSize(16).fillColor(C_TESTO)
|
||||
.text('INSANITYLAB', x0, doc.page.margins.top);
|
||||
doc.font('Helvetica').fontSize(8).fillColor(C_ACCENTO)
|
||||
.text(cal.sala.toUpperCase(), x0, doc.y + 2, { characterSpacing: 1.6 });
|
||||
doc.font('Helvetica-Bold').fontSize(12).fillColor(C_TESTO)
|
||||
.text(cal.titolo, x0, doc.y + 4);
|
||||
|
||||
// --- Griglia ---
|
||||
const larghezzaOra = 52;
|
||||
const larghezzaGiorno = (larghezzaUtile - larghezzaOra) / GIORNI.length;
|
||||
const conNota = GIORNI.some((g) => cal.noteGiorno?.[g]);
|
||||
const altezzaTestata = conNota ? 26 : 18;
|
||||
let y = doc.y + 12;
|
||||
|
||||
const xDi = (i: number) => x0 + larghezzaOra + i * larghezzaGiorno;
|
||||
|
||||
// Intestazione delle colonne
|
||||
doc.rect(x0, y, larghezzaUtile, altezzaTestata).fill(C_ALT);
|
||||
doc.font('Helvetica-Bold').fontSize(7).fillColor(C_ACCENTO)
|
||||
.text('ORA', x0 + 5, y + 6, { width: larghezzaOra - 10, characterSpacing: 1 });
|
||||
GIORNI.forEach((g, i) => {
|
||||
doc.font('Helvetica-Bold').fontSize(7).fillColor(cal.chiusi.includes(g) ? C_TENUE : C_TESTO)
|
||||
.text(g, xDi(i) + 5, y + 6, { width: larghezzaGiorno - 10, characterSpacing: 1 });
|
||||
const nota = cal.noteGiorno?.[g];
|
||||
if (nota) {
|
||||
doc.font('Helvetica').fontSize(5.5).fillColor(C_TENUE)
|
||||
.text(nota, xDi(i) + 5, y + 15, { width: larghezzaGiorno - 8 });
|
||||
}
|
||||
});
|
||||
y += altezzaTestata;
|
||||
|
||||
// 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.
|
||||
const spazioSotto = 52; // legenda + nota + piè di pagina
|
||||
const altezzaRiga = Math.min(
|
||||
22,
|
||||
(doc.page.height - doc.page.margins.bottom - spazioSotto - y) / cal.righe.length,
|
||||
);
|
||||
|
||||
// Prima passata: riempimenti e testi.
|
||||
const yPrimaRiga = y;
|
||||
for (const r of cal.righe) {
|
||||
if (r.clou) doc.rect(x0, y, larghezzaOra, altezzaRiga).fill('#f7f2e6');
|
||||
doc.font('Helvetica-Bold').fontSize(7.5).fillColor(C_TESTO)
|
||||
.text(r.ora, x0 + 5, y + altezzaRiga / 2 - 4, { width: larghezzaOra - 8 });
|
||||
|
||||
GIORNI.forEach((g: Giorno, i) => {
|
||||
const x = xDi(i);
|
||||
const chiuso = cal.chiusi.includes(g);
|
||||
const lezione = r.celle[g];
|
||||
if (chiuso) {
|
||||
doc.rect(x, y, larghezzaGiorno, altezzaRiga).fill('#efece7');
|
||||
doc.font('Helvetica').fontSize(5.5).fillColor(C_TENUE)
|
||||
.text('CHIUSO', x + 5, y + altezzaRiga / 2 - 3, { width: larghezzaGiorno - 8, characterSpacing: 0.8 });
|
||||
} else if (lezione) {
|
||||
const c = COLORI[lezione];
|
||||
doc.rect(x, y, larghezzaGiorno, altezzaRiga).fill(c.fondo);
|
||||
doc.rect(x, y, 2.5, altezzaRiga).fill(c.barra);
|
||||
doc.font('Helvetica-Bold').fontSize(6.5).fillColor(C_TESTO)
|
||||
.text(lezione, x + 7, y + altezzaRiga / 2 - 3.5, { width: larghezzaGiorno - 10, characterSpacing: 0.5 });
|
||||
}
|
||||
});
|
||||
y += altezzaRiga;
|
||||
}
|
||||
|
||||
// Seconda passata: la griglia sopra i riempimenti. Tracciando le linee riga per riga, il
|
||||
// riempimento della riga seguente copriva quella appena disegnata e due lezioni uguali una
|
||||
// sotto l'altra si leggevano come un unico blocco lungo il doppio.
|
||||
doc.lineWidth(0.4).strokeColor(C_BORDO);
|
||||
for (let i = 0; i <= cal.righe.length; i++) {
|
||||
const yy = yPrimaRiga + i * altezzaRiga;
|
||||
doc.moveTo(x0, yy).lineTo(x0 + larghezzaUtile, yy).stroke();
|
||||
}
|
||||
for (let i = 0; i <= GIORNI.length; i++) {
|
||||
const x = i === 0 ? x0 + larghezzaOra : xDi(i);
|
||||
doc.moveTo(x, yPrimaRiga - altezzaTestata).lineTo(x, y).stroke();
|
||||
}
|
||||
doc.rect(x0, yPrimaRiga - altezzaTestata, larghezzaUtile, altezzaTestata + altezzaRiga * cal.righe.length).stroke();
|
||||
|
||||
// --- Legenda: solo le discipline che compaiono davvero in questa griglia ---
|
||||
const presenti = (Object.keys(COLORI) as Disciplina[])
|
||||
.filter((d) => cal.righe.some((r) => Object.values(r.celle).includes(d)));
|
||||
let xLeg = x0;
|
||||
const yLeg = y + 12;
|
||||
for (const d of presenti) {
|
||||
doc.rect(xLeg, yLeg, 9, 9).fill(COLORI[d].fondo);
|
||||
doc.rect(xLeg, yLeg, 2.5, 9).fill(COLORI[d].barra);
|
||||
doc.font('Helvetica').fontSize(7).fillColor(C_TESTO)
|
||||
.text(d, xLeg + 13, yLeg + 2, { width: 70 });
|
||||
xLeg += 13 + doc.widthOfString(d) + 18;
|
||||
}
|
||||
|
||||
doc.font('Helvetica').fontSize(7).fillColor(C_TENUE).text(
|
||||
"Gli orari possono subire variazioni. Le lezioni si prenotano dall'app Bsport.",
|
||||
x0, yLeg + 16, { width: larghezzaUtile },
|
||||
);
|
||||
doc.font('Helvetica').fontSize(7).fillColor(C_ACCENTO)
|
||||
.text('insanitylab.it', x0, yLeg + 27, { width: larghezzaUtile, align: 'right' });
|
||||
|
||||
doc.end();
|
||||
return finito;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { calendari } from '../../data/calendari';
|
||||
import { generaCalendarioPdf, nomeFilePdf } from '../../lib/calendario-pdf';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Il PDF del calendario, come file scaricabile: `/calendario/<id>.pdf`.
|
||||
//
|
||||
// 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.
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const cal = calendari.find((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 });
|
||||
|
||||
const pdf = await generaCalendarioPdf(cal);
|
||||
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-Length': String(pdf.length),
|
||||
// Il calendario cambia solo quando cambia il dato, cioè con un deploy: un'ora di cache
|
||||
// basta a non rigenerarlo a ogni click, e non abbastanza da servire un orario vecchio.
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user