Foundation: route, recharts, sprint-dates migration, chart-colors helper (#46)
* feat(ST-1201): add Sprint start_date/end_date + claude_jobs index migration - Sprint model: optionele start_date en end_date (DATE) voor burndown x-as - CREATE INDEX claude_jobs(status, finished_at) voor agent-throughput-queries - Bestaande sprints houden NULL; burndown skipt die Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1202): add lib/chart-colors.ts + vitest coverage MD3-token-to-CSS-var mappings for STATUS, PRIORITY, VERIFY, JOB_STATUS and SERIES_COLORS; all 5 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1203): add Insights link to NavBar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1204): move Insights NavBar link between Solo and Todo's Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1205): add sprint start_date/end_date UI + server actions - createSprintAction + updateSprintDatesAction: Zod date validation with end_date >= start_date cross-check - start-sprint-button: date inputs in create dialog - sprint-header: date display button + edit dialog with updateSprintDatesAction - sprint page: select start_date/end_date for SprintHeader prop - Demo blokkade via bestaande isDemo checks - 6 tests groen (validation + demo guard) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
55a1ee035c
commit
ce94fb48c3
10 changed files with 333 additions and 7 deletions
97
__tests__/actions/sprint-dates.test.ts
Normal file
97
__tests__/actions/sprint-dates.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
||||
vi.mock('iron-session', () => ({
|
||||
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
|
||||
}))
|
||||
vi.mock('@/lib/session', () => ({
|
||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||
}))
|
||||
vi.mock('@/lib/product-access', () => ({
|
||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1', user_id: 'user-1' }),
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
sprint: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints'
|
||||
|
||||
const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } }
|
||||
|
||||
function makeFormData(data: Record<string, string | null>) {
|
||||
const fd = new FormData()
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v !== null) fd.append(k, v)
|
||||
}
|
||||
return fd
|
||||
}
|
||||
|
||||
describe('createSprintAction — date validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSprint.sprint.findFirst.mockResolvedValue(null)
|
||||
mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' })
|
||||
})
|
||||
|
||||
it('accepts valid start_date + end_date', async () => {
|
||||
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-01', end_date: '2026-05-14' })
|
||||
const result = await createSprintAction(undefined, fd)
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockSprint.sprint.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ start_date: new Date('2026-05-01'), end_date: new Date('2026-05-14') }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects end_date before start_date', async () => {
|
||||
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' })
|
||||
const result = await createSprintAction(undefined, fd)
|
||||
expect(result.error).toBeTruthy()
|
||||
const errors = result.error as Record<string, string[]>
|
||||
expect(errors.end_date?.[0]).toContain('Einddatum')
|
||||
})
|
||||
|
||||
it('accepts no dates (both optional)', async () => {
|
||||
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '', end_date: '' })
|
||||
const result = await createSprintAction(undefined, fd)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSprintDatesAction — date validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSprint.sprint.findFirst.mockResolvedValue({ id: 'sprint-1', product_id: 'product-1' })
|
||||
mockSprint.sprint.update.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('saves valid dates', async () => {
|
||||
const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-01', end_date: '2026-05-14' })
|
||||
const result = await updateSprintDatesAction(undefined, fd)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects end_date before start_date', async () => {
|
||||
const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' })
|
||||
const result = await updateSprintDatesAction(undefined, fd)
|
||||
expect(result.error).toBeTruthy()
|
||||
const errors = result.error as Record<string, string[]>
|
||||
expect(errors.end_date?.[0]).toContain('Einddatum')
|
||||
})
|
||||
|
||||
it('blocks demo users', async () => {
|
||||
const { getIronSession } = await import('iron-session')
|
||||
vi.mocked(getIronSession).mockResolvedValueOnce({ userId: 'user-1', isDemo: true } as never)
|
||||
const fd = makeFormData({ id: 'sprint-1', start_date: '', end_date: '' })
|
||||
const result = await updateSprintDatesAction(undefined, fd)
|
||||
expect(result.error).toBe('Niet beschikbaar in demo-modus')
|
||||
})
|
||||
})
|
||||
52
__tests__/lib/chart-colors.test.ts
Normal file
52
__tests__/lib/chart-colors.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
PRIORITY_COLORS,
|
||||
VERIFY_COLORS,
|
||||
JOB_STATUS_COLORS,
|
||||
SERIES_COLORS,
|
||||
} from '@/lib/chart-colors'
|
||||
|
||||
describe('chart-colors', () => {
|
||||
it('STATUS_COLORS has all TaskStatus keys and non-empty values', () => {
|
||||
const keys: (keyof typeof STATUS_COLORS)[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE']
|
||||
for (const key of keys) {
|
||||
expect(STATUS_COLORS[key]).toBeTruthy()
|
||||
expect(typeof STATUS_COLORS[key]).toBe('string')
|
||||
}
|
||||
})
|
||||
|
||||
it('PRIORITY_COLORS has keys 1-4 with non-empty values', () => {
|
||||
const keys = [1, 2, 3, 4] as const
|
||||
for (const key of keys) {
|
||||
expect(PRIORITY_COLORS[key]).toBeTruthy()
|
||||
expect(typeof PRIORITY_COLORS[key]).toBe('string')
|
||||
}
|
||||
})
|
||||
|
||||
it('VERIFY_COLORS has all VerifyResult keys and non-empty values', () => {
|
||||
const keys: (keyof typeof VERIFY_COLORS)[] = ['ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT']
|
||||
for (const key of keys) {
|
||||
expect(VERIFY_COLORS[key]).toBeTruthy()
|
||||
expect(typeof VERIFY_COLORS[key]).toBe('string')
|
||||
}
|
||||
})
|
||||
|
||||
it('JOB_STATUS_COLORS has all ClaudeJobStatus keys and non-empty values', () => {
|
||||
const keys: (keyof typeof JOB_STATUS_COLORS)[] = [
|
||||
'queued', 'claimed', 'running', 'done', 'failed', 'cancelled',
|
||||
]
|
||||
for (const key of keys) {
|
||||
expect(JOB_STATUS_COLORS[key]).toBeTruthy()
|
||||
expect(typeof JOB_STATUS_COLORS[key]).toBe('string')
|
||||
}
|
||||
})
|
||||
|
||||
it('SERIES_COLORS has 5 non-empty entries', () => {
|
||||
expect(SERIES_COLORS).toHaveLength(5)
|
||||
for (const color of SERIES_COLORS) {
|
||||
expect(color).toBeTruthy()
|
||||
expect(typeof color).toBe('string')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -16,6 +16,14 @@ function hasDuplicateIds(ids: string[]) {
|
|||
return new Set(ids).size !== ids.length
|
||||
}
|
||||
|
||||
const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null))
|
||||
|
||||
function validateDateOrder(data: { start_date: Date | null; end_date: Date | null }, ctx: z.RefinementCtx) {
|
||||
if (data.start_date && data.end_date && data.end_date < data.start_date) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' })
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSprintAction(_prevState: unknown, formData: FormData) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
|
|
@ -24,9 +32,13 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
|||
const parsed = z.object({
|
||||
productId: z.string(),
|
||||
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
|
||||
}).safeParse({
|
||||
start_date: dateField,
|
||||
end_date: dateField,
|
||||
}).superRefine(validateDateOrder).safeParse({
|
||||
productId: formData.get('productId'),
|
||||
sprint_goal: formData.get('sprint_goal'),
|
||||
start_date: formData.get('start_date'),
|
||||
end_date: formData.get('end_date'),
|
||||
})
|
||||
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
|
||||
|
||||
|
|
@ -43,6 +55,8 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
|||
product_id: parsed.data.productId,
|
||||
sprint_goal: parsed.data.sprint_goal,
|
||||
status: 'ACTIVE',
|
||||
start_date: parsed.data.start_date,
|
||||
end_date: parsed.data.end_date,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -50,6 +64,35 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
|||
return { success: true, sprintId: sprint.id }
|
||||
}
|
||||
|
||||
export async function updateSprintDatesAction(_prevState: unknown, formData: FormData) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const parsed = z.object({
|
||||
id: z.string(),
|
||||
start_date: dateField,
|
||||
end_date: dateField,
|
||||
}).superRefine(validateDateOrder).safeParse({
|
||||
id: formData.get('id'),
|
||||
start_date: formData.get('start_date'),
|
||||
end_date: formData.get('end_date'),
|
||||
})
|
||||
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { id: parsed.data.id, product: productAccessFilter(session.userId) },
|
||||
})
|
||||
if (!sprint) return { error: 'Sprint niet gevonden' }
|
||||
|
||||
await prisma.sprint.update({
|
||||
where: { id: parsed.data.id },
|
||||
data: { start_date: parsed.data.start_date, end_date: parsed.data.end_date },
|
||||
})
|
||||
revalidatePath(`/products/${sprint.product_id}/sprint`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
|
|
|
|||
|
|
@ -33,6 +33,13 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: id, status: 'ACTIVE' },
|
||||
select: {
|
||||
id: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
},
|
||||
})
|
||||
if (!sprint) redirect(`/products/${id}`)
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export function NavBar({
|
|||
pathname.includes('/solo')
|
||||
)
|
||||
: disabledSpan('Solo')}
|
||||
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
|
||||
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,13 +12,15 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { updateSprintGoalAction, completeSprintAction } from '@/actions/sprints'
|
||||
import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction } from '@/actions/sprints'
|
||||
import type { SprintStory } from './sprint-backlog'
|
||||
|
||||
interface Sprint {
|
||||
id: string
|
||||
sprint_goal: string
|
||||
status: string
|
||||
start_date: Date | null
|
||||
end_date: Date | null
|
||||
}
|
||||
|
||||
interface SprintHeaderProps {
|
||||
|
|
@ -34,8 +36,14 @@ function SaveGoalButton() {
|
|||
return <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button>
|
||||
}
|
||||
|
||||
function toDateInputValue(d: Date | null) {
|
||||
if (!d) return ''
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) {
|
||||
const [editingGoal, setEditingGoal] = useState(false)
|
||||
const [editingDates, setEditingDates] = useState(false)
|
||||
const [completeOpen, setCompleteOpen] = useState(false)
|
||||
const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({})
|
||||
const [isCompleting, startCompleting] = useTransition()
|
||||
|
|
@ -50,6 +58,16 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
|||
undefined
|
||||
)
|
||||
|
||||
const [datesState, datesFormAction] = useActionState(
|
||||
async (_prev: unknown, fd: FormData) => {
|
||||
const result = await updateSprintDatesAction(_prev, fd)
|
||||
if (result?.success) { setEditingDates(false); toast.success('Sprint datums opgeslagen') }
|
||||
else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt')
|
||||
return result
|
||||
},
|
||||
undefined
|
||||
)
|
||||
|
||||
function setDecision(storyId: string, value: 'DONE' | 'OPEN') {
|
||||
setDecisions(prev => ({ ...prev, [storyId]: value }))
|
||||
}
|
||||
|
|
@ -96,13 +114,57 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
|||
)}
|
||||
</div>
|
||||
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button size="sm" variant="outline" disabled={isDemo} className="shrink-0 border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
|
||||
Sprint afronden
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button size="sm" variant="ghost" disabled={isDemo} className="text-muted-foreground" onClick={() => !isDemo && setEditingDates(true)}>
|
||||
{sprint.start_date && sprint.end_date
|
||||
? `${toDateInputValue(sprint.start_date)} → ${toDateInputValue(sprint.end_date)}`
|
||||
: 'Datums instellen'}
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button size="sm" variant="outline" disabled={isDemo} className="border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
|
||||
Sprint afronden
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates edit dialog */}
|
||||
<Dialog open={editingDates} onOpenChange={setEditingDates}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sprint datums instellen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form action={datesFormAction} className="space-y-4 p-1">
|
||||
<input type="hidden" name="id" value={sprint.id} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Startdatum</label>
|
||||
<input type="date" name="start_date" defaultValue={toDateInputValue(sprint.start_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).start_date && (
|
||||
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).start_date[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Einddatum</label>
|
||||
<input type="date" name="end_date" defaultValue={toDateInputValue(sprint.end_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).end_date && (
|
||||
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).end_date[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{typeof datesState?.error === 'string' && (
|
||||
<p className="text-xs text-error">{datesState.error}</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="ghost" onClick={() => setEditingDates(false)}>Annuleren</Button>
|
||||
<Button type="submit">Opslaan</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Complete sprint dialog */}
|
||||
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
|
|
|
|||
|
|
@ -75,6 +75,23 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Startdatum</label>
|
||||
<input type="date" name="start_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).start_date && (
|
||||
<p className="text-xs text-error">{(state.error as Record<string, string[]>).start_date[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Einddatum</label>
|
||||
<input type="date" name="end_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).end_date && (
|
||||
<p className="text-xs text-error">{(state.error as Record<string, string[]>).end_date[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{globalError && (
|
||||
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
||||
{globalError}
|
||||
|
|
|
|||
39
lib/chart-colors.ts
Normal file
39
lib/chart-colors.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Mapping van MD3-tokens naar CSS-var-strings voor Recharts fill/stroke.
|
||||
// Recharts accepteert gewone strings — 'var(--status-done)' werkt direct.
|
||||
export const STATUS_COLORS = {
|
||||
TO_DO: 'var(--status-todo)',
|
||||
IN_PROGRESS: 'var(--status-in-progress)',
|
||||
REVIEW: 'var(--status-in-progress)',
|
||||
DONE: 'var(--status-done)',
|
||||
} as const
|
||||
|
||||
export const PRIORITY_COLORS = {
|
||||
1: 'var(--priority-critical)',
|
||||
2: 'var(--priority-high)',
|
||||
3: 'var(--priority-medium)',
|
||||
4: 'var(--priority-low)',
|
||||
} as const
|
||||
|
||||
export const VERIFY_COLORS = {
|
||||
ALIGNED: 'var(--status-done)',
|
||||
PARTIAL: 'var(--priority-medium)',
|
||||
EMPTY: 'var(--priority-critical)',
|
||||
DIVERGENT: 'var(--priority-high)',
|
||||
} as const
|
||||
|
||||
export const JOB_STATUS_COLORS = {
|
||||
queued: 'var(--muted-foreground)',
|
||||
claimed: 'var(--status-in-progress)',
|
||||
running: 'var(--status-in-progress)',
|
||||
done: 'var(--status-done)',
|
||||
failed: 'var(--priority-critical)',
|
||||
cancelled: 'var(--muted-foreground)',
|
||||
} as const
|
||||
|
||||
export const SERIES_COLORS = [
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
] as const
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "sprints" ADD COLUMN "end_date" DATE,
|
||||
ADD COLUMN "start_date" DATE;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "claude_jobs_status_finished_at_idx" ON "claude_jobs"("status", "finished_at");
|
||||
|
|
@ -223,6 +223,8 @@ model Sprint {
|
|||
product_id String
|
||||
sprint_goal String
|
||||
status SprintStatus @default(ACTIVE)
|
||||
start_date DateTime? @db.Date
|
||||
end_date DateTime? @db.Date
|
||||
created_at DateTime @default(now())
|
||||
completed_at DateTime?
|
||||
stories Story[]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue