Add pub hours admin panel, rebuild announcements/users lists on CSS Grid

- 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>
This commit is contained in:
David DyerandClaude Sonnet 5 committed 2026-09-01 11:18:23 -04:00
1 parent fd928a24cf
commit eab20899de
14 files changed
+1224 -441

No files matched your search

+38
View File
@@ -77,6 +77,15 @@ function init_db_schema(PDO $pdo): void {
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pub_hours (
day_of_week INTEGER PRIMARY KEY,
is_open INTEGER NOT NULL DEFAULT 0,
open_time TEXT NULL,
close_time TEXT NULL,
kitchen_close_time TEXT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
");
// Seed default admin user if table is empty (username: admin / password: password)
@@ -87,4 +96,33 @@ function init_db_schema(PDO $pdo): void {
$insert = $pdo->prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')");
$insert->execute(['admin', $defaultPassHash]);
}
// Seed default weekly hours if the table is empty: closed Sun–Wed,
// 4–10pm Thu–Sat with the kitchen closing at 9 — matches the hours the
// site originally shipped with as hardcoded copy.
$stmt = $pdo->query("SELECT COUNT(*) as count FROM pub_hours");
$hoursCount = $stmt->fetchColumn();
if ($hoursCount == 0) {
$insert = $pdo->prepare("
INSERT INTO pub_hours (day_of_week, is_open, open_time, close_time, kitchen_close_time)
VALUES (?, ?, ?, ?, ?)
");
// 0=Sunday ... 6=Saturday
$defaults = [
0 => null, // Sunday — closed
1 => null, // Monday — closed
2 => null, // Tuesday — closed
3 => null, // Wednesday — closed
4 => ['16:00', '22:00', '21:00'], // Thursday
5 => ['16:00', '22:00', '21:00'], // Friday
6 => ['16:00', '22:00', '21:00'], // Saturday
];
foreach ($defaults as $day => $times) {
if ($times === null) {
$insert->execute([$day, 0, null, null, null]);
} else {
$insert->execute([$day, 1, $times[0], $times[1], $times[2]]);
}
}
}
}
+294
View File
@@ -0,0 +1,294 @@
<?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);
}