Files
fitme3070.pt/site/includes/horario.php
T
andreandClaude Sonnet 5 595a37fd8b fix(site): width/height nas imagens da home (auditoria 4.7)
125 imagens na home, 0 com srcset e 122 sem width/height — o browser
só sabia o tamanho depois de descarregar, o que causa layout shift.

Uploads são redimensionados a um limite de lado maior (functions_images.php),
não a uma dimensão fixa (fotos de equipa vão de 389x512 a 512x512, ícones
de oferta de 128x128 a 256x256), por isso as dimensões não dão para
hardcode: site_img_dims_attr() em functions_site.php lê o ficheiro real
com getimagesize() por pedido, tal como o gallery.php já fazia, e
devolve o par width="" height="" ou "" se o ficheiro não existir. SVG
não passa por getimagesize() (sem decoder embutido) — svg_img_dims_attr()
lê os atributos width/height (ícones fitme-*.svg, 48x48 fixo) ou cai
para o viewBox (logos do navbar, só têm viewBox).

Aplicado a: ícones da oferta, fotos de equipa (card + painel de detalhe),
ícone de modalidade em modalidade_icon_html() e no horário (index.php e
includes/horario.php), logo do navbar.

srcset ficou de fora: as imagens já vêm coartadas para pouco mais do que
o maior tamanho a que aparecem (fotos de equipa a 512px cabidas numa caixa
de no máx. 200px CSS com object-fit:cover; ícones a 128-256px mostrados a
44px) — é a folga normal para ecrãs retina, não desperdício a resolver
com múltiplas resoluções. E o layout é sempre CSS-driven (width/height:
100% + object-fit), por isso um srcset não mudaria o que é descarregado
por variação de viewport, só pouparia bytes nalguns casos — gerar 2-3
tamanhos por upload via GD seria trabalho a mais para o ganho real neste
site. Fica só o fix de dimensões, que é o que resolve o CLS que a
auditoria mediu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 21:44:29 +01:00

174 lines
8.9 KiB
PHP

