feat(PBI-79/ST-1343): sprint-conflicts helper-library

- lib/sprint-conflicts.ts: drie pure/server-side helpers voor eligibility
  + cross-sprint detectie.
  - isEligibleForSprint(story): sprint_id IS NULL en status != DONE
  - partitionByEligibility(prisma, storyIds, excludeSprintId): split in
    eligible / notEligible / crossSprint met reden per story
  - getBlockingSprintMap(prisma, productId, storyIds, excludeSprintId):
    map storyId → { sprintId, sprintName } voor stories in andere OPEN sprint
- Tests: __tests__/lib/sprint-conflicts.test.ts (16 cases) — alle eligibility
  paden + cross-sprint scoping + CLOSED-sprint filtering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-11 13:45:59 +02:00
parent 56c55e1813
commit b4a515e86e
2 changed files with 311 additions and 0 deletions

View file

@ -0,0 +1,195 @@
import { describe, it, expect, vi } from 'vitest'
import type { StoryStatus } from '@prisma/client'
import {
getBlockingSprintMap,
isEligibleForSprint,
partitionByEligibility,
} from '@/lib/sprint-conflicts'
function mockPrisma(stories: Array<Record<string, unknown>>) {
return {
story: {
findMany: vi.fn().mockResolvedValue(stories),
},
} as unknown as Parameters<typeof partitionByEligibility>[0]
}
describe('isEligibleForSprint', () => {
it('returns true for OPEN story without sprint', () => {
expect(
isEligibleForSprint({ sprint_id: null, status: 'OPEN' as StoryStatus }),
).toBe(true)
})
it('returns true for IN_SPRINT story without sprint_id (edge: restoration)', () => {
expect(
isEligibleForSprint({
sprint_id: null,
status: 'IN_SPRINT' as StoryStatus,
}),
).toBe(true)
})
it('returns false for DONE story without sprint', () => {
expect(
isEligibleForSprint({ sprint_id: null, status: 'DONE' as StoryStatus }),
).toBe(false)
})
it('returns false when story is in any sprint (open status)', () => {
expect(
isEligibleForSprint({
sprint_id: 'abc',
status: 'OPEN' as StoryStatus,
}),
).toBe(false)
})
it('returns false when story is in any sprint (done status)', () => {
expect(
isEligibleForSprint({
sprint_id: 'abc',
status: 'DONE' as StoryStatus,
}),
).toBe(false)
})
})
describe('partitionByEligibility', () => {
it('returns empty partition for empty input', async () => {
const prisma = mockPrisma([])
const result = await partitionByEligibility(prisma, [])
expect(result).toEqual({ eligible: [], notEligible: [], crossSprint: [] })
})
it('classifies all eligible when stories are free + OPEN', async () => {
const prisma = mockPrisma([
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
{ id: 's2', sprint_id: null, status: 'IN_SPRINT', sprint: null },
])
const result = await partitionByEligibility(prisma, ['s1', 's2'])
expect(result.eligible).toEqual(['s1', 's2'])
expect(result.notEligible).toEqual([])
expect(result.crossSprint).toEqual([])
})
it('marks DONE stories as notEligible with reason=DONE', async () => {
const prisma = mockPrisma([
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
])
const result = await partitionByEligibility(prisma, ['s1'])
expect(result.eligible).toEqual([])
expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }])
})
it('marks stories in other OPEN sprint as crossSprint + notEligible', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-other',
status: 'IN_SPRINT',
sprint: { id: 'sprint-other', code: 'SP-2', status: 'OPEN' },
},
])
const result = await partitionByEligibility(prisma, ['s1'])
expect(result.crossSprint).toEqual([
{ storyId: 's1', sprintId: 'sprint-other', sprintName: 'SP-2' },
])
expect(result.notEligible).toEqual([
{ storyId: 's1', reason: 'IN_OTHER_SPRINT' },
])
expect(result.eligible).toEqual([])
})
it('classifies story in CLOSED sprint with status=OPEN as eligible (status reset already happened)', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: null,
status: 'OPEN',
sprint: null,
},
])
const result = await partitionByEligibility(prisma, ['s1'])
expect(result.eligible).toEqual(['s1'])
})
it('does NOT mark crossSprint for stories in CLOSED other sprint', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-closed',
status: 'DONE',
sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' },
},
])
const result = await partitionByEligibility(prisma, ['s1'])
expect(result.crossSprint).toEqual([])
expect(result.notEligible).toEqual([
{ storyId: 's1', reason: 'IN_OTHER_SPRINT' },
])
})
it('respects excludeSprintId — story in same sprint is eligible', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-active',
status: 'IN_SPRINT',
sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' },
},
])
const result = await partitionByEligibility(prisma, ['s1'], 'sprint-active')
expect(result.eligible).toEqual(['s1'])
expect(result.crossSprint).toEqual([])
})
})
describe('getBlockingSprintMap', () => {
it('returns empty map for empty input', async () => {
const prisma = mockPrisma([])
const result = await getBlockingSprintMap(prisma, 'p1', [])
expect(result.size).toBe(0)
})
it('returns blocking sprint info for stories in OPEN sprints', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-x',
sprint: { id: 'sprint-x', code: 'SP-X', status: 'OPEN' },
},
])
const result = await getBlockingSprintMap(prisma, 'p1', ['s1'])
expect(result.get('s1')).toEqual({
sprintId: 'sprint-x',
sprintName: 'SP-X',
})
})
it('excludes the active sprint from blocking', async () => {
const prisma = mockPrisma([
{
id: 's1',
sprint_id: 'sprint-active',
sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' },
},
])
const result = await getBlockingSprintMap(
prisma,
'p1',
['s1'],
'sprint-active',
)
expect(result.size).toBe(0)
})
it('does not include CLOSED sprints (filtered at DB query level)', async () => {
// The prisma mock receives WHERE sprint.status='OPEN' so CLOSED stories
// are already filtered out before reaching this function's mapping logic.
const prisma = mockPrisma([])
const result = await getBlockingSprintMap(prisma, 'p1', ['s1'])
expect(result.size).toBe(0)
})
})

