${d<0?'✓':d}
${esc(c.title)}
${fmtDate(c.target_date)}
@@ -2310,6 +2369,86 @@ async function deleteAccount(){
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='
'+subjects.map(s=>`
`).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+=`
Aktuell${subj?' ('+esc(subj)+')':''}: ${wavg.toFixed(2)} (${valid.length} Note${valid.length!==1?'n':''})
`;
+ } else {
+ html+=`
Noch keine Noten${subj?' in '+esc(subj):''}.
`;
+ }
+
+ 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=`
Bereits erreicht – mit ${GRADE_TYPES[type]} egal welche Note.`;
+ } else if(needed>6){
+ msg=`
Nicht mehr erreichbar mit einem ${GRADE_TYPES[type]}.`;
+ } else {
+ const col=needed<=2?'var(--green)':needed<=4?'var(--amber)':'var(--red)';
+ msg=`Für Schnitt
${target} beim nächsten ${GRADE_TYPES[type]}:
${needed.toFixed(2)}`;
+ }
+ html+=`
${msg}
`;
+ }
+
+ el.innerHTML=html;
+}
+
// ── CHAT ──────────────────────────────────────────────────────
let chatLastId = 0;
let chatPollTimer = null;
diff --git a/src/db.js b/src/db.js
index b7bab37..596913c 100644
--- a/src/db.js
+++ b/src/db.js
@@ -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 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 users ADD COLUMN ical_token TEXT`); } catch {}
+try { db.exec(`ALTER TABLE grades ADD COLUMN created_at TEXT DEFAULT (datetime('now'))`); } catch {}
db.exec(`
CREATE TABLE IF NOT EXISTS password_resets (
diff --git a/src/routes.js b/src/routes.js
index 4f514cb..6945e63 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -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;