feat: feature log CRUD endpoints, timestamps, and project list sorting
- Add dedicated REST endpoints for feature CRUD (GET/POST/PUT/DELETE /api/projects/:id/features) - Add normalizeFeatureEntry() with updatedAt, updatedBy, shippedAt lifecycle - Auto-set shippedAt when status transitions to 'shipped' - Frontend api.js: getFeatures, createFeature, updateFeature, deleteFeature methods - App.jsx: optimistic CRUD handlers using dedicated feature endpoints - Project list: feature-status filter dropdown + 5-way sort (feature activity, feature status, project status, due date, name) - Project cards: feature log preview (latest entry + in-flight count) - FeaturesTab: filter by status, sort by updatedAt desc - FeatureRow: show updatedAt, updatedBy, shippedAt metadata; updatedBy edit field
This commit is contained in:
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user