const { useState, useEffect, useRef, useCallback, useMemo } = React; const API = window.location.origin; /* ---- View Transition Wrapper ---- */ function ViewTransition({ viewKey, children }) { const [visible, setVisible] = useState(true); const [rendered, setRendered] = useState({ key: viewKey, content: children }); const prevKey = useRef(viewKey); useEffect(() => { if (viewKey !== prevKey.current) { setVisible(false); const timer = setTimeout(() => { setRendered({ key: viewKey, content: children }); setVisible(true); prevKey.current = viewKey; }, 180); return () => clearTimeout(timer); } else { setRendered({ key: viewKey, content: children }); } }, [viewKey, children]); return (
{rendered.content}
); } /* ---- Skeleton Loader ---- */ function SkeletonLoader() { return (
); } const COLORS = { red: '#ef4444', orange: '#f59e0b', green: '#22c55e', blue: '#3b82f6', purple: '#a855f7', accent: '#6366f1', cyan: '#06b6d4', pink: '#ec4899', }; const PROJECT_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#22c55e', '#3b82f6', '#a855f7', '#06b6d4', '#ef4444']; /* ---- Login Screen ---- */ function LoginScreen({ onLogin }) { const [user, setUser] = useState(''); const [pass, setPass] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const submit = async (e) => { e.preventDefault(); setLoading(true); setError(''); try { const res = await fetch(`${API}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: user, password: pass }), }); const data = await res.json(); if (data.ok) { localStorage.setItem('dashboard_token', data.token); onLogin(data.token); } else { setError('Falscher Benutzername oder Passwort'); } } catch { setError('Verbindungsfehler'); } setLoading(false); }; return (

Dashboard

