feat(M9): active product backlog — persistent active PB, NavBar splits, sprint card styling (#10)

* feat(tooling): extend backlog parser to support PBI-x milestone headers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(backlog): mark ST-801–806 as done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(backlog): sorteer PBI's en stories op prio/code/datum, onthoud keuze in localStorage; vergroot sprint-afronden dialoog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-901): add user.active_product_id with FK to Product

- Nullable relation User → Product with onDelete: SetNull
- Index on active_product_id for join performance
- Migration: 20260427165329_add_user_active_product_id
- Install @tanstack/react-table (was missing from node_modules)
- Fix PRIORITY_COLORS ref removed in earlier refactor
- Note: User schema change affects vendor/scrum4me-mcp submodule — run prisma generate + tsc --noEmit there after merge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: restore priority color on PBI filter pill

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-902): add setActiveProduct + clearActiveProduct server actions

- actions/active-product.ts: setActiveProductAction validates access via
  productAccessFilter, rejects archived products and demo users
- archiveProductAction: clears active_product_id for all affected users in transaction
- removeProductMemberAction: clears active_product_id for removed member
- leaveProductAction: clears active_product_id for leaving user

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-903): load active product in layout, replace cookie with DB lookup in solo

- layout.tsx: fetch active_product_id, resolve product, clear stale ref server-side
- NavBar: add activeProduct prop (rendering changes in ST-904)
- solo/page.tsx: redirect via user.active_product_id instead of lastProductId cookie
- proxy.ts: remove lastProductId cookie logic
- lib/cookies.ts: deleted (no longer used)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-904): split NavBar into 5 tabs with disabled-states and product-switcher dropdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-905): add Activeer button per product row in dashboard and product header

* feat(ST-906): redirect to dashboard with toast when active product becomes inaccessible

* feat(ST-907): tests for active-product actions and functional spec update for M9

* docs(M9): add implementation plan document and link from backlog

* feat: active PB indicator, Maak actief button and new product link in settings

* feat: apply priority-color card style to sprint story rows

* fix: move add-to-sprint click from entire card to + Toevoegen button

* feat: apply priority-color card style to sprint task rows

* fix(sprint-backlog): prevent text selection on PBI collapse button

* chore: bump version to 0.4.0 (M9 active product backlog)

* fix(landing): align logged-in nav left to match app NavBar

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 20:25:13 +02:00 committed by GitHub
parent c1c219639a
commit 88dca4102c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1184 additions and 481 deletions

View file

