const API_BASE = "/api"; function getToken(){ return localStorage.getItem("token"); } function setToken(token){ localStorage.setItem("token", token); } function clearToken(){ localStorage.removeItem("token"); } async function apiFetch(path, options = {}){ let headers = options.headers ? { ...options.headers } : {}; headers["Content-Type"] = "application/json"; let token = getToken(); if(token){ headers["Authorization"] = "Bearer " + token; } let response = await fetch(API_BASE + path, { ...options, headers }); if(response.status === 401){ clearToken(); showLogin(); throw new Error("not authenticated"); } let data = await response.json().catch(() => null); if(!response.ok){ let message = (data && data.detail) ? data.detail : "request failed"; throw new Error(message); } return data; } // Small helper: several endpoints (join-room, search-room, create event, // delete room) return HTTP 200 with a { status: "failed" | "denied" | ... } // body instead of a real error status. apiFetch only throws on non-2xx, // so callers that care about these soft failures check result.status // themselves — see handleJoinRoom, handleSearchRoom, handleCreateEvent, // handleDeleteRoom below. // ---------- date helpers ---------- // datetime-local inputs give "YYYY-MM-DDTHH:MM"; the backend stores // "YYYY-MM-DD HH:MM" (see the create_event() example in events.py). function toBackendDateTime(localValue){ return localValue ? localValue.replace("T", " ") : localValue; } function toDatetimeLocal(value){ if(!value) return ""; let d = new Date(value); if(isNaN(d.getTime())) return ""; let pad = n => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } // ---------- auth ---------- function showLogin(){ document.getElementById("app").style.display = "none"; document.getElementById("login-screen").style.display = "flex"; } function showApp(){ document.getElementById("login-screen").style.display = "none"; document.getElementById("app").style.display = "grid"; } async function handleLogin(event){ event.preventDefault(); let username = document.getElementById("login-username").value; let password = document.getElementById("login-password").value; let errorBox = document.getElementById("login-error"); errorBox.textContent = ""; try{ let result = await apiFetch("/login", { method: "POST", body: JSON.stringify({ username, password }) }); if(result.status !== "success"){ errorBox.textContent = "invalid username or password"; return; } setToken(result.access_token); showApp(); showPage("dashboard"); } catch(error){ errorBox.textContent = error.message; } } async function handleRegister(event){ event.preventDefault(); let username = document.getElementById("login-username").value; let password = document.getElementById("login-password").value; let errorBox = document.getElementById("login-error"); errorBox.textContent = ""; try{ await apiFetch("/create-user", { method: "POST", body: JSON.stringify({ username, password }) }); errorBox.style.color = "green"; errorBox.textContent = "account created, you can log in now"; } catch(error){ errorBox.style.color = ""; errorBox.textContent = error.message; } } function logout(){ clearToken(); showLogin(); } // ---------- page router ---------- function showPage(page){ let content = document.getElementById("content"); document.querySelectorAll("nav a").forEach(link => link.classList.remove("active")); let activeLink = document.querySelector(`nav a[data-page="${page}"]`); if(activeLink){ activeLink.classList.add("active"); } if(page === "dashboard") loadDashboard(); if(page === "rooms") loadRooms(); if(page === "events") loadEvents(); if(page === "settings"){ renderSettingsPage(content); } } // ---------- dashboard ---------- let calendarWeekOffset = 0; let cachedDashboardEvents = []; let cachedRoomsForForm = []; let currentUserId = null; async function loadDashboard(){ let content = document.getElementById("content"); content.innerHTML = `

Dashboard

Loading...
`; calendarWeekOffset = 0; try{ let me = await apiFetch("/me"); currentUserId = me.id; let roomsData = await apiFetch("/rooms"); let rooms = roomsData.rooms || []; cachedRoomsForForm = rooms; let eventsData = await apiFetch("/events"); cachedDashboardEvents = eventsData.events || []; let roomsHtml = rooms.length ? rooms.map(room => `
${room.name}
`).join("") : `

