Skip to main content

max / goingson

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