/** * GoingsOn - Projects Module * Project CRUD, dashboard, project-task/event/email linking */ // ============ Projects Module ============ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttr; // ============ Form Field Definitions ============ const PROJECT_TYPES = [ { value: 'SideProject', label: 'Side Project' }, { value: 'Job', label: 'Job' }, { value: 'Company', label: 'Company' }, { value: 'Essay', label: 'Essay' }, { value: 'Article', label: 'Article' }, { value: 'Other', label: 'Other' }, ]; const PROJECT_STATUSES = [ { value: 'Active', label: 'Active' }, { value: 'OnHold', label: 'On Hold' }, { value: 'Completed', label: 'Completed' }, { value: 'Archived', label: 'Archived' }, ]; /** * Build form field definitions for the project create/edit modal. * @param {Object|null} project - Existing project for edit mode, or null for create * @returns {FormField[]} Array of form field definitions */ function getProjectFormFields(project = null) { const isEdit = !!project; return [ { name: 'name', type: 'text', label: 'Project Name', placeholder: 'My Awesome Project', required: true, value: project?.name || '', validate: (v) => v && v.length > 100 ? 'Maximum 100 characters' : null, }, { name: 'description', type: 'textarea', label: 'Description', placeholder: "What's this project about?", value: project?.description || '', validate: (v) => v && v.length > 1000 ? 'Maximum 1000 characters' : null, }, { name: 'project_type', type: 'select', label: 'Type', options: PROJECT_TYPES.map(t => ({ ...t, selected: project?.projectType === t.value, })), value: project?.projectType || 'SideProject', }, { name: 'status', type: 'select', label: 'Status', options: (isEdit ? PROJECT_STATUSES : PROJECT_STATUSES.slice(0, 2)).map(s => ({ ...s, selected: project?.status === s.value, })), value: project?.status || 'Active', }, ]; } // ============ Core Functions ============ /** * Fetch all projects and render the project card grid. */ async function load() { if (GoingsOn.cache.isFresh('projects')) return; const grid = document.getElementById('projects-grid'); try { const projects = await GoingsOn.api.projects.list(); GoingsOn.state.set('projects', projects); if (projects.length === 0) { grid.innerHTML = GoingsOn.ui.renderEmptyState('No projects yet.', 'Create First Project', 'GoingsOn.projects.openNew()', 'projects'); return; } grid.innerHTML = projects.map(p => `

${esc(p.name)}