You have no rooms yet.

`; content.innerHTML = `

Dashboard

Welcome back, ${me.username}

Overview of your calendar.

${buildWeekCalendarHtml(cachedDashboardEvents, calendarWeekOffset)}

Your rooms

${roomsHtml}

Create a room

Join a room

`; } catch(error){ content.innerHTML = `

Dashboard

Could not load dashboard: ${error.message}
`; } } async function handleCreateRoom(event){ event.preventDefault(); let roomName = document.getElementById("create-room-name").value; let inviteCode = document.getElementById("create-room-code").value; let messageBox = document.getElementById("create-room-message"); try{ await apiFetch("/create-room", { method: "POST", body: JSON.stringify({ room_name: roomName, invite_code: inviteCode }) }); messageBox.textContent = "room created"; loadDashboard(); } catch(error){ messageBox.textContent = error.message; } } async function handleJoinRoom(event){ event.preventDefault(); let inviteCode = document.getElementById("join-room-code").value; let messageBox = document.getElementById("join-room-message"); try{ let result = await apiFetch("/join-room", { method: "POST", body: JSON.stringify({ invite_code: inviteCode }) }); // /join-room returns 200 with status:"failed" for both an // unknown code AND an already-joined room - it can't be told apart // client-side, so the message stays deliberately generic. if(result.status === "failed"){ messageBox.textContent = "invalid invite code, or you're already in that room"; return; } messageBox.textContent = "joined room"; loadDashboard(); } catch(error){ messageBox.textContent = error.message; } } // ---------- calendar (week view) ---------- const CALENDAR_SLOT_HOURS = 2; const CALENDAR_SLOT_HEIGHT = 48; const CALENDAR_START_HOUR = 0; const CALENDAR_END_HOUR = 24; const PIXELS_PER_MINUTE = CALENDAR_SLOT_HEIGHT / (CALENDAR_SLOT_HOURS * 60); function isEventCancelled(event){ let status = (event.status || "").toLowerCase(); return status === "cancelled" || status === "canceled" || status === "declined"; } function getEventColorClass(event, currentUserId){ let isOwnEvent = event.creator_id === currentUserId; let isPrivateOther = event.visibility === "private" && !isOwnEvent; if(isPrivateOther) return "event-private"; let statusLower = (event.status || "").toLowerCase(); if(statusLower === "confirmed") return "event-confirmed"; if(statusLower === "busy") return "event-busy"; // pending proposals fall back to the same tentative color as busy return "event-busy"; } function getWeekStart(offset){ let today = new Date(); let base = new Date(today.getFullYear(), today.getMonth(), today.getDate() + (offset * 7)); let dayOfWeek = base.getDay(); let mondayDiff = (dayOfWeek === 0) ? -6 : (1 - dayOfWeek); base.setDate(base.getDate() + mondayDiff); return base; } function changeCalendarWeek(delta){ calendarWeekOffset += delta; let calendarCard = document.getElementById("calendar-card"); if(calendarCard){ calendarCard.innerHTML = buildWeekCalendarHtml(cachedDashboardEvents, calendarWeekOffset); } } function buildWeekCalendarHtml(events, offset){ let weekStart = getWeekStart(offset); let today = new Date(); let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; let days = []; for(let i = 0; i < 7; i++){ let d = new Date(weekStart); d.setDate(weekStart.getDate() + i); days.push(d); } let rangeLabel = days[0].toLocaleDateString("default", { day: "numeric", month: "short" }) + " - " + days[6].toLocaleDateString("default", { day: "numeric", month: "short", year: "numeric" }); let totalMinutes = (CALENDAR_END_HOUR - CALENDAR_START_HOUR) * 60; let trackHeight = ((CALENDAR_END_HOUR - CALENDAR_START_HOUR) / CALENDAR_SLOT_HOURS) * CALENDAR_SLOT_HEIGHT; let hourRows = ""; for(let h = CALENDAR_START_HOUR; h < CALENDAR_END_HOUR; h += CALENDAR_SLOT_HOURS){ hourRows += `
${String(h).padStart(2, "0")}:00
`; } let dayColumns = days.map(day => { let dayEvents = events.filter(event => { if(!event.start_time || isEventCancelled(event)) return false; let eventStart = new Date(event.start_time); return ( eventStart.getFullYear() === day.getFullYear() && eventStart.getMonth() === day.getMonth() && eventStart.getDate() === day.getDate() ); }).sort((a, b) => new Date(a.start_time) - new Date(b.start_time)); let layerEnds = []; let placed = dayEvents.map(event => { function parseLocalDate(dateString){ let parts = dateString.split(/[- :]/); return new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]); } let start = parseLocalDate(event.start_time); let end = event.end_time ? parseLocalDate(event.end_time) : new Date(start.getTime() + 30 * 60000); let startMinutes = start.getHours() * 60 + start.getMinutes(); let endMinutes = Math.max(startMinutes + 15, end.getHours() * 60 + end.getMinutes()); let layer = 0; while(layerEnds[layer] !== undefined && layerEnds[layer] > startMinutes){ layer++; } layerEnds[layer] = endMinutes; return { event, startMinutes, endMinutes, layer }; }); let maxLayer = placed.reduce((m, p) => Math.max(m, p.layer), 0); let blocksHtml = placed.map(p => { let topPx = (p.startMinutes - CALENDAR_START_HOUR * 60) * PIXELS_PER_MINUTE; let heightPx = (p.endMinutes - p.startMinutes) * PIXELS_PER_MINUTE; let leftPct = p.layer * 14; let widthPct = 100 - (maxLayer * 14); let colorClass = getEventColorClass(p.event, currentUserId); let timeLabel = new Date(p.event.start_time).toLocaleTimeString("nl-NL", { hour: "2-digit", minute: "2-digit", hour12: false }); return `
${timeLabel} ${p.event.title}
`; }).join(""); let isToday = day.getFullYear() === today.getFullYear() && day.getMonth() === today.getMonth() && day.getDate() === today.getDate(); return `
${dayNames[days.indexOf(day)]}
${day.getDate()}
${blocksHtml}
`; }).join(""); return `

