Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats
This commit is contained in:
827
api/mc.py
827
api/mc.py
@@ -1,218 +1,695 @@
|
||||
"""
|
||||
Mission Control API — Data layer for Hermes WebUI Mission Control extension.
|
||||
Provides priorities, tasks, feed, and dashboard status management.
|
||||
"""
|
||||
# api/mc.py
|
||||
# Mission Control — Projects & Tasks API
|
||||
# Rose's persönliches PM-System
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from datetime import datetime, date, timedelta
|
||||
|
||||
from api.helpers import j
|
||||
HERMES_HOME = Path.home() / ".hermes"
|
||||
DATA_DIR = HERMES_HOME / "data" / "mc"
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── State file ────────────────────────────────────────────────────────────────
|
||||
_MC_DATA_FILE = Path.home() / ".hermes" / "data" / "mc-data.json"
|
||||
_MC_LOCK = threading.RLock()
|
||||
TASKS_FILE = DATA_DIR / "tasks.json"
|
||||
PROJECTS_FILE = DATA_DIR / "projects.json"
|
||||
|
||||
# ── Default structure ─────────────────────────────────────────────────────────
|
||||
DEFAULT_MC_DATA = {
|
||||
"priorities": [],
|
||||
"tasks": [],
|
||||
"feed": [],
|
||||
TASKS_FILE.write_text(json.dumps({"version": "3.0.0", "tasks": []}, indent=2))
|
||||
PROJECTS_FILE.write_text(json.dumps({"version": "3.0.0", "projects": []}, indent=2))
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# AGENT REGISTRY
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
AGENTS = {
|
||||
"root": {"name": "🌳 Root", "emoji": "🌳", "domain": "Infrastruktur, Server, Docker, Backups"},
|
||||
"forget-me-not": {"name": "🌼 Forget-me-not", "emoji": "🌼", "domain": "Kalender, Termine, Geburtstage"},
|
||||
"sunflower": {"name": "🌻 Sunflower", "emoji": "🌻", "domain": "Finanzen, Abos, Rechnungen"},
|
||||
"iris": {"name": "⚜️ Iris", "emoji": "⚜️", "domain": "Karriere, Lernen, Focus"},
|
||||
"lotus": {"name": "🪷 Lotus", "emoji": "🪷", "domain": "Gesundheit, Fitness, Hobbys"},
|
||||
"ivy": {"name": "🌿 Ivy", "emoji": "🌿", "domain": "Smart Home, Home Assistant"},
|
||||
"dandelion": {"name": "🛡 Dandelion", "emoji": "🛡", "domain": "Kommunikation, Notifications, Spam"},
|
||||
"rose": {"name": "🌹 Rose", "emoji": "🌹", "domain": "Orchestrierung, Koordination"},
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# INTERNAL HELPERS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_mc_data() -> dict:
|
||||
"""Load Mission Control data from disk."""
|
||||
with _MC_LOCK:
|
||||
if not _MC_DATA_FILE.exists():
|
||||
return DEFAULT_MC_DATA.copy()
|
||||
try:
|
||||
with open(_MC_DATA_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return DEFAULT_MC_DATA.copy()
|
||||
def _load_tasks():
|
||||
if TASKS_FILE.exists():
|
||||
with TASKS_FILE.open(encoding="utf-8") as f:
|
||||
return json.loads(f.read())
|
||||
return {"version": "3.0.0", "tasks": []}
|
||||
|
||||
def _save_tasks(data):
|
||||
TASKS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
def _save_mc_data(data: dict) -> None:
|
||||
"""Save Mission Control data to disk."""
|
||||
with _MC_LOCK:
|
||||
_MC_DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(_MC_DATA_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
def _load_projects():
|
||||
if PROJECTS_FILE.exists():
|
||||
with PROJECTS_FILE.open(encoding="utf-8") as f:
|
||||
return json.loads(f.read())
|
||||
return {"version": "3.0.0", "projects": []}
|
||||
|
||||
def _save_projects(data):
|
||||
PROJECTS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
# ── Priority helpers ──────────────────────────────────────────────────────────
|
||||
def _new_id(prefix="task"):
|
||||
return f"{prefix}-{datetime.now().strftime('%y%m%d%H%M%S')}-{uuid.uuid4().hex[:4]}"
|
||||
|
||||
def get_priorities() -> list[dict]:
|
||||
"""Return all priorities sorted by id."""
|
||||
data = _load_mc_data()
|
||||
return sorted(data.get("priorities", []), key=lambda p: p.get("id", 0))
|
||||
def _now():
|
||||
return datetime.now().isoformat()
|
||||
|
||||
def _today():
|
||||
return date.today().isoformat()
|
||||
|
||||
def create_priority(name: str, color: str = "#808080") -> dict:
|
||||
"""Add a new priority. Returns the created priority."""
|
||||
data = _load_mc_data()
|
||||
priorities = data.get("priorities", [])
|
||||
new_id = max([p.get("id", 0) for p in priorities], default=0) + 1
|
||||
priority = {"id": new_id, "name": name, "color": color}
|
||||
priorities.append(priority)
|
||||
data["priorities"] = priorities
|
||||
_save_mc_data(data)
|
||||
_add_feed_event(f"Priority created: {name}")
|
||||
return priority
|
||||
def _auto_done_subtasks(item):
|
||||
"""Check if all subtasks are done (for auto-done logic)."""
|
||||
subtasks = item.get("subtasks", [])
|
||||
if not subtasks:
|
||||
return None
|
||||
all_done = all(s.get("done", False) for s in subtasks)
|
||||
return all_done
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# TASKS — CRUD
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def update_priority(priority_id: int, name: str = None, color: str = None, done: bool = None) -> dict | None:
|
||||
"""Update an existing priority. Returns updated priority or None if not found."""
|
||||
data = _load_mc_data()
|
||||
priorities = data.get("priorities", [])
|
||||
for p in priorities:
|
||||
if p.get("id") == priority_id:
|
||||
if name is not None:
|
||||
p["name"] = name
|
||||
if color is not None:
|
||||
p["color"] = color
|
||||
if done is not None:
|
||||
p["done"] = done
|
||||
if done:
|
||||
_add_feed_event(f"Priority completed: {p['name']}")
|
||||
data["priorities"] = priorities
|
||||
_save_mc_data(data)
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def delete_priority(priority_id: int) -> bool:
|
||||
"""Delete a priority. Returns True if found and deleted."""
|
||||
data = _load_mc_data()
|
||||
priorities = data.get("priorities", [])
|
||||
original_len = len(priorities)
|
||||
priorities = [p for p in priorities if p.get("id") != priority_id]
|
||||
if len(priorities) < original_len:
|
||||
data["priorities"] = priorities
|
||||
_save_mc_data(data)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Task helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_tasks() -> list[dict]:
|
||||
"""Return all tasks sorted by priority then id."""
|
||||
data = _load_mc_data()
|
||||
return sorted(data.get("tasks", []), key=lambda t: (t.get("priority", 999), t.get("id", 0)))
|
||||
|
||||
|
||||
def create_task(title: str, priority: int = 1, status: str = "backlog") -> dict:
|
||||
"""Create a new task. Returns the created task."""
|
||||
data = _load_mc_data()
|
||||
def list_tasks(filters=None):
|
||||
"""GET /api/mc/tasks — alle Tasks mit optionalen Filtern."""
|
||||
data = _load_tasks()
|
||||
tasks = data.get("tasks", [])
|
||||
new_id = max([t.get("id", 0) for t in tasks], default=0) + 1
|
||||
task = {"id": new_id, "title": title, "priority": priority, "status": status}
|
||||
tasks.append(task)
|
||||
data["tasks"] = tasks
|
||||
_save_mc_data(data)
|
||||
_add_feed_event(f"Task created: {title}")
|
||||
|
||||
if not filters:
|
||||
return tasks
|
||||
|
||||
# Filter: project_id
|
||||
if "project_id" in filters and filters["project_id"]:
|
||||
tasks = [t for t in tasks if t.get("project_id") == filters["project_id"]]
|
||||
|
||||
# Filter: phase_id
|
||||
if "phase_id" in filters and filters["phase_id"]:
|
||||
tasks = [t for t in tasks if t.get("phase_id") == filters["phase_id"]]
|
||||
|
||||
# Filter: task_type
|
||||
if "task_type" in filters and filters["task_type"]:
|
||||
tasks = [t for t in tasks if t.get("task_type") == filters["task_type"]]
|
||||
|
||||
# Filter: type (user/agent)
|
||||
if "type" in filters and filters["type"]:
|
||||
tasks = [t for t in tasks if t.get("type") == filters["type"]]
|
||||
|
||||
# Filter: assigned_agent
|
||||
if "assigned_agent" in filters and filters["assigned_agent"]:
|
||||
tasks = [t for t in tasks if t.get("assigned_agent") == filters["assigned_agent"]]
|
||||
|
||||
# Filter: status
|
||||
if "status" in filters and filters["status"]:
|
||||
tasks = [t for t in tasks if t.get("status") == filters["status"]]
|
||||
|
||||
# Filter: priority
|
||||
if "priority" in filters and filters["priority"]:
|
||||
tasks = [t for t in tasks if t.get("priority") == filters["priority"]]
|
||||
|
||||
# Filter: task_type = one-time | daily (shorthand)
|
||||
if "task_type" in filters and filters["task_type"]:
|
||||
tasks = [t for t in tasks if t.get("task_type") == filters["task_type"]]
|
||||
|
||||
return tasks
|
||||
|
||||
def get_task(task_id):
|
||||
"""GET /api/mc/tasks/:id — einzelner Task."""
|
||||
data = _load_tasks()
|
||||
return next((t for t in data["tasks"] if t["id"] == task_id), None)
|
||||
|
||||
def create_task(body):
|
||||
"""POST /api/mc/tasks — Task erstellen."""
|
||||
data = _load_tasks()
|
||||
|
||||
task = {
|
||||
"id": _new_id("task"),
|
||||
"title": body.get("title", "Untitled Task"),
|
||||
"task_type": body.get("task_type", "one-time"),
|
||||
"type": body.get("type", "user"),
|
||||
"project_id": body.get("project_id"),
|
||||
"phase_id": body.get("phase_id"),
|
||||
"status": body.get("status", "todo"),
|
||||
"priority": body.get("priority", "p2"),
|
||||
"due": body.get("due"),
|
||||
"due_time": body.get("due_time"),
|
||||
"tags": body.get("tags", []),
|
||||
"daily_schedule": body.get("daily_schedule"),
|
||||
"daily_completed_today": False,
|
||||
"daily_last_done": None,
|
||||
"assigned_agent": body.get("assigned_agent"),
|
||||
"agent_status": "pending" if body.get("type") == "agent" else None,
|
||||
"agent_note": body.get("agent_note"),
|
||||
"cron_schedule": body.get("cron_schedule"),
|
||||
"cron_last_run": None,
|
||||
"cron_next_run": None,
|
||||
"subtasks": [],
|
||||
"created_by": body.get("created_by", "user"),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"completed_at": None,
|
||||
}
|
||||
|
||||
data["tasks"].append(task)
|
||||
_save_tasks(data)
|
||||
return task
|
||||
|
||||
def update_task(task_id, body):
|
||||
"""PUT /api/mc/tasks/:id — Task updaten."""
|
||||
data = _load_tasks()
|
||||
|
||||
def update_task(task_id: int, **kwargs) -> dict | None:
|
||||
"""Update a task by id. kwargs: title, priority, status. Returns updated task or None."""
|
||||
data = _load_mc_data()
|
||||
tasks = data.get("tasks", [])
|
||||
for t in tasks:
|
||||
if t.get("id") == task_id:
|
||||
old_status = t.get("status")
|
||||
for key in ("title", "priority", "status"):
|
||||
if key in kwargs:
|
||||
t[key] = kwargs[key]
|
||||
new_status = t.get("status")
|
||||
# Feed events for status transitions
|
||||
if old_status != new_status:
|
||||
if new_status == "done":
|
||||
_add_feed_event(f"Task completed: {t['title']}")
|
||||
elif new_status == "progress":
|
||||
_add_feed_event(f"Task started: {t['title']}")
|
||||
data["tasks"] = tasks
|
||||
_save_mc_data(data)
|
||||
for t in data["tasks"]:
|
||||
if t["id"] == task_id:
|
||||
# Erlaubte Felder
|
||||
for key in ["title", "task_type", "type", "project_id", "phase_id",
|
||||
"status", "priority", "due", "due_time", "tags",
|
||||
"daily_schedule", "assigned_agent", "agent_status",
|
||||
"agent_note", "cron_schedule", "cron_last_run",
|
||||
"cron_next_run", "daily_completed_today", "daily_last_done"]:
|
||||
if key in body:
|
||||
t[key] = body[key]
|
||||
|
||||
# Status → completed_at
|
||||
if body.get("status") == "done" and t["completed_at"] is None:
|
||||
t["completed_at"] = _now()
|
||||
elif body.get("status") and body.get("status") != "done":
|
||||
t["completed_at"] = None
|
||||
|
||||
t["updated_at"] = _now()
|
||||
|
||||
# Auto-done via subtasks
|
||||
all_done = _auto_done_subtasks(t)
|
||||
if all_done is True and t["status"] != "done":
|
||||
t["status"] = "done"
|
||||
t["completed_at"] = _now()
|
||||
elif all_done is False and t["status"] == "done":
|
||||
t["status"] = "todo"
|
||||
|
||||
_save_tasks(data)
|
||||
return t
|
||||
|
||||
return None
|
||||
|
||||
def delete_task(task_id):
|
||||
"""DELETE /api/mc/tasks/:id — Task löschen."""
|
||||
data = _load_tasks()
|
||||
before = len(data["tasks"])
|
||||
data["tasks"] = [t for t in data["tasks"] if t["id"] != task_id]
|
||||
_save_tasks(data)
|
||||
return len(data["tasks"]) < before
|
||||
|
||||
def delete_task(task_id: int) -> bool:
|
||||
"""Delete a task. Returns True if found and deleted."""
|
||||
data = _load_mc_data()
|
||||
tasks = data.get("tasks", [])
|
||||
original_len = len(tasks)
|
||||
tasks = [t for t in tasks if t.get("id") != task_id]
|
||||
if len(tasks) < original_len:
|
||||
data["tasks"] = tasks
|
||||
_save_mc_data(data)
|
||||
return True
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# DAILY TASKS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_daily():
|
||||
"""GET /api/mc/daily — alle Daily Tasks."""
|
||||
return list_tasks({"task_type": "daily"})
|
||||
|
||||
def toggle_daily_done(task_id):
|
||||
"""POST /api/mc/daily/:id/done — Daily Task heute erledigt togglen."""
|
||||
data = _load_tasks()
|
||||
|
||||
for t in data["tasks"]:
|
||||
if t["id"] == task_id and t.get("task_type") == "daily":
|
||||
t["daily_completed_today"] = not t["daily_completed_today"]
|
||||
if t["daily_completed_today"]:
|
||||
t["daily_last_done"] = _today()
|
||||
t["status"] = "done"
|
||||
t["completed_at"] = _now()
|
||||
else:
|
||||
t["status"] = "todo"
|
||||
t["completed_at"] = None
|
||||
t["updated_at"] = _now()
|
||||
_save_tasks(data)
|
||||
return t
|
||||
|
||||
return None
|
||||
|
||||
def reset_daily_tasks():
|
||||
"""POST /api/mc/daily/reset — Alle daily_completed_today = false (Mitternacht)."""
|
||||
data = _load_tasks()
|
||||
for t in data["tasks"]:
|
||||
if t.get("task_type") == "daily":
|
||||
t["daily_completed_today"] = False
|
||||
if t["status"] == "done" and t.get("daily_last_done") != _today():
|
||||
t["status"] = "todo"
|
||||
t["completed_at"] = None
|
||||
_save_tasks(data)
|
||||
return {"ok": True, "reset_at": _now()}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# SUBTASKS — TASK LEVEL
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def add_subtask_task(task_id, body):
|
||||
"""POST /api/mc/tasks/:id/subtasks — Subtask zu Task."""
|
||||
data = _load_tasks()
|
||||
for t in data["tasks"]:
|
||||
if t["id"] == task_id:
|
||||
subtask = {
|
||||
"id": _new_id("sub"),
|
||||
"title": body.get("title", "Subtask"),
|
||||
"done": False,
|
||||
"order": len(t.get("subtasks", [])) + 1,
|
||||
"created_at": _now(),
|
||||
}
|
||||
if "subtasks" not in t:
|
||||
t["subtasks"] = []
|
||||
t["subtasks"].append(subtask)
|
||||
t["updated_at"] = _now()
|
||||
_save_tasks(data)
|
||||
return subtask
|
||||
return None
|
||||
|
||||
def update_subtask_task(task_id, subtask_id, body):
|
||||
"""PUT /api/mc/tasks/:id/subtasks/:sid — Subtask updaten."""
|
||||
data = _load_tasks()
|
||||
for t in data["tasks"]:
|
||||
if t["id"] == task_id:
|
||||
for s in t.get("subtasks", []):
|
||||
if s["id"] == subtask_id:
|
||||
if "title" in body:
|
||||
s["title"] = body["title"]
|
||||
if "done" in body:
|
||||
s["done"] = body["done"]
|
||||
if "order" in body:
|
||||
s["order"] = body["order"]
|
||||
t["updated_at"] = _now()
|
||||
|
||||
# Auto-done check
|
||||
all_done = _auto_done_subtasks(t)
|
||||
if all_done is True and t["status"] != "done":
|
||||
t["status"] = "done"
|
||||
t["completed_at"] = _now()
|
||||
elif all_done is False and t["status"] == "done":
|
||||
t["status"] = "todo"
|
||||
t["completed_at"] = None
|
||||
|
||||
_save_tasks(data)
|
||||
return s
|
||||
return None
|
||||
|
||||
def delete_subtask_task(task_id, subtask_id):
|
||||
"""DELETE /api/mc/tasks/:id/subtasks/:sid — Subtask löschen."""
|
||||
data = _load_tasks()
|
||||
for t in data["tasks"]:
|
||||
if t["id"] == task_id:
|
||||
before = len(t.get("subtasks", []))
|
||||
t["subtasks"] = [s for s in t.get("subtasks", []) if s["id"] != subtask_id]
|
||||
t["updated_at"] = _now()
|
||||
_save_tasks(data)
|
||||
return len(t["subtasks"]) < before
|
||||
return False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# PROJECTS — CRUD
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Feed helpers ──────────────────────────────────────────────────────────────
|
||||
def list_projects():
|
||||
"""GET /api/mc/projects — alle Projekte mit Phasen."""
|
||||
data = _load_projects()
|
||||
return data.get("projects", [])
|
||||
|
||||
def get_feed(limit: int = 50) -> list[dict]:
|
||||
"""Return recent feed events, newest first."""
|
||||
data = _load_mc_data()
|
||||
feed = data.get("feed", [])
|
||||
return sorted(feed, key=lambda f: f.get("timestamp", ""), reverse=True)[:limit]
|
||||
def get_project(project_id):
|
||||
"""GET /api/mc/projects/:id — einzelnes Projekt."""
|
||||
data = _load_projects()
|
||||
return next((p for p in data["projects"] if p["id"] == project_id), None)
|
||||
|
||||
def create_project(body):
|
||||
"""POST /api/mc/projects — Projekt erstellen."""
|
||||
data = _load_projects()
|
||||
|
||||
def _add_feed_event(event: str) -> None:
|
||||
"""Add a timestamped feed event."""
|
||||
data = _load_mc_data()
|
||||
feed = data.get("feed", [])
|
||||
feed.append({
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"event": event,
|
||||
project = {
|
||||
"id": body.get("id") or _new_id("proj").replace("proj-", ""),
|
||||
"name": body.get("name", "Neues Projekt"),
|
||||
"color": body.get("color", "#6366f1"),
|
||||
"description": body.get("description", ""),
|
||||
"status": body.get("status", "active"),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"subtasks": [],
|
||||
"phases": [],
|
||||
}
|
||||
|
||||
data["projects"].append(project)
|
||||
_save_projects(data)
|
||||
return project
|
||||
|
||||
def update_project(project_id, body):
|
||||
"""PUT /api/mc/projects/:id — Projekt updaten."""
|
||||
data = _load_projects()
|
||||
|
||||
for p in data["projects"]:
|
||||
if p["id"] == project_id:
|
||||
for key in ["name", "color", "description", "status"]:
|
||||
if key in body:
|
||||
p[key] = body[key]
|
||||
p["updated_at"] = _now()
|
||||
_save_projects(data)
|
||||
return p
|
||||
|
||||
return None
|
||||
|
||||
def delete_project(project_id):
|
||||
"""DELETE /api/mc/projects/:id — Projekt löschen."""
|
||||
data = _load_projects()
|
||||
before = len(data["projects"])
|
||||
data["projects"] = [p for p in data["projects"] if p["id"] != project_id]
|
||||
_save_projects(data)
|
||||
|
||||
# Auch alle Tasks dieses Projekts löschen
|
||||
tasks_data = _load_tasks()
|
||||
tasks_data["tasks"] = [t for t in tasks_data["tasks"] if t.get("project_id") != project_id]
|
||||
_save_tasks(tasks_data)
|
||||
|
||||
return len(data["projects"]) < before
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# PROJECT SUBTASKS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def add_subtask_project(project_id, body):
|
||||
"""POST /api/mc/projects/:id/subtasks."""
|
||||
data = _load_projects()
|
||||
for p in data["projects"]:
|
||||
if p["id"] == project_id:
|
||||
subtask = {
|
||||
"id": _new_id("sub"),
|
||||
"title": body.get("title", "Subtask"),
|
||||
"done": False,
|
||||
"order": len(p.get("subtasks", [])) + 1,
|
||||
"created_at": _now(),
|
||||
}
|
||||
if "subtasks" not in p:
|
||||
p["subtasks"] = []
|
||||
p["subtasks"].append(subtask)
|
||||
p["updated_at"] = _now()
|
||||
_save_projects(data)
|
||||
return subtask
|
||||
return None
|
||||
|
||||
def update_subtask_project(project_id, subtask_id, body):
|
||||
"""PUT /api/mc/projects/:id/subtasks/:sid."""
|
||||
data = _load_projects()
|
||||
for p in data["projects"]:
|
||||
if p["id"] == project_id:
|
||||
for s in p.get("subtasks", []):
|
||||
if s["id"] == subtask_id:
|
||||
if "title" in body:
|
||||
s["title"] = body["title"]
|
||||
if "done" in body:
|
||||
s["done"] = body["done"]
|
||||
if "order" in body:
|
||||
s["order"] = body["order"]
|
||||
p["updated_at"] = _now()
|
||||
_save_projects(data)
|
||||
return s
|
||||
return None
|
||||
|
||||
def delete_subtask_project(project_id, subtask_id):
|
||||
"""DELETE /api/mc/projects/:id/subtasks/:sid."""
|
||||
data = _load_projects()
|
||||
for p in data["projects"]:
|
||||
if p["id"] == project_id:
|
||||
before = len(p.get("subtasks", []))
|
||||
p["subtasks"] = [s for s in p.get("subtasks", []) if s["id"] != subtask_id]
|
||||
p["updated_at"] = _now()
|
||||
_save_projects(data)
|
||||
return len(p["subtasks"]) < before
|
||||
return False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# PHASES
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def add_phase(project_id, body):
|
||||
"""POST /api/mc/projects/:id/phases — Phase hinzufügen."""
|
||||
data = _load_projects()
|
||||
|
||||
for p in data["projects"]:
|
||||
if p["id"] == project_id:
|
||||
phase = {
|
||||
"id": _new_id("phase"),
|
||||
"name": body.get("name", "Neue Phase"),
|
||||
"description": body.get("description", ""),
|
||||
"testing": body.get("testing", ""),
|
||||
"testing_status": body.get("testing_status", "pending"),
|
||||
"reflection": body.get("reflection"),
|
||||
"status": body.get("status", "todo"),
|
||||
"order": len(p.get("phases", [])) + 1,
|
||||
"completed_at": None,
|
||||
"subtasks": [],
|
||||
}
|
||||
if "phases" not in p:
|
||||
p["phases"] = []
|
||||
p["phases"].append(phase)
|
||||
p["updated_at"] = _now()
|
||||
_save_projects(data)
|
||||
return phase
|
||||
|
||||
return None
|
||||
|
||||
def update_phase(phase_id, body):
|
||||
"""PUT /api/mc/phases/:id — Phase updaten."""
|
||||
data = _load_projects()
|
||||
|
||||
for p in data["projects"]:
|
||||
for ph in p.get("phases", []):
|
||||
if ph["id"] == phase_id:
|
||||
for key in ["name", "description", "testing", "testing_status",
|
||||
"reflection", "status", "order"]:
|
||||
if key in body:
|
||||
ph[key] = body[key]
|
||||
|
||||
if body.get("status") == "done" and ph["completed_at"] is None:
|
||||
ph["completed_at"] = _now()
|
||||
elif body.get("status") and body.get("status") != "done":
|
||||
ph["completed_at"] = None
|
||||
|
||||
p["updated_at"] = _now()
|
||||
_save_projects(data)
|
||||
return ph
|
||||
|
||||
return None
|
||||
|
||||
def delete_phase(phase_id):
|
||||
"""DELETE /api/mc/phases/:id — Phase löschen."""
|
||||
data = _load_projects()
|
||||
|
||||
for p in data["projects"]:
|
||||
before = len(p.get("phases", []))
|
||||
p["phases"] = [ph for ph in p.get("phases", []) if ph["id"] != phase_id]
|
||||
if len(p["phases"]) < before:
|
||||
p["updated_at"] = _now()
|
||||
_save_projects(data)
|
||||
|
||||
# Tasks dieser Phase auf standalone setzen
|
||||
tasks_data = _load_tasks()
|
||||
for t in tasks_data["tasks"]:
|
||||
if t.get("phase_id") == phase_id:
|
||||
t["phase_id"] = None
|
||||
_save_tasks(tasks_data)
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def complete_phase(phase_id):
|
||||
"""PUT /api/mc/phases/:id/complete — Phase als done markieren."""
|
||||
return update_phase(phase_id, {"status": "done", "completed_at": _now()})
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# PHASE SUBTASKS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def add_subtask_phase(phase_id, body):
|
||||
"""POST /api/mc/phases/:id/subtasks."""
|
||||
data = _load_projects()
|
||||
|
||||
for p in data["projects"]:
|
||||
for ph in p.get("phases", []):
|
||||
if ph["id"] == phase_id:
|
||||
subtask = {
|
||||
"id": _new_id("sub"),
|
||||
"title": body.get("title", "Subtask"),
|
||||
"done": False,
|
||||
"order": len(ph.get("subtasks", [])) + 1,
|
||||
"created_at": _now(),
|
||||
}
|
||||
if "subtasks" not in ph:
|
||||
ph["subtasks"] = []
|
||||
ph["subtasks"].append(subtask)
|
||||
p["updated_at"] = _now()
|
||||
_save_projects(data)
|
||||
return subtask
|
||||
|
||||
return None
|
||||
|
||||
def update_subtask_phase(phase_id, subtask_id, body):
|
||||
"""PUT /api/mc/phases/:id/subtasks/:sid."""
|
||||
data = _load_projects()
|
||||
|
||||
for p in data["projects"]:
|
||||
for ph in p.get("phases", []):
|
||||
if ph["id"] == phase_id:
|
||||
for s in ph.get("subtasks", []):
|
||||
if s["id"] == subtask_id:
|
||||
if "title" in body:
|
||||
s["title"] = body["title"]
|
||||
if "done" in body:
|
||||
s["done"] = body["done"]
|
||||
if "order" in body:
|
||||
s["order"] = body["order"]
|
||||
p["updated_at"] = _now()
|
||||
|
||||
# Auto-done check für phase
|
||||
all_done = all(st.get("done", False) for st in ph.get("subtasks", []))
|
||||
if all_done and ph["status"] != "done":
|
||||
ph["status"] = "done"
|
||||
ph["completed_at"] = _now()
|
||||
|
||||
_save_projects(data)
|
||||
return s
|
||||
return None
|
||||
|
||||
def delete_subtask_phase(phase_id, subtask_id):
|
||||
"""DELETE /api/mc/phases/:id/subtasks/:sid."""
|
||||
data = _load_projects()
|
||||
|
||||
for p in data["projects"]:
|
||||
for ph in p.get("phases", []):
|
||||
if ph["id"] == phase_id:
|
||||
before = len(ph.get("subtasks", []))
|
||||
ph["subtasks"] = [s for s in ph.get("subtasks", []) if s["id"] != subtask_id]
|
||||
p["updated_at"] = _now()
|
||||
_save_projects(data)
|
||||
return len(ph["subtasks"]) < before
|
||||
|
||||
return False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# AGENT ACTIONS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def agent_progress(task_id, body):
|
||||
"""POST /api/mc/tasks/:id/progress — Agent meldet Fortschritt."""
|
||||
return update_task(task_id, {
|
||||
"agent_status": body.get("agent_status"),
|
||||
"agent_note": body.get("agent_note"),
|
||||
"cron_last_run": body.get("cron_last_run"),
|
||||
"cron_next_run": body.get("cron_next_run"),
|
||||
})
|
||||
# Keep only last 200 events
|
||||
data["feed"] = feed[-200:]
|
||||
_save_mc_data(data)
|
||||
|
||||
def agent_note(task_id, body):
|
||||
"""POST /api/mc/tasks/:id/note — Agent setzt Notiz."""
|
||||
return update_task(task_id, {"agent_note": body.get("note")})
|
||||
|
||||
# ── Dashboard status ──────────────────────────────────────────────────────────
|
||||
def get_agents():
|
||||
"""GET /api/mc/agents — Agent-Registry."""
|
||||
return AGENTS
|
||||
|
||||
def get_dashboard_status() -> dict:
|
||||
"""Return aggregated dashboard status for Mission Control."""
|
||||
data = _load_mc_data()
|
||||
priorities = data.get("priorities", [])
|
||||
tasks = data.get("tasks", [])
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# STATS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
priorities_total = len(priorities)
|
||||
priorities_done = sum(1 for p in priorities if p.get("done"))
|
||||
def get_stats():
|
||||
"""GET /api/mc/stats — Statistiken."""
|
||||
tasks_data = _load_tasks()
|
||||
projects_data = _load_projects()
|
||||
tasks = tasks_data.get("tasks", [])
|
||||
projects = projects_data.get("projects", [])
|
||||
|
||||
tasks_backlog = sum(1 for t in tasks if t.get("status") == "backlog")
|
||||
tasks_progress = sum(1 for t in tasks if t.get("status") == "progress")
|
||||
tasks_done = sum(1 for t in tasks if t.get("status") == "done")
|
||||
total = len(tasks)
|
||||
done = len([t for t in tasks if t.get("status") == "done"])
|
||||
today = _today()
|
||||
|
||||
feed = get_feed(limit=5)
|
||||
latest_event = feed[0]["event"] if feed else "No recent activity"
|
||||
# Overdue
|
||||
overdue = len([t for t in tasks if t.get("due") and t["due"] < today and t.get("status") != "done"])
|
||||
|
||||
# Health assessment
|
||||
if tasks_done == 0 and tasks_backlog == 0 and tasks_progress == 0:
|
||||
health = "empty"
|
||||
elif tasks_progress > 0 and tasks_done > 0:
|
||||
health = "healthy"
|
||||
elif tasks_progress > 0:
|
||||
health = "active"
|
||||
elif tasks_backlog > 0:
|
||||
health = "warning"
|
||||
else:
|
||||
health = "ok"
|
||||
# By priority
|
||||
by_priority = {"p1": 0, "p2": 0, "p3": 0}
|
||||
for t in tasks:
|
||||
p = t.get("priority", "p2")
|
||||
if p in by_priority:
|
||||
by_priority[p] += 1
|
||||
|
||||
# By type
|
||||
by_type = {"one-time": 0, "daily": 0}
|
||||
for t in tasks:
|
||||
tt = t.get("task_type", "one-time")
|
||||
if tt in by_type:
|
||||
by_type[tt] += 1
|
||||
|
||||
# By status
|
||||
by_status = {"todo": 0, "in_progress": 0, "review": 0, "done": 0}
|
||||
for t in tasks:
|
||||
s = t.get("status", "todo")
|
||||
if s in by_status:
|
||||
by_status[s] += 1
|
||||
|
||||
# Daily done today
|
||||
daily_done_today = len([t for t in tasks if t.get("task_type") == "daily" and t.get("daily_completed_today", False)])
|
||||
|
||||
# Streak: consecutive days with completions going backwards
|
||||
streak = 0
|
||||
check_date = date.today()
|
||||
done_dates = set()
|
||||
for t in tasks:
|
||||
c = t.get("completed_at")
|
||||
if c:
|
||||
done_dates.add(c[:10])
|
||||
while True:
|
||||
d = check_date.isoformat()
|
||||
if d in done_dates:
|
||||
streak += 1
|
||||
check_date -= timedelta(days=1)
|
||||
else:
|
||||
break
|
||||
|
||||
# Agent activity
|
||||
agent_activity = {}
|
||||
for agent_id, agent in AGENTS.items():
|
||||
agent_tasks = [t for t in tasks if t.get("assigned_agent") == agent_id]
|
||||
active = next((t for t in agent_tasks if t.get("agent_status") in ("running", "pending")), None)
|
||||
agent_activity[agent_id] = {
|
||||
**agent,
|
||||
"task_count": len(agent_tasks),
|
||||
"status": "active" if active else "idle",
|
||||
"current_task": active["title"] if active else None,
|
||||
}
|
||||
|
||||
# Project progress
|
||||
project_progress = []
|
||||
for pr in projects:
|
||||
pr_tasks = [t for t in tasks if t.get("project_id") == pr["id"]]
|
||||
pr_tasks_done = len([t for t in pr_tasks if t.get("status") == "done"])
|
||||
phases_total = len(pr.get("phases", []))
|
||||
phases_done = len([ph for ph in pr.get("phases", []) if ph.get("status") == "done"])
|
||||
project_progress.append({
|
||||
"id": pr["id"],
|
||||
"name": pr["name"],
|
||||
"color": pr.get("color"),
|
||||
"status": pr.get("status"),
|
||||
"tasks_total": len(pr_tasks),
|
||||
"tasks_done": pr_tasks_done,
|
||||
"phases_total": phases_total,
|
||||
"phases_done": phases_done,
|
||||
})
|
||||
|
||||
return {
|
||||
"priorities_total": priorities_total,
|
||||
"priorities_done": priorities_done,
|
||||
"tasks_backlog": tasks_backlog,
|
||||
"tasks_progress": tasks_progress,
|
||||
"tasks_done": tasks_done,
|
||||
"latest_feed_event": latest_event,
|
||||
"dashboard_health": health,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"total": total,
|
||||
"done": done,
|
||||
"overdue": overdue,
|
||||
"streak": streak,
|
||||
"daily_done_today": daily_done_today,
|
||||
"by_priority": by_priority,
|
||||
"by_type": by_type,
|
||||
"by_status": by_status,
|
||||
"project_progress": project_progress,
|
||||
"agent_activity": agent_activity,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user