2026-04-17 16:39:13 +02:00

446 lines
13 KiB
JavaScript

const state = {
watchItems: [],
events: [],
notifications: [],
providerStatuses: [],
};
const watchItemsEl = document.querySelector("#watch-items");
const eventsEl = document.querySelector("#events");
const notificationsEl = document.querySelector("#notifications");
const watchForm = document.querySelector("#watch-form");
const eventFilter = document.querySelector("#event-filter");
const providerFilter = document.querySelector("#provider-filter");
const syncButton = document.querySelector("#sync-button");
const toastEl = document.querySelector("#toast");
const providerSummaryEl = document.querySelector("#provider-summary");
const providerStatusListEl = document.querySelector("#provider-status-list");
function showToast(message) {
toastEl.textContent = message;
toastEl.classList.add("visible");
window.clearTimeout(showToast.timeoutId);
showToast.timeoutId = window.setTimeout(() => {
toastEl.classList.remove("visible");
}, 2600);
}
function formatDate(value) {
if (!value) {
return "unbekannt";
}
const normalizedValue =
typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(value)
? `${value}Z`
: value;
const date = new Date(normalizedValue);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function apiFetch(url, options = {}) {
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...(options.headers || {}),
},
...options,
});
if (!response.ok) {
let message = "Die Anfrage ist fehlgeschlagen.";
try {
const payload = await response.json();
message = payload.detail || message;
} catch (_) {
message = response.statusText || message;
}
throw new Error(message);
}
if (response.status === 204) {
return null;
}
return response.json();
}
function renderStats() {
document.querySelector("#watch-count").textContent = state.watchItems.length;
document.querySelector("#event-count").textContent = state.events.length;
document.querySelector("#ticket-count").textContent = state.events.filter(
(event) => event.is_ticket_purchased
).length;
renderProviderSummary();
}
function prettifyProviderName(value) {
const names = {
ticketmaster: "Ticketmaster",
bandsintown: "Bandsintown",
eventim: "Eventim",
barclays_arena: "Barclays Arena",
fabrik: "Fabrik",
};
return names[value] || value;
}
function getProviderClass(value) {
const classes = {
ticketmaster: "provider-ticketmaster",
bandsintown: "provider-bandsintown",
eventim: "provider-eventim",
barclays_arena: "provider-barclays-arena",
fabrik: "provider-fabrik",
};
return classes[value] || "";
}
function renderProviderSummary() {
if (!state.events.length) {
providerSummaryEl.innerHTML = '<span class="provider-chip">Noch keine Daten</span>';
return;
}
const counts = state.events.reduce((accumulator, event) => {
const key = event.source || "unknown";
accumulator[key] = (accumulator[key] || 0) + 1;
return accumulator;
}, {});
providerSummaryEl.innerHTML = Object.entries(counts)
.sort((left, right) => right[1] - left[1])
.map(
([provider, count]) => `
<span class="provider-chip ${getProviderClass(provider)}">
${escapeHtml(prettifyProviderName(provider))}: ${count}
</span>
`
)
.join("");
}
function renderProviderStatuses() {
if (!state.providerStatuses.length) {
providerStatusListEl.className = "provider-status-list empty-state";
providerStatusListEl.textContent = "Noch keine Provider-Statusdaten vorhanden.";
return;
}
providerStatusListEl.className = "provider-status-list";
providerStatusListEl.innerHTML = state.providerStatuses
.map(
(entry) => `
<article class="provider-status-card">
<div class="notification-header">
<div>
<h3>${escapeHtml(prettifyProviderName(entry.provider_name))}</h3>
<div class="pill-row">
<span class="pill ${
entry.status === "ok"
? "success"
: entry.status === "blocked"
? "warning"
: "danger"
}">${escapeHtml(entry.status)}</span>
</div>
</div>
<span class="muted">${escapeHtml(formatDate(entry.last_checked_at))}</span>
</div>
<p>${escapeHtml(entry.message)}</p>
</article>
`
)
.join("");
}
function renderWatchItems() {
if (!state.watchItems.length) {
watchItemsEl.className = "watch-list empty-state";
watchItemsEl.textContent = "Noch keine Eintraege vorhanden.";
return;
}
watchItemsEl.className = "watch-list";
watchItemsEl.innerHTML = state.watchItems
.map(
(item) => `
<article class="watch-card">
<div class="watch-header">
<div>
<h3>${escapeHtml(item.name)}</h3>
<div class="pill-row">
<span class="pill">${item.watch_type}</span>
<span class="pill">${item.region_scope}</span>
<span class="pill ${item.is_active ? "success" : "warning"}">
${item.is_active ? "aktiv" : "pausiert"}
</span>
</div>
</div>
<div class="action-row">
<button class="action-button" data-action="toggle-watch" data-id="${item.id}">
${item.is_active ? "Pausieren" : "Aktivieren"}
</button>
<button class="action-button danger" data-action="delete-watch" data-id="${item.id}">
Loeschen
</button>
</div>
</div>
<p>${escapeHtml(item.notes || "Keine Notiz hinterlegt.")}</p>
</article>
`
)
.join("");
}
function getWatchNameById(id) {
return state.watchItems.find((item) => item.id === id)?.name || `Watch #${id}`;
}
function renderEvents() {
const filterValue = eventFilter.value;
const providerValue = providerFilter.value;
const filteredEvents = state.events.filter((event) => {
if (filterValue === "ticketed") {
if (!event.is_ticket_purchased) {
return false;
}
}
if (filterValue === "open" && event.is_ticket_purchased) {
return false;
}
if (providerValue !== "all" && event.source !== providerValue) {
return false;
}
return true;
});
if (!filteredEvents.length) {
eventsEl.className = "event-list empty-state";
eventsEl.textContent = "Noch keine Events vorhanden.";
return;
}
eventsEl.className = "event-list";
eventsEl.innerHTML = filteredEvents
.map(
(event) => `
<article class="event-card">
<div class="event-header">
<div>
<h3>${escapeHtml(event.title)}</h3>
<div class="pill-row">
<span class="pill">${escapeHtml(getWatchNameById(event.watch_item_id))}</span>
<span class="pill">${escapeHtml(event.city || "ohne Stadt")}</span>
<span class="pill ${getProviderClass(event.source)}">
${escapeHtml(prettifyProviderName(event.source))}
</span>
<span class="pill ${event.is_ticket_purchased ? "success" : "warning"}">
${event.is_ticket_purchased ? "Ticket markiert" : "ohne Ticket"}
</span>
</div>
</div>
<div class="event-actions">
<button
class="action-button ${event.is_ticket_purchased ? "" : "success"}"
data-action="toggle-ticket"
data-id="${event.id}"
data-value="${event.is_ticket_purchased ? "false" : "true"}"
>
${event.is_ticket_purchased ? "Ticket entfernen" : "Ticket gekauft"}
</button>
</div>
</div>
<div class="event-meta">
<span><strong>Datum:</strong> ${escapeHtml(formatDate(event.event_date))}</span>
<span><strong>Venue:</strong> ${escapeHtml(event.venue_name || "unbekannt")}</span>
<span><strong>Quelle:</strong> ${escapeHtml(event.source)}</span>
</div>
${
event.ticket_url
? `<p><a class="event-link" href="${escapeHtml(event.ticket_url)}" target="_blank" rel="noreferrer">Ticketlink oeffnen</a></p>`
: ""
}
</article>
`
)
.join("");
}
function renderNotifications() {
if (!state.notifications.length) {
notificationsEl.className = "notification-list empty-state";
notificationsEl.textContent = "Noch keine Benachrichtigungen protokolliert.";
return;
}
notificationsEl.className = "notification-list";
notificationsEl.innerHTML = state.notifications
.slice(0, 12)
.map(
(entry) => `
<article class="notification-card">
<div class="notification-header">
<div>
<h3>${escapeHtml(entry.notification_type)}</h3>
<div class="pill-row">
<span class="pill ${
entry.status === "sent"
? "success"
: entry.status === "failed"
? "danger"
: "warning"
}">${escapeHtml(entry.status)}</span>
</div>
</div>
<span class="muted">${escapeHtml(formatDate(entry.created_at))}</span>
</div>
<p>${escapeHtml(entry.message)}</p>
</article>
`
)
.join("");
}
function updateSyncStatus(message) {
document.querySelector("#sync-status").textContent = message;
}
async function loadData() {
const [watchItems, events, notifications, providerStatuses] = await Promise.all([
apiFetch("/watch-items"),
apiFetch("/events"),
apiFetch("/notifications"),
apiFetch("/provider-statuses"),
]);
state.watchItems = watchItems;
state.events = events;
state.notifications = notifications;
state.providerStatuses = providerStatuses;
renderStats();
renderWatchItems();
renderEvents();
renderProviderStatuses();
renderNotifications();
}
watchForm.addEventListener("submit", async (event) => {
event.preventDefault();
const payload = {
name: document.querySelector("#watch-name").value.trim(),
watch_type: document.querySelector("#watch-type").value,
region_scope: document.querySelector("#watch-region").value,
notes: document.querySelector("#watch-notes").value.trim() || null,
};
if (!payload.name) {
showToast("Bitte einen Namen eingeben.");
return;
}
try {
await apiFetch("/watch-items", {
method: "POST",
body: JSON.stringify(payload),
});
watchForm.reset();
document.querySelector("#watch-region").value = "hamburg";
document.querySelector("#watch-type").value = "artist";
await loadData();
showToast("Watchlist-Eintrag angelegt.");
} catch (error) {
showToast(error.message);
}
});
syncButton.addEventListener("click", async () => {
syncButton.disabled = true;
syncButton.textContent = "Synchronisiere...";
try {
const result = await apiFetch("/sync", { method: "POST" });
updateSyncStatus(
`${result.new_events} neue Events, ${result.updated_events} aktualisiert, ${result.notifications_sent} Benachrichtigungen versendet`
);
await loadData();
showToast("Synchronisation abgeschlossen.");
} catch (error) {
showToast(error.message);
} finally {
syncButton.disabled = false;
syncButton.textContent = "Jetzt synchronisieren";
}
});
eventFilter.addEventListener("change", renderEvents);
providerFilter.addEventListener("change", renderEvents);
document.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]");
if (!button) {
return;
}
const { action, id, value } = button.dataset;
try {
if (action === "delete-watch") {
await apiFetch(`/watch-items/${id}`, { method: "DELETE" });
await loadData();
showToast("Watchlist-Eintrag geloescht.");
return;
}
if (action === "toggle-watch") {
const watchItem = state.watchItems.find((item) => item.id === Number(id));
await apiFetch(`/watch-items/${id}`, {
method: "PATCH",
body: JSON.stringify({ is_active: !watchItem.is_active }),
});
await loadData();
showToast("Watchlist-Status aktualisiert.");
return;
}
if (action === "toggle-ticket") {
await apiFetch(`/events/${id}/purchase`, {
method: "PATCH",
body: JSON.stringify({ is_ticket_purchased: value === "true" }),
});
await loadData();
showToast("Ticketstatus aktualisiert.");
}
} catch (error) {
showToast(error.message);
}
});
loadData().catch((error) => {
updateSyncStatus("Fehler beim Laden der Daten");
showToast(error.message);
});