diff --git a/public/app.html b/public/app.html index bb1df9c..836b4dc 100644 --- a/public/app.html +++ b/public/app.html @@ -551,6 +551,20 @@ main { .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-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 ────────────────────────────────────────────── */ .ab-item { @@ -596,6 +610,9 @@ main { .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.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-title { font-size: 13px; font-weight: 500; color: var(--text); } .cd-date { font-size: 11px; color: var(--text-muted); margin-top: 1px; } @@ -1108,11 +1125,31 @@ footer {
Noten
+
-
+
+
+
Notenrechner
+
+ + + +
+
+
+
+
+
+
Kalender-Export (iCal)
+

Abonniere deinen Stundenplan, Hausaufgaben und Events in Google Calendar, Apple Calendar oder Outlook.

+
+ + +
+ +
+ +
+
Sitzungsverwaltung
+

Alle anderen Geräte und Browser werden sofort abgemeldet. Du bleibst eingeloggt.

+ +
+

Account und alle gespeicherten Daten werden unwiderruflich gelöscht.

@@ -1902,6 +1958,8 @@ function renderHW(data){ // ── GRADES ──────────────────────────────────────────────────── function renderGrades(data){ + gradeCalcData=data; + if(document.getElementById('grade-calc-panel')?.classList.contains('open')) updateGradeCalc(); const el=document.getElementById('list-grades'); if(!data.length){el.innerHTML='
Keine Noten eingetragen
';return;} const rows=data.map(g=>{ @@ -2012,7 +2070,8 @@ function renderCountdowns(data){ if(!data.length){el.innerHTML='
Keine Countdowns
';return;} el.innerHTML=[...data].sort((a,b)=>(a.target_date||'').localeCompare(b.target_date||'')).map(c=>{ const d=daysUntil(c.target_date); - return `
+ const urgent=d>=0&&d<=2?' urgent':''; + return `
${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;