From 44cfd4d5f103f6dbce0899ed5dd82e8aca50d22c Mon Sep 17 00:00:00 2001 From: christiankrag Date: Sun, 14 Dec 2025 20:00:49 +0100 Subject: [PATCH] Add Supabase data layer and integrate design thinking dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add projects.ts with full CRUD operations for design thinking entities - Add use-project hook for reactive data fetching - Add DesignThinkingDashboard that integrates all components - Update project tabs to use new Phases view - Add Skeleton component for loading states - All operations use explicit type casting for schema compatibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../projects/[id]/project-tabs.tsx | 17 +- .../project/design-thinking-dashboard.tsx | 166 +++++++++ src/components/project/index.ts | 1 + src/components/ui/skeleton.tsx | 15 + src/hooks/use-project.ts | 132 ++++++++ src/lib/supabase/projects.ts | 319 ++++++++++++++++++ 6 files changed, 640 insertions(+), 10 deletions(-) create mode 100644 src/components/project/design-thinking-dashboard.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/hooks/use-project.ts create mode 100644 src/lib/supabase/projects.ts diff --git a/src/app/(dashboard)/projects/[id]/project-tabs.tsx b/src/app/(dashboard)/projects/[id]/project-tabs.tsx index aec6682..4a72087 100644 --- a/src/app/(dashboard)/projects/[id]/project-tabs.tsx +++ b/src/app/(dashboard)/projects/[id]/project-tabs.tsx @@ -2,8 +2,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ProjectChat } from '@/components/chat/project-chat' -import { AgenticDashboard } from '@/components/dashboard/agentic-dashboard' -import { MessageSquare, Workflow } from 'lucide-react' +import { DesignThinkingDashboard } from '@/components/project/design-thinking-dashboard' +import { MessageSquare, Compass } from 'lucide-react' import type { Database } from '@/types/database' type Message = Database['public']['Tables']['messages']['Row'] @@ -28,11 +28,11 @@ export function ProjectTabs({ projectId, projectName, initialMessages }: Project Chat - - Workflow + + Phases @@ -42,12 +42,9 @@ export function ProjectTabs({ projectId, projectName, initialMessages }: Project - +
- +
diff --git a/src/components/project/design-thinking-dashboard.tsx b/src/components/project/design-thinking-dashboard.tsx new file mode 100644 index 0000000..79acbd5 --- /dev/null +++ b/src/components/project/design-thinking-dashboard.tsx @@ -0,0 +1,166 @@ +'use client' + +import { useProject } from '@/hooks/use-project' +import { PhaseNavigator } from './phase-navigator' +import { HealthWidget } from './health-widget' +import { BacklogBoard } from './backlog-board' +import { RecommendationsWidget } from './recommendations-widget' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { AlertCircle, RefreshCw, ChevronRight } from 'lucide-react' +import type { DesignPhase, BacklogItem, AIRecommendation } from '@/types/design-thinking' + +interface DesignThinkingDashboardProps { + projectId: string +} + +function DashboardSkeleton() { + return ( +
+ +
+ + +
+
+ + +
+
+ ) +} + +function ErrorState({ error, onRetry }: { error: Error; onRetry: () => void }) { + return ( + + + +

Failed to load project

+

{error.message}

+ +
+
+ ) +} + +export function DesignThinkingDashboard({ projectId }: DesignThinkingDashboardProps) { + const { + project, + phaseStatuses, + backlogItems, + health, + recommendations, + loading, + error, + refresh, + advancePhase, + loopBack, + updateBacklog, + dismissRec + } = useProject(projectId) + + if (loading) { + return + } + + if (error) { + return + } + + if (!project) { + return null + } + + const handlePhaseClick = (phase: DesignPhase) => { + // Could open a modal with phase details + console.log('Phase clicked:', phase) + } + + const handleLoopBack = (fromPhase: DesignPhase, toPhase: DesignPhase) => { + loopBack(fromPhase, toPhase) + } + + const handleBacklogItemClick = (item: BacklogItem) => { + // Could open item detail modal + console.log('Backlog item clicked:', item) + } + + const handleStatusChange = (itemId: string, newStatus: BacklogItem['status']) => { + updateBacklog(itemId, { status: newStatus }) + } + + const handlePriorityChange = (itemId: string, direction: 'up' | 'down') => { + const item = backlogItems.find(i => i.id === itemId) + if (!item) return + + // Adjust user_value to change priority + const adjustment = direction === 'up' ? 1 : -1 + const newValue = Math.max(1, Math.min(10, item.user_value + adjustment)) + updateBacklog(itemId, { user_value: newValue }) + } + + const handleRecommendationAction = (rec: AIRecommendation) => { + if (rec.action_command) { + // Execute the command - this would integrate with the chat + console.log('Execute command:', rec.action_command) + } + } + + const handleDismissRecommendation = (recId: string) => { + dismissRec(recId) + } + + return ( +
+ {/* Header with project info */} +
+
+

{project.name}

+

+ Current phase: {project.current_phase} +

+
+ +
+ + {/* Phase Navigator */} + + + + + + + {/* Health + Recommendations row */} +
+
+ +
+ +
+ + {/* Backlog Board - full width */} + +
+ ) +} diff --git a/src/components/project/index.ts b/src/components/project/index.ts index 27ecde2..eb26afe 100644 --- a/src/components/project/index.ts +++ b/src/components/project/index.ts @@ -2,3 +2,4 @@ export { PhaseNavigator } from './phase-navigator' export { HealthWidget } from './health-widget' export { BacklogBoard } from './backlog-board' export { RecommendationsWidget } from './recommendations-widget' +export { DesignThinkingDashboard } from './design-thinking-dashboard' diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..c23a30d --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from '@/lib/utils' + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/src/hooks/use-project.ts b/src/hooks/use-project.ts new file mode 100644 index 0000000..c72da0b --- /dev/null +++ b/src/hooks/use-project.ts @@ -0,0 +1,132 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { + getProject, + getBacklogItems, + getProjectHealth, + getRecommendations, + getProjectActivities, + updateProjectPhase, + loopBackToPhase, + updateBacklogItem, + dismissRecommendation, + type Project, + type ProjectPhase +} from '@/lib/supabase/projects' +import type { + DesignPhase, + PhaseStatus, + BacklogItem, + ProjectHealth, + AIRecommendation +} from '@/types/design-thinking' +import { DESIGN_PHASES } from '@/types/design-thinking' + +interface UseProjectReturn { + project: Project | null + phases: ProjectPhase[] + phaseStatuses: Record + backlogItems: BacklogItem[] + health: ProjectHealth + recommendations: AIRecommendation[] + activities: Array<{ id: string; activity_type: string; message: string; created_at: string }> + loading: boolean + error: Error | null + refresh: () => Promise + advancePhase: (phase: DesignPhase) => Promise + loopBack: (fromPhase: DesignPhase, toPhase: DesignPhase) => Promise + updateBacklog: (itemId: string, updates: Partial) => Promise + dismissRec: (recId: string) => Promise +} + +export function useProject(projectId: string): UseProjectReturn { + const [project, setProject] = useState(null) + const [phases, setPhases] = useState([]) + const [backlogItems, setBacklogItems] = useState([]) + const [health, setHealth] = useState({ + overall: 100, + velocity: 0, + blockers: 0, + overdue: 0, + completion_rate: 0 + }) + const [recommendations, setRecommendations] = useState([]) + const [activities, setActivities] = useState>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Derive phase statuses from phases array + const phaseStatuses: Record = DESIGN_PHASES.reduce((acc, phase) => { + const latestPhase = phases.find(p => p.phase === phase) + acc[phase] = latestPhase?.status || 'not_started' + return acc + }, {} as Record) + + const fetchAll = useCallback(async () => { + try { + setLoading(true) + setError(null) + + const [projectData, backlog, healthData, recs, acts] = await Promise.all([ + getProject(projectId), + getBacklogItems(projectId), + getProjectHealth(projectId), + getRecommendations(projectId), + getProjectActivities(projectId) + ]) + + setProject(projectData.project) + setPhases(projectData.phases) + setBacklogItems(backlog) + setHealth(healthData) + setRecommendations(recs) + setActivities(acts) + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to load project')) + } finally { + setLoading(false) + } + }, [projectId]) + + useEffect(() => { + fetchAll() + }, [fetchAll]) + + const advancePhase = useCallback(async (phase: DesignPhase) => { + await updateProjectPhase(projectId, phase, 'completed') + await fetchAll() + }, [projectId, fetchAll]) + + const loopBack = useCallback(async (fromPhase: DesignPhase, toPhase: DesignPhase) => { + await loopBackToPhase(projectId, fromPhase, toPhase) + await fetchAll() + }, [projectId, fetchAll]) + + const updateBacklog = useCallback(async (itemId: string, updates: Partial) => { + await updateBacklogItem(itemId, updates) + await fetchAll() + }, [fetchAll]) + + const dismissRec = useCallback(async (recId: string) => { + await dismissRecommendation(recId) + setRecommendations(prev => prev.filter(r => r.id !== recId)) + }, []) + + return { + project, + phases, + phaseStatuses, + backlogItems, + health, + recommendations, + activities, + loading, + error, + refresh: fetchAll, + advancePhase, + loopBack, + updateBacklog, + dismissRec + } +} diff --git a/src/lib/supabase/projects.ts b/src/lib/supabase/projects.ts new file mode 100644 index 0000000..81fdb17 --- /dev/null +++ b/src/lib/supabase/projects.ts @@ -0,0 +1,319 @@ +import { createClient } from '@/lib/supabase/client' +import type { + DesignPhase, + PhaseStatus, + BacklogItem, + ProjectHealth, + AIRecommendation +} from '@/types/design-thinking' + +export interface Project { + id: string + user_id: string + name: string + description: string | null + current_phase: DesignPhase + health_score: number + created_at: string + updated_at: string +} + +export interface ProjectPhase { + id: string + project_id: string + phase: DesignPhase + status: PhaseStatus + started_at: string | null + completed_at: string | null + notes: string | null + iteration: number +} + +// Fetch all projects for current user +export async function getProjects(): Promise { + const supabase = createClient() + const { data, error } = await supabase + .from('projects') + .select('*') + .order('updated_at', { ascending: false }) + + if (error) throw error + return (data || []) as unknown as Project[] +} + +// Fetch single project with phases +export async function getProject(projectId: string): Promise<{ project: Project; phases: ProjectPhase[] }> { + const supabase = createClient() + + const [projectResult, phasesResult] = await Promise.all([ + supabase.from('projects').select('*').eq('id', projectId).single(), + supabase.from('project_phases').select('*').eq('project_id', projectId).order('iteration', { ascending: false }) + ]) + + if (projectResult.error) throw projectResult.error + if (phasesResult.error) throw phasesResult.error + + return { + project: projectResult.data as unknown as Project, + phases: (phasesResult.data || []) as unknown as ProjectPhase[] + } +} + +// Create new project +export async function createProject(name: string, description?: string): Promise { + const supabase = createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) throw new Error('Not authenticated') + + // Use raw SQL-like insert with explicit types + const { data, error } = await supabase + .from('projects') + .insert([{ + user_id: user.id, + name, + description: description || null, + current_phase: 'empathize' as DesignPhase, + health_score: 100 + }] as never) + .select() + .single() + + if (error) throw error + const project = data as unknown as Project + + // Initialize phase tracking + const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test'] + await supabase.from('project_phases').insert( + phases.map((phase, index) => ({ + project_id: project.id, + phase, + status: index === 0 ? 'in_progress' : 'not_started', + iteration: 1, + started_at: index === 0 ? new Date().toISOString() : null + })) as never + ) + + return project +} + +// Update project phase +export async function updateProjectPhase(projectId: string, phase: DesignPhase, status: PhaseStatus): Promise { + const supabase = createClient() + + // Update the phase record + const { error: phaseError } = await supabase + .from('project_phases') + .update({ + status, + started_at: status === 'in_progress' ? new Date().toISOString() : undefined, + completed_at: status === 'completed' ? new Date().toISOString() : undefined + } as never) + .eq('project_id', projectId) + .eq('phase', phase) + + if (phaseError) throw phaseError + + // Update project's current phase if advancing + if (status === 'completed') { + const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test'] + const currentIndex = phases.indexOf(phase) + if (currentIndex < phases.length - 1) { + const nextPhase = phases[currentIndex + 1] + await supabase + .from('projects') + .update({ current_phase: nextPhase } as never) + .eq('id', projectId) + + // Start the next phase + await supabase + .from('project_phases') + .update({ status: 'in_progress', started_at: new Date().toISOString() } as never) + .eq('project_id', projectId) + .eq('phase', nextPhase) + } + } +} + +// Loop back to a previous phase (start new iteration) +export async function loopBackToPhase(projectId: string, fromPhase: DesignPhase, toPhase: DesignPhase): Promise { + const supabase = createClient() + + // Get current max iteration + const { data: currentPhases } = await supabase + .from('project_phases') + .select('iteration') + .eq('project_id', projectId) + .order('iteration', { ascending: false }) + .limit(1) + + const phasesData = currentPhases as unknown as Array<{ iteration: number }> | null + const newIteration = (phasesData?.[0]?.iteration || 1) + 1 + + // Create new iteration phases from toPhase onwards + const phases: DesignPhase[] = ['empathize', 'define', 'ideate', 'prototype', 'test'] + const startIndex = phases.indexOf(toPhase) + + await supabase.from('project_phases').insert( + phases.slice(startIndex).map((phase, index) => ({ + project_id: projectId, + phase, + status: index === 0 ? 'in_progress' : 'not_started', + iteration: newIteration, + started_at: index === 0 ? new Date().toISOString() : null + })) as never + ) + + // Update project's current phase + await supabase + .from('projects') + .update({ current_phase: toPhase } as never) + .eq('id', projectId) + + // Log activity + await supabase.from('project_activities').insert([{ + project_id: projectId, + activity_type: 'loop_back', + message: `Looped back from ${fromPhase} to ${toPhase} (iteration ${newIteration})`, + metadata: { from_phase: fromPhase, to_phase: toPhase, iteration: newIteration } + }] as never) +} + +// Fetch backlog items for a project +export async function getBacklogItems(projectId: string): Promise { + const supabase = createClient() + const { data, error } = await supabase + .from('backlog_items') + .select('*') + .eq('project_id', projectId) + .order('priority_score', { ascending: false }) + + if (error) throw error + return (data || []) as unknown as BacklogItem[] +} + +// Create backlog item +export async function createBacklogItem( + projectId: string, + item: Omit +): Promise { + const supabase = createClient() + const { data, error } = await supabase + .from('backlog_items') + .insert([{ ...item, project_id: projectId }] as never) + .select() + .single() + + if (error) throw error + return data as unknown as BacklogItem +} + +// Update backlog item +export async function updateBacklogItem(itemId: string, updates: Partial): Promise { + const supabase = createClient() + const { data, error } = await supabase + .from('backlog_items') + .update(updates as never) + .eq('id', itemId) + .select() + .single() + + if (error) throw error + return data as unknown as BacklogItem +} + +// Fetch project health +export async function getProjectHealth(projectId: string): Promise { + const supabase = createClient() + + // Get latest health snapshot + const { data: snapshot } = await supabase + .from('project_health_snapshots') + .select('*') + .eq('project_id', projectId) + .order('snapshot_date', { ascending: false }) + .limit(1) + .single() + + const snapshotData = snapshot as unknown as { + overall_score: number + velocity: number + blockers: number + overdue: number + completion_rate: number + } | null + + if (snapshotData) { + return { + overall: snapshotData.overall_score, + velocity: snapshotData.velocity, + blockers: snapshotData.blockers, + overdue: snapshotData.overdue, + completion_rate: snapshotData.completion_rate + } + } + + // Calculate on the fly if no snapshot + const { data: items } = await supabase + .from('backlog_items') + .select('status, due_date') + .eq('project_id', projectId) + + const itemsData = items as unknown as Array<{ status: string; due_date: string | null }> | null + const total = itemsData?.length || 0 + const done = itemsData?.filter(i => i.status === 'done').length || 0 + const blocked = itemsData?.filter(i => i.status === 'blocked').length || 0 + const overdue = itemsData?.filter(i => + i.due_date && new Date(i.due_date) < new Date() && i.status !== 'done' + ).length || 0 + + return { + overall: Math.max(0, 100 - (blocked * 10) - (overdue * 5)), + velocity: done, + blockers: blocked, + overdue, + completion_rate: total > 0 ? Math.round((done / total) * 100) : 0 + } +} + +// Fetch AI recommendations +export async function getRecommendations(projectId: string): Promise { + const supabase = createClient() + const { data, error } = await supabase + .from('ai_recommendations') + .select('*') + .eq('project_id', projectId) + .eq('dismissed', false) + .order('created_at', { ascending: false }) + + if (error) throw error + return (data || []) as unknown as AIRecommendation[] +} + +// Dismiss recommendation +export async function dismissRecommendation(recommendationId: string): Promise { + const supabase = createClient() + await supabase + .from('ai_recommendations') + .update({ dismissed: true, dismissed_at: new Date().toISOString() } as never) + .eq('id', recommendationId) +} + +// Fetch project activities +export async function getProjectActivities(projectId: string, limit = 20) { + const supabase = createClient() + const { data, error } = await supabase + .from('project_activities') + .select('*') + .eq('project_id', projectId) + .order('created_at', { ascending: false }) + .limit(limit) + + if (error) throw error + return (data || []) as unknown as Array<{ + id: string + activity_type: string + message: string + created_at: string + }> +}