#!/usr/bin/env python3 """Phase-5: dark-mode, settings, calendar, customer/project details.""" from __future__ import annotations import asyncio import datetime import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from phase2_features import Feature, FileGen, ROOT, log, log_section # noqa: E402 from phase3_features import run_feature_v2 # noqa: E402 PHASE5_STATE = ROOT / ".phase5-state.json" FEATURES: list[Feature] = [ Feature( name="dark-mode-toggle", description="Dark-Mode mit System-Preference + localStorage + toggle", files=[ FileGen( path="apps/web/src/lib/theme.ts", purpose=( "Theme-Hook + Util. useTheme() returns {theme:'light'|'dark', setTheme, toggle}. " "Persist in localStorage 'theme'. Initial: System-pref via matchMedia. " "Side-effect: setzt document.documentElement.classList toggle('dark')." ), ), FileGen( path="apps/web/tailwind.config.ts", purpose=( "ERWEITERT — füge `darkMode: 'class'` hinzu. Behalte content + theme.extend.colors.ember." ), refs=["apps/web/tailwind.config.ts"], ), FileGen( path="apps/web/src/components/Nav.tsx", purpose=( "ERWEITERT — füge Theme-Toggle-Button rechts (Sun/Moon Icon von lucide-react, useTheme hook). " "Behalte alle bestehenden Links + Logout + Profile + Admin (bei admin-role)." ), refs=["apps/web/src/components/Nav.tsx"], ), ], ), Feature( name="customer-detail-page", description="Customer-Detail: zeigt Projekte + letzte Time-Entries des Kunden", files=[ FileGen( path="apps/api/src/routes/customers.ts", purpose=( "ERWEITERT — behalte CRUD. Füge GET /:id/projects (alle Projekte zum Kunden) und " "GET /:id/time-entries (alle TimeEntries deren projectId zu einem Projekt dieses Kunden gehört, " "letzte 50 sortiert by startTime desc)." ), refs=["apps/api/src/routes/customers.ts"], ), FileGen( path="apps/web/src/pages/CustomerDetail.tsx", purpose=( "CustomerDetail-Page. Liest customerId aus URL-Param. Zeigt: Customer-Header (name, status). " "Section: Projekte (Liste, Klick → /projects). Section: Letzte 20 TimeEntries (kompakte Tabelle). " "Verwende api.getCustomerProjects(id), api.getCustomerTimeEntries(id)." ), refs=["apps/web/src/pages/Customers.tsx"], ), ], ), Feature( name="project-detail-page", description="Project-Detail: zeigt Customer + alle TimeEntries des Projekts", files=[ FileGen( path="apps/web/src/pages/ProjectDetail.tsx", purpose=( "ProjectDetail-Page. Header: name + linked customer. Section: TimeEntries für dieses Projekt " "(alle, mit Gesamt-Stunden-Summary). Verwende api.getProject(id), api.listTimeEntries({projectId:id})." ), refs=["apps/web/src/pages/Projects.tsx"], ), ], ), Feature( name="settings-page", description="App-Settings (workspace name, default-billable, etc.)", files=[ FileGen( path="apps/api/src/db/schema.ts", purpose=( "ERWEITERT — behalte alle bestehenden Tabellen. Füge neue Tabelle `appSettings` (pgTable 'app_settings'): " "id (singleton uuid, primary), workspaceName (text default 'EmberClone'), defaultBillable (boolean default true), " "weekStart (integer default 1 für Monday), updatedAt (timestamp.notNull().defaultNow())." ), refs=["apps/api/src/db/schema.ts"], ), FileGen( path="apps/api/src/routes/settings.ts", purpose=( "Fastify-Plugin /api/settings. GET / (current settings, lazy-init if none exist). " "PATCH / (admin-only update). Auth required, role-check für PATCH." ), refs=["apps/api/src/routes/users.ts"], ), FileGen( path="apps/web/src/pages/Settings.tsx", purpose=( "Settings-Page. Form mit workspaceName, defaultBillable (checkbox), weekStart (select Mon/Sun). " "Admin-only sichtbar (sonst Forbidden). Submit → api.updateSettings()." ), refs=["apps/web/src/pages/Profile.tsx"], ), ], ), Feature( name="api-client-phase5", description="API um customer-detail, project-detail, settings, theme erweitern", files=[ FileGen( path="apps/web/src/lib/api.ts", purpose=( "ERWEITERT — behalte ALLES. Füge: getCustomerProjects(id), getCustomerTimeEntries(id), " "getProject(id), getSettings(), updateSettings({workspaceName?, defaultBillable?, weekStart?})." ), refs=["apps/web/src/lib/api.ts"], ), ], ), Feature( name="router-phase5", description="App.tsx + Nav um neue Routen erweitern + db-migrate nicht vergessen", files=[ FileGen( path="apps/api/src/routes/index.ts", purpose=( "ERWEITERT — behalte alle bestehenden registrations. Füge `settingsRoutes` mit prefix '/api/settings' hinzu." ), refs=["apps/api/src/routes/index.ts"], ), FileGen( path="apps/web/src/App.tsx", purpose=( "ERWEITERT — füge Routes /customers/$id (CustomerDetail), /projects/$id (ProjectDetail), " "/settings (Settings, admin-only). Behalte ErrorBoundary + ToastProvider + alle bestehenden Routes." ), refs=["apps/web/src/App.tsx"], ), FileGen( path="apps/web/src/components/Nav.tsx", purpose=( "ERWEITERT — füge Settings-Link bei admin-role + Theme-Toggle. Behalte alle bestehenden Links." ), refs=["apps/web/src/components/Nav.tsx"], ), ], ), ] def load_state() -> dict: if PHASE5_STATE.exists(): return json.loads(PHASE5_STATE.read_text()) return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} def save_state(state: dict) -> None: PHASE5_STATE.write_text(json.dumps(state, indent=2)) async def main() -> int: log_section(f"🚀 Phase-5 Codegen-Run gestartet") log(f"Features: {len(FEATURES)}") state = load_state() for feature in FEATURES: if feature.name in state.get("completed_features", []): continue state["current_feature"] = feature.name; save_state(state) try: success = await run_feature_v2(feature) if success: state.setdefault("completed_features", []).append(feature.name) else: state.setdefault("attempted_features", []).append(feature.name) save_state(state) except Exception as e: log(f"❌ {feature.name} crashed: {e}", level="ERROR") state.setdefault("attempted_features", []).append(feature.name); save_state(state) log_section("Phase-5 Run beendet") log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}") # auto-run db:generate + db:migrate for schema-changes (settings table) import subprocess log("Running db:generate + db:migrate for schema changes…") r = subprocess.run(["pnpm", "--filter", "api", "db:generate"], cwd=ROOT, capture_output=True, text=True, timeout=60) log(f" db:generate rc={r.returncode}: {r.stdout[-300:]}") r = subprocess.run(["pnpm", "--filter", "api", "db:migrate"], cwd=ROOT, capture_output=True, text=True, timeout=60) log(f" db:migrate rc={r.returncode}: {r.stdout[-300:]}") return 0 if __name__ == "__main__": sys.exit(asyncio.run(main()))