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
+283
View File
@@ -0,0 +1,283 @@
<?php
/**
* JARVIS Calendar Sync — iCloud CalDAV + Google/ICS feeds
* Fetches events, parses them, syncs into appointments table.
* Runs via cron (/usr/local/lsws/lsphp85/bin/lsphp /path/to/calendar_sync.php)
* or via HTTP: GET /api/calendar/sync
*/
$isCLI = php_sapi_name() === 'cli' || php_sapi_name() === 'litespeed';
if (!class_exists('JarvisDB')) {
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
}
// ── ICS Parser ───────────────────────────────────────────────────────────────
function parseICS(string $ics): array {
$events = [];
// Unfold lines (RFC 5545: lines folded with CRLF + whitespace)
$ics = preg_replace('/\r\n[ \t]/', '', $ics);
$ics = preg_replace('/\n[ \t]/', '', $ics);
preg_match_all('/BEGIN:VEVENT(.+?)END:VEVENT/s', $ics, $blocks);
foreach ($blocks[1] as $block) {
$e = [];
// Parse each property line
foreach (explode("\n", $block) as $line) {
$line = trim($line);
if (!$line) continue;
// Split on first colon not inside quotes
$pos = strpos($line, ':');
if ($pos === false) continue;
$prop = strtoupper(trim(substr($line, 0, $pos)));
$val = trim(substr($line, $pos + 1));
// Extract param-stripped property name (e.g. DTSTART;TZID=... → DTSTART)
$propBase = explode(';', $prop)[0];
// Extract TZID param if present
$tzid = null;
if (preg_match('/TZID=([^;:]+)/i', $prop, $tzm)) $tzid = $tzm[1];
$e[$propBase] = ['val' => $val, 'tzid' => $tzid, 'raw_prop' => $prop];
}
if (empty($e['SUMMARY']) || empty($e['UID'])) continue;
$uid = $e['UID']['val'];
$summary = calDecode($e['SUMMARY']['val']);
$desc = isset($e['DESCRIPTION']) ? calDecode($e['DESCRIPTION']['val']) : '';
$loc = isset($e['LOCATION']) ? calDecode($e['LOCATION']['val']) : '';
$startRaw = $e['DTSTART']['val'] ?? null;
$endRaw = $e['DTEND']['val'] ?? ($e['DURATION']['val'] ?? null);
if (!$startRaw) continue;
$startDt = parseCalDate($startRaw, $e['DTSTART']['tzid'] ?? null);
$endDt = $endRaw ? parseCalDate($endRaw, $e['DTEND']['tzid'] ?? null) : null;
$allDay = (strlen($startRaw) === 8); // YYYYMMDD format = all-day
if (!$startDt) continue;
// Skip events more than 1 year in the past
if (strtotime($startDt) < time() - 365 * 86400) continue;
$events[] = compact('uid','summary','desc','loc','startDt','endDt','allDay');
}
return $events;
}
function parseCalDate(string $raw, ?string $tzid): ?string {
$raw = preg_replace('/[^0-9T Z]/i', '', $raw);
// All-day: YYYYMMDD
if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $raw, $m))
return "{$m[1]}-{$m[2]}-{$m[3]} 00:00:00";
// Datetime: YYYYMMDDTHHMMSS[Z]
if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/', $raw, $m)) {
$ts = gmmktime((int)$m[4],(int)$m[5],(int)$m[6],(int)$m[2],(int)$m[3],(int)$m[1]);
// Convert to local (server) timezone
return date('Y-m-d H:i:s', $ts - date('Z'));
}
return null;
}
function calDecode(string $s): string {
// Decode quoted-printable and backslash escapes
$s = str_replace(['\\n','\\N','\\,','\;'], ["\n"," ",",",";"], $s);
return trim($s);
}
// ── CalDAV Client ─────────────────────────────────────────────────────────────
function caldavRequest(string $method, string $url, string $user, string $pass,
string $body = '', array $extraHeaders = []): array {
$ch = curl_init($url);
$headers = array_merge([
'Content-Type: text/xml; charset=utf-8',
'Prefer: return-minimal',
], $extraHeaders);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_USERPWD => "$user:$pass",
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
return ['code' => $code, 'body' => $resp ?: '', 'error' => $err];
}
function xmlVal(string $xml, string $tag): ?string {
if (preg_match('~<(?:[^:]+:)?' . preg_quote($tag, '~') . '[^>]*>(.*?)</(?:[^:]+:)?' . preg_quote($tag, '~') . '>~s', $xml, $m))
return trim(html_entity_decode(strip_tags($m[1])));
return null;
}
function fetchICloudCalendars(string $user, string $pass): array {
$results = [];
// Step 1: Discover principal
$propfind = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>';
$r = caldavRequest('PROPFIND', 'https://caldav.icloud.com/', $user, $pass, $propfind, ['Depth: 0']);
$principal = xmlVal($r['body'], 'current-user-principal');
if (!$principal) $principal = xmlVal($r['body'], 'href');
if (!$principal) return ['error' => 'Cannot discover principal: HTTP ' . $r['code']];
// Make absolute URL
if (!str_starts_with($principal, 'http')) $principal = 'https://caldav.icloud.com' . $principal;
// Step 2: Get calendar home
$propfind2 = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-home-set/></d:prop></d:propfind>';
$r2 = caldavRequest('PROPFIND', $principal, $user, $pass, $propfind2, ['Depth: 0']);
$calHome = xmlVal($r2['body'], 'calendar-home-set');
if (!$calHome) $calHome = xmlVal($r2['body'], 'href');
if (!$calHome) return ['error' => 'Cannot find calendar home'];
if (!str_starts_with($calHome, 'http')) $calHome = 'https://caldav.icloud.com' . $calHome;
// Step 3: List calendars
$propfind3 = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><c:supported-calendar-component-set/></d:prop></d:propfind>';
$r3 = caldavRequest('PROPFIND', $calHome, $user, $pass, $propfind3, ['Depth: 1']);
// Parse calendar URLs from multistatus response
preg_match_all('~<d:response>(.*?)</d:response>~s', $r3['body'], $responses);
foreach ($responses[1] as $resp) {
$href = xmlVal($resp, 'href');
$name = xmlVal($resp, 'displayname');
// Only include actual calendars (not the home itself)
if ($href && $href !== parse_url($calHome, PHP_URL_PATH) && strpos($resp, 'VEVENT') !== false) {
$calUrl = str_starts_with($href, 'http') ? $href : 'https://caldav.icloud.com' . $href;
$results[] = ['url' => $calUrl, 'name' => $name ?: 'iCloud Calendar'];
}
}
return ['calendars' => $results, 'home' => $calHome];
}
function fetchCalDAVEvents(string $calUrl, string $user, string $pass): array {
// Fetch events from 1 year ago to 2 years ahead
$from = gmdate('Ymd\THis\Z', strtotime('-1 year'));
$to = gmdate('Ymd\THis\Z', strtotime('+2 years'));
$report = <<<XML
<?xml version="1.0" encoding="utf-8" ?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/><c:calendar-data/></d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="$from" end="$to"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>
XML;
$r = caldavRequest('REPORT', $calUrl, $user, $pass, $report, ['Depth: 1', 'Content-Type: text/xml']);
if ($r['code'] !== 207) return ['error' => 'CalDAV REPORT failed: HTTP ' . $r['code']];
// Extract calendar-data from response
$allICS = '';
preg_match_all('~<(?:[^:]+:)?calendar-data[^>]*>(.*?)</(?:[^:]+:)?calendar-data>~s', $r['body'], $m);
foreach ($m[1] as $icsBlock) {
$allICS .= html_entity_decode($icsBlock) . "\n";
}
return ['events' => parseICS("BEGIN:VCALENDAR\n" . $allICS . "\nEND:VCALENDAR")];
}
// ── Sync events to DB ─────────────────────────────────────────────────────────
function syncEvents(array $events, string $source, string $feedName): array {
$inserted = $updated = $skipped = 0;
foreach ($events as $ev) {
$uid = md5($source . ':' . $ev['uid']); // deterministic per source
$existing = JarvisDB::single('SELECT id, start_at, title FROM appointments WHERE external_uid=? AND external_source=?', [$uid, $source]);
$cat = 'personal';
if (preg_match('/\b(work|meeting|office|client|project|call|conference|interview|standup|sprint)\b/i', $ev['summary'])) $cat = 'work';
if (preg_match('/\b(doctor|medical|dentist|pharmacy|hospital|clinic|appointment)\b/i', $ev['summary'])) $cat = 'medical';
if ($existing) {
// Update if start time changed
if ($existing['start_at'] !== $ev['startDt']) {
JarvisDB::execute(
'UPDATE appointments SET title=?, description=?, location=?, start_at=?, end_at=?, all_day=?, category=?, last_synced=NOW() WHERE id=?',
[$ev['summary'], $ev['desc'], $ev['loc'], $ev['startDt'], $ev['endDt'], $ev['allDay'] ? 1 : 0, $cat, $existing['id']]
);
$updated++;
} else {
$skipped++;
}
} else {
JarvisDB::execute(
'INSERT INTO appointments (title, description, location, category, start_at, end_at, all_day, external_uid, external_source, last_synced)
VALUES (?,?,?,?,?,?,?,?,?,NOW())',
[$ev['summary'], $ev['desc'], $ev['loc'], $cat, $ev['startDt'], $ev['endDt'], $ev['allDay'] ? 1 : 0, $uid, $source]
);
$inserted++;
}
}
// Remove events that no longer exist in the feed (deleted from calendar)
$syncedUids = array_map(fn($ev) => md5($source . ':' . $ev['uid']), $events);
if ($syncedUids) {
$placeholders = implode(',', array_fill(0, count($syncedUids), '?'));
JarvisDB::execute(
"DELETE FROM appointments WHERE external_source=? AND external_uid NOT IN ($placeholders) AND external_uid IS NOT NULL",
array_merge([$source], $syncedUids)
);
}
return compact('inserted', 'updated', 'skipped');
}
// ── Main sync runner ──────────────────────────────────────────────────────────
function runSync(): array {
$results = [];
// ── iCloud CalDAV ──────────────────────────────────────────────────────
if (defined('ICLOUD_USER') && ICLOUD_USER && defined('ICLOUD_PASS') && ICLOUD_PASS) {
$calInfo = fetchICloudCalendars(ICLOUD_USER, ICLOUD_PASS);
if (isset($calInfo['error'])) {
$results['icloud'] = 'Error: ' . $calInfo['error'];
} else {
$totalEvents = [];
foreach ($calInfo['calendars'] as $cal) {
$evData = fetchCalDAVEvents($cal['url'], ICLOUD_USER, ICLOUD_PASS);
if (!empty($evData['events'])) $totalEvents = array_merge($totalEvents, $evData['events']);
}
$r = syncEvents($totalEvents, 'icloud', 'iCloud');
JarvisDB::execute("UPDATE calendar_feeds SET last_sync=NOW(), last_count=? WHERE source='icloud'", [count($totalEvents)]);
$results['icloud'] = "OK: {$r['inserted']} new, {$r['updated']} updated, {$r['skipped']} unchanged (" . count($totalEvents) . " events total)";
}
}
// ── Google Calendar ICS ────────────────────────────────────────────────
$feeds = JarvisDB::query("SELECT * FROM calendar_feeds WHERE source IN ('google','ics') AND active=1") ?? [];
foreach ($feeds as $feed) {
if (!$feed['ics_url']) continue;
$ch = curl_init($feed['ics_url']);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>20, CURLOPT_FOLLOWLOCATION=>true, CURLOPT_SSL_VERIFYPEER=>true]);
if ($feed['username']) curl_setopt($ch, CURLOPT_USERPWD, $feed['username'] . ':' . $feed['password']);
$ics = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200 || !$ics) {
$results[$feed['name']] = "Error fetching ICS (HTTP $code)";
continue;
}
$events = parseICS($ics);
$r = syncEvents($events, $feed['source'] . '_' . $feed['id'], $feed['name']);
JarvisDB::execute('UPDATE calendar_feeds SET last_sync=NOW(), last_count=? WHERE id=?', [count($events), $feed['id']]);
$results[$feed['name']] = "OK: {$r['inserted']} new, {$r['updated']} updated (" . count($events) . " events)";
}
return $results;
}
// ── Entry point ───────────────────────────────────────────────────────────────
if ($isCLI) {
echo date('Y-m-d H:i:s') . " Calendar sync started\n";
$r = runSync();
foreach ($r as $src => $msg) echo " $src: $msg\n";
echo date('Y-m-d H:i:s') . " Done\n";
} else {
// HTTP endpoint
$r = runSync();
echo json_encode(['status' => 'ok', 'results' => $r, 'timestamp' => date('c')]);
}
+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]);
+57 -2
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;
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'; return; }
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 ────────────────────────────────────────────────────────────