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:
parent
c6db0ab569
commit
bc2dcb5589
5 changed files with 134 additions and 25 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue