feat: iCal export, grade calculator, countdown pulse, session logout

- iCal feed at /api/ical/:token.ics — timetable (recurring), homework,
  class events, countdowns; subscribe in Google/Apple/Outlook Calendar
- POST /api/ical-token + /api/ical-token/regenerate for token management
- Grade calculator panel in Noten overlay: per-subject weighted average
  + "what score do I need?" solver (picks correct GRADE_WEIGHTS per type)
- Countdown urgency: items ≤2 days pulse red via CSS animation
- POST /api/me/logout-other — invalidates all other sessions by bumping
  token_version, re-issues JWT for current session
- Settings: iCal URL section + copy button; session management section
- Migration: users.ical_token, grades.created_at
This commit is contained in:
Simon
2026-04-23 11:42:31 +02:00
parent ca5f3f39e2
commit a765d2d088
3 changed files with 270 additions and 2 deletions
+141 -2
View File
@@ -551,6 +551,20 @@ main {
.grade-avg-val { font-size: 24px; font-weight: 800; color: var(--blue); letter-spacing: -1px; } .grade-avg-val { font-size: 24px; font-weight: 800; color: var(--blue); letter-spacing: -1px; }
.grade-avg-sub { font-size: 10px; color: var(--text-subtle); margin-top: 2px; } .grade-avg-sub { font-size: 10px; color: var(--text-subtle); margin-top: 2px; }
.grade-calc-panel {
margin-top: 12px; padding: 12px;
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--r); display: none;
}
.grade-calc-panel.open { display: block; }
.grade-calc-row { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px; }
.grade-calc-row select, .grade-calc-row input {
height:28px; padding:0 8px; border:1px solid var(--border);
border-radius:var(--r-sm); font-size:12px; background:var(--surface);
color:var(--text); font-family:inherit; min-width:0;
}
.grade-calc-result { font-size:13px; color:var(--text-2); min-height:18px; }
/* ── ABSENCES ────────────────────────────────────────────── */ /* ── ABSENCES ────────────────────────────────────────────── */
.ab-item { .ab-item {
@@ -596,6 +610,9 @@ main {
.cd-item:last-child { border-bottom: none; } .cd-item:last-child { border-bottom: none; }
.cd-days { font-size: 18px; font-weight: 800; color: var(--blue); min-width: 36px; text-align: center; letter-spacing: -1px; font-variant-numeric: tabular-nums; line-height: 1; } .cd-days { font-size: 18px; font-weight: 800; color: var(--blue); min-width: 36px; text-align: center; letter-spacing: -1px; font-variant-numeric: tabular-nums; line-height: 1; }
.cd-days.past { color: var(--text-subtle); font-size: 14px; } .cd-days.past { color: var(--text-subtle); font-size: 14px; }
@keyframes cd-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(220,38,38,0)} 50%{box-shadow:0 0 0 5px rgba(220,38,38,.18)} }
.cd-item.urgent { border-radius:var(--r-sm); border-bottom:1px solid rgba(220,38,38,.25); animation:cd-pulse 2s ease-in-out infinite; }
.cd-item.urgent .cd-days { color:var(--red)!important; }
.cd-info { flex: 1; } .cd-info { flex: 1; }
.cd-title { font-size: 13px; font-weight: 500; color: var(--text); } .cd-title { font-size: 13px; font-weight: 500; color: var(--text); }
.cd-date { font-size: 11px; color: var(--text-muted); margin-top: 1px; } .cd-date { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
@@ -1108,11 +1125,31 @@ footer {
<div class="card-head"> <div class="card-head">
<div class="card-title"><i data-lucide="graduation-cap" aria-hidden="true"></i> Noten</div> <div class="card-title"><i data-lucide="graduation-cap" aria-hidden="true"></i> Noten</div>
<div class="card-actions"> <div class="card-actions">
<button class="add-btn" onclick="toggleGradeCalc()" id="btn-grade-calc">Rechner</button>
<button class="add-btn" onclick="openModal('grades')">+ Note</button> <button class="add-btn" onclick="openModal('grades')">+ Note</button>
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button> <button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div> </div>
</div> </div>
<div class="card-body" id="list-grades"></div> <div class="card-body" style="padding:0;display:flex;flex-direction:column">
<div class="grade-calc-panel" id="grade-calc-panel" style="margin:12px 12px 0">
<div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Notenrechner</div>
<div class="grade-calc-row">
<select id="calc-subject" onchange="updateGradeCalc()" style="flex:1">
<option value="">Alle Fächer</option>
</select>
<select id="calc-type" onchange="updateGradeCalc()">
<option value="schulaufgabe">Schulaufgabe</option>
<option value="kurzarbeit">Kurzarbeit</option>
<option value="stegreifaufgabe">Stegreifaufgabe</option>
<option value="muendlich">Mündlich</option>
<option value="sonstiges">Sonstiges</option>
</select>
<input type="number" id="calc-target" min="1" max="6" step="0.1" placeholder="Ziel (16)" style="width:90px" oninput="updateGradeCalc()">
</div>
<div class="grade-calc-result" id="calc-result"></div>
</div>
<div id="list-grades" style="padding:12px;flex:1"></div>
</div>
</div> </div>
<div class="card ov-card" id="card-files" style="display:none"> <div class="card ov-card" id="card-files" style="display:none">
@@ -1594,6 +1631,25 @@ footer {
</div> </div>
</div> </div>
<div class="settings-section">
<div class="settings-label">Kalender-Export (iCal)</div>
<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Abonniere deinen Stundenplan, Hausaufgaben und Events in Google Calendar, Apple Calendar oder Outlook.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-save" style="font-size:12px;padding:5px 12px" onclick="getIcalUrl()">URL generieren</button>
<button class="btn-cancel" style="font-size:12px;padding:5px 12px" onclick="regenIcalUrl()">Neu generieren</button>
</div>
<div id="ical-url-row" style="display:none;margin-top:8px">
<input id="ical-url" type="text" readonly style="width:100%;font-size:11px;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r-sm);background:var(--surface-2);color:var(--text);font-family:monospace" onclick="this.select()">
<button class="btn-cancel" style="font-size:11px;padding:3px 10px;margin-top:4px" onclick="copyIcalUrl()">Kopieren</button>
</div>
</div>
<div class="settings-section">
<div class="settings-label">Sitzungsverwaltung</div>
<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Alle anderen Geräte und Browser werden sofort abgemeldet. Du bleibst eingeloggt.</p>
<button class="btn-cancel" style="font-size:12px;padding:5px 12px" onclick="logoutOther()">Andere Geräte abmelden</button>
</div>
<div class="settings-section"> <div class="settings-section">
<div class="danger-zone"> <div class="danger-zone">
<p>Account und alle gespeicherten Daten werden <strong>unwiderruflich gelöscht</strong>.</p> <p>Account und alle gespeicherten Daten werden <strong>unwiderruflich gelöscht</strong>.</p>
@@ -1902,6 +1958,8 @@ function renderHW(data){
// ── GRADES ──────────────────────────────────────────────────── // ── GRADES ────────────────────────────────────────────────────
function renderGrades(data){ function renderGrades(data){
gradeCalcData=data;
if(document.getElementById('grade-calc-panel')?.classList.contains('open')) updateGradeCalc();
const el=document.getElementById('list-grades'); const el=document.getElementById('list-grades');
if(!data.length){el.innerHTML='<div class="empty">Keine Noten eingetragen</div>';return;} if(!data.length){el.innerHTML='<div class="empty">Keine Noten eingetragen</div>';return;}
const rows=data.map(g=>{ const rows=data.map(g=>{
@@ -2012,7 +2070,8 @@ function renderCountdowns(data){
if(!data.length){el.innerHTML='<div class="empty">Keine Countdowns</div>';return;} if(!data.length){el.innerHTML='<div class="empty">Keine Countdowns</div>';return;}
el.innerHTML=[...data].sort((a,b)=>(a.target_date||'').localeCompare(b.target_date||'')).map(c=>{ el.innerHTML=[...data].sort((a,b)=>(a.target_date||'').localeCompare(b.target_date||'')).map(c=>{
const d=daysUntil(c.target_date); const d=daysUntil(c.target_date);
return `<div class="cd-item"> const urgent=d>=0&&d<=2?' urgent':'';
return `<div class="cd-item${urgent}">
<div class="cd-days ${d<0?'past':''}">${d<0?'✓':d}</div> <div class="cd-days ${d<0?'past':''}">${d<0?'✓':d}</div>
<div class="cd-info"><div class="cd-title">${esc(c.title)}</div><div class="cd-date">${fmtDate(c.target_date)}</div></div> <div class="cd-info"><div class="cd-title">${esc(c.title)}</div><div class="cd-date">${fmtDate(c.target_date)}</div></div>
<button class="del-btn" onclick="delItem('countdowns',${c.id})">🗑</button> <button class="del-btn" onclick="delItem('countdowns',${c.id})">🗑</button>
@@ -2310,6 +2369,86 @@ async function deleteAccount(){
else toast(r.error,'error'); else toast(r.error,'error');
} }
// ── ICAL EXPORT ───────────────────────────────────────────────
async function getIcalUrl(regen=false){
const r=await api('POST', regen?'ical-token/regenerate':'ical-token');
if(r.error){toast(r.error,'error');return;}
const url=location.origin+'/api/ical/'+r.token+'.ics';
document.getElementById('ical-url').value=url;
document.getElementById('ical-url-row').style.display='';
}
async function regenIcalUrl(){
if(!confirm('Neue URL generieren? Der alte Link wird ungültig.'))return;
await getIcalUrl(true);
toast('Neue iCal-URL generiert');
}
function copyIcalUrl(){
const input=document.getElementById('ical-url');
navigator.clipboard.writeText(input.value).then(()=>toast('URL kopiert ✓')).catch(()=>{input.select();document.execCommand('copy');toast('URL kopiert ✓');});
}
// ── LOGOUT OTHER DEVICES ──────────────────────────────────────
async function logoutOther(){
if(!confirm('Alle anderen Geräte abmelden?'))return;
const r=await api('POST','me/logout-other');
if(r.ok) toast('Andere Sitzungen beendet ✓');
else toast(r.error,'error');
}
// ── GRADE CALCULATOR ──────────────────────────────────────────
let gradeCalcData=[];
function toggleGradeCalc(){
const panel=document.getElementById('grade-calc-panel');
panel.classList.toggle('open');
if(panel.classList.contains('open')) updateGradeCalcSubjects();
}
function updateGradeCalcSubjects(){
const sel=document.getElementById('calc-subject');
const subjects=[...new Set(gradeCalcData.map(g=>g.subject).filter(Boolean))].sort();
sel.innerHTML='<option value="">Alle Fächer</option>'+subjects.map(s=>`<option value="${esc(s)}">${esc(s)}</option>`).join('');
updateGradeCalc();
}
function updateGradeCalc(){
const subj=document.getElementById('calc-subject').value;
const type=document.getElementById('calc-type').value;
const target=parseFloat(document.getElementById('calc-target').value);
const el=document.getElementById('calc-result');
if(!el)return;
const grades=subj?gradeCalcData.filter(g=>g.subject===subj):gradeCalcData;
const valid=grades.filter(g=>g.grade!=null);
const wSum=valid.reduce((s,g)=>s+(GRADE_WEIGHTS[g.type]||1),0);
const wGrades=valid.reduce((s,g)=>s+g.grade*(GRADE_WEIGHTS[g.type]||1),0);
const wavg=wSum?wGrades/wSum:null;
let html='';
if(wavg!==null){
html+=`<div style="margin-bottom:6px">Aktuell${subj?' ('+esc(subj)+')':''}: <strong style="color:var(--blue)">${wavg.toFixed(2)}</strong> <span style="color:var(--text-muted);font-size:11px">(${valid.length} Note${valid.length!==1?'n':''})</span></div>`;
} else {
html+=`<div style="margin-bottom:6px;color:var(--text-muted)">Noch keine Noten${subj?' in '+esc(subj):''}.</div>`;
}
if(!isNaN(target)&&target>=1&&target<=6){
const w=GRADE_WEIGHTS[type]||1;
const needed=((target*(wSum+w))-wGrades)/w;
let msg;
if(needed<1){
msg=`<span style="color:var(--green)">Bereits erreicht mit ${GRADE_TYPES[type]} egal welche Note.</span>`;
} else if(needed>6){
msg=`<span style="color:var(--red)">Nicht mehr erreichbar mit einem ${GRADE_TYPES[type]}.</span>`;
} else {
const col=needed<=2?'var(--green)':needed<=4?'var(--amber)':'var(--red)';
msg=`Für Schnitt <strong>${target}</strong> beim nächsten ${GRADE_TYPES[type]}: <strong style="color:${col}">${needed.toFixed(2)}</strong>`;
}
html+=`<div>${msg}</div>`;
}
el.innerHTML=html;
}
// ── CHAT ────────────────────────────────────────────────────── // ── CHAT ──────────────────────────────────────────────────────
let chatLastId = 0; let chatLastId = 0;
let chatPollTimer = null; let chatPollTimer = null;
+2
View File
@@ -248,6 +248,8 @@ try { db.exec(`ALTER TABLE users ADD COLUMN verify_token TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN verify_expires TEXT`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN verify_expires TEXT`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`); } catch {}
try { db.exec(`ALTER TABLE absences ADD COLUMN teacher_id INTEGER`); } catch {} try { db.exec(`ALTER TABLE absences ADD COLUMN teacher_id INTEGER`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN ical_token TEXT`); } catch {}
try { db.exec(`ALTER TABLE grades ADD COLUMN created_at TEXT DEFAULT (datetime('now'))`); } catch {}
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS password_resets ( CREATE TABLE IF NOT EXISTS password_resets (
+127
View File
@@ -774,4 +774,131 @@ router.get('/health', (req, res) => {
} }
}); });
// --- ICAL EXPORT ---
const ICAL_BYDAY = { Montag:'MO', Dienstag:'TU', Mittwoch:'WE', Donnerstag:'TH', Freitag:'FR' };
const ICAL_DAY_OFFSET = { Montag:0, Dienstag:1, Mittwoch:2, Donnerstag:3, Freitag:4 };
function icalDate(s) { return s.replace(/-/g, ''); }
function icalDateNext(s) {
const d = new Date(s); d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10).replace(/-/g, '');
}
function icalEsc(s) {
return String(s || '').replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n');
}
function icalStamp() {
return new Date().toISOString().replace(/[-:]/g, '').slice(0, 15) + 'Z';
}
router.post('/ical-token', requireAuth, (req, res) => {
let { ical_token } = db.prepare('SELECT ical_token FROM users WHERE id = ?').get(req.user.id);
if (!ical_token) {
ical_token = crypto.randomBytes(24).toString('hex');
db.prepare('UPDATE users SET ical_token = ? WHERE id = ?').run(ical_token, req.user.id);
}
res.json({ token: ical_token });
});
router.post('/ical-token/regenerate', requireAuth, (req, res) => {
const ical_token = crypto.randomBytes(24).toString('hex');
db.prepare('UPDATE users SET ical_token = ? WHERE id = ?').run(ical_token, req.user.id);
res.json({ token: ical_token });
});
router.get('/ical/:token.ics', (req, res) => {
const row = db.prepare('SELECT id FROM users WHERE ical_token = ?').get(req.params.token);
if (!row) return res.status(404).type('text/plain').send('Not found');
const uid = row.id;
const stamp = icalStamp();
const timetable = db.prepare('SELECT * FROM timetable WHERE user_id = ?').all(uid);
const homework = db.prepare("SELECT * FROM homework WHERE user_id = ? AND due_date IS NOT NULL AND done = 0").all(uid);
const classEvents = db.prepare('SELECT * FROM class_events WHERE date IS NOT NULL ORDER BY date').all();
const countdowns = db.prepare('SELECT * FROM countdowns WHERE user_id = ?').all(uid);
const L = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Klassenportal//KP//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Klassenportal',
'X-WR-CALDESC:Stundenplan und Termine',
];
for (const t of timetable) {
const byday = ICAL_BYDAY[t.day];
if (!byday) continue;
const off = ICAL_DAY_OFFSET[t.day];
const base = new Date(2024, 0, 1 + off);
const ymd = base.getFullYear().toString() +
String(base.getMonth() + 1).padStart(2, '0') +
String(base.getDate()).padStart(2, '0');
const st = (t.time_start || '08:00').replace(':', '') + '00';
const et = (t.time_end || '08:45').replace(':', '') + '00';
const summary = [t.subject, t.room].filter(Boolean).join(' · ');
L.push('BEGIN:VEVENT',
`UID:tt-${t.id}@klassenportal`,
`DTSTAMP:${stamp}`,
`DTSTART:${ymd}T${st}`,
`DTEND:${ymd}T${et}`,
`RRULE:FREQ=WEEKLY;BYDAY=${byday}`,
`SUMMARY:${icalEsc(summary || 'Unterricht')}`,
...(t.room ? [`LOCATION:${icalEsc(t.room)}`] : []),
...(t.teacher ? [`DESCRIPTION:Lehrer/in: ${icalEsc(t.teacher)}`] : []),
'END:VEVENT');
}
for (const h of homework) {
const label = h.subject ? `${h.subject}: ${h.title}` : h.title;
L.push('BEGIN:VEVENT',
`UID:hw-${h.id}@klassenportal`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${icalDate(h.due_date)}`,
`DTEND;VALUE=DATE:${icalDateNext(h.due_date)}`,
`SUMMARY:📚 ${icalEsc(label)}`,
'CATEGORIES:Hausaufgaben',
'END:VEVENT');
}
const TYPE_ICON = { pruefung:'📝', ferien:'🏖️', termin:'📅', sonstiges:'📌' };
for (const e of classEvents) {
const icon = TYPE_ICON[e.type] || '📌';
const endDate = e.date_end ? icalDateNext(e.date_end) : icalDateNext(e.date);
L.push('BEGIN:VEVENT',
`UID:ce-${e.id}@klassenportal`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${icalDate(e.date)}`,
`DTEND;VALUE=DATE:${endDate}`,
`SUMMARY:${icon} ${icalEsc(e.title)}`,
...(e.description ? [`DESCRIPTION:${icalEsc(e.description)}`] : []),
'END:VEVENT');
}
for (const c of countdowns) {
L.push('BEGIN:VEVENT',
`UID:cd-${c.id}@klassenportal`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${icalDate(c.target_date)}`,
`DTEND;VALUE=DATE:${icalDateNext(c.target_date)}`,
`SUMMARY:⏳ ${icalEsc(c.title)}`,
'END:VEVENT');
}
L.push('END:VCALENDAR');
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="klassenportal.ics"');
res.send(L.join('\r\n'));
});
// --- SESSION MANAGEMENT ---
router.post('/me/logout-other', requireAuth, (req, res) => {
const newVer = (req.user.token_version | 0) + 1;
db.prepare('UPDATE users SET token_version = ? WHERE id = ?').run(newVer, req.user.id);
const refreshed = db.prepare('SELECT id, username, role, subject, token_version FROM users WHERE id = ?').get(req.user.id);
res.cookie('token', signToken(refreshed), COOKIE_OPTIONS);
res.json({ ok: true });
});
module.exports = router; module.exports = router;