Phase 8: TypeScript migration, i18n rewrite, Activity Tree, Projects API, Heartbeats
This commit is contained in:
280
api/projects.py
Normal file
280
api/projects.py
Normal 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),
|
||||
}
|
||||
Reference in New Issue
Block a user