/**
* 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 `
`;
}
/**
* 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