diff --git a/.gitignore b/.gitignore
index 4757342..df980ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,4 @@ db/fitz.sqlite-shm
.hintrc
dev.sh
+.claude/
diff --git a/admin/hours.js b/admin/hours.js
new file mode 100644
index 0000000..7030aba
--- /dev/null
+++ b/admin/hours.js
@@ -0,0 +1,18 @@
+// Hours admin page: purely a progressive enhancement over the plain form
+// POST that actually saves β greys out (and stops submitting) a day's time
+// fields while its "Open" box is unchecked, so a closed day can't look like
+// it still has stale hours set.
+
+document.querySelectorAll('.hours-day-row').forEach((row) => {
+ const toggle = row.querySelector('input[type="checkbox"]');
+ const timeFields = row.querySelectorAll('.day-time-field');
+
+ function applyState() {
+ timeFields.forEach((field) => {
+ field.disabled = !toggle.checked;
+ });
+ }
+
+ toggle.addEventListener('change', applyState);
+ applyState();
+});
diff --git a/admin/hours.php b/admin/hours.php
new file mode 100644
index 0000000..582800e
--- /dev/null
+++ b/admin/hours.php
@@ -0,0 +1,205 @@
+ !empty($dayData['is_open']) ? 1 : 0,
+ 'open_time' => normalize_time_value($dayData['open_time'] ?? null),
+ 'close_time' => normalize_time_value($dayData['close_time'] ?? null),
+ 'kitchen_close_time' => normalize_time_value($dayData['kitchen_close_time'] ?? null),
+ ];
+}
+
+/**
+ * A day marked "Open" needs both ends of its range set β everything else
+ * (closed days, the optional kitchen closing time) is fine left blank.
+ */
+function validate_hours_input(array $daysInput): ?string {
+ for ($d = 0; $d <= 6; $d++) {
+ $day = hours_day_from_post(is_array($daysInput[$d] ?? null) ? $daysInput[$d] : []);
+ if ($day['is_open'] && (!$day['open_time'] || !$day['close_time'])) {
+ return HOURS_DAY_NAMES[$d] . ' is marked open but is missing an opening or closing time.';
+ }
+ }
+ return null;
+}
+
+$submittedHours = null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ $errorMsg = 'Session expired or invalid security token. Please try again.';
+ } elseif (($_POST['action'] ?? '') === 'save_hours') {
+ $daysInput = is_array($_POST['day'] ?? null) ? $_POST['day'] : [];
+
+ if ($error = validate_hours_input($daysInput)) {
+ $errorMsg = $error;
+ $submittedHours = [];
+ for ($d = 0; $d <= 6; $d++) {
+ $submittedHours[$d] = hours_day_from_post(is_array($daysInput[$d] ?? null) ? $daysInput[$d] : []);
+ }
+ } else {
+ save_all_hours($daysInput);
+ header('Location: hours.php?saved=1');
+ exit;
+ }
+ }
+}
+
+$hoursByDay = $submittedHours ?? get_all_hours();
+$justSaved = !$_POST && isset($_GET['saved']);
+?>
+
+
+
+
+
+
+ Hours | The Old Fitz Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= htmlspecialchars($errorMsg) ?>
+
+
Hours saved β the public site is now showing the updated schedule.
+
+
+
+
+
+ π Weekly Schedule
+
+
+
+
+
+
+
+
+
+ π Public Site Preview
+
+
+
This is how the schedule above will read on the site once saved.
+
+
+
+
+
+
+
- = htmlspecialchars($row['label']) ?>
+ - = htmlspecialchars($row['text']) ?>
+
+
+
+
+
+ = htmlspecialchars($kitchenNote) ?> β bar snacks stay out later. Holiday hours may vary; check our Facebook page for the latest.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/index.js b/admin/index.js
new file mode 100644
index 0000000..62b5ee7
--- /dev/null
+++ b/admin/index.js
@@ -0,0 +1,34 @@
+// Announcements admin page: intercepts the status badge's toggle form so
+// clicking it updates in place via fetch instead of reloading the whole
+// page β same interaction as the menu item's status toggle. Everything
+// else here (create/edit/delete) is still a plain form POST + reload.
+
+document.addEventListener('submit', async (e) => {
+ const form = e.target;
+ if (!form.classList.contains('status-toggle-form')) return;
+
+ e.preventDefault();
+ const btn = form.querySelector('button');
+ btn.disabled = true;
+
+ try {
+ const response = await fetch('index.php', {
+ method: 'POST',
+ body: new FormData(form),
+ headers: { 'X-Requested-With': 'XMLHttpRequest' }
+ });
+ const data = await response.json();
+
+ if (data.success) {
+ btn.className = `status-badge ${data.status_class}`;
+ btn.textContent = data.status_label;
+ btn.title = `Click to turn ${data.is_active ? 'off' : 'on'}`;
+ } else {
+ alert(data.error || 'Failed to update status.');
+ }
+ } catch {
+ alert('Network error. Please try again.');
+ } finally {
+ btn.disabled = false;
+ }
+});
diff --git a/admin/index.php b/admin/index.php
index c884cf2..016627a 100644
--- a/admin/index.php
+++ b/admin/index.php
@@ -5,12 +5,40 @@ require_once __DIR__ . '/../inc/announcements.php';
require_auth();
$user = get_logged_in_user();
-$successMsg = '';
$errorMsg = '';
+$today = date('Y-m-d');
-// Handle Actions (Save, Delete, Toggle)
+/**
+ * Whether an announcement is actually showing right now β active flag on
+ * AND within its optional date window. Matches the currently-live check
+ * used when rendering the list below.
+ */
+function announcement_is_live(array $ann, string $today): bool
+{
+ return (bool) $ann['is_active']
+ && (empty($ann['start_date']) || $ann['start_date'] <= $today)
+ && (empty($ann['end_date']) || $ann['end_date'] >= $today);
+}
+
+// Handle Actions (Save, Delete, Toggle). Success isn't announced with a
+// banner β the list itself is the feedback (the row appears/updates/
+// disappears, the status badge flips) β same as the menu admin, and it
+// avoids a banner shoving the whole page down every time someone clicks
+// the status badge.
+//
+// The status toggle specifically is also fetched via AJAX (see index.js)
+// so clicking it doesn't reload the page, matching the menu item's status
+// toggle β everything else here (save/delete) is still a plain form POST.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $isAjax = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest';
+
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ if ($isAjax) {
+ header('Content-Type: application/json; charset=utf-8');
+ http_response_code(403);
+ echo json_encode(['success' => false, 'error' => 'Session expired or invalid security token. Please refresh.']);
+ exit;
+ }
$errorMsg = 'Session expired or invalid security token. Please try again.';
} else {
$action = $_POST['action'] ?? '';
@@ -27,19 +55,34 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'show_banner' => isset($_POST['show_banner']) ? 1 : 0,
'show_in_hours' => isset($_POST['show_in_hours']) ? 1 : 0,
], $annId);
-
- $successMsg = $annId ? 'Announcement updated successfully!' : 'New announcement created successfully!';
} elseif ($action === 'delete_announcement') {
$annId = (int)($_POST['announcement_id'] ?? 0);
if ($annId > 0) {
delete_announcement($annId);
- $successMsg = 'Announcement deleted successfully.';
}
} elseif ($action === 'toggle_active') {
$annId = (int)($_POST['announcement_id'] ?? 0);
if ($annId > 0) {
toggle_announcement_active($annId);
- $successMsg = 'Status updated.';
+ }
+
+ if ($isAjax) {
+ header('Content-Type: application/json; charset=utf-8');
+ $ann = $annId > 0 ? get_announcement_by_id($annId) : null;
+ if (!$ann) {
+ http_response_code(404);
+ echo json_encode(['success' => false, 'error' => 'Announcement not found.']);
+ exit;
+ }
+
+ $isLive = announcement_is_live($ann, $today);
+ echo json_encode([
+ 'success' => true,
+ 'is_active' => (bool) $ann['is_active'],
+ 'status_class' => $isLive ? 'active' : 'inactive',
+ 'status_label' => $isLive ? 'β Live' : ($ann['is_active'] ? 'β³ Scheduled' : 'β Off'),
+ ]);
+ exit;
}
}
}
@@ -55,7 +98,6 @@ if ($editingId) {
}
$announcements = get_all_announcements();
-$today = date('Y-m-d');
?>
@@ -80,6 +122,7 @@ $today = date('Y-m-d');
Announcements
Menu
Users
+ Hours
Logged in as = htmlspecialchars($user['username']) ?>
@@ -96,193 +139,183 @@ $today = date('Y-m-d');
Site Announcements & Alerts
-
- = htmlspecialchars($successMsg) ?>
-
= htmlspecialchars($errorMsg) ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | Status |
- Headline |
- Type |
- Schedule |
- Placements |
- Actions |
-
-
-
-
- = $today);
- ?>
-
- |
-
- β Live
-
- β³ Scheduled
-
- β Off
-
- |
-
- = htmlspecialchars($ann['headline']) ?>
-
- = htmlspecialchars($ann['message']) ?>
-
- |
-
- = htmlspecialchars($ann['type']) ?>
- |
-
-
- = htmlspecialchars($ann['start_date'] ?: 'Now') ?> β = htmlspecialchars($ann['end_date'] ?: 'Ongoing') ?>
-
- Always (while on)
-
- |
-
-
- |
-
-
- Edit
-
-
-
-
-
- |
-
-
-
-
-
-
+
+
+
+
+ π’ All Announcements (= count($announcements) ?>)
+
-
+
+
+
+
+
+
+ Status
+ Headline
+ Type
+ Schedule
+ Placements
+ Actions
+
+
+
+
+
+
+
+
+
+
= htmlspecialchars($ann['headline']) ?>
+
+ = htmlspecialchars($ann['message']) ?>
+
+
+
+ = htmlspecialchars($ann['type']) ?>
+
+
+
+ = htmlspecialchars($ann['start_date'] ?: 'Now') ?> β = htmlspecialchars($ann['end_date'] ?: 'Ongoing') ?>
+
+ Always (while on)
+
+
+
+
+
+
+
+
Edit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+