feat: complete calendar integration + planner widget + 298 new KB intents

- Add calendar sync route to api.php (/api/calendar → calendar_sync.php)
- Add CALENDAR SYNC tab to admin with feed CRUD (add/edit/delete Google/ICS feeds)
- Add cal_sync_now action to admin for on-demand iCloud/Google sync
- Add cron: calendar_sync.php every 15 min (iCloud CalDAV + ICS feeds)
- Add PLANNER mini panel to index.html (left panel, shows today tasks + appointments)
- Update loadPlannerSummary() to render tasks/appts with priority dots and times
- Seed 298 new KB intents across 37 categories: science, history, tech, geography,
  math, health, food, space, philosophy, psychology, sports, music, film, travel,
  language, literature, finance, productivity, nature, facts, home automation,
  architecture, geopolitics, and more — 543 total intents
This commit is contained in:
2026-05-31 22:47:35 +00:00
parent 1c7a42f68b
commit 5d5eb2fdac
4 changed files with 473 additions and 9 deletions
+123
View File
@@ -425,6 +425,40 @@ if ($action) {
JarvisDB::execute('DELETE FROM appointments WHERE id=?',[$id]);
j(['ok'=>true]);
case 'cal_feeds_list':
j(JarvisDB::query("SELECT * FROM calendar_feeds ORDER BY source,name") ?? []);
case 'cal_feed_save':
$id = (int)($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
$source = $_POST['source'] ?? 'ics';
$ics = trim($_POST['ics_url'] ?? '');
$user = trim($_POST['username'] ?? '');
$pass = trim($_POST['password'] ?? '');
$active = (int)($_POST['active'] ?? 1);
if (!$name) bad('Name required');
if ($id) {
JarvisDB::execute("UPDATE calendar_feeds SET name=?,source=?,ics_url=?,username=?,password=?,active=? WHERE id=?",
[$name,$source,$ics,$user,$pass,$active,$id]);
j(['ok'=>true,'id'=>$id]);
} else {
$nid = JarvisDB::insert("INSERT INTO calendar_feeds(name,source,ics_url,username,password,active) VALUES(?,?,?,?,?,?)",
[$name,$source,$ics,$user,$pass,$active]);
j(['ok'=>true,'id'=>$nid]);
}
case 'cal_feed_delete':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('No id');
JarvisDB::execute("DELETE FROM calendar_feeds WHERE id=?", [$id]);
j(['ok'=>true]);
case 'cal_sync_now':
if (!class_exists('JarvisDB')) require_once __DIR__ . '/../../api/lib/db.php';
require_once __DIR__ . '/../../api/endpoints/calendar_sync.php';
$r = runSync();
j(['ok'=>true,'results'=>$r]);
case 'users_list':
j(JarvisDB::query('SELECT id,username,display_name,last_seen,created_at FROM users ORDER BY username'));
@@ -660,6 +694,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<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-item" data-tab="calendar" onclick="nav(this)">🗓 CALENDAR SYNC</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>
@@ -871,6 +906,22 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="tbl-wrap" id="appts-tbl"></div>
</div>
<!-- CALENDAR SYNC -->
<div class="tab" id="tab-calendar">
<div class="page-title">CALENDAR SYNC
<div class="actions">
<button class="btn btn-sm btn-green" onclick="calFeedModal()">+ ADD FEED</button>
<button class="btn btn-sm" onclick="syncCalNow()" id="calSyncBtn">⟳ SYNC NOW</button>
<button class="btn btn-sm" onclick="loadCalFeeds()">REFRESH</button>
</div>
</div>
<div style="font-size:0.65rem;color:var(--dim);margin-bottom:10px">
iCloud CalDAV syncs automatically every 15 min. Add Google Calendar or ICS feeds below.
<span id="cal-sync-status" style="margin-left:12px;color:var(--cyan)"></span>
</div>
<div class="tbl-wrap" id="cal-feeds-tbl"></div>
</div>
<!-- USERS -->
<div class="tab" id="tab-users">
<div class="page-title">USERS</div>
@@ -990,6 +1041,7 @@ function loadTab(tab) {
email: ()=>{ loadEmailInbox(); loadEmailActionItems(); },
tasks: loadTasks,
appointments: loadAppts,
calendar: loadCalFeeds,
})[tab]?.();
}
@@ -1925,6 +1977,77 @@ function apptSave(id){ apiPost('appt_save',{id,title:document.getElementById('am
function apptDel(id){ if(!confirm('Delete appointment?'))return; apiPost('appt_delete',{id},()=>{toast('Deleted','ok');loadAppts();}); }
// ── CALENDAR FEEDS ────────────────────────────────────────────────────────────
async function loadCalFeeds() {
const feeds = await api('cal_feeds_list');
const el = document.getElementById('cal-feeds-tbl');
if (!feeds || !feeds.length) {
el.innerHTML = '<div class="loading">No calendar feeds configured. iCloud syncs via config.php credentials.</div>';
return;
}
const srcLabel = {google:'Google',icloud:'iCloud',outlook:'Outlook',caldav:'CalDAV',ics:'ICS URL'};
el.innerHTML = `<table class="data-tbl"><thead><tr>
<th>NAME</th><th>SOURCE</th><th>ICS URL</th><th>LAST SYNC</th><th>COUNT</th><th>STATUS</th><th>ACTIONS</th>
</tr></thead><tbody>${feeds.map(f=>`<tr>
<td>${esc(f.name)}</td>
<td><span class="badge badge-dim">${srcLabel[f.source]||f.source}</span></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.6rem">${esc(f.ics_url||'—')}</td>
<td>${ts(f.last_sync)}</td>
<td>${f.last_count||0}</td>
<td>${f.active?'<span class="badge badge-green">ACTIVE</span>':'<span class="badge badge-red">PAUSED</span>'}</td>
<td><button class="btn btn-xs" onclick='calFeedModal(${JSON.stringify(f)})'>EDIT</button>
<button class="btn btn-xs btn-red" onclick="calFeedDel(${f.id})">DEL</button></td>
</tr>`).join('')}</tbody></table>`;
}
function calFeedModal(f={}) {
const id = f.id||0;
openModal(id?'EDIT CALENDAR FEED':'ADD CALENDAR FEED', `
<div class="form-row"><label>NAME</label><input id="cf-name" class="inp" value="${esc(f.name||'')}"></div>
<div class="form-row"><label>SOURCE</label>
<select id="cf-source" class="inp">
<option value="google"${f.source==='google'?' selected':''}>Google Calendar (ICS)</option>
<option value="ics"${f.source==='ics'||!f.source?' selected':''}>ICS URL</option>
<option value="caldav"${f.source==='caldav'?' selected':''}>CalDAV</option>
<option value="outlook"${f.source==='outlook'?' selected':''}>Outlook (ICS)</option>
</select></div>
<div class="form-row"><label>ICS URL</label><input id="cf-ics" class="inp" value="${esc(f.ics_url||'')}" placeholder="https://..."></div>
<div class="form-row"><label>USERNAME (optional)</label><input id="cf-user" class="inp" value="${esc(f.username||'')}"></div>
<div class="form-row"><label>PASSWORD (optional)</label><input id="cf-pass" class="inp" type="password" placeholder="leave blank to keep"></div>
<div class="form-row"><label>ACTIVE</label>
<select id="cf-active" class="inp"><option value="1"${f.active!==0?' selected':''}>Yes</option><option value="0"${f.active===0?' selected':''}>No</option></select></div>
`, () => calFeedSave(id));
}
async function calFeedSave(id) {
await apiPost('cal_feed_save', {
id, name: document.getElementById('cf-name').value,
source: document.getElementById('cf-source').value,
ics_url: document.getElementById('cf-ics').value,
username: document.getElementById('cf-user').value,
password: document.getElementById('cf-pass').value,
active: document.getElementById('cf-active').value
}, () => { toast('Saved','ok'); closeModal(); loadCalFeeds(); });
}
function calFeedDel(id) {
if (!confirm('Delete this calendar feed?')) return;
apiPost('cal_feed_delete', {id}, () => { toast('Deleted','ok'); loadCalFeeds(); });
}
async function syncCalNow() {
const btn = document.getElementById('calSyncBtn');
const status = document.getElementById('cal-sync-status');
btn.disabled = true; btn.textContent = '⟳ SYNCING...';
status.textContent = 'Syncing...';
apiPost('cal_sync_now', {}, d => {
status.textContent = d.ok ? Object.entries(d.results||{}).map(([k,v])=>k+': '+v).join(' | ') || 'Sync complete' : 'Error';
toast('Calendar sync complete','ok');
loadCalFeeds();
btn.disabled = false; btn.textContent = '⟳ SYNC NOW';
});
}
</script>
</body>
</html>
+3
View File
@@ -99,6 +99,9 @@ switch ($endpoint) {
case "planner":
require __DIR__ . '/../api/endpoints/planner.php';
break;
case "calendar":
require __DIR__ . '/../api/endpoints/calendar_sync.php';
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]);
+64 -9
View File
@@ -388,6 +388,16 @@ body::after{
}
#contextClear:hover{color:var(--red)}
/* Planner mini panel */
#plannerMiniPanel .task-item{display:flex;align-items:center;gap:6px;padding:2px 0;border-bottom:1px solid rgba(0,212,255,0.06);cursor:default}
#plannerMiniPanel .task-item:last-child{border-bottom:none}
#plannerMiniPanel .pri-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
#plannerMiniPanel .pri-urgent{background:#f44}
#plannerMiniPanel .pri-high{background:#fa0}
#plannerMiniPanel .pri-normal{background:var(--cyan)}
#plannerMiniPanel .pri-low{background:var(--text-dim)}
#plannerMiniPanel .appt-row{color:var(--text-dim);font-size:0.58rem;padding:2px 0;display:flex;gap:6px;border-bottom:1px solid rgba(0,212,255,0.04)}
#plannerMiniPanel .appt-time{color:var(--cyan);min-width:42px}
/* Clickable panel items */
.vm-card{cursor:pointer;transition:background 0.15s,border-color 0.15s}
.vm-card:hover{background:rgba(0,212,255,0.07);border-color:rgba(0,212,255,0.4)}
@@ -718,6 +728,16 @@ body::after{
</div>
</div>
<!-- PLANNER MINI PANEL -->
<div id="plannerMiniPanel" class="panel" style="flex:0 0 auto;max-height:220px;overflow:hidden;display:flex;flex-direction:column">
<div class="panel-title" style="display:flex;align-items:center;gap:8px">
PLANNER
<span id="planner-badge" style="font-size:0.55rem;color:var(--cyan);margin-left:auto"></span>
<a href="/admin" target="_blank" style="font-size:0.55rem;color:var(--text-dim);text-decoration:none;letter-spacing:1px">ADMIN ↗</a>
</div>
<div id="planner-tasks" style="overflow-y:auto;flex:1;font-size:0.62rem;line-height:1.7"></div>
</div>
<!-- CENTER: Arc Reactor + Chat -->
<div id="centerPanel">
<div id="arcReactor">
@@ -1543,15 +1563,50 @@ 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 = '';
if (el && tx) {
const tasksDue = (d.tasks_today || []).length + (d.tasks_overdue || []).length;
const appts = (d.appts_today || []).length;
if (!tasksDue && !appts) { el.style.display = 'none'; }
else {
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 = '';
}
}
// Render planner mini panel
const pEl = document.getElementById('planner-tasks');
const badge = document.getElementById('planner-badge');
if (!pEl) return;
const priClass = {urgent:'pri-urgent',high:'pri-high',normal:'pri-normal',low:'pri-low'};
const fmtTime = s => { if(!s) return ''; const d=new Date(s); return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); };
const fmtDate = s => { if(!s) return ''; const d=new Date(s+'T00:00:00'); return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); };
const tasks = [...(d.tasks_overdue||[]).map(t=>({...t,_overdue:true})), ...(d.tasks_today||[])];
const appts = d.appts_today || [];
let html = '';
if (!tasks.length && !appts.length) {
html = '<div style="color:var(--text-dim);font-size:0.6rem;padding:4px 0">No tasks or appointments today.</div>';
} else {
if (appts.length) {
html += '<div style="color:var(--cyan);font-size:0.55rem;letter-spacing:2px;margin-bottom:3px">TODAY'S SCHEDULE</div>';
html += appts.map(a => `<div class="appt-row"><span class="appt-time">${fmtTime(a.start_at)}</span><span>${a.title}</span>${a.location?'<span style="color:var(--text-dim);font-size:0.55rem"> · '+a.location+'</span>':''}</div>`).join('');
}
if (tasks.length) {
html += '<div style="color:var(--cyan);font-size:0.55rem;letter-spacing:2px;margin:5px 0 3px">TASKS DUE</div>';
html += tasks.map(t => `<div class="task-item"><span class="pri-dot ${priClass[t.priority]||'pri-normal'}"></span><span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${t.title}</span>${t._overdue?'<span style="color:#f66;font-size:0.55rem">OVERDUE</span>':''}</div>`).join('');
}
if (d.pending_count > tasks.length) {
html += `<div style="color:var(--text-dim);font-size:0.55rem;padding:3px 0">${d.pending_count} pending total</div>`;
}
}
pEl.innerHTML = html;
const total = tasks.length + appts.length;
if (badge) badge.textContent = total ? total + ' TODAY' : '';
}
// ── ALERTS ────────────────────────────────────────────────────────────