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.
+ New event
${buildWeekCalendarHtml(cachedDashboardEvents, calendarWeekOffset)}
Your rooms
${roomsHtml}
`;
} 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 `
`;
}).join("");
return `
confirmed
busy / pending
private (others)
`;
}
// ---------- 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 => `
${room.name}
`).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 = `
+ Propose event
`;
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 || "-"}
View events
Manage
Propose event
`).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 = `
`;
} 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
+ Propose event
${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}
View events (${events.length})
Propose event
${renderMembersList(members)}
Leave or delete
Leave room
Delete room
`;
} 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
+ New event
${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}
Going
Maybe
Declined
Details
`).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
${isOwn ? `
Edit
Delete
` : ""}
Close
`);
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);
}
}
// ---------- settings / accessibility ----------
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
Log out
Accessibility
Reduce motion
High contrast
Underline links
Always show focus outline
Text size
Normal
Large
Extra large
Appearance
Toggle dark mode
Diagnostics
Check API status
Show ascii art
`;
}
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;
}
}
// ---------- 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(getA11ySettings());
if(getToken()){
showApp();
showPage("dashboard");
} else {
showLogin();
}