${rangeLabel}

confirmed busy / pending private (others)
${hourRows}
${dayColumns}
`; } // ---------- create / propose event ---------- function openCreateEventModal(prefill = {}){ if(cachedRoomsForForm.length === 0){ alert("You need to be in a room before you can create an event."); return; } let kindValue = prefill.kind || "proposal"; let roomOptions = cachedRoomsForForm.map(room => ` `).join(""); openModal(`

New event

`); onEventTypeChange(); } function onEventTypeChange(){ let kind = document.getElementById("event-type").value; let wrap = document.getElementById("event-min-people-wrap"); wrap.style.display = (kind === "proposal") ? "block" : "none"; } async function handleCreateEvent(event){ event.preventDefault(); let kind = document.getElementById("event-type").value; let roomId = parseInt(document.getElementById("event-room").value, 10); let title = document.getElementById("event-title").value; let description = document.getElementById("event-description").value; let startLocal = document.getElementById("event-start").value; let endLocal = document.getElementById("event-end").value; let messageBox = document.getElementById("create-event-message"); let payload = { room_id: roomId, title, description, start_time: toBackendDateTime(startLocal), end_time: toBackendDateTime(endLocal) }; // Field values follow the convention from the create_event() example // in events.py: type="proposal", visibility="room", status="proposed". // "busy" / "private" extend that same pattern for the two other kinds. if(kind === "proposal"){ payload.type = "proposal"; payload.status = "proposed"; payload.visibility = "room"; payload.min_people = parseInt(document.getElementById("event-min-people").value, 10) || 1; } else if(kind === "busy"){ payload.type = "busy"; payload.status = "busy"; payload.visibility = "room"; payload.min_people = 0; } else { payload.type = "private"; payload.status = "confirmed"; payload.visibility = "private"; payload.min_people = 0; } try{ let result = await apiFetch("/events", { method: "POST", body: JSON.stringify(payload) }); // POST /events returns 200 with status:"failed" (not an exception) // when the room membership check fails. if(result.status === "failed"){ messageBox.textContent = result.reason === "not_room_member" ? "you're not a member of that room" : (result.reason || "could not create event"); return; } closeModal(); let activeLink = document.querySelector("nav a.active"); showPage(activeLink ? activeLink.dataset.page : "dashboard"); } catch(error){ messageBox.textContent = error.message; } } // ---------- modal infrastructure ---------- function ensureModalRoot(){ let modalRoot = document.getElementById("modal-root"); if(!modalRoot){ modalRoot = document.createElement("div"); modalRoot.id = "modal-root"; document.body.appendChild(modalRoot); } return modalRoot; } function openModal(innerHtml){ let modalRoot = ensureModalRoot(); modalRoot.innerHTML = ` `; document.addEventListener("keydown", handleModalEscape); } function closeModal(){ let modalRoot = document.getElementById("modal-root"); if(modalRoot) modalRoot.innerHTML = ""; document.removeEventListener("keydown", handleModalEscape); } function handleModalEscape(event){ if(event.key === "Escape") closeModal(); } // ---------- rooms page ---------- async function loadRooms(){ let content = document.getElementById("content"); content.innerHTML = `

