feat: add bulk date management functionality and update admin interface

This commit is contained in:
troogs
2026-10-01 17:16:02 +02:00
parent 3605a42099
commit 84ed5a7a28
5 changed files with 371 additions and 141 deletions
+87
View File
@@ -0,0 +1,87 @@
---
agent: agent
description: Create or improve the Date Wizard admin page for managing available date slots, including authentication, calendar bulk editing, staged saves, and booked-date protection.
---
Implement or review the complete admin page for the Date Wizard application. Preserve the existing project architecture, styling conventions, API patterns, and German user-facing language.
## Existing admin functionality
The admin page is available at `/admin` and must provide:
- A password login form.
- Session-based authentication using the existing admin login and logout endpoints.
- A clear message when the password is invalid.
- A clear message when the session has expired.
- A logout action.
- Loading and displaying all available dates from the admin API.
- Displaying each date with its configured type: `short` (Kurz) or `long` (Lang).
- Date validation that allows only today or future dates and only the two supported types.
- Responsive styling consistent with the existing Date Wizard design.
## Calendar bulk editor
Replace the need to add dates one at a time with a monthly calendar editor similar to the public booking calendar.
- Show a month calendar with Monday-first weekday headings.
- Provide previous-month and next-month navigation.
- Do not allow selecting today or dates in the past.
- Clicking an editable future date cycles through exactly three states:
1. Not selected: no available date.
2. First click: `short` / Kurz.
3. Second click: `long` / Lang.
4. Third click: back to not selected.
- Render a short date with a half-filled circle, for example a circle split into two colors.
- Render a long date with the normal full selected circle.
- Make the state visually and programmatically accessible with appropriate labels and disabled states.
- Show a legend explaining the short and long visual states.
- Keep all calendar changes in a client-side draft. Clicking a date must never save immediately.
- Include an explicit `Änderungen speichern` button.
- Save the complete draft in one authenticated bulk request only after the administrator clicks the save button.
- Refresh the visible date list from the draft after local changes and show a success or error message after saving.
- Allow removing an unbooked date from the pending draft without immediately saving it.
## Booked-date protection
Dates that have already been booked are read-only in the admin page.
- Load booked dates from the existing `/api/booked-dates` endpoint.
- A booked date must remain in the available-date configuration and must not be removable through the admin calendar.
- Render a booked future date as a green calendar circle.
- Disable the calendar button for a booked date so clicks do nothing.
- Mark the calendar entry as booked using an accessible label.
- Disable its `Entfernen` button in the date list.
- Style the disabled removal button grey with a not-allowed cursor.
- Do not let bulk-save requests remove or modify booked dates, even if a malicious or stale client submits a draft that omits them.
- Enforce this protection server-side as well as in the browser.
## Backend requirements
Use the existing Go HTTP server and settings persistence mechanisms.
- Keep the existing single-date endpoints compatible unless there is a compelling project-specific reason to change them.
- Add an authenticated bulk endpoint, such as `POST /api/admin/dates/bulk`.
- Accept a JSON array of objects shaped like `{ "date": "YYYY-MM-DD", "type": "short" | "long" }`.
- Reject malformed dates, unsupported types, duplicate dates, past dates, and today.
- Validate the complete request before changing the settings file.
- Save the complete future-date selection atomically under the existing settings mutex.
- Preserve historical dates according to the existing application behavior.
- Preserve all booked dates regardless of whether they are included in the submitted draft.
- Do not create duplicate available-date entries.
- Return useful JSON success and error responses and use appropriate HTTP status codes.
## Implementation and validation
Before editing, inspect the existing admin HTML, JavaScript, CSS, Go handlers, settings model, public booking calendar, and booked-date storage/API. Reuse existing helpers and visual patterns where practical.
After editing:
1. Run the project's formatter.
2. Run the Go test/build command available in the repository.
3. Run a JavaScript syntax check for the admin script when Node.js is available.
4. Check diagnostics and whitespace errors.
5. Verify that the page renders the login view when unauthenticated.
6. Verify that the calendar, save button, staged state changes, booked green state, and disabled grey removal button are present after authentication.
7. Do not modify booked-date fixtures or unrelated application behavior.
When reporting the result, summarize the files changed, the user-visible behavior, the API changes, and the validation commands that passed or failed.
+56
View File
@@ -82,6 +82,7 @@ func main() {
http.HandleFunc("/api/admin/login", handleAdminLogin)
http.HandleFunc("/api/admin/logout", handleAdminLogout)
http.HandleFunc("/api/admin/dates", handleAdminDates)
http.HandleFunc("/api/admin/dates/bulk", handleAdminBulkDates)
http.HandleFunc("/admin", handleAdminPage)
// Serve static files from webDir directory
@@ -281,6 +282,61 @@ func handleAdminDates(w http.ResponseWriter, r *http.Request) {
}
}
func handleAdminBulkDates(w http.ResponseWriter, r *http.Request) {
if !isAdminRequest(r) {
writeJSON(w, http.StatusUnauthorized, Response{Success: false, Message: "Anmeldung erforderlich."})
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var requestedDates []AvailableDate
if err := json.NewDecoder(r.Body).Decode(&requestedDates); err != nil {
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Ungültige Terminauswahl."})
return
}
seen := make(map[string]bool, len(requestedDates))
for _, date := range requestedDates {
if !isValidAvailableDate(date) || !isTodayOrFuture(date.Date) || seen[date.Date] {
writeJSON(w, http.StatusBadRequest, Response{Success: false, Message: "Bitte wähle gültige, eindeutige Daten ab heute."})
return
}
seen[date.Date] = true
}
bookedDates, err := getBookedDates()
if err != nil {
log.Printf("Error reading booked dates: %v", err)
http.Error(w, "Booked dates could not be loaded", http.StatusInternalServerError)
return
}
bookedDateSet := make(map[string]bool, len(bookedDates))
for _, date := range bookedDates {
bookedDateSet[date] = true
}
if err := updateAvailableDates(func(dates []AvailableDate) ([]AvailableDate, error) {
preserved := make([]AvailableDate, 0, len(dates)+len(requestedDates))
for _, date := range dates {
if !isTodayOrFuture(date.Date) || bookedDateSet[date.Date] {
preserved = append(preserved, date)
}
}
for _, date := range requestedDates {
if !bookedDateSet[date.Date] {
preserved = append(preserved, date)
}
}
return preserved, nil
}); err != nil {
log.Printf("Error saving available dates: %v", err)
http.Error(w, "Dates could not be saved", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, Response{Success: true, Message: "Änderungen gespeichert."})
}
func getSettingsFilePath() string {
path := os.Getenv("SETTINGS_FILE")
if path == "" {
+90
View File
@@ -225,11 +225,101 @@
background: #fff0f2;
}
.delete-date-btn.is-booked,
.delete-date-btn.is-booked:hover,
.delete-date-btn.is-booked:focus-visible {
border-color: #bdb7b2;
background: #e3e0dd;
color: #88827d;
cursor: not-allowed;
}
.empty-dates {
margin: 18px 0 0;
color: var(--color-text-light);
}
.admin-calendar {
margin-top: 20px;
padding: 16px;
background: #fffefa;
border: 1px solid #eaded1;
border-radius: 8px;
}
.calendar-header,
.weekday-row,
.day-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.calendar-header {
grid-template-columns: 40px 1fr 40px;
align-items: center;
margin-bottom: 12px;
}
.month-label {
margin: 0;
color: var(--color-rose-dark);
font-size: 1.05rem;
text-align: center;
}
.month-nav-btn {
width: 40px;
height: 40px;
border: 0;
border-radius: 50%;
background: var(--color-rose-light);
color: var(--color-rose-dark);
font-size: 1.2rem;
cursor: pointer;
}
.month-nav-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.weekday-row { margin-bottom: 6px; }
.weekday-row span { text-align: center; color: var(--color-text-light); font-size: 0.78rem; font-weight: 700; }
.day-cell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--color-text);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.day-cell.is-empty { pointer-events: none; }
.day-cell.is-disabled { color: #d9c3cb; cursor: not-allowed; }
.day-cell.is-today { box-shadow: inset 0 0 0 2px var(--color-rose); }
.day-cell.is-short { background: linear-gradient(90deg, var(--color-rose) 0 50%, var(--color-rose-light) 50%); color: var(--color-white); }
.day-cell.is-long { background: linear-gradient(135deg, var(--color-rose) 0%, var(--color-rose-dark) 100%); color: var(--color-white); }
.day-cell.is-booked { background: #4b9b68; color: var(--color-white); cursor: not-allowed; }
.calendar-legend {
display: flex;
gap: 18px;
margin: 12px 0 16px;
color: var(--color-text-light);
font-size: 0.85rem;
}
.calendar-legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend-dot { width: 16px; height: 16px; border-radius: 50%; }
.legend-dot.is-short { background: linear-gradient(90deg, var(--color-rose) 0 50%, var(--color-rose-light) 50%); }
.legend-dot.is-long { background: var(--color-rose-dark); }
.date-type-label { color: var(--color-text-light); font-weight: 700; }
.hidden {
display: none !important;
}
+16 -18
View File
@@ -35,25 +35,23 @@
<section class="admin-section add-date-section" aria-labelledby="addDateTitle">
<div class="section-heading">
<h2 id="addDateTitle">Neuen Tag hinzufügen</h2>
<p>Datum im Kalender wählen und Zeitumfang festlegen.</p>
<h2 id="addDateTitle">Verfügbare Tage planen</h2>
<p>Einmal klicken für kurz, zweimal für lang und ein drittes Mal zum Entfernen.</p>
</div>
<form id="addDateForm" class="date-form">
<label for="dateInput">Datum</label>
<input id="dateInput" name="date" type="date" required />
<fieldset class="type-selector">
<legend>Tagestyp</legend>
<label>
<input type="radio" name="dateType" value="short" checked />
<span>Kurz</span>
</label>
<label>
<input type="radio" name="dateType" value="long" />
<span>Lang</span>
</label>
</fieldset>
<button class="btn btn-primary" type="submit">Datum hinzufügen</button>
</form>
<div class="admin-calendar" aria-label="Kalender zur Terminplanung">
<div class="calendar-header">
<button id="prevMonthBtn" class="month-nav-btn" type="button" aria-label="Vorheriger Monat">‹</button>
<h3 id="monthLabel" class="month-label">Monat Jahr</h3>
<button id="nextMonthBtn" class="month-nav-btn" type="button" aria-label="Nächster Monat">›</button>
</div>
<div class="weekday-row" id="weekdayRow"></div>
<div class="day-grid" id="dayGrid"></div>
</div>
<div class="calendar-legend" aria-label="Legende">
<span><i class="legend-dot is-short"></i>Kurz</span>
<span><i class="legend-dot is-long"></i>Lang</span>
</div>
<button id="saveDatesBtn" class="btn btn-primary" type="button">Änderungen speichern</button>
<p id="addDateMessage" class="form-message" role="status" aria-live="polite"></p>
</section>
+122 -123
View File
@@ -4,46 +4,42 @@
const loginForm = document.getElementById("loginForm");
const passwordInput = document.getElementById("passwordInput");
const loginMessage = document.getElementById("loginMessage");
const addDateForm = document.getElementById("addDateForm");
const dateInput = document.getElementById("dateInput");
const addDateMessage = document.getElementById("addDateMessage");
const datesList = document.getElementById("datesList");
const dateCount = document.getElementById("dateCount");
const emptyDates = document.getElementById("emptyDates");
const logoutBtn = document.getElementById("logoutBtn");
const monthLabel = document.getElementById("monthLabel");
const weekdayRow = document.getElementById("weekdayRow");
const dayGrid = document.getElementById("dayGrid");
const prevMonthBtn = document.getElementById("prevMonthBtn");
const nextMonthBtn = document.getElementById("nextMonthBtn");
const saveDatesBtn = document.getElementById("saveDatesBtn");
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
const today = new Date();
today.setHours(0, 0, 0, 0);
let dates = [];
let bookedDates = new Set();
let draftDates = new Map();
let viewYear = today.getFullYear();
let viewMonth = today.getMonth();
function setMessage(element, message, success) {
element.textContent = message;
element.classList.toggle("is-success", Boolean(success));
}
function todayKey() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
function toDateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function formatDate(date) {
return new Date(`${date}T00:00:00`).toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
return new Date(`${date}T00:00:00`).toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", year: "numeric" });
}
async function request(path, options) {
const response = await fetch(path, {
...options,
headers: {
"Content-Type": "application/json",
...(options && options.headers),
},
});
const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...(options && options.headers) } });
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.message || "Die Anfrage ist fehlgeschlagen.");
@@ -56,101 +52,111 @@
function showLogin(message) {
adminView.classList.add("hidden");
loginView.classList.remove("hidden");
if (message) {
setMessage(loginMessage, message, false);
}
if (message) setMessage(loginMessage, message, false);
passwordInput.focus();
}
function renderDates() {
const plannedDates = Array.from(draftDates, ([date, type]) => ({ date, type })).sort((a, b) => a.date.localeCompare(b.date));
datesList.innerHTML = "";
dateCount.textContent = `${dates.length} ${dates.length === 1 ? "Termin" : "Termine"}`;
emptyDates.classList.toggle("hidden", dates.length !== 0);
dates.forEach((date) => {
dateCount.textContent = `${plannedDates.length} ${plannedDates.length === 1 ? "Termin" : "Termine"}`;
emptyDates.classList.toggle("hidden", plannedDates.length !== 0);
plannedDates.forEach((date) => {
const row = document.createElement("li");
row.className = "date-row";
const label = document.createElement("span");
label.className = "date-label";
label.textContent = formatDate(date.date);
const typeSelect = document.createElement("select");
typeSelect.className = "date-type-select";
typeSelect.setAttribute("aria-label", `Tagestyp für ${formatDate(date.date)}`);
[["short", "Kurz"], ["long", "Lang"]].forEach(([value, text]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = text;
option.selected = date.type === value;
typeSelect.appendChild(option);
});
typeSelect.addEventListener("change", async () => {
typeSelect.disabled = true;
try {
await request("/api/admin/dates", {
method: "PUT",
body: JSON.stringify({ date: date.date, type: typeSelect.value }),
});
date.type = typeSelect.value;
setMessage(addDateMessage, "Tagestyp aktualisiert.", true);
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
typeSelect.value = date.type;
setMessage(addDateMessage, error.message, false);
} finally {
typeSelect.disabled = false;
}
});
const typeLabel = document.createElement("span");
typeLabel.className = "date-type-label";
typeLabel.textContent = date.type === "short" ? "Kurz" : "Lang";
const deleteButton = document.createElement("button");
deleteButton.className = "delete-date-btn";
deleteButton.type = "button";
deleteButton.textContent = "Löschen";
deleteButton.addEventListener("click", async () => {
if (!window.confirm(`${formatDate(date.date)} wirklich löschen?`)) {
return;
}
deleteButton.textContent = "Entfernen";
if (bookedDates.has(date.date)) {
deleteButton.disabled = true;
try {
await request("/api/admin/dates", {
method: "DELETE",
body: JSON.stringify({ date: date.date }),
});
dates = dates.filter((item) => item.date !== date.date);
deleteButton.classList.add("is-booked");
deleteButton.title = "Gebuchte Termine können nicht entfernt werden.";
} else {
deleteButton.addEventListener("click", () => {
draftDates.delete(date.date);
renderCalendar();
renderDates();
setMessage(addDateMessage, "Datum gelöscht.", true);
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
setMessage(addDateMessage, error.message, false);
deleteButton.disabled = false;
}
});
row.append(label, typeSelect, deleteButton);
setMessage(addDateMessage, "Änderung vorgemerkt. Noch nicht gespeichert.", false);
});
}
row.append(label, typeLabel, deleteButton);
datesList.appendChild(row);
});
}
function renderCalendar() {
monthLabel.textContent = `${months[viewMonth]} ${viewYear}`;
weekdayRow.innerHTML = "";
weekdays.forEach((day) => {
const span = document.createElement("span");
span.textContent = day;
weekdayRow.appendChild(span);
});
dayGrid.innerHTML = "";
const firstOfMonth = new Date(viewYear, viewMonth, 1);
const leadingBlanks = (firstOfMonth.getDay() + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
for (let index = 0; index < leadingBlanks; index += 1) {
const blank = document.createElement("span");
blank.className = "day-cell is-empty";
dayGrid.appendChild(blank);
}
for (let day = 1; day <= daysInMonth; day += 1) {
const cellDate = new Date(viewYear, viewMonth, day);
const key = toDateKey(cellDate);
const type = draftDates.get(key);
const isBooked = bookedDates.has(key);
const button = document.createElement("button");
button.type = "button";
button.className = "day-cell";
button.textContent = String(day);
if (cellDate <= today) {
button.disabled = true;
button.classList.add("is-disabled");
} else if (isBooked) {
button.disabled = true;
button.classList.add("is-booked");
} else if (type) {
button.classList.add(type === "short" ? "is-short" : "is-long");
}
if (cellDate.getTime() === today.getTime()) button.classList.add("is-today");
button.setAttribute("aria-label", `${formatDate(key)}${isBooked ? ", gebucht" : type ? `, ${type === "short" ? "kurz" : "lang"}` : ", nicht verfügbar"}`);
if (!isBooked && cellDate > today) button.addEventListener("click", () => {
const nextType = type === undefined ? "short" : type === "short" ? "long" : undefined;
if (nextType) draftDates.set(key, nextType); else draftDates.delete(key);
renderCalendar();
renderDates();
setMessage(addDateMessage, "Änderung vorgemerkt. Noch nicht gespeichert.", false);
});
dayGrid.appendChild(button);
}
prevMonthBtn.disabled = viewYear === today.getFullYear() && viewMonth === today.getMonth();
}
async function loadDates() {
try {
dates = await request("/api/admin/dates", { method: "GET" });
dates.sort((first, second) => first.date.localeCompare(second.date));
const [loadedDates, loadedBookedDates] = await Promise.all([
request("/api/admin/dates", { method: "GET" }),
request(`/api/booked-dates?t=${Date.now()}`, { method: "GET" }),
]);
dates = loadedDates;
bookedDates = new Set((loadedBookedDates || []).filter((date) => typeof date === "string"));
dates.sort((a, b) => a.date.localeCompare(b.date));
draftDates = new Map(dates.map((date) => [date.date, date.type]));
loginView.classList.add("hidden");
adminView.classList.remove("hidden");
renderCalendar();
renderDates();
} catch (error) {
if (error.status === 401) {
showLogin();
return;
}
showLogin("Die Termine konnten nicht geladen werden.");
if (error.status === 401) showLogin();
else showLogin("Die Termine konnten nicht geladen werden.");
}
}
@@ -158,10 +164,7 @@
event.preventDefault();
setMessage(loginMessage, "", false);
try {
await request("/api/admin/login", {
method: "POST",
body: JSON.stringify({ password: passwordInput.value }),
});
await request("/api/admin/login", { method: "POST", body: JSON.stringify({ password: passwordInput.value }) });
passwordInput.value = "";
await loadDates();
} catch (error) {
@@ -169,38 +172,34 @@
}
});
addDateForm.addEventListener("submit", async (event) => {
event.preventDefault();
const type = addDateForm.elements.dateType.value;
setMessage(addDateMessage, "", false);
saveDatesBtn.addEventListener("click", async () => {
saveDatesBtn.disabled = true;
try {
await request("/api/admin/dates", {
method: "POST",
body: JSON.stringify({ date: dateInput.value, type }),
});
dates.push({ date: dateInput.value, type });
dates.sort((first, second) => first.date.localeCompare(second.date));
await request("/api/admin/dates/bulk", { method: "POST", body: JSON.stringify(Array.from(draftDates, ([date, type]) => ({ date, type }))) });
dates = Array.from(draftDates, ([date, type]) => ({ date, type }));
renderDates();
setMessage(addDateMessage, "Datum hinzugefügt.", true);
addDateForm.reset();
dateInput.min = todayKey();
setMessage(addDateMessage, "Änderungen gespeichert.", true);
} catch (error) {
if (error.status === 401) {
showLogin("Deine Anmeldung ist abgelaufen.");
return;
}
setMessage(addDateMessage, error.message, false);
}
});
logoutBtn.addEventListener("click", async () => {
try {
await request("/api/admin/logout", { method: "POST" });
if (error.status === 401) showLogin("Deine Anmeldung ist abgelaufen.");
else setMessage(addDateMessage, error.message, false);
} finally {
showLogin();
saveDatesBtn.disabled = false;
}
});
dateInput.min = todayKey();
prevMonthBtn.addEventListener("click", () => {
viewMonth -= 1;
if (viewMonth < 0) { viewMonth = 11; viewYear -= 1; }
renderCalendar();
});
nextMonthBtn.addEventListener("click", () => {
viewMonth += 1;
if (viewMonth > 11) { viewMonth = 0; viewYear += 1; }
renderCalendar();
});
logoutBtn.addEventListener("click", async () => {
try { await request("/api/admin/logout", { method: "POST" }); } finally { showLogin(); }
});
loadDates();
})();
})();