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:
319
src/lib/supabase/projects.ts
Normal file
319
src/lib/supabase/projects.ts
Normal 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
|
||||
}>
|
||||
}
|
||||
Reference in New Issue
Block a user