Rooms

Loading...
`; try{ let roomsData = await apiFetch("/rooms"); let rooms = roomsData.rooms || []; cachedRoomsForForm = rooms; let actionsHtml = `

Find a room

`; if(rooms.length === 0){ content.innerHTML = `

Rooms

${actionsHtml}
Your shared rooms appear here.
`; return; } let roomsHtml = rooms.map(room => `

${room.name}

invite code: ${room.invite_code || "-"}

`).join(""); content.innerHTML = `

Rooms

${actionsHtml} ${roomsHtml} `; } catch(error){ content.innerHTML = `

Rooms

Could not load rooms: ${error.message}
`; } } async function handleSearchRoom(event){ event.preventDefault(); let code = document.getElementById("search-room-code").value; let resultBox = document.getElementById("search-room-result"); resultBox.innerHTML = `

Searching...

`; try{ let result = await apiFetch("/search-room", { method: "POST", body: JSON.stringify({ invite_code: code }) }); if(result.status === "failed"){ resultBox.innerHTML = `

No room found for that code.

`; return; } resultBox.innerHTML = `
${result.room_name}
`; } catch(error){ resultBox.innerHTML = `

${error.message}

`; } } async function handleJoinRoomByCode(code){ try{ let result = await apiFetch("/join-room", { method: "POST", body: JSON.stringify({ invite_code: code }) }); if(result.status === "failed"){ alert("Could not join: invalid code, or you're already a member."); return; } showPage("rooms"); } catch(error){ alert(error.message); } } async function loadRoomEvents(roomId){ let content = document.getElementById("content"); content.innerHTML = `

Room events

Loading...
`; try{ let data = await apiFetch(`/rooms/${roomId}/events`); let events = data.events || []; content.innerHTML = `

Room events

${renderEventCards(events)} `; } catch(error){ content.innerHTML = `

Room events

Could not load events: ${error.message}
`; } } // ---------- room detail (manage) ---------- async function loadRoomDetail(roomId){ let content = document.getElementById("content"); content.innerHTML = `

Room

Loading...
`; try{ let data = await apiFetch(`/rooms/${roomId}`); let room = data.room; let members = data.members || []; let events = data.events || []; let ownerUsername = `user #${room.owner_id}`; try{ let owner = await apiFetch(`/users/${room.owner_id}`); ownerUsername = owner.username; } catch(error){ // keep the fallback label } content.innerHTML = `

