// ============================================================
// Meetings & Events Module
// ============================================================
let mtgState = {
page: 1, search: '', type: '', status: '', my_only: 1,
};
// ── Entry Point ───────────────────────────────────────────────
async function renderMeetings(params = {}) {
if (params.id) return renderMeetingRoom(params.id);
// If arriving with a tab param, set state first
if (params.tab) mtgState.type = params.tab;
const content = document.getElementById('page-content');
const canCreate = Auth.can('meetings', 'create') || Auth.isAdmin() || Auth.isDev();
// "New" button label and action depends on active tab
const tabActions = {
'': { label: '+ New Meeting', fn: 'openMeetingModal()' },
'internal': { label: '+ New Meeting', fn: 'openMeetingModal()' },
'client': { label: '+ New Meeting', fn: 'openMeetingModal()' },
'task': { label: '+ New Task', fn: 'openTaskModal()' },
'reminder': { label: '+ New Reminder', fn: 'openReminderModal()' },
};
const act = tabActions[mtgState.type] || tabActions[''];
content.innerHTML = `
`;
_renderTabBody();
}
function mtgSetTab(btn, type) {
document.querySelectorAll('#mtg-tabs .tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
mtgState.type = type;
mtgState.page = 1;
// Update new button
const tabActions = {
'': 'openMeetingModal()', 'internal': 'openMeetingModal()', 'client': 'openMeetingModal()',
'task': 'openTaskModal()', 'reminder': 'openReminderModal()',
};
const tabLabels = { '': '+ New Meeting', 'internal': '+ New Meeting', 'client': '+ New Meeting', 'task': '+ New Task', 'reminder': '+ New Reminder' };
const newBtn = document.getElementById('mtg-new-btn');
if (newBtn) { newBtn.textContent = tabLabels[type] || '+ New Event'; newBtn.setAttribute('onclick', tabActions[type] || 'openMeetingModal()'); }
_renderTabBody();
}
function _renderTabBody() {
const body = document.getElementById('mtg-tab-body');
if (!body) return;
if (mtgState.type === 'task') { renderTasksInline(body); return; }
if (mtgState.type === 'reminder') { renderRemindersInline(body); return; }
// Meetings list
body.innerHTML = `
`;
loadMeetings(1);
}
const mtgSearchDebounced = debounce(val => { mtgState.search = val; loadMeetings(1); }, 350);
async function loadMeetings(page = 1) {
mtgState.page = page;
const wrap = document.getElementById('mtg-list-wrap');
if (!wrap) return;
wrap.innerHTML = '';
const res = await API.post('meetings/index', { action: 'list', page, limit: 20, ...mtgState });
if (!res.success) { wrap.innerHTML = ``; return; }
const meetings = res.data.meetings || [];
if (!meetings.length) {
wrap.innerHTML = `📅
No events found
Create your first event to get started.
`;
document.getElementById('mtg-pagination').innerHTML = '';
return;
}
const TYPE_META = {
internal: { icon: '👥', label: 'Internal', color: '#dbeafe', text: '#1e40af' },
client: { icon: '🏢', label: 'Client', color: '#dcfce7', text: '#166534' },
personal: { icon: '👤', label: 'Personal', color: '#fce7f3', text: '#831843' },
task: { icon: '✅', label: 'Task', color: '#f3f4f6', text: '#374151' },
reminder: { icon: '🔔', label: 'Reminder', color: '#fef9c3', text: '#713f12' },
deadline: { icon: '⚠️', label: 'Deadline', color: '#fee2e2', text: '#991b1b' },
};
const STATUS_META = {
scheduled: { bg: '#dbeafe', text: '#1e40af', label: 'Scheduled' },
in_progress: { bg: '#fef9c3', text: '#713f12', label: 'In Progress' },
completed: { bg: '#dcfce7', text: '#166534', label: 'Completed' },
cancelled: { bg: '#f3f4f6', text: '#9ca3af', label: 'Cancelled' },
};
const PRIORITY_DOT = { low: '#9ca3af', normal: '#3b82f6', high: '#f97316', urgent: '#ef4444' };
if (window.innerWidth < 768) {
wrap.innerHTML = `
${meetings.map(m => {
const tm = TYPE_META[m.type] || TYPE_META.internal;
const sm = STATUS_META[m.status] || STATUS_META.scheduled;
return `
${mtgH(m.title)}
${sm.label}
${Fmt.date(m.meeting_date)}${m.start_time ? ' · ' + m.start_time.slice(0, 5) : ''} · ${tm.icon} ${tm.label}
${m.client_name ? `
🏢 ${mtgH(m.client_name)}
` : ''}
👤 ${m.attendee_count} attendee${m.attendee_count != 1 ? 's' : ''} · ${m.accepted_count} accepted
`;
}).join('')}
`;
} else {
wrap.innerHTML = `
| Title | Type | Date & Time | Attendees | Status | Priority | |
${meetings.map(m => {
const tm = TYPE_META[m.type] || TYPE_META.internal;
const sm = STATUS_META[m.status] || STATUS_META.scheduled;
const pd = PRIORITY_DOT[m.priority] || PRIORITY_DOT.normal;
return `
|
${mtgH(m.title)}
${m.client_name ? `🏢 ${mtgH(m.client_name)} ` : ''}
${m.location ? `📍 ${mtgH(m.location)} ` : ''}
|
${tm.icon} ${tm.label} |
${Fmt.date(m.meeting_date)}${m.start_time ? ' ' + m.start_time.slice(0, 5) + (m.end_time ? ' – ' + m.end_time.slice(0, 5) : '') + '' : ''} |
${m.attendee_count} total · ${m.accepted_count} ✓${m.declined_count ? ` · ${m.declined_count} ✗` : ''} |
${sm.label} |
${Fmt.capitalize(m.priority)} |
|
`;
}).join('')}
`;
}
renderPagination('mtg-pagination', res.data.pagination, `p => loadMeetings(p)`);
}
// ── Meeting Room (detail / live page) ────────────────────────
async function renderMeetingRoom(id) {
const content = document.getElementById('page-content');
content.innerHTML = ``;
const res = await API.post('meetings/index', { action: 'get', id });
if (!res.success) { Toast.show(res.message, 'error'); Router.navigate('meetings'); return; }
const m = res.data.meeting;
window._mtgCurrentId = id;
window._mtgRecorder = null;
window._mtgChunks = [];
const TYPE_META = {
internal: { icon: '👥', color: '#dbeafe', text: '#1e40af' },
client: { icon: '🏢', color: '#dcfce7', text: '#166534' },
personal: { icon: '👤', color: '#fce7f3', text: '#831843' },
task: { icon: '✅', color: '#f3f4f6', text: '#374151' },
reminder: { icon: '🔔', color: '#fef9c3', text: '#713f12' },
deadline: { icon: '⚠️', color: '#fee2e2', text: '#991b1b' },
};
const RSVP_META = {
pending: { icon: '⏳', color: '#d97706', label: 'Pending' },
accepted: { icon: '✅', color: '#16a34a', label: 'Accepted' },
declined: { icon: '❌', color: '#dc2626', label: 'Declined' },
reschedule_requested: { icon: '🔄', color: '#0284c7', label: 'Reschedule' },
};
const tm = TYPE_META[m.type] || TYPE_META.internal;
const canEdit = Auth.isAdmin() || Auth.isDev() || (m.created_by == Auth.getCurrentUser()?.id);
const isActive = m.status === 'in_progress';
const isCompleted = m.status === 'completed';
const timeStr = m.start_time
? m.start_time.slice(0, 5) + (m.end_time ? ' – ' + m.end_time.slice(0, 5) : '')
: 'All Day';
content.innerHTML = `
Status
${Fmt.capitalize(m.status.replace('_', ' '))}
Priority
${Fmt.capitalize(m.priority)}
${m.client_name ? `
Client
${mtgH(m.client_name)}` : ''}
Organised by
${mtgH(m.created_by_name)}
${m.description ? `
Description
${mtgH(m.description)}
` : ''}
Click "Start Recording" to record the meeting audio.
${(m.files || []).filter(f => f.file_type === 'recording').map(f => _mtgFileCard(f)).join('')}
${m.notes && m.notes.length ? m.notes.map(n => _mtgNoteCard(n)).join('') : '
No notes yet.
'}
${(m.files || []).filter(f => f.file_type !== 'recording').length
? `
${(m.files || []).filter(f => f.file_type !== 'recording').map(f => _mtgFileCard(f)).join('')}
`
: '
No files uploaded yet.
'}
${m.attendees && m.attendees.length ? m.attendees.map(a => {
const rm = RSVP_META[a.rsvp_status] || RSVP_META.pending;
const name = a.user_name || a.contact_name || a.name || a.email;
const initials = (name || '?').split(' ').map(p => p[0]).join('').slice(0, 2).toUpperCase();
// Parse proposed reschedule details
let rescheduleHtml = '';
if (a.rsvp_status === 'reschedule_requested' && a.rsvp_note) {
const parts = {};
a.rsvp_note.split('|').forEach(p => {
if (p.startsWith('date:')) parts.date = p.slice(5);
if (p.startsWith('time:')) parts.time = p.slice(5);
if (p.startsWith('place:')) parts.place = p.slice(6);
});
const rows = [
parts.date ? `
📅 ${parts.date}
` : '',
parts.time ? `
⏰ ${parts.time}
` : '',
parts.place ? `
📍 ${mtgH(parts.place)}
` : '',
].join('');
if (rows && canEdit) {
rescheduleHtml = `
`;
}
}
return `
${initials}
${mtgH(name)}
${a.email || ''}
${rescheduleHtml}
${rm.icon} ${rm.label}
${canEdit && !a.invite_sent ? `
` : ''}
${canEdit && a.invite_sent ? `
` : ''}
`;
}).join('') : '
No attendees added.
'}
${m.attendees && m.attendees.length ? `
${[['accepted', '✅', '#16a34a'], ['pending', '⏳', '#d97706'], ['declined', '❌', '#dc2626'], ['reschedule_requested', '🔄', '#0284c7']].map(([status, icon, color]) => {
const count = (m.attendees || []).filter(a => a.rsvp_status === status).length;
return `
${count}
${icon} ${Fmt.capitalize(status.replace('_', ' '))}
`;
}).join('')}
` : ''}
`;
// Make responsive on small screens
if (window.innerWidth < 900) {
const grid = document.getElementById('mtg-room-grid');
if (grid) grid.style.gridTemplateColumns = '1fr';
}
}
// ── Meeting note helpers ──────────────────────────────────────
function _mtgNoteCard(n) {
return `
${mtgH(n.user_name)} · ${Fmt.datetime(n.created_at)}
${mtgH(n.content)}
`;
}
async function saveMeetingNote(meetingId) {
const input = document.getElementById('mtg-note-input');
const content = input?.value?.trim();
if (!content) return;
const res = await API.post('meetings/index', { action: 'save_note', meeting_id: meetingId, content });
if (res.success) {
const list = document.getElementById('mtg-notes-list');
if (list) {
if (list.querySelector('.text-muted')) list.innerHTML = '';
list.insertAdjacentHTML('beforeend', _mtgNoteCard(res.data));
}
input.value = '';
Toast.show('Note saved.', 'success');
} else Toast.show(res.message, 'error');
}
// ── File helpers ──────────────────────────────────────────────
function _mtgFileCard(f) {
const isImage = f.file_type === 'image' || (f.mime_type || '').startsWith('image/');
const isRec = f.file_type === 'recording';
const url = `./api/meetings/uploads/${f.filename}`;
if (isImage) {
return ``;
}
if (isRec) {
return `
🎙 ${mtgH(f.original_name)}
`;
}
return ``;
}
async function uploadMeetingFiles(meetingId, input) {
const files = [...input.files];
if (!files.length) return;
let uploaded = 0;
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
fd.append('meeting_id', meetingId);
const res = await API.postForm('meetings/upload', fd);
if (res.success) {
uploaded++;
const f = res.data;
const grid = document.getElementById('mtg-files-grid');
if (grid) {
if (grid.querySelector('.text-muted')) grid.innerHTML = '';
const inner = grid.querySelector('div') || grid;
inner.insertAdjacentHTML('beforeend', _mtgFileCard(f));
}
}
}
input.value = '';
if (uploaded) Toast.show(`${uploaded} file(s) uploaded.`, 'success');
}
// ── Recording ─────────────────────────────────────────────────
let _recTimer = null;
let _recSeconds = 0;
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
window._mtgRecorder = new MediaRecorder(stream);
window._mtgChunks = [];
window._mtgRecorder.ondataavailable = e => window._mtgChunks.push(e.data);
window._mtgRecorder.start(1000);
document.getElementById('rec-start-btn').style.display = 'none';
document.getElementById('rec-stop-btn').style.display = '';
const timerEl = document.getElementById('rec-timer');
timerEl.style.display = 'inline';
_recSeconds = 0;
_recTimer = setInterval(() => {
_recSeconds++;
const m = String(Math.floor(_recSeconds / 60)).padStart(2, '0');
const s = String(_recSeconds % 60).padStart(2, '0');
timerEl.textContent = `⏺ ${m}:${s}`;
}, 1000);
document.getElementById('rec-status').innerHTML = '⏺ Recording in progress…';
} catch (e) {
Toast.show('Microphone access denied: ' + e.message, 'error');
}
}
async function stopRecording() {
if (!window._mtgRecorder) return;
clearInterval(_recTimer);
document.getElementById('rec-stop-btn').style.display = 'none';
document.getElementById('rec-start-btn').style.display = '';
document.getElementById('rec-timer').style.display = 'none';
document.getElementById('rec-status').textContent = 'Saving recording…';
window._mtgRecorder.stop();
window._mtgRecorder.stream.getTracks().forEach(t => t.stop());
window._mtgRecorder.onstop = async () => {
const blob = new Blob(window._mtgChunks, { type: 'audio/webm' });
const fd = new FormData();
fd.append('file', blob, `recording_${Date.now()}.webm`);
fd.append('meeting_id', window._mtgCurrentId);
fd.append('file_type', 'recording');
const res = await API.postForm('meetings/upload', fd);
if (res.success) {
document.getElementById('rec-status').textContent = 'Recording saved.';
const recFiles = document.getElementById('rec-files');
if (recFiles) recFiles.insertAdjacentHTML('beforeend', _mtgFileCard(res.data));
Toast.show('Recording saved ✓', 'success');
} else {
document.getElementById('rec-status').textContent = 'Failed to save recording.';
Toast.show(res.message, 'error');
}
};
}
// ── Start / Complete meeting ──────────────────────────────────
async function startMeeting(id) {
const res = await API.post('meetings/index', { action: 'start', id });
if (res.success) { Toast.show('Event started!', 'success'); renderMeetingRoom(id); }
else Toast.show(res.message, 'error');
}
async function completeMeeting(id) {
const notes = document.getElementById('mtg-note-input')?.value || '';
if (!await Modal.confirm('End and mark this event as completed?', 'Complete Event')) return;
const res = await API.post('meetings/index', { action: 'complete', id, notes });
if (res.success) { Toast.show('Event completed ✓', 'success'); renderMeetingRoom(id); }
else Toast.show(res.message, 'error');
}
async function deleteMeeting(id) {
if (!await Modal.confirm('Delete this event? This cannot be undone.', 'Delete Event', true)) return;
const res = await API.post('meetings/index', { action: 'delete', id });
if (res.success) { Toast.show('Event deleted.', 'success'); loadMeetings(mtgState.page); }
else Toast.show(res.message, 'error');
}
async function organiserAction(meetingId, attendeeId, action, note) {
const labels = { accept_reschedule: 'Accept this reschedule request? The meeting date will be updated and all other attendees re-notified.', keep_original: 'Keep the original date? The attendee will be marked as declined and notified.' };
if (!await Modal.confirm(labels[action], action === 'accept_reschedule' ? 'Accept Reschedule' : 'Keep Original Date', action === 'keep_original')) return;
const res = await API.post('meetings/organiser_action', { meeting_id: meetingId, attendee_id: attendeeId, action, note });
if (res.success) { Toast.show(res.message, 'success'); renderMeetingRoom(meetingId); }
else Toast.show(res.message, 'error');
}
async function resendInvite(meetingId, attendeeId) {
const res = await API.post('meetings/index', { action: 'resend_invite', meeting_id: meetingId, attendee_id: attendeeId });
if (res.success) Toast.show('Invite queued ✓', 'success');
else Toast.show(res.message, 'error');
}
// ── Create / Edit Modal ───────────────────────────────────────
let _mtgAttendees = [];
async function openMeetingModal(editId = 0) {
_mtgAttendees = [];
let m = null;
if (editId) {
const res = await API.post('meetings/index', { action: 'get', id: editId });
if (!res.success) { Toast.show(res.message, 'error'); return; }
m = res.data.meeting;
_mtgAttendees = (m.attendees || []).map(a => ({
type: a.type,
user_id: a.user_id,
contact_id: a.contact_id,
name: a.user_name || a.contact_name || a.name || '',
email: a.email || '',
}));
}
// Load users + clients in parallel
const [usersRes, clientsRes] = await Promise.all([
API.post('auth/users_list', { limit: 100 }),
API.post('clients/list', { limit: 100 }),
]);
const users = usersRes.data?.users || [];
const clients = clientsRes.data?.clients || [];
const v = (k, def = '') => m ? (m[k] ?? def) : def;
Modal.open({
id: 'mtg-modal',
title: editId ? `✏️ Edit: ${m.title}` : '📅 New Event',
size: 'modal-xl',
body: `
Attendees
${_mtgAttendees.length ? '' : '
No attendees added yet.
'}
${_mtgAttendees.length ? '' : ''}
Attendees will receive an invite with Accept / Decline / Reschedule options.
`,
footer: `
`,
});
// Render existing attendees
_mtgAttendees.forEach((a, i) => _renderMtgAttendee(a, i));
// If editing a client meeting, load contacts
if (m?.client_id) setTimeout(() => mtgLoadContacts(m.client_id), 100);
}
function mtgTypeChange(type) {
const clientRow = document.getElementById('mtg-client-row');
const contactsRow = document.getElementById('mtg-contacts-row');
if (clientRow) clientRow.style.display = type === 'client' ? '' : 'none';
if (contactsRow) contactsRow.style.display = type === 'client' ? '' : 'none';
}
async function mtgLoadContacts(clientId) {
if (!clientId) return;
const sel = document.getElementById('mtg-add-contact');
if (!sel) return;
const res = await API.post('clients/get', { id: clientId });
if (!res.success) return;
const contacts = res.data.client.contacts || [];
sel.innerHTML = '' +
contacts.map(c => ``).join('');
document.getElementById('mtg-contacts-row').style.display = '';
}
function mtgAddUserAttendee() {
const sel = document.getElementById('mtg-add-user');
if (!sel.value) return;
const opt = sel.options[sel.selectedIndex];
_mtgPushAttendee({ type: 'user', user_id: parseInt(sel.value), name: opt.dataset.name, email: opt.dataset.email });
sel.value = '';
}
function mtgAddContactAttendee() {
const sel = document.getElementById('mtg-add-contact');
if (!sel.value) return;
const opt = sel.options[sel.selectedIndex];
_mtgPushAttendee({ type: 'client_contact', contact_id: parseInt(sel.value), name: opt.dataset.name, email: opt.dataset.email });
sel.value = '';
}
function mtgAddExternalAttendee() {
const name = document.getElementById('mtg-ext-name')?.value.trim();
const email = document.getElementById('mtg-ext-email')?.value.trim();
if (!email) { Toast.show('Email is required for external attendees.', 'error'); return; }
_mtgPushAttendee({ type: 'external', name: name || email, email });
document.getElementById('mtg-ext-name').value = '';
document.getElementById('mtg-ext-email').value = '';
}
function _mtgPushAttendee(att) {
// Deduplicate by email
if (_mtgAttendees.find(a => a.email && a.email === att.email)) {
Toast.show('This person is already in the list.', 'warning'); return;
}
const idx = _mtgAttendees.push(att) - 1;
const empty = document.getElementById('mtg-att-empty');
if (empty) empty.remove();
_renderMtgAttendee(att, idx);
}
function _renderMtgAttendee(att, idx) {
const list = document.getElementById('mtg-attendees-list');
if (!list) return;
const typeIcon = { user: '👤', client_contact: '🏢', external: '🌐' };
const div = document.createElement('div');
div.id = `mtg-att-${idx}`;
div.style.cssText = 'display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;background:var(--bg);border:1px solid var(--border);border-radius:6px';
div.innerHTML = `
${typeIcon[att.type] || '👤'}
${mtgH(att.name || att.email)}
${att.email && att.email !== att.name ? `
${mtgH(att.email)}
` : ''}
`;
list.appendChild(div);
}
function mtgRemoveAttendee(idx) {
_mtgAttendees.splice(idx, 1);
// Re-render all
const list = document.getElementById('mtg-attendees-list');
if (!list) return;
list.innerHTML = _mtgAttendees.length ? '' : 'No attendees added yet.
';
_mtgAttendees.forEach((a, i) => _renderMtgAttendee(a, i));
}
async function submitMeeting(editId = 0) {
const btn = document.querySelector('#modal-mtg-modal .btn-primary');
if (btn) btn.classList.add('loading');
const data = getFormData('mtg-form');
data.attendees = _mtgAttendees;
data.send_invites = document.getElementById('mtg-send-invites')?.checked ? '1' : '';
const res = await API.post('meetings/index', { action: 'save', ...data });
if (btn) btn.classList.remove('loading');
if (res.success) {
Toast.show(editId ? 'Event updated ✓' : 'Event created ✓', 'success');
Modal.close();
if (editId) renderMeetingRoom(editId);
else loadMeetings(1);
} else Toast.show(res.message, 'error');
}
// ── Utility ───────────────────────────────────────────────────
function mtgH(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); }
// ══════════════════════════════════════════════════════════════
// TASKS — inline tab view with sub-checklist
// ══════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════════════
// TASKS — Modern task management (Todoist-inspired)
// ══════════════════════════════════════════════════════════════════════
const TASK_PRIORITY = {
urgent: { label: 'Urgent', color: '#ef4444', bg: '#fee2e2', flag: '🔴' },
high: { label: 'High', color: '#f97316', bg: '#ffedd5', flag: '🟠' },
normal: { label: 'Normal', color: '#3b82f6', bg: '#dbeafe', flag: '🔵' },
low: { label: 'Low', color: '#9ca3af', bg: '#f3f4f6', flag: '⚪' },
};
let _taskState = { view: 'inbox', search: '', expandedIds: new Set(), editingId: null };
async function renderTasksInline(container) {
_taskState.expandedIds = new Set();
_taskState.search = '';
_taskState.view = 'inbox';
container.innerHTML = `
`;
// Mobile: collapse sidebar
if (window.innerWidth < 640) {
const shell = document.getElementById('task-shell');
if (shell) shell.style.gridTemplateColumns = '1fr';
const sidebar = document.getElementById('task-sidebar');
if (sidebar) {
sidebar.style.cssText = 'display:flex;flex-direction:row;flex-wrap:wrap;gap:.35rem;margin-bottom:.75rem';
}
}
await _loadTasks();
}
const taskSearchDebounce = debounce(v => { _taskState.search = v; _loadTasks(); }, 280);
async function taskSetView(v) {
_taskState.view = v;
_taskState.expandedIds = new Set();
// Update sidebar highlights
document.querySelectorAll('[id^="tsv-"]').forEach(btn => {
const isActive = btn.id === `tsv-${v}`;
btn.style.background = isActive ? 'var(--primary)' : 'transparent';
btn.style.color = isActive ? '#fff' : 'var(--text)';
btn.style.fontWeight = isActive ? '600' : '400';
});
await _loadTasks();
}
async function _loadTasks() {
const main = document.getElementById('task-main');
if (!main) return;
main.innerHTML = '';
const today = new Date().toISOString().split('T')[0];
const in7 = new Date(Date.now() + 7 * 864e5).toISOString().split('T')[0];
const params = { action: 'list', type: 'task', my_only: 1, limit: 200, page: 1 };
const v = _taskState.view;
if (v === 'inbox') { params.status = 'scheduled'; }
if (v === 'today') { params.status = 'scheduled'; params.date_from = today; params.date_to = today; }
if (v === 'upcoming') { params.status = 'scheduled'; params.date_from = today; params.date_to = in7; }
if (v === 'overdue') { params.status = 'scheduled'; params.date_to = new Date(Date.now() - 864e5).toISOString().split('T')[0]; }
if (v === 'completed') { params.status = 'completed'; }
if (v.startsWith('p:')) { params.status = 'scheduled'; }
if (_taskState.search) params.search = _taskState.search;
const res = await API.post('meetings/index', params);
let tasks = res.data?.meetings || [];
if (v.startsWith('p:')) { const pri = v.slice(2); tasks = tasks.filter(t => t.priority === pri); }
// Update sidebar counts — run a full fetch for counts
_updateTaskCounts();
if (!tasks.length) {
const empties = {
inbox: 'No pending tasks', today: 'Nothing due today', upcoming: 'Nothing in the next 7 days',
overdue: 'You\'re all caught up! 🎉', completed: 'No completed tasks yet',
};
const msg = empties[v] || 'No tasks';
main.innerHTML = `
${v === 'overdue' ? '🎉' : '📋'}
${msg}
Press Enter in the quick-add bar above to create one.
`;
return;
}
// Group by date bucket
const groups = {};
const addTo = (key, label, urgent, t) => {
if (!groups[key]) groups[key] = { label, urgent, items: [] };
groups[key].items.push(t);
};
tasks.forEach(t => {
const td = t.meeting_date;
if (!td) { addTo('someday', 'Someday', false, t); return; }
if (td < today) { addTo('overdue', '🔥 Overdue', true, t); return; }
if (td === today) { addTo('today', '☀️ Today', false, t); return; }
const tomorrow = new Date(Date.now() + 864e5).toISOString().split('T')[0];
if (td === tomorrow) { addTo('tomorrow', '🗓 Tomorrow', false, t); return; }
addTo(td, Fmt.date(td), false, t);
});
const ORDER = ['overdue', 'today', 'tomorrow'];
const sortedKeys = [
...ORDER.filter(k => groups[k]),
...Object.keys(groups).filter(k => !ORDER.includes(k) && k !== 'someday').sort(),
...(groups.someday ? ['someday'] : []),
];
main.innerHTML = sortedKeys.map(key => {
const grp = groups[key];
return `
${grp.label}
${grp.items.length} task${grp.items.length !== 1 ? 's' : ''}
${grp.items.map(t => _taskCard(t, today)).join('')}
`;
}).join('');
}
function _taskCard(t, today) {
const done = t.status === 'completed';
const overdue = !done && t.meeting_date && t.meeting_date < today;
const p = TASK_PRIORITY[t.priority] || TASK_PRIORITY.normal;
const isOpen = _taskState.expandedIds.has(t.id);
// Sub-task progress (stored inline as data attr for now, loaded on expand)
return `
${mtgH(t.title)}
${t.meeting_date ? `
📅 ${overdue ? 'Overdue · ' : ''} ${t.meeting_date === today ? 'Today' : Fmt.date(t.meeting_date)}
` : 'No date'}
${t.location ? `· 📍 ${mtgH(t.location)}` : ''}
${isOpen ? '▾' : '▸'} sub-tasks
${t.description ? `
${mtgH(t.description)}
` : ''}
`;
}
async function taskToggleExpand(id) {
const sub = document.getElementById(`tsk-sub-${id}`);
if (!sub) return;
if (_taskState.expandedIds.has(id)) {
_taskState.expandedIds.delete(id);
sub.style.display = 'none';
} else {
_taskState.expandedIds.add(id);
sub.style.display = 'block';
await loadTaskItems(id);
}
// refresh arrow
const card = document.getElementById(`tsk-${id}`);
const arrow = card?.querySelector('[style*="▸"], [style*="▾"]');
// just reload the view won't work here; the arrow is inside the card text
// simplest: toggle text manually
const hint = card?.querySelector('[style*="margin-left:auto"]');
if (hint) hint.textContent = _taskState.expandedIds.has(id) ? '▾ sub-tasks' : '▸ sub-tasks';
}
async function _updateTaskCounts() {
const today = new Date().toISOString().split('T')[0];
const in7 = new Date(Date.now() + 7 * 864e5).toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 864e5).toISOString().split('T')[0];
const all = await API.post('meetings/index', { action: 'list', type: 'task', my_only: 1, limit: 500, page: 1 });
const tasks = all.data?.meetings || [];
const counts = {
inbox: tasks.filter(t => t.status === 'scheduled').length,
today: tasks.filter(t => t.status === 'scheduled' && t.meeting_date === today).length,
upcoming: tasks.filter(t => t.status === 'scheduled' && t.meeting_date >= today && t.meeting_date <= in7).length,
overdue: tasks.filter(t => t.status === 'scheduled' && t.meeting_date && t.meeting_date <= yesterday).length,
completed: tasks.filter(t => t.status === 'completed').length,
'p:urgent': tasks.filter(t => t.status === 'scheduled' && t.priority === 'urgent').length,
'p:high': tasks.filter(t => t.status === 'scheduled' && t.priority === 'high').length,
'p:normal': tasks.filter(t => t.status === 'scheduled' && t.priority === 'normal').length,
'p:low': tasks.filter(t => t.status === 'scheduled' && t.priority === 'low').length,
};
Object.entries(counts).forEach(([k, n]) => {
const el = document.getElementById(`tsv-cnt-${k}`);
if (el) el.textContent = n > 0 ? n : '';
});
}
async function taskQuickAdd(title) {
const priority = document.getElementById('task-quick-priority')?.value || 'normal';
const date = document.getElementById('task-quick-date')?.value || new Date().toISOString().split('T')[0];
const res = await API.post('meetings/index', { action: 'save', type: 'task', title, priority, meeting_date: date });
if (res.success) {
// Animate the quick bar
const inp = document.getElementById('task-quick-input');
if (inp) { inp.placeholder = '✓ Task added!'; setTimeout(() => { inp.placeholder = 'Add a task… (press Enter to save)'; }, 1200); }
await _loadTasks();
} else Toast.show(res.message, 'error');
}
async function toggleTask(id, done) {
const check = document.querySelector(`#tsk-${id} .task-check`);
if (check) { check.style.opacity = '.3'; check.style.transform = 'scale(.85)'; }
await API.post('meetings/index', { action: 'toggle_complete', id, done });
await _loadTasks();
}
async function cycleTaskPriority(id, current) {
const order = ['urgent', 'high', 'normal', 'low'];
const next = order[(order.indexOf(current) + 1) % order.length];
await API.post('meetings/index', {
action: 'save', id, type: 'task', priority: next,
title: document.querySelector(`#tsk-${id} [style*="font-size:.9rem"]`)?.textContent || ''
});
await _loadTasks();
}
// Sub-checklist
async function loadTaskItems(taskId) {
const wrap = document.getElementById(`tsk-items-${taskId}`);
if (!wrap) return;
const res = await API.post('meetings/index', { action: 'task_items', sub: 'list', task_id: taskId });
const items = res.data?.items || [];
wrap.innerHTML = items.length
? items.map(it => _taskItemRow(taskId, it)).join('')
: 'No items yet — add one below';
}
function _taskItemRow(taskId, it) {
return ``;
}
async function addTaskItem(taskId) {
const inp = document.getElementById(`tsk-new-${taskId}`);
const text = inp?.value?.trim();
if (!text) return;
inp.value = '';
const res = await API.post('meetings/index', { action: 'task_items', sub: 'add', task_id: taskId, text });
if (res.success) {
const wrap = document.getElementById(`tsk-items-${taskId}`);
if (wrap) {
const placeholder = wrap.querySelector('.text-xs.text-muted');
if (placeholder) placeholder.remove();
wrap.insertAdjacentHTML('beforeend', _taskItemRow(taskId, res.data));
}
}
}
async function toggleTaskItem(taskId, itemId, done) {
const row = document.getElementById(`ti-${itemId}`);
const circ = row?.querySelector('div');
if (circ) { circ.style.background = done ? '#16a34a' : 'transparent'; circ.style.borderColor = done ? '#16a34a' : '#d1d5db'; circ.innerHTML = done ? '' : ''; }
const label = row?.querySelector('span');
if (label) label.style.cssText = done ? 'flex:1;font-size:.82rem;text-decoration:line-through;color:var(--text-muted)' : 'flex:1;font-size:.82rem';
await API.post('meetings/index', { action: 'task_items', sub: 'toggle', task_id: taskId, item_id: itemId, done });
}
async function deleteTaskItem(taskId, itemId) {
document.getElementById(`ti-${itemId}`)?.remove();
await API.post('meetings/index', { action: 'task_items', sub: 'delete', task_id: taskId, item_id: itemId });
}
function openTaskModal(editId = 0) {
(async () => {
let t = null;
if (editId) { const r = await API.post('meetings/index', { action: 'get', id: editId }); if (r.success) t = r.data.meeting; }
const v = (k, d = '') => t ? (t[k] ?? d) : d;
const today = new Date().toISOString().split('T')[0];
Modal.open({
id: 'task-modal', title: editId ? 'Edit Task' : 'New Task',
body: ``,
footer: `
`,
});
})();
}
// ══════════════════════════════════════════════════════════════════════
// REMINDERS — Smart time-aware reminder system
// ══════════════════════════════════════════════════════════════════════
let _reminderView = 'upcoming';
async function renderRemindersInline(container) {
_reminderView = 'upcoming';
container.innerHTML = `
${[['upcoming', '🔔 Upcoming'], ['today', '☀️ Today'], ['overdue', '🔥 Overdue'], ['done', '✅ Done']].map(([k, l]) => `
`).join('')}
When
${[
['today-morning', '☀️ Today Morning', _todayAt('08:00')],
['today-afternoon', '🌤 This Afternoon', _todayAt('14:00')],
['today-evening', '🌙 This Evening', _todayAt('18:00')],
['tomorrow-morning', '🌅 Tomorrow Morning', _tomorrowAt('08:00')],
['next-week', '📅 Next Week', _nextWeekAt('09:00')],
['custom', '🗓 Custom…', ''],
].map(([k, l, v]) => `
`).join('')}
`;
// Mobile: stack
if (window.innerWidth < 640) {
const shell = document.getElementById('rem-shell');
if (shell) shell.style.gridTemplateColumns = '1fr';
}
window._remSelectedDate = _todayAt('09:00').date;
window._remSelectedTime = _todayAt('09:00').time;
window._remSelectedPri = 'normal';
await _loadReminders();
}
function _todayAt(time) {
return { date: new Date().toISOString().split('T')[0], time };
}
function _tomorrowAt(time) {
const d = new Date(); d.setDate(d.getDate() + 1);
return { date: d.toISOString().split('T')[0], time };
}
function _nextWeekAt(time) {
const d = new Date(); d.setDate(d.getDate() + 7);
return { date: d.toISOString().split('T')[0], time };
}
function remSelectPreset(btn, key) {
document.querySelectorAll('.rem-preset-btn').forEach(b => {
b.style.borderColor = 'var(--border)'; b.style.background = 'var(--surface)'; b.style.color = 'var(--text)';
});
btn.style.borderColor = 'var(--primary)'; btn.style.background = '#eff6ff'; btn.style.color = 'var(--primary)';
const custom = document.getElementById('rem-custom-fields');
if (key === 'custom') {
if (custom) custom.style.display = 'flex';
return;
}
if (custom) custom.style.display = 'none';
window._remSelectedDate = btn.dataset.date;
window._remSelectedTime = btn.dataset.time;
}
function remSelectPriority(btn, pri) {
window._remSelectedPri = pri;
document.querySelectorAll('.rem-pri-btn').forEach(b => {
const p = TASK_PRIORITY[b.dataset.pri];
b.style.borderColor = 'var(--border)';
b.style.background = 'transparent';
b.style.color = 'var(--text-muted)';
});
const p = TASK_PRIORITY[pri];
btn.style.borderColor = p.color;
btn.style.background = p.bg;
btn.style.color = p.color;
}
async function remQuickCreate() {
const title = document.getElementById('rem-title')?.value?.trim();
const notes = document.getElementById('rem-notes')?.value?.trim();
const customDate = document.getElementById('rem-custom-date')?.value;
const customTime = document.getElementById('rem-custom-time')?.value;
if (!title) { Toast.show('Please enter what to remember.', 'error'); return; }
const isCustom = document.getElementById('rem-custom-fields')?.style.display !== 'none';
const date = isCustom ? customDate : window._remSelectedDate;
const time = isCustom ? customTime : window._remSelectedTime;
const res = await API.post('meetings/index', {
action: 'save', type: 'reminder', title,
description: notes, meeting_date: date, start_time: time,
priority: window._remSelectedPri || 'normal',
});
if (res.success) {
Toast.show('Reminder set ✓', 'success');
document.getElementById('rem-title').value = '';
document.getElementById('rem-notes').value = '';
// Reset preset selection
document.querySelectorAll('.rem-preset-btn').forEach(b => { b.style.borderColor = 'var(--border)'; b.style.background = 'var(--surface)'; b.style.color = 'var(--text)'; });
await _loadReminders();
} else Toast.show(res.message, 'error');
}
async function remSetView(v) {
_reminderView = v;
document.querySelectorAll('[id^="rvt-"]').forEach(b => {
const isA = b.id === `rvt-${v}`;
b.style.background = isA ? 'var(--primary)' : 'transparent';
b.style.color = isA ? '#fff' : 'var(--text-muted)';
});
await _loadReminders();
}
async function _loadReminders() {
const wrap = document.getElementById('rem-list');
if (!wrap) return;
wrap.innerHTML = '';
const today = new Date().toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 864e5).toISOString().split('T')[0];
const params = { action: 'list', type: 'reminder', my_only: 1, limit: 100, page: 1 };
if (_reminderView === 'upcoming') { params.status = 'scheduled'; params.date_from = today; }
if (_reminderView === 'today') { params.status = 'scheduled'; params.date_from = today; params.date_to = today; }
if (_reminderView === 'overdue') { params.status = 'scheduled'; params.date_to = new Date(Date.now() - 864e5).toISOString().split('T')[0]; }
if (_reminderView === 'done') { params.status = 'completed'; }
const res = await API.post('meetings/index', params);
const rems = res.data?.meetings || [];
if (!rems.length) {
const msgs = { upcoming: 'No upcoming reminders', today: 'Nothing today', overdue: 'Nothing overdue — great!', done: 'No dismissed reminders' };
wrap.innerHTML = `
${_reminderView === 'overdue' ? '🎉' : '🔔'}
${msgs[_reminderView] || 'No reminders'}
`;
return;
}
// Group by date
const groups = {};
rems.forEach(r => {
const d = r.meeting_date || 'someday';
if (!groups[d]) groups[d] = [];
groups[d].push(r);
});
wrap.innerHTML = Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)).map(([date, items]) => {
const isToday = date === today;
const isTom = date === tomorrow;
const isPast = date !== 'someday' && date < today;
const label = date === 'someday' ? 'Someday' : isToday ? 'Today' : isTom ? 'Tomorrow' : Fmt.date(date);
return `
${label}
${items.map(r => _remCard(r, today)).join('')}
`;
}).join('');
}
function _remCard(r, today) {
const done = r.status === 'completed';
const isPast = !done && r.meeting_date && r.meeting_date < today;
const p = TASK_PRIORITY[r.priority] || TASK_PRIORITY.normal;
const timeStr = r.start_time ? r.start_time.slice(0, 5) : '';
return `
${done ? '✅' : isPast ? '⚠️' : p.flag}
${mtgH(r.title)}
${r.description ? `
${mtgH(r.description)}
` : ''}
${timeStr ? `⏰ ${timeStr}` : ''}
${isPast ? ' · Overdue' : ''}
${!done ? `
` : ''}
`;
}
function _snoozeDate(days, timeOverride = '') {
const d = new Date(Date.now() + days * 864e5);
return { date: d.toISOString().split('T')[0], time: timeOverride };
}
function remToggleSnoozeMenu(id) {
// Close all other menus first
document.querySelectorAll('[id^="snooze-menu-"]').forEach(m => {
if (m.id !== `snooze-menu-${id}`) m.style.display = 'none';
});
const menu = document.getElementById(`snooze-menu-${id}`);
if (menu) menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
// Close on outside click
setTimeout(() => {
const close = (e) => {
if (!document.getElementById(`snooze-menu-${id}`)?.contains(e.target)) {
const m = document.getElementById(`snooze-menu-${id}`);
if (m) m.style.display = 'none';
document.removeEventListener('click', close);
}
};
document.addEventListener('click', close);
}, 50);
}
async function dismissReminder(id) {
await API.post('meetings/index', { action: 'dismiss_reminder', id });
Toast.show('Done ✓', 'success');
_loadReminders();
}
async function snoozeReminder(id, date, time = '') {
document.querySelectorAll('[id^="snooze-menu-"]').forEach(m => m.style.display = 'none');
await API.post('meetings/index', { action: 'snooze_reminder', id, new_date: date, new_time: time });
const d = new Date(date);
const label = date === new Date().toISOString().split('T')[0] ? 'today' : Fmt.date(date);
Toast.show(`Snoozed to ${label}${time ? ' at ' + time : ''} 💤`, 'success');
_loadReminders();
}
function openSnoozeModal(id) {
const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
Modal.open({
id: 'snooze-modal', title: '💤 Snooze Reminder',
body: ``,
footer: `
`,
});
}
function openReminderModal(editId = 0) {
(async () => {
let r = null;
if (editId) { const res = await API.post('meetings/index', { action: 'get', id: editId }); if (res.success) r = res.data.meeting; }
const v = (k, d = '') => r ? (r[k] ?? d) : d;
const today = new Date().toISOString().split('T')[0];
Modal.open({
id: 'reminder-modal', title: editId ? 'Edit Reminder' : 'New Reminder',
body: ``,
footer: `
`,
});
})();
}
// ── Shared save & delete ───────────────────────────────────────
async function submitTaskOrReminder(formId, editId = 0) {
const modalId = formId === 'task-form' ? 'task-modal' : 'reminder-modal';
const btn = document.querySelector(`#modal-${modalId} .btn-primary`);
if (btn) btn.classList.add('loading');
const res = await API.post('meetings/index', { action: 'save', ...getFormData(formId) });
if (btn) btn.classList.remove('loading');
if (res.success) {
Toast.show(editId ? 'Saved ✓' : 'Created ✓', 'success');
Modal.close();
if (document.getElementById('task-main')) _loadTasks();
if (document.getElementById('rem-list')) _loadReminders();
} else Toast.show(res.message, 'error');
}
async function deleteTaskOrReminder(id) {
if (!await Modal.confirm('Delete this item?', 'Delete', true)) return;
const res = await API.post('meetings/index', { action: 'delete', id });
if (res.success) {
Toast.show('Deleted.', 'success');
if (document.getElementById('task-main')) _loadTasks();
if (document.getElementById('rem-list')) _loadReminders();
} else Toast.show(res.message, 'error');
}