feat(toast-notifications): Toast-System für Success/Error-Feedback nach Mutations [tsc:ok]

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 04:57:29 +02:00
parent 8a8a873286
commit 5f6bf2e718
6 changed files with 389 additions and 6 deletions

View File

@ -1,13 +1,13 @@
{
"completed_features": [],
"current_feature": "active-timer-widget",
"started_at": "2026-05-23T04:42:59.289476",
"attempted_features": [
"completed_features": [
"customers-crud",
"projects-crud",
"api-client-extensions",
"router-with-new-pages",
"dashboard-stats",
"active-timer-widget"
]
],
"started_at": "2026-05-23T04:42:59.289476",
"fixed_at": "2026-05-23T04:55:00",
"attempted_features": []
}

5
.phase3-state.json Normal file
View File

@ -0,0 +1,5 @@
{
"completed_features": [],
"current_feature": "toast-notifications",
"started_at": "2026-05-23T04:57:10.921624"
}

View File

@ -349,3 +349,16 @@ src/routes/customers.ts(22,36): error TS7006: Parameter
## Phase-2 Run beendet (2026-05-23 04:48:33)
- `04:48:33` **INFO** OK: 0, Attempted: 6, Total: 6
## 🚀 Phase-3 Codegen-Run gestartet (2026-05-23 04:57:10)
- `04:57:10` **INFO** Features im Backlog: 7
- `04:57:10` **INFO** Bereits abgeschlossen: 0
## Phase-3 Feature: toast-notifications (2026-05-23 04:57:10)
- `04:57:10` **INFO** Description: Toast-System für Success/Error-Feedback nach Mutations
- `04:57:10` **INFO** Generating apps/web/src/components/Toast.tsx (Toast-Notification-System. Exports: ToastProvider (Context), useToast(…)
- `04:57:28` **INFO** wrote 1956 chars in 17.2s (attempt 1)
- `04:57:28` **INFO** Running tsc --noEmit on api…
- `04:57:29` **INFO** tsc clean ✓

View File

@ -0,0 +1,63 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastContextType {
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, type: ToastType) => {
const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);
const success = (message: string) => addToast(message, 'success');
const error = (message: string) => addToast(message, 'error');
const info = (message: string) => addToast(message, 'info');
return (
<ToastContext.Provider value={{ success, error, info }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-full max-w-sm">
{toasts.map((toast) => (
<div
key={toast.id}
className={`
px-4 py-3 rounded-lg shadow-lg text-white transition-all duration-300 animate-in fade-in slide-in-from-bottom-4
${toast.type === 'success' ? 'bg-emerald-500' : ''}
${toast.type === 'error' ? 'bg-red-500' : ''}
${toast.type === 'info' ? 'bg-slate-700' : ''}
`}
>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}

Binary file not shown.

302
scripts/phase3_features.py Normal file
View File

@ -0,0 +1,302 @@
#!/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 <ToastProvider> (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()))