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:
2026-08-17 16:14:02 +02:00
parent 01f49ab6d2
commit 5be0fa787c
6 changed files with 416 additions and 11 deletions
+181 -3
View File
@@ -22,6 +22,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pdfkit": "^0.19.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"recharts": "^3.10.1", "recharts": "^3.10.1",
@@ -32,6 +33,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"marked": "^18.0.7", "marked": "^18.0.7",
"typescript": "^6.0.3", "typescript": "^6.0.3",
@@ -1964,6 +1966,30 @@
"@emnapi/runtime": "^1.7.1" "@emnapi/runtime": "^1.7.1"
} }
}, },
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@oslojs/encoding": { "node_modules/@oslojs/encoding": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -2387,6 +2413,15 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tiptap/core": { "node_modules/@tiptap/core": {
"version": "3.27.1", "version": "3.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz",
@@ -2979,6 +3014,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -3583,6 +3628,24 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.7", "version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
@@ -3751,6 +3814,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -4163,6 +4235,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": { "node_modules/diff": {
"version": "8.0.4", "version": "8.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
@@ -4449,7 +4527,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-string-truncated-width": { "node_modules/fast-string-truncated-width": {
@@ -4534,6 +4611,23 @@
"fontkitten": "^1.0.2" "fontkitten": "^1.0.2"
} }
}, },
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkitten": { "node_modules/fontkitten": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz",
@@ -4889,6 +4983,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5222,6 +5322,25 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/linkifyjs": { "node_modules/linkifyjs": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
@@ -5692,6 +5811,12 @@
"integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-srcset": { "node_modules/parse-srcset": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
@@ -5712,6 +5837,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfkit": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"fontkit": "^2.0.4",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^1.1.0"
}
},
"node_modules/piccolore": { "node_modules/piccolore": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@@ -5736,6 +5875,14 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
@@ -6216,6 +6363,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/retext-smartypants": { "node_modules/retext-smartypants": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz",
@@ -6782,8 +6935,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD", "license": "0BSD"
"optional": true
}, },
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
@@ -6853,6 +7005,32 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+2
View File
@@ -28,6 +28,7 @@
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pdfkit": "^0.19.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"recharts": "^3.10.1", "recharts": "^3.10.1",
@@ -38,6 +39,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pdfkit": "^0.17.6",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
"marked": "^18.0.7", "marked": "^18.0.7",
"typescript": "^6.0.3", "typescript": "^6.0.3",
+8 -8
View File
@@ -33,7 +33,7 @@ const presenti = cal
)} )}
</div> </div>
<div class="cal-dlg__azioni"> <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> <button type="button" class="cal-dlg__chiudi" data-chiudi-calendario aria-label={t('calendario.chiudi-label')}>×</button>
</div> </div>
</header> </header>
@@ -93,10 +93,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" = stampa con la destinazione "Salva come PDF": la pagina di stampa è // "Scarica in PDF" è un link a `/calendario/<id>.pdf`, cioè un file vero generato dal
// già impaginata per la sola griglia (vedi @media print), quindi non serve generare nulla // server: si salva sul telefono e si gira su WhatsApp. Il CSS di stampa più sotto resta
// lato server né caricare una libreria. // per chi preme Ctrl+P col popup aperto.
dlg.querySelector('[data-stampa-calendario]')?.addEventListener('click', () => window.print());
} }
</script> </script>
@@ -122,9 +121,10 @@ const presenti = cal
} }
.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 {
font-family: var(--font-heading); font-size: .68rem; font-weight: 600; letter-spacing: .2em; display: inline-block; font-family: var(--font-heading); font-size: .68rem; font-weight: 600;
text-transform: uppercase; border: 0; cursor: pointer; padding: 13px 22px; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; border: 0;
background: var(--c-accent); color: #fff; transition: background .2s; 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__btn:hover { background: var(--c-accent-dark); }
.cal-dlg__chiudi { .cal-dlg__chiudi {
+159
View File
@@ -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;
}
+31
View File
@@ -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',
},
});
};
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { calendari } from '../src/data/calendari';
import { generaCalendarioPdf, 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);
// 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.
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-');
expect(pdf.subarray(-8).toString()).toContain('%%EOF');
expect(pdf.length).toBeGreaterThan(2000);
});
}
// 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
// /Type /Pages, che dichiara quante pagine contiene.
it('sta in una pagina sola', async () => {
for (const cal of calendari) {
const pdf = await generaCalendarioPdf(cal);
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);
}
});
it('il nome del file distingue i due periodi', () => {
const nomi = calendari.map(nomeFilePdf);
expect(new Set(nomi).size).toBe(nomi.length);
for (const n of nomi) expect(n).toMatch(/^insanitylab-calendario-[a-z0-9-]+\.pdf$/);
});
});