705 lines
26 KiB
JavaScript
705 lines
26 KiB
JavaScript
/* ==========================================================================
|
||
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 `
|
||
<tr data-exam="${index}" title="click for details">
|
||
<td class="num">
|
||
<span class="date-badge ${dateClass}">${esc(fmtDate(exam.Date))}</span>
|
||
<div class="soft">${esc(weekdayShort(exam.Date))}</div>
|
||
</td>
|
||
<td class="strong">${esc(exam.Lesson || "—")}</td>
|
||
<td><span class="tag ${typeClass}">${esc(exam.Type || "—")}</span></td>
|
||
<td class="num">${esc(timeRange(exam.StartTime, exam.EndTime))}</td>
|
||
<td class="soft">${esc(personName(exam.AddedBy) || "—")}</td>
|
||
<td class="content-cell">${esc(content)}</td>
|
||
</tr>`;
|
||
})
|
||
.join("")
|
||
: "";
|
||
|
||
$("#view").innerHTML = `
|
||
<div class="view-inner">
|
||
<div class="toolbar">
|
||
<span><b>${upcoming}</b> upcoming</span>
|
||
<span class="dim">·</span>
|
||
<span><b>${exams.length}</b> total</span>
|
||
<span class="grow"></span>
|
||
<span class="dim">click a row for the full description</span>
|
||
</div>
|
||
<section class="panel">
|
||
<div class="panel-title"><span>homework & tests</span><span class="title-note">sorted by date</span></div>
|
||
${
|
||
exams.length
|
||
? `<div class="table-wrap"><table class="data-table">
|
||
<thead>
|
||
<tr><th>date</th><th>subject</th><th>type</th><th>time</th><th>teacher</th><th>description</th></tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table></div>`
|
||
: `<div class="empty">no homework or tests</div>`
|
||
}
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function openExamDetail(index) {
|
||
const exam = state.exams[index];
|
||
if (!exam) return;
|
||
const body = `
|
||
<dl class="detail-grid">
|
||
<dt>subject</dt><dd>${esc(exam.Lesson || "—")}</dd>
|
||
<dt>type</dt><dd>${esc(exam.Type || "—")}</dd>
|
||
<dt>date</dt><dd>${esc(fmtDate(exam.Date))} · ${esc(weekdayShort(exam.Date))}</dd>
|
||
<dt>time</dt><dd>${esc(timeRange(exam.StartTime, exam.EndTime))}</dd>
|
||
<dt>teacher</dt><dd>${esc(personName(exam.AddedBy) || "—")}</dd>
|
||
<dt>added</dt><dd>${esc(fmtDate(exam.AddDate))}</dd>
|
||
</dl>
|
||
<div class="help-section">description</div>
|
||
<p style="white-space:pre-wrap">${esc(exam.Content || "—")}</p>
|
||
`;
|
||
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 `
|
||
<tr>
|
||
<td class="num">${esc(fmtDate(item.Date))}</td>
|
||
<td class="strong">${esc(item.Lesson || "—")}</td>
|
||
<td><span class="tag ${tagClass}">${esc(item.Short || "?")} ${esc(item.Type || "")}</span></td>
|
||
<td class="soft">${esc(personName(item.AddedBy) || "—")}</td>
|
||
<td class="num presence ${item.isPresence ? "yes" : "no"}">${item.isPresence ? "present" : "absent"}</td>
|
||
</tr>`;
|
||
})
|
||
.join("")
|
||
: "";
|
||
|
||
$("#view").innerHTML = `
|
||
<div class="view-inner">
|
||
<div class="att-summary">
|
||
<div class="att-tile"><div class="value">${total}</div><div class="label">entries</div></div>
|
||
<div class="att-tile"><div class="value">${Math.max(0, total - absent)}</div><div class="label">present</div></div>
|
||
<div class="att-tile absent"><div class="value">${absent}</div><div class="label">absences</div></div>
|
||
<div class="att-tile late"><div class="value">${late}</div><div class="label">late · ${excused} excused</div></div>
|
||
</div>
|
||
<section class="panel">
|
||
<div class="panel-title"><span>attendance register</span><span class="title-note">${total} entries</span></div>
|
||
${
|
||
items.length
|
||
? `<div class="table-wrap"><table class="data-table">
|
||
<thead><tr><th>date</th><th>subject</th><th>type</th><th>teacher</th><th>status</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table></div>`
|
||
: `<div class="empty">no attendance entries</div>`
|
||
}
|
||
</section>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ 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 = `
|
||
<div class="view-inner">
|
||
<div class="messages-layout">
|
||
<section class="panel">
|
||
<div class="panel-title">
|
||
<span>inbox</span>
|
||
<span class="title-note">${state.messages.length} messages</span>
|
||
</div>
|
||
<div class="message-list" id="message-list"></div>
|
||
</section>
|
||
<div class="panel" id="message-reader"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
renderMessageList();
|
||
|
||
if (state.messageIndex >= 0) {
|
||
loadMessage(state.messageIndex);
|
||
} else {
|
||
$("#message-reader").innerHTML = `
|
||
<div class="panel-title"><span>reader</span></div>
|
||
<div class="empty">inbox empty</div>`;
|
||
}
|
||
}
|
||
|
||
function renderMessageList() {
|
||
const list = $("#message-list");
|
||
if (!list) return;
|
||
if (!state.messages.length) {
|
||
list.innerHTML = `<div class="empty">no messages</div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = state.messages
|
||
.map(
|
||
(message, index) => `
|
||
<button class="message-item ${index === state.messageIndex ? "active" : ""}" type="button" data-message="${index}">
|
||
<div class="msg-top">
|
||
<span class="msg-from">${esc(message.from || "unknown")}</span>
|
||
<span class="msg-date">${esc(message.date || "")}</span>
|
||
</div>
|
||
<div class="msg-subject">${esc(message.subject || "(no subject)")}</div>
|
||
</button>`
|
||
)
|
||
.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 = `
|
||
<div class="panel-title"><span>reader</span><span class="title-note">loading…</span></div>
|
||
<div class="loading">[..] opening message…</div>`;
|
||
|
||
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 = `
|
||
<div class="panel-title"><span>reader</span><span class="title-note">error</span></div>
|
||
<div class="error-message">${esc(error.message)}</div>`;
|
||
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 `
|
||
<a class="attachment" href="/api/download?link=${encodeURIComponent(link)}" download>
|
||
<span>[↓]</span>
|
||
<span>${esc(attachment.name || link || "attachment")}</span>
|
||
<span class="size-hint">download</span>
|
||
</a>`;
|
||
})
|
||
.join("");
|
||
|
||
const readState = detail.read
|
||
? `<span class="tag tag-dim">read: ${esc(detail.read)}</span>`
|
||
: "";
|
||
|
||
reader.innerHTML = `
|
||
<div class="panel-title">
|
||
<span>reader</span>
|
||
<span class="title-note">${index + 1} / ${state.messages.length}</span>
|
||
</div>
|
||
<div class="reader-head">
|
||
<h2 class="reader-subject">${esc(detail.subject || "(no subject)")}</h2>
|
||
<div class="reader-meta">
|
||
<span>from <b>${esc(detail.from || "unknown")}</b></span>
|
||
<span>${esc(detail.date || "")}</span>
|
||
${readState}
|
||
</div>
|
||
</div>
|
||
<div class="message-body">${detail.content || `<div class="empty">message body is empty</div>`}</div>
|
||
${
|
||
attachments
|
||
? `<div class="attachments"><div class="attachments-title">attachments</div>${attachments}</div>`
|
||
: ""
|
||
}
|
||
`;
|
||
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 `
|
||
<div class="free-item" ${past ? 'style="opacity:.55"' : ""}>
|
||
<div>
|
||
<div class="free-name">${esc(item.Name || "free day")}</div>
|
||
<div class="conf-meta">${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}</div>
|
||
</div>
|
||
<span class="countdown ${countdownClass(item.DateFrom)}">${esc(countdownLabel(item.DateFrom))}</span>
|
||
</div>`;
|
||
})
|
||
.join("")
|
||
: `<div class="empty">no free days</div>`;
|
||
|
||
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 `
|
||
<div class="free-item">
|
||
<div>
|
||
<div class="free-name">${esc(teacher)}</div>
|
||
<div class="conf-meta">${esc(fmtDate(item.DateFrom))} → ${esc(fmtDate(item.DateTo))}${times}</div>
|
||
</div>
|
||
<span class="countdown ${countdownClass(item.DateFrom)}">${esc(countdownLabel(item.DateFrom))}</span>
|
||
</div>`;
|
||
})
|
||
.join("")
|
||
: `<div class="empty">no teacher absences</div>`;
|
||
|
||
const confRows = conf.length
|
||
? conf
|
||
.map(
|
||
(item) => `
|
||
<div class="conf-item">
|
||
<div class="conf-name">${esc(item.Name || "conference")}</div>
|
||
<div class="conf-meta">${esc(item.Topic || "")}</div>
|
||
<div class="conf-meta">${esc(fmtDate(item.Date))} · ${esc(shortTime(item.Time))}${item.Room ? ` · room ${esc(item.Room)}` : ""}</div>
|
||
</div>`
|
||
)
|
||
.join("")
|
||
: `<div class="empty">no scheduled conferences</div>`;
|
||
|
||
$("#view").innerHTML = `
|
||
<div class="view-inner">
|
||
<div class="split-2">
|
||
<section class="panel">
|
||
<div class="panel-title"><span>school free days</span><span class="title-note">${school.length}</span></div>
|
||
<div class="panel-body tight">${schoolRows}</div>
|
||
</section>
|
||
<section class="panel">
|
||
<div class="panel-title"><span>teacher free days</span><span class="title-note">${teachers.length}</span></div>
|
||
<div class="panel-body tight">${teacherRows}</div>
|
||
</section>
|
||
<section class="panel span-2">
|
||
<div class="panel-title"><span>parent-teacher conferences</span><span class="title-note">${conf.length}</span></div>
|
||
<div class="panel-body tight">${confRows}</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* ==========================================================================
|
||
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 = `<div class="empty">no activity yet</div>`;
|
||
return;
|
||
}
|
||
container.innerHTML = log
|
||
.map((entry) => {
|
||
const when = fmtDateShort ? fmtDateShort(entry.at) : entry.at;
|
||
return `<div class="freeday-line">
|
||
<span class="name">${esc(entry.message)}</span>
|
||
<span class="countdown">${esc(when)}</span>
|
||
</div>`;
|
||
})
|
||
.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 = `
|
||
<div class="view-inner">
|
||
<div class="split-2">
|
||
<section class="panel">
|
||
<div class="panel-title"><span>ntfy notifications</span><span class="title-note">push alerts</span></div>
|
||
<div class="panel-body">
|
||
<label class="field">
|
||
<span class="field-label">enabled</span>
|
||
<span class="field-input-wrap">
|
||
<input id="set-enabled" type="checkbox">
|
||
<span class="dim">send pushes when new items appear</span>
|
||
</span>
|
||
</label>
|
||
<label class="field">
|
||
<span class="field-label">server</span>
|
||
<span class="field-input-wrap">
|
||
<span class="field-prompt">></span>
|
||
<input id="set-server" type="url" placeholder="https://ntfy.sh" spellcheck="false" autocapitalize="off">
|
||
</span>
|
||
</label>
|
||
<label class="field">
|
||
<span class="field-label">topic</span>
|
||
<span class="field-input-wrap">
|
||
<span class="field-prompt">></span>
|
||
<input id="set-topic" type="text" placeholder="librus-yourname" spellcheck="false" autocapitalize="off">
|
||
</span>
|
||
</label>
|
||
<label class="field">
|
||
<span class="field-label">access token (optional)</span>
|
||
<span class="field-input-wrap">
|
||
<span class="field-prompt">></span>
|
||
<input id="set-token" type="password" placeholder="tk_..." autocomplete="off">
|
||
</span>
|
||
</label>
|
||
<label class="field">
|
||
<span class="field-label">poll interval (minutes)</span>
|
||
<span class="field-input-wrap">
|
||
<span class="field-prompt">></span>
|
||
<input id="set-poll" type="number" min="1" max="60" step="1">
|
||
</span>
|
||
</label>
|
||
<div class="toolbar">
|
||
<button class="btn btn-primary" type="button" data-action="settings-save">[ save ]</button>
|
||
<button class="btn" type="button" data-action="settings-test">[ test push ]</button>
|
||
<button class="btn btn-warn" type="button" data-action="settings-clear">[ reset ]</button>
|
||
</div>
|
||
<div id="settings-status" class="dim" style="margin-top:8px;"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-title"><span>notification categories</span><span class="title-note">what to push</span></div>
|
||
<div class="panel-body">
|
||
<label class="field"><span class="field-label">new grades</span><span class="field-input-wrap"><input id="set-grades" type="checkbox"><span class="dim">grade added or changed</span></span></label>
|
||
<label class="field"><span class="field-label">new messages</span><span class="field-input-wrap"><input id="set-messages" type="checkbox"><span class="dim">inbox count increases</span></span></label>
|
||
<label class="field"><span class="field-label">exams / homework</span><span class="field-input-wrap"><input id="set-exams" type="checkbox"><span class="dim">new tests or assignments</span></span></label>
|
||
<label class="field"><span class="field-label">absences</span><span class="field-input-wrap"><input id="set-attendance" type="checkbox"><span class="dim">new absence entries</span></span></label>
|
||
<p class="dim" style="margin-top:10px; font-size:10.5px; line-height:1.5;">
|
||
Polling runs while this tab is open. The first poll only records the
|
||
baseline and does not send pushes.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel span-2">
|
||
<div class="panel-title"><span>activity log</span><span class="title-note">recent pushes</span></div>
|
||
<div class="panel-body tight" id="settings-log"></div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 */
|
||
}
|
||
}
|