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:
+141
-2
@@ -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 (1–6)" 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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user