// ============================================================ // 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 = `

Events

Meetings, tasks and reminders

${canCreate ? `` : ''}
`; _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 = `
${res.message}
`; 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 = `
${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 ``; }).join('')}
TitleTypeDate & TimeAttendeesStatusPriority
${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)}
`; } 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 = `

${mtgH(m.title)}

${tm.icon} ${Fmt.capitalize(m.type)} ${Fmt.date(m.meeting_date)} · ${timeStr} ${m.location ? ` · 📍 ${mtgH(m.location)}` : ''}

${!isCompleted && !isActive && canEdit ? `` : ''} ${isActive ? `` : ''} ${canEdit ? `` : ''} ${isCompleted ? `✓ Completed` : ''}
📋 Details
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)}

` : ''}
🎙 Recording
Click "Start Recording" to record the meeting audio.
${(m.files || []).filter(f => f.file_type === 'recording').map(f => _mtgFileCard(f)).join('')}
📝 Meeting Notes
${m.notes && m.notes.length ? m.notes.map(n => _mtgNoteCard(n)).join('') : '

No notes yet.

'}
📎 Files & Images
${(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.

'}
👥 Attendees (${(m.attendees || []).length})
${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 = `
Proposed
${rows}
`; } } 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 `
📄 ${mtgH(f.original_name)}
`; } 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: `
${editId ? `` : ''}
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 = `
${[ { key: 'inbox', icon: '📥', label: 'Inbox', desc: 'All pending' }, { key: 'today', icon: '☀️', label: 'Today', desc: 'Due today' }, { key: 'upcoming', icon: '📅', label: 'Upcoming', desc: 'Next 7 days' }, { key: 'overdue', icon: '🔥', label: 'Overdue', desc: 'Past due' }, { key: 'completed', icon: '✅', label: 'Completed', desc: 'Done' }, ].map(v => ` `).join('')}
${Object.entries(TASK_PRIORITY).map(([k, p]) => ` `).join('')}
`; // 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 `
${p.flag}
${done ? '' : ''}
${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)}
` : ''}
Loading…
`; } 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 `
${it.is_done ? '' : ''}
${mtgH(it.text)}
`; } 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: `
${editId ? `` : ''}
`, 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('')}
🔔 New Reminder
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('')}
${Object.entries(TASK_PRIORITY).map(([k, p]) => ` `).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: `
${editId ? `` : ''}
`, 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'); }