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)}
HOME ASSISTANT
NEWS
PROXMOX VMs
+ PLANNER
+ 📋 TASKS
+ 📅 APPOINTMENTS
INFO
SITES
USERS
@@ -707,6 +772,37 @@ select.filter-sel:focus{border-color:var(--cyan)}
+
+
+
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 = '= htmlspecialchars($_SESSION['admin_name'] ?? $_SESSION['admin_user']) ?>'.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 = `
| TITLE | CAT | PRI | DUE | STATUS | ACTIONS |
${rows}
`;
+}
+
+function taskModal(t={}) {
+ const id = t.id||0;
+ document.body.insertAdjacentHTML('beforeend',`
+
+
${id?'EDIT':'NEW'} TASK
+
+
+
+
+
+
+
+
+
+
+
+
+
`);
+}
+
+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 = `
| TITLE | CAT | START | LOCATION | ACTIONS |
${rows}
`;
+}
+
+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();}); }
+
+