- Add a Hours admin panel (admin/hours.php, admin/hours.js) for editing the weekly open/close schedule and an optional per-day kitchen closing time, with a live preview of how it'll read on the public site - Store hours in a new pub_hours table (inc/db.php), seeded with the site's existing Thu-Sat/9pm-kitchen schedule; inc/hours.php formats and groups it for display (day ranges, kitchen-close note, footer summary) - Wire the public Visit card and both site footers (index.php, menu.php) to read hours from that data instead of hardcoded copy - Rebuild the Announcements and Users admin tables onto the same CSS Grid list pattern already used for the Menu (admin-table-actions -> row-actions), and make the Announcements status toggle an in-place AJAX action like the menu item's (admin/index.js) - Add the Hours tab to admin nav across all four admin pages Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
295 lines
9.8 KiB
PHP
295 lines
9.8 KiB
PHP
<?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)];
|
||
}
|
||
|
||
/**
|
||
* "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);
|
||
}
|