diff --git a/cron/class_report.php b/cron/class_report.php
new file mode 100644
index 0000000..89cc41f
--- /dev/null
+++ b/cron/class_report.php
@@ -0,0 +1,327 @@
+ classi di oggi
+ * php cron/class_report.php 2026-09-25 -> classi di una data specifica
+ * php cron/class_report.php --dry-run -> NON invia, salva le anteprime HTML in cron/preview/
+ */
+
+use PHPMailer\PHPMailer\PHPMailer;
+use PHPMailer\PHPMailer\Exception as MailException;
+
+if (PHP_SAPI !== 'cli') {
+ http_response_code(403);
+ exit('Accesso consentito solo da CLI');
+}
+
+date_default_timezone_set('Europe/Rome');
+
+$root = dirname(__DIR__);
+chdir($root);
+require_once $root . '/public/class/db-functions.php'; // include anche vendor/autoload.php
+
+$pdo = DBHandlerSelect::getInstance()->getConnection(); // carica anche il .env
+
+/* ---------------------------------------------------------------- helper */
+
+function cfg(string $key, $default = null)
+{
+ $v = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
+ return ($v === false || $v === null || $v === '') ? $default : $v;
+}
+
+function h($v): string
+{
+ return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
+}
+
+function logmsg(string $m): void
+{
+ echo '[' . date('Y-m-d H:i:s') . '] ' . $m . PHP_EOL;
+}
+
+function chip(string $label, string $bg, string $fg): string
+{
+ return '' . h($label) . '';
+}
+
+function fullName(array $p): string
+{
+ $n = trim(($p['first_name'] ?? '') . ' ' . ($p['last_name'] ?? ''));
+ return $n !== '' ? $n : 'Utente #' . (int) ($p['iduser'] ?? 0);
+}
+
+function italianDate(DateTime $d): string
+{
+ $days = ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato'];
+ $months = ['', 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'];
+ return $days[(int) $d->format('w')] . ' ' . (int) $d->format('j') . ' ' . $months[(int) $d->format('n')] . ' ' . $d->format('Y');
+}
+
+/* ----------------------------------------------------------- argomenti */
+
+$dryRun = in_array('--dry-run', $argv, true);
+$day = date('Y-m-d');
+foreach (array_slice($argv, 1) as $a) {
+ if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $a)) {
+ $day = $a;
+ }
+}
+
+$recipients = array_values(array_filter(array_map('trim', explode(',', (string) cfg('TEACHER_EMAIL', '')))));
+if (!$recipients && !$dryRun) {
+ logmsg('ERRORE: TEACHER_EMAIL non impostato nel .env');
+ exit(1);
+}
+
+$location = cfg('CLASS_LOCATION', 'via Valassina 62/B Seregno - Sala Contesto Yoga');
+
+/* ------------------------------------------------------ classi del giorno */
+
+$stmt = $pdo->prepare(
+ "SELECT ss.idserviceschedule, ss.dateschedule, s.servicename, s.colorclass, s.maxcapacity
+ FROM serviceschedule ss
+ LEFT JOIN service s ON ss.idservice = s.idservice
+ WHERE ss.dateschedule >= :d1 AND ss.dateschedule < :d2
+ ORDER BY ss.dateschedule"
+);
+$stmt->execute([
+ ':d1' => $day . ' 00:00:00',
+ ':d2' => date('Y-m-d', strtotime($day . ' +1 day')) . ' 00:00:00',
+]);
+$classes = $stmt->fetchAll();
+
+if (!$classes) {
+ logmsg("Nessuna classe il $day. Nessuna mail inviata.");
+ exit(0);
+}
+
+$ids = array_map(fn($c) => (int) $c['idserviceschedule'], $classes);
+$ph = implode(',', array_fill(0, count($ids), '?'));
+$stmtB = $pdo->prepare(
+ "SELECT bc.idbookingclass, bc.idserviceschedule, bc.lostlesson, bc.status,
+ u.id AS iduser, u.first_name, u.last_name, u.email
+ FROM bookingclass bc
+ LEFT JOIN auth_users u ON bc.iduser = u.id
+ WHERE bc.idserviceschedule IN ($ph)
+ AND bc.status IN ('booked', 'pending', 'waiting')
+ ORDER BY bc.created_at, u.last_name, u.first_name"
+);
+$stmtB->execute($ids);
+
+$bookingsBySchedule = [];
+foreach ($stmtB->fetchAll() as $b) {
+ $bookingsBySchedule[(int) $b['idserviceschedule']][] = $b;
+}
+
+/* ------------------------------------------------------------ template */
+
+function personRows(array $list, string $type): string
+{
+ if (!$list) {
+ return '
| Nessun partecipante |
';
+ }
+ $numColor = ['booked' => '#1ebf73', 'pending' => '#8a6d00', 'waiting' => '#d1698a'][$type];
+ $html = '';
+ $i = 1;
+ foreach ($list as $p) {
+ $isLost = ($type === 'booked' && ($p['lostlesson'] ?? '') === 'Y');
+ if ($type === 'booked') {
+ $chipHtml = $isLost ? chip('Persa', '#1f2937', '#ffffff') : chip('Confermata', '#e3f2ec', '#1a7f52');
+ } elseif ($type === 'pending') {
+ $chipHtml = chip('In attesa', '#fff4d6', '#8a6d00');
+ } else {
+ $chipHtml = chip('In coda', '#fbeef3', '#d1698a');
+ }
+ $nameColor = $isLost ? '#9ca3af' : '#1f2937';
+ $email = !empty($p['email']) ? '
' . h($p['email']) . '' : '';
+
+ $html .= ''
+ . ''
+ . '| ' . $i . ($type === 'waiting' ? '°' : '.') . ' | '
+ . '' . h(fullName($p)) . $email . ' | '
+ . '' . $chipHtml . ' | '
+ . '
|
';
+ $i++;
+ }
+ return $html;
+}
+
+function section(string $title, string $color, string $rows): string
+{
+ return '| '
+ . ' ' . $title . ' '
+ . ''
+ . ' |
';
+}
+
+function statBox(string $value, string $label, string $color): string
+{
+ return ''
+ . ' ' . $value . ' '
+ . '' . $label . ' '
+ . ' | ';
+}
+
+function renderEmail(array $cls, array $booked, array $pending, array $waiting, string $location): string
+{
+ $d = new DateTime($cls['dateschedule']);
+ $accent = $cls['colorclass'] ?: '#1ebf73';
+ $max = (int) $cls['maxcapacity'];
+ $nBooked = count($booked);
+ $nLost = count(array_filter($booked, fn($b) => ($b['lostlesson'] ?? '') === 'Y'));
+ $free = $max > 0 ? max(0, $max - $nBooked) : '–';
+ $isFull = $max > 0 && $nBooked >= $max;
+
+ $stats = ''
+ . statBox($nBooked . '/' . $max, 'Iscritti', $isFull ? '#c0392b' : '#1a7f52')
+ . statBox((string) $free, 'Posti liberi', '#374151')
+ . statBox((string) count($pending), 'In attesa', '#8a6d00')
+ . statBox((string) count($waiting), 'In coda', '#d1698a')
+ . '
';
+
+ $lostNote = $nLost > 0
+ ? 'di cui ' . $nLost . ' segnat' . ($nLost === 1 ? 'a' : 'e') . ' come pers' . ($nLost === 1 ? 'a' : 'e') . '
'
+ : '';
+
+ $body = section('Partecipanti confermati (' . $nBooked . ')', '#374151', personRows($booked, 'booked'));
+ if ($pending) {
+ $body .= section('In attesa di conferma (' . count($pending) . ')', '#8a6d00', personRows($pending, 'pending'));
+ }
+ if ($waiting) {
+ $body .= section('Lista d\'attesa (' . count($waiting) . ')', '#d1698a', personRows($waiting, 'waiting'));
+ }
+
+ return '' . h($cls['servicename']) . ''
+ . ''
+ . ''
+ . ''
+ // barra colore classe
+ . '| | '
+ // intestazione
+ . '| '
+ . ' YogiBook · Riepilogo classe '
+ . '' . h($cls['servicename']) . ''
+ . '' . h(italianDate($d)) . ' · ore ' . $d->format('H:i') . ' '
+ . '' . h($location) . ' '
+ . ' | '
+ // statistiche
+ . '| ' . $stats . ' ' . $lostNote . ' | '
+ // elenchi
+ . $body
+ // footer
+ . '| Generato automaticamente da YogiBook il ' . date('d/m/Y \a\l\l\e H:i') . ' | '
+ . ' |
';
+}
+
+function renderText(array $cls, array $booked, array $pending, array $waiting, string $location): string
+{
+ $d = new DateTime($cls['dateschedule']);
+ $max = (int) $cls['maxcapacity'];
+ $t = $cls['servicename'] . "\n" . italianDate($d) . ' ore ' . $d->format('H:i') . "\n" . $location . "\n\n";
+ $t .= 'Iscritti: ' . count($booked) . '/' . $max . ' | In attesa: ' . count($pending) . ' | In coda: ' . count($waiting) . "\n\n";
+ $blocks = ['CONFERMATI' => $booked, 'IN ATTESA DI CONFERMA' => $pending, "LISTA D'ATTESA" => $waiting];
+ foreach ($blocks as $title => $list) {
+ if ($title !== 'CONFERMATI' && !$list) {
+ continue;
+ }
+ $t .= $title . "\n";
+ if (!$list) {
+ $t .= " Nessun partecipante\n";
+ }
+ $i = 1;
+ foreach ($list as $p) {
+ $lost = ($p['status'] === 'booked' && ($p['lostlesson'] ?? '') === 'Y') ? ' (PERSA)' : '';
+ $t .= ' ' . $i++ . '. ' . fullName($p) . $lost . "\n";
+ }
+ $t .= "\n";
+ }
+ return $t;
+}
+
+/* -------------------------------------------------------------- mailer */
+
+function makeMailer(): PHPMailer
+{
+ $m = new PHPMailer(true);
+ $m->isSMTP();
+ $m->Host = cfg('MAIL_HOST');
+ $m->Port = (int) cfg('MAIL_PORT', 465);
+ $m->SMTPAuth = true;
+ $m->Username = cfg('MAIL_USERNAME');
+ $m->Password = cfg('MAIL_PASSWORD');
+ $enc = strtolower((string) cfg('MAIL_ENCRYPTION', 'ssl'));
+ $m->SMTPSecure = $enc === 'ssl' ? PHPMailer::ENCRYPTION_SMTPS : ($enc === 'tls' ? PHPMailer::ENCRYPTION_STARTTLS : '');
+ $m->SMTPKeepAlive = true;
+ $m->CharSet = 'UTF-8';
+ $m->Encoding = 'base64';
+ $m->setFrom(cfg('MAIL_FROM_ADDRESS'), cfg('MAIL_FROM_NAME', 'YogiBook'));
+ $m->isHTML(true);
+ return $m;
+}
+
+/* --------------------------------------------------------------- invio */
+
+$mailer = $dryRun ? null : makeMailer();
+$sent = 0;
+$failed = 0;
+
+foreach ($classes as $cls) {
+ $sid = (int) $cls['idserviceschedule'];
+ $all = $bookingsBySchedule[$sid] ?? [];
+ $booked = array_values(array_filter($all, fn($b) => $b['status'] === 'booked'));
+ $pending = array_values(array_filter($all, fn($b) => $b['status'] === 'pending'));
+ $waiting = array_values(array_filter($all, fn($b) => $b['status'] === 'waiting'));
+
+ $d = new DateTime($cls['dateschedule']);
+ $subject = $cls['servicename'] . ' · ' . $d->format('d/m') . ' ore ' . $d->format('H:i')
+ . ' · ' . count($booked) . '/' . (int) $cls['maxcapacity'] . ' iscritti';
+ if ($pending) {
+ $subject .= ' · ' . count($pending) . ' in attesa';
+ }
+ if ($waiting) {
+ $subject .= ' · ' . count($waiting) . ' in coda';
+ }
+
+ $html = renderEmail($cls, $booked, $pending, $waiting, $location);
+ $text = renderText($cls, $booked, $pending, $waiting, $location);
+
+ if ($dryRun) {
+ $dir = __DIR__ . '/preview';
+ if (!is_dir($dir)) {
+ mkdir($dir, 0775, true);
+ }
+ $file = $dir . '/classe_' . $sid . '.html';
+ file_put_contents($file, $html);
+ logmsg("[DRY-RUN] $subject -> $file");
+ continue;
+ }
+
+ try {
+ $mailer->clearAddresses();
+ foreach ($recipients as $r) {
+ $mailer->addAddress($r, cfg('TEACHER_NAME', ''));
+ }
+ $mailer->Subject = $subject;
+ $mailer->Body = $html;
+ $mailer->AltBody = $text;
+ $mailer->send();
+ $sent++;
+ logmsg("OK: $subject");
+ } catch (MailException $e) {
+ $failed++;
+ logmsg("ERRORE classe #$sid: " . $mailer->ErrorInfo);
+ }
+ sleep(1); // evita limiti di invio del server SMTP
+}
+
+if ($mailer) {
+ $mailer->smtpClose();
+}
+
+logmsg("Fine: $sent inviate, $failed errori (" . count($classes) . " classi il $day).");
+exit($failed > 0 ? 1 : 0);
diff --git a/cron/preview/classe_314.html b/cron/preview/classe_314.html
new file mode 100644
index 0000000..ed5b51d
--- /dev/null
+++ b/cron/preview/classe_314.html
@@ -0,0 +1 @@
+Aerial Yoga - mix| | YogiBook · Riepilogo classe Aerial Yoga - mixGiovedì 24 Settembre 2026 · ore 19:00 via Valassina 62/B Seregno - Sala Contesto Yoga | 2/8 Iscritti | 6 Posti liberi | 0 In attesa | 0 In coda |
| Partecipanti confermati (2) | 1. | Amelia Benucci info@claudiosironi.com | Confermata |
| | 2. | Aurora Masiero info@yogasoul.it | Confermata |
|
| | Generato automaticamente da YogiBook il 24/09/2026 alle 10:55 |
|
\ No newline at end of file