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-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 {
<div class="card-head">
<div class="card-title"><i data-lucide="graduation-cap" aria-hidden="true"></i> Noten</div>
<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="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</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 class="card ov-card" id="card-files" style="display:none">
@@ -1594,6 +1631,25 @@ footer {
</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="danger-zone">
<p>Account und alle gespeicherten Daten werden <strong>unwiderruflich gelöscht</strong>.</p>
@@ -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='<div class="empty">Keine Noten eingetragen</div>';return;}
const rows=data.map(g=>{
@@ -2012,7 +2070,8 @@ function renderCountdowns(data){
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=>{
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-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>
@@ -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='<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 ──────────────────────────────────────────────────────
let chatLastId = 0;
let chatPollTimer = null;