- Sprint lifecycle: ACTIVE→OPEN, COMPLETED→CLOSED, +ARCHIVED (FAILED behouden) - TaskStatus: +EXCLUDED (overgeslagen door agent-loop via bestaande TO_DO filter) - Cookie-gebaseerde actieve sprint per product (lib/active-sprint.ts) - Route splitsen: /products/[id]/sprint/[sprintId] + /sprint redirect-page - NavBar: gestapelde product/sprint dropdowns + BUILDING-badge derivatie - Backlog selectie-modus + nieuwe-sprint-dialog (createSprintWithPbisAction) - Migratie 20260507210000_sprint_lifecycle: ALTER TYPE RENAME (geen data-rewrite) - Version bump 1.0.0 → 1.2.0 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
6.3 KiB
TypeScript
173 lines
6.3 KiB
TypeScript
import { cookies } from 'next/headers'
|
|
import { getIronSession } from 'iron-session'
|
|
import { SessionData, sessionOptions } from '@/lib/session'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { productAccessFilter } from '@/lib/product-access'
|
|
import { getBurndownData } from '@/lib/insights/burndown'
|
|
import { getSprintStatusBreakdown } from '@/lib/insights/sprint-status'
|
|
import { getVerifyResultStats, getAlignmentTrend } from '@/lib/insights/verify-stats'
|
|
import { getJobsPerDay } from '@/lib/insights/agent-throughput'
|
|
import { getTokenStats } from '@/lib/insights/token-stats'
|
|
import { getVelocity } from '@/lib/insights/velocity'
|
|
import { getBacklogHealth } from '@/lib/insights/backlog-health'
|
|
import { SprintInfoStrip } from './components/sprint-info-strip'
|
|
import { BurndownChart } from './components/burndown-chart'
|
|
import { SprintStatusDonut } from './components/sprint-status-donut'
|
|
import { PlanQualityCard } from './components/plan-quality'
|
|
import { AlignmentTrend } from './components/alignment-trend'
|
|
import { AgentThroughputCard } from './components/agent-throughput'
|
|
import { TokenUsageCard } from './components/token-usage'
|
|
import { VelocityChart } from './components/velocity-chart'
|
|
import { BacklogHealthCard } from './components/backlog-health'
|
|
|
|
const DAY_MS = 86_400_000
|
|
const ASSUMED_SPRINT_DAYS = 14
|
|
|
|
interface InsightsPageProps {
|
|
searchParams: Promise<{ product?: string }>
|
|
}
|
|
|
|
function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) {
|
|
return (
|
|
<p className="text-muted-foreground text-sm">
|
|
{productName} — sprint heeft geen datums.{' '}
|
|
<a href={`/products/${productId}/sprint`} className="underline text-primary">
|
|
Stel datums in
|
|
</a>
|
|
</p>
|
|
)
|
|
}
|
|
|
|
export default async function InsightsPage({ searchParams }: InsightsPageProps) {
|
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
|
const userId = session.userId!
|
|
const { product: filterProductId } = await searchParams
|
|
|
|
const [
|
|
burndownSprints,
|
|
statusBreakdown,
|
|
activeSprints,
|
|
productList,
|
|
verifyStats,
|
|
alignmentTrend,
|
|
jobsPerDay,
|
|
velocity,
|
|
backlogHealth,
|
|
] = await Promise.all([
|
|
getBurndownData(userId),
|
|
getSprintStatusBreakdown(userId),
|
|
prisma.sprint.findMany({
|
|
where: { status: 'OPEN', product: productAccessFilter(userId) },
|
|
select: {
|
|
id: true,
|
|
code: true,
|
|
sprint_goal: true,
|
|
created_at: true,
|
|
product: { select: { id: true, name: true } },
|
|
tasks: { select: { id: true } },
|
|
},
|
|
}),
|
|
prisma.product.findMany({
|
|
where: productAccessFilter(userId),
|
|
select: { id: true, name: true },
|
|
orderBy: { name: 'asc' },
|
|
}),
|
|
getVerifyResultStats(userId, 30),
|
|
getAlignmentTrend(userId, 5),
|
|
getJobsPerDay(userId, 14, filterProductId),
|
|
getVelocity(userId, 5),
|
|
getBacklogHealth(userId),
|
|
])
|
|
|
|
const activeSprintId = activeSprints.find(s => s.product.id === filterProductId)?.id ?? ''
|
|
const tokenStats = await (activeSprints.length > 0 && filterProductId
|
|
? getTokenStats(userId, activeSprintId)
|
|
: Promise.resolve({ kpi: { totalTokens: 0, totalCostUsd: 0, avgCostPerJob: 0, jobCount: 0 }, jobs: [] }))
|
|
|
|
// Date.now is an impure call but used once per request — safe in a Server Component.
|
|
// eslint-disable-next-line react-hooks/purity
|
|
const nowMs = Date.now()
|
|
const sprintInfos = activeSprints.map(s => ({
|
|
sprintId: s.id,
|
|
sprintCode: s.code,
|
|
productId: s.product.id,
|
|
productName: s.product.name,
|
|
sprintGoal: s.sprint_goal,
|
|
taskCount: s.tasks.length,
|
|
daysLeft: ASSUMED_SPRINT_DAYS - Math.floor((nowMs - s.created_at.getTime()) / DAY_MS),
|
|
}))
|
|
const burndownMap = new Map(burndownSprints.map(b => [b.sprintId, b]))
|
|
|
|
return (
|
|
<div className="p-6 space-y-8 max-w-6xl mx-auto w-full">
|
|
<h1 className="text-2xl font-semibold text-foreground">Insights</h1>
|
|
|
|
{/* Sprint Health */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-medium text-foreground">Sprint Health</h2>
|
|
{activeSprints.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
Geen active sprints — start er een via /products/[id]/sprint.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<SprintInfoStrip sprints={sprintInfos} />
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-4">
|
|
{sprintInfos.map(s => {
|
|
const burndown = burndownMap.get(s.sprintId)
|
|
if (!burndown || burndown.days.length === 0) {
|
|
return (
|
|
<MissingDatesNotice
|
|
key={s.sprintId}
|
|
productId={s.productId}
|
|
productName={s.productName}
|
|
/>
|
|
)
|
|
}
|
|
return <BurndownChart key={s.sprintId} sprint={burndown} />
|
|
})}
|
|
</div>
|
|
<SprintStatusDonut data={statusBreakdown} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
{/* Plan-quality */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-medium text-foreground">Plan-quality</h2>
|
|
<PlanQualityCard stats={verifyStats} nowMs={nowMs} />
|
|
{alignmentTrend.length > 0 && <AlignmentTrend trend={alignmentTrend} />}
|
|
</section>
|
|
|
|
{/* Agent throughput */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-medium text-foreground">Agent throughput</h2>
|
|
<AgentThroughputCard
|
|
data={jobsPerDay}
|
|
productList={productList}
|
|
currentProductId={filterProductId}
|
|
/>
|
|
</section>
|
|
|
|
{/* Token usage */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-medium text-foreground">Token gebruik</h2>
|
|
<TokenUsageCard kpi={tokenStats.kpi} jobs={tokenStats.jobs} />
|
|
</section>
|
|
|
|
{/* Velocity */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-medium text-foreground">Velocity</h2>
|
|
<VelocityChart data={velocity} />
|
|
</section>
|
|
|
|
{/* Backlog health */}
|
|
<section className="space-y-3">
|
|
<h2 className="text-lg font-medium text-foreground">Backlog health</h2>
|
|
<BacklogHealthCard data={backlogHealth} />
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|