Skip to main content

max / goingson

19.9 KB · 544 lines History Blame Raw
1 /**
2 * GoingsOn - Projects Module
3 * Project CRUD, dashboard, project-task/event/email linking
4 */
5
6 // ============ Projects Module ============
7
8 (function() {
9 'use strict';
10 const esc = GoingsOn.utils.escapeHtml;
11 const escAttr = GoingsOn.utils.escapeAttr;
12
13 // ============ Form Field Definitions ============
14
15 const PROJECT_TYPES = [
16 { value: 'SideProject', label: 'Side Project' },
17 { value: 'Job', label: 'Job' },
18 { value: 'Company', label: 'Company' },
19 { value: 'Essay', label: 'Essay' },
20 { value: 'Article', label: 'Article' },
21 { value: 'Other', label: 'Other' },
22 ];
23
24 const PROJECT_STATUSES = [
25 { value: 'Active', label: 'Active' },
26 { value: 'OnHold', label: 'On Hold' },
27 { value: 'Completed', label: 'Completed' },
28 { value: 'Archived', label: 'Archived' },
29 ];
30
31 /**
32 * Build form field definitions for the project create/edit modal.
33 * @param {Object|null} project - Existing project for edit mode, or null for create
34 * @returns {FormField[]} Array of form field definitions
35 */
36 function getProjectFormFields(project = null) {
37 const isEdit = !!project;
38 return [
39 {
40 name: 'name',
41 type: 'text',
42 label: 'Project Name',
43 placeholder: 'My Awesome Project',
44 required: true,
45 value: project?.name || '',
46 validate: (v) => v && v.length > 100 ? 'Maximum 100 characters' : null,
47 },
48 {
49 name: 'description',
50 type: 'textarea',
51 label: 'Description',
52 placeholder: "What's this project about?",
53 value: project?.description || '',
54 validate: (v) => v && v.length > 1000 ? 'Maximum 1000 characters' : null,
55 },
56 {
57 name: 'project_type',
58 type: 'select',
59 label: 'Type',
60 options: PROJECT_TYPES.map(t => ({
61 ...t,
62 selected: project?.projectType === t.value,
63 })),
64 value: project?.projectType || 'SideProject',
65 },
66 {
67 name: 'status',
68 type: 'select',
69 label: 'Status',
70 options: (isEdit ? PROJECT_STATUSES : PROJECT_STATUSES.slice(0, 2)).map(s => ({
71 ...s,
72 selected: project?.status === s.value,
73 })),
74 value: project?.status || 'Active',
75 },
76 ];
77 }
78
79 // ============ Core Functions ============
80
81 /**
82 * Fetch all projects and render the project card grid.
83 */
84 async function load() {
85 if (GoingsOn.cache.isFresh('projects')) return;
86
87 const grid = document.getElementById('projects-grid');
88 try {
89 const projects = await GoingsOn.api.projects.list();
90 GoingsOn.state.set('projects', projects);
91
92 if (projects.length === 0) {
93 grid.innerHTML = GoingsOn.ui.renderEmptyState('No projects yet.', 'Create First Project', 'GoingsOn.projects.openNew()', 'projects');
94 return;
95 }
96
97 grid.innerHTML = projects.map(p => `
98 <div class="card project-card" onclick="GoingsOn.projects.open('${escAttr(p.id)}')"
99 oncontextmenu="GoingsOn.contextMenus.showProject(event, '${escAttr(p.id)}')"
100 tabindex="0" role="button" aria-label="Open project ${esc(p.name)}">
101 <div class="card-header">
102 <h3 class="card-title">${esc(p.name)}</h3>
103 <button class="btn-icon kebab-btn" style="opacity: 1;" onclick="event.stopPropagation(); GoingsOn.contextMenus.showProject(event, '${escAttr(p.id)}')" title="Actions" aria-label="Project actions">&#x22EE;</button>
104 </div>
105 <div class="card-description markdown-content">${p.descriptionHtml || ''}</div>
106 <div class="card-meta">
107 <span class="tag type-${(p.projectType || 'other').toLowerCase()}">${esc(p.projectTypeDisplay || p.projectType || 'Other')}</span>
108 <span class="tag status-${(p.status || 'active').toLowerCase()}">${esc(p.statusDisplay || p.status || 'Active')}</span>
109 </div>
110 </div>
111 `).join('');
112 GoingsOn.cache.markLoaded('projects');
113 } catch (err) {
114 GoingsOn.utils.showError(grid, err, 'Failed to load projects');
115 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load projects'), 'error', {
116 action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('projects'); load(); } },
117 duration: 8000,
118 });
119 }
120 }
121
122 function openNew() {
123 GoingsOn.ui.openFormModal({
124 title: 'New Project',
125 entityType: 'project',
126 isEdit: false,
127 fields: getProjectFormFields(),
128 onSubmit: create,
129 });
130 }
131
132 /**
133 * Create a new project from form data.
134 * @param {Object} data - Form data with name, description, project_type, status
135 */
136 async function create(data) {
137 const input = {
138 name: data.name,
139 description: data.description || '',
140 projectType: data.project_type,
141 status: data.status,
142 };
143
144 GoingsOn.cache.invalidate('projects');
145 await GoingsOn.ui.apiCall(GoingsOn.api.projects.create(input), {
146 successMessage: 'Project created!',
147 errorMessage: 'Failed to create project',
148 reload: load,
149 });
150 }
151
152 /**
153 * Open the edit form modal for a project from the cached list.
154 * @param {string} id - Project ID to edit
155 */
156 async function openEdit(id) {
157 const project = GoingsOn.state.projects.find(p => p.id === id);
158 if (!project) return;
159
160 GoingsOn.ui.openFormModal({
161 title: 'Edit Project',
162 entityType: 'project',
163 isEdit: true,
164 entityId: id,
165 fields: getProjectFormFields(project),
166 onSubmit: (data) => update(id, data),
167 extraContent: `
168 <div style="margin-bottom: 1rem;">
169 <button type="button" class="btn btn-danger" onclick="GoingsOn.projects.delete('${escAttr(id)}')">Delete Project</button>
170 </div>
171 `,
172 });
173 }
174
175 /**
176 * Update an existing project from form data.
177 * @param {string} id - Project ID to update
178 * @param {Object} data - Form data with name, description, project_type, status
179 */
180 async function update(id, data) {
181 const input = {
182 name: data.name,
183 description: data.description || '',
184 projectType: data.project_type,
185 status: data.status,
186 };
187
188 GoingsOn.cache.invalidate('projects');
189 await GoingsOn.ui.apiCall(GoingsOn.api.projects.update(id, input), {
190 successMessage: 'Project updated!',
191 errorMessage: 'Failed to update project',
192 reload: load,
193 });
194 }
195
196 /**
197 * Delete a project with confirmation and undo support.
198 * @param {string} id - Project ID to delete
199 */
200 async function deleteProject(id) {
201 if (!await GoingsOn.ui.confirmDelete('project')) return;
202
203 GoingsOn.cache.invalidate('projects');
204 const cachedProjects = GoingsOn.state.projects;
205 const removedProject = cachedProjects.find(p => p.id === id);
206 GoingsOn.state.set('projects', cachedProjects.filter(p => p.id !== id));
207
208 GoingsOn.ui.showUndoToast('Project deleted', {
209 onConfirm: async () => {
210 try {
211 await GoingsOn.api.projects.delete(id);
212 } catch (err) {
213 GoingsOn.ui.showToast('Failed to delete project', 'error');
214 load();
215 }
216 },
217 onUndo: () => {
218 if (removedProject) {
219 GoingsOn.state.set('projects', [...GoingsOn.state.projects, removedProject]);
220 }
221 },
222 });
223 }
224
225 /**
226 * Navigate to the project dashboard view for a specific project.
227 * @param {string} id - Project ID to open
228 */
229 async function open(id) {
230 GoingsOn.state.set('currentProjectId', id);
231 const project = GoingsOn.state.projects.find(p => p.id === id);
232 if (!project) return;
233
234 // Show work tab group, hide others
235 document.querySelectorAll('.view.tab-group').forEach(v => v.classList.add('hidden'));
236 const workView = document.getElementById('work-view');
237 if (workView) workView.classList.remove('hidden');
238
239 // Hide all sub-views in work group, show project dashboard
240 workView.querySelectorAll('.subview').forEach(sv => sv.classList.add('hidden'));
241 document.getElementById('project-dashboard-view').classList.remove('hidden');
242
243 // Deactivate pills (dashboard has no pill)
244 workView.querySelectorAll('.pill-nav .pill').forEach(p => p.classList.remove('active'));
245
246 // Keep Work tab active in top nav
247 document.querySelectorAll('.tab-navigation .tab').forEach(t => {
248 t.classList.remove('active');
249 t.setAttribute('aria-selected', 'false');
250 });
251 const workTab = document.querySelector('.tab-navigation [data-view="work"]');
252 if (workTab) {
253 workTab.classList.add('active');
254 workTab.setAttribute('aria-selected', 'true');
255 }
256
257 // Set project info
258 document.getElementById('project-dashboard-title').textContent = project.name;
259 document.getElementById('project-dashboard-description').textContent = project.description || '';
260
261 // Push URL (unless router is handling)
262 if (GoingsOn.router && !GoingsOn.router.suppressPush) {
263 GoingsOn.router.navigate(`/project/${id}`);
264 }
265
266 // Load dashboard data
267 await loadDashboard(id);
268 }
269
270 /**
271 * Load and render the project dashboard for a given project.
272 * @param {string} projectId - Project ID to load dashboard for
273 */
274 async function loadDashboard(projectId) {
275 await GoingsOn.projectsRender.renderDashboard(projectId);
276 }
277
278 function closeDashboard() {
279 GoingsOn.state.set('currentProjectId', null);
280 GoingsOn.navigation.switchView('projects');
281 }
282
283 function editCurrent() {
284 const currentId = GoingsOn.state.currentProjectId;
285 if (currentId) {
286 openEdit(currentId);
287 }
288 }
289
290 function addTask() {
291 const currentProjectId = GoingsOn.state.currentProjectId;
292 if (!currentProjectId) return;
293 GoingsOn.tasks.openNewForProject(currentProjectId);
294 }
295
296 function addEvent() {
297 const currentProjectId = GoingsOn.state.currentProjectId;
298 if (!currentProjectId) return;
299 GoingsOn.events.openNewForProject(currentProjectId);
300 }
301
302 async function linkEmail() {
303 const currentProjectId = GoingsOn.state.currentProjectId;
304 if (!currentProjectId) return;
305
306 try {
307 const unlinkedEmails = await GoingsOn.api.emails.listUnlinked();
308
309 if (unlinkedEmails.length === 0) {
310 GoingsOn.ui.showToast('No unlinked emails available', 'info');
311 return;
312 }
313
314 const emailOptions = unlinkedEmails.map(e =>
315 `<option value="${e.id}">${esc(e.subject)} - ${esc(e.from)}</option>`
316 ).join('');
317
318 const content = `
319 <form id="link-email-form" onsubmit="GoingsOn.projects.submitLinkEmail(event)">
320 <div class="form-group">
321 <label class="form-label">Select Email to Link</label>
322 <select class="form-select" name="email_id" required>
323 <option value="">Choose an email...</option>
324 ${emailOptions}
325 </select>
326 </div>
327 <div class="form-actions">
328 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
329 <button type="submit" class="btn btn-primary">Link Email</button>
330 </div>
331 </form>
332 `;
333 GoingsOn.ui.openModal('Link Email to Project', content);
334 } catch (err) {
335 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load emails'), 'error');
336 }
337 }
338
339 async function submitLinkEmail(e) {
340 e.preventDefault();
341 const form = e.target;
342 const emailId = form.email_id.value;
343 const currentProjectId = GoingsOn.state.currentProjectId;
344
345 if (!emailId || !currentProjectId) return;
346
347 await GoingsOn.ui.apiCall(GoingsOn.api.emails.linkToProject(emailId, currentProjectId), {
348 successMessage: 'Email linked to project!',
349 errorMessage: 'Failed to link email',
350 reload: () => loadDashboard(currentProjectId),
351 });
352 }
353
354 // ============ Cache Accessors (for backward compatibility) ============
355
356 /**
357 * Get the cached projects array from centralized state.
358 * @returns {Array<Object>} Cached project objects
359 */
360 function getCache() {
361 return GoingsOn.state.projects;
362 }
363
364 /**
365 * Replace the cached projects array in centralized state.
366 * @param {Array<Object>} cache - New projects array
367 */
368 function setCache(cache) {
369 GoingsOn.state.set('projects', cache);
370 }
371
372 /**
373 * Get the currently viewed project ID from state.
374 * @returns {string|null} Current project ID, or null
375 */
376 function getCurrentId() {
377 return GoingsOn.state.currentProjectId;
378 }
379
380 /**
381 * Set the currently viewed project ID in state.
382 * @param {string|null} id - Project ID to set, or null to clear
383 */
384 function setCurrentId(id) {
385 GoingsOn.state.set('currentProjectId', id);
386 }
387
388 // ============ Milestones ============
389
390 function toggleCompletedMilestones() {
391 GoingsOn.projectsRender.toggleCompletedMilestones();
392 }
393
394 /**
395 * Reorder a milestone by moving it up or down within the project.
396 * @param {string} id - Milestone ID to move
397 * @param {number} direction - Move direction (-1 for up, 1 for down)
398 */
399 async function moveMilestone(id, direction) {
400 const projectId = GoingsOn.state.currentProjectId;
401 if (!projectId) return;
402
403 try {
404 const milestones = await GoingsOn.api.milestones.list(projectId);
405 // Only reorder open milestones
406 const openMilestones = milestones.filter(m => m.status !== 'completed');
407 const idx = openMilestones.findIndex(m => m.id === id);
408 if (idx === -1) return;
409
410 const newIdx = idx + direction;
411 if (newIdx < 0 || newIdx >= openMilestones.length) return;
412
413 // Swap
414 [openMilestones[idx], openMilestones[newIdx]] = [openMilestones[newIdx], openMilestones[idx]];
415
416 // Build full ordered ID array (open first, then completed)
417 const completedMilestones = milestones.filter(m => m.status === 'completed');
418 const orderedIds = [...openMilestones, ...completedMilestones].map(m => m.id);
419
420 await GoingsOn.api.milestones.reorder(projectId, { milestoneIds: orderedIds });
421 await loadDashboard(projectId);
422 } catch (err) {
423 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to reorder milestones'), 'error');
424 }
425 }
426
427 function openNewMilestone() {
428 const projectId = GoingsOn.state.currentProjectId;
429 if (!projectId) return;
430
431 GoingsOn.ui.openFormModal({
432 title: 'New Milestone',
433 entityType: 'milestone',
434 isEdit: false,
435 fields: [
436 { name: 'name', type: 'text', label: 'Name', required: true, value: '' },
437 { name: 'description', type: 'textarea', label: 'Description', value: '' },
438 { name: 'targetDate', type: 'text', label: 'Target Date', value: '', placeholder: 'next friday, 2026-03-01...', transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v, onInput: GoingsOn.utils.dateParsePreview },
439 ],
440 onSubmit: async (data) => {
441 await GoingsOn.ui.apiCall(
442 GoingsOn.api.milestones.create({
443 projectId,
444 name: data.name,
445 description: data.description || '',
446 targetDate: data.targetDate || null,
447 }),
448 {
449 successMessage: 'Milestone created!',
450 errorMessage: 'Failed to create milestone',
451 reload: () => loadDashboard(projectId),
452 }
453 );
454 },
455 });
456 }
457
458 async function openEditMilestone(id) {
459 const projectId = GoingsOn.state.currentProjectId;
460 if (!projectId) return;
461
462 const milestones = await GoingsOn.api.milestones.list(projectId);
463 const m = milestones.find(ms => ms.id === id);
464 if (!m) return;
465
466 GoingsOn.ui.openFormModal({
467 title: 'Edit Milestone',
468 entityType: 'milestone',
469 isEdit: true,
470 entityId: id,
471 fields: [
472 { name: 'name', type: 'text', label: 'Name', required: true, value: m.name },
473 { name: 'description', type: 'textarea', label: 'Description', value: m.description },
474 { name: 'targetDate', type: 'text', label: 'Target Date', value: m.targetDate || '', placeholder: 'next friday, 2026-03-01...', transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v, onInput: GoingsOn.utils.dateParsePreview },
475 { name: 'status', type: 'select', label: 'Status', value: m.status, options: [
476 { value: 'open', label: 'Open' },
477 { value: 'completed', label: 'Completed' },
478 ]},
479 ],
480 onSubmit: async (data) => {
481 await GoingsOn.ui.apiCall(
482 GoingsOn.api.milestones.update(id, {
483 projectId,
484 name: data.name,
485 description: data.description || '',
486 targetDate: data.targetDate || null,
487 status: data.status,
488 }),
489 {
490 successMessage: 'Milestone updated!',
491 errorMessage: 'Failed to update milestone',
492 reload: () => loadDashboard(projectId),
493 }
494 );
495 },
496 });
497 }
498
499 async function deleteMilestone(id) {
500 const projectId = GoingsOn.state.currentProjectId;
501 await GoingsOn.ui.confirmDelete('milestone', async () => {
502 await GoingsOn.ui.apiCall(
503 GoingsOn.api.milestones.delete(id),
504 {
505 successMessage: 'Milestone deleted',
506 errorMessage: 'Failed to delete milestone',
507 reload: () => loadDashboard(projectId),
508 }
509 );
510 });
511 }
512
513 // ============ Populate GoingsOn.projects Namespace ============
514
515 GoingsOn.projects = {
516 load,
517 openNew,
518 create,
519 openEdit,
520 update,
521 delete: deleteProject,
522 open,
523 loadDashboard,
524 closeDashboard,
525 editCurrent,
526 addTask,
527 addEvent,
528 linkEmail,
529 submitLinkEmail,
530 getCache,
531 setCache,
532 getCurrentId,
533 setCurrentId,
534 openNewMilestone,
535 openEditMilestone,
536 deleteMilestone,
537 moveMilestone,
538 toggleCompletedMilestones,
539 // Expose form fields for potential reuse
540 getFormFields: getProjectFormFields,
541 };
542
543 })();
544