@ -16,10 +16,15 @@ export default async function DashboardPage({ searchParams }: Props) {
const { archived } = await searchParams
const showArchived = archived === '1'
const products = await prisma.product.findMany({
where: { archived: showArchived, ...productAccessFilter(session.userId) },
orderBy: { created_at: 'desc' },
})
const [products, user] = await Promise.all([
prisma.product.findMany({
where: { archived: showArchived, ...productAccessFilter(session.userId) },
orderBy: { created_at: 'desc' },
}),
session.userId
? prisma.user.findUnique({ where: { id: session.userId }, select: { active_product_id: true } })
: null,
])
return (
<div className="p-6 max-w-4xl mx-auto w-full">
@ -47,6 +52,7 @@ export default async function DashboardPage({ searchParams }: Props) {
products={products}
isDemo={session.isDemo ?? false}
showArchived={showArchived}
activeProductId={user?.active_product_id ?? null}
/>
</div>
)

View file

@ -3,10 +3,13 @@ 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 { NavBar } from '@/components/shared/nav-bar'
import { MinWidthBanner } from '@/components/shared/min-width-banner'
import { StatusBar } from '@/components/shared/status-bar'
import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge'
import { AlertToast } from '@/components/shared/alert-toast'
import { Suspense } from 'react'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
@ -15,15 +18,20 @@ export default async function AppLayout({ children }: { children: React.ReactNod
redirect('/login')
}
const [user, userRoles] = await Promise.all([
const [user, userRoles, accessibleProducts] = await Promise.all([
prisma.user.findUnique({
where: { id: session.userId },
select: { username: true, email: true },
select: { username: true, email: true, active_product_id: true },
}),
prisma.userRole.findMany({
where: { user_id: session.userId },
select: { role: true },
}),
prisma.product.findMany({
where: { archived: false, ...productAccessFilter(session.userId) },
orderBy: { name: 'asc' },
select: { id: true, name: true },
}),
])
const roles = userRoles.map(r => r.role as string)
@ -31,6 +39,30 @@ export default async function AppLayout({ children }: { children: React.ReactNod
redirect('/login')
}
// Resolve active product — clear stale reference if archived or inaccessible
let activeProduct: { id: string; name: string } | null = null
let hasActiveSprint = false
if (user.active_product_id) {
const product = await prisma.product.findFirst({
where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) },
select: { id: true, name: true },
})
if (product) {
activeProduct = product
const sprint = await prisma.sprint.findFirst({
where: { product_id: product.id, status: 'ACTIVE' },
select: { id: true },
})
hasActiveSprint = !!sprint
} else {
await prisma.user.update({
where: { id: session.userId },
data: { active_product_id: null },
})
redirect('/dashboard?alert=product_unavailable')
}
}
return (
<div className="h-screen bg-background flex flex-col overflow-hidden">
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:text-sm">
@ -42,6 +74,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
userId={session.userId}
username={user.username}
email={user.email}
activeProduct={activeProduct}
products={accessibleProducts}
hasActiveSprint={hasActiveSprint}
/>
<MinWidthBanner />
<main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0">
@ -49,6 +84,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
</main>
<StatusBar />
<SoloRealtimeBridge />
<Suspense>
<AlertToast />
</Suspense>
</div>
)
}

View file

@ -7,6 +7,7 @@ import { PbiList } from '@/components/backlog/pbi-list'
import { StoryPanel } from '@/components/backlog/story-panel'
import type { Story } from '@/components/backlog/story-panel'
import { StartSprintButton } from '@/components/sprint/start-sprint-button'
import { ActivateProductButton } from '@/components/shared/activate-product-button'
import Link from 'next/link'
interface Props {
@ -21,9 +22,10 @@ export default async function ProductBacklogPage({ params }: Props) {
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const activeSprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' },
})
const [activeSprint, user] = await Promise.all([
prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' } }),
prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }),
])
const pbis = await prisma.pbi.findMany({
where: { product_id: id },
@ -42,6 +44,7 @@ export default async function ProductBacklogPage({ params }: Props) {
priority: true,
status: true,
pbi_id: true,
created_at: true,
},
})
@ -65,6 +68,9 @@ export default async function ProductBacklogPage({ params }: Props) {
)}
</div>
<div className="flex items-center gap-3">
{user?.active_product_id !== id && (
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
)}
{activeSprint ? (
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
Sprint actief
@ -88,7 +94,7 @@ export default async function ProductBacklogPage({ params }: Props) {
left={
<PbiList
productId={id}
pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description }))}
pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at }))}
isDemo={isDemo}
/>
}

View file

