diff --git a/api/src/features/ai-briefing.js b/api/src/features/ai-briefing.js
new file mode 100644
index 0000000..ddb631c
--- /dev/null
+++ b/api/src/features/ai-briefing.js
@@ -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;
diff --git a/api/src/features/kanban.js b/api/src/features/kanban.js
new file mode 100644
index 0000000..e1ff6bf
--- /dev/null
+++ b/api/src/features/kanban.js
@@ -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;
diff --git a/api/src/features/time-tracking.js b/api/src/features/time-tracking.js
new file mode 100644
index 0000000..eae94ca
--- /dev/null
+++ b/api/src/features/time-tracking.js
@@ -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;
diff --git a/api/src/features/webhooks-outgoing.js b/api/src/features/webhooks-outgoing.js
new file mode 100644
index 0000000..feb30c1
--- /dev/null
+++ b/api/src/features/webhooks-outgoing.js
@@ -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;
diff --git a/apps/tasks/components/Header.tsx b/apps/tasks/components/Header.tsx
index 3aa8872..d1ad59e 100644
--- a/apps/tasks/components/Header.tsx
+++ b/apps/tasks/components/Header.tsx
@@ -1,205 +1,7 @@
"use client";
-import { useState, useEffect, useCallback } from "react";
-import { useAuth } from "@/lib/auth";
-import { useTheme } from "./ThemeProvider";
-import { useTranslation } from "@/lib/i18n";
-import Link from "next/link";
-import { useRouter } from "next/navigation";
+import CompactHeader from "./features/CompactHeader";
export default function Header() {
- const { user, logout, token } = useAuth();
- const { theme, toggleTheme } = useTheme();
- const { t } = useTranslation();
- const router = useRouter();
- const [drawerOpen, setDrawerOpen] = useState(false);
-
- function handleLogout() {
- logout();
- router.push("/login");
- setDrawerOpen(false);
- }
-
- const closeDrawer = useCallback(() => setDrawerOpen(false), []);
- const openDrawer = useCallback(() => setDrawerOpen(true), []);
-
- // Close drawer on escape
- useEffect(() => {
- if (!drawerOpen) return;
- function handleKey(e: KeyboardEvent) {
- if (e.key === "Escape") closeDrawer();
- }
- document.addEventListener("keydown", handleKey);
- document.body.style.overflow = "hidden";
- return () => {
- document.removeEventListener("keydown", handleKey);
- document.body.style.overflow = "";
- };
- }, [drawerOpen, closeDrawer]);
-
- return (
- <>
-
{user.name || t("settings.user")}
-{user.email}
-{user.name || t('settings.user')}
+{user.email}
+