feat: JARVIS Planner — tasks/appointments with voice intents, status bar badge, admin CRUD

This commit is contained in:
2026-05-31 16:53:21 +00:00
parent 02d62cbe53
commit f122de483a
5 changed files with 545 additions and 0 deletions
+149
View File
@@ -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");
+159
View File
@@ -0,0 +1,159 @@
<?php
// JARVIS Planner — tasks, appointments, daily briefing
// ── Helpers ──────────────────────────────────────────────────────────────────
function parseNaturalDate(string $text): ?string {
$text = trim($text);
if (!$text || strtolower($text) === 'none') return null;
$ts = strtotime($text);
return ($ts !== false && $ts > 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;
}
+216
View File
@@ -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)}
<div class="nav-item" data-tab="ha" onclick="nav(this)">HOME ASSISTANT</div>
<div class="nav-item" data-tab="news" onclick="nav(this)">NEWS</div>
<div class="nav-item" data-tab="vms" onclick="nav(this)">PROXMOX VMs</div>
<div class="nav-section">PLANNER</div>
<div class="nav-item" data-tab="tasks" onclick="nav(this)">📋 TASKS</div>
<div class="nav-item" data-tab="appointments" onclick="nav(this)">📅 APPOINTMENTS</div>
<div class="nav-section">INFO</div>
<div class="nav-item" data-tab="sites" onclick="nav(this)">SITES</div>
<div class="nav-item" data-tab="users" onclick="nav(this)">USERS</div>
@@ -707,6 +772,37 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div id="sites-content"><div class="loading">SCANNING...</div></div>
</div>
<!-- TASKS -->
<div class="tab" id="tab-tasks">
<div class="page-title">TASKS
<div class="actions">
<select id="task-status-filter" onchange="loadTasks()" class="filter-sel">
<option value="">ACTIVE</option><option value="pending">PENDING</option>
<option value="in_progress">IN PROGRESS</option><option value="done">DONE</option>
<option value="cancelled">CANCELLED</option>
</select>
<select id="task-cat-filter" onchange="loadTasks()" class="filter-sel">
<option value="">ALL CATEGORIES</option><option value="personal">PERSONAL</option>
<option value="work">WORK</option><option value="todo">TODO</option>
</select>
<button class="btn btn-sm btn-green" onclick="taskModal()">+ ADD TASK</button>
<button class="btn btn-sm" onclick="loadTasks()">REFRESH</button>
</div>
</div>
<div class="tbl-wrap" id="tasks-tbl"></div>
</div>
<!-- APPOINTMENTS -->
<div class="tab" id="tab-appointments">
<div class="page-title">APPOINTMENTS
<div class="actions">
<button class="btn btn-sm btn-green" onclick="apptModal()">+ ADD APPOINTMENT</button>
<button class="btn btn-sm" onclick="loadAppts()">REFRESH</button>
</div>
</div>
<div class="tbl-wrap" id="appts-tbl"></div>
</div>
<!-- USERS -->
<div class="tab" id="tab-users">
<div class="page-title">USERS</div>
@@ -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();
<?php endif; ?>
// ── 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='<div class="loading">No tasks found.</div>'; return; }
const rows = tasks.map(t => {
const due = t.due_date ? `<span style="color:${new Date(t.due_date)<new Date()?'var(--red)':'var(--text-dim)'}">${t.due_date}</span>` : '—';
const pri = `<span style="color:${_PRI_COLOR[t.priority]||'var(--text)'};font-size:0.6rem">${t.priority.toUpperCase()}</span>`;
const done = t.status==='done'||t.status==='cancelled';
const doneBtnHtml = done ? '' : `<button class="btn btn-xs btn-green" onclick="taskDone(${t.id})">DONE</button> `;
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,'&quot;');
return `<tr><td ${td}>${esc(t.title)}</td><td>${t.category}</td><td>${pri}</td><td>${due}</td>
<td style="font-size:0.6rem;color:var(--text-dim)">${t.status.replace('_',' ').toUpperCase()}</td>
<td style="white-space:nowrap">${doneBtnHtml}<button class="btn btn-xs" onclick='taskModal(${tJson})'>EDIT</button> <button class="btn btn-xs btn-red" onclick="taskDel(${t.id})">DEL</button></td></tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>TITLE</th><th>CAT</th><th>PRI</th><th>DUE</th><th>STATUS</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function taskModal(t={}) {
const id = t.id||0;
document.body.insertAdjacentHTML('beforeend',`<div class="modal-overlay" onclick="this.remove()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-title">${id?'EDIT':'NEW'} TASK</div>
<label>TITLE *<input id="tm-title" value="${(t.title||'').replace(/"/g,'&quot;')}" style="width:100%;margin-top:4px"></label>
<label style="margin-top:8px;display:block">NOTES<textarea id="tm-notes" rows="2" style="width:100%;margin-top:4px;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:6px;resize:vertical">${esc(t.notes||'')}</textarea></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
<label>CATEGORY<select id="tm-cat" style="width:100%;margin-top:4px">
<option value="personal"${t.category==='personal'?' selected':''}>Personal</option>
<option value="work"${t.category==='work'?' selected':''}>Work</option>
<option value="todo"${t.category==='todo'?' selected':''}>Todo</option>
</select></label>
<label>PRIORITY<select id="tm-pri" style="width:100%;margin-top:4px">
<option value="low"${t.priority==='low'?' selected':''}>Low</option>
<option value="normal"${!t.priority||t.priority==='normal'?' selected':''}>Normal</option>
<option value="high"${t.priority==='high'?' selected':''}>High</option>
<option value="urgent"${t.priority==='urgent'?' selected':''}>Urgent</option>
</select></label>
<label>DUE DATE<input type="date" id="tm-due" value="${t.due_date||''}" style="width:100%;margin-top:4px"></label>
<label>STATUS<select id="tm-stat" style="width:100%;margin-top:4px">
<option value="pending"${!t.status||t.status==='pending'?' selected':''}>Pending</option>
<option value="in_progress"${t.status==='in_progress'?' selected':''}>In Progress</option>
<option value="done"${t.status==='done'?' selected':''}>Done</option>
<option value="cancelled"${t.status==='cancelled'?' selected':''}>Cancelled</option>
</select></label>
</div>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="this.closest('.modal-overlay').remove()">CANCEL</button>
<button class="btn btn-green" onclick="taskSave(${id})">SAVE</button>
</div>
</div></div>`);
}
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='<div class="loading">No upcoming appointments.</div>'; 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,'&quot;');
return `<tr><td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.title)}</td>
<td>${a.category}</td><td>${start}</td>
<td style="max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-dim)">${esc(a.location||'—')}</td>
<td style="white-space:nowrap"><button class="btn btn-xs" onclick='apptModal(${aJson})'>EDIT</button> <button class="btn btn-xs btn-red" onclick="apptDel(${a.id})">DEL</button></td></tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>TITLE</th><th>CAT</th><th>START</th><th>LOCATION</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
}
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',`<div class="modal-overlay" onclick="this.remove()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-title">${id?'EDIT':'NEW'} APPOINTMENT</div>
<label>TITLE *<input id="am-title" value="${(a.title||'').replace(/"/g,'&quot;')}" style="width:100%;margin-top:4px"></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
<label>START *<input type="datetime-local" id="am-start" value="${sv}" style="width:100%;margin-top:4px"></label>
<label>END<input type="datetime-local" id="am-end" value="${ev}" style="width:100%;margin-top:4px"></label>
<label>CATEGORY<select id="am-cat" style="width:100%;margin-top:4px">
<option value="personal"${!a.category||a.category==='personal'?' selected':''}>Personal</option>
<option value="work"${a.category==='work'?' selected':''}>Work</option>
<option value="medical"${a.category==='medical'?' selected':''}>Medical</option>
<option value="other"${a.category==='other'?' selected':''}>Other</option>
</select></label>
<label>LOCATION<input id="am-loc" value="${(a.location||'').replace(/"/g,'&quot;')}" placeholder="optional" style="width:100%;margin-top:4px"></label>
</div>
<label style="margin-top:8px;display:block">NOTES<textarea id="am-desc" rows="2" style="width:100%;margin-top:4px;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:6px;resize:vertical">${esc(a.description||'')}</textarea></label>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="this.closest('.modal-overlay').remove()">CANCEL</button>
<button class="btn btn-green" onclick="apptSave(${id})">SAVE</button>
</div>
</div></div>`);
}
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();}); }
</script>
</body>
</html>
+3
View File
@@ -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]);
+18
View File
@@ -658,6 +658,7 @@ body::after{
<div class="tb-stat">MEM&nbsp;<span id="tb-mem">--</span>%</div>
<div class="tb-stat">DO SERVER&nbsp;<span id="tb-do" class="text-dim">--</span></div>
<div class="tb-stat"><span id="tb-alerts" class="text-green">NO ALERTS</span></div>
<div class="tb-stat" id="tb-planner" style="display:none"><span id="tb-planner-text" class="text-yellow"></span></div>
</div>
<div class="tb-right">
<div>
@@ -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');