#!/usr/bin/env python3 """ Phase-3: Polish, UX, more entities. Reuses utilities from phase2_features.py but with NEW features and STRICTER prompts to avoid type bugs. """ from __future__ import annotations import asyncio import datetime import sys import time from dataclasses import dataclass, field from pathlib import Path import httpx # import the helper utilities and shared types from phase2 — careful to not double-name sys.path.insert(0, str(Path(__file__).resolve().parent)) from phase2_features import ( # noqa: E402 Feature, FileGen, ROOT, log, log_section, git, gemma, strip_codefence, load_existing, tsc_check, run_feature, MAX_RETRIES, LOG, PHASE2_STATE, ) PHASE3_STATE = ROOT / ".phase3-state.json" # Common preamble that gets prepended to all prompts to prevent the bugs we saw in Phase 2 HARDENING_HINTS = """ **STRICT PATTERN-HINWEISE (frühere Fehler vermeiden):** - Fastify-Route-Files: `export default async function xRoutes(fastify: FastifyInstance)` — NIE `FastifyPluginAsync` als Param-Typ - Drizzle `.where()` braucht definites SQL, kein conditional-undefined: `conds.length ? and(...conds) : undefined as any` ist akzeptiert - React imports aus `@emberclone/shared` — NIEMALS aus `@rmpks/shared` o.ä. - Verwende `import type { X } from "@emberclone/shared"` für Type-Only-Imports - Keine `import "dotenv/config"` — nicht installiert, use process.env direkt - Bei TanStack Router: code-based Routing (createRoute/createRootRoute), KEIN routeTree.gen """ FEATURES: list[Feature] = [ Feature( name="toast-notifications", description="Toast-System für Success/Error-Feedback nach Mutations", files=[ FileGen( path="apps/web/src/components/Toast.tsx", purpose=( "Toast-Notification-System. Exports: ToastProvider (Context), useToast() hook (returns {success, error, info}). " "Toasts erscheinen unten rechts, auto-dismiss nach 4s, stackbar. Tailwind: bg-emerald-500 (success), bg-red-500 (error), bg-slate-700 (info). " "Animation: opacity-fade + translate-y." ), ), ], after_commit_routes=["/"], ), Feature( name="logout-everywhere", description="Logout-Button in Nav + funktionierender Flow", files=[ FileGen( path="apps/web/src/components/Nav.tsx", purpose=( "AKTUALISIERTE Nav-Bar. Behalte bestehende Links (Dashboard /, TimeEntries /time-entries, Customers /customers, Projects /projects). " "Logout-Button rechts: ruft api.logout() auf, dann window.location.href='/login'. " "Active-Link-Styling per useLocation/useRouter. Tailwind, container mx-auto, py-3." ), refs=["apps/web/src/components/Nav.tsx", "apps/web/src/lib/api.ts"], ), ], after_commit_routes=["/"], ), Feature( name="empty-loading-states", description="Bessere Loading- und Empty-States in allen List-Pages", files=[ FileGen( path="apps/web/src/components/EmptyState.tsx", purpose=( "Reusable EmptyState-Komponente. Props: title (string), description (string), action? ({label, onClick}). " "Tailwind: centered, large icon-emoji (📋), helper text, optional primary-button. " "Klein und sauber, ~80 Zeilen." ), ), FileGen( path="apps/web/src/components/LoadingSpinner.tsx", purpose=( "Reusable LoadingSpinner. Props: label? (string, default 'Lädt…'). " "Tailwind: zentriertes animate-spin + Label. Simpel." ), ), ], after_commit_routes=["/"], ), Feature( name="time-entries-search-filter", description="Search + Date-Range-Filter in TimeEntries-Liste", files=[ FileGen( path="apps/web/src/pages/TimeEntries.tsx", purpose=( "ERWEITERTE TimeEntries-Page. Behalte existing Create-Form + Liste. " "Füge oben einen Filter-Bar hinzu: Search-Input (filter description local-side), " "From-Date + To-Date (date-pickers) — bei Änderung re-fetch via listTimeEntries({from, to}). " "EmptyState wenn Liste leer (verwende ./components/EmptyState). " "LoadingSpinner während fetch (verwende ./components/LoadingSpinner)." ), refs=["apps/web/src/pages/TimeEntries.tsx", "apps/web/src/lib/api.ts"], ), ], after_commit_routes=["/time-entries"], ), Feature( name="user-profile-page", description="User-Profile-Page mit Name/Email/Theme", files=[ FileGen( path="apps/api/src/routes/users.ts", purpose=( "Fastify-Plugin für /api/users. GET /me (aktueller User), PATCH /me (update name only, KEIN role/email change). " "Auth required via fastify.addHook('preHandler', request.jwtVerify())." ), refs=["apps/api/src/routes/customers.ts"], extra="WICHTIG: `export default async function userRoutes(fastify: FastifyInstance)` — NIE FastifyPluginAsync.", ), FileGen( path="apps/web/src/pages/Profile.tsx", purpose=( "Profile-Page. Liest current user via api.getMe(). " "Form mit Name (editierbar), Email (readonly), Role (readonly Badge). " "Submit → api.updateProfile({name}). Toast-Feedback. " "Layout: max-w-2xl Card." ), refs=["apps/web/src/components/Toast.tsx", "apps/web/src/lib/api.ts"], ), ], after_commit_routes=["/profile"], ), Feature( name="api-client-final", description="API-Client mit allen Phase-3 Endpoints + Logout-fix", files=[ FileGen( path="apps/web/src/lib/api.ts", purpose=( "FINALE Version der api.ts. Behalte ALLE bestehenden Funktionen " "(login, logout, getMe, listTimeEntries, createTimeEntry, deleteTimeEntry, " "listCustomers, createCustomer, deleteCustomer, listProjects, createProject, deleteProject). " "Füge hinzu: " "updateProfile({name}: string) → PATCH /users/me, " "getRunningTimeEntry() → GET /time-entries/running (return null on 404), " "startTimeEntry({description, projectId?}) → POST /time-entries/start, " "stopTimeEntry(id) → POST /time-entries/:id/stop. " "Logout sollte auch lokal cleanup + POST /auth/logout machen." ), refs=["apps/web/src/lib/api.ts"], ), ], after_commit_routes=["/"], ), Feature( name="router-with-profile", description="App.tsx erweitert um /profile-Route + ToastProvider + active-link", files=[ FileGen( path="apps/web/src/App.tsx", purpose=( "FINALE App.tsx. Behalte bestehende Routes (/, /login, /time-entries, /customers, /projects). " "Füge /profile (Profile component) hinzu, mit Auth-Check. " "Wrappe RouterProvider in (aus ./components/Toast)." ), refs=["apps/web/src/App.tsx", "apps/web/src/pages/Profile.tsx", "apps/web/src/components/Toast.tsx"], ), FileGen( path="apps/web/src/components/Nav.tsx", purpose=( "FINAL Nav-Bar. Plus Profile-Link rechts neben Logout. Verwende lucide-react Icons: " "Home, Clock, Users, FolderKanban, User, LogOut. Logout-Button mit api.logout() + redirect." ), refs=["apps/web/src/components/Nav.tsx"], ), ], after_commit_routes=["/", "/profile"], ), ] def load_state() -> dict: if PHASE3_STATE.exists(): import json return json.loads(PHASE3_STATE.read_text()) return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} def save_state(state: dict) -> None: import json PHASE3_STATE.write_text(json.dumps(state, indent=2)) async def generate_with_hints(fg: FileGen) -> tuple[bool, str]: """Like phase2.generate_file but with HARDENING_HINTS prepended.""" path = ROOT / fg.path path.parent.mkdir(parents=True, exist_ok=True) refs_ctx = load_existing(fg.refs) log(f" Generating {fg.path} ({fg.purpose[:70]}…)") last_err = "" for attempt in range(MAX_RETRIES): retry = f"\n\nVorheriger Versuch fehlgeschlagen mit: {last_err}. Bitte korrigieren." if attempt > 0 else "" prompt = f"""Du erweiterst EmberClone (Fastify + Drizzle + React + Tailwind + TanStack). {HARDENING_HINTS} {refs_ctx} **Aufgabe:** Generiere `{fg.path}`. **Zweck:** {fg.purpose} {fg.extra} ANTWORTE NUR MIT DEM DATEI-INHALT. Kein Code-Fence (```), keine Erklärung. Direkt der TypeScript/TSX/CSS-Code.{retry} """ t0 = time.time() resp = await gemma(prompt) dt = time.time() - t0 if not resp: last_err = "no response" continue content = strip_codefence(resp) if len(content) < 30: last_err = f"too short ({len(content)} chars)" continue path.write_text(content) log(f" wrote {len(content)} chars in {dt:.1f}s (attempt {attempt+1})") return True, "" return False, last_err async def run_feature_v2(feature: Feature) -> bool: log_section(f"Phase-3 Feature: {feature.name}") log(f"Description: {feature.description}") all_ok = True for fg in feature.files: ok, err = await generate_with_hints(fg) if not ok: log(f" FAILED {fg.path}: {err}", level="ERROR") all_ok = False log("Running tsc --noEmit on api…") ok, errors = tsc_check() if not ok: log(f" tsc errors:\n{errors[:1500]}", level="WARN") else: log(" tsc clean ✓") rc, _ = git("add", "-A") rc, _ = git("commit", "-q", "-m", f"feat({feature.name}): {feature.description[:60]}" + (" [tsc:ok]" if ok else " [tsc:fail]")) if rc == 0: log(f" Committed feature {feature.name}") rc, _ = git("push", "-q", "origin", "main") log(f" Pushed: rc={rc}") return all_ok and ok async def main() -> int: log_section(f"🚀 Phase-3 Codegen-Run gestartet") log(f"Features im Backlog: {len(FEATURES)}") state = load_state() log(f"Bereits abgeschlossen: {len(state.get('completed_features', []))}") for feature in FEATURES: if feature.name in state.get("completed_features", []): log(f"⏭ Skip {feature.name}") 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 {feature.name} crashed: {e}", level="ERROR") state.setdefault("attempted_features", []).append(feature.name) save_state(state) log_section("Phase-3 Run beendet") log(f"OK: {len(state.get('completed_features', []))}, " f"Attempted: {len(state.get('attempted_features', []))}, " f"Total: {len(FEATURES)}") return 0 if __name__ == "__main__": sys.exit(asyncio.run(main()))