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;
}
// ---------- 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"){
content.innerHTML = `
Settings
Account preferences.
`;
}
}
// ---------- dashboard ----------
let calendarWeekOffset = 0;
let cachedDashboardEvents = [];
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 || [];
let eventsData =
await apiFetch("/events");
cachedDashboardEvents =
eventsData.events || [];
let roomsHtml =
rooms.length
? rooms.map(room => `
${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}
`;
} 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{
await apiFetch(
"/join-room",
{
method: "POST",
body: JSON.stringify({ invite_code: inviteCode })
}
);
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";
}
if((event.status || "").toLowerCase() === "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;
// time gutter
let hourRows = "";
for(
let h = CALENDAR_START_HOUR;
h < CALENDAR_END_HOUR;
h += CALENDAR_SLOT_HOURS
){
hourRows += `
${String(h).padStart(2, "0")}:00
`;
}
// day columns
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));
// assign overlap layers: an event gets the lowest layer
// whose most recent block has already ended
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 heightPct =
((p.endMinutes - p.startMinutes) / totalMinutes) * 100;
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
});
console.log({
title: p.event.title,
startMinutes: p.startMinutes,
endMinutes: p.endMinutes,
topPx,
heightPx
});
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
private (others)
`;
}
// ---------- rooms page ----------
async function loadRooms(){
let content =
document.getElementById("content");
content.innerHTML = `
Rooms
Loading...
`;
try{
let roomsData =
await apiFetch("/rooms");
let rooms =
roomsData.rooms || [];
if(rooms.length === 0){
content.innerHTML = `
Rooms
Your shared rooms appear here.
`;
return;
}
let roomsHtml =
rooms.map(room => `
${room.room_name}
invite code: ${room.invite_code || "-"}
`).join("");
content.innerHTML = `
Rooms
${roomsHtml}
`;
} catch(error){
content.innerHTML = `
Rooms
Could not load rooms: ${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}
`;
}
}
// ---------- 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;
}
}
// ---------- 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");
}
if(getToken()){
showApp();
showPage("dashboard");
} else {
showLogin();
}