Add Supabase data layer and integrate design thinking dashboard

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-14 20:00:49 +01:00
parent ffb4dc28c5
commit 44cfd4d5f1
6 changed files with 640 additions and 10 deletions

View File

@@ -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
</TabsTrigger>
<TabsTrigger
value="workflow"
value="phases"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
>
<Workflow className="w-4 h-4 mr-2" />
Workflow
<Compass className="w-4 h-4 mr-2" />
Phases
</TabsTrigger>
</TabsList>
</div>
@@ -42,12 +42,9 @@ export function ProjectTabs({ projectId, projectName, initialMessages }: Project
<ProjectChat projectId={projectId} initialMessages={initialMessages} />
</TabsContent>
<TabsContent value="workflow" className="flex-1 overflow-auto m-0 p-6 data-[state=inactive]:hidden">
<TabsContent value="phases" className="flex-1 overflow-auto m-0 p-6 data-[state=inactive]:hidden">
<div className="max-w-7xl mx-auto">
<AgenticDashboard
projectId={projectId}
projectName={projectName}
/>
<DesignThinkingDashboard projectId={projectId} />
</div>
</TabsContent>
</Tabs>

View File

@@ -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 (
<div className="space-y-6">
<Skeleton className="h-32 w-full" />
<div className="grid lg:grid-cols-3 gap-6">
<Skeleton className="h-48 lg:col-span-2" />
<Skeleton className="h-48" />
</div>
<div className="grid lg:grid-cols-2 gap-6">
<Skeleton className="h-64" />
<Skeleton className="h-64" />
</div>
</div>
)
}
function ErrorState({ error, onRetry }: { error: Error; onRetry: () => void }) {
return (
<Card className="border-red-500/20 bg-red-500/5">
<CardContent className="flex flex-col items-center justify-center py-12">
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<h3 className="text-lg font-semibold mb-2">Failed to load project</h3>
<p className="text-sm text-muted-foreground mb-4">{error.message}</p>
<Button onClick={onRetry} variant="outline" className="gap-2">
<RefreshCw className="w-4 h-4" />
Retry
</Button>
</CardContent>
</Card>
)
}
export function DesignThinkingDashboard({ projectId }: DesignThinkingDashboardProps) {
const {
project,
phaseStatuses,
backlogItems,
health,
recommendations,
loading,
error,
refresh,
advancePhase,
loopBack,
updateBacklog,
dismissRec
} = useProject(projectId)
if (loading) {
return <DashboardSkeleton />
}
if (error) {
return <ErrorState error={error} onRetry={refresh} />
}
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 (
<div className="space-y-6">
{/* Header with project info */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{project.name}</h2>
<p className="text-sm text-muted-foreground">
Current phase: <span className="capitalize font-medium">{project.current_phase}</span>
</p>
</div>
<Button onClick={() => advancePhase(project.current_phase)} className="gap-2">
Complete Phase
<ChevronRight className="w-4 h-4" />
</Button>
</div>
{/* Phase Navigator */}
<Card>
<CardContent className="pt-6">
<PhaseNavigator
currentPhase={project.current_phase}
phaseStatuses={phaseStatuses}
onPhaseClick={handlePhaseClick}
onLoopBack={handleLoopBack}
/>
</CardContent>
</Card>
{/* Health + Recommendations row */}
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<HealthWidget health={health} />
</div>
<RecommendationsWidget
recommendations={recommendations}
onAction={handleRecommendationAction}
onDismiss={handleDismissRecommendation}
/>
</div>
{/* Backlog Board - full width */}
<BacklogBoard
items={backlogItems}
onItemClick={handleBacklogItemClick}
onStatusChange={handleStatusChange}
onPriorityChange={handlePriorityChange}
/>
</div>
)
}

View File

@@ -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'

View File

@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils'
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
)
}
export { Skeleton }

132
src/hooks/use-project.ts Normal file
View File

@@ -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<DesignPhase, PhaseStatus>
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<void>
advancePhase: (phase: DesignPhase) => Promise<void>
loopBack: (fromPhase: DesignPhase, toPhase: DesignPhase) => Promise<void>
updateBacklog: (itemId: string, updates: Partial<BacklogItem>) => Promise<void>
dismissRec: (recId: string) => Promise<void>
}
export function useProject(projectId: string): UseProjectReturn {
const [project, setProject] = useState<Project | null>(null)
const [phases, setPhases] = useState<ProjectPhase[]>([])
const [backlogItems, setBacklogItems] = useState<BacklogItem[]>([])
const [health, setHealth] = useState<ProjectHealth>({
overall: 100,
velocity: 0,
blockers: 0,
overdue: 0,
completion_rate: 0
})
const [recommendations, setRecommendations] = useState<AIRecommendation[]>([])
const [activities, setActivities] = useState<Array<{ id: string; activity_type: string; message: string; created_at: string }>>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
// Derive phase statuses from phases array
const phaseStatuses: Record<DesignPhase, PhaseStatus> = DESIGN_PHASES.reduce((acc, phase) => {
const latestPhase = phases.find(p => p.phase === phase)
acc[phase] = latestPhase?.status || 'not_started'
return acc
}, {} as Record<DesignPhase, PhaseStatus>)
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<BacklogItem>) => {
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
}
}

View File

@@ -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<Project[]> {
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<Project> {
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<void> {
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<void> {
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<BacklogItem[]> {
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<BacklogItem, 'id' | 'priority_score' | 'created_at' | 'updated_at'>
): Promise<BacklogItem> {
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<BacklogItem>): Promise<BacklogItem> {
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<ProjectHealth> {
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<AIRecommendation[]> {
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<void> {
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
}>
}