@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
import { RoleManager } from '@/components/settings/role-manager'
import { LeaveProductButton } from '@/components/settings/leave-product-button'
import { ProfileEditor } from '@/components/settings/profile-editor'
import { ActivateProductButton } from '@/components/shared/activate-product-button'
import Link from 'next/link'
export default async function SettingsPage() {
@ -13,7 +14,7 @@ export default async function SettingsPage() {
const [user, userRoles, ownedProducts, memberships] = await Promise.all([
prisma.user.findUnique({
where: { id: session.userId },
select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true },
select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true },
}),
prisma.userRole.findMany({ where: { user_id: session.userId } }),
prisma.product.findMany({
@ -33,7 +34,9 @@ export default async function SettingsPage() {
| { kind: 'owner'; id: string; name: string }
| { kind: 'member'; id: string; name: string; ownerUsername: string }
const productBacklogs: PbEntry[] = [
const activeProductId = user?.active_product_id ?? null
const allBacklogs: PbEntry[] = [
...ownedProducts.map(p => ({ kind: 'owner' as const, id: p.id, name: p.name })),
...memberships.map(m => ({
kind: 'member' as const,
@ -43,6 +46,12 @@ export default async function SettingsPage() {
})),
]
// Active product floats to the top
const productBacklogs = [
...allBacklogs.filter(pb => pb.id === activeProductId),
...allBacklogs.filter(pb => pb.id !== activeProductId),
]
return (
<div className="p-6 max-w-2xl mx-auto w-full space-y-6">
<h1 className="text-xl font-medium text-foreground">Instellingen</h1>
@ -78,11 +87,21 @@ export default async function SettingsPage() {
<RoleManager currentRoles={currentRoles} isDemo={session.isDemo ?? false} />
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3">
<div>
<h2 className="text-sm font-medium text-foreground">Product Backlogs</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Alle product backlogs waarbij je betrokken bent.
</p>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-medium text-foreground">Product Backlogs</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Alle product backlogs waarbij je betrokken bent.
</p>
</div>
{!session.isDemo && (
<Link
href="/products/new"
className="shrink-0 text-xs text-primary hover:underline font-medium"
>
+ Nieuw product
</Link>
)}
</div>
{productBacklogs.length === 0 ? (
<p className="text-sm text-muted-foreground">
@ -91,33 +110,52 @@ export default async function SettingsPage() {
</p>
) : (
<ul className="space-y-2">
{productBacklogs.map(pb => (
<li key={`${pb.kind}-${pb.id}`} className="flex items-center justify-between gap-3 rounded-lg bg-surface-container px-3 py-2.5">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Link
href={`/products/${pb.id}`}
className="text-sm font-medium text-foreground hover:text-primary hover:underline truncate"
>
{pb.name}
</Link>
<span className={`shrink-0 text-xs px-1.5 py-0.5 rounded font-medium ${
pb.kind === 'owner'
? 'bg-primary-container text-primary-container-foreground'
: 'bg-secondary-container text-secondary-container-foreground'
}`}>
{pb.kind === 'owner' ? 'Eigenaar' : 'Developer'}
</span>
{productBacklogs.map(pb => {
const isActive = pb.id === activeProductId
return (
<li key={`${pb.kind}-${pb.id}`} className={`flex items-center justify-between gap-3 rounded-lg px-3 py-2.5 ${
isActive ? 'bg-primary-container/30 border border-primary/20' : 'bg-surface-container'
}`}>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/products/${pb.id}`}
className="text-sm font-medium text-foreground hover:text-primary hover:underline truncate"
>
{pb.name}
</Link>
<span className={`shrink-0 text-xs px-1.5 py-0.5 rounded font-medium ${
pb.kind === 'owner'
? 'bg-primary-container text-primary-container-foreground'
: 'bg-secondary-container text-secondary-container-foreground'
}`}>
{pb.kind === 'owner' ? 'Eigenaar' : 'Developer'}
</span>
{isActive && (
<span className="shrink-0 text-xs px-1.5 py-0.5 rounded font-medium bg-primary text-primary-foreground">
Actief
</span>
)}
</div>
{pb.kind === 'member' && (
<p className="text-xs text-muted-foreground mt-0.5">Eigenaar: {pb.ownerUsername}</p>
)}
</div>
{pb.kind === 'member' && (
<p className="text-xs text-muted-foreground mt-0.5">Eigenaar: {pb.ownerUsername}</p>
)}
</div>
{pb.kind === 'member' && !session.isDemo && (
<LeaveProductButton productId={pb.id} />
)}
</li>
))}
<div className="flex items-center gap-3 shrink-0">
{!isActive && (
<ActivateProductButton
productId={pb.id}
isDemo={session.isDemo ?? false}
label="Maak actief"
/>
)}
{pb.kind === 'member' && !session.isDemo && (
<LeaveProductButton productId={pb.id} />
)}
</div>
</li>
)
})}
</ul>
)}
</div>

View file

@ -1,7 +1,6 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getLastProductCookie } from '@/lib/cookies'
import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
import { productAccessFilter } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { ProductPicker } from '@/components/solo/product-picker'
@ -9,11 +8,17 @@ export default async function SoloPage() {
const session = await getSession()
if (!session.userId) redirect('/login')
const lastProductId = await getLastProductCookie()
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: { active_product_id: true },
})
if (lastProductId) {
const product = await getAccessibleProduct(lastProductId, session.userId)
if (product && !product.archived) redirect(`/products/${lastProductId}/solo`)
if (user?.active_product_id) {
const product = await prisma.product.findFirst({
where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) },
select: { id: true },
})
if (product) redirect(`/products/${user.active_product_id}/solo`)
}
const products = await prisma.product.findMany({

View file

@ -31,51 +31,49 @@ export default async function LandingPage() {
<AppIcon size={24} />
Scrum4Me
</Link>
<nav className="ml-auto flex items-center gap-2">
{isLoggedIn ? (
<>
<Link
href="/dashboard"
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
>
Producten
</Link>
<Link
href="/solo"
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
>
Solo
</Link>
<Link
href="/todos"
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
>
Todo&apos;s
</Link>
<Link
href="/settings"
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
>
Instellingen
</Link>
</>
) : (
<>
<Link
href="/login"
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
>
Inloggen
</Link>
<Link
href="/register"
className="px-4 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:opacity-90 transition-opacity font-medium"
>
Registreren
</Link>
</>
)}
</nav>
{isLoggedIn ? (
<nav className="flex items-center gap-1 ml-2">
<Link
href="/dashboard"
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
>
Producten
</Link>
<Link
href="/solo"
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
>
Solo
</Link>
<Link
href="/todos"
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
>
Todo&apos;s
</Link>
<Link
href="/settings"
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
>
Instellingen
</Link>
</nav>
) : (
<nav className="ml-auto flex items-center gap-2">
<Link
href="/login"
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
>
Inloggen
</Link>
<Link
href="/register"
className="px-4 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:opacity-90 transition-opacity font-medium"
>
Registreren
</Link>
</nav>
)}
</header>
<main className="flex-1 overflow-y-auto">