Files
David DyerandClaude Sonnet 5 e0904b368c Derive meta-description hours from Hours & Visit data instead of hardcoding
The <meta description>, og:description, and twitter:description tags all
had "Open Thursday through Saturday, 4–10pm" typed in by hand, so editing
hours in the admin panel silently left the page's search/social preview
copy wrong.

Added hours_meta_summary() to inc/hours.php, built from the same
group_hours_into_ranges() grouping the Hours & Visit card and footer
already use, so all three now read the live pub_hours data and stay in
sync with whatever's set under Hours & Visit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 10:27:42 -04:00

371 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Pub hours helper functions.
*
* Days are keyed 0=Sunday .. 6=Saturday throughout (SQLite's/JS's own
* convention), so callers can index straight into HOURS_DAY_NAMES etc.
* without any Monday-first translation.
*/
require_once __DIR__ . '/db.php';
const HOURS_DAY_NAMES = [
0 => 'Sunday', 1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday',
4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday',
];
const HOURS_DAY_ABBR = [
0 => 'Sun', 1 => 'Mon', 2 => 'Tue', 3 => 'Wed',
4 => 'Thu', 5 => 'Fri', 6 => 'Sat',
];
/**
* Returns all 7 days, 0..6, always fully populated (a day missing from the
* table — shouldn't happen once seeded, but this keeps every caller below
* from having to guard against a gap) defaults to closed.
*/
function get_all_hours(): array {
$pdo = get_db();
$rows = $pdo->query("SELECT * FROM pub_hours ORDER BY day_of_week ASC")->fetchAll() ?: [];
$byDay = [];
foreach ($rows as $row) {
$byDay[(int) $row['day_of_week']] = $row;
}
$result = [];
for ($d = 0; $d <= 6; $d++) {
$result[$d] = $byDay[$d] ?? [
'day_of_week' => $d,
'is_open' => 0,
'open_time' => null,
'close_time' => null,
'kitchen_close_time' => null,
];
}
return $result;
}
/**
* Normalizes a submitted time value down to "HH:MM", or null if it's blank
* or not something an <input type="time"> would ever actually send.
*/
function normalize_time_value(?string $value): ?string {
if ($value === null) {
return null;
}
$value = trim($value);
if ($value === '') {
return null;
}
if (!preg_match('/^([01]?\d|2[0-3]):([0-5]\d)/', $value, $m)) {
return null;
}
return sprintf('%02d:%s', (int) $m[1], $m[2]);
}
/**
* Persists all 7 days in one go from the admin form's day[N][...] fields.
* A day marked closed has its times forced to null regardless of whatever
* stale values its (disabled) time inputs may have carried in the POST.
*/
function save_all_hours(array $daysInput): void {
$pdo = get_db();
$stmt = $pdo->prepare("
INSERT INTO pub_hours (day_of_week, is_open, open_time, close_time, kitchen_close_time, updated_at)
VALUES (:day, :is_open, :open_time, :close_time, :kitchen_close_time, CURRENT_TIMESTAMP)
ON CONFLICT(day_of_week) DO UPDATE SET
is_open = excluded.is_open,
open_time = excluded.open_time,
close_time = excluded.close_time,
kitchen_close_time = excluded.kitchen_close_time,
updated_at = CURRENT_TIMESTAMP
");
for ($d = 0; $d <= 6; $d++) {
$dayData = is_array($daysInput[$d] ?? null) ? $daysInput[$d] : [];
$isOpen = !empty($dayData['is_open']) ? 1 : 0;
$openTime = normalize_time_value($dayData['open_time'] ?? null);
$closeTime = normalize_time_value($dayData['close_time'] ?? null);
$kitchenClose = normalize_time_value($dayData['kitchen_close_time'] ?? null);
if (!$isOpen) {
$openTime = null;
$closeTime = null;
$kitchenClose = null;
}
$stmt->execute([
':day' => $d,
':is_open' => $isOpen,
':open_time' => $openTime,
':close_time' => $closeTime,
':kitchen_close_time' => $kitchenClose,
]);
}
}
/**
* "4:00 PM" / "9 AM" — 12-hour, minutes dropped when they're :00 and
* $dropZeroMinutes is set (used for one-off mentions like the kitchen note;
* ranges below always keep :00 so both ends line up visually).
*/
function format_time_12h(?string $hhmm, bool $dropZeroMinutes = false): string {
if (!$hhmm) {
return '';
}
[$h, $m] = array_map('intval', explode(':', $hhmm));
$period = $h >= 12 ? 'PM' : 'AM';
$hour12 = $h % 12;
if ($hour12 === 0) {
$hour12 = 12;
}
if ($dropZeroMinutes && $m === 0) {
return "{$hour12} {$period}";
}
return sprintf('%d:%02d %s', $hour12, $m, $period);
}
/**
* "4:00 – 10:00 PM" (or "11:00 AM – 2:00 PM" when the two ends straddle
* noon) — a single trailing meridiem when both ends share one, otherwise
* both spelled out. $dash lets callers pick the tight "–" the footer's
* condensed line uses vs the spaced " – " the hours card uses.
*/
function format_time_range(string $openTime, string $closeTime, string $dash = ' – '): string {
$openHour = (int) explode(':', $openTime)[0];
$closeHour = (int) explode(':', $closeTime)[0];
$openPeriod = $openHour >= 12 ? 'PM' : 'AM';
$closePeriod = $closeHour >= 12 ? 'PM' : 'AM';
$openLabel = format_time_12h($openTime);
$closeLabel = format_time_12h($closeTime);
if ($openPeriod === $closePeriod) {
$openLabel = trim(str_replace($openPeriod, '', $openLabel));
}
return "{$openLabel}{$dash}{$closeLabel}";
}
/**
* Groups the week (in Sun..Sat order) into consecutive runs sharing the
* same open/closed status and, if open, the same start/end time. Doesn't
* wrap around from Saturday back to Sunday — a closed Sat + Sun would come
* back as two separate single-day groups rather than one "Sat – Sun" — but
* that's an edge case this pub's schedule doesn't hit, and not worth the
* extra complexity of circular grouping for.
*/
function group_hours_into_ranges(array $hoursByDay): array {
$groups = [];
$current = null;
for ($d = 0; $d <= 6; $d++) {
$day = $hoursByDay[$d];
$isOpen = !empty($day['is_open']) && $day['open_time'] && $day['close_time'];
$key = $isOpen ? 'open|' . $day['open_time'] . '|' . $day['close_time'] : 'closed';
if ($current !== null && $current['key'] === $key) {
$current['days'][] = $d;
} else {
if ($current !== null) {
$groups[] = $current;
}
$current = ['key' => $key, 'is_open' => $isOpen, 'days' => [$d], 'data' => $day];
}
}
if ($current !== null) {
$groups[] = $current;
}
return $groups;
}
/**
* Rows for the "Hours & Visit" card: every open day gets its own line (even
* if it's identical to its neighbor's), any run of closed days collapses
* into one summary row — matches the level of detail the site originally
* hardcoded (Thursday / Friday / Saturday individually, "Sun – Wed" combined).
*/
function hours_card_rows(array $hoursByDay): array {
// Open days are collected separately from closed runs, and always
// rendered first — matching the original hardcoded card, which led
// with the days you *can* visit and tucked the closed stretch at the
// bottom as a footnote, rather than the plain chronological (Sun..Sat)
// order that would otherwise put a Sun–Wed closed run up top.
$openRows = [];
$closedGroups = [];
$closedRun = [];
for ($d = 0; $d <= 6; $d++) {
$day = $hoursByDay[$d];
$isOpen = !empty($day['is_open']) && $day['open_time'] && $day['close_time'];
if ($isOpen) {
if (!empty($closedRun)) {
$closedGroups[] = $closedRun;
$closedRun = [];
}
$openRows[] = ['label' => HOURS_DAY_NAMES[$d], 'text' => format_time_range($day['open_time'], $day['close_time'])];
} else {
$closedRun[] = $d;
}
}
if (!empty($closedRun)) {
$closedGroups[] = $closedRun;
}
$rows = $openRows;
foreach ($closedGroups as $group) {
$rows[] = ['label' => day_abbr_range($group), 'text' => 'Closed'];
}
return $rows;
}
/**
* Condensed lines for the footer's compact hours summary: every group
* (open or closed) collapses to one line, open groups listed first — the
* order the site's footer originally used — with the kitchen note (if any)
* inserted between the open and closed lines.
*/
function hours_footer_lines(array $hoursByDay): array {
$groups = group_hours_into_ranges($hoursByDay);
$openLines = [];
$closedLines = [];
foreach ($groups as $group) {
$label = day_abbr_range($group['days'], ' — ');
if ($group['is_open']) {
$openLines[] = $label . ' · ' . format_time_range($group['data']['open_time'], $group['data']['close_time'], '–');
} else {
$closedLines[] = $label . ' · Closed';
}
}
$lines = $openLines;
$kitchenNote = hours_kitchen_note($hoursByDay);
if ($kitchenNote) {
$lines[] = $kitchenNote;
}
return array_merge($lines, $closedLines);
}
/**
* "Sun" for a single day, "Sun – Wed" (or with a custom $dash) for a run.
*/
function day_abbr_range(array $days, string $dash = ' – '): string {
if (count($days) === 1) {
return HOURS_DAY_ABBR[$days[0]];
}
return HOURS_DAY_ABBR[$days[0]] . $dash . HOURS_DAY_ABBR[end($days)];
}
/**
* "4pm" / "4:30pm" — tight, lowercase, no leading-zero-minute clutter.
* Used only by format_time_range_compact() below for the meta-description
* summary's condensed style; the site's on-page hours use format_time_12h
* instead, which keeps a space and uppercase AM/PM.
*/
function format_hour_compact(int $hour, int $minute): string {
$period = $hour >= 12 ? 'pm' : 'am';
$hour12 = $hour % 12;
if ($hour12 === 0) {
$hour12 = 12;
}
if ($minute === 0) {
return "{$hour12}{$period}";
}
return sprintf('%d:%02d%s', $hour12, $minute, $period);
}
/**
* "4–10pm" (or "11am–2pm" when the two ends straddle noon) — same single
* trailing-meridiem rule as format_time_range(), but compact/lowercase to
* match the site's existing meta-description copy style.
*/
function format_time_range_compact(string $openTime, string $closeTime): string {
[$openHour, $openMin] = array_map('intval', explode(':', $openTime));
[$closeHour, $closeMin] = array_map('intval', explode(':', $closeTime));
$openPeriod = $openHour >= 12 ? 'pm' : 'am';
$closePeriod = $closeHour >= 12 ? 'pm' : 'am';
$openLabel = format_hour_compact($openHour, $openMin);
$closeLabel = format_hour_compact($closeHour, $closeMin);
if ($openPeriod === $closePeriod) {
$openLabel = substr($openLabel, 0, -strlen($openPeriod));
}
return "{$openLabel}–{$closeLabel}";
}
/**
* "Thursday" for a single day, "Thursday through Saturday" for a run —
* the full-name equivalent of day_abbr_range(), for prose summaries.
*/
function day_name_range(array $days): string {
if (count($days) === 1) {
return HOURS_DAY_NAMES[$days[0]];
}
return HOURS_DAY_NAMES[$days[0]] . ' through ' . HOURS_DAY_NAMES[end($days)];
}
/**
* "Open Thursday through Saturday, 4–10pm." — the one-line summary used in
* <meta name="description">, og:description and twitter:description, built
* from the same day groupings as the rest of this file so the blurb search
* engines and social previews show always matches whatever's actually set
* in Hours & Visit / the admin hours page, instead of hand-typed copy that
* can silently drift out of date.
*/
function hours_meta_summary(array $hoursByDay): string {
$openGroups = array_values(array_filter(
group_hours_into_ranges($hoursByDay),
fn(array $group): bool => $group['is_open']
));
if (empty($openGroups)) {
return 'Check our Facebook page for current hours.';
}
$parts = [];
foreach ($openGroups as $group) {
$parts[] = day_name_range($group['days']) . ', ' . format_time_range_compact($group['data']['open_time'], $group['data']['close_time']);
}
return 'Open ' . implode('; ', $parts) . '.';
}
/**
* "Kitchen closes at 9 PM" — or, if open days don't all share one kitchen
* closing time, one clause per distinct time naming which days it applies
* to. Returns null when no open day has a kitchen closing time set, so
* callers can omit the note entirely rather than print an empty sentence.
*/
function hours_kitchen_note(array $hoursByDay, bool $dropZeroMinutes = true): ?string {
$byTime = [];
for ($d = 0; $d <= 6; $d++) {
$day = $hoursByDay[$d];
if (!empty($day['is_open']) && !empty($day['kitchen_close_time'])) {
$byTime[$day['kitchen_close_time']][] = $d;
}
}
if (empty($byTime)) {
return null;
}
if (count($byTime) === 1) {
$time = array_key_first($byTime);
return 'Kitchen closes at ' . format_time_12h($time, $dropZeroMinutes);
}
$parts = [];
foreach ($byTime as $time => $days) {
$parts[] = format_time_12h($time, $dropZeroMinutes) . ' (' . day_abbr_range($days, '–') . ')';
}
return 'Kitchen closes at ' . implode(', ', $parts);
}