From 18649c47df461388c5252a633022d8098798be30 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 31 May 2026 19:12:06 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20full=20voice=20intents=20=E2=80=94=20ov?= =?UTF-8?q?erdue/priority/week=20tasks,=20next=20appt,=20reschedule,=20can?= =?UTF-8?q?cel,=20week=20calendar,=20email=E2=86=92planner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/endpoints/chat.php | 251 ++++++++++++++++++++++++++++------------- 1 file changed, 170 insertions(+), 81 deletions(-) diff --git a/api/endpoints/chat.php b/api/endpoints/chat.php index 98cb031..f303a56 100644 --- a/api/endpoints/chat.php +++ b/api/endpoints/chat.php @@ -560,91 +560,125 @@ if (!$reply) { } -// ── Tier 0.7: Planner — tasks & appointments ────────────────────────────── +// ── Tier 0.7: Planner — full voice intent coverage ──────────────────────── if (!$reply) { - $lc = strtolower($message); + $lc = strtolower($message); + $today = date('Y-m-d'); - // ── 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'); + // ── Daily briefing ──────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(briefing|daily summary|my day|schedule today|what.*today|morning|good morning|what do i have|what.?s on)\b/i', $message) + && !preg_match('/\b(weather|news|temperature|forecast)\b/i', $message)) { $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]) ?? []; + $tasks_overdue = JarvisDB::query("SELECT COUNT(*) cnt 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]) ?? []; - + $email_actions = JarvisDB::single("SELECT COUNT(*) cnt FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL"); $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); + $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."; + $parts[] = count($tasks_today) . ' task' . (count($tasks_today) > 1 ? 's' : '') . ' due today: ' . implode(', ', $tl); } + $ov = (int)($tasks_overdue['cnt'] ?? 0); + if ($ov > 0) $parts[] = $ov . ' overdue task' . ($ov > 1 ? 's' : '') . ' need attention'; + $ai = (int)($email_actions['cnt'] ?? 0); + if ($ai > 0) $parts[] = $ai . ' email' . ($ai > 1 ? 's' : '') . ' require action'; + $reply = $parts + ? "Good morning, {$userAddr}. " . implode('. ', $parts) . '.' + : "Good morning, {$userAddr}. Your schedule is clear — no tasks, appointments, or email actions pending today."; $source = 'planner:briefing'; } + // ── Overdue tasks ───────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(overdue|past due|late|missed.*task|task.*overdue)\b/i', $message)) { + $rows = JarvisDB::query("SELECT title, due_date FROM tasks WHERE due_date < ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC LIMIT 6", [$today]) ?? []; + if (!$rows) { + $reply = "No overdue tasks, {$userAddr}. You're all caught up."; + } else { + $items = array_map(fn($r) => $r['title'] . ' (was due ' . date('M j', strtotime($r['due_date'])) . ')', $rows); + $reply = count($rows) . " overdue task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:overdue'; + } + + // ── Urgent / high priority tasks ────────────────────────────────────── + if (!$reply && preg_match('/\b(urgent|high priority|important|critical|asap)\b.*\btask|\btask.*\b(urgent|high priority|important|critical)\b/i', $message)) { + $rows = JarvisDB::query("SELECT title, priority, due_date FROM tasks WHERE priority IN ('urgent','high') AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high'), due_date ASC LIMIT 6") ?? []; + if (!$rows) { + $reply = "No urgent or high priority tasks at the moment, {$userAddr}."; + } else { + $items = array_map(fn($r) => '[' . strtoupper($r['priority']) . '] ' . $r['title'], $rows); + $reply = count($rows) . " priority task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:priority_tasks'; + } + + // ── Tasks due this week ─────────────────────────────────────────────── + if (!$reply && preg_match('/\b(this week|week.?s tasks|due this week|tasks.*week)\b/i', $message)) { + $endOfWeek = date('Y-m-d', strtotime('sunday this week')); + $rows = JarvisDB::query("SELECT title, due_date, priority FROM tasks WHERE due_date BETWEEN ? AND ? AND status NOT IN ('done','cancelled') ORDER BY due_date ASC, FIELD(priority,'urgent','high','normal','low') LIMIT 8", [$today, $endOfWeek]) ?? []; + if (!$rows) { + $reply = "Nothing due this week, {$userAddr}."; + } else { + $items = array_map(fn($r) => $r['title'] . ' (' . date('D', strtotime($r['due_date'])) . ')', $rows); + $reply = count($rows) . " task" . (count($rows) > 1 ? 's' : '') . " due this week, {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:week_tasks'; + } + + // ── Work tasks ──────────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(work tasks|work.*todo|office tasks|work related)\b/i', $message)) { + $rows = JarvisDB::query("SELECT title, priority, due_date FROM tasks WHERE category='work' AND status NOT IN ('done','cancelled') ORDER BY FIELD(priority,'urgent','high','normal','low'), due_date ASC LIMIT 6") ?? []; + if (!$rows) { + $reply = "No work tasks pending, {$userAddr}."; + } else { + $items = array_map(fn($r) => $r['title'] . ($r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''), $rows); + $reply = count($rows) . " work task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:work_tasks'; + } + // ── 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); + if (!$reply && preg_match('/\b(add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task|put.*task|add.*to.*list)\b/i', $message)) { + $title = preg_replace('/^.*(add task|remind me to|todo:|to do:|i need to|don.?t forget to|create task|new task|put.*task|add.*to.*list)\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)); + $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 (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] - ); + 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'; + $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 (!$reply && preg_match('/\b(my tasks|todo list|to.?do|pending tasks|what.*tasks|show.*tasks|task list|how many tasks|task count)\b/i', $message)) { + $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) . '.'; + $items = array_map(fn($r) => $r['title'] . ($r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''), $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); + if (!$reply && preg_match('/\b(mark|complete|finished|done with|completed|check off|close)\b.{1,40}\btask\b|\btask\b.{1,20}\b(done|complete|finished)\b/i', $message)) { + $search = preg_replace('/\b(mark|complete|finished|done|completed|task|as|the|check|off|close|with)\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) { @@ -654,55 +688,110 @@ if (!$reply) { } } - // ── 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)); + // ── Delete / cancel task ────────────────────────────────────────────── + if (!$reply && preg_match('/\b(delete task|remove task|cancel task|drop task)\b/i', $message)) { + $search = preg_replace('/\b(delete|remove|cancel|drop|task)\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='cancelled' WHERE id=?", [$row['id']]); + $reply = "Task \"{$row['title']}\" cancelled, {$userAddr}."; + $source = 'planner:task_cancel'; + } + } + + // ── Reschedule / move task ──────────────────────────────────────────── + if (!$reply && preg_match('/\b(reschedule|move|push|change due|update due)\b.*\btask\b/i', $message)) { + if (preg_match('/\bto\s+(.+)$/i', $message, $dm)) { + $ts = strtotime($dm[1]); + if ($ts !== false) { + $newDate = date('Y-m-d', $ts); + $search = preg_replace('/\b(reschedule|move|push|change|update|due|task|to\s+.+)$/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 due_date=? WHERE id=?", [$newDate, $row['id']]); + $reply = "Moved \"{$row['title']}\" to " . date('l, M j', $ts) . ", {$userAddr}."; + $source = 'planner:task_reschedule'; + } } } - 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); } + } + + // ── Next appointment ────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(next appointment|next meeting|next event|when.*next|upcoming appointment)\b/i', $message)) { + $row = JarvisDB::single("SELECT title, start_at, location FROM appointments WHERE start_at > NOW() ORDER BY start_at ASC LIMIT 1"); + if ($row) { + $when = date('l, M j \a\t g:i A', strtotime($row['start_at'])); + $locPart = $row['location'] ? ' at ' . $row['location'] : ''; + $reply = "Your next appointment is \"{$row['title']}\"{$locPart} on {$when}, {$userAddr}."; + } else { + $reply = "No upcoming appointments on your calendar, {$userAddr}."; + } + $source = 'planner:next_appt'; + } + + // ── Week / date range calendar view ────────────────────────────────── + if (!$reply && preg_match('/\b(this week|week.*calendar|calendar.*week|appointments.*week|what.*week)\b/i', $message) + && !preg_match('/\btasks\b/i', $message)) { + $endOfWeek = date('Y-m-d', strtotime('sunday this week')); + $rows = JarvisDB::query("SELECT title, start_at FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 8", [$today, $endOfWeek]) ?? []; + if (!$rows) { + $reply = "Nothing on your calendar this week, {$userAddr}."; + } else { + $items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j g:ia', strtotime($r['start_at'])), $rows); + $reply = count($rows) . " appointment" . (count($rows) > 1 ? 's' : '') . " this week, {$userAddr}: " . implode('; ', $items) . '.'; + } + $source = 'planner:week_calendar'; + } + + // ── Add appointment ─────────────────────────────────────────────────── + if (!$reply && preg_match('/\b(schedule|appointment|add.*calendar|book|set up.*meeting|add.*meeting)\b/i', $message) + && !preg_match('/\b(my calendar|upcoming|list|show|what)\b/i', $message)) { + $raw = preg_replace('/^.*(schedule|appointment|add.*calendar|book|set up.*meeting|add.*meeting)\s*/i', '', $message); + $raw = trim($raw); + $dtParsed = null; + $title = $raw; + 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)?).{0,30}\b(\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\b/i', $raw, $dm)) { + $ts = strtotime($dm[0]); + if ($ts !== false && $ts > time()) { $dtParsed = date('Y-m-d H:i:s', $ts); $title = trim(str_replace($dm[0], '', $raw)); } + } + if (!$dtParsed && preg_match('/\b(tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|next\s+\w+)\b/i', $raw, $dm)) { + $ts = strtotime($dm[0]); + if ($ts !== false && $ts >= strtotime($today)) { $dtParsed = date('Y-m-d 09:00:00', $ts); $title = trim(str_replace($dm[0], '', $raw)); } } $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}."; + $category = preg_match('/\b(work|meeting|office|client|project|call)\b/i', $title) ? 'work' : 'personal'; + JarvisDB::execute('INSERT INTO appointments (title,category,start_at) VALUES (?,?,?)', [$title, $category, $dtParsed]); + $reply = "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?"; + } elseif ($title) { + $reply = "I can add that, {$userAddr} — what date and time?"; $source = 'planner:appt_need_time'; } } + // ── Cancel / delete appointment ─────────────────────────────────────── + if (!$reply && preg_match('/\b(cancel|delete|remove)\b.*\b(appointment|meeting|event)\b/i', $message)) { + $search = preg_replace('/\b(cancel|delete|remove|appointment|meeting|event|the|my)\b/i', ' ', $message); + $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; + $row = JarvisDB::single("SELECT id, title FROM appointments WHERE title LIKE ? AND start_at > NOW() LIMIT 1", [$search]); + if ($row) { + JarvisDB::execute("DELETE FROM appointments WHERE id=?", [$row['id']]); + $reply = "Appointment \"{$row['title']}\" removed from your calendar, {$userAddr}."; + $source = 'planner:appt_cancel'; + } + } + // ── 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 (!$reply && preg_match('/\b(appointments|my calendar|my schedule|what.*scheduled|upcoming.*event|show.*calendar)\b/i', $message)) { + $rows = JarvisDB::query("SELECT title, start_at FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 6", [$today, date('Y-m-d', strtotime('+7 days'))]) ?? []; 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) . '.'; + $items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j g:ia', strtotime($r['start_at'])), $rows); + $reply = count($rows) . " appointment" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.'; } $source = 'planner:appt_list'; }