<?php
// Weekly schedule — time-aligned CSS grid with a single-day picker. Shared by
// the CrossFit page (the home page renders its own copy inline). $data holds the
// horario rows (site_horario), $schedule_h2 the heading. Mirrors the home grid
// so the same CSS (.schedule…) and the day-filter JS apply unchanged.
$week_day = ['Seg'=>'segunda','Ter'=>'terça','Qua'=>'quarta','Qui'=>'quinta','Sex'=>'sexta','Sab'=>'sábado'];
$rows = $data ?? [];
$week_values = array_values($week_day);
$today_map = [1=>'segunda', 2=>'terça', 3=>'quarta', 4=>'quinta', 5=>'sexta', 6=>'sábado'];
$default_day = $today_map[(int) date('N')] ?? null;
if (!in_array($default_day, $week_values, true)) { $default_day = $week_values[0]; }
// Distinct start times → grid rows; days → columns. Split Manhã (<12:00) / Tarde.
$slot_times = [];
foreach ($rows as $d) { $slot_times[$d['hora_inicio']] = true; }
ksort($slot_times);
$slot_times = array_keys($slot_times);
$morning = $afternoon = [];
foreach ($slot_times as $t) { if ((int) substr($t, 0, 2) < 12) { $morning[] = $t; } else { $afternoon[] = $t; } }
$row_of = []; $sections = []; $gr = 2; // row 1 = day headers
if ($morning) { $sections[] = ['Manhã', $gr++]; foreach ($morning as $t) { $row_of[$t] = $gr++; } }
if ($afternoon) { $sections[] = ['Tarde', $gr++]; foreach ($afternoon as $t) { $row_of[$t] = $gr++; } }
$total_rows = $gr - 1;
$grid = [];
foreach ($rows as $d) { $grid[$d['dia_semana']][$d['hora_inicio']][] = $d; }
?>
<div class="schedule fade-in">
<div class="container">
<?php if (!empty($schedule_h2)): ?>
<div class="row justify-content-md-center mb-4">
<div class="col-lg-8">
<div class="section-title text-center deco">
<h2><?= htmlspecialchars($schedule_h2) ?></h2>
</div>
<div class="text-center mb-2">
<button type="button" class="schedule-dl-btn" id="scheduleDownloadCf">
<i class="fas fa-download" aria-hidden="true"></i> Guardar horário
</button>
</div>
</div>
</div>
<?php endif; ?>
<div class="schedule-day-filter" role="tablist" aria-label="Filtrar horário por dia">
<?php foreach ($week_day as $key => $value): ?>
<button type="button" class="sdf-btn<?= $value === $default_day ? ' active' : '' ?>" role="tab" data-day="<?= htmlspecialchars($value) ?>"><?= $key ?></button>
<?php endforeach; ?>
</div>
<div class="row">
<div class="col">
<div class="schedule_container">
<div class="schedule_panel" style="--sched-days:<?= count($week_day) ?>;--sched-rows:<?= $total_rows ?>;">
<?php $col = 0; foreach ($week_day as $key => $value): $col++; ?>
<div class="schedule_stripe<?= $col % 2 === 0 ? ' is-alt' : '' ?>" aria-hidden="true" style="grid-column:<?= $col ?>;grid-row:1 / -1;"></div>
<?php endforeach; ?>
<?php $col = 0; foreach ($week_day as $key => $value): $col++; ?>
<div class="schedule_dayhead<?= $value === $default_day ? '' : ' sched-hide' ?>" data-day="<?= htmlspecialchars($value) ?>" style="grid-column:<?= $col ?>;grid-row:1;">
<div class="rounded_frame"><?= $key ?></div>
</div>
<?php endforeach; ?>
<?php foreach ($sections as $sec): ?>
<div class="schedule_section_label" data-section="<?= $sec[0] === 'Manhã' ? 'manha' : 'tarde' ?>" style="grid-row:<?= $sec[1] ?>;"><span><?= htmlspecialchars($sec[0]) ?></span></div>
<?php endforeach; ?>
<?php $col = 0; foreach ($week_day as $key => $value): $col++; ?>
<?php foreach ($slot_times as $time): ?>
<?php if (!empty($grid[$value][$time])): ?>
<div class="schedule_cell<?= $value === $default_day ? '' : ' sched-hide' ?>" data-day="<?= htmlspecialchars($value) ?>" data-section="<?= ((int) substr($time, 0, 2) < 12) ? 'manha' : 'tarde' ?>" style="grid-column:<?= $col ?>;grid-row:<?= $row_of[$time] ?>;">
<?php foreach ($grid[$value][$time] as $d): ?>
<div class="schedule_box" style="background-color:<?= htmlspecialchars((string) $d['cor']) ?>">
<img src="<?= htmlspecialchars(site_img_url($d['icon'], 'uploads/modalidades/icons')) ?>" alt="<?= htmlspecialchars((string) $d['nome']) ?>" <?= site_img_dims_attr($d['icon'], 'uploads/modalidades/icons') ?> decoding="async">
<a><b><?= htmlspecialchars((string) $d['nome']) ?></b></a>
<div class="schedule_info">
<?= htmlspecialchars((string) $d['hora_inicio']) ?>
<b><?= htmlspecialchars((string) $d['local']) ?></b>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
/* Quick "save the weekly schedule as JPG" — replicated from the home page,
scoped to this partial's .schedule (the CrossFit page has no #horario wrapper).
Renders the live schedule in a 1920px virtual window and restyles the CLONE
(onclone) with .schedule-export so the image is always the full desktop week,
stamped with the generation date. */
(function () {
var btn = document.getElementById('scheduleDownloadCf');
var schedule = btn ? btn.closest('.schedule') : null;
if (!btn || !schedule) return;
var H2C_SRC = '<?= base_url('site/assets/js/print/html2canvas.min.js') ?>?v=1.4.1';
var BOARD_W = 1920;
function loadH2C() {
if (window.html2canvas) return Promise.resolve();
return new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = H2C_SRC;
s.onload = resolve;
s.onerror = function () { reject(new Error('Falha a carregar html2canvas')); };
document.head.appendChild(s);
});
}
btn.addEventListener('click', function () {
var label = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = 'A gerar imagem…';
var fontsReady = (document.fonts && document.fonts.ready) ? document.fonts.ready : Promise.resolve();
loadH2C().then(function () { return fontsReady; }).then(function () {
var bg = (getComputedStyle(document.body).getPropertyValue('--fitme-surface') || '#F7F8FA').trim();
return html2canvas(schedule, {
backgroundColor: bg,
scale: 1,
useCORS: true,
logging: false,
windowWidth: BOARD_W,
windowHeight: 1080,
scrollX: 0,
scrollY: 0,
onclone: function (doc) {
var board = doc.querySelector('.schedule');
if (!board) return;
board.classList.add('schedule-export');
board.classList.remove('fade-in');
Array.prototype.forEach.call(board.querySelectorAll('.fade-in, .revealed'), function (el) {
el.classList.remove('fade-in');
el.classList.remove('revealed');
});
var f = board.querySelector('.schedule-day-filter'); if (f) f.parentNode.removeChild(f);
var b = board.querySelector('.schedule-dl-btn'); if (b) { var w = b.closest('div'); (w || b).parentNode.removeChild(w || b); }
Array.prototype.forEach.call(board.querySelectorAll('.sched-hide'), function (c) { c.classList.remove('sched-hide'); });
var stamp = doc.createElement('p');
stamp.className = 'schedule-stamp';
stamp.textContent = 'Gerado a ' + new Date().toLocaleDateString('pt-PT') + ' · sujeito a alterações';
(board.querySelector('.container') || board).appendChild(stamp);
}
});
}).then(function (canvas) {
return new Promise(function (resolve, reject) {
canvas.toBlob(function (blob) {
if (!blob) { reject(new Error('Falha ao gerar a imagem')); return; }
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'horario-crossfit-' + new Date().toISOString().slice(0, 10) + '.jpg';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
resolve();
}, 'image/jpeg', 0.95);
});
}).catch(function (err) {
console.error(err);
alert('Não foi possível gerar a imagem. Tenta novamente.');
}).then(function () {
btn.disabled = false;
btn.innerHTML = label;
});
});
})();
</script>