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