116
lib/sprint-conflicts.ts Normal file
View file

@ -0,0 +1,116 @@
import type { Prisma, PrismaClient, StoryStatus } from '@prisma/client'
export type EligibilityReason = 'DONE' | 'IN_OTHER_SPRINT'
export type CrossSprintBlock = {
storyId: string
sprintId: string
sprintName: string
}
export type EligibilityPartition = {
eligible: string[]
notEligible: { storyId: string; reason: EligibilityReason }[]
crossSprint: CrossSprintBlock[]
}
type StoryEligibilityInput = {
sprint_id: string | null
status: StoryStatus
}
export function isEligibleForSprint(story: StoryEligibilityInput): boolean {
return story.sprint_id === null && story.status !== 'DONE'
}
type PrismaLike = Pick<PrismaClient, 'story'> | Prisma.TransactionClient
export async function partitionByEligibility(
prisma: PrismaLike,
storyIds: string[],
excludeSprintId?: string,
): Promise<EligibilityPartition> {
if (storyIds.length === 0) {
return { eligible: [], notEligible: [], crossSprint: [] }
}
const stories = await prisma.story.findMany({
where: { id: { in: storyIds } },
select: {
id: true,
sprint_id: true,
status: true,
sprint: { select: { id: true, code: true, status: true } },
},
})
const eligible: string[] = []
const notEligible: { storyId: string; reason: EligibilityReason }[] = []
const crossSprint: CrossSprintBlock[] = []
for (const story of stories) {
const inOtherSprint = story.sprint_id !== null && story.sprint_id !== excludeSprintId
const inSameSprint = excludeSprintId !== undefined && story.sprint_id === excludeSprintId
if (inOtherSprint) {
if (story.sprint && story.sprint.status === 'OPEN') {
crossSprint.push({
storyId: story.id,
sprintId: story.sprint.id,
sprintName: story.sprint.code,
})
}
notEligible.push({ storyId: story.id, reason: 'IN_OTHER_SPRINT' })
continue
}
if (story.status === 'DONE') {
notEligible.push({ storyId: story.id, reason: 'DONE' })
continue
}
if (inSameSprint) {
eligible.push(story.id)
continue
}
eligible.push(story.id)
}
return { eligible, notEligible, crossSprint }
}
export async function getBlockingSprintMap(
prisma: PrismaLike,
productId: string,
storyIds: string[],
excludeSprintId?: string,
): Promise<Map<string, { sprintId: string; sprintName: string }>> {
const out = new Map<string, { sprintId: string; sprintName: string }>()
if (storyIds.length === 0) return out
const stories = await prisma.story.findMany({
where: {
id: { in: storyIds },
product_id: productId,
sprint_id: { not: null },
sprint: { status: 'OPEN' },
},
select: {
id: true,
sprint_id: true,
sprint: { select: { id: true, code: true, status: true } },
},
})
for (const story of stories) {
if (!story.sprint) continue
if (excludeSprintId !== undefined && story.sprint.id === excludeSprintId) continue
out.set(story.id, {
sprintId: story.sprint.id,
sprintName: story.sprint.code,
})
}
return out
}