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) ? (typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail)) : "request failed"; throw new Error(message); } return data; } /* small helpers */ function escapeHtml(value){ return String(value == null ? "" : value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function toBackendTime(localValue){ // datetime-local gives "YYYY-MM-DDTHH:MM" -> "YYYY-MM-DD HH:MM:00" if(!localValue){ return null; } let value = localValue.replace("T", " "); if(value.length === 16){ value += ":00"; } return value; } // ---------- 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.classList.add("success"); errorBox.classList.remove("error"); errorBox.textContent = "account created, you can log in now"; } catch(error){ errorBox.classList.remove("success"); errorBox.classList.add("error"); errorBox.textContent = error.message; } } function logout(){ clearToken(); showLogin(); } // ---------- shared state ---------- let calendarWeekOffset = 0; let cachedDashboardEvents = []; let cachedRooms = []; let currentUserId = null; let currentUsername = null; async function ensureMe(){ if(currentUserId !== null){ return; } let me = await apiFetch("/me"); currentUserId = me.id; currentUsername = me.username; } async function ensureRooms(force){ if(cachedRooms.length && !force){ return cachedRooms; } let roomsData = await apiFetch("/rooms"); cachedRooms = roomsData.rooms || []; return cachedRooms; } // ---------- 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"){ loadSettings(); } } // ---------- dashboard ---------- async function loadDashboard(){ let content = document.getElementById("content"); content.innerHTML = `

Dashboard

Loading...
`; calendarWeekOffset = 0; try{ await ensureMe(); let rooms = await ensureRooms(true); let eventsData = await apiFetch("/events"); cachedDashboardEvents = eventsData.events || []; let roomsHtml = rooms.length ? rooms.map(room => `
${escapeHtml(room.room_name)}
`).join("") : `

You have no rooms yet.

`; content.innerHTML = `

Dashboard

Welcome back, ${escapeHtml(currentUsername)}

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: ${escapeHtml(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"; await ensureRooms(true); 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{ await apiFetch("/join-room", { method: "POST", body: JSON.stringify({ invite_code: inviteCode }) }); messageBox.textContent = "joined room"; await ensureRooms(true); 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 status = (event.status || "").toLowerCase(); let type = (event.type || "").toLowerCase(); if(status === "proposed"){ return "event-proposed"; } if(type === "busy"){ return "event-busy"; } if(status === "confirmed"){ return "event-confirmed"; } 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(/[- :T]/); return new Date( parts[0], parts[1] - 1, parts[2], parts[3] || 0, parts[4] || 0 ); } 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} ${escapeHtml(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 proposed busy private (others)
${hourRows}
${dayColumns}
`; } // ---------- rooms page ---------- async function loadRooms(){ let content = document.getElementById("content"); content.innerHTML = `

Rooms

Loading...
`; try{ await ensureMe(); let rooms = await ensureRooms(true); let searchCard = `

Find a room by invite code

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

Rooms

${searchCard}
Your shared rooms appear here.
`; return; } let roomsHtml = rooms.map(room => { let isOwner = room.owner_id === currentUserId; return `

${escapeHtml(room.room_name)}

${isOwner ? `owner` : ""}

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

${isOwner ? `` : ``}

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

Rooms

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

Rooms

Could not load rooms: ${escapeHtml(error.message)}
`; } } async function handleSearchRoom(event){ event.preventDefault(); let inviteCode = document.getElementById("search-room-code").value; let resultBox = document.getElementById("search-room-result"); resultBox.textContent = "searching..."; try{ let data = await apiFetch("/search-room", { method: "POST", body: JSON.stringify({ invite_code: inviteCode }) }); let room = data.room || data; if(!room || (!room.room_name && !room.id)){ resultBox.textContent = "no room found for that code"; return; } resultBox.innerHTML = `
${escapeHtml(room.room_name || "room")}
`; } catch(error){ resultBox.textContent = error.message; } } async function handleJoinRoomByCode(inviteCode){ try{ await apiFetch("/join-room", { method: "POST", body: JSON.stringify({ invite_code: inviteCode }) }); await ensureRooms(true); loadRooms(); } catch(error){ let resultBox = document.getElementById("search-room-result"); if(resultBox){ resultBox.textContent = error.message; } } } async function showRoomMembers(roomId){ let container = document.getElementById(`room-members-${roomId}`); if(!container){ return; } container.innerHTML = `loading members...`; try{ let data = await apiFetch(`/rooms/${roomId}/members`); let members = data.members || []; if(members.length === 0){ container.innerHTML = `no members`; return; } container.innerHTML = members.map(m => ` ${escapeHtml(m.username || ("user " + m.id))} `).join(""); } catch(error){ container.innerHTML = `${escapeHtml(error.message)}`; } } async function handleLeaveRoom(roomId){ let messageBox = document.getElementById(`room-message-${roomId}`); try{ await apiFetch(`/rooms/${roomId}/members/me`, { method: "DELETE" }); await ensureRooms(true); loadRooms(); } catch(error){ if(messageBox){ messageBox.textContent = error.message; } } } async function handleDeleteRoom(roomId){ let messageBox = document.getElementById(`room-message-${roomId}`); try{ await apiFetch(`/room/${roomId}`, { method: "DELETE" }); await ensureRooms(true); loadRooms(); } catch(error){ if(messageBox){ messageBox.textContent = error.message; } } } async function loadRoomEvents(roomId){ let content = document.getElementById("content"); content.innerHTML = `

Room events

Loading...
`; try{ await ensureMe(); 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: ${escapeHtml(error.message)}
`; } } // ---------- events page ---------- // kind -> backend fields mapping const EVENT_KINDS = { confirmed: { type: "event", visibility: "public", status: "confirmed", label: "Confirmed event", desc: "A regular event everyone can see." }, proposed: { type: "event", visibility: "public", status: "proposed", label: "Propose event", desc: "Suggest a time and collect responses." }, busy: { type: "busy", visibility: "public", status: "confirmed", label: "Busy", desc: "Block time without sharing details." }, private: { type: "private", visibility: "private", status: "confirmed", label: "Private", desc: "Only you can see the details." } }; async function loadEvents(){ let content = document.getElementById("content"); content.innerHTML = `

Events

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

Events

${renderCreateEventCard(rooms)}

Upcoming events

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

Events

Could not load events: ${escapeHtml(error.message)}
`; } } function renderCreateEventCard(rooms){ let roomOptions = rooms.length ? rooms.map(r => ``).join("") : ``; let kindOptions = Object.keys(EVENT_KINDS).map((key, index) => { let k = EVENT_KINDS[key]; return ` `; }).join(""); return `

Create an event

${kindOptions}

`; } function onEventKindChange(){ let selected = document.querySelector('input[name="event-kind"]:checked'); let wrap = document.getElementById("event-minpeople-wrap"); if(!selected || !wrap){ return; } // min people is only meaningful when proposing wrap.style.display = (selected.value === "proposed") ? "block" : "none"; } async function handleCreateEvent(event){ event.preventDefault(); let messageBox = document.getElementById("create-event-message"); messageBox.classList.remove("success", "error"); let kindKey = (document.querySelector('input[name="event-kind"]:checked') || {}).value || "confirmed"; let kind = EVENT_KINDS[kindKey]; let roomId = document.getElementById("event-room").value; let title = document.getElementById("event-title").value; let description = document.getElementById("event-description").value; let start = toBackendTime(document.getElementById("event-start").value); let end = toBackendTime(document.getElementById("event-end").value); let minPeople = 0; if(kindKey === "proposed"){ minPeople = parseInt(document.getElementById("event-minpeople").value, 10) || 0; } if(!roomId){ messageBox.classList.add("error"); messageBox.textContent = "please create or join a room first"; return; } let payload = { room_id: parseInt(roomId, 10), type: kind.type, title: title, description: description, start_time: start, end_time: end, visibility: kind.visibility, status: kind.status, min_people: minPeople }; try{ await apiFetch("/events", { method: "POST", body: JSON.stringify(payload) }); messageBox.classList.add("success"); messageBox.textContent = `${kind.label} created`; // refresh dashboard cache and reload the events list loadEvents(); } catch(error){ messageBox.classList.add("error"); messageBox.textContent = error.message; } } function eventBadge(event){ let status = (event.status || "").toLowerCase(); let type = (event.type || "").toLowerCase(); if(event.visibility === "private"){ return `private`; } if(status === "proposed"){ return `proposed`; } if(type === "busy"){ return `busy`; } return `confirmed`; } function renderEventCards(events){ if(events.length === 0){ return `
No events scheduled.
`; } return events.map(event => { let isOwner = event.creator_id === currentUserId; let isProposed = (event.status || "").toLowerCase() === "proposed"; return `

${escapeHtml(event.title)}

${eventBadge(event)}

${escapeHtml(event.description || "")}

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

${isProposed && event.min_people ? `

needs at least ${event.min_people} people

` : ""}
${isOwner ? `` : ""} ${isOwner ? `` : ""}

`; }).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" }); let going = (result && result.going != null) ? result.going : "?"; messageBox.textContent = `you responded: ${status} (${going} going)`; } catch(error){ messageBox.textContent = error.message; } } async function viewResponses(eventId){ let container = document.getElementById(`event-responses-${eventId}`); if(!container){ return; } container.innerHTML = `

loading responses...

`; try{ let data = await apiFetch(`/events/${eventId}/responses`); let responses = data.responses || []; if(responses.length === 0){ container.innerHTML = `

no responses yet

`; return; } container.innerHTML = `
${responses.map(r => ` ${escapeHtml(r.username || ("user " + r.user_id))}: ${escapeHtml(r.status)} `).join("")}
`; } catch(error){ container.innerHTML = `

${escapeHtml(error.message)}

`; } } async function toggleEditEvent(eventId){ let container = document.getElementById(`event-edit-${eventId}`); if(!container){ return; } if(container.innerHTML.trim() !== ""){ container.innerHTML = ""; return; } try{ let event = await apiFetch(`/events/${eventId}`); let ev = event.event || event; let startVal = (ev.start_time || "").replace(" ", "T").slice(0, 16); let endVal = (ev.end_time || "").replace(" ", "T").slice(0, 16); container.innerHTML = `

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

${escapeHtml(error.message)}

`; } } async function handleUpdateEvent(event, eventId){ event.preventDefault(); let messageBox = document.getElementById(`edit-message-${eventId}`); let payload = { title: document.getElementById(`edit-title-${eventId}`).value, description: document.getElementById(`edit-desc-${eventId}`).value, start_time: toBackendTime(document.getElementById(`edit-start-${eventId}`).value), end_time: toBackendTime(document.getElementById(`edit-end-${eventId}`).value), min_people: parseInt(document.getElementById(`edit-minpeople-${eventId}`).value, 10) || 0 }; try{ await apiFetch(`/events/${eventId}`, { method: "PATCH", body: JSON.stringify(payload) }); messageBox.classList.add("success"); messageBox.textContent = "event updated"; loadEvents(); } catch(error){ messageBox.textContent = error.message; } } async function handleDeleteEvent(eventId){ try{ await apiFetch(`/events/${eventId}`, { method: "DELETE" }); let card = document.getElementById(`event-card-${eventId}`); if(card){ card.remove(); } } catch(error){ let messageBox = document.getElementById(`event-response-${eventId}`); if(messageBox){ messageBox.textContent = error.message; } } } // ---------- settings page ---------- const A11Y_DEFAULTS = { contrast: false, underline: false, reduceMotion: false, dyslexic: false, fontScale: "1" }; function getA11ySettings(){ try{ let raw = localStorage.getItem("a11y"); if(!raw){ return { ...A11Y_DEFAULTS }; } return { ...A11Y_DEFAULTS, ...JSON.parse(raw) }; } catch(e){ return { ...A11Y_DEFAULTS }; } } function saveA11ySettings(settings){ localStorage.setItem("a11y", JSON.stringify(settings)); } function applyA11ySettings(){ let s = getA11ySettings(); let body = document.body; body.classList.toggle("a11y-contrast", !!s.contrast); body.classList.toggle("a11y-underline", !!s.underline); body.classList.toggle("a11y-reduce-motion", !!s.reduceMotion); body.classList.toggle("a11y-dyslexic", !!s.dyslexic); document.documentElement.style.setProperty("--font-scale", s.fontScale || "1"); } function updateA11ySetting(key, value){ let s = getA11ySettings(); s[key] = value; saveA11ySettings(s); applyA11ySettings(); } async function loadSettings(){ let content = document.getElementById("content"); let s = getA11ySettings(); let isDark = document.body.classList.contains("dark"); content.innerHTML = `

Settings

Appearance

Dark mode
Switch between the light paper theme and the dark theme.
Text size
Scale all text for easier reading.

Accessibility

High contrast
Increase contrast and thicken borders for better visibility.
Underline links
Always underline links so they stand out from regular text.
Reduce motion
Turn off transitions and animations across the app.
Dyslexia-friendly reading
Use a more legible font with extra letter and line spacing.

Account

Loading account...

`; // load account info via /me try{ await ensureMe(); let box = document.getElementById("settings-account"); if(box){ box.textContent = `Signed in as ${currentUsername} (id ${currentUserId})`; } } catch(e){ /* ignore */ } } function resetA11ySettings(){ saveA11ySettings({ ...A11Y_DEFAULTS }); applyA11ySettings(); loadSettings(); } // ---------- theme ---------- function toggleTheme(){ document.body.classList.toggle("dark"); let theme = document.body.classList.contains("dark") ? "dark" : "light"; localStorage.setItem("theme", theme); } // ---------- init ---------- let savedTheme = localStorage.getItem("theme"); if(savedTheme === "dark"){ document.body.classList.add("dark"); } applyA11ySettings(); if(getToken()){ showApp(); showPage("dashboard"); } else { showLogin(); }