/** * GoingsOn - Contacts Module * Contact CRUD, card grid, detail modal, sub-collection management */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttr; // ============ Selection State ============ const selectedIds = new Set(); function toggleSelection(id, event) { if (event) event.stopPropagation(); if (selectedIds.has(id)) { selectedIds.delete(id); } else { selectedIds.add(id); } updateSelectionUI(); } function selectAll() { const contacts = GoingsOn.state.contacts || []; contacts.forEach(c => selectedIds.add(c.id)); updateSelectionUI(); } function clearSelection() { selectedIds.clear(); updateSelectionUI(); } function updateSelectionUI() { // Update checkbox states document.querySelectorAll('.contact-select-cb').forEach(cb => { cb.checked = selectedIds.has(cb.dataset.id); }); // Show/hide bulk bar const bar = document.getElementById('contacts-bulk-bar'); if (bar) { bar.classList.toggle('hidden', selectedIds.size === 0); const count = bar.querySelector('.bulk-count'); if (count) count.textContent = `${selectedIds.size} selected`; } } async function bulkDelete() { const count = selectedIds.size; if (count === 0) return; if (!await GoingsOn.ui.confirmDelete(`${count} contact${count > 1 ? 's' : ''}`)) return; const ids = [...selectedIds]; GoingsOn.cache.invalidate('contacts'); try { await GoingsOn.api.contacts.bulkDelete(ids); selectedIds.clear(); GoingsOn.ui.showToast(`${count} contact${count > 1 ? 's' : ''} deleted`, 'success'); load(); } catch (err) { GoingsOn.ui.showToast('Failed to delete contacts: ' + GoingsOn.utils.getErrorMessage(err), 'error'); } } async function bulkTag() { const count = selectedIds.size; if (count === 0) return; const tag = await GoingsOn.ui.showPromptDialog( `Tag ${count} contact${count !== 1 ? 's' : ''}`, 'Tag to add:', { placeholder: 'e.g. follow-up', confirmText: 'Add tag' } ); if (!tag) return; const ids = [...selectedIds]; GoingsOn.cache.invalidate('contacts'); GoingsOn.ui.bulkActionWithUndo({ ids, label: `Tagged "${tag}" on`, itemType: 'contact', apply: (ids) => { const idSet = new Set(ids); const cached = GoingsOn.state.contacts || []; // Capture per-contact whether the tag was already present, so revert // only removes the tag from contacts that didn't already have it. const newlyTagged = new Set(); const next = cached.map(c => { if (!idSet.has(c.id)) return c; const tags = c.tags || []; if (tags.includes(tag)) return c; newlyTagged.add(c.id); return { ...c, tags: [...tags, tag] }; }); GoingsOn.state.set('contacts', next); selectedIds.clear(); load(); return newlyTagged; }, revert: (newlyTagged) => { const cached = GoingsOn.state.contacts || []; GoingsOn.state.set('contacts', cached.map(c => newlyTagged.has(c.id) ? { ...c, tags: (c.tags || []).filter(t => t !== tag) } : c )); load(); }, commit: async (ids) => { const affected = await GoingsOn.api.contacts.bulkTag(ids, tag); if (typeof affected === 'number' && affected !== ids.length) { GoingsOn.ui.showToast(`Tagged ${affected} contact${affected !== 1 ? 's' : ''}`, 'info'); } load(); }, errorMessage: 'Failed to tag contacts', }); } // ============ Sub-Collection Configuration ============ const SUB_COLLECTIONS = { email: { formId: 'add-contact-email-form', modalTitle: 'Add Email Address', fields: [ { name: 'address', label: 'Email Address', type: 'email', required: true, id: 'ce-address', placeholder: 'jane@example.com' }, { name: 'label', label: 'Label', type: 'text', required: false, id: 'ce-label', placeholder: 'Work, Personal, etc.' }, { name: 'is_primary', label: 'Primary email', type: 'checkbox', required: false, id: 'ce-primary' }, ], collectData: (form) => ({ address: form.address.value.trim(), label: form.label.value.trim() || null, isPrimary: form.is_primary.checked, }), validate: (form) => { if (!form.address.value.trim()) return 'Email address is required'; return null; }, addCommand: 'addEmail', removeCommand: 'removeEmail', updateCommand: 'updateEmail', entityLabel: 'email', deleteLabel: 'this email address', submitButtonText: 'Add Email', editModalTitle: 'Edit Email Address', editButtonText: 'Save Email', prefill: (form, row) => { form.address.value = row.address || ''; form.label.value = row.label || ''; form.is_primary.checked = !!row.isPrimary; }, }, phone: { formId: 'add-contact-phone-form', modalTitle: 'Add Phone Number', fields: [ { name: 'number', label: 'Phone Number', type: 'tel', required: true, id: 'cp-number', placeholder: '+1 555-123-4567' }, { name: 'label', label: 'Label', type: 'text', required: false, id: 'cp-label', placeholder: 'Mobile, Work, Home' }, { name: 'is_primary', label: 'Primary phone', type: 'checkbox', required: false, id: 'cp-primary' }, ], collectData: (form) => ({ number: form.number.value.trim(), label: form.label.value.trim() || null, isPrimary: form.is_primary.checked, }), validate: (form) => { if (!form.number.value.trim()) return 'Phone number is required'; return null; }, addCommand: 'addPhone', removeCommand: 'removePhone', updateCommand: 'updatePhone', entityLabel: 'phone', deleteLabel: 'this phone number', submitButtonText: 'Add Phone', editModalTitle: 'Edit Phone Number', editButtonText: 'Save Phone', prefill: (form, row) => { form.number.value = row.number || ''; form.label.value = row.label || ''; form.is_primary.checked = !!row.isPrimary; }, }, social: { formId: 'add-contact-social-form', modalTitle: 'Add Social Handle', fields: [ { name: 'platform', label: 'Platform', type: 'text', required: true, id: 'cs-platform', placeholder: 'Twitter, LinkedIn, GitHub...' }, { name: 'handle', label: 'Handle', type: 'text', required: true, id: 'cs-handle', placeholder: '@username' }, { name: 'url', label: 'Profile URL (optional)', type: 'url', required: false, id: 'cs-url', placeholder: 'https://twitter.com/username' }, ], collectData: (form) => ({ platform: form.platform.value.trim(), handle: form.handle.value.trim(), url: form.url.value.trim() || null, }), validate: (form) => { if (!form.platform.value.trim() || !form.handle.value.trim()) return 'Platform and handle are required'; return null; }, addCommand: 'addSocialHandle', removeCommand: 'removeSocialHandle', updateCommand: 'updateSocialHandle', entityLabel: 'social handle', submitButtonText: 'Add Handle', editModalTitle: 'Edit Social Handle', editButtonText: 'Save Handle', prefill: (form, row) => { form.platform.value = row.platform || ''; form.handle.value = row.handle || ''; form.url.value = row.url || ''; }, }, customField: { formId: 'add-contact-custom-field-form', modalTitle: 'Add Custom Field', fields: [ { name: 'label', label: 'Label', type: 'text', required: true, id: 'cf-label', placeholder: 'Reddit, Portfolio, etc.' }, { name: 'value', label: 'Value', type: 'text', required: true, id: 'cf-value', placeholder: 'username or display text' }, { name: 'url', label: 'URL (optional)', type: 'url', required: false, id: 'cf-url', placeholder: 'https://reddit.com/u/username' }, ], collectData: (form) => ({ label: form.label.value.trim(), value: form.value.value.trim(), url: form.url.value.trim() || null, }), validate: (form) => { if (!form.label.value.trim() || !form.value.value.trim()) return 'Label and value are required'; return null; }, addCommand: 'addCustomField', removeCommand: 'removeCustomField', updateCommand: 'updateCustomField', entityLabel: 'custom field', submitButtonText: 'Add Field', editModalTitle: 'Edit Custom Field', editButtonText: 'Save Field', prefill: (form, row) => { form.label.value = row.label || ''; form.value.value = row.value || ''; form.url.value = row.url || ''; }, }, }; // ============ Generic Sub-Collection Functions ============ const ADD_SUBMIT_FN = { email: 'submitAddEmail', phone: 'submitAddPhone', social: 'submitAddSocial', customField: 'submitAddCustomField', }; const EDIT_SUBMIT_FN = { email: 'submitEditEmail', phone: 'submitEditPhone', social: 'submitEditSocial', customField: 'submitEditCustomField', }; /** * Build the HTML form for adding or editing a sub-collection item. * @param {string} type - Sub-collection type key from SUB_COLLECTIONS * @param {string} contactId - Parent contact ID * @param {string|null} editingId - When set, render as edit form (submit routes to update). * @returns {string} HTML string for the form */ function buildSubCollectionFormHtml(type, contactId, editingId = null) { const config = SUB_COLLECTIONS[type]; const fieldHtml = config.fields.map(f => { if (f.type === 'checkbox') { return `
`; } return `
`; }).join(''); const isEdit = !!editingId; const submitFn = isEdit ? EDIT_SUBMIT_FN[type] : ADD_SUBMIT_FN[type]; const submitText = isEdit ? (config.editButtonText || 'Save') : config.submitButtonText; const onclick = isEdit ? `GoingsOn.contacts.${submitFn}('${escAttr(contactId)}', '${escAttr(editingId)}')` : `GoingsOn.contacts.${submitFn}('${escAttr(contactId)}')`; return `
${fieldHtml}
`; } /** * Open a modal to add a sub-collection item to a contact. * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField') * @param {string} contactId - Parent contact ID */ function openAddSubCollection(type, contactId) { const config = SUB_COLLECTIONS[type]; const content = buildSubCollectionFormHtml(type, contactId); GoingsOn.ui.openModal(config.modalTitle, content); } /** * Open a modal to edit an existing sub-collection item, prefilled with its current values. * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField') * @param {string} contactId - Parent contact ID * @param {Object} row - Existing sub-collection row (the JSON shape returned by the backend) */ function openEditSubCollection(type, contactId, row) { const config = SUB_COLLECTIONS[type]; if (!row || !row.id) return; const content = buildSubCollectionFormHtml(type, contactId, row.id); GoingsOn.ui.openModal(config.editModalTitle || config.modalTitle, content); // openModal injects content synchronously; the form fields are now in the DOM. const form = document.getElementById(config.formId); if (form && config.prefill) config.prefill(form, row); } /** * Validate and submit a sub-collection add form. * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField') * @param {string} contactId - Parent contact ID */ async function submitSubCollection(type, contactId) { const config = SUB_COLLECTIONS[type]; const form = document.getElementById(config.formId); if (!form) return; const error = config.validate ? config.validate(form) : null; if (error) { GoingsOn.ui.showToast(error, 'error'); return; } const input = config.collectData(form); GoingsOn.cache.invalidate('contacts'); await GoingsOn.ui.apiCall(GoingsOn.api.contacts[config.addCommand](contactId, input), { successMessage: `${config.entityLabel.charAt(0).toUpperCase() + config.entityLabel.slice(1)} added!`, errorMessage: `Failed to add ${config.entityLabel}`, reload: async () => { await load(); openEdit(contactId); }, }); } /** * Validate and submit a sub-collection edit form. * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField') * @param {string} contactId - Parent contact ID * @param {string} itemId - Sub-collection row ID being updated */ async function submitEditSubCollection(type, contactId, itemId) { const config = SUB_COLLECTIONS[type]; const form = document.getElementById(config.formId); if (!form) return; const error = config.validate ? config.validate(form) : null; if (error) { GoingsOn.ui.showToast(error, 'error'); return; } const input = config.collectData(form); GoingsOn.cache.invalidate('contacts'); await GoingsOn.ui.apiCall(GoingsOn.api.contacts[config.updateCommand](itemId, input), { successMessage: `${config.entityLabel.charAt(0).toUpperCase() + config.entityLabel.slice(1)} updated`, errorMessage: `Failed to update ${config.entityLabel}`, reload: async () => { await load(); openEdit(contactId); }, }); } /** * Remove a sub-collection item from a contact with confirmation. * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField') * @param {string} contactId - Parent contact ID * @param {string} itemId - Sub-collection item ID to remove */ async function removeSubCollection(type, contactId, itemId) { const config = SUB_COLLECTIONS[type]; if (!await GoingsOn.ui.confirmDelete(config.deleteLabel || `this ${config.entityLabel}`)) return; GoingsOn.cache.invalidate('contacts'); await GoingsOn.ui.apiCall(GoingsOn.api.contacts[config.removeCommand](itemId), { successMessage: `${config.entityLabel.charAt(0).toUpperCase() + config.entityLabel.slice(1)} removed`, errorMessage: `Failed to remove ${config.entityLabel}`, reload: async () => { await load(); openEdit(contactId); }, }); } // ============ Sub-Collection Wrappers (backward-compatible) ============ function openAddEmail(cid) { openAddSubCollection('email', cid); } function submitAddEmail(cid) { submitSubCollection('email', cid); } function removeEmail(cid, id) { removeSubCollection('email', cid, id); } function openEditEmail(cid, id) { openEditSubCollection('email', cid, findSubRow(cid, 'emails', id)); } function submitEditEmail(cid, id) { submitEditSubCollection('email', cid, id); } function openAddPhone(cid) { openAddSubCollection('phone', cid); } function submitAddPhone(cid) { submitSubCollection('phone', cid); } function removePhone(cid, id) { removeSubCollection('phone', cid, id); } function openEditPhone(cid, id) { openEditSubCollection('phone', cid, findSubRow(cid, 'phones', id)); } function submitEditPhone(cid, id) { submitEditSubCollection('phone', cid, id); } function openAddSocial(cid) { openAddSubCollection('social', cid); } function submitAddSocial(cid) { submitSubCollection('social', cid); } function removeSocialHandle(cid, id) { removeSubCollection('social', cid, id); } function openEditSocial(cid, id) { openEditSubCollection('social', cid, findSubRow(cid, 'socialHandles', id)); } function submitEditSocial(cid, id) { submitEditSubCollection('social', cid, id); } function openAddCustomField(cid) { openAddSubCollection('customField', cid); } function submitAddCustomField(cid) { submitSubCollection('customField', cid); } function removeCustomField(cid, id) { removeSubCollection('customField', cid, id); } function openEditCustomField(cid, id) { openEditSubCollection('customField', cid, findSubRow(cid, 'customFields', id)); } function submitEditCustomField(cid, id) { submitEditSubCollection('customField', cid, id); } /** * Look up a sub-collection row by id from the cached `GoingsOn.state.contacts` * list. Keeps the inline edit buttons honest with the current data without * passing JSON payloads through HTML attributes. */ function findSubRow(contactId, field, rowId) { const contact = (GoingsOn.state.contacts || []).find(c => c.id === contactId); if (!contact) return null; const list = contact[field] || []; return list.find(r => r.id === rowId) || null; } // ============ Form Field Definitions ============ /** * Build form field definitions for the contact create/edit modal. * @param {Object|null} contact - Existing contact for edit mode, or null for create * @returns {FormField[]} Array of form field definitions */ function getContactFormFields(contact = null) { return [ { name: 'display_name', type: 'text', label: 'Name', placeholder: 'Jane Smith', required: true, value: contact?.displayName || '', }, { name: 'nickname', type: 'text', label: 'Nickname', placeholder: 'Optional nickname', value: contact?.nickname || '', }, { name: 'company', type: 'text', label: 'Company', placeholder: 'Acme Corp', value: contact?.company || '', }, { name: 'title', type: 'text', label: 'Title', placeholder: 'Software Engineer', value: contact?.title || '', }, { name: 'tags', type: 'text', label: 'Tags (comma-separated)', placeholder: 'friend, coworker', value: contact?.tags?.join(', ') || '', }, { name: 'birthday', type: 'text', label: 'Birthday (YYYY-MM-DD)', placeholder: '1990-01-15', value: contact?.birthday || '', }, { name: 'timezone', type: 'text', label: 'Timezone', placeholder: 'America/New_York', value: contact?.timezone || '', }, { name: 'notes', type: 'textarea', label: 'Notes', placeholder: 'Any notes about this contact...', value: contact?.notes || '', }, ]; } // ============ Helpers ============ function parseTags(tagString) { return GoingsOn.utils.normalizeTags(tagString); } /** * Extract all unique tags from a list of contacts, sorted alphabetically. * @param {Array} contacts - Contact objects with optional tags arrays * @returns {string[]} Sorted array of unique tag strings */ function getAllTags(contacts) { const tagSet = new Set(); contacts.forEach(c => (c.tags || []).forEach(t => tagSet.add(t))); return [...tagSet].sort(); } // ============ Rendering (delegated to contacts-render.js) ============ const renderCard = GoingsOn.contactsRender.renderCard; function updateTagFilter(contacts) { const select = document.getElementById('contacts-tag-filter'); if (!select) return; const tags = getAllTags(contacts); const current = select.value; select.innerHTML = '' + tags.map(t => ``).join(''); } // ============ Core Functions ============ async function load() { if (GoingsOn.cache.isFresh('contacts')) return; // Phase 7 Tier 4 — restore filter state from URL. restoreFiltersFromUrl(); const grid = document.getElementById('contacts-grid'); try { const sq = GoingsOn.state.contactsSearchQuery || ''; const tf = GoingsOn.state.contactsTagFilter || ''; const contacts = await GoingsOn.api.contacts.listFiltered(sq, tf); GoingsOn.state.set('contacts', contacts); // Update tag filter from full list (no search/tag filter) if needed if (!sq && !tf) { updateTagFilter(contacts); } render(contacts); GoingsOn.cache.markLoaded('contacts'); } catch (err) { GoingsOn.utils.showError(grid, err, 'Failed to load contacts'); GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load contacts'), 'error', { action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('contacts'); load(); } }, duration: 8000, }); } } function render(contacts) { const grid = document.getElementById('contacts-grid'); if (!contacts) contacts = GoingsOn.state.contacts || []; const filtered = contacts; if (filtered.length === 0) { if (contacts.length === 0) { grid.innerHTML = GoingsOn.ui.renderEmptyState('No contacts yet.', 'Add Contact', 'GoingsOn.contacts.openNew()', 'contacts'); } else { grid.innerHTML = GoingsOn.ui.renderEmptyState('No contacts match your filters.'); } return; } grid.innerHTML = filtered.map(renderCard).join(''); } // ============ CRUD ============ function openNew() { GoingsOn.ui.openFormModal({ title: 'New Contact', entityType: 'contact', isEdit: false, fields: getContactFormFields(), onSubmit: create, }); } async function create(data) { const input = { displayName: data.display_name, nickname: data.nickname || null, company: data.company || null, title: data.title || null, tags: parseTags(data.tags), birthday: data.birthday || null, timezone: data.timezone || null, notes: data.notes || '', }; GoingsOn.cache.invalidate('contacts'); await GoingsOn.ui.apiCall(GoingsOn.api.contacts.create(input), { successMessage: 'Contact created!', errorMessage: 'Failed to create contact', reload: load, }); } async function openEdit(id) { const contact = (GoingsOn.state.contacts || []).find(c => c.id === id); if (!contact) return; // Build sub-collection summaries for the edit form. Each row carries // inline Edit / Remove buttons; data is looked up by id from // `GoingsOn.state.contacts` rather than smuggled through HTML attrs. const rowActions = (kind, rowId) => ` `; const emailSummary = (contact.emails || []).map(e => `
${esc(e.address)}${e.label ? ` (${esc(e.label)})` : ''}${e.isPrimary ? ' Primary' : ''}${rowActions('Email', e.id)}
` ).join('') || 'None'; const phoneSummary = (contact.phones || []).map(p => `
${esc(p.number)}${p.label ? ` (${esc(p.label)})` : ''}${p.isPrimary ? ' Primary' : ''}${rowActions('Phone', p.id)}
` ).join('') || 'None'; const socialSummary = (contact.socialHandles || []).map(s => `
${esc(s.platform)}: ${esc(s.handle)}${rowActions('Social', s.id)}
` ).join('') || 'None'; const customFieldSummary = (contact.customFields || []).map(f => `
${esc(f.label)}: ${esc(f.value)}${rowActions('CustomField', f.id)}
` ).join('') || 'None'; GoingsOn.ui.openFormModal({ title: 'Edit Contact', entityType: 'contact', isEdit: true, entityId: id, fields: getContactFormFields(contact), onSubmit: (data) => update(id, data), extraContent: `
Emails
${emailSummary}
Phones
${phoneSummary}
Social
${socialSummary}
Custom Fields
${customFieldSummary}
`, }); } async function update(id, data) { const input = { displayName: data.display_name, nickname: data.nickname || null, company: data.company || null, title: data.title || null, tags: parseTags(data.tags), birthday: data.birthday || null, timezone: data.timezone || null, notes: data.notes || '', }; GoingsOn.cache.invalidate('contacts'); await GoingsOn.ui.apiCall(GoingsOn.api.contacts.update(id, input), { successMessage: 'Contact updated!', errorMessage: 'Failed to update contact', reload: load, }); } async function deleteContact(id) { if (!await GoingsOn.ui.confirmDelete('contact')) return; GoingsOn.cache.invalidate('contacts'); await GoingsOn.ui.apiCall(GoingsOn.api.contacts.delete(id), { successMessage: 'Contact deleted!', errorMessage: 'Failed to delete contact', reload: load, }); } // ============ Detail Modal ============ /** * Fetch a contact by ID and open its detail modal. * @param {string} id - Contact ID to open */ async function open(id) { GoingsOn.contactDashboard.open(id); } function showDetailModal(contact) { GoingsOn.contactsRender.showDetailModal(contact); } // ============ Filtering ============ /** * Filter contacts by search query (server-side filtering). * @param {string} query - Search text to filter by */ function filterBySearch(query) { const trimmed = query.trim(); GoingsOn.state.set('contactsSearchQuery', trimmed); GoingsOn.queryState?.write('q', trimmed); GoingsOn.cache.invalidate('contacts'); load(); } /** * Filter contacts by tag (server-side filtering). * @param {string} tag - Tag to filter by, or empty string for all */ function filterByTag(tag) { GoingsOn.state.set('contactsTagFilter', tag); GoingsOn.queryState?.write('tag', tag); GoingsOn.cache.invalidate('contacts'); load(); } /** * Phase 7 Tier 4 — restore search / tag filter from URL on first load. */ function restoreFiltersFromUrl() { if (!GoingsOn.queryState) return; const q = GoingsOn.queryState.readMany(['q', 'tag']); if (q.q) { GoingsOn.state.set('contactsSearchQuery', q.q); const input = document.getElementById('contacts-search'); if (input) input.value = q.q; } if (q.tag) { GoingsOn.state.set('contactsTagFilter', q.tag); const sel = document.getElementById('contacts-tag-filter'); if (sel) sel.value = q.tag; } } // ============ Populate GoingsOn.contacts Namespace ============ GoingsOn.contacts = { load, openNew, open, openEdit, deleteContact, filterBySearch, filterByTag, // Bulk operations toggleSelection, selectAll, clearSelection, bulkDelete, bulkTag, // Sub-collection openAddEmail, submitAddEmail, removeEmail, openEditEmail, submitEditEmail, openAddPhone, submitAddPhone, removePhone, openEditPhone, submitEditPhone, openAddSocial, submitAddSocial, removeSocialHandle, openEditSocial, submitEditSocial, openAddCustomField, submitAddCustomField, removeCustomField, openEditCustomField, submitEditCustomField, }; })();