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 ( - <> -
-
- {/* Right side only: avatar (no name text) + hamburger */} - {token && user && ( - - )} - -
-
- - {/* Slide-out drawer */} - {drawerOpen && ( -
- {/* Backdrop */} -
- - {/* Drawer panel */} -
- {/* Close button */} -
- {t("common.menu")} - -
- - {/* User info - full name + email */} - {token && user && ( -
-
-
- {(user.name || user.email || "?").charAt(0).toUpperCase()} -
-
-

{user.name || t("settings.user")}

-

{user.email}

-
-
-
- )} - - {/* Menu items */} -
- - - - - {t("nav.tasks")} - - - - - - - {t("nav.calendar")} - - - - - - - {t("nav.chat")} - - - - - - - - {t("nav.settings")} - - - {/* Install link */} - - - - - {t("settings.install") || "Instalace"} - - - {/* Theme toggle */} - -
- - {/* Logout icon at bottom */} - {token && ( -
- -
- )} -
-
- )} - - ); + return ; } diff --git a/apps/tasks/components/features/CollabActionButtons.tsx b/apps/tasks/components/features/CollabActionButtons.tsx new file mode 100644 index 0000000..6427817 --- /dev/null +++ b/apps/tasks/components/features/CollabActionButtons.tsx @@ -0,0 +1,55 @@ +'use client'; +import IconButton from './IconButton'; + +interface Props { + onAssign: () => void; + onTransfer: () => void; + onClaim: () => void; + disabled: boolean; + t: (key: string) => string; +} + +export default function CollabActionButtons({ onAssign, onTransfer, onClaim, disabled, t }: Props) { + return ( +
+ + + + } + label={t("collab.assign")} + onClick={onAssign} + disabled={disabled} + variant="primary" + size="md" + /> + + + + + } + label={t("collab.transfer")} + onClick={onTransfer} + disabled={disabled} + variant="warning" + size="md" + /> + + + + + } + label={t("collab.claim")} + onClick={onClaim} + disabled={disabled} + variant="success" + size="md" + /> +
+ ); +} diff --git a/apps/tasks/components/features/CollabBackButton.tsx b/apps/tasks/components/features/CollabBackButton.tsx new file mode 100644 index 0000000..07d07fb --- /dev/null +++ b/apps/tasks/components/features/CollabBackButton.tsx @@ -0,0 +1,23 @@ +'use client'; +import IconButton from './IconButton'; + +interface Props { + onClick: () => void; + label: string; +} + +export default function CollabBackButton({ onClick, label }: Props) { + return ( + + + + } + label={label} + onClick={onClick} + variant="default" + size="md" + /> + ); +} diff --git a/apps/tasks/components/features/CompactHeader.tsx b/apps/tasks/components/features/CompactHeader.tsx new file mode 100644 index 0000000..dad5ba8 --- /dev/null +++ b/apps/tasks/components/features/CompactHeader.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useAuth } from '@/lib/auth'; +import { useTheme } from '@/components/ThemeProvider'; +import { useTranslation } from '@/lib/i18n'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import IconButton from './IconButton'; + +export default function CompactHeader() { + 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), []); + + 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 ( + <> +
+
+ {token && user && ( + + )} + + + + } + label={t('common.menu')} + onClick={openDrawer} + variant="default" + size="sm" + className="!bg-transparent" + /> +
+
+ + {/* Slide-out drawer */} + {drawerOpen && ( +
+
+ +
+ {/* Close */} +
+ {t('common.menu')} + + + + } + label={t('common.closeMenu')} + onClick={closeDrawer} + variant="default" + size="md" + /> +
+ + {/* User info */} + {token && user && ( +
+
+
+ {(user.name || user.email || '?').charAt(0).toUpperCase()} +
+
+

{user.name || t('settings.user')}

+

{user.email}

+
+
+
+ )} + + {/* Menu items - icons only with labels next to them in the drawer */} +
+ {[ + { + href: '/tasks', + icon: , + label: t('nav.tasks'), + }, + { + href: '/calendar', + icon: , + label: t('nav.calendar'), + }, + { + href: '/chat', + icon: , + label: t('nav.chat'), + }, + { + href: '/settings', + icon: , + label: t('nav.settings'), + }, + { + href: '/settings#install', + icon: , + label: t('settings.install') || 'Instalace', + }, + ].map((item) => ( + + {item.icon} + {item.label} + + ))} + + {/* Theme toggle */} + +
+ + {/* Logout icon at bottom */} + {token && ( +
+ + + + } + label={t('auth.logout')} + onClick={handleLogout} + variant="danger" + size="md" + /> +
+ )} +
+
+ )} + + ); +} diff --git a/apps/tasks/components/features/DeleteIconButton.tsx b/apps/tasks/components/features/DeleteIconButton.tsx new file mode 100644 index 0000000..97a6119 --- /dev/null +++ b/apps/tasks/components/features/DeleteIconButton.tsx @@ -0,0 +1,24 @@ +'use client'; +import IconButton from './IconButton'; + +interface Props { + onClick: () => void; + label: string; + size?: 'sm' | 'md' | 'lg'; +} + +export default function DeleteIconButton({ onClick, label, size = 'sm' }: Props) { + return ( + + + + } + label={label} + onClick={onClick} + variant="danger" + size={size} + /> + ); +} diff --git a/apps/tasks/components/features/GoalActionButtons.tsx b/apps/tasks/components/features/GoalActionButtons.tsx new file mode 100644 index 0000000..1adf4bf --- /dev/null +++ b/apps/tasks/components/features/GoalActionButtons.tsx @@ -0,0 +1,63 @@ +'use client'; +import IconButton from './IconButton'; + +interface Props { + onPlan: () => void; + onReport: () => void; + onDelete: () => void; + planLoading: boolean; + reportLoading: boolean; + t: (key: string) => string; +} + +export default function GoalActionButtons({ onPlan, onReport, onDelete, planLoading, reportLoading, t }: Props) { + return ( +
+ + ) : ( + + + + ) + } + label={t("goals.plan")} + onClick={onPlan} + disabled={planLoading} + variant="purple" + size="lg" + /> + + + ) : ( + + + + ) + } + label={t("goals.report")} + onClick={onReport} + disabled={reportLoading} + variant="success" + size="lg" + /> + + + + + } + label={t("tasks.delete")} + onClick={onDelete} + variant="danger" + size="lg" + /> +
+ ); +} diff --git a/apps/tasks/components/features/IconButton.tsx b/apps/tasks/components/features/IconButton.tsx new file mode 100644 index 0000000..a27d046 --- /dev/null +++ b/apps/tasks/components/features/IconButton.tsx @@ -0,0 +1,52 @@ +'use client'; +import { ReactNode } from 'react'; + +interface Props { + icon: ReactNode; + label: string; + onClick?: () => void; + className?: string; + variant?: 'default' | 'danger' | 'success' | 'primary' | 'warning' | 'purple'; + size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; + type?: 'button' | 'submit'; +} + +const variants: Record = { + default: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300', + danger: 'bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-800/40 text-red-600 dark:text-red-400', + success: 'bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-800/40 text-green-600 dark:text-green-400', + primary: 'bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-800/40 text-blue-600 dark:text-blue-400', + warning: 'bg-yellow-100 dark:bg-yellow-900/30 hover:bg-yellow-200 dark:hover:bg-yellow-800/40 text-yellow-600 dark:text-yellow-400', + purple: 'bg-purple-100 dark:bg-purple-900/30 hover:bg-purple-200 dark:hover:bg-purple-800/40 text-purple-600 dark:text-purple-400', +}; + +const sizes: Record = { + sm: 'w-8 h-8 text-sm', + md: 'w-10 h-10 text-base', + lg: 'w-12 h-12 text-lg', +}; + +export default function IconButton({ + icon, + label, + onClick, + className, + variant = 'default', + size = 'md', + disabled, + type = 'button', +}: Props) { + return ( + + ); +} diff --git a/apps/tasks/components/features/InlineEditField.tsx b/apps/tasks/components/features/InlineEditField.tsx new file mode 100644 index 0000000..7c2b1f1 --- /dev/null +++ b/apps/tasks/components/features/InlineEditField.tsx @@ -0,0 +1,98 @@ +'use client'; +import { useState, useRef, useEffect } from 'react'; + +interface Props { + value: string; + onSave: (newValue: string) => void; + className?: string; + as?: 'input' | 'textarea'; + placeholder?: string; + multiline?: boolean; +} + +export default function InlineEditField({ + value, + onSave, + className = '', + as = 'input', + placeholder = '', + multiline = false, +}: Props) { + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(value); + const inputRef = useRef(null); + + useEffect(() => { + setEditValue(value); + }, [value]); + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus(); + // Select all text + if ('setSelectionRange' in inputRef.current) { + inputRef.current.setSelectionRange(0, inputRef.current.value.length); + } + } + }, [editing]); + + function handleBlur() { + setEditing(false); + const trimmed = editValue.trim(); + if (trimmed !== value && trimmed !== '') { + onSave(trimmed); + } else { + setEditValue(value); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && !multiline) { + e.preventDefault(); + (e.target as HTMLElement).blur(); + } + if (e.key === 'Escape') { + setEditValue(value); + setEditing(false); + } + } + + if (editing) { + if (as === 'textarea' || multiline) { + return ( +