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:
Ryan Lancaster
2026-03-17 15:23:43 -04:00
parent d1a755e4cb
commit 9302a88aea
3 changed files with 386 additions and 34 deletions

View File

@@ -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));