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