feat: full voice intents — overdue/priority/week tasks, next appt, reschedule, cancel, week calendar, email→planner

This commit is contained in:
2026-05-31 19:12:06 +00:00
parent 7c1cfda588
commit 18649c47df
+170 -81
View File
@@ -560,91 +560,125 @@ if (!$reply) {
} }
// ── Tier 0.7: Planner — tasks & appointments ────────────────────────────── // ── Tier 0.7: Planner — full voice intent coverage ────────────────────────
if (!$reply) { if (!$reply) {
$lc = strtolower($message); $lc = strtolower($message);
$today = date('Y-m-d');
// ── Daily briefing / "what's my day" ───────────────────────────────── // ── Daily briefing ────────────────────────────────────────────────────
if (preg_match('/\b(briefing|daily summary|my day|schedule today|what.*today|morning|good morning)\b/i', $message) 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)\b/i', $message)) { && !preg_match('/\b(weather|news|temperature|forecast)\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_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]) ?? []; $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 = []; $parts = [];
if ($appts_today) { if ($appts_today) {
$ap = array_map(fn($a) => $a['title'] . ' at ' . date('g:i A', strtotime($a['start_at'])), $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) { if ($tasks_today) {
$tl = array_map(fn($t) => $t['title'], $tasks_today); $tl = array_map(fn($t) => $t['title'], $tasks_today);
$parts[] = count($tasks_today) . ' task' . (count($tasks_today)>1?'s':'') . ' due: ' . implode(', ', $tl); $parts[] = count($tasks_today) . ' task' . (count($tasks_today) > 1 ? 's' : '') . ' due today: ' . 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.";
} }
$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'; $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 ────────────────────────────────────────────────────────── // ── 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)) { 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)) {
// 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|put.*task|add.*to.*list)\s*/i', '', $message);
$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, '. '); $title = trim($title, '. ');
// Check for due date: "by tomorrow", "on Monday", "due Friday"
$dueDate = null; $dueDate = null;
if (preg_match('/\b(?:by|on|due|before)\s+(.+)$/i', $title, $dm)) { if (preg_match('/\b(?:by|on|due|before)\s+(.+)$/i', $title, $dm)) {
$ts = strtotime($dm[1]); $ts = strtotime($dm[1]);
if ($ts !== false) { if ($ts !== false) {
$dueDate = date('Y-m-d', $ts); $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'; $category = preg_match('/\b(work|meeting|project|client|office)\b/i', $title) ? 'work' : 'personal';
// Detect priority
$priority = 'normal'; $priority = 'normal';
if (preg_match('/\b(urgent|asap|emergency|critical)\b/i', $title)) $priority = 'urgent'; 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'; elseif (preg_match('/\b(important|high priority)\b/i', $title)) $priority = 'high';
if ($title) { if ($title) {
JarvisDB::execute( JarvisDB::execute('INSERT INTO tasks (title,category,priority,due_date) VALUES (?,?,?,?)', [$title, $category, $priority, $dueDate]);
'INSERT INTO tasks (title,category,priority,due_date) VALUES (?,?,?,?)',
[$title, $category, $priority, $dueDate]
);
$duePart = $dueDate ? ', due ' . date('l, M j', strtotime($dueDate)) : ''; $duePart = $dueDate ? ', due ' . date('l, M j', strtotime($dueDate)) : '';
$reply = "Task added: \"{$title}\"{$duePart}, {$userAddr}."; $reply = "Task added: \"{$title}\"{$duePart}, {$userAddr}.";
$source = 'planner:task_add'; $source = 'planner:task_add';
} }
} }
// ── List tasks ──────────────────────────────────────────────────────── // ── List tasks ────────────────────────────────────────────────────────
if (!$reply && preg_match('/\b(my tasks|todo list|to.?do|pending tasks|what.*tasks|show.*tasks|task list)\b/i', $message)) { 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)) {
$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") ?? [];
$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) { if (!$rows) {
$reply = "Your task list is clear, {$userAddr}. Nothing pending."; $reply = "Your task list is clear, {$userAddr}. Nothing pending.";
} else { } else {
$items = array_map(function($r) { $items = array_map(fn($r) => $r['title'] . ($r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''), $rows);
$due = $r['due_date'] ? ' (due ' . date('M j', strtotime($r['due_date'])) . ')' : ''; $reply = "You have " . count($rows) . " pending task" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.';
return $r['title'] . $due;
}, $rows);
$reply = "You have " . count($rows) . " pending task" . (count($rows)>1?'s':'') . ", {$userAddr}: " . implode('; ', $items) . '.';
} }
$source = 'planner:task_list'; $source = 'planner:task_list';
} }
// ── Mark task done ──────────────────────────────────────────────────── // ── 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)) { 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)) {
// extract keyword to search by $search = preg_replace('/\b(mark|complete|finished|done|completed|task|as|the|check|off|close|with)\b/i', ' ', $message);
$search = preg_replace('/\b(mark|complete|finished|done|completed|task|as|the)\b/i', ' ', $message);
$search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%'; $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]); $row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]);
if ($row) { if ($row) {
@@ -654,55 +688,110 @@ if (!$reply) {
} }
} }
// ── Add appointment ─────────────────────────────────────────────────── // ── Delete / cancel task ──────────────────────────────────────────────
if (!$reply && preg_match('/\b(schedule|appointment|meeting|add.*calendar|book|event)\b/i', $message)) { if (!$reply && preg_match('/\b(delete task|remove task|cancel task|drop task)\b/i', $message)) {
// Extract title and time: "schedule dentist Tuesday at 2pm" $search = preg_replace('/\b(delete|remove|cancel|drop|task)\b/i', ' ', $message);
$raw = preg_replace('/^.*(schedule|appointment|meeting|add.*calendar|book|event)\s*/i', '', $message); $search = '%' . trim(preg_replace('/\s+/', '%', trim($search))) . '%';
$raw = trim($raw); $row = JarvisDB::single("SELECT id, title FROM tasks WHERE title LIKE ? AND status NOT IN ('done','cancelled') LIMIT 1", [$search]);
// Try to find a datetime in the text if ($row) {
$dtParsed = null; JarvisDB::execute("UPDATE tasks SET status='cancelled' WHERE id=?", [$row['id']]);
$title = $raw; $reply = "Task \"{$row['title']}\" cancelled, {$userAddr}.";
// Look for time/date keywords $source = 'planner:task_cancel';
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()) { // ── Reschedule / move task ────────────────────────────────────────────
$dtParsed = date('Y-m-d H:i:s', $ts); if (!$reply && preg_match('/\b(reschedule|move|push|change due|update due)\b.*\btask\b/i', $message)) {
$title = trim(str_replace($dtStr, '', $raw)); 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, ' .'); $title = trim($title, ' .');
if ($title && $dtParsed) { if ($title && $dtParsed) {
$category = preg_match('/\b(work|meeting|office|client|project)\b/i', $title) ? 'work' : 'personal'; $category = preg_match('/\b(work|meeting|office|client|project|call)\b/i', $title) ? 'work' : 'personal';
JarvisDB::execute( JarvisDB::execute('INSERT INTO appointments (title,category,start_at) VALUES (?,?,?)', [$title, $category, $dtParsed]);
'INSERT INTO appointments (title,category,start_at) VALUES (?,?,?)', $reply = "Scheduled: \"{$title}\" on " . date('l, M j \a\t g:i A', strtotime($dtParsed)) . ", {$userAddr}.";
[$title, $category, $dtParsed]
);
$reply = "Appointment scheduled: \"{$title}\" on " . date('l, M j \a\t g:i A', strtotime($dtParsed)) . ", {$userAddr}.";
$source = 'planner:appt_add'; $source = 'planner:appt_add';
} elseif ($title && !$dtParsed) { } elseif ($title) {
$reply = "I'll add that appointment, {$userAddr} — what date and time?"; $reply = "I can add that, {$userAddr} — what date and time?";
$source = 'planner:appt_need_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 ────────────────────────────────────── // ── View appointments / calendar ──────────────────────────────────────
if (!$reply && preg_match('/\b(appointments|calendar|my schedule|what.*scheduled|upcoming.*events?)\b/i', $message)) { if (!$reply && preg_match('/\b(appointments|my calendar|my schedule|what.*scheduled|upcoming.*event|show.*calendar)\b/i', $message)) {
$from = date('Y-m-d'); $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'))]) ?? [];
$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) { if (!$rows) {
$reply = "No appointments in the next 7 days, {$userAddr}."; $reply = "No appointments in the next 7 days, {$userAddr}.";
} else { } else {
$items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j \a\t g:i A', strtotime($r['start_at'])), $rows); $items = array_map(fn($r) => $r['title'] . ' — ' . date('D M j g:ia', strtotime($r['start_at'])), $rows);
$reply = "Upcoming appointments, {$userAddr}: " . implode('; ', $items) . '.'; $reply = count($rows) . " appointment" . (count($rows) > 1 ? 's' : '') . ", {$userAddr}: " . implode('; ', $items) . '.';
} }
$source = 'planner:appt_list'; $source = 'planner:appt_list';
} }