Skip to main content

max / goingson

20.6 KB · 472 lines History Blame Raw
1 /**
2 * GoingsOn - Task Forms Module
3 * Form field definitions, option builders, and milestone select helper.
4 * Loaded before tasks.js -- populates GoingsOn.taskForms namespace.
5 */
6
7 (function() {
8 'use strict';
9 const esc = GoingsOn.utils.escapeHtml;
10 const escAttr = GoingsOn.utils.escapeAttr;
11
12 // ============ Form Field Constants ============
13
14 const PRIORITIES = [
15 { value: 'Low', label: 'Low' },
16 { value: 'Medium', label: 'Medium' },
17 { value: 'High', label: 'High' },
18 ];
19
20 const RECURRENCE_OPTIONS = [
21 { value: 'None', label: 'None' },
22 { value: 'Daily', label: 'Daily' },
23 { value: 'Weekly', label: 'Weekly' },
24 { value: 'Monthly', label: 'Monthly' },
25 ];
26
27 const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
28
29 /**
30 * Build recurrence config HTML (interval, weekday checkboxes, monthly spec).
31 * Shown below the recurrence select when pattern != None.
32 * @param {Object|null} rule - Existing recurrence rule, or null
33 * @param {string} prefix - ID prefix for form fields
34 * @returns {string} HTML string for the config panel
35 */
36 function buildRecurrenceConfigHtml(rule, prefix) {
37 const interval = rule?.interval || 1;
38 const weekdays = rule?.weekdays || [];
39 const monthlySpec = rule?.monthlySpec || null;
40
41 const weekdayCheckboxes = WEEKDAY_LABELS.map((label, i) => {
42 const checked = weekdays.includes(i) ? 'checked' : '';
43 return `<label class="recurrence-weekday-label"><input type="checkbox" name="${prefix}-weekday-${i}" value="${i}" ${checked}><span>${label}</span></label>`;
44 }).join('');
45
46 const monthlyDom = monthlySpec?.type === 'dayOfMonth' ? monthlySpec.day : '';
47 const monthlyWeek = monthlySpec?.type === 'nthWeekday' ? monthlySpec.week : 1;
48 const monthlyWd = monthlySpec?.type === 'nthWeekday' ? monthlySpec.weekday : 0;
49 const monthlyType = monthlySpec?.type || 'dayOfMonth';
50
51 const hasNthWeekday = monthlyType === 'nthWeekday';
52
53 return `
54 <div id="${prefix}-recurrence-config" class="recurrence-config">
55 <div id="${prefix}-recurrence-preview" class="recurrence-preview"></div>
56 <div class="form-group recurrence-row">
57 <label class="form-label recurrence-sublabel">Every</label>
58 <div class="recurrence-interval-row">
59 <input type="number" class="form-input recurrence-num" name="${prefix}-interval" value="${interval}" min="1" max="99">
60 <span id="${prefix}-interval-unit" class="recurrence-unit">time(s)</span>
61 </div>
62 </div>
63 <div id="${prefix}-weekdays-section" class="form-group recurrence-row recurrence-hidden">
64 <label class="form-label recurrence-sublabel">On days</label>
65 <div class="recurrence-weekdays">${weekdayCheckboxes}</div>
66 </div>
67 <div id="${prefix}-monthly-section" class="form-group recurrence-monthly recurrence-hidden">
68 <label class="form-label recurrence-sublabel">On</label>
69 <div class="recurrence-monthly-stack">
70 <label class="recurrence-day-label">
71 <input type="radio" name="${prefix}-monthly-type" value="dayOfMonth" ${monthlyType === 'dayOfMonth' ? 'checked' : ''}>
72 Day <input type="number" class="form-input recurrence-num" name="${prefix}-monthly-day" value="${monthlyDom || 1}" min="1" max="31"> of the month
73 </label>
74 <button type="button" id="${prefix}-monthly-nth-toggle" class="form-more-toggle recurrence-nth-toggle ${hasNthWeekday ? 'expanded' : ''}" onclick="this.classList.toggle('expanded'); this.nextElementSibling.classList.toggle('hidden');">Specific weekday</button>
75 <div id="${prefix}-monthly-nth-section" class="${hasNthWeekday ? '' : 'hidden'}">
76 <label class="recurrence-day-label">
77 <input type="radio" name="${prefix}-monthly-type" value="nthWeekday" ${hasNthWeekday ? 'checked' : ''}>
78 <select class="form-select recurrence-monthly-week" name="${prefix}-monthly-week">
79 <option value="1" ${monthlyWeek == 1 ? 'selected' : ''}>1st</option>
80 <option value="2" ${monthlyWeek == 2 ? 'selected' : ''}>2nd</option>
81 <option value="3" ${monthlyWeek == 3 ? 'selected' : ''}>3rd</option>
82 <option value="4" ${monthlyWeek == 4 ? 'selected' : ''}>4th</option>
83 <option value="-1" ${monthlyWeek == -1 ? 'selected' : ''}>Last</option>
84 </select>
85 <select class="form-select recurrence-monthly-week" name="${prefix}-monthly-weekday">
86 ${WEEKDAY_LABELS.map((l, i) => `<option value="${i}" ${monthlyWd == i ? 'selected' : ''}>${l}</option>`).join('')}
87 </select>
88 </label>
89 </div>
90 </div>
91 </div>
92 </div>
93 `;
94 }
95
96 /**
97 * Initialize recurrence config panel visibility and event handlers.
98 * Call after the form modal is rendered.
99 * @param {string} prefix - ID prefix matching buildRecurrenceConfigHtml
100 * @param {string} selectName - Name of the recurrence select element
101 */
102 /**
103 * Build a human-readable description of the current recurrence rule.
104 * @param {string} prefix - ID prefix for form fields
105 * @param {string} pattern - Recurrence pattern (Daily, Weekly, Monthly)
106 * @returns {string} Preview text (e.g., "Repeats every 2 weeks on Mon, Wed, Fri")
107 */
108 function buildRecurrencePreview(prefix, pattern) {
109 if (pattern === 'None') return '';
110
111 const form = document.querySelector('.modal-content form');
112 if (!form) return '';
113
114 const interval = parseInt(form.elements[`${prefix}-interval`]?.value) || 1;
115 const unitSingular = { Daily: 'day', Weekly: 'week', Monthly: 'month' }[pattern] || '';
116 const unitPlural = unitSingular + 's';
117 const unit = interval === 1 ? unitSingular : `${interval} ${unitPlural}`;
118
119 let detail = '';
120
121 if (pattern === 'Weekly') {
122 const days = [];
123 for (let i = 0; i < 7; i++) {
124 if (form.elements[`${prefix}-weekday-${i}`]?.checked) {
125 days.push(WEEKDAY_LABELS[i]);
126 }
127 }
128 if (days.length > 0) detail = ` on ${days.join(', ')}`;
129 }
130
131 if (pattern === 'Monthly') {
132 const monthlyType = form.querySelector(`[name="${prefix}-monthly-type"]:checked`)?.value || 'dayOfMonth';
133 if (monthlyType === 'dayOfMonth') {
134 const day = parseInt(form.elements[`${prefix}-monthly-day`]?.value) || 1;
135 detail = ` on day ${day}`;
136 } else {
137 const weekLabels = { '1': '1st', '2': '2nd', '3': '3rd', '4': '4th', '-1': 'last' };
138 const week = form.elements[`${prefix}-monthly-week`]?.value || '1';
139 const wd = parseInt(form.elements[`${prefix}-monthly-weekday`]?.value) || 0;
140 detail = ` on the ${weekLabels[week] || week} ${WEEKDAY_LABELS[wd]}`;
141 }
142 }
143
144 return `Repeats every ${unit}${detail}`;
145 }
146
147 function initRecurrenceConfig(prefix, selectName) {
148 const form = document.querySelector('.modal-content form');
149 if (!form) return;
150
151 const select = form.elements[selectName];
152 const config = document.getElementById(`${prefix}-recurrence-config`);
153 const weekdaysSection = document.getElementById(`${prefix}-weekdays-section`);
154 const monthlySection = document.getElementById(`${prefix}-monthly-section`);
155 const intervalUnit = document.getElementById(`${prefix}-interval-unit`);
156 const previewEl = document.getElementById(`${prefix}-recurrence-preview`);
157 if (!select || !config) return;
158
159 function updatePreview() {
160 if (previewEl) {
161 previewEl.textContent = buildRecurrencePreview(prefix, select.value);
162 }
163 }
164
165 function updateVisibility() {
166 const pattern = select.value;
167 config.style.display = pattern === 'None' ? 'none' : 'block';
168 if (weekdaysSection) weekdaysSection.style.display = pattern === 'Weekly' ? 'block' : 'none';
169 if (monthlySection) monthlySection.style.display = pattern === 'Monthly' ? 'block' : 'none';
170 if (intervalUnit) {
171 const units = { Daily: 'day(s)', Weekly: 'week(s)', Monthly: 'month(s)' };
172 intervalUnit.textContent = units[pattern] || 'time(s)';
173 }
174 updatePreview();
175 }
176
177 select.addEventListener('change', updateVisibility);
178 updateVisibility();
179
180 // Listen for changes to interval, weekday checkboxes, and monthly options to update preview
181 const intervalInput = form.elements[`${prefix}-interval`];
182 if (intervalInput) intervalInput.addEventListener('input', updatePreview);
183
184 for (let i = 0; i < 7; i++) {
185 const cb = form.elements[`${prefix}-weekday-${i}`];
186 if (cb) cb.addEventListener('change', updatePreview);
187 }
188
189 const monthlyDay = form.elements[`${prefix}-monthly-day`];
190 if (monthlyDay) monthlyDay.addEventListener('input', updatePreview);
191
192 form.querySelectorAll(`[name="${prefix}-monthly-type"]`).forEach(radio => {
193 radio.addEventListener('change', updatePreview);
194 });
195
196 const monthlyWeekSel = form.elements[`${prefix}-monthly-week`];
197 const monthlyWdSel = form.elements[`${prefix}-monthly-weekday`];
198 if (monthlyWeekSel) monthlyWeekSel.addEventListener('change', updatePreview);
199 if (monthlyWdSel) monthlyWdSel.addEventListener('change', updatePreview);
200 }
201
202 /**
203 * Collect recurrence rule from the config panel form elements.
204 * @param {HTMLFormElement} form - The form element
205 * @param {string} prefix - ID prefix
206 * @param {string} pattern - Current recurrence pattern value
207 * @returns {Object|null} RecurrenceRule JSON object, or null if None
208 */
209 function collectRecurrenceRule(form, prefix, pattern) {
210 if (pattern === 'None') return null;
211
212 const interval = parseInt(form.elements[`${prefix}-interval`]?.value) || 1;
213 const rule = { pattern, interval, weekdays: [], monthlySpec: null };
214
215 if (pattern === 'Weekly') {
216 for (let i = 0; i < 7; i++) {
217 const cb = form.elements[`${prefix}-weekday-${i}`];
218 if (cb?.checked) rule.weekdays.push(i);
219 }
220 }
221
222 if (pattern === 'Monthly') {
223 const monthlyType = form.querySelector(`[name="${prefix}-monthly-type"]:checked`)?.value || 'dayOfMonth';
224 if (monthlyType === 'dayOfMonth') {
225 const day = parseInt(form.elements[`${prefix}-monthly-day`]?.value) || 1;
226 rule.monthlySpec = { type: 'dayOfMonth', day };
227 } else {
228 const week = parseInt(form.elements[`${prefix}-monthly-week`]?.value) || 1;
229 const weekday = parseInt(form.elements[`${prefix}-monthly-weekday`]?.value) || 0;
230 rule.monthlySpec = { type: 'nthWeekday', week, weekday };
231 }
232 }
233
234 // If it's a simple rule (interval 1, no extras), still return it for consistency
235 return rule;
236 }
237
238 const STATUS_OPTIONS = [
239 { value: 'Pending', label: 'Pending' },
240 { value: 'Started', label: 'Started' },
241 { value: 'Completed', label: 'Completed' },
242 ];
243
244 /**
245 * Build project options for a select field.
246 * @param {string|null} [selectedProjectId=null] - Currently selected project ID
247 * @returns {Array<{value: string, label: string, selected: boolean}>}
248 */
249 function getProjectOptions(selectedProjectId = null) {
250 return GoingsOn.getProjectsCache().map(p => ({
251 value: p.id,
252 label: p.name,
253 selected: p.id === selectedProjectId,
254 }));
255 }
256
257 /**
258 * Build contact options for a select field.
259 * @param {string|null} [selectedContactId=null] - Currently selected contact ID
260 * @returns {Array<{value: string, label: string, selected: boolean}>}
261 */
262 function getContactOptions(selectedContactId = null) {
263 return (GoingsOn.state.contacts || []).map(c => ({
264 value: c.id,
265 label: c.displayName || c.display_name,
266 selected: c.id === selectedContactId,
267 }));
268 }
269
270 /**
271 * Build form field definitions for the task create/edit modal.
272 * @param {Object|null} [task=null] - Existing task for edit, or null for create
273 * @param {string|null} [presetProjectId=null] - Pre-selected project ID
274 * @returns {FormField[]} Array of form field definitions
275 */
276 function getTaskFormFields(task = null, presetProjectId = null) {
277 const isEdit = !!task;
278 const dueValue = task?.due ? (() => {
279 const d = new Date(task.due);
280 const pad = (n) => String(n).padStart(2, '0');
281 return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
282 })() : '';
283 const tagsValue = task?.tags ? task.tags.join(', ') : '';
284
285 const fields = [
286 {
287 name: 'description',
288 type: 'text',
289 label: 'Description',
290 placeholder: 'What needs to be done?',
291 required: true,
292 value: task?.description || '',
293 validate: (v) => v && v.length > 500 ? 'Maximum 500 characters' : null,
294 },
295 {
296 name: 'project_id',
297 type: 'select',
298 label: 'Project',
299 options: [
300 { value: '', label: 'No Project' },
301 ...getProjectOptions(task?.project_id || presetProjectId),
302 ],
303 value: task?.project_id || presetProjectId || '',
304 },
305 ];
306
307 if (isEdit) {
308 fields.push({
309 name: 'status',
310 type: 'select',
311 label: 'Status',
312 options: STATUS_OPTIONS.map(s => ({
313 ...s,
314 selected: task?.status === s.value,
315 })),
316 value: task?.status || 'Pending',
317 });
318 }
319
320 fields.push(
321 {
322 name: 'priority',
323 type: 'select',
324 label: 'Priority',
325 options: PRIORITIES.map(p => ({
326 ...p,
327 selected: task?.priority === p.value,
328 })),
329 value: task?.priority || 'Medium',
330 },
331 {
332 name: 'due',
333 type: 'text',
334 label: 'Due Date (optional)',
335 placeholder: 'tomorrow, friday 3pm, 2026-12-25...',
336 value: dueValue,
337 transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v,
338 onInput: GoingsOn.utils.dateParsePreview,
339 validate: (v) => {
340 if (!v || !v.trim()) return null;
341 const parsed = GoingsOn.utils.parseNaturalDate(v);
342 if (!parsed && !/^\d{4}-\d{2}-\d{2}/.test(v.trim())) {
343 return 'Date not recognized. Try "tomorrow", "friday 3pm", or "2026-12-25".';
344 }
345 return null;
346 },
347 },
348 {
349 name: 'tags',
350 type: 'text',
351 label: 'Tags (comma-separated)',
352 placeholder: 'work, urgent, meeting',
353 value: tagsValue,
354 transform: (v) => GoingsOn.utils.normalizeTags(v).join(', '),
355 extended: true,
356 },
357 {
358 name: 'recurrence',
359 type: 'select',
360 label: 'Recurrence',
361 hint: 'Completing a recurring task auto-creates the next occurrence',
362 hintExtraHtml: buildRecurrenceConfigHtml(task?.recurrenceRule, 'task'),
363 options: RECURRENCE_OPTIONS.map(r => ({
364 ...r,
365 selected: task?.recurrence === r.value,
366 })),
367 value: task?.recurrence || 'None',
368 extended: true,
369 },
370 {
371 name: 'estimated_minutes',
372 type: 'number',
373 label: 'Estimated Time (minutes)',
374 placeholder: 'e.g. 30, 60, 120',
375 hint: 'Used for day plan scheduling and time tracking progress',
376 value: task?.estimatedMinutes || '',
377 extended: true,
378 },
379 {
380 name: 'contact_id',
381 type: 'select',
382 label: 'Contact',
383 options: [
384 { value: '', label: 'No Contact' },
385 ...getContactOptions(task?.contactId),
386 ],
387 value: task?.contactId || '',
388 extended: true,
389 },
390 {
391 name: 'milestone_id',
392 type: 'select',
393 label: 'Milestone',
394 hint: 'Group tasks into project phases — milestones are managed per project',
395 options: [
396 { value: '', label: 'No Milestone' },
397 ],
398 value: task?.milestoneId || '',
399 extended: true,
400 }
401 );
402
403 return fields;
404 }
405
406 // ============ Milestone Select Helper ============
407
408 /**
409 * Wire up the milestone select to update options when the project changes.
410 * @param {string} entityType - Entity type for form ID lookup (e.g. 'task')
411 * @param {string} mode - 'edit' or 'new'
412 * @param {string|null} [initialProjectId=null] - Initial project to load milestones for
413 * @param {string|null} [initialMilestoneId=null] - Initially selected milestone
414 */
415 function setupMilestoneSelect(entityType, mode, initialProjectId = null, initialMilestoneId = null) {
416 // Wait for modal DOM to be ready
417 setTimeout(async () => {
418 const formId = `form-modal-${entityType}-${mode === 'edit' ? 'edit' : 'new'}`;
419 const projectSelect = document.getElementById(`${formId}-project_id`);
420 const milestoneSelect = document.getElementById(`${formId}-milestone_id`);
421 if (!projectSelect || !milestoneSelect) return;
422
423 async function updateMilestones(projectId) {
424 if (!projectId) {
425 milestoneSelect.innerHTML = '<option value="">No Milestone</option>';
426 milestoneSelect.closest('.form-group').style.display = 'none';
427 return;
428 }
429 try {
430 const milestones = await GoingsOn.api.milestones.list(projectId);
431 if (milestones.length === 0) {
432 milestoneSelect.innerHTML = '<option value="">No Milestone</option>';
433 milestoneSelect.closest('.form-group').style.display = 'none';
434 } else {
435 milestoneSelect.innerHTML = '<option value="">No Milestone</option>' +
436 milestones.map(m =>
437 `<option value="${escAttr(m.id)}" ${m.id === initialMilestoneId ? 'selected' : ''}>${esc(m.name)}</option>`
438 ).join('');
439 milestoneSelect.closest('.form-group').style.display = '';
440 }
441 } catch (err) {
442 console.error('Failed to load milestones:', err);
443 }
444 }
445
446 projectSelect.addEventListener('change', () => {
447 initialMilestoneId = null; // Clear when project changes
448 updateMilestones(projectSelect.value);
449 });
450
451 // Initial load
452 await updateMilestones(initialProjectId || projectSelect.value);
453 }, 100);
454 }
455
456 // ============ Namespace ============
457
458 GoingsOn.taskForms = {
459 PRIORITIES,
460 RECURRENCE_OPTIONS,
461 STATUS_OPTIONS,
462 getProjectOptions,
463 getContactOptions,
464 getTaskFormFields,
465 setupMilestoneSelect,
466 buildRecurrenceConfigHtml,
467 initRecurrenceConfig,
468 collectRecurrenceRule,
469 };
470
471 })();
472