${room.name}

Owner: ${ownerUsername}

Your role: ${room.role}

Members

${renderMembersList(members)}

Leave or delete

`; } catch(error){ content.innerHTML = `

Room

Could not load room: ${error.message}
`; } } function renderMembersList(members){ if(!members.length){ return `

No members found.

`; } return members.map(member => `
${member.username}${member.role ? ` (${member.role})` : ""}
`).join(""); } async function refreshRoomMembers(roomId){ let listBox = document.getElementById("room-members-list"); if(!listBox) return; listBox.innerHTML = `

Refreshing...

`; try{ let data = await apiFetch(`/rooms/${roomId}/members`); listBox.innerHTML = renderMembersList(data.members || []); } catch(error){ listBox.innerHTML = `

Could not refresh members: ${error.message}

`; } } async function handleLeaveRoom(roomId){ if(!confirm("Leave this room?")) return; try{ await apiFetch(`/rooms/${roomId}/members/me`, { method: "DELETE" }); showPage("rooms"); } catch(error){ alert("Could not leave room: " + error.message); } } async function handleDeleteRoom(roomId){ if(!confirm("Delete this room? This cannot be undone.")) return; try{ let result = await apiFetch(`/room/${roomId}`, { method: "DELETE" }); if(result.status === "denied"){ alert("You don't have permission to delete this room."); return; } if(result.status !== "deleted"){ // the endpoint currently always returns this until it's implemented alert("Room was not deleted: " + (result.reason || "unknown reason")); return; } showPage("rooms"); } catch(error){ alert("Could not delete room: " + error.message); } } // ---------- events page ---------- async function loadEvents(){ let content = document.getElementById("content"); content.innerHTML = `

Events

Loading...
`; try{ let data = await apiFetch("/events"); let events = data.events || []; content.innerHTML = `

Events

${renderEventCards(events)} `; } catch(error){ content.innerHTML = `

Events

Could not load events: ${error.message}
`; } } function renderEventCards(events){ if(events.length === 0){ return `
No events scheduled.
`; } return events.map(event => `

${event.title}

${event.description || ""}

${formatEventTime(event.start_time, event.end_time)}

status: ${event.status}

`).join(""); } function formatEventTime(start, end){ if(!start) return "time unknown"; let startDate = new Date(start); let endDate = end ? new Date(end) : null; let startText = startDate.toLocaleString(); if(!endDate) return startText; return startText + " - " + endDate.toLocaleString(); } async function handleRespond(eventId, status){ let messageBox = document.getElementById(`event-response-${eventId}`); try{ let result = await apiFetch(`/events/${eventId}/respond?status=${status}`, { method: "POST" }); messageBox.textContent = `you responded: ${status} (${result.going} going)`; } catch(error){ messageBox.textContent = error.message; } } // ---------- event detail / edit / delete ---------- async function ensureCurrentUser(){ if(currentUserId !== null) return currentUserId; try{ let me = await apiFetch("/me"); currentUserId = me.id; } catch(error){ // isOwn checks just stay false } return currentUserId; } async function openEventDetailModal(eventId){ openModal(`

Loading...

`); try{ await ensureCurrentUser(); let eventDetail = await apiFetch(`/events/${eventId}`); let isOwn = eventDetail.creator_id === currentUserId; openModal(`

${eventDetail.title}

${formatEventTime(eventDetail.start_time, eventDetail.end_time)}

${eventDetail.description || ""}

status: ${eventDetail.status}

Responses

Loading responses...

`); try{ let responsesData = await apiFetch(`/events/${eventId}/responses`); let responses = responsesData.responses || []; let listBox = document.getElementById("event-responses-list"); if(listBox){ listBox.innerHTML = responses.length ? responses.map(r => `
${r.username} — ${r.status}
`).join("") : `

No responses yet.

