diff --git a/server/routers/tasks.py b/server/routers/tasks.py index 988762b..a77986a 100644 --- a/server/routers/tasks.py +++ b/server/routers/tasks.py @@ -6,10 +6,11 @@ import logging from typing import Any, Dict from fastapi import APIRouter +from pydantic import BaseModel from server.cache import cache from server.config import get_settings -from server.services.vikunja_service import fetch_tasks +from server.services.vikunja_service import fetch_tasks, toggle_task_done logger = logging.getLogger(__name__) @@ -20,20 +21,12 @@ CACHE_KEY = "tasks" @router.get("/tasks") async def get_tasks() -> Dict[str, Any]: - """Return Vikunja task data. + """Return Vikunja task data.""" - The exact shape depends on what ``fetch_tasks`` returns; on failure an - error stub is returned instead:: - - { "error": true, "message": "..." } - """ - - # --- cache hit? ----------------------------------------------------------- cached = await cache.get(CACHE_KEY) if cached is not None: return cached - # --- cache miss ----------------------------------------------------------- try: data: Dict[str, Any] = await fetch_tasks( get_settings().vikunja_url, @@ -45,3 +38,27 @@ async def get_tasks() -> Dict[str, Any]: await cache.set(CACHE_KEY, data, get_settings().vikunja_cache_ttl) return data + + +class TaskToggleRequest(BaseModel): + task_id: int + done: bool + + +@router.post("/tasks/toggle") +async def toggle_task(body: TaskToggleRequest) -> Dict[str, Any]: + """Toggle a Vikunja task's done state.""" + settings = get_settings() + result = await toggle_task_done( + settings.vikunja_url, + settings.vikunja_token, + body.task_id, + body.done, + ) + + if result.get("ok"): + # Invalidate cache so next fetch reflects the change + await cache.invalidate(CACHE_KEY) + logger.info("[TASKS] task/%d → done=%s ✓", body.task_id, body.done) + + return result diff --git a/server/services/vikunja_service.py b/server/services/vikunja_service.py index 410a61a..8599aee 100644 --- a/server/services/vikunja_service.py +++ b/server/services/vikunja_service.py @@ -171,6 +171,46 @@ async def fetch_tasks(base_url: str, token: str) -> Dict[str, Any]: return result +async def toggle_task_done( + base_url: str, + token: str, + task_id: int, + done: bool, +) -> Dict[str, Any]: + """Toggle the done state of a single Vikunja task. + + Args: + base_url: Vikunja instance base URL. + token: API token for authentication. + task_id: The task ID to update. + done: New done state. + + Returns: + Dictionary with ``ok`` and optionally ``error``. + """ + if not base_url or not token: + return {"ok": False, "error": "Missing Vikunja base URL or token"} + + clean_url = base_url.rstrip("/") + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + try: + async with httpx.AsyncClient(timeout=10, headers=headers) as client: + resp = await client.post( + f"{clean_url}/tasks/{task_id}", + json={"done": done}, + ) + resp.raise_for_status() + return {"ok": True, "task": {"id": task_id, "done": done}} + except httpx.HTTPStatusError as exc: + return {"ok": False, "error": f"HTTP {exc.response.status_code}"} + except Exception as exc: + return {"ok": False, "error": str(exc)} + + async def fetch_single_project( base_url: str, token: str, diff --git a/web/src/api.ts b/web/src/api.ts index 99ed019..016d298 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -199,5 +199,16 @@ export const controlHA = async ( return res.json(); }; export const fetchTasks = () => fetchJSON("/tasks"); +export const toggleTask = async ( + taskId: number, + done: boolean, +): Promise<{ ok: boolean; error?: string }> => { + const res = await fetch(`${API_BASE}/tasks/toggle`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ task_id: taskId, done }), + }); + return res.json(); +}; export const fetchMqtt = () => fetchJSON("/mqtt"); export const fetchAll = () => fetchJSON("/all"); diff --git a/web/src/components/TasksCard.tsx b/web/src/components/TasksCard.tsx index 4538e84..2166254 100644 --- a/web/src/components/TasksCard.tsx +++ b/web/src/components/TasksCard.tsx @@ -1,9 +1,11 @@ -import { useState } from "react"; -import { CheckSquare, Square, AlertTriangle, Calendar } from "lucide-react"; +import { useState, useCallback } from "react"; +import { CheckSquare, Square, AlertTriangle, Calendar, ExternalLink } from "lucide-react"; import type { TasksResponse, Task } from "../api"; +import { toggleTask } from "../api"; interface TasksCardProps { data: TasksResponse; + onRefresh?: () => void; } type TabKey = "private" | "sams"; @@ -13,6 +15,9 @@ const TABS: { key: TabKey; label: string }[] = [ { key: "sams", label: "Sam's" }, ]; +/* Vikunja web base URL — strip /api/v1 from the API URL */ +const VIKUNJA_WEB = "http://10.10.10.10:3456"; + function priorityIndicator(priority: number): { color: string; label: string } { if (priority <= 1) return { color: "bg-cherry", label: "Hoch" }; if (priority <= 2) return { color: "bg-gold", label: "Mittel" }; @@ -37,7 +42,7 @@ function formatDueDate(iso: string | null): string | null { } } -export default function TasksCard({ data }: TasksCardProps) { +export default function TasksCard({ data, onRefresh }: TasksCardProps) { const [activeTab, setActiveTab] = useState("private"); if (!data) return null; @@ -102,7 +107,7 @@ export default function TasksCard({ data }: TasksCardProps) { ) : (
{tasks.map((task) => ( - + ))}
)} @@ -110,24 +115,58 @@ export default function TasksCard({ data }: TasksCardProps) { ); } -function TaskItem({ task }: { task: Task }) { +function TaskItem({ task, onRefresh }: { task: Task; onRefresh?: () => void }) { + const [toggling, setToggling] = useState(false); + const [justDone, setJustDone] = useState(false); const p = priorityIndicator(task.priority); const due = formatDueDate(task.due_date); const isOverdue = due === "Überfällig"; + const handleToggle = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (toggling) return; + setToggling(true); + try { + const res = await toggleTask(task.id, !task.done); + if (res.ok) { + setJustDone(true); + // Brief visual feedback before refreshing + setTimeout(() => onRefresh?.(), 600); + } + } finally { + setToggling(false); + } + }, [task.id, task.done, toggling, onRefresh]); + + const handleOpenTask = useCallback(() => { + window.open(`${VIKUNJA_WEB}/tasks/${task.id}`, "_blank"); + }, [task.id]); + + const isDone = task.done || justDone; + return ( -
-
- {task.done ? ( - +
+ {/* Checkbox */} +
+

{task.title}

@@ -150,8 +189,10 @@ function TaskItem({ task }: { task: Task }) {
-
-
+ {/* Priority + open link */} +
+
+
); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index e4723c3..bb8184c 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -98,7 +98,7 @@ export default function Dashboard() {
- +