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-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 (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 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;
|
||||
|
||||
Reference in New Issue
Block a user