`; } } catch(error){ let listBox = document.getElementById("event-responses-list"); if(listBox) listBox.innerHTML = `

Could not load responses: ${error.message}

`; } } catch(error){ openModal(`

Could not load event: ${error.message}

`); } } async function openEditEventModal(eventId){ try{ let eventDetail = await apiFetch(`/events/${eventId}`); openModal(`

Edit event

`); } catch(error){ alert("Could not load event: " + error.message); } } async function handleUpdateEvent(event, eventId){ event.preventDefault(); let messageBox = document.getElementById("edit-event-message"); let payload = { title: document.getElementById("edit-event-title").value, description: document.getElementById("edit-event-description").value, start_time: toBackendDateTime(document.getElementById("edit-event-start").value), end_time: toBackendDateTime(document.getElementById("edit-event-end").value), min_people: parseInt(document.getElementById("edit-event-min-people").value, 10) || 1 }; try{ await apiFetch(`/events/${eventId}`, { method: "PATCH", body: JSON.stringify(payload) }); closeModal(); showPage("events"); } catch(error){ messageBox.textContent = error.message; } } async function handleDeleteEvent(eventId){ if(!confirm("Delete this event?")) return; try{ await apiFetch(`/events/${eventId}`, { method: "DELETE" }); closeModal(); showPage("events"); } catch(error){ alert("Could not delete event: " + error.message); } } const A11Y_DEFAULTS = { reducedMotion: false, highContrast: false, fontScale: "normal", underlineLinks: false, alwaysShowFocus: false }; function getA11ySettings(){ try{ let stored = JSON.parse(localStorage.getItem("a11y-settings")); return { ...A11Y_DEFAULTS, ...(stored || {}) }; } catch(error){ return { ...A11Y_DEFAULTS }; } } function applyA11ySettings(settings){ document.body.classList.toggle("reduced-motion", settings.reducedMotion); document.body.classList.toggle("high-contrast", settings.highContrast); document.body.classList.toggle("underline-links", settings.underlineLinks); document.body.classList.toggle("always-focus", settings.alwaysShowFocus); document.body.classList.remove("font-scale-large", "font-scale-xlarge"); if(settings.fontScale === "large") document.body.classList.add("font-scale-large"); if(settings.fontScale === "xlarge") document.body.classList.add("font-scale-xlarge"); } function updateA11ySetting(key, value){ let settings = getA11ySettings(); settings[key] = value; localStorage.setItem("a11y-settings", JSON.stringify(settings)); applyA11ySettings(settings); } function renderSettingsPage(content){ let settings = getA11ySettings(); content.innerHTML = `

Settings

Account

Accessibility

Appearance

Diagnostics

`; } async function runDiagnostics(){ let resultBox = document.getElementById("diagnostics-result"); resultBox.textContent = "Checking..."; try{ let home = await apiFetch("/"); let db = await apiFetch("/database"); resultBox.textContent = `${home.service}: ${home.status}. Tables: ${(db.tables || []).join(", ")}`; } catch(error){ resultBox.textContent = "Diagnostics failed: " + error.message; } } async function loadAsciiArt(){ let box = document.getElementById("ascii-art-box"); try{ let headers = {}; let token = getToken(); if(token) headers["Authorization"] = "Bearer " + token; let response = await fetch(API_BASE + "/ascii-art", { headers }); let text = await response.text(); box.textContent = text; box.hidden = false; } catch(error){ box.textContent = "Could not load ascii art."; box.hidden = false; } } function toggleTheme(){ document.body.classList.toggle("dark"); let theme = document.body.classList.contains("dark") ? "dark" : "light"; localStorage.setItem("theme", theme); } let savedTheme = localStorage.getItem("theme"); if(savedTheme === "dark"){ document.body.classList.add("dark"); } applyA11ySettings(getA11ySettings()); if(getToken()){ showApp(); showPage("dashboard"); } else { showLogin(); }