mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
feat: JARVIS Planner — tasks/appointments with voice intents, status bar badge, admin CRUD
This commit is contained in:
@@ -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,'"');
|
||||
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,'"')}" 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,'"');
|
||||
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,'"')}" 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,'"')}" 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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -658,6 +658,7 @@ body::after{
|
||||
<div class="tb-stat">MEM <span id="tb-mem">--</span>%</div>
|
||||
<div class="tb-stat">DO SERVER <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');
|
||||
|
||||
Reference in New Issue
Block a user