From f122de483a016eebd6fb875c7bf02f2e9ea31280 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 31 May 2026 16:53:21 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20JARVIS=20Planner=20=E2=80=94=20tasks/ap?= =?UTF-8?q?pointments=20with=20voice=20intents,=20status=20bar=20badge,=20?= =?UTF-8?q?admin=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/endpoints/chat.php | 149 +++++++++++++++++++++++++ api/endpoints/planner.php | 159 ++++++++++++++++++++++++++ public_html/admin/index.php | 216 ++++++++++++++++++++++++++++++++++++ public_html/api.php | 3 + public_html/index.html | 18 +++ 5 files changed, 545 insertions(+) create mode 100644 api/endpoints/planner.php diff --git a/api/endpoints/chat.php b/api/endpoints/chat.php index e855be1..a6670f3 100644 --- a/api/endpoints/chat.php +++ b/api/endpoints/chat.php @@ -512,6 +512,155 @@ if (!$reply) { } } + +// ── Tier 0.7: Planner — tasks & appointments ────────────────────────────── +if (!$reply) { + $lc = strtolower($message); + + // ── Daily briefing / "what's my day" ───────────────────────────────── + if (preg_match('/\b(briefing|daily summary|my day|schedule today|what.*today|morning|good morning)\b/i', $message) + && !preg_match('/\b(weather|news|temperature)\b/i', $message)) { + $today = date('Y-m-d'); + $tasks_today = JarvisDB::query("SELECT title,priority FROM tasks WHERE due_date=? AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low')", [$today]) ?? []; + $tasks_overdue = JarvisDB::query("SELECT title FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled')", [$today]) ?? []; + $appts_today = JarvisDB::query("SELECT title,start_at FROM appointments WHERE DATE(start_at)=? ORDER BY start_at ASC", [$today]) ?? []; + + $parts = []; + if ($appts_today) { + $ap = array_map(fn($a) => $a['title'] . ' at ' . date('g:i A', strtotime($a['start_at'])), $appts_today); + $parts[] = count($appts_today) . ' appointment' . (count($appts_today)>1?'s':'') . ': ' . implode(', ', $ap); + } + if ($tasks_today) { + $tl = array_map(fn($t) => $t['title'], $tasks_today); + $parts[] = count($tasks_today) . ' task' . (count($tasks_today)>1?'s':'') . ' due: ' . implode(', ', $tl); + } + if ($tasks_overdue) { + $parts[] = count($tasks_overdue) . ' overdue item' . (count($tasks_overdue)>1?'s':''); + } + if ($parts) { + $reply = "Good morning, {$userAddr}. Today — " . implode('. ', $parts) . '.'; + } else { + $reply = "Good morning, {$userAddr}. Your schedule is clear today — nothing due or scheduled."; + } + $source = 'planner:briefing'; + } + + // ── Add task ────────────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task)\b/i', $message)) { + // Extract title — text after the trigger phrase + $title = preg_replace('/^.*(add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task)\s*/i', '', $message); + $title = trim($title, '. '); + // Check for due date: "by tomorrow", "on Monday", "due Friday" + $dueDate = null; + if (preg_match('/\b(?:by|on|due|before)\s+(.+)$/i', $title, $dm)) { + $ts = strtotime($dm[1]); + if ($ts !== false) { + $dueDate = date('Y-m-d', $ts); + $title = trim(preg_replace('/\s+(?:by|on|due|before)\s+.+$/i', '', $title)); + } + } + // Detect category + $category = preg_match('/\b(work|meeting|project|client|office)\b/i', $title) ? 'work' : 'personal'; + // Detect priority + $priority = 'normal'; + if (preg_match('/\b(urgent|asap|emergency|critical)\b/i', $title)) $priority = 'urgent'; + elseif (preg_match('/\b(important|high priority)\b/i', $title)) $priority = 'high'; + if ($title) { + JarvisDB::execute( + 'INSERT INTO tasks (title,category,priority,due_date) VALUES (?,?,?,?)', + [$title, $category, $priority, $dueDate] + ); + $duePart = $dueDate ? ', due ' . date('l, M j', strtotime($dueDate)) : ''; + $reply = "Task added: \"{$title}\"{$duePart}, {$userAddr}."; + $source = 'planner:task_add'; + } + } + + // ── List tasks ──────────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(my tasks|todo list|to.?do|pending tasks|what.*tasks|show.*tasks|task list)\b/i', $message)) { + $today = date('Y-m-d'); + $rows = JarvisDB::query( + "SELECT title,priority,due_date FROM tasks WHERE status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low'), due_date ASC LIMIT 8" + ) ?? []; + if (!$rows) { + $reply = "Your task list is clear, {$userAddr}. Nothing pending."; + } else { + $items = array_map(function($r) { + $due = $r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''; + return $r['title'] . $due; + }, $rows); + $reply = "You have " . count($rows) . " pending task" . (count($rows)>1?'s':'') . ", {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:task_list'; + } + + // ── Mark task done ──────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(mark|complete|finished|done|completed)\b.*\btask\b|\btask\b.*\b(done|complete|finished)\b/i', $message)) { + // extract keyword to search by + $search = preg_replace('/\b(mark|complete|finished|done|completed|task|as|the)\b/i', ' ', $message); + $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; + $row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]); + if ($row) { + JarvisDB::execute("UPDATE tasks SET status='done', completed_at=NOW() WHERE id=?", [$row['id']]); + $reply = "Marked \"{$row['title']}\" as complete, {$userAddr}."; + $source = 'planner:task_done'; + } + } + + // ── Add appointment ─────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(schedule|appointment|meeting|add.*calendar|book|event)\b/i', $message)) { + // Extract title and time: "schedule dentist Tuesday at 2pm" + $raw = preg_replace('/^.*(schedule|appointment|meeting|add.*calendar|book|event)\s*/i', '', $message); + $raw = trim($raw); + // Try to find a datetime in the text + $dtParsed = null; + $title = $raw; + // Look for time/date keywords + if (preg_match('/\b(tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|next\s+\w+|jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b.*?(\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?/i', $raw, $dm)) { + $dtStr = $dm[0]; + $ts = strtotime($dtStr); + if ($ts !== false && $ts > time()) { + $dtParsed = date('Y-m-d H:i:s', $ts); + $title = trim(str_replace($dtStr, '', $raw)); + } + } + if (!$dtParsed && preg_match('/\b(\d{1,2}(?::\d{2})?\s*(?:am|pm))\b/i', $raw, $tm)) { + $ts = strtotime('today ' . $tm[1]); + if ($ts !== false) { $dtParsed = date('Y-m-d H:i:s', $ts); } + } + $title = trim($title, ' .'); + if ($title && $dtParsed) { + $category = preg_match('/\b(work|meeting|office|client|project)\b/i', $title) ? 'work' : 'personal'; + JarvisDB::execute( + 'INSERT INTO appointments (title,category,start_at) VALUES (?,?,?)', + [$title, $category, $dtParsed] + ); + $reply = "Appointment scheduled: \"{$title}\" on " . date('l, M j \a\t g:i A', strtotime($dtParsed)) . ", {$userAddr}."; + $source = 'planner:appt_add'; + } elseif ($title && !$dtParsed) { + $reply = "I'll add that appointment, {$userAddr} — what date and time?"; + $source = 'planner:appt_need_time'; + } + } + + // ── View appointments / calendar ────────────────────────────────────── + if (!$reply && preg_match('/\b(appointments|calendar|my schedule|what.*scheduled|upcoming.*events?)\b/i', $message)) { + $from = date('Y-m-d'); + $to = date('Y-m-d', strtotime('+7 days')); + $rows = JarvisDB::query( + "SELECT title, start_at FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 6", + [$from, $to] + ) ?? []; + if (!$rows) { + $reply = "No appointments in the next 7 days, {$userAddr}."; + } else { + $items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j \a\t g:i A', strtotime($r['start_at'])), $rows); + $reply = "Upcoming appointments, {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:appt_list'; + } +} + // ── Tier 0.8: Weather & News intents ───────────────────────────────────── if (!$reply && preg_match('/\b(weather|forecast|temperature|temp|rain|snow|storm|outside|how.?s it (out|look)|what.?s it like outside)\b/i', $message)) { $wRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='weather' LIMIT 1"); diff --git a/api/endpoints/planner.php b/api/endpoints/planner.php new file mode 100644 index 0000000..ab3aea6 --- /dev/null +++ b/api/endpoints/planner.php @@ -0,0 +1,159 @@ + 0) ? date('Y-m-d', $ts) : null; +} +function parseNaturalDatetime(string $text): ?string { + $text = trim($text); + if (!$text) return null; + $ts = strtotime($text); + return ($ts !== false && $ts > 0) ? date('Y-m-d H:i:s', $ts) : null; +} + +// ── Route ───────────────────────────────────────────────────────────────────── +// $action from api.php: tasks | appointments | today | done | summary + +switch ($action) { + + // ── Tasks ───────────────────────────────────────────────────────────────── + case 'tasks': + if ($method === 'GET') { + $status = $_GET['status'] ?? ''; + $category = $_GET['category'] ?? ''; + $where = '1=1'; + $params = []; + if ($status) { $where .= ' AND status=?'; $params[] = $status; } + if ($category) { $where .= ' AND category=?'; $params[] = $category; } + else { $where .= " AND status NOT IN ('done','cancelled')"; } + $rows = JarvisDB::query( + "SELECT * FROM tasks WHERE {$where} ORDER BY + FIELD(priority,'urgent','high','normal','low'), due_date ASC, created_at DESC", + $params + ) ?? []; + echo json_encode(['tasks' => $rows]); + } elseif ($method === 'POST') { + $id = (int)($data['id'] ?? 0); + $title = trim($data['title'] ?? ''); + $notes = trim($data['notes'] ?? ''); + $category = $data['category'] ?? 'personal'; + $priority = $data['priority'] ?? 'normal'; + $status = $data['status'] ?? 'pending'; + $due_date = parseNaturalDate($data['due_date'] ?? ''); + $due_time = !empty($data['due_time']) ? $data['due_time'] : null; + if (!$title) { echo json_encode(['error' => 'Title required']); exit; } + if ($id) { + JarvisDB::execute( + 'UPDATE tasks SET title=?,notes=?,category=?,priority=?,status=?,due_date=?,due_time=?,updated_at=NOW() WHERE id=?', + [$title,$notes,$category,$priority,$status,$due_date,$due_time,$id] + ); + echo json_encode(['success' => true, 'id' => $id]); + } else { + $newId = JarvisDB::insert( + 'INSERT INTO tasks (title,notes,category,priority,due_date,due_time) VALUES (?,?,?,?,?,?)', + [$title,$notes,$category,$priority,$due_date,$due_time] + ); + echo json_encode(['success' => true, 'id' => $newId]); + } + } elseif ($method === 'DELETE') { + $id = (int)($_GET['id'] ?? 0); + if ($id) { JarvisDB::execute('DELETE FROM tasks WHERE id=?', [$id]); } + echo json_encode(['success' => true]); + } + break; + + // ── Mark task done ──────────────────────────────────────────────────────── + case 'done': + $id = (int)($data['id'] ?? $_GET['id'] ?? 0); + if ($id) { + JarvisDB::execute( + "UPDATE tasks SET status='done', completed_at=NOW() WHERE id=?", [$id] + ); + } + echo json_encode(['success' => true]); + break; + + // ── Appointments ────────────────────────────────────────────────────────── + case 'appointments': + if ($method === 'GET') { + $from = $_GET['from'] ?? date('Y-m-d'); + $to = $_GET['to'] ?? date('Y-m-d', strtotime('+90 days')); + $rows = JarvisDB::query( + "SELECT * FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC", + [$from, $to] + ) ?? []; + echo json_encode(['appointments' => $rows]); + } elseif ($method === 'POST') { + $id = (int)($data['id'] ?? 0); + $title = trim($data['title'] ?? ''); + $description = trim($data['description'] ?? ''); + $category = $data['category'] ?? 'personal'; + $location = trim($data['location'] ?? ''); + $all_day = (int)($data['all_day'] ?? 0); + $reminder = (int)($data['reminder_min'] ?? 30); + $start_raw = trim($data['start_at'] ?? ''); + $end_raw = trim($data['end_at'] ?? ''); + if (!$title || !$start_raw) { echo json_encode(['error' => 'Title and start time required']); exit; } + $start_at = parseNaturalDatetime($start_raw) ?? date('Y-m-d H:i:s', strtotime($start_raw)); + $end_at = $end_raw ? (parseNaturalDatetime($end_raw) ?? null) : null; + if ($id) { + JarvisDB::execute( + 'UPDATE appointments SET title=?,description=?,category=?,start_at=?,end_at=?,location=?,all_day=?,reminder_min=?,alerted=0,updated_at=NOW() WHERE id=?', + [$title,$description,$category,$start_at,$end_at,$location,$all_day,$reminder,$id] + ); + echo json_encode(['success' => true, 'id' => $id]); + } else { + $newId = JarvisDB::insert( + 'INSERT INTO appointments (title,description,category,start_at,end_at,location,all_day,reminder_min) VALUES (?,?,?,?,?,?,?,?)', + [$title,$description,$category,$start_at,$end_at,$location,$all_day,$reminder] + ); + echo json_encode(['success' => true, 'id' => $newId]); + } + } elseif ($method === 'DELETE') { + $id = (int)($_GET['id'] ?? 0); + if ($id) { JarvisDB::execute('DELETE FROM appointments WHERE id=?', [$id]); } + echo json_encode(['success' => true]); + } + break; + + // ── Today / briefing summary ────────────────────────────────────────────── + case 'today': + default: + $today = date('Y-m-d'); + $tomorrow = date('Y-m-d', strtotime('+1 day')); + + $tasks_today = JarvisDB::query( + "SELECT * FROM tasks WHERE due_date=? AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low')", + [$today] + ) ?? []; + $tasks_overdue = JarvisDB::query( + "SELECT * FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC", + [$today] + ) ?? []; + $tasks_pending = JarvisDB::query( + "SELECT COUNT(*) cnt FROM tasks WHERE status='pending' AND (due_date IS NULL OR due_date >= ?)", + [$today] + ); + $appts_today = JarvisDB::query( + "SELECT * FROM appointments WHERE DATE(start_at)=? ORDER BY start_at ASC", + [$today] + ) ?? []; + $appts_upcoming = JarvisDB::query( + "SELECT * FROM appointments WHERE start_at > NOW() ORDER BY start_at ASC LIMIT 5", + [] + ) ?? []; + + echo json_encode([ + 'date' => $today, + 'tasks_today' => $tasks_today, + 'tasks_overdue' => $tasks_overdue, + 'pending_count' => (int)($tasks_pending[0]['cnt'] ?? 0), + 'appts_today' => $appts_today, + 'appts_upcoming' => $appts_upcoming, + ]); + break; +} diff --git a/public_html/admin/index.php b/public_html/admin/index.php index d19b1fc..c3d462c 100644 --- a/public_html/admin/index.php +++ b/public_html/admin/index.php @@ -320,6 +320,68 @@ if ($action) { j(['vms'=>$pve['vms']??[],'containers'=>$pve['containers']??[],'node_info'=>$pve['node_info']??[],'node_status'=>$pve['node_status']??null,'ts'=>$raw['ts']]); // ── USERS ──────────────────────────────────────────────────────────── + case 'task_list': + $status = trim($_GET['status'] ?? ''); + $category = trim($_GET['category'] ?? ''); + $where = '1=1'; $params = []; + if ($status) { $where .= ' AND status=?'; $params[] = $status; } + if ($category) { $where .= ' AND category=?'; $params[] = $category; } + else if (!$status) { $where .= " AND status NOT IN ('done','cancelled')"; } + $rows = JarvisDB::query("SELECT * FROM tasks WHERE {$where} ORDER BY FIELD(priority,'urgent','high','normal','low'),due_date ASC,created_at DESC LIMIT 200",$params) ?? []; + j(['tasks'=>$rows]); + + case 'task_save': + $id=$_POST['id']??0; $title=trim($_POST['title']??''); + $notes=trim($_POST['notes']??''); $cat=$_POST['category']??'personal'; + $pri=$_POST['priority']??'normal'; $stat=$_POST['status']??'pending'; + $due=!empty($_POST['due_date'])?$_POST['due_date']:null; + $dtime=!empty($_POST['due_time'])?$_POST['due_time']:null; + if(!$title) bad('Title required'); + if($id){ + JarvisDB::execute('UPDATE tasks SET title=?,notes=?,category=?,priority=?,status=?,due_date=?,due_time=?,updated_at=NOW() WHERE id=?',[$title,$notes,$cat,$pri,$stat,$due,$dtime,$id]); + j(['ok'=>true,'id'=>(int)$id]); + } else { + $newId=JarvisDB::insert('INSERT INTO tasks(title,notes,category,priority,status,due_date,due_time)VALUES(?,?,?,?,?,?,?)',[$title,$notes,$cat,$pri,$stat,$due,$dtime]); + j(['ok'=>true,'id'=>$newId]); + } + + case 'task_done': + $id=(int)($_POST['id']??0); if(!$id) bad('No id'); + JarvisDB::execute("UPDATE tasks SET status='done',completed_at=NOW() WHERE id=?",[$id]); + j(['ok'=>true]); + + case 'task_delete': + $id=(int)($_POST['id']??0); if(!$id) bad('No id'); + JarvisDB::execute('DELETE FROM tasks WHERE id=?',[$id]); + j(['ok'=>true]); + + case 'appt_list': + $from=$_GET['from']??date('Y-m-d'); $to=$_GET['to']??date('Y-m-d',strtotime('+90 days')); + $rows=JarvisDB::query("SELECT * FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 200",[$from,$to]) ?? []; + j(['appointments'=>$rows]); + + case 'appt_save': + $id=$_POST['id']??0; $title=trim($_POST['title']??''); $desc=trim($_POST['description']??''); + $cat=$_POST['category']??'personal'; $loc=trim($_POST['location']??''); + $all_day=(int)($_POST['all_day']??0); $rem=(int)($_POST['reminder_min']??30); + $start=trim($_POST['start_at']??''); $end=trim($_POST['end_at']??''); + if(!$title||!$start) bad('Title and start required'); + $ts=strtotime($start); if(!$ts) bad('Invalid start datetime'); + $startDt=date('Y-m-d H:i:s',$ts); + $endDt=($end&&strtotime($end))?date('Y-m-d H:i:s',strtotime($end)):null; + if($id){ + JarvisDB::execute('UPDATE appointments SET title=?,description=?,category=?,start_at=?,end_at=?,location=?,all_day=?,reminder_min=?,alerted=0,updated_at=NOW() WHERE id=?',[$title,$desc,$cat,$startDt,$endDt,$loc,$all_day,$rem,$id]); + j(['ok'=>true,'id'=>(int)$id]); + } else { + $newId=JarvisDB::insert('INSERT INTO appointments(title,description,category,start_at,end_at,location,all_day,reminder_min)VALUES(?,?,?,?,?,?,?,?)',[$title,$desc,$cat,$startDt,$endDt,$loc,$all_day,$rem]); + j(['ok'=>true,'id'=>$newId]); + } + + case 'appt_delete': + $id=(int)($_POST['id']??0); if(!$id) bad('No id'); + JarvisDB::execute('DELETE FROM appointments WHERE id=?',[$id]); + j(['ok'=>true]); + case 'users_list': j(JarvisDB::query('SELECT id,username,display_name,last_seen,created_at FROM users ORDER BY username')); @@ -550,6 +612,9 @@ select.filter-sel:focus{border-color:var(--cyan)} + + + @@ -707,6 +772,37 @@ select.filter-sel:focus{border-color:var(--cyan)}
SCANNING...
+ +
+
TASKS +
+ + + + +
+
+
+
+ + +
+
APPOINTMENTS +
+ + +
+
+
+
+
USERS
@@ -823,6 +919,8 @@ function loadTab(tab) { vms: loadVMs, sites: loadSites, users: loadUsers, + tasks: loadTasks, + appointments: loadAppts, })[tab]?.(); } @@ -1560,6 +1658,124 @@ document.getElementById('app').style.display='flex'; document.getElementById('adminUser').textContent = ''.toUpperCase(); initApp(); +// ── PLANNER ───────────────────────────────────────────────────────────────── +const _PRI_COLOR = {urgent:'var(--red)',high:'var(--orange)',normal:'var(--text)',low:'var(--border2)'}; + +async function loadTasks() { + const status = document.getElementById('task-status-filter')?.value || ''; + const cat = document.getElementById('task-cat-filter')?.value || ''; + const d = await api('task_list', {status, category:cat}); + const tasks = d.tasks || []; + const el = document.getElementById('tasks-tbl'); + if (!tasks.length) { el.innerHTML='
No tasks found.
'; return; } + const rows = tasks.map(t => { + const due = t.due_date ? `${t.due_date}` : '—'; + const pri = `${t.priority.toUpperCase()}`; + const done = t.status==='done'||t.status==='cancelled'; + const doneBtnHtml = done ? '' : ` `; + const td = `style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap${done?';opacity:0.45;text-decoration:line-through':''}"`; + const tJson = JSON.stringify(t).replace(/"/g,'"'); + return `${esc(t.title)}${t.category}${pri}${due} + ${t.status.replace('_',' ').toUpperCase()} + ${doneBtnHtml} `; + }).join(''); + el.innerHTML = `${rows}
TITLECATPRIDUESTATUSACTIONS
`; +} + +function taskModal(t={}) { + const id = t.id||0; + document.body.insertAdjacentHTML('beforeend',``); +} + +function taskSave(id) { + apiPost('task_save',{id,title:document.getElementById('tm-title').value,notes:document.getElementById('tm-notes').value, + category:document.getElementById('tm-cat').value,priority:document.getElementById('tm-pri').value, + status:document.getElementById('tm-stat').value,due_date:document.getElementById('tm-due').value}, + ()=>{ document.querySelector('.modal-overlay')?.remove(); toast('Saved','ok'); loadTasks(); }); +} +function taskDone(id){ apiPost('task_done',{id},()=>{toast('Marked done','ok');loadTasks();}); } +function taskDel(id){ if(!confirm('Delete task?'))return; apiPost('task_delete',{id},()=>{toast('Deleted','ok');loadTasks();}); } + +async function loadAppts() { + const from = new Date().toISOString().slice(0,10); + const to = new Date(Date.now()+90*86400000).toISOString().slice(0,10); + const d = await api('appt_list', {from, to}); + const appts = d.appointments || []; + const el = document.getElementById('appts-tbl'); + if (!appts.length) { el.innerHTML='
No upcoming appointments.
'; return; } + const rows = appts.map(a => { + const start = new Date(a.start_at).toLocaleString('en-US',{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); + const aJson = JSON.stringify(a).replace(/"/g,'"'); + return `${esc(a.title)} + ${a.category}${start} + ${esc(a.location||'—')} + `; + }).join(''); + el.innerHTML = `${rows}
TITLECATSTARTLOCATIONACTIONS
`; +} + +function apptModal(a={}) { + const id=a.id||0; + const sv=a.start_at?a.start_at.slice(0,16):''; const ev=a.end_at?a.end_at.slice(0,16):''; + document.body.insertAdjacentHTML('beforeend',``); +} + +function apptSave(id){ apiPost('appt_save',{id,title:document.getElementById('am-title').value,description:document.getElementById('am-desc').value, + category:document.getElementById('am-cat').value,location:document.getElementById('am-loc').value, + start_at:document.getElementById('am-start').value,end_at:document.getElementById('am-end').value}, + ()=>{ document.querySelector('.modal-overlay')?.remove(); toast('Saved','ok'); loadAppts(); }); } +function apptDel(id){ if(!confirm('Delete appointment?'))return; apiPost('appt_delete',{id},()=>{toast('Deleted','ok');loadAppts();}); } + + diff --git a/public_html/api.php b/public_html/api.php index 67fc938..564848e 100644 --- a/public_html/api.php +++ b/public_html/api.php @@ -96,6 +96,9 @@ switch ($endpoint) { case "agent": require __DIR__ . '/../api/endpoints/agent.php'; break; + case "planner": + require __DIR__ . '/../api/endpoints/planner.php'; + break; default: http_response_code(404); echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]); diff --git a/public_html/index.html b/public_html/index.html index 3e03815..4967592 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -658,6 +658,7 @@ body::after{
MEM --%
DO SERVER --
NO ALERTS
+
@@ -1202,6 +1203,7 @@ async function refreshAll() { try { await loadAlerts(); } catch(e) {} try { await loadAgents(); } catch(e) {} try { await loadProxmox(); } catch(e) {} + try { await loadPlannerSummary(); } catch(e) {} } // Refresh weather + news every 18th tick (~3 min — cache updates every 30 min) if (_refreshTick % 18 === 0) { @@ -1521,6 +1523,22 @@ async function toggleHA(entityId, domain, currentState) { } catch(e) {} } +// ── PLANNER SUMMARY (top bar badge only) ───────────────────────────────── +async function loadPlannerSummary() { + const d = await api('planner/today'); + const el = document.getElementById('tb-planner'); + const tx = document.getElementById('tb-planner-text'); + if (!el || !tx) return; + const tasksDue = (d.tasks_today || []).length + (d.tasks_overdue || []).length; + const appts = (d.appts_today || []).length; + if (!tasksDue && !appts) { el.style.display = 'none'; return; } + const parts = []; + if (tasksDue) parts.push(tasksDue + ' TASK' + (tasksDue > 1 ? 'S' : '')); + if (appts) parts.push(appts + ' APPT' + (appts > 1 ? 'S' : '')); + tx.textContent = parts.join(' · '); + el.style.display = ''; +} + // ── ALERTS ──────────────────────────────────────────────────────────── async function loadAlerts() { const data = await api('alerts');