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 (
{ 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 ;
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) => (
))}
{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();