/* ========================================================================== librus-tui frontend - homework, attendance, messages, free days ========================================================================== */ "use strict"; /* ------------------------------------------------------------------ homework */ async function renderHomework(token) { const data = await api("/api/exams"); if (token !== state.loadToken) return; const exams = (data.exams || []).slice().sort((a, b) => { const left = parseDate(a.Date); const right = parseDate(b.Date); return (left ? left.getTime() : Number.MAX_SAFE_INTEGER) - (right ? right.getTime() : Number.MAX_SAFE_INTEGER); }); state.exams = exams; const upcoming = exams.filter((exam) => (daysUntil(exam.Date) ?? -1) >= 0).length; const rows = exams.length ? exams .map((exam, index) => { const distance = daysUntil(exam.Date); const dateClass = distance === null ? "" : distance < 0 ? "soft" : distance <= 1 ? "urgent" : distance <= 3 ? "soon" : ""; const typeClass = examTagClass(exam.Type); const content = exam.Content ? truncate(exam.Content, 84) : "—"; return ` ${esc(fmtDate(exam.Date))}
${esc(weekdayShort(exam.Date))}
${esc(exam.Lesson || "—")} ${esc(exam.Type || "—")} ${esc(timeRange(exam.StartTime, exam.EndTime))} ${esc(personName(exam.AddedBy) || "—")} ${esc(content)} `; }) .join("") : ""; $("#view").innerHTML = `
${upcoming} upcoming · ${exams.length} total click a row for the full description
homework & testssorted by date
${ exams.length ? `
${rows}
datesubjecttypetimeteacherdescription
` : `
no homework or tests
` }
`; } function openExamDetail(index) { const exam = state.exams[index]; if (!exam) return; const body = `
subject
${esc(exam.Lesson || "—")}
type
${esc(exam.Type || "—")}
date
${esc(fmtDate(exam.Date))} · ${esc(weekdayShort(exam.Date))}
time
${esc(timeRange(exam.StartTime, exam.EndTime))}
teacher
${esc(personName(exam.AddedBy) || "—")}
added
${esc(fmtDate(exam.AddDate))}
description

${esc(exam.Content || "—")}

`; openModal(`homework · ${exam.Lesson || "—"}`, body, [{ label: "[ close ]", close: true }]); } /* ---------------------------------------------------------------- attendance */ async function renderAttendance(token) { const data = await api("/api/attendances"); if (token !== state.loadToken) return; const items = (data.attendances || []).slice().sort((a, b) => { const left = parseDate(a.Date); const right = parseDate(b.Date); return (right ? right.getTime() : 0) - (left ? left.getTime() : 0); }); const total = items.length; const absent = items.filter((item) => !item.isPresence).length; const late = items.filter((item) => /późn|spoźn|sp\./i.test(`${item.Type} ${item.Short}`)).length; const excused = items.filter((item) => /zwoln|zw\./i.test(`${item.Type} ${item.Short}`)).length; const rows = items.length ? items .map((item) => { const isLate = /późn|spoźn|sp\./i.test(`${item.Type} ${item.Short}`); const tagClass = !item.isPresence ? "tag-red" : isLate ? "tag-amber" : "tag-green"; return ` ${esc(fmtDate(item.Date))} ${esc(item.Lesson || "—")} ${esc(item.Short || "?")} ${esc(item.Type || "")} ${esc(personName(item.AddedBy) || "—")} ${item.isPresence ? "present" : "absent"} `; }) .join("") : ""; $("#view").innerHTML = `
${total}
entries
${Math.max(0, total - absent)}
present
${absent}
absences
${late}
late · ${excused} excused
attendance register${total} entries
${ items.length ? `
${rows}
datesubjecttypeteacherstatus
` : `
no attendance entries
` }
`; } /* ------------------------------------------------------------------ messages */ async function renderMessages(token) { const data = await api("/api/messages"); if (token !== state.loadToken) return; state.messages = data.messages || []; state.messageCache.clear(); state.messageIndex = state.messages.length ? 0 : -1; $("#view").innerHTML = `
inbox ${state.messages.length} messages
`; renderMessageList(); if (state.messageIndex >= 0) { loadMessage(state.messageIndex); } else { $("#message-reader").innerHTML = `
reader
inbox empty
`; } } function renderMessageList() { const list = $("#message-list"); if (!list) return; if (!state.messages.length) { list.innerHTML = `
no messages
`; return; } list.innerHTML = state.messages .map( (message, index) => ` ` ) .join(""); } function highlightMessageList() { $$("#message-list .message-item").forEach((element, index) => { element.classList.toggle("active", index === state.messageIndex); }); const active = $("#message-list .message-item.active"); if (active) active.scrollIntoView({ block: "nearest" }); } function selectMessage(index) { if (!state.messages.length) return; const next = Math.max(0, Math.min(state.messages.length - 1, index)); if (next === state.messageIndex) return; state.messageIndex = next; highlightMessageList(); loadMessage(next); } function moveMessage(delta) { if (!state.messages.length) return; selectMessage(state.messageIndex + delta); } async function loadMessage(index, force = false) { const message = state.messages[index]; const reader = $("#message-reader"); if (!message || !reader) return; const token = ++state.messageToken; const link = message.link; if (!force && state.messageCache.has(link)) { renderMessageDetail(state.messageCache.get(link), index); return; } reader.innerHTML = `
readerloading…
[..] opening message…
`; try { const detail = await api(`/api/message?link=${encodeURIComponent(link)}`); if (token !== state.messageToken) return; state.messageCache.set(link, detail); renderMessageDetail(detail, index); setStatus(`[ok] message ${index + 1}/${state.messages.length}`, "ok"); } catch (error) { if (token !== state.messageToken) return; reader.innerHTML = `
readererror
${esc(error.message)}
`; toast(error.message, "err"); } } function renderMessageDetail(detail, index) { const reader = $("#message-reader"); if (!reader) return; const attachments = (detail.attachments || []) .map((attachment) => { const link = attachment.nice || attachment.source || ""; return ` [↓] ${esc(attachment.name || link || "attachment")} download `; }) .join(""); const readState = detail.read ? `read: ${esc(detail.read)}` : ""; reader.innerHTML = `
reader ${index + 1} / ${state.messages.length}

${esc(detail.subject || "(no subject)")}

from ${esc(detail.from || "unknown")} ${esc(detail.date || "")} ${readState}
${detail.content || `
message body is empty
`}
${ attachments ? `
attachments
${attachments}
` : "" } `; reader.scrollTop = 0; } /* ----------------------------------------------------------------- free days */ async function renderFreedays(token) { const [free, conferences] = await Promise.all([ api("/api/freedays"), api("/api/conferences").catch(() => null), ]); if (token !== state.loadToken) return; const school = (free.school || []) .slice() .sort((a, b) => (parseDate(a.DateFrom) || 0) - (parseDate(b.DateFrom) || 0)); const teachers = (free.teachers || []) .slice() .sort((a, b) => (parseDate(a.DateFrom) || 0) - (parseDate(b.DateFrom) || 0)); const conf = (conferences && conferences.conferences) || []; const schoolRows = school.length ? school .map((item) => { const distance = daysUntil(item.DateFrom); const past = distance !== null && distance < 0; return `
${esc(item.Name || "free day")}
${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}
${esc(countdownLabel(item.DateFrom))}
`; }) .join("") : `
no free days
`; const teacherRows = teachers.length ? teachers .map((item) => { const teacher = personName(item.Teacher) || "teacher"; const times = item.TimeFrom ? ` · ${esc(shortTime(item.TimeFrom))}–${esc(shortTime(item.TimeTo))}` : ""; return `
${esc(teacher)}
${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}${times}
${esc(countdownLabel(item.DateFrom))}
`; }) .join("") : `
no teacher absences
`; const confRows = conf.length ? conf .map( (item) => `
${esc(item.Name || "conference")}
${esc(item.Topic || "")}
${esc(fmtDate(item.Date))} · ${esc(shortTime(item.Time))}${item.Room ? ` · room ${esc(item.Room)}` : ""}
` ) .join("") : `
no scheduled conferences
`; $("#view").innerHTML = `
school free days${school.length}
${schoolRows}
teacher free days${teachers.length}
${teacherRows}
parent-teacher conferences${conf.length}
${confRows}
`; } /* ========================================================================== settings + ntfy ========================================================================== */ const SETTINGS_KEY = "librustui.settings.v1"; const SETTINGS_LOG_KEY = "librustui.settings.log.v1"; function defaultSettings() { return { enabled: false, server: "https://ntfy.sh", topic: "", token: "", grades: true, messages: true, exams: true, attendance: false, pollMinutes: 5, }; } function loadSettings() { try { const raw = localStorage.getItem(SETTINGS_KEY); if (!raw) return defaultSettings(); const parsed = JSON.parse(raw); return Object.assign(defaultSettings(), parsed || {}); } catch (error) { return defaultSettings(); } } function saveSettings(settings) { const next = Object.assign(defaultSettings(), settings || {}); try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(next)); } catch (error) { /* ignore storage errors */ } return next; } function loadSettingsLog() { try { const raw = localStorage.getItem(SETTINGS_LOG_KEY); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed : []; } catch (error) { return []; } } function appendSettingsLog(message) { const log = loadSettingsLog(); log.unshift({ at: new Date().toISOString(), message: String(message || "") }); if (log.length > 40) log.length = 40; try { localStorage.setItem(SETTINGS_LOG_KEY, JSON.stringify(log)); } catch (error) { /* ignore */ } const container = $("#settings-log"); if (container) renderSettingsLog(container); } function renderSettingsLog(container) { const log = loadSettingsLog(); if (!log.length) { container.innerHTML = `
no activity yet
`; return; } container.innerHTML = log .map((entry) => { const when = fmtDateShort ? fmtDateShort(entry.at) : entry.at; return `
${esc(entry.message)} ${esc(when)}
`; }) .join(""); } function collectSettingsFromForm() { const get = (id) => $(`#${id}`); const checkbox = (id) => !!(get(id) && get(id).checked); const value = (id) => (get(id)?.value || "").trim(); return { enabled: checkbox("set-enabled"), server: value("set-server") || "https://ntfy.sh", topic: value("set-topic"), token: value("set-token"), grades: checkbox("set-grades"), messages: checkbox("set-messages"), exams: checkbox("set-exams"), attendance: checkbox("set-attendance"), pollMinutes: Math.max(1, Number(value("set-poll")) || 5), }; } function fillSettingsForm(settings) { const set = (id, value) => { const element = $(`#${id}`); if (element) element.value = value; }; const check = (id, value) => { const element = $(`#${id}`); if (element) element.checked = !!value; }; const s = settings || defaultSettings(); check("set-enabled", s.enabled); set("set-server", s.server); set("set-topic", s.topic); set("set-token", s.token); check("set-grades", s.grades); check("set-messages", s.messages); check("set-exams", s.exams); check("set-attendance", s.attendance); set("set-poll", s.pollMinutes); } async function sendNtfy(settings, title, message, priority) { const cfg = settings || loadSettings(); if (!cfg.enabled) throw new Error("NTFY is disabled in settings."); if (!cfg.topic) throw new Error("NTFY topic is empty."); const response = await api("/api/ntfy", { method: "POST", body: { server: cfg.server, topic: cfg.topic, token: cfg.token, title: title || "librus-tui", message: message || "", priority: priority || null, }, }); if (!response || !response.ok) throw new Error("NTFY push failed."); return true; } async function renderSettings() { const settings = loadSettings(); $("#view").innerHTML = `
ntfy notificationspush alerts
notification categorieswhat to push

Polling runs while this tab is open. The first poll only records the baseline and does not send pushes.

activity logrecent pushes
`; fillSettingsForm(settings); renderSettingsLog($("#settings-log")); const status = $("#settings-status"); if (status) { status.textContent = settings.enabled && settings.topic ? `enabled · topic ${settings.topic} · poll every ${settings.pollMinutes} min` : "disabled · configure a topic to enable pushes"; } } function stopNotificationPolling() { if (state.notificationPoller) { clearInterval(state.notificationPoller); state.notificationPoller = null; } } function startNotificationPolling() { stopNotificationPolling(); const settings = loadSettings(); state.lastNotifySnapshot = null; if (!settings.enabled) return; const minutes = Math.max(1, Number(settings.pollMinutes) || 5); pollNotifications(); state.notificationPoller = setInterval(pollNotifications, minutes * 60 * 1000); } async function pollNotifications() { if (!state.session || !state.session.authenticated) return; const settings = loadSettings(); if (!settings.enabled) return; try { const notif = await api("/api/notifications"); let messageCount = 0; if (settings.messages) { try { const messages = await api("/api/messages"); messageCount = Array.isArray(messages) ? messages.length : Array.isArray(messages?.items) ? messages.items.length : 0; } catch (error) { messageCount = state.lastNotifySnapshot?.messages || 0; } } else if (state.lastNotifySnapshot) { messageCount = state.lastNotifySnapshot.messages; } const snapshot = { grades: Number(notif?.grades) || 0, exams: Number(notif?.exams) || 0, absences: Number(notif?.absences) || 0, conferences: Number(notif?.conferences) || 0, messages: messageCount, }; if (state.lastNotifySnapshot) { const previous = state.lastNotifySnapshot; const pushes = []; if (settings.grades && snapshot.grades > previous.grades) { pushes.push({ title: "New grades", message: `${snapshot.grades - previous.grades} new grade(s). Total: ${snapshot.grades}.`, priority: 3, }); } if (settings.exams && snapshot.exams > previous.exams) { pushes.push({ title: "New exams / homework", message: `${snapshot.exams - previous.exams} new item(s). Total: ${snapshot.exams}.`, priority: 4, }); } if (settings.messages && snapshot.messages > previous.messages) { pushes.push({ title: "New messages", message: `${snapshot.messages - previous.messages} new message(s). Total: ${snapshot.messages}.`, priority: 3, }); } if (settings.attendance && snapshot.absences > previous.absences) { pushes.push({ title: "New absence", message: `${snapshot.absences - previous.absences} new absence entry. Total: ${snapshot.absences}.`, priority: 4, }); } for (const push of pushes) { toast(`[ntfy] ${push.title}`, "ok", 2200); try { await sendNtfy(settings, push.title, push.message, push.priority); appendSettingsLog(`pushed: ${push.title}`); } catch (error) { appendSettingsLog(`push failed: ${push.title} — ${error.message}`); toast(`[ntfy fail] ${error.message}`, "warn"); } } } state.lastNotifySnapshot = snapshot; } catch (error) { /* polling failures are non-fatal */ } }