Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats

This commit is contained in:
Rose
2026-04-29 11:50:00 +02:00
parent c705fad626
commit 255914c9f1
43 changed files with 17948 additions and 6899 deletions

280
api/projects.py Normal file
View File

@@ -0,0 +1,280 @@
# api/projects.py
# Projects Tab Backend — Rose's Projects & Tasks Dashboard
import json
from pathlib import Path
from datetime import datetime
HERMES_HOME = Path.home() / ".hermes"
PROJECTS_DIR = HERMES_HOME / "projects"
DATA_FILE = HERMES_HOME / "data" / "projects.json"
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
def _load():
"""Lädt data/projects.json oder gibt leere Struktur zurück."""
if DATA_FILE.exists():
with DATA_FILE.open(encoding="utf-8") as f:
return json.loads(f.read())
return {"version": "1.0.0", "projects": [], "daily_tasks": [], "recurring_tasks": []}
def _save(data):
"""Speichert data/projects.json."""
DATA_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
def list_projects():
"""Liest projects/ Ordner aus, synced mit data/projects.json.
Jeder Unterordner in ~/.hermes/projects/ wird als Projekt registriert.
Bereits existierende Projekte (nach folder) werden nicht dupliziert.
"""
data = _load()
# Sync: jede Folder in projects/ → Projekt-Eintrag wenn nicht vorhanden
for folder in sorted(PROJECTS_DIR.iterdir()):
if folder.is_dir() and not folder.name.startswith('.'):
exists = any(p.get('folder') == folder.name for p in data['projects'])
if not exists:
data['projects'].append({
"id": folder.name,
"name": folder.name.replace('-', ' ').replace('_', ' ').title(),
"description": "",
"folder": folder.name,
"category": "unknown",
"status": "active",
"created": datetime.now().date().isoformat(),
"updated": datetime.now().isoformat(),
"tasks": []
})
_save(data)
return data['projects']
def get_project(project_id):
"""Holt ein einzelnes Projekt nach ID."""
data = _load()
return next((p for p in data['projects'] if p['id'] == project_id), None)
def create_task(project_id, task):
"""Erstellt Task in Projekt oder als daily/recurring.
Args:
project_id: ID des Projekts (für project tasks) oder None
task: dict mit title, task_type, status, priority, due, tags
Returns:
Das erstellte Task-Objekt mit generierter ID
"""
data = _load()
# ID generieren basierend auf task_type
task_type = task.get('task_type', 'project')
if task_type == 'daily':
existing = len(data.get('daily_tasks', []))
task['id'] = f"daily-{existing + 1:03d}"
elif task_type == 'recurring':
existing = len(data.get('recurring_tasks', []))
task['id'] = f"recurring-{existing + 1:03d}"
else:
existing = sum(len(p.get('tasks', [])) for p in data['projects'])
task['id'] = f"project-{existing + 1:03d}"
task['created'] = datetime.now().isoformat()
task['completed'] = None
if task_type == 'project' and project_id:
for p in data['projects']:
if p['id'] == project_id:
if 'tasks' not in p:
p['tasks'] = []
p['tasks'].append(task)
p['updated'] = datetime.now().isoformat()
break
elif task_type == 'project':
# Unassigned project task → find or create Inbox project
inbox = next((p for p in data['projects'] if p['id'] == 'inbox'), None)
if not inbox:
inbox = {
'id': 'inbox',
'name': '📥 Inbox',
'color': '#6366f1',
'tasks': [],
'created': datetime.now().isoformat(),
'updated': datetime.now().isoformat()
}
data['projects'].insert(0, inbox)
inbox['tasks'].append(task)
inbox['updated'] = datetime.now().isoformat()
elif task_type == 'daily':
data['daily_tasks'].append(task)
elif task_type == 'recurring':
data['recurring_tasks'].append(task)
_save(data)
return task
def update_task(task_id, updates):
"""Updated Task (status, priority, due, etc.).
Sucht Task in allen drei Listen (projects.tasks, daily_tasks, recurring_tasks).
"""
data = _load()
# Search in project tasks
for p in data['projects']:
for t in p.get('tasks', []):
if t['id'] == task_id:
t.update(updates)
p['updated'] = datetime.now().isoformat()
_save(data)
return t
# Search in daily tasks
for t in data.get('daily_tasks', []):
if t['id'] == task_id:
t.update(updates)
_save(data)
return t
# Search in recurring tasks
for t in data.get('recurring_tasks', []):
if t['id'] == task_id:
t.update(updates)
_save(data)
return t
return None
def delete_task(task_id):
"""Löscht Task aus allen drei Listen."""
data = _load()
# Remove from project tasks
for p in data['projects']:
p['tasks'] = [t for t in p.get('tasks', []) if t['id'] != task_id]
# Remove from daily tasks
data['daily_tasks'] = [t for t in data.get('daily_tasks', []) if t['id'] != task_id]
# Remove from recurring tasks
data['recurring_tasks'] = [t for t in data.get('recurring_tasks', []) if t['id'] != task_id]
_save(data)
return True
def get_all_tasks():
"""Holt alle Tasks für Kanban-View.
Fügt project_name hinzu für Project-Tasks.
Setzt Defaults für fehlende Felder (defensive).
"""
data = _load()
tasks = []
# Defaults für alle Tasks
DEFAULT_FIELDS = {
'title': 'Untitled Task',
'task_type': 'project',
'status': 'todo',
'priority': 'p2',
'due': None,
'tags': [],
'project_id': None,
'project_name': None,
'completed': None,
}
for p in data['projects']:
for t in p.get('tasks', []):
t = dict(t) # Copy to avoid mutating original
t['project_name'] = p.get('name')
# Apply defaults for missing fields
for k, v in DEFAULT_FIELDS.items():
t.setdefault(k, v)
tasks.append(t)
for t in data.get('daily_tasks', []):
t = dict(t)
for k, v in DEFAULT_FIELDS.items():
t.setdefault(k, v)
t.setdefault('status', 'pending')
tasks.append(t)
for t in data.get('recurring_tasks', []):
t = dict(t)
for k, v in DEFAULT_FIELDS.items():
t.setdefault(k, v)
t.setdefault('status', 'pending')
tasks.append(t)
return tasks
def get_stats():
"""Statistiken für Projects Tab.
Returns:
dict mit total_tasks, done, today_completed, active_projects,
streak, by_priority, by_type, overdue
"""
from datetime import date, timedelta
data = _load()
all_tasks = get_all_tasks()
done = [t for t in all_tasks if t.get('status') == 'done']
today = date.today().isoformat()
today_done = [t for t in done if (t.get('completed') or '').startswith(today)]
# Streak: consecutive days with completions going backwards
streak = 0
check_date = date.today()
done_dates = set()
for t in done:
c = t.get('completed')
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
# By priority
by_priority = {'p1': 0, 'p2': 0, 'p3': 0}
for t in all_tasks:
p = t.get('priority', 'p2')
if p in by_priority:
by_priority[p] += 1
# By type
by_type = {'project': 0, 'daily': 0, 'recurring': 0}
for t in all_tasks:
by_type[t.get('task_type', 'project')] += 1
# Overdue
overdue = [
t for t in all_tasks
if t.get('due') and t['due'] < today and t.get('status') != 'done'
]
return {
"total_tasks": len(all_tasks),
"done": len(done),
"today_completed": len(today_done),
"active_projects": len([p for p in data['projects'] if p.get('status') == 'active']),
"streak": streak,
"by_priority": by_priority,
"by_type": by_type,
"overdue": len(overdue),
}