${p.descriptionHtml || ''}
${esc(p.projectTypeDisplay || p.projectType || 'Other')} ${esc(p.statusDisplay || p.status || 'Active')}
`).join(''); GoingsOn.cache.markLoaded('projects'); } catch (err) { GoingsOn.utils.showError(grid, err, 'Failed to load projects'); GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load projects'), 'error', { action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('projects'); load(); } }, duration: 8000, }); } } function openNew() { GoingsOn.ui.openFormModal({ title: 'New Project', entityType: 'project', isEdit: false, fields: getProjectFormFields(), onSubmit: create, }); } /** * Create a new project from form data. * @param {Object} data - Form data with name, description, project_type, status */ async function create(data) { const input = { name: data.name, description: data.description || '', projectType: data.project_type, status: data.status, }; GoingsOn.cache.invalidate('projects'); await GoingsOn.ui.apiCall(GoingsOn.api.projects.create(input), { successMessage: 'Project created!', errorMessage: 'Failed to create project', reload: load, }); } /** * Open the edit form modal for a project from the cached list. * @param {string} id - Project ID to edit */ async function openEdit(id) { const project = GoingsOn.state.projects.find(p => p.id === id); if (!project) return; GoingsOn.ui.openFormModal({ title: 'Edit Project', entityType: 'project', isEdit: true, entityId: id, fields: getProjectFormFields(project), onSubmit: (data) => update(id, data), extraContent: `
`, }); } /** * Update an existing project from form data. * @param {string} id - Project ID to update * @param {Object} data - Form data with name, description, project_type, status */ async function update(id, data) { const input = { name: data.name, description: data.description || '', projectType: data.project_type, status: data.status, }; GoingsOn.cache.invalidate('projects'); await GoingsOn.ui.apiCall(GoingsOn.api.projects.update(id, input), { successMessage: 'Project updated!', errorMessage: 'Failed to update project', reload: load, }); } /** * Delete a project with confirmation and undo support. * @param {string} id - Project ID to delete */ async function deleteProject(id) { if (!await GoingsOn.ui.confirmDelete('project')) return; GoingsOn.cache.invalidate('projects'); const cachedProjects = GoingsOn.state.projects; const removedProject = cachedProjects.find(p => p.id === id); GoingsOn.state.set('projects', cachedProjects.filter(p => p.id !== id)); GoingsOn.ui.showUndoToast('Project deleted', { onConfirm: async () => { try { await GoingsOn.api.projects.delete(id); } catch (err) { GoingsOn.ui.showToast('Failed to delete project', 'error'); load(); } }, onUndo: () => { if (removedProject) { GoingsOn.state.set('projects', [...GoingsOn.state.projects, removedProject]); } }, }); } /** * Navigate to the project dashboard view for a specific project. * @param {string} id - Project ID to open */ async function open(id) { GoingsOn.state.set('currentProjectId', id); const project = GoingsOn.state.projects.find(p => p.id === id); if (!project) return; // Show work tab group, hide others document.querySelectorAll('.view.tab-group').forEach(v => v.classList.add('hidden')); const workView = document.getElementById('work-view'); if (workView) workView.classList.remove('hidden'); // Hide all sub-views in work group, show project dashboard workView.querySelectorAll('.subview').forEach(sv => sv.classList.add('hidden')); document.getElementById('project-dashboard-view').classList.remove('hidden'); // Deactivate pills (dashboard has no pill) workView.querySelectorAll('.pill-nav .pill').forEach(p => p.classList.remove('active')); // Keep Work tab active in top nav document.querySelectorAll('.tab-navigation .tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); }); const workTab = document.querySelector('.tab-navigation [data-view="work"]'); if (workTab) { workTab.classList.add('active'); workTab.setAttribute('aria-selected', 'true'); } // Set project info document.getElementById('project-dashboard-title').textContent = project.name; document.getElementById('project-dashboard-description').textContent = project.description || ''; // Push URL (unless router is handling) if (GoingsOn.router && !GoingsOn.router.suppressPush) { GoingsOn.router.navigate(`/project/${id}`); } // Load dashboard data await loadDashboard(id); } /** * Load and render the project dashboard for a given project. * @param {string} projectId - Project ID to load dashboard for */ async function loadDashboard(projectId) { await GoingsOn.projectsRender.renderDashboard(projectId); } function closeDashboard() { GoingsOn.state.set('currentProjectId', null); GoingsOn.navigation.switchView('projects'); } function editCurrent() { const currentId = GoingsOn.state.currentProjectId; if (currentId) { openEdit(currentId); } } function addTask() { const currentProjectId = GoingsOn.state.currentProjectId; if (!currentProjectId) return; GoingsOn.tasks.openNewForProject(currentProjectId); } function addEvent() { const currentProjectId = GoingsOn.state.currentProjectId; if (!currentProjectId) return; GoingsOn.events.openNewForProject(currentProjectId); } async function linkEmail() { const currentProjectId = GoingsOn.state.currentProjectId; if (!currentProjectId) return; try { const unlinkedEmails = await GoingsOn.api.emails.listUnlinked(); if (unlinkedEmails.length === 0) { GoingsOn.ui.showToast('No unlinked emails available', 'info'); return; } const emailOptions = unlinkedEmails.map(e => `` ).join(''); const content = ` `; GoingsOn.ui.openModal('Link Email to Project', content); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load emails'), 'error'); } } async function submitLinkEmail(e) { e.preventDefault(); const form = e.target; const emailId = form.email_id.value; const currentProjectId = GoingsOn.state.currentProjectId; if (!emailId || !currentProjectId) return; await GoingsOn.ui.apiCall(GoingsOn.api.emails.linkToProject(emailId, currentProjectId), { successMessage: 'Email linked to project!', errorMessage: 'Failed to link email', reload: () => loadDashboard(currentProjectId), }); } // ============ Cache Accessors (for backward compatibility) ============ /** * Get the cached projects array from centralized state. * @returns {Array} Cached project objects */ function getCache() { return GoingsOn.state.projects; } /** * Replace the cached projects array in centralized state. * @param {Array} cache - New projects array */ function setCache(cache) { GoingsOn.state.set('projects', cache); } /** * Get the currently viewed project ID from state. * @returns {string|null} Current project ID, or null */ function getCurrentId() { return GoingsOn.state.currentProjectId; } /** * Set the currently viewed project ID in state. * @param {string|null} id - Project ID to set, or null to clear */ function setCurrentId(id) { GoingsOn.state.set('currentProjectId', id); } // ============ Milestones ============ function toggleCompletedMilestones() { GoingsOn.projectsRender.toggleCompletedMilestones(); } /** * Reorder a milestone by moving it up or down within the project. * @param {string} id - Milestone ID to move * @param {number} direction - Move direction (-1 for up, 1 for down) */ async function moveMilestone(id, direction) { const projectId = GoingsOn.state.currentProjectId; if (!projectId) return; try { const milestones = await GoingsOn.api.milestones.list(projectId); // Only reorder open milestones const openMilestones = milestones.filter(m => m.status !== 'completed'); const idx = openMilestones.findIndex(m => m.id === id); if (idx === -1) return; const newIdx = idx + direction; if (newIdx < 0 || newIdx >= openMilestones.length) return; // Swap [openMilestones[idx], openMilestones[newIdx]] = [openMilestones[newIdx], openMilestones[idx]]; // Build full ordered ID array (open first, then completed) const completedMilestones = milestones.filter(m => m.status === 'completed'); const orderedIds = [...openMilestones, ...completedMilestones].map(m => m.id); await GoingsOn.api.milestones.reorder(projectId, { milestoneIds: orderedIds }); await loadDashboard(projectId); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to reorder milestones'), 'error'); } } function openNewMilestone() { const projectId = GoingsOn.state.currentProjectId; if (!projectId) return; GoingsOn.ui.openFormModal({ title: 'New Milestone', entityType: 'milestone', isEdit: false, fields: [ { name: 'name', type: 'text', label: 'Name', required: true, value: '' }, { name: 'description', type: 'textarea', label: 'Description', value: '' }, { 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 }, ], onSubmit: async (data) => { await GoingsOn.ui.apiCall( GoingsOn.api.milestones.create({ projectId, name: data.name, description: data.description || '', targetDate: data.targetDate || null, }), { successMessage: 'Milestone created!', errorMessage: 'Failed to create milestone', reload: () => loadDashboard(projectId), } ); }, }); } async function openEditMilestone(id) { const projectId = GoingsOn.state.currentProjectId; if (!projectId) return; const milestones = await GoingsOn.api.milestones.list(projectId); const m = milestones.find(ms => ms.id === id); if (!m) return; GoingsOn.ui.openFormModal({ title: 'Edit Milestone', entityType: 'milestone', isEdit: true, entityId: id, fields: [ { name: 'name', type: 'text', label: 'Name', required: true, value: m.name }, { name: 'description', type: 'textarea', label: 'Description', value: m.description }, { 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 }, { name: 'status', type: 'select', label: 'Status', value: m.status, options: [ { value: 'open', label: 'Open' }, { value: 'completed', label: 'Completed' }, ]}, ], onSubmit: async (data) => { await GoingsOn.ui.apiCall( GoingsOn.api.milestones.update(id, { projectId, name: data.name, description: data.description || '', targetDate: data.targetDate || null, status: data.status, }), { successMessage: 'Milestone updated!', errorMessage: 'Failed to update milestone', reload: () => loadDashboard(projectId), } ); }, }); } async function deleteMilestone(id) { const projectId = GoingsOn.state.currentProjectId; await GoingsOn.ui.confirmDelete('milestone', async () => { await GoingsOn.ui.apiCall( GoingsOn.api.milestones.delete(id), { successMessage: 'Milestone deleted', errorMessage: 'Failed to delete milestone', reload: () => loadDashboard(projectId), } ); }); } // ============ Populate GoingsOn.projects Namespace ============ GoingsOn.projects = { load, openNew, create, openEdit, update, delete: deleteProject, open, loadDashboard, closeDashboard, editCurrent, addTask, addEvent, linkEmail, submitLinkEmail, getCache, setCache, getCurrentId, setCurrentId, openNewMilestone, openEditMilestone, deleteMilestone, moveMilestone, toggleCompletedMilestones, // Expose form fields for potential reuse getFormFields: getProjectFormFields, }; })();