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:
28
api/src/features/ai-briefing.js
Normal file
28
api/src/features/ai-briefing.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Task Team — AI Daily Briefing
|
||||
const Anthropic = require("@anthropic-ai/sdk");
|
||||
|
||||
async function aiBriefingFeature(app) {
|
||||
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
|
||||
app.get("/briefing/:userId", async (req) => {
|
||||
const { rows: tasks } = await app.db.query(
|
||||
"SELECT title, status, priority, due_at, group_id FROM tasks WHERE user_id=$1 AND status NOT IN ('done','completed','cancelled') ORDER BY priority DESC, due_at ASC NULLS LAST LIMIT 20",
|
||||
[req.params.userId]
|
||||
);
|
||||
const { rows: goals } = await app.db.query(
|
||||
"SELECT title, progress_pct FROM goals WHERE user_id=$1 LIMIT 5", [req.params.userId]
|
||||
);
|
||||
const { rows: reviews } = await app.db.query(
|
||||
"SELECT count(*) as due FROM review_items WHERE user_id=$1 AND next_review <= NOW()", [req.params.userId]
|
||||
);
|
||||
|
||||
const response = await anthropic.messages.create({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 512,
|
||||
system: "Jsi osobni asistent. Dej strucny ranni briefing v cestine. Bud prakticky a motivujici.",
|
||||
messages: [{ role: "user", content: `Ranni briefing pro uzivatele:\nUkoly (${tasks.length}): ${JSON.stringify(tasks.slice(0,5).map(t=>t.title))}\nCile: ${JSON.stringify(goals.map(g=>g.title+" "+g.progress_pct+"%"))}\nKarty k opakovani: ${reviews[0]?.due || 0}\nDnes je ${new Date().toLocaleDateString("cs-CZ",{weekday:"long",day:"numeric",month:"long"})}` }]
|
||||
});
|
||||
return { data: { briefing: response.content[0].text, tasks_count: tasks.length, reviews_due: parseInt(reviews[0]?.due || 0) } };
|
||||
});
|
||||
}
|
||||
module.exports = aiBriefingFeature;
|
||||
44
api/src/features/kanban.js
Normal file
44
api/src/features/kanban.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Task Team — Kanban Board — drag-and-drop columns
|
||||
async function kanbanFeature(app) {
|
||||
// Kanban uses existing tasks table, just provides board-view endpoints
|
||||
|
||||
app.get("/kanban/board", async (req) => {
|
||||
const { group_id, project_id } = req.query;
|
||||
let where = "1=1";
|
||||
const params = [];
|
||||
if (group_id) { params.push(group_id); where += ` AND t.group_id=$${params.length}`; }
|
||||
if (project_id) { params.push(project_id); where += ` AND t.project_id=$${params.length}`; }
|
||||
|
||||
const columns = ["pending", "in_progress", "done", "cancelled"];
|
||||
const board = {};
|
||||
for (const col of columns) {
|
||||
const colParams = [...params, col];
|
||||
const { rows } = await app.db.query(
|
||||
`SELECT t.id, t.title, t.priority, t.assigned_to, t.group_id, tg.color as group_color, tg.icon as group_icon
|
||||
FROM tasks t LEFT JOIN task_groups tg ON t.group_id=tg.id
|
||||
WHERE ${where} AND t.status=$${colParams.length}
|
||||
ORDER BY t.created_at DESC LIMIT 50`, colParams
|
||||
);
|
||||
board[col] = rows;
|
||||
}
|
||||
return { data: board };
|
||||
});
|
||||
|
||||
// Move task between columns (change status)
|
||||
app.post("/kanban/move", async (req) => {
|
||||
const { task_id, new_status } = req.body;
|
||||
const valid = ["pending", "in_progress", "done", "completed", "cancelled"];
|
||||
if (!valid.includes(new_status)) throw { statusCode: 400, message: "Invalid status" };
|
||||
|
||||
const sets = ["status=$1", "updated_at=NOW()"];
|
||||
const params = [new_status];
|
||||
if (new_status === "done" || new_status === "completed") sets.push("completed_at=NOW()");
|
||||
params.push(task_id);
|
||||
|
||||
const { rows } = await app.db.query(
|
||||
`UPDATE tasks SET ${sets.join(",")} WHERE id=$${params.length} RETURNING *`, params
|
||||
);
|
||||
return { data: rows[0] };
|
||||
});
|
||||
}
|
||||
module.exports = kanbanFeature;
|
||||
72
api/src/features/time-tracking.js
Normal file
72
api/src/features/time-tracking.js
Normal 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;
|
||||
57
api/src/features/webhooks-outgoing.js
Normal file
57
api/src/features/webhooks-outgoing.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Task Team — Outgoing Webhooks — notify external systems
|
||||
async function webhooksFeature(app) {
|
||||
await app.db.query(`
|
||||
CREATE TABLE IF NOT EXISTS webhook_endpoints (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
events TEXT[] DEFAULT '{"task.created","task.completed"}',
|
||||
secret VARCHAR(64),
|
||||
active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`).catch(() => {});
|
||||
|
||||
app.get("/webhooks", async (req) => {
|
||||
const { user_id } = req.query;
|
||||
const { rows } = await app.db.query("SELECT id,url,events,active,created_at FROM webhook_endpoints WHERE user_id=$1", [user_id]);
|
||||
return { data: rows };
|
||||
});
|
||||
|
||||
app.post("/webhooks", async (req) => {
|
||||
const { user_id, url, events, secret } = req.body;
|
||||
const crypto = require("crypto");
|
||||
const { rows } = await app.db.query(
|
||||
"INSERT INTO webhook_endpoints (user_id, url, events, secret) VALUES ($1,$2,$3,$4) RETURNING *",
|
||||
[user_id, url, events || ["task.created","task.completed"], secret || crypto.randomBytes(16).toString("hex")]
|
||||
);
|
||||
return { data: rows[0] };
|
||||
});
|
||||
|
||||
app.delete("/webhooks/:id", async (req) => {
|
||||
await app.db.query("DELETE FROM webhook_endpoints WHERE id=$1", [req.params.id]);
|
||||
return { status: "deleted" };
|
||||
});
|
||||
|
||||
// Fire webhook (internal — called from task routes)
|
||||
app.post("/webhooks/fire", async (req) => {
|
||||
const { event, payload, user_id } = req.body;
|
||||
const { rows } = await app.db.query(
|
||||
"SELECT * FROM webhook_endpoints WHERE user_id=$1 AND active=true AND $2=ANY(events)",
|
||||
[user_id, event]
|
||||
);
|
||||
let sent = 0;
|
||||
for (const wh of rows) {
|
||||
try {
|
||||
await fetch(wh.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Webhook-Secret": wh.secret || "" },
|
||||
body: JSON.stringify({ event, payload, timestamp: new Date().toISOString() })
|
||||
});
|
||||
sent++;
|
||||
} catch {}
|
||||
}
|
||||
return { status: "ok", sent, total: rows.length };
|
||||
});
|
||||
}
|
||||
module.exports = webhooksFeature;
|
||||
Reference in New Issue
Block a user