'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 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
* , 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);
}