setUser(e.target.value)} autoComplete="username" autoFocus /> setPass(e.target.value)} autoComplete="current-password" /> {error &&
{error}
}
); } /* ---- Root App with Auth ---- */ function Root() { const [token, setToken] = useState(localStorage.getItem('dashboard_token')); if (!token) return ; return { localStorage.removeItem('dashboard_token'); setToken(null); }} />; } function App({ token, onLogout }) { const [data, setData] = useState(null); const [view, setView] = useState("overview"); const [selectedProject, setSelectedProject] = useState(null); const [chatMessages, setChatMessages] = useState([ { role: "bot", text: "Hallo Simon! Ich bin dein Dashboard-Assistent. Frag mich was zu deinen Projekten, z.B. \"Was soll ich heute machen?\" oder \"Wie steht es um MPM?\"" } ]); const [loading, setLoading] = useState(true); const [completedToast, setCompletedToast] = useState(null); const reload = useCallback(() => { fetch(`${API}/api/dashboard`) .then(r => r.json()) .then(d => { setData(d); setLoading(false); }) .catch(() => setLoading(false)); }, []); useEffect(() => { reload(); }, [reload]); const completeTask = async (taskId, rowEl) => { if (!taskId) return; // Haptic feedback if (navigator.vibrate) navigator.vibrate(10); // Animate row out if (rowEl) { rowEl.closest('.task-row')?.classList.add('completing'); } try { await new Promise(r => setTimeout(r, 300)); // wait for animation const res = await fetch(`${API}/api/tasks/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId }), }); const result = await res.json(); if (result.ok) { setCompletedToast(result.task?.substring(0, 40) || 'Task erledigt'); setTimeout(() => setCompletedToast(null), 2500); } reload(); } catch (e) { console.error(e); } }; if (loading) return
; if (!data) return

Keine Daten.

; const { summary, projects, project_tree } = data; const urgent = projects.filter(p => p.overdue || (p.days_left !== null && p.days_left <= 7)).length; const viewTitles = { overview: 'Eisenhower Matrix', projects: 'Projekte', tasks: 'Alle Tasks', chat: 'Assistent', detail: projects.find(p => p.id === selectedProject)?.name || '', activity: 'Aktivitaet', }; return (

Dashboard

Personal Assistant
Sync: {summary.synced_at ? new Date(summary.synced_at).toLocaleString('de-CH') : 'nie'}

{viewTitles[view] || ''}

{urgent > 0 && {urgent} dringend} {summary.inbox_count > 0 && {summary.inbox_count} Inbox}
{view === 'overview' && { setSelectedProject(id); setView('detail'); }} />} {view === 'projects' && { setSelectedProject(id); setView('detail'); }} />} {view === 'tasks' && } {view === 'chat' && } {view === 'detail' && p.id === selectedProject)} onComplete={completeTask} />} {view === 'activity' && }
{completedToast && (
✓ {completedToast}
)}
); } /* ---- Mobile Bottom Bar ---- */ function MobileBar({ view, setView, setSelectedProject, taskCount, inboxCount, projects }) { const [showMore, setShowMore] = useState(false); const go = (v) => { setView(v); setSelectedProject(null); setShowMore(false); }; return ( <>
{showMore && (
{ if (e.target === e.currentTarget) setShowMore(false); }}>

Navigation

Projekte

{projects.map((p, i) => ( ))}
)} ); } /* ---- Voice / Recording Button ---- */ function VoiceButton() { const [recording, setRecording] = useState(false); const [showModal, setShowModal] = useState(false); const [status, setStatus] = useState('idle'); // idle, recording, uploading, done const [recordings, setRecordings] = useState([]); const [transcribing, setTranscribing] = useState(null); const mediaRef = useRef(null); const streamRef2 = useRef(null); const chunksRef = useRef([]); const recordStartRef = useRef(null); const loadRecordings = async () => { try { const res = await fetch(`${API}/api/recordings`); const data = await res.json(); setRecordings(data.recordings || []); } catch {} }; const startRecording = async () => { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (location.protocol !== 'https:' && location.hostname !== 'localhost') { alert('Mikrofon benoetigt HTTPS. Die App laeuft aktuell ueber HTTP.'); } else { alert('Mikrofon nicht verfuegbar auf diesem Geraet.'); } return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef2.current = stream; const mr = new MediaRecorder(stream); chunksRef.current = []; mr.ondataavailable = e => chunksRef.current.push(e.data); mr.onstop = async () => { // Release mic immediately (kills iOS orange dot) if (streamRef2.current) { streamRef2.current.getTracks().forEach(t => t.stop()); streamRef2.current = null; } const duration = (Date.now() - recordStartRef.current) / 1000; if (duration < 3) { setStatus('idle'); return; // Zu kurz — nicht speichern } const mimeType = mr.mimeType || 'audio/wav'; const ext = mimeType.includes('webm') ? 'webm' : 'wav'; const blob = new Blob(chunksRef.current, { type: mimeType }); setStatus('uploading'); const form = new FormData(); form.append('file', blob, `meeting_${Date.now()}.${ext}`); try { await fetch(`${API}/api/recordings/upload`, { method: 'POST', body: form }); setStatus('done'); loadRecordings(); } catch { setStatus('idle'); } }; mediaRef.current = mr; mr.start(); recordStartRef.current = Date.now(); setRecording(true); setStatus('recording'); if (navigator.vibrate) navigator.vibrate(10); } catch (e) { if (e.name === 'NotAllowedError') { alert('Mikrofon-Zugriff verweigert. Bitte in den Browser-Einstellungen erlauben.'); } else { alert('Mikrofon nicht verfuegbar: ' + e.message); } } }; const stopRecording = () => { if (mediaRef.current && mediaRef.current.state !== 'inactive') { mediaRef.current.stop(); } // Release mic immediately if (streamRef2.current) { streamRef2.current.getTracks().forEach(t => t.stop()); streamRef2.current = null; } setRecording(false); }; const transcribe = async (id) => { setTranscribing(id); try { await fetch(`${API}/api/recordings/${id}/transcribe`, { method: 'POST' }); loadRecordings(); } catch {} setTranscribing(null); }; const openModal = () => { setShowModal(true); setStatus('idle'); loadRecordings(); }; return ( <> {showModal && (
{ if (e.target === e.currentTarget && status !== 'recording') setShowModal(false); }}>

Meeting / Gespraech aufnehmen

