/* ==========================================================================
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
? `
| date | subject | type | time | teacher | description |
${rows}
`
: `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 = `
${Math.max(0, total - absent)}
present
${late}
late · ${excused} excused
attendance register${total} entries
${
items.length
? `
| date | subject | type | teacher | status |
${rows}
`
: `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
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 */
}
}