diff --git a/backend/index.js b/backend/index.js index 4b31f14..4adfaff 100644 --- a/backend/index.js +++ b/backend/index.js @@ -43,10 +43,42 @@ const toInitials = (name = '') => .toUpperCase() .slice(0, 2); +const FEATURE_TYPES = ['idea', 'feature', 'fix', 'removal']; +const FEATURE_STATUSES = ['backlog', 'planned', 'in-progress', 'shipped', 'dropped']; + +const normalizeFeatureEntry = (feature = {}, previousFeature = null) => { + const now = new Date().toISOString(); + const title = String(feature?.title || previousFeature?.title || '').trim() || 'Untitled entry'; + const type = FEATURE_TYPES.includes(feature?.type) ? feature.type : (FEATURE_TYPES.includes(previousFeature?.type) ? previousFeature.type : 'idea'); + const status = FEATURE_STATUSES.includes(feature?.status) ? feature.status : (FEATURE_STATUSES.includes(previousFeature?.status) ? previousFeature.status : 'backlog'); + const createdAt = feature?.createdAt || previousFeature?.createdAt || now; + const updatedAt = feature?.updatedAt || previousFeature?.updatedAt || feature?.createdAt || previousFeature?.createdAt || now; + const updatedBy = typeof feature?.updatedBy === 'string' + ? feature.updatedBy.trim() + : typeof previousFeature?.updatedBy === 'string' + ? previousFeature.updatedBy.trim() + : ''; + const shippedAt = feature?.shippedAt || previousFeature?.shippedAt || (status === 'shipped' ? (updatedAt || createdAt || now) : ''); + + return { + id: feature?.id || previousFeature?.id || randomUUID(), + title, + type, + status, + note: feature?.note || feature?.description || previousFeature?.note || '', + createdAt, + updatedAt, + updatedBy, + shippedAt, + }; +}; + const ensureProjectShape = (project) => { if (!Array.isArray(project.members)) project.members = []; if (!Array.isArray(project.tasks)) project.tasks = []; if (!Array.isArray(project.milestones)) project.milestones = []; + if (!Array.isArray(project.features)) project.features = []; + project.features = project.features.map(feature => normalizeFeatureEntry(feature)); }; try { @@ -262,7 +294,7 @@ app.get('/api/projects', (req, res) => { }); app.post('/api/projects', limiter, requireApiKey, async (req, res) => { - const project = { id: Date.now().toString(), members: [], tasks: [], milestones: [], ...req.body }; + const project = { id: Date.now().toString(), members: [], tasks: [], milestones: [], features: [], ...req.body }; ensureProjectShape(project); db.data.projects.push(project); await db.write(); @@ -373,6 +405,58 @@ app.delete('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, asy res.status(204).end(); }); +// Feature log CRUD +app.get('/api/projects/:projectId/features', (req, res) => { + const project = db.data.projects.find(p => p.id === req.params.projectId); + if (!project) return res.status(404).json({ error: 'Project not found' }); + ensureProjectShape(project); + res.json(project.features); +}); + +app.post('/api/projects/:projectId/features', limiter, requireApiKey, async (req, res) => { + const project = db.data.projects.find(p => p.id === req.params.projectId); + if (!project) return res.status(404).json({ error: 'Project not found' }); + ensureProjectShape(project); + + const feature = normalizeFeatureEntry(req.body); + project.features.push(feature); + await db.write(); + res.status(201).json(feature); +}); + +app.put('/api/projects/:projectId/features/:featureId', limiter, requireApiKey, async (req, res) => { + const project = db.data.projects.find(p => p.id === req.params.projectId); + if (!project) return res.status(404).json({ error: 'Project not found' }); + ensureProjectShape(project); + const idx = project.features.findIndex(feature => feature.id === req.params.featureId); + if (idx === -1) return res.status(404).json({ error: 'Feature entry not found' }); + + const current = project.features[idx]; + project.features[idx] = normalizeFeatureEntry({ + ...current, + ...req.body, + id: req.params.featureId, + createdAt: current.createdAt, + shippedAt: req.body.status === 'shipped' + ? (req.body.shippedAt || current.shippedAt || new Date().toISOString()) + : (req.body.status && req.body.status !== 'shipped' ? '' : current.shippedAt), + }, current); + + await db.write(); + res.json(project.features[idx]); +}); + +app.delete('/api/projects/:projectId/features/:featureId', limiter, requireApiKey, async (req, res) => { + const project = db.data.projects.find(p => p.id === req.params.projectId); + if (!project) return res.status(404).json({ error: 'Project not found' }); + ensureProjectShape(project); + const idx = project.features.findIndex(feature => feature.id === req.params.featureId); + if (idx === -1) return res.status(404).json({ error: 'Feature entry not found' }); + project.features.splice(idx, 1); + await db.write(); + res.status(204).end(); +}); + // ── Static uploads ────────────────────────────────────────────────────────── app.use('/uploads', express.static(UPLOAD_DIR)); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ad8e6f4..343eb4a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { api } from './api.js' +import { api, ATTACHMENT_MAX_FILE_SIZE_LABEL } from './api.js' import GanttView from './components/GanttView.jsx' import CalendarView from './components/CalendarView.jsx' import BurndownChart from './components/BurndownChart.jsx' @@ -58,10 +58,43 @@ const SKEY = 'pm_hub_v1' const COLORS = ['#6366f1','#8b5cf6','#ec4899','#f97316','#10b981','#3b82f6','#f59e0b','#ef4444'] const STATUS = { planning: { label: 'Planning', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' }, active: { label: 'Active', color: '#10b981', bg: 'rgba(16,185,129,0.12)' }, 'on-hold': { label: 'On Hold', color: '#f97316', bg: 'rgba(249,115,22,0.12)' }, completed: { label: 'Completed', color: '#818cf8', bg: 'rgba(129,140,248,0.12)' } } const PRI = { low: { label: 'Low', color: '#10b981' }, medium: { label: 'Med', color: '#f59e0b' }, high: { label: 'High', color: '#ef4444' } } +const FEATURE_TYPE = { + idea: { label: 'Idea', color: '#38bdf8', bg: 'rgba(56,189,248,0.12)' }, + feature: { label: 'Feature', color: '#818cf8', bg: 'rgba(129,140,248,0.12)' }, + fix: { label: 'Fix', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' }, + removal: { label: 'Removal', color: '#f87171', bg: 'rgba(248,113,113,0.12)' }, +} +const FEATURE_STATUS = { + backlog: { label: 'Backlog', color: '#64748b', bg: 'rgba(100,116,139,0.12)' }, + planned: { label: 'Planned', color: '#38bdf8', bg: 'rgba(56,189,248,0.12)' }, + 'in-progress': { label: 'In Progress', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' }, + shipped: { label: 'Shipped', color: '#10b981', bg: 'rgba(16,185,129,0.12)' }, + dropped: { label: 'Dropped', color: '#f87171', bg: 'rgba(248,113,113,0.12)' }, +} const uid = () => Math.random().toString(36).slice(2,9) const prog = tasks => !tasks.length ? 0 : Math.round(tasks.filter(t => t.status === 'done').length / tasks.length * 100) const overdue = d => d && new Date(d) < new Date() const fmt = d => d ? new Date(d).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}) : '—' +const fmtDateTime = d => d ? new Date(d).toLocaleString('en-US',{month:'short',day:'numeric',year:'numeric',hour:'numeric',minute:'2-digit'}) : '—' +const featureStatusRank = status => ({ 'in-progress': 0, planned: 1, backlog: 2, shipped: 3, dropped: 4 }[status] ?? 5) +const normalizeFeature = feature => ({ + id: feature?.id || uid(), + title: String(feature?.title || '').trim() || 'Untitled entry', + type: FEATURE_TYPE[feature?.type] ? feature.type : 'idea', + status: FEATURE_STATUS[feature?.status] ? feature.status : 'backlog', + note: feature?.note || feature?.description || '', + createdAt: feature?.createdAt || new Date().toISOString(), + updatedAt: feature?.updatedAt || feature?.createdAt || new Date().toISOString(), + updatedBy: typeof feature?.updatedBy === 'string' ? feature.updatedBy : '', + shippedAt: feature?.shippedAt || (feature?.status === 'shipped' ? (feature?.updatedAt || feature?.createdAt || new Date().toISOString()) : ''), +}) +const normalizeProject = project => ({ + ...project, + members: Array.isArray(project?.members) ? project.members : [], + tasks: Array.isArray(project?.tasks) ? project.tasks : [], + milestones: Array.isArray(project?.milestones) ? project.milestones : [], + features: Array.isArray(project?.features) ? project.features.map(normalizeFeature) : [], +}) // Sample data (full from original inline HTML) const AP = {id:"authentiapol",name:"AuthentiPol",description:"React Native + PWA delivering 0-100 Authenticity Score from public data. Non-partisan Say vs Do vs Funded.",status:"active",color:"#6366f1",startDate:"2026-03-14",dueDate:"2026-06-12", @@ -87,6 +120,11 @@ const AP = {id:"authentiapol",name:"AuthentiPol",description:"React Native + PWA {id:"ms4",title:"PWA soft launch (Day -7)",date:"2026-06-05",completed:false}, {id:"ms5",title:"🚀 App Store + X live thread + email blast",date:"2026-06-12",completed:false}, {id:"ms6",title:"Press round-up + PostHog iteration (Day +7)",date:"2026-06-19",completed:false}, + ], + features:[ + {id:"af1",title:"Public politician scorecards",type:"feature",status:"planned",note:"Full public profile pages with score breakdowns, receipts, and revision history.",createdAt:"2026-03-16T09:00:00.000Z"}, + {id:"af2",title:"False-positive vote matching cleanup",type:"fix",status:"in-progress",note:"Tighten promise-to-vote matching to reduce noisy mismatches in the scoring engine.",createdAt:"2026-03-16T14:30:00.000Z"}, + {id:"af3",title:"Remove manual CSV import fallback",type:"removal",status:"backlog",note:"Retire the temporary admin import once the nightly ETL pipeline is stable.",createdAt:"2026-03-17T08:15:00.000Z"}, ] }; @@ -94,11 +132,13 @@ const SAMPLES = [ {id:"s1",name:"Website Redesign",description:"Full overhaul with new branding and improved UX",status:"active",color:"#8b5cf6",startDate:"2026-01-15",dueDate:"2026-04-30", members:[{id:"m1",name:"Alice Chen",role:"Designer",initials:"AC"},{id:"m2",name:"Bob Smith",role:"Developer",initials:"BS"}], tasks:[{id:"t1",title:"Wireframes",status:"done",priority:"high",dueDate:"2026-02-01",subtasks:[]},{id:"t2",title:"Design system",status:"done",priority:"high",dueDate:"2026-02-15",subtasks:[]},{id:"t3",title:"Frontend dev",status:"in-progress",priority:"high",dueDate:"2026-03-30",subtasks:[{id:"ss1",title:"Homepage",done:true},{id:"ss2",title:"About page",done:false}]},{id:"t4",title:"QA Testing",status:"todo",priority:"medium",dueDate:"2026-04-25",subtasks:[]}], - milestones:[{id:"ms1",title:"Design approval",date:"2026-02-20",completed:true},{id:"ms2",title:"Go live",date:"2026-04-30",completed:false}]}, + milestones:[{id:"ms1",title:"Design approval",date:"2026-02-20",completed:true},{id:"ms2",title:"Go live",date:"2026-04-30",completed:false}], + features:[{id:"sf1",title:"Dark-mode toggle review",type:"idea",status:"backlog",note:"Reassess once the new brand palette is locked.",createdAt:"2026-03-10T10:00:00.000Z"}]}, {id:"s2",name:"Q2 Marketing Campaign",description:"Multi-channel campaign for Q2 product launch",status:"planning",color:"#ec4899",startDate:"2026-03-01",dueDate:"2026-05-31", members:[{id:"m3",name:"Frank Johnson",role:"Marketing Lead",initials:"FJ"},{id:"m4",name:"Grace Kim",role:"Content Writer",initials:"GK"}], tasks:[{id:"t5",title:"Strategy document",status:"done",priority:"high",dueDate:"2026-03-10",subtasks:[]},{id:"t6",title:"Content calendar",status:"in-progress",priority:"medium",dueDate:"2026-03-20",subtasks:[]},{id:"t7",title:"Ad creatives",status:"todo",priority:"high",dueDate:"2026-04-01",subtasks:[]}], - milestones:[{id:"ms3",title:"Campaign kickoff",date:"2026-04-01",completed:false}]}, + milestones:[{id:"ms3",title:"Campaign kickoff",date:"2026-04-01",completed:false}], + features:[{id:"sf2",title:"Retire PDF-only briefs",type:"removal",status:"planned",note:"Replace with shared live briefing docs for campaign stakeholders.",createdAt:"2026-03-12T11:30:00.000Z"}]}, ]; // Styles helpers @@ -122,6 +162,8 @@ function App(){ const [serverOpen, setServerOpen] = useState(false) const [apiUrlInput, setApiUrlInput] = useState('') const [apiUrlMsg, setApiUrlMsg] = useState('') + const [featureStatusFilter, setFeatureStatusFilter] = useState('all') + const [projectSort, setProjectSort] = useState('feature-updated-desc') useEffect(()=>{ (async()=>{ @@ -132,17 +174,18 @@ function App(){ try { const m = await api.getMembers(); setAllMembers(m) } catch {} try { - const bp = await api.getProjects() + const bp = (await api.getProjects()).map(normalizeProject) if(bp.length > 0){ setProjects(bp); setLoaded(true); return } } catch {} let ps try{ const r = await persist.get(SKEY) if(r?.value){ - const saved = JSON.parse(r.value) + const saved = JSON.parse(r.value).map(normalizeProject) ps = saved.some(p => p.id === 'authentiapol') ? saved : [AP, ...saved] } else ps = [AP, ...SAMPLES] } catch { ps = [AP, ...SAMPLES] } + ps = ps.map(normalizeProject) setProjects(ps) try { for(const p of ps) await api.createProject(p) } catch {} setLoaded(true) @@ -152,12 +195,13 @@ function App(){ useEffect(()=>{ if(!loaded) return; (async()=>{ try{ await persist.set(SKEY, JSON.stringify(projects)) }catch{} })() }, [projects, loaded]) const save = p => { + const normalized = normalizeProject(p) if(p.id){ - setProjects(ps => ps.map(x => x.id === p.id ? p : x)) - if(sel?.id === p.id) setSel(p) - api.updateProject(p.id, p).catch(() => {}) + setProjects(ps => ps.map(x => x.id === p.id ? normalized : x)) + if(sel?.id === p.id) setSel(normalized) + api.updateProject(p.id, normalized).catch(() => {}) } else { - const n = {...p, id: uid()} + const n = normalizeProject({ ...normalized, id: uid() }) setProjects(ps => [...ps, n]) api.createProject(n).catch(() => {}) } @@ -173,45 +217,116 @@ function App(){ const createTaskForProject = async (projectId, taskDraft) => { try { const created = await api.createTask(projectId, taskDraft) - setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, created] } : p)) - if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, created] } : s) + setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, tasks: [...p.tasks, created] }) : p)) + if (sel?.id === projectId) setSel(s => s ? normalizeProject({ ...s, tasks: [...s.tasks, created] }) : s) } catch { const local = { ...taskDraft, id: uid(), subtasks: taskDraft.subtasks || [], attachments: taskDraft.attachments || [] } - setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, local] } : p)) - if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, local] } : s) + setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, tasks: [...p.tasks, local] }) : p)) + if (sel?.id === projectId) setSel(s => s ? normalizeProject({ ...s, tasks: [...s.tasks, local] }) : s) } } const updateTaskForProject = async (projectId, taskId, patch) => { - setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : p)) + setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, tasks: p.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) }) : p)) if (sel?.id === projectId) { - setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : s) + setSel(s => s ? normalizeProject({ ...s, tasks: s.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) }) : s) } try { const updated = await api.updateTask(projectId, taskId, patch) - setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.map(t => t.id === taskId ? updated : t) } : p)) + setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, tasks: p.tasks.map(t => t.id === taskId ? updated : t) }) : p)) if (sel?.id === projectId) { - setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? updated : t) } : s) + setSel(s => s ? normalizeProject({ ...s, tasks: s.tasks.map(t => t.id === taskId ? updated : t) }) : s) } } catch {} } const deleteTaskForProject = async (projectId, taskId) => { - setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.filter(t => t.id !== taskId) } : p)) + setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, tasks: p.tasks.filter(t => t.id !== taskId) }) : p)) if (sel?.id === projectId) { - setSel(s => s ? { ...s, tasks: s.tasks.filter(t => t.id !== taskId) } : s) + setSel(s => s ? normalizeProject({ ...s, tasks: s.tasks.filter(t => t.id !== taskId) }) : s) } try { await api.deleteTask(projectId, taskId) } catch {} } - const change = u => { - setProjects(ps => ps.map(p => p.id === u.id ? u : p)) - setSel(u) - api.updateProject(u.id, u).catch(() => {}) + const createFeatureForProject = async (projectId, featureDraft) => { + const optimistic = normalizeFeature({ ...featureDraft, id: uid(), updatedAt: new Date().toISOString() }) + setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, features: [optimistic, ...p.features] }) : p)) + if (sel?.id === projectId) setSel(s => s ? normalizeProject({ ...s, features: [optimistic, ...s.features] }) : s) + + try { + const created = normalizeFeature(await api.createFeature(projectId, featureDraft)) + setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, features: p.features.map(feature => feature.id === optimistic.id ? created : feature) }) : p)) + if (sel?.id === projectId) setSel(s => s ? normalizeProject({ ...s, features: s.features.map(feature => feature.id === optimistic.id ? created : feature) }) : s) + } catch {} } - const filtered = projects.filter(p => { const ms = p.name.toLowerCase().includes(search.toLowerCase()) || p.description.toLowerCase().includes(search.toLowerCase()); return ms && (fSt === 'all' || p.status === fSt) }) + const updateFeatureForProject = async (projectId, featureId, patch) => { + const patchWithMeta = { ...patch, updatedAt: new Date().toISOString() } + if (patch.status === 'shipped') patchWithMeta.shippedAt = patch.shippedAt || new Date().toISOString() + if (patch.status && patch.status !== 'shipped') patchWithMeta.shippedAt = '' + + setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ + ...p, + features: p.features.map(feature => feature.id === featureId ? { ...feature, ...patchWithMeta } : feature), + }) : p)) + if (sel?.id === projectId) { + setSel(s => s ? normalizeProject({ + ...s, + features: s.features.map(feature => feature.id === featureId ? { ...feature, ...patchWithMeta } : feature), + }) : s) + } + + try { + const updated = normalizeFeature(await api.updateFeature(projectId, featureId, patch)) + setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ + ...p, + features: p.features.map(feature => feature.id === featureId ? updated : feature), + }) : p)) + if (sel?.id === projectId) { + setSel(s => s ? normalizeProject({ + ...s, + features: s.features.map(feature => feature.id === featureId ? updated : feature), + }) : s) + } + } catch {} + } + + const deleteFeatureForProject = async (projectId, featureId) => { + setProjects(ps => ps.map(p => p.id === projectId ? normalizeProject({ ...p, features: p.features.filter(feature => feature.id !== featureId) }) : p)) + if (sel?.id === projectId) { + setSel(s => s ? normalizeProject({ ...s, features: s.features.filter(feature => feature.id !== featureId) }) : s) + } + try { await api.deleteFeature(projectId, featureId) } catch {} + } + + const change = u => { + const normalized = normalizeProject(u) + setProjects(ps => ps.map(p => p.id === normalized.id ? normalized : p)) + setSel(normalized) + api.updateProject(normalized.id, normalized).catch(() => {}) + } + + const visibleProjects = [...projects] + .filter(p => { + const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()) || p.description.toLowerCase().includes(search.toLowerCase()) + const matchesProjectStatus = fSt === 'all' || p.status === fSt + const matchesFeatureStatus = featureStatusFilter === 'all' || p.features.some(feature => feature.status === featureStatusFilter) + return matchesSearch && matchesProjectStatus && matchesFeatureStatus + }) + .sort((a, b) => { + if (projectSort === 'name-asc') return a.name.localeCompare(b.name) + if (projectSort === 'due-asc') return String(a.dueDate || '9999-12-31').localeCompare(String(b.dueDate || '9999-12-31')) + if (projectSort === 'status') return (STATUS[a.status]?.label || a.status).localeCompare(STATUS[b.status]?.label || b.status) + if (projectSort === 'feature-status') { + const aRank = Math.min(...(a.features.map(feature => featureStatusRank(feature.status)).concat(99))) + const bRank = Math.min(...(b.features.map(feature => featureStatusRank(feature.status)).concat(99))) + return aRank - bRank + } + const aUpdated = Math.max(...a.features.map(feature => new Date(feature.updatedAt || feature.createdAt || 0).getTime()).concat(0)) + const bUpdated = Math.max(...b.features.map(feature => new Date(feature.updatedAt || feature.createdAt || 0).getTime()).concat(0)) + return bUpdated - aUpdated + }) const stats = { total: projects.length, active: projects.filter(p => p.status === 'active').length, completed: projects.filter(p => p.status === 'completed').length, overdue: projects.filter(p => overdue(p.dueDate) && p.status !== 'completed').length } @@ -236,7 +351,7 @@ function App(){ - {view === 'projects' && } @@ -272,7 +387,7 @@ function App(){ {/* Search + Filter */} -
+
setSearch(e.target.value)} placeholder="Search projects…" style={{ ...inp(), paddingLeft: 32 }} /> @@ -284,23 +399,34 @@ function App(){ ))}
+ +
{/* Cards */} - {!filtered.length ? ( + {!visibleProjects.length ? (
📋
No projects found
) : (
- {filtered.map(p => { setSel(p); setTab('tasks') }} onEdit={() => setEditing(p)} onDel={() => setDelId(p.id)} />)} + {visibleProjects.map(p => { setSel(p); setTab('tasks') }} onEdit={() => setEditing(p)} onDel={() => setDelId(p.id)} />)}
)} }
- {sel && setSel(null)} onEdit={() => setEditing(sel)} onChange={change} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} allMembers={allMembers} />} + {sel && setSel(null)} onEdit={() => setEditing(sel)} onChange={change} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} onFeatureCreate={createFeatureForProject} onFeatureUpdate={updateFeatureForProject} onFeatureDelete={deleteFeatureForProject} allMembers={allMembers} />} {editing !== null && setEditing(null)} />} {delId && ( @@ -353,6 +479,8 @@ function App(){ // ── Card ── function Card({ project: p, onOpen, onEdit, onDel }){ const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning, ov = overdue(p.dueDate) && p.status !== 'completed' + const latestFeature = [...p.features].sort((a, b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0))[0] + const inFlightFeatureCount = p.features.filter(feature => ['backlog', 'planned', 'in-progress'].includes(feature.status)).length const [menu, setMenu] = useState(false) return (
e.currentTarget.style.borderColor = p.color} onMouseLeave={e => e.currentTarget.style.borderColor = '#181828'} style={{ background: '#0d0d1a', border: '1px solid #181828', borderRadius: 14, padding: '18px 18px 16px', cursor: 'pointer', transition: 'border-color 0.2s', position: 'relative', overflow: 'hidden' }}> @@ -389,12 +517,26 @@ function Card({ project: p, onOpen, onEdit, onDel }){ {p.members.length > 3 &&
+{p.members.length-3}
}
+
+
+ Feature log + {p.features.length} entries · {inFlightFeatureCount} open +
+ {latestFeature ? ( +
+ {(FEATURE_STATUS[latestFeature.status] || FEATURE_STATUS.backlog).label} + {latestFeature.title} +
+ ) : ( +
No feature activity yet.
+ )} +
) } // ── Detail Panel ── -function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreate, onTaskUpdate, onTaskDelete, allMembers = [] }){ +function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreate, onTaskUpdate, onTaskDelete, onFeatureCreate, onFeatureUpdate, onFeatureDelete, allMembers = [] }){ const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning const upTask = (id, u) => { const task = p.tasks.find(t => t.id === id) @@ -424,6 +566,9 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreat const togMs = id => onChange({ ...p, milestones: p.milestones.map(m => m.id === id ? { ...m, completed: !m.completed } : m) }) const addMs = m => onChange({ ...p, milestones: [...p.milestones, { ...m, id: uid(), completed: false }] }) const delMs = id => onChange({ ...p, milestones: p.milestones.filter(m => m.id !== id) }) + const addFeature = feature => onFeatureCreate?.(p.id, feature) + const updateFeature = (featureId, patch) => onFeatureUpdate?.(p.id, featureId, patch) + const delFeature = featureId => onFeatureDelete?.(p.id, featureId) return (
@@ -446,16 +591,17 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreat
- {['tasks','milestones','team','gantt','calendar','burndown'].map(t => ( + {['tasks','features','milestones','team','gantt','calendar','burndown'].map(t => ( ))}
{tab === 'tasks' && } + {tab === 'features' && } {tab === 'milestones' && } {tab === 'team' && } {tab === 'gantt' && } @@ -582,6 +728,9 @@ function TaskRow({ task: t, color, members = [], projectId, onUpdate, onDel, exp Upload file +
+ Max file size: {ATTACHMENT_MAX_FILE_SIZE_LABEL} per upload. +
)} @@ -647,6 +796,116 @@ function MsTab({ project: p, onToggle, onAdd, onDel }){ ) } +function FeaturesTab({ project: p, onAdd, onUpdate, onDel }){ + const EMPTY = { title: '', type: 'idea', status: 'backlog', note: '', updatedBy: '' } + const [adding, setAdding] = useState(false) + const [draft, setDraft] = useState(EMPTY) + const [filter, setFilter] = useState('all') + const [expanded, setExpanded] = useState(null) + + const submit = () => { + if (!draft.title.trim()) return + onAdd({ ...draft, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }) + setDraft(EMPTY) + setAdding(false) + } + + const items = [...p.features] + .sort((a, b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0)) + .filter(feature => filter === 'all' || feature.status === filter) + + return ( +
+ +
+ {['all','backlog','planned','in-progress','shipped','dropped'].map(status => ( + + ))} +
+ {adding && ( +
+ setDraft({ ...draft, title: e.target.value })} placeholder="Entry title…" style={inp({ marginBottom: 8 })} autoFocus /> +
+ + +
+ setDraft({ ...draft, updatedBy: e.target.value })} placeholder="Updated by (optional)…" style={inp({ marginBottom: 8 })} /> +