{status !== 'recording' && }
{status === 'idle' && ( )} {status === 'recording' && (

Aufnahme laeuft...

)} {status === 'uploading' &&

Wird hochgeladen...

} {status === 'done' &&

✓ Hochgeladen!

} {recordings.length > 0 && (

Aufnahmen

{recordings.map(r => (
{r.filename}
{new Date(r.created_at).toLocaleString('de-CH')}
{r.status === 'transcribed' && r.transcript && (
Transkript:
{r.transcript.substring(0, 200)}{r.transcript.length > 200 ? '...' : ''}
)} {r.followups && (
Follow-ups:
{r.followups.split('\n').map((l, i) =>
{l}
)}
)}
{r.status === 'uploaded' && ( )} {r.status === 'transcribed' && Fertig}
))}
)}
)} ); } /* ---- Eisenhower Chart ---- */ function OverviewView({ projects, summary, onSelect }) { const canvasRef = useRef(null); const chartRef = useRef(null); const [mode, setMode] = useState('projects'); // 'projects' or 'tasks' const [filterProject, setFilterProject] = useState('all'); useEffect(() => { if (!canvasRef.current) return; if (chartRef.current) chartRef.current.destroy(); let points = []; if (mode === 'projects') { const maxTasks = Math.max(...projects.map(p => p.open_count), 1); points = projects.map((p, i) => { const urgency = calcUrgency(p); const radius = 10 + (p.open_count / maxTasks) * 28; const color = PROJECT_COLORS[i % PROJECT_COLORS.length]; return { x: urgency, y: p.importance, r: radius, label: shortName(p.name), color, id: p.id, info: `${p.open_count} Tasks` }; }); } else { // Tasks mode const allTasks = []; projects.forEach((proj, pi) => { if (filterProject !== 'all' && proj.id !== filterProject) return; const ids = proj.task_ids || []; proj.open_tasks.forEach((task, ti) => { allTasks.push({ text: task, projectName: proj.name, importance: proj.importance, urgency: calcUrgency(proj), color: PROJECT_COLORS[pi % PROJECT_COLORS.length], id: ids[ti], }); }); }); points = allTasks.map(t => ({ x: t.urgency + (Math.random() - 0.5) * 0.8, y: t.importance + (Math.random() - 0.5) * 0.6, r: 6, label: '', color: t.color, id: t.id, info: t.text.substring(0, 50), })); } chartRef.current = new Chart(canvasRef.current, { type: 'bubble', data: { datasets: [{ data: points, backgroundColor: points.map(p => p.color + '50'), borderColor: points.map(p => p.color), borderWidth: 2, hoverBorderWidth: 3, }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 300 }, onClick: (evt, elements) => { if (elements.length > 0 && mode === 'projects') { const idx = elements[0].index; if (points[idx].id) onSelect(points[idx].id); } }, scales: { x: { min: 0, max: 11, title: { display: true, text: 'Dringlichkeit →', color: '#6b7280', font: { size: 12 } }, ticks: { display: false }, grid: { color: '#1a1d27' }, border: { color: '#2a2e3a' }, }, y: { min: 0, max: 11, title: { display: true, text: '↑ Wichtigkeit', color: '#6b7280', font: { size: 12 } }, ticks: { display: false }, grid: { color: '#1a1d27' }, border: { color: '#2a2e3a' }, }, }, plugins: { legend: { display: false }, tooltip: { callbacks: { title: () => '', label: ctx => { const p = points[ctx.dataIndex]; return p.label ? `${p.label} — ${p.info}` : p.info; } }, backgroundColor: '#1c2029', borderColor: '#2a2e3a', borderWidth: 1, padding: 10, }, datalabels: { display: mode === 'projects', color: '#e4e4e7', font: { size: 11, weight: 500 }, anchor: 'end', align: 'top', offset: 4, formatter: (val, ctx) => points[ctx.dataIndex].label, }, }, }, plugins: [ChartDataLabels, { afterDraw: (chart) => { const { ctx, chartArea: { left, right, top, bottom } } = chart; const midX = (left + right) / 2; const midY = (top + bottom) / 2; ctx.save(); ctx.font = '600 11px system-ui'; ctx.fillStyle = '#1f2233'; ctx.textAlign = 'center'; ctx.fillText('PLANEN', (left + midX) / 2, top + 20); ctx.fillText('SOFORT', (midX + right) / 2, top + 20); ctx.fillText('ELIMINIEREN', (left + midX) / 2, bottom - 10); ctx.fillText('DELEGIEREN', (midX + right) / 2, bottom - 10); ctx.strokeStyle = '#1f2233'; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(midX, top); ctx.lineTo(midX, bottom); ctx.moveTo(left, midY); ctx.lineTo(right, midY); ctx.stroke(); ctx.restore(); } }], }); return () => { if (chartRef.current) chartRef.current.destroy(); }; }, [projects, mode, filterProject]); return ( <>
Projekte
{summary.total_projects}
Offene Tasks
{summary.total_open_tasks}
Dringend
p.overdue || (p.days_left !== null && p.days_left <= 7)).length > 0 ? COLORS.red : COLORS.green}}> {projects.filter(p => p.overdue || (p.days_left !== null && p.days_left <= 7)).length}
Inbox
{summary.inbox_count}
{mode === 'tasks' && ( <> | {projects.map((p, i) => ( ))} )}
{projects.map((p, i) => (
{shortName(p.name)}
))} {mode === 'projects' &&
Groesse = Anzahl Tasks
}
); } /* ---- Projects List ---- */ function ProjectsView({ projects, onSelect }) { return (
Projekt
Prioritaet
Deadline
Status
Tasks
{projects.map((p, i) => { const color = PROJECT_COLORS[i % PROJECT_COLORS.length]; const impLabel = p.importance >= 9 ? 'Kritisch' : p.importance >= 7 ? 'Hoch' : 'Normal'; const impClass = p.importance >= 9 ? 'badge-red' : p.importance >= 7 ? 'badge-orange' : 'badge-green'; let dueLabel = '--'; let dueClass = ''; if (p.overdue) { dueLabel = 'Ueberfaellig'; dueClass = 'badge-red'; } else if (p.days_left !== null && p.days_left <= 7) { dueLabel = p.days_left + 'd'; dueClass = 'badge-red'; } else if (p.days_left !== null && p.days_left <= 30) { dueLabel = p.days_left + 'd'; dueClass = 'badge-orange'; } else if (p.due && p.due !== 'None') { dueLabel = p.due; dueClass = 'badge-blue'; } return (
onSelect(p.id)}>
{p.name}
{impLabel} {dueLabel} {p.status} {p.open_count}
); })}
); } /* ---- Task Row with inline edit ---- */ function TaskRow({ item, onComplete, formatDate }) { const [editing, setEditing] = useState(false); const [editText, setEditText] = useState(item.task); const [saving, setSaving] = useState(false); const save = async () => { if (!editText.trim() || editText === item.task) { setEditing(false); return; } setSaving(true); try { await fetch(`${API}/api/tasks/${item.taskId}/edit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: editText.trim() }), }); item.task = editText.trim(); } catch {} setSaving(false); setEditing(false); }; return (
{ e.stopPropagation(); onComplete(item.taskId, e.target); }} title="Als erledigt markieren">
{editing ? (
setEditText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false); }} autoFocus style={{fontSize: 13, padding: '4px 8px'}} />
) : (
setEditing(true)} style={{cursor: 'text'}}>
{item.displayId && {item.displayId}} {item.task}
{item.createdAt &&
{formatDate(item.createdAt)}
}
)}
{shortName(item.projectName)}
); } /* ---- All Tasks ---- */ function TasksView({ projects, onComplete }) { const [filter, setFilter] = useState('all'); const [projectFilter, setProjectFilter] = useState('all'); const [search, setSearch] = useState(''); const [sort, setSort] = useState('newest'); // 'newest', 'oldest', 'importance' const allTasks = []; projects.forEach((proj, pi) => { const ids = proj.task_ids || []; const dids = proj.task_display_ids || []; const dates = proj.task_dates || []; proj.open_tasks.forEach((task, idx) => { allTasks.push({ task, taskId: ids[idx], displayId: dids[idx] || '', createdAt: dates[idx] || '', projectName: proj.name, projectId: proj.id, importance: proj.importance, color: PROJECT_COLORS[pi % PROJECT_COLORS.length] }); }); }); let filtered = allTasks; if (filter === 'critical') filtered = filtered.filter(t => t.importance >= 9); else if (filter === 'high') filtered = filtered.filter(t => t.importance >= 7); if (projectFilter !== 'all') filtered = filtered.filter(t => t.projectId === projectFilter); if (search) filtered = filtered.filter(t => t.task.toLowerCase().includes(search.toLowerCase())); // Sort filtered = [...filtered].sort((a, b) => { if (sort === 'newest') return (b.createdAt || '').localeCompare(a.createdAt || ''); if (sort === 'oldest') return (a.createdAt || '').localeCompare(b.createdAt || ''); return b.importance - a.importance; // importance }); const formatDate = (d) => { if (!d) return ''; try { const dt = new Date(d); const now = new Date(); const diff = Math.floor((now - dt) / 86400000); if (diff === 0) return 'Heute'; if (diff === 1) return 'Gestern'; if (diff < 7) return `vor ${diff}d`; return dt.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }); } catch { return ''; } }; return ( <>
{['all', 'critical', 'high'].map(f => ( ))} | {projects.map((p, i) => ( ))} | {[['newest', 'Neueste'], ['oldest', 'Aelteste'], ['importance', 'Wichtigkeit']].map(([s, label]) => ( ))}
setSearch(e.target.value)} />
{filtered.length} Tasks
{filtered.map((item, i) => ( ))}
); } /* ---- Project Detail ---- */ function DetailView({ project, onComplete }) { if (!project) return

Projekt nicht gefunden.

; const ids = project.task_ids || []; const dids = project.task_display_ids || []; const dates = project.task_dates || []; const formatDate = (d) => { if (!d) return ''; try { const dt = new Date(d); const now = new Date(); const diff = Math.floor((now - dt) / 86400000); if (diff === 0) return 'Heute'; if (diff === 1) return 'Gestern'; if (diff < 7) return `vor ${diff}d`; return dt.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }); } catch { return ''; } }; return ( <>

{project.name} {project.short_code && ({project.short_code})}

= 9 ? 'badge-red' : project.importance >= 7 ? 'badge-orange' : 'badge-green'}`}> Imp: {project.importance}/10 {project.status} {project.due && project.due !== 'None' && ( {project.overdue ? 'Ueberfaellig' : `${project.days_left}d (${project.due})`} )} {project.open_count} Tasks
{project.sub_projects && project.sub_projects.length > 0 && (

Sub-Projekte

{project.sub_projects.map(sp => (
{ onComplete._setProject && onComplete._setProject(sp.id); }}>
{sp.short_code}
{sp.name}
{sp.open_count} Tasks
))}
)}
{project.open_tasks.length > 0 && project.sub_projects && project.sub_projects.length > 0 && (

Direkte Tasks

)} {project.open_tasks.map((task, i) => ( ))} {project.open_tasks.length === 0 && (!project.sub_projects || project.sub_projects.length === 0) && (

Keine offenen Tasks!

)}
); } /* ---- Activity View ---- */ function ActivityView({ projects }) { const totalTasks = projects.reduce((sum, p) => sum + p.open_count, 0); const tasksByProject = projects.map((p, i) => ({ name: shortName(p.name), count: p.open_count, pct: Math.round((p.open_count / totalTasks) * 100), color: PROJECT_COLORS[i % PROJECT_COLORS.length], })); return ( <>

Task-Verteilung nach Projekt

{tasksByProject.map((p, i) => (
{p.name} {p.count} ({p.pct}%)
))}

Projekt-Steckbriefe

{projects.map((p, i) => (
{shortName(p.name)}
{p.open_count}
Imp: {p.importance}/10 {p.due && p.due !== 'None' ? ` · ${p.days_left}d` : ''}
))}

Quick Stats

Kritische Projekte
{projects.filter(p => p.importance >= 9).length}
Mit Deadline
{projects.filter(p => p.due && p.due !== 'None').length}
Ohne Deadline
{projects.filter(p => !p.due || p.due === 'None').length}
Groesstes Projekt
{projects.reduce((a, b) => a.open_count > b.open_count ? a : b).name.split(' ').slice(0, 2).join(' ')}
); } /* ---- Voice Chat Button (Speech-to-Text) ---- */ function VoiceChatButton({ onTranscript, disabled }) { const [listening, setListening] = useState(false); const [processing, setProcessing] = useState(false); const mediaRef = useRef(null); const streamRef = useRef(null); const chunksRef = useRef([]); const hasMediaDevices = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); const stopListening = useCallback(() => { // Stop MediaRecorder if (mediaRef.current && mediaRef.current.state !== 'inactive') { mediaRef.current.stop(); } // Immediately release microphone (kills iOS orange dot) if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null; } setListening(false); }, []); const startListening = async () => { if (listening) { stopListening(); return; } if (!hasMediaDevices) { alert('Mikrofon nicht verfuegbar. HTTPS wird benoetigt.'); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef.current = stream; const mr = new MediaRecorder(stream); chunksRef.current = []; mr.ondataavailable = e => chunksRef.current.push(e.data); mr.onstop = async () => { // Release mic immediately if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null; } const mimeType = mr.mimeType || 'audio/wav'; const ext = mimeType.includes('webm') ? 'webm' : 'wav'; const blob = new Blob(chunksRef.current, { type: mimeType }); setListening(false); setProcessing(true); try { const form = new FormData(); form.append('file', blob, `voice.${ext}`); const res = await fetch(`${API}/api/speech-to-text`, { method: 'POST', body: form }); const data = await res.json(); if (data.ok && data.text) { onTranscript(data.text); } else { alert('Spracherkennung fehlgeschlagen: ' + (data.error || 'Kein Text erkannt')); } } catch { alert('Fehler bei der Spracherkennung'); } setProcessing(false); }; mediaRef.current = mr; mr.start(); setListening(true); if (navigator.vibrate) navigator.vibrate(10); } catch (e) { // Release mic on error too if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null; } if (e.name === 'NotAllowedError') { alert('Mikrofon-Zugriff verweigert. Bitte in den Einstellungen erlauben.'); } else { alert('Mikrofon nicht verfuegbar: ' + e.message); } } }; return ( ); } /* ---- Chat ---- */ function ChatView({ messages, setMessages, onAction }) { const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const bottomRef = useRef(null); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); const send = async (text) => { const msg = text || input.trim(); if (!msg || sending) return; setInput(''); setMessages(prev => [...prev, { role: 'user', text: msg }]); setSending(true); try { const res = await fetch(`${API}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: msg }), }); const data = await res.json(); setMessages(prev => [...prev, { role: 'bot', text: data.reply || 'Keine Antwort.' }]); // Refresh dashboard data after bot action (task created, completed, etc.) if (onAction) onAction(); } catch { setMessages(prev => [...prev, { role: 'bot', text: 'Fehler bei der Verbindung.' }]); } setSending(false); }; return (
{messages.map((msg, i) => (
{msg.text.split('\n').map((line, j) => {line}
)}
))} {sending &&
Denke nach...
}
setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && send()} placeholder="Frag mich was..." /> { setInput(text); }} disabled={sending} />
); } /* ---- Helpers ---- */ function calcUrgency(p) { if (p.overdue) return 10; if (p.days_left === null) return 3; if (p.days_left <= 0) return 10; if (p.days_left <= 7) return 9; if (p.days_left <= 14) return 8; if (p.days_left <= 30) return 7; if (p.days_left <= 60) return 5; if (p.days_left <= 90) return 4; return 2; } function shortName(name) { const words = name.split(/[\s\-]+/); return words.length > 3 ? words.slice(0, 3).join(' ') : name; } ReactDOM.createRoot(document.getElementById("app")).render();