Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/app/api/projects/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ export async function GET() {

const { data, error } = await supabase
.from('projects')
.select('*, style:styles(*)')
// Ids only, and in one round trip: the dashboard badge reports progress
// from these rows rather than from a status column nothing ever wrote.
.select('*, style:styles(*), figures(id), surfaces(id), compositions(id)')
.eq('user_id', userId)
.order('updated_at', { ascending: false })

Expand Down
39 changes: 24 additions & 15 deletions app/src/components/layout/ProjectStepNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,36 @@ import { Users, Palette, Ruler, Layers, Download, Check, ArrowLeft } from 'lucid
import { useProject } from '@/hooks/useProject'
import { useFigures } from '@/hooks/useFigures'
import { useSurface } from '@/hooks/useSurface'
import { useComposition } from '@/hooks/useComposition'
import { PROJECT_STEPS, type ProjectStepId } from '@/lib/config/project-steps'
import { deriveProjectProgress } from '@/lib/domain/project-progress'

const STEPS = [
{ id: 'figures', label: 'Figures', icon: Users, href: 'figures' },
{ id: 'style', label: 'Style', icon: Palette, href: 'style' },
{ id: 'surface', label: 'Surface', icon: Ruler, href: 'surface' },
{ id: 'compose', label: 'Compose', icon: Layers, href: 'compose' },
{ id: 'export', label: 'Export', icon: Download, href: 'export' },
]
/** Icons are the only per-step thing the nav owns; the steps themselves are config. */
const STEP_ICONS: Record<ProjectStepId, typeof Users> = {
figures: Users,
style: Palette,
surface: Ruler,
compose: Layers,
export: Download,
}

export function ProjectStepNav({ projectId }: { projectId: string }) {
const pathname = usePathname()
const { data: project } = useProject(projectId)
const { data: figures } = useFigures(projectId)
const { data: surface } = useSurface(projectId)
const { data: composition } = useComposition(projectId)

const completedSteps = new Set<string>()
if (figures && figures.length > 0) completedSteps.add('figures')
if (project?.style_id) completedSteps.add('style')
if (surface) completedSteps.add('surface')
if (figures?.some(f => f.styled_url)) completedSteps.add('compose')
// Same derivation the dashboard badge uses, so the two cannot disagree.
const { completed } = deriveProjectProgress({
figureCount: figures?.length ?? 0,
hasStyle: !!project?.style_id,
hasSurface: !!surface,
hasComposition: !!composition,
})
const completedSteps = new Set<ProjectStepId>(completed)

const activeIndex = STEPS.findIndex(s => pathname.endsWith(`/${s.href}`))
const activeIndex = PROJECT_STEPS.findIndex(s => pathname.endsWith(`/${s.href}`))

return (
<nav className="border-b border-white/[0.06] bg-background/80 backdrop-blur-xl">
Expand All @@ -46,11 +54,12 @@ export function ProjectStepNav({ projectId }: { projectId: string }) {

{/* Steps — horizontally scrollable on mobile */}
<div className="flex items-center gap-0.5 overflow-x-auto scrollbar-none -mx-1 px-1">
{STEPS.map((step, i) => {
{PROJECT_STEPS.map((step, i) => {
const href = `/project/${projectId}/${step.href}`
const isActive = pathname.endsWith(`/${step.href}`)
const isComplete = completedSteps.has(step.id)
const isPast = i < activeIndex
const Icon = STEP_ICONS[step.id]

return (
<div key={step.id} className="flex items-center shrink-0">
Expand Down Expand Up @@ -80,7 +89,7 @@ export function ProjectStepNav({ projectId }: { projectId: string }) {
<Check className="h-3 w-3 text-primary" />
</div>
) : (
<step.icon className="h-4 w-4" />
<Icon className="h-4 w-4" />
)}
<span className="hidden sm:inline">{step.label}</span>
</Link>
Expand Down
22 changes: 11 additions & 11 deletions app/src/components/projects/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,24 @@ import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Trash2, Palette, ArrowUpRight } from 'lucide-react'
import { deriveProjectProgress } from '@/lib/domain/project-progress'
import type { Project } from '@/types/database'

const STATUS_LABELS: Record<string, string> = {
draft: 'Draft',
uploading: 'Uploading',
generating: 'Generating',
composing: 'Composing',
previewing: 'Previewing',
exported: 'Exported',
printed: 'Printed',
}

interface ProjectCardProps {
project: Project
onDelete: (id: string) => void
}

export function ProjectCard({ project, onDelete }: ProjectCardProps) {
// project.status is never written by anything in the app, so it always read
// 'Draft'. The rows say where the project actually is.
const progress = deriveProjectProgress({
figureCount: project.figures?.length ?? 0,
hasStyle: !!project.style_id,
hasSurface: (project.surfaces?.length ?? 0) > 0,
hasComposition: (project.compositions?.length ?? 0) > 0,
})

return (
<Card className="group relative overflow-hidden rounded-2xl border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] transition-all duration-300 card-hover">
<Link href={`/project/${project.id}/figures`} className="block">
Expand All @@ -46,7 +46,7 @@ export function ProjectCard({ project, onDelete }: ProjectCardProps) {
<div className="flex items-center justify-between pt-4 border-t border-white/[0.04]">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs font-normal rounded-full px-3">
{STATUS_LABELS[project.status] ?? project.status}
{progress.label}
</Badge>
{project.style && (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
Expand Down
16 changes: 16 additions & 0 deletions app/src/lib/config/project-steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* The steps a project actually moves through in this app. One list, so the
* step nav and the dashboard badge cannot disagree about where a project is.
*/
export const PROJECT_STEPS = [
{ id: 'figures', label: 'Figures', href: 'figures', doneLabel: 'Figures added' },
{ id: 'style', label: 'Style', href: 'style', doneLabel: 'Style chosen' },
{ id: 'surface', label: 'Surface', href: 'surface', doneLabel: 'Surface set' },
{ id: 'compose', label: 'Compose', href: 'compose', doneLabel: 'Composed' },
{ id: 'export', label: 'Export', href: 'export', doneLabel: 'Exported' },
] as const

export type ProjectStepId = (typeof PROJECT_STEPS)[number]['id']

/** Shown when no step is finished yet. */
export const NOT_STARTED_LABEL = 'Not started'
60 changes: 60 additions & 0 deletions app/src/lib/domain/project-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { deriveProjectProgress } from './project-progress'
import { NOT_STARTED_LABEL } from '@/lib/config/project-steps'

const nothing = { figureCount: 0, hasStyle: false, hasSurface: false, hasComposition: false }

describe('deriveProjectProgress', () => {
it('reports nothing started for an empty project', () => {
const progress = deriveProjectProgress(nothing)
expect(progress.completed).toEqual([])
expect(progress.furthest).toBeNull()
expect(progress.label).toBe(NOT_STARTED_LABEL)
})

it('counts a step only once its rows exist', () => {
expect(deriveProjectProgress({ ...nothing, figureCount: 1 }).completed).toEqual(['figures'])
expect(deriveProjectProgress({ ...nothing, hasStyle: true }).completed).toEqual(['style'])
expect(deriveProjectProgress({ ...nothing, hasSurface: true }).completed).toEqual(['surface'])
expect(deriveProjectProgress({ ...nothing, hasComposition: true }).completed).toEqual(['compose'])
})

it('reports the furthest step reached', () => {
const progress = deriveProjectProgress({
figureCount: 3,
hasStyle: true,
hasSurface: true,
hasComposition: true,
})
expect(progress.furthest).toBe('compose')
expect(progress.label).toBe('Composed')
})

/** A surface can be defined before any photo is uploaded. */
it('does not require the earlier steps to report a later one', () => {
const progress = deriveProjectProgress({ ...nothing, hasSurface: true })
expect(progress.furthest).toBe('surface')
expect(progress.label).toBe('Surface set')
})

/**
* The old badge read 'Draft' forever because nothing wrote project.status,
* and the step nav separately called compose finished as soon as any figure
* had a styled image. Both now answer from the rows.
*/
it('does not treat a styled figure as a finished composition', () => {
const progress = deriveProjectProgress({ ...nothing, figureCount: 4, hasStyle: true })
expect(progress.completed).not.toContain('compose')
expect(progress.furthest).toBe('style')
})

it('leaves export open until an exports row is written', () => {
const progress = deriveProjectProgress({
figureCount: 2,
hasStyle: true,
hasSurface: true,
hasComposition: true,
})
expect(progress.completed).not.toContain('export')
})
})
42 changes: 42 additions & 0 deletions app/src/lib/domain/project-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { PROJECT_STEPS, NOT_STARTED_LABEL, type ProjectStepId } from '@/lib/config/project-steps'

/**
* What a project has, as far as the app can observe it. These are the rows
* themselves, not a status column copied from them — a stored status is a
* second copy of this and drifts the moment any step is undone.
*/
export interface ProjectProgressInput {
figureCount: number
hasStyle: boolean
hasSurface: boolean
hasComposition: boolean
}

export interface ProjectProgress {
completed: ProjectStepId[]
/** Furthest finished step — the one-line answer to "where is this project?" */
furthest: ProjectStepId | null
label: string
}

export function deriveProjectProgress(input: ProjectProgressInput): ProjectProgress {
const completed: ProjectStepId[] = []

if (input.figureCount > 0) completed.push('figures')
if (input.hasStyle) completed.push('style')
if (input.hasSurface) completed.push('surface')
if (input.hasComposition) completed.push('compose')
// 'export' is deliberately never derived: nothing writes an exports row yet,
// so claiming it would be a guess. It stays open until that row is written.

// Steps can be done out of order, so the badge reports the furthest one
// reached rather than assuming a contiguous run.
const furthest =
[...PROJECT_STEPS].reverse().find(step => completed.includes(step.id))?.id ?? null

const label = furthest
? PROJECT_STEPS.find(step => step.id === furthest)!.doneLabel
: NOT_STARTED_LABEL

return { completed, furthest, label }
}
3 changes: 3 additions & 0 deletions app/src/types/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export interface Project {
style?: Style | null
surface?: Surface | null
figures?: Figure[]
// Existence probes for progress — ids only, see GET /api/projects.
surfaces?: { id: string }[]
compositions?: { id: string }[]
}

export type ProjectStatus =
Expand Down
Loading