/** * GoingsOn - Task Forms Module * Form field definitions, option builders, and milestone select helper. * Loaded before tasks.js -- populates GoingsOn.taskForms namespace. */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttr; // ============ Form Field Constants ============ const PRIORITIES = [ { value: 'Low', label: 'Low' }, { value: 'Medium', label: 'Medium' }, { value: 'High', label: 'High' }, ]; const RECURRENCE_OPTIONS = [ { value: 'None', label: 'None' }, { value: 'Daily', label: 'Daily' }, { value: 'Weekly', label: 'Weekly' }, { value: 'Monthly', label: 'Monthly' }, ]; const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; /** * Build recurrence config HTML (interval, weekday checkboxes, monthly spec). * Shown below the recurrence select when pattern != None. * @param {Object|null} rule - Existing recurrence rule, or null * @param {string} prefix - ID prefix for form fields * @returns {string} HTML string for the config panel */ function buildRecurrenceConfigHtml(rule, prefix) { const interval = rule?.interval || 1; const weekdays = rule?.weekdays || []; const monthlySpec = rule?.monthlySpec || null; const weekdayCheckboxes = WEEKDAY_LABELS.map((label, i) => { const checked = weekdays.includes(i) ? 'checked' : ''; return ``; }).join(''); const monthlyDom = monthlySpec?.type === 'dayOfMonth' ? monthlySpec.day : ''; const monthlyWeek = monthlySpec?.type === 'nthWeekday' ? monthlySpec.week : 1; const monthlyWd = monthlySpec?.type === 'nthWeekday' ? monthlySpec.weekday : 0; const monthlyType = monthlySpec?.type || 'dayOfMonth'; const hasNthWeekday = monthlyType === 'nthWeekday'; return `
time(s)
${weekdayCheckboxes}
`; } /** * Initialize recurrence config panel visibility and event handlers. * Call after the form modal is rendered. * @param {string} prefix - ID prefix matching buildRecurrenceConfigHtml * @param {string} selectName - Name of the recurrence select element */ /** * Build a human-readable description of the current recurrence rule. * @param {string} prefix - ID prefix for form fields * @param {string} pattern - Recurrence pattern (Daily, Weekly, Monthly) * @returns {string} Preview text (e.g., "Repeats every 2 weeks on Mon, Wed, Fri") */ function buildRecurrencePreview(prefix, pattern) { if (pattern === 'None') return ''; const form = document.querySelector('.modal-content form'); if (!form) return ''; const interval = parseInt(form.elements[`${prefix}-interval`]?.value) || 1; const unitSingular = { Daily: 'day', Weekly: 'week', Monthly: 'month' }[pattern] || ''; const unitPlural = unitSingular + 's'; const unit = interval === 1 ? unitSingular : `${interval} ${unitPlural}`; let detail = ''; if (pattern === 'Weekly') { const days = []; for (let i = 0; i < 7; i++) { if (form.elements[`${prefix}-weekday-${i}`]?.checked) { days.push(WEEKDAY_LABELS[i]); } } if (days.length > 0) detail = ` on ${days.join(', ')}`; } if (pattern === 'Monthly') { const monthlyType = form.querySelector(`[name="${prefix}-monthly-type"]:checked`)?.value || 'dayOfMonth'; if (monthlyType === 'dayOfMonth') { const day = parseInt(form.elements[`${prefix}-monthly-day`]?.value) || 1; detail = ` on day ${day}`; } else { const weekLabels = { '1': '1st', '2': '2nd', '3': '3rd', '4': '4th', '-1': 'last' }; const week = form.elements[`${prefix}-monthly-week`]?.value || '1'; const wd = parseInt(form.elements[`${prefix}-monthly-weekday`]?.value) || 0; detail = ` on the ${weekLabels[week] || week} ${WEEKDAY_LABELS[wd]}`; } } return `Repeats every ${unit}${detail}`; } function initRecurrenceConfig(prefix, selectName) { const form = document.querySelector('.modal-content form'); if (!form) return; const select = form.elements[selectName]; const config = document.getElementById(`${prefix}-recurrence-config`); const weekdaysSection = document.getElementById(`${prefix}-weekdays-section`); const monthlySection = document.getElementById(`${prefix}-monthly-section`); const intervalUnit = document.getElementById(`${prefix}-interval-unit`); const previewEl = document.getElementById(`${prefix}-recurrence-preview`); if (!select || !config) return; function updatePreview() { if (previewEl) { previewEl.textContent = buildRecurrencePreview(prefix, select.value); } } function updateVisibility() { const pattern = select.value; config.style.display = pattern === 'None' ? 'none' : 'block'; if (weekdaysSection) weekdaysSection.style.display = pattern === 'Weekly' ? 'block' : 'none'; if (monthlySection) monthlySection.style.display = pattern === 'Monthly' ? 'block' : 'none'; if (intervalUnit) { const units = { Daily: 'day(s)', Weekly: 'week(s)', Monthly: 'month(s)' }; intervalUnit.textContent = units[pattern] || 'time(s)'; } updatePreview(); } select.addEventListener('change', updateVisibility); updateVisibility(); // Listen for changes to interval, weekday checkboxes, and monthly options to update preview const intervalInput = form.elements[`${prefix}-interval`]; if (intervalInput) intervalInput.addEventListener('input', updatePreview); for (let i = 0; i < 7; i++) { const cb = form.elements[`${prefix}-weekday-${i}`]; if (cb) cb.addEventListener('change', updatePreview); } const monthlyDay = form.elements[`${prefix}-monthly-day`]; if (monthlyDay) monthlyDay.addEventListener('input', updatePreview); form.querySelectorAll(`[name="${prefix}-monthly-type"]`).forEach(radio => { radio.addEventListener('change', updatePreview); }); const monthlyWeekSel = form.elements[`${prefix}-monthly-week`]; const monthlyWdSel = form.elements[`${prefix}-monthly-weekday`]; if (monthlyWeekSel) monthlyWeekSel.addEventListener('change', updatePreview); if (monthlyWdSel) monthlyWdSel.addEventListener('change', updatePreview); } /** * Collect recurrence rule from the config panel form elements. * @param {HTMLFormElement} form - The form element * @param {string} prefix - ID prefix * @param {string} pattern - Current recurrence pattern value * @returns {Object|null} RecurrenceRule JSON object, or null if None */ function collectRecurrenceRule(form, prefix, pattern) { if (pattern === 'None') return null; const interval = parseInt(form.elements[`${prefix}-interval`]?.value) || 1; const rule = { pattern, interval, weekdays: [], monthlySpec: null }; if (pattern === 'Weekly') { for (let i = 0; i < 7; i++) { const cb = form.elements[`${prefix}-weekday-${i}`]; if (cb?.checked) rule.weekdays.push(i); } } if (pattern === 'Monthly') { const monthlyType = form.querySelector(`[name="${prefix}-monthly-type"]:checked`)?.value || 'dayOfMonth'; if (monthlyType === 'dayOfMonth') { const day = parseInt(form.elements[`${prefix}-monthly-day`]?.value) || 1; rule.monthlySpec = { type: 'dayOfMonth', day }; } else { const week = parseInt(form.elements[`${prefix}-monthly-week`]?.value) || 1; const weekday = parseInt(form.elements[`${prefix}-monthly-weekday`]?.value) || 0; rule.monthlySpec = { type: 'nthWeekday', week, weekday }; } } // If it's a simple rule (interval 1, no extras), still return it for consistency return rule; } const STATUS_OPTIONS = [ { value: 'Pending', label: 'Pending' }, { value: 'Started', label: 'Started' }, { value: 'Completed', label: 'Completed' }, ]; /** * Build project options for a select field. * @param {string|null} [selectedProjectId=null] - Currently selected project ID * @returns {Array<{value: string, label: string, selected: boolean}>} */ function getProjectOptions(selectedProjectId = null) { return GoingsOn.getProjectsCache().map(p => ({ value: p.id, label: p.name, selected: p.id === selectedProjectId, })); } /** * Build contact options for a select field. * @param {string|null} [selectedContactId=null] - Currently selected contact ID * @returns {Array<{value: string, label: string, selected: boolean}>} */ function getContactOptions(selectedContactId = null) { return (GoingsOn.state.contacts || []).map(c => ({ value: c.id, label: c.displayName || c.display_name, selected: c.id === selectedContactId, })); } /** * Build form field definitions for the task create/edit modal. * @param {Object|null} [task=null] - Existing task for edit, or null for create * @param {string|null} [presetProjectId=null] - Pre-selected project ID * @returns {FormField[]} Array of form field definitions */ function getTaskFormFields(task = null, presetProjectId = null) { const isEdit = !!task; const dueValue = task?.due ? (() => { const d = new Date(task.due); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; })() : ''; const tagsValue = task?.tags ? task.tags.join(', ') : ''; const fields = [ { name: 'description', type: 'text', label: 'Description', placeholder: 'What needs to be done?', required: true, value: task?.description || '', validate: (v) => v && v.length > 500 ? 'Maximum 500 characters' : null, }, { name: 'project_id', type: 'select', label: 'Project', options: [ { value: '', label: 'No Project' }, ...getProjectOptions(task?.project_id || presetProjectId), ], value: task?.project_id || presetProjectId || '', }, ]; if (isEdit) { fields.push({ name: 'status', type: 'select', label: 'Status', options: STATUS_OPTIONS.map(s => ({ ...s, selected: task?.status === s.value, })), value: task?.status || 'Pending', }); } fields.push( { name: 'priority', type: 'select', label: 'Priority', options: PRIORITIES.map(p => ({ ...p, selected: task?.priority === p.value, })), value: task?.priority || 'Medium', }, { name: 'due', type: 'text', label: 'Due Date (optional)', placeholder: 'tomorrow, friday 3pm, 2026-12-25...', value: dueValue, transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v, onInput: GoingsOn.utils.dateParsePreview, validate: (v) => { if (!v || !v.trim()) return null; const parsed = GoingsOn.utils.parseNaturalDate(v); if (!parsed && !/^\d{4}-\d{2}-\d{2}/.test(v.trim())) { return 'Date not recognized. Try "tomorrow", "friday 3pm", or "2026-12-25".'; } return null; }, }, { name: 'tags', type: 'text', label: 'Tags (comma-separated)', placeholder: 'work, urgent, meeting', value: tagsValue, transform: (v) => GoingsOn.utils.normalizeTags(v).join(', '), extended: true, }, { name: 'recurrence', type: 'select', label: 'Recurrence', hint: 'Completing a recurring task auto-creates the next occurrence', hintExtraHtml: buildRecurrenceConfigHtml(task?.recurrenceRule, 'task'), options: RECURRENCE_OPTIONS.map(r => ({ ...r, selected: task?.recurrence === r.value, })), value: task?.recurrence || 'None', extended: true, }, { name: 'estimated_minutes', type: 'number', label: 'Estimated Time (minutes)', placeholder: 'e.g. 30, 60, 120', hint: 'Used for day plan scheduling and time tracking progress', value: task?.estimatedMinutes || '', extended: true, }, { name: 'contact_id', type: 'select', label: 'Contact', options: [ { value: '', label: 'No Contact' }, ...getContactOptions(task?.contactId), ], value: task?.contactId || '', extended: true, }, { name: 'milestone_id', type: 'select', label: 'Milestone', hint: 'Group tasks into project phases — milestones are managed per project', options: [ { value: '', label: 'No Milestone' }, ], value: task?.milestoneId || '', extended: true, } ); return fields; } // ============ Milestone Select Helper ============ /** * Wire up the milestone select to update options when the project changes. * @param {string} entityType - Entity type for form ID lookup (e.g. 'task') * @param {string} mode - 'edit' or 'new' * @param {string|null} [initialProjectId=null] - Initial project to load milestones for * @param {string|null} [initialMilestoneId=null] - Initially selected milestone */ function setupMilestoneSelect(entityType, mode, initialProjectId = null, initialMilestoneId = null) { // Wait for modal DOM to be ready setTimeout(async () => { const formId = `form-modal-${entityType}-${mode === 'edit' ? 'edit' : 'new'}`; const projectSelect = document.getElementById(`${formId}-project_id`); const milestoneSelect = document.getElementById(`${formId}-milestone_id`); if (!projectSelect || !milestoneSelect) return; async function updateMilestones(projectId) { if (!projectId) { milestoneSelect.innerHTML = ''; milestoneSelect.closest('.form-group').style.display = 'none'; return; } try { const milestones = await GoingsOn.api.milestones.list(projectId); if (milestones.length === 0) { milestoneSelect.innerHTML = ''; milestoneSelect.closest('.form-group').style.display = 'none'; } else { milestoneSelect.innerHTML = '' + milestones.map(m => `` ).join(''); milestoneSelect.closest('.form-group').style.display = ''; } } catch (err) { console.error('Failed to load milestones:', err); } } projectSelect.addEventListener('change', () => { initialMilestoneId = null; // Clear when project changes updateMilestones(projectSelect.value); }); // Initial load await updateMilestones(initialProjectId || projectSelect.value); }, 100); } // ============ Namespace ============ GoingsOn.taskForms = { PRIORITIES, RECURRENCE_OPTIONS, STATUS_OPTIONS, getProjectOptions, getContactOptions, getTaskFormFields, setupMilestoneSelect, buildRecurrenceConfigHtml, initRecurrenceConfig, collectRecurrenceRule, }; })();