feat: interactive Vikunja tasks — checkbox toggles done, click opens task

- Added POST /api/tasks/toggle endpoint to mark tasks as done/undone
- Added toggle_task_done() in vikunja_service (POST /tasks/{id})
- Cache invalidated after toggle for immediate refresh
- Checkbox click toggles done state with visual feedback
- Click on task row opens Vikunja in new tab (/tasks/{id})
- ExternalLink icon appears on hover as affordance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam 2026-03-02 23:43:01 +01:00
parent c6db0ab569
commit bc2dcb5589
5 changed files with 134 additions and 25 deletions

View file

@ -6,10 +6,11 @@ import logging
from typing import Any, Dict from typing import Any, Dict
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel
from server.cache import cache from server.cache import cache
from server.config import get_settings 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__) logger = logging.getLogger(__name__)
@ -20,20 +21,12 @@ CACHE_KEY = "tasks"
@router.get("/tasks") @router.get("/tasks")
async def get_tasks() -> Dict[str, Any]: 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) cached = await cache.get(CACHE_KEY)
if cached is not None: if cached is not None:
return cached return cached
# --- cache miss -----------------------------------------------------------
try: try:
data: Dict[str, Any] = await fetch_tasks( data: Dict[str, Any] = await fetch_tasks(
get_settings().vikunja_url, 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) await cache.set(CACHE_KEY, data, get_settings().vikunja_cache_ttl)
return data 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

View file

@ -171,6 +171,46 @@ async def fetch_tasks(base_url: str, token: str) -> Dict[str, Any]:
return result 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( async def fetch_single_project(
base_url: str, base_url: str,
token: str, token: str,

View file

@ -199,5 +199,16 @@ export const controlHA = async (
return res.json(); return res.json();
}; };
export const fetchTasks = () => fetchJSON<TasksResponse>("/tasks"); export const fetchTasks = () => fetchJSON<TasksResponse>("/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<MqttData>("/mqtt"); export const fetchMqtt = () => fetchJSON<MqttData>("/mqtt");
export const fetchAll = () => fetchJSON<DashboardData>("/all"); export const fetchAll = () => fetchJSON<DashboardData>("/all");

View file

@ -1,9 +1,11 @@
import { useState } from "react"; import { useState, useCallback } from "react";
import { CheckSquare, Square, AlertTriangle, Calendar } from "lucide-react"; import { CheckSquare, Square, AlertTriangle, Calendar, ExternalLink } from "lucide-react";
import type { TasksResponse, Task } from "../api"; import type { TasksResponse, Task } from "../api";
import { toggleTask } from "../api";
interface TasksCardProps { interface TasksCardProps {
data: TasksResponse; data: TasksResponse;
onRefresh?: () => void;
} }
type TabKey = "private" | "sams"; type TabKey = "private" | "sams";
@ -13,6 +15,9 @@ const TABS: { key: TabKey; label: string }[] = [
{ key: "sams", label: "Sam's" }, { 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 } { function priorityIndicator(priority: number): { color: string; label: string } {
if (priority <= 1) return { color: "bg-cherry", label: "Hoch" }; if (priority <= 1) return { color: "bg-cherry", label: "Hoch" };
if (priority <= 2) return { color: "bg-gold", label: "Mittel" }; 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<TabKey>("private"); const [activeTab, setActiveTab] = useState<TabKey>("private");
if (!data) return null; if (!data) return null;
@ -102,7 +107,7 @@ export default function TasksCard({ data }: TasksCardProps) {
) : ( ) : (
<div className="space-y-px max-h-80 overflow-y-auto"> <div className="space-y-px max-h-80 overflow-y-auto">
{tasks.map((task) => ( {tasks.map((task) => (
<TaskItem key={task.id} task={task} /> <TaskItem key={task.id} task={task} onRefresh={onRefresh} />
))} ))}
</div> </div>
)} )}
@ -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 p = priorityIndicator(task.priority);
const due = formatDueDate(task.due_date); const due = formatDueDate(task.due_date);
const isOverdue = due === "Überfällig"; 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 ( return (
<div className="flex items-start gap-3 px-3 py-2.5 bg-base-100 border-l-2 border-base-300 hover:border-azure transition-colors group"> <div
<div className="mt-0.5 flex-shrink-0"> className="flex items-start gap-3 px-3 py-2.5 bg-base-100 border-l-2 border-base-300 hover:border-azure transition-colors group cursor-pointer"
{task.done ? ( onClick={handleOpenTask}
<CheckSquare className="w-4 h-4 text-azure" /> title="In Vikunja öffnen"
>
{/* Checkbox */}
<button
onClick={handleToggle}
disabled={toggling}
className="mt-0.5 flex-shrink-0 focus:outline-none"
title={isDone ? "Als offen markieren" : "Abhaken"}
>
{isDone ? (
<CheckSquare className={`w-4 h-4 text-azure ${toggling ? "animate-pulse" : ""}`} />
) : ( ) : (
<Square className="w-4 h-4 text-base-400 group-hover:text-base-600 transition-colors" /> <Square className={`w-4 h-4 text-base-400 hover:text-azure transition-colors ${toggling ? "animate-pulse" : ""}`} />
)} )}
</div> </button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className={`text-sm leading-snug ${ <p className={`text-sm leading-snug ${
task.done ? "text-base-500 line-through" : "text-base-800" isDone ? "text-base-500 line-through" : "text-base-800"
}`}> }`}>
{task.title} {task.title}
</p> </p>
@ -150,8 +189,10 @@ function TaskItem({ task }: { task: Task }) {
</div> </div>
</div> </div>
<div className="flex-shrink-0 mt-1.5" title={p.label}> {/* Priority + open link */}
<div className={`w-1.5 h-1.5 ${p.color}`} style={{ borderRadius: 0 }} /> <div className="flex-shrink-0 flex items-center gap-2 mt-1">
<div className={`w-1.5 h-1.5 ${p.color}`} style={{ borderRadius: 0 }} title={p.label} />
<ExternalLink className="w-3 h-3 text-base-400 opacity-0 group-hover:opacity-100 transition-opacity" />
</div> </div>
</div> </div>
); );

View file

@ -98,7 +98,7 @@ export default function Dashboard() {
<div className="xl:col-span-2"> <div className="xl:col-span-2">
<HomeAssistant data={data.ha} /> <HomeAssistant data={data.ha} />
</div> </div>
<TasksCard data={data.tasks} /> <TasksCard data={data.tasks} onRefresh={refresh} />
</div> </div>
</section> </section>