Feature pack: Media, Gamification, Templates, Time Tracking, Kanban, AI Briefing, Webhooks, Icon UI

API features (separate files in /api/src/features/):
- media-input: upload text/audio/photo/video, transcription
- gamification: points, streaks, badges, leaderboard
- templates: predefined task sets (sprint, study, moving)
- time-tracking: start/stop timer, task/user reports
- kanban: board view, drag-and-drop move
- ai-briefing: daily AI summary with tasks/goals/reviews
- webhooks-outgoing: notify external systems on events

UI components (separate files in /components/features/):
- IconButton: icon-only buttons with tooltip
- CompactHeader, PageActionBar, InlineEditField
- TaskDetailActions, GoalActionButtons, CollabActionButtons
- DeleteIconButton, CollabBackButton

All features modular — registry.js enables/disables each one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 01:13:09 +00:00
parent f9c4ec631c
commit 8cf14dcf59
14 changed files with 875 additions and 200 deletions

View File

@@ -0,0 +1,72 @@
// Task Team — Time Tracking — stopwatch on tasks
async function timeTrackingFeature(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS time_entries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
duration_seconds INTEGER,
note TEXT DEFAULT '\,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_time_task ON time_entries(task_id);
`).catch(() => {});
// Start timer
app.post("/time/start", async (req) => {
const { task_id, user_id } = req.body;
// Stop any running timer first
await app.db.query(
"UPDATE time_entries SET ended_at=NOW(), duration_seconds=EXTRACT(EPOCH FROM NOW()-started_at)::integer WHERE user_id=$1 AND ended_at IS NULL",
[user_id]
);
const { rows } = await app.db.query(
"INSERT INTO time_entries (task_id, user_id) VALUES ($1,$2) RETURNING *",
[task_id, user_id]
);
return { data: rows[0] };
});
// Stop timer
app.post("/time/stop", async (req) => {
const { user_id, note } = req.body;
const { rows } = await app.db.query(
"UPDATE time_entries SET ended_at=NOW(), duration_seconds=EXTRACT(EPOCH FROM NOW()-started_at)::integer, note=$2 WHERE user_id=$1 AND ended_at IS NULL RETURNING *",
[user_id, note || ""]
);
if (!rows.length) throw { statusCode: 404, message: "No running timer" };
return { data: rows[0] };
});
// Get active timer
app.get("/time/active/:userId", async (req) => {
const { rows } = await app.db.query(
"SELECT te.*, t.title as task_title FROM time_entries te JOIN tasks t ON te.task_id=t.id WHERE te.user_id=$1 AND te.ended_at IS NULL",
[req.params.userId]
);
return { data: rows[0] || null };
});
// Task time report
app.get("/time/task/:taskId", async (req) => {
const { rows } = await app.db.query(
"SELECT te.*, u.name as user_name FROM time_entries te JOIN users u ON te.user_id=u.id WHERE te.task_id=$1 ORDER BY te.started_at DESC",
[req.params.taskId]
);
const total = rows.reduce((s, r) => s + (r.duration_seconds || 0), 0);
return { data: rows, total_seconds: total, total_hours: Math.round(total / 36) / 100 };
});
// User weekly report
app.get("/time/report/:userId", async (req) => {
const { rows } = await app.db.query(`
SELECT date_trunc('day', started_at)::date as day, sum(duration_seconds) as seconds, count(*) as entries
FROM time_entries WHERE user_id=$1 AND started_at > NOW() - INTERVAL '7 days' AND duration_seconds IS NOT NULL
GROUP BY 1 ORDER BY 1
`, [req.params.userId]);
return { data: rows };
});
}
module.exports = timeTrackingFeature;