/**
* 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 => `
${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