Merge pull request #81 from madhura68/feat/ST-1134-mobile-shell-foundation

feat(PBI-11): mobile-shell met landscape-lock — settings + backlog + solo
This commit is contained in:
Janpeter Visser 2026-05-04 11:06:41 +02:00 committed by GitHub
commit db8be67d9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1125 additions and 21 deletions

View file

@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { redirectMock, verifyUserMock, headerGetMock, sessionSaveMock } = vi.hoisted(() => ({
redirectMock: vi.fn((path: string) => { throw new Error(`REDIRECT:${path}`) }),
verifyUserMock: vi.fn(),
headerGetMock: vi.fn(),
sessionSaveMock: vi.fn(),
}))
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({}),
headers: vi.fn().mockResolvedValue({ get: headerGetMock }),
}))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({
userId: '',
isDemo: false,
save: sessionSaveMock,
}),
}))
vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 't', password: 't' } }))
vi.mock('@/lib/auth', () => ({
verifyUser: verifyUserMock,
registerUser: vi.fn(),
}))
vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) }))
import { loginAction } from '@/actions/auth'
const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) Mobile/15E148 Safari/604.1'
const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) Safari/604.1'
const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Chrome/124.0.0.0 Safari/537.36'
function fd(username: string, password: string) {
const f = new FormData()
f.set('username', username)
f.set('password', password)
return f
}
beforeEach(() => {
redirectMock.mockClear()
verifyUserMock.mockReset()
headerGetMock.mockReset()
sessionSaveMock.mockReset()
})
describe('loginAction UA-redirect', () => {
it('phone-UA + actief product → /m/products/[id]/solo', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(IPHONE_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/products/p1/solo')
})
it('phone-UA zonder actief product → /m/settings', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: null })
headerGetMock.mockReturnValue(IPHONE_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/settings')
})
it('tablet-UA (iPad) → /dashboard', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(IPAD_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
})
it('desktop-UA → /dashboard', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(DESKTOP_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
})
it('geen UA-header → /dashboard', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(null)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
})
it('demo-user op phone volgt dezelfde routing', async () => {
verifyUserMock.mockResolvedValue({ id: 'demo', is_demo: true, active_product_id: 'p1' })
headerGetMock.mockReturnValue(IPHONE_UA)
await expect(loginAction(undefined, fd('demo', 'demo123pw'))).rejects.toThrow('REDIRECT:/m/products/p1/solo')
})
})

View file

@ -0,0 +1,38 @@
// Lichte regressie-tests voor de mobile backlog-page. Server-component render
// vereist te veel mocking; we asserten op statische source-eigenschappen die
// kritisch zijn voor de mobile-shell (cookie-key gescheiden, /m/-paden).
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/page.tsx')
const src = readFileSync(PAGE, 'utf-8')
describe('mobile backlog page (ST-1137)', () => {
it('gebruikt gescheiden cookie-key (backlog-{id}-mobile)', () => {
// Beslissing C: tab-mode-gebruikers vervuilen desktop-split niet.
expect(src).toMatch(/cookieKey=\{`backlog-\$\{id\}-mobile`\}/)
})
it('closePath en TaskDialog redirect blijven onder /m/products/', () => {
expect(src).toContain('const closePath = `/m/products/${id}`')
})
it('hergebruikt BacklogHydrationWrapper + BacklogSplitPane (geen content-componenten dupliceren)', () => {
expect(src).toContain('BacklogHydrationWrapper')
expect(src).toContain('BacklogSplitPane')
expect(src).toContain('PbiList')
expect(src).toContain('StoryPanel')
expect(src).toContain('TaskPanel')
})
it('auth via requireSession() (gedeelde guard)', () => {
expect(src).toContain("from '@/lib/auth-guard'")
expect(src).toContain('requireSession()')
})
it('rendert TaskDialog op ?newTask en EditTaskLoader op ?editTask', () => {
expect(src).toContain('{newTask &&')
expect(src).toContain('{editTask && !newTask &&')
})
})

View file

@ -0,0 +1,35 @@
// ST-1138: regressie-vangnet voor mobile solo-page (server component).
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/solo/page.tsx')
const TASK_DETAIL = resolve(process.cwd(), 'components/solo/task-detail-dialog.tsx')
describe('mobile solo page (ST-1138)', () => {
const src = readFileSync(PAGE, 'utf-8')
it('hergebruikt SoloBoard zonder content-aanpassingen', () => {
expect(src).toContain('SoloBoard')
expect(src).toContain("from '@/components/solo/solo-board'")
})
it('auth via gedeelde requireSession()', () => {
expect(src).toContain("from '@/lib/auth-guard'")
expect(src).toContain('requireSession()')
})
it('geeft NoActiveSprint terug als geen actieve sprint (zelfde gedrag als desktop)', () => {
expect(src).toContain('NoActiveSprint')
})
})
describe('TaskDetailDialog erft mobile-fullscreen (ST-1138 T-332 verify-only)', () => {
// Beslissing A: TaskDetailDialog gebruikt entityDialogContentClasses; mobile-classes
// komen automatisch door uit T-317. Dit test bewijst de wiring blijft staan.
const src = readFileSync(TASK_DETAIL, 'utf-8')
it('rendert DialogContent met entityDialogContentClasses (geen eigen className-override)', () => {
expect(src).toContain('className={entityDialogContentClasses}')
})
})

View file

@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, act } from '@testing-library/react'
import { LandscapeGuard } from '@/components/mobile/landscape-guard'
type Listener = (e: MediaQueryListEvent) => void
function mockMatchMedia(initialPortrait: boolean) {
let matches = initialPortrait
let listener: Listener | null = null
const mql = {
get matches() { return matches },
media: '(orientation: portrait)',
onchange: null,
addEventListener: (_: string, l: Listener) => { listener = l },
removeEventListener: () => { listener = null },
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}
Object.defineProperty(window, 'matchMedia', {
writable: true,
configurable: true,
value: () => mql,
})
return {
setPortrait(p: boolean) {
matches = p
if (listener) listener({ matches: p } as MediaQueryListEvent)
},
}
}
describe('LandscapeGuard', () => {
beforeEach(() => {})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders children always', () => {
mockMatchMedia(false)
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
expect(screen.getByText('kids')).toBeTruthy()
})
it('shows overlay in portrait', () => {
mockMatchMedia(true)
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
expect(screen.getByRole('alert').textContent).toContain('Draai je telefoon naar landscape')
// children blijven in DOM (geen unmount → SSE-streams blijven leven)
expect(screen.getByText('kids')).toBeTruthy()
})
it('hides overlay in landscape', () => {
mockMatchMedia(false)
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
expect(screen.queryByRole('alert')).toBeNull()
})
it('toggles overlay on orientation change', () => {
const ctl = mockMatchMedia(false)
render(<LandscapeGuard><div>kids</div></LandscapeGuard>)
expect(screen.queryByRole('alert')).toBeNull()
act(() => ctl.setPortrait(true))
expect(screen.getByRole('alert')).toBeTruthy()
act(() => ctl.setPortrait(false))
expect(screen.queryByRole('alert')).toBeNull()
})
})

View file

@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
const { logoutMock } = vi.hoisted(() => ({
logoutMock: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/actions/auth', () => ({ logoutAction: logoutMock }))
import { LogoutButton } from '@/components/mobile/logout-button'
beforeEach(() => {
logoutMock.mockClear()
})
describe('LogoutButton', () => {
it('toont initieel alleen de Uitloggen-knop, geen dialog', () => {
render(<LogoutButton />)
expect(screen.getByRole('button', { name: /Uitloggen/ })).toBeTruthy()
expect(screen.queryByText(/Weet je zeker/)).toBeNull()
})
it('opent AlertDialog bij klikken op de knop', () => {
render(<LogoutButton />)
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
expect(screen.getByText('Uitloggen?')).toBeTruthy()
expect(screen.getByText(/Weet je zeker/)).toBeTruthy()
})
it('roept logoutAction aan op bevestigen', async () => {
const { container } = render(<LogoutButton />)
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
// Het body-portal wordt buiten container gerenderd; query op document.body.
const allButtons = Array.from(document.body.querySelectorAll('button'))
const confirmBtn = allButtons.find(b => b.textContent?.trim() === 'Uitloggen' && !container.contains(b)) ?? allButtons[allButtons.length - 1]
fireEvent.click(confirmBtn)
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(1))
})
it('roept logoutAction NIET aan bij annuleren', () => {
render(<LogoutButton />)
fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ }))
fireEvent.click(screen.getByText('Annuleren'))
expect(logoutMock).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MobileTabBar } from '@/components/mobile/mobile-tab-bar'
let pathname = '/m/products/p1'
vi.mock('next/navigation', () => ({
usePathname: () => pathname,
}))
function setPathname(p: string) { pathname = p }
describe('MobileTabBar', () => {
it('toont 3 tabs als activeProductId aanwezig is', () => {
setPathname('/m/products/p1')
render(<MobileTabBar activeProductId="p1" />)
expect(screen.getByLabelText('Backlog')).toBeTruthy()
expect(screen.getByLabelText('Solo')).toBeTruthy()
expect(screen.getByLabelText('Settings')).toBeTruthy()
})
it('toont alleen Settings als activeProductId null is', () => {
setPathname('/m/settings')
render(<MobileTabBar activeProductId={null} />)
expect(screen.queryByLabelText('Backlog')).toBeNull()
expect(screen.queryByLabelText('Solo')).toBeNull()
expect(screen.getByLabelText('Settings')).toBeTruthy()
})
it('Backlog-tab is aria-current op /m/products/[id]', () => {
setPathname('/m/products/p1')
render(<MobileTabBar activeProductId="p1" />)
expect(screen.getByLabelText('Backlog').getAttribute('aria-current')).toBe('page')
expect(screen.getByLabelText('Solo').getAttribute('aria-current')).toBeNull()
})
it('Solo-tab is aria-current op /m/products/[id]/solo', () => {
setPathname('/m/products/p1/solo')
render(<MobileTabBar activeProductId="p1" />)
expect(screen.getByLabelText('Solo').getAttribute('aria-current')).toBe('page')
expect(screen.getByLabelText('Backlog').getAttribute('aria-current')).toBeNull()
})
it('Settings-tab is aria-current op /m/settings', () => {
setPathname('/m/settings')
render(<MobileTabBar activeProductId="p1" />)
expect(screen.getByLabelText('Settings').getAttribute('aria-current')).toBe('page')
})
it('tap-targets >=44x44 (h-14 = 56px breedtevulling per tab)', () => {
setPathname('/m/products/p1')
render(<MobileTabBar activeProductId="p1" />)
const tab = screen.getByLabelText('Backlog')
expect(tab.className).toContain('h-14')
expect(tab.className).toContain('flex-1')
})
})

View file

@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { entityDialogContentClasses } from '@/components/shared/entity-dialog-layout'
describe('entityDialogContentClasses', () => {
it('bevat mobile-fullscreen classes (<640px)', () => {
const cls = entityDialogContentClasses
expect(cls).toContain('max-sm:w-screen')
expect(cls).toContain('max-sm:h-screen')
expect(cls).toContain('max-sm:max-w-none')
expect(cls).toContain('max-sm:rounded-none')
})
it('behoudt desktop-classes (>=640px)', () => {
const cls = entityDialogContentClasses
expect(cls).toContain('sm:max-w-[90vw]')
expect(cls).toContain('sm:max-h-[85vh]')
expect(cls).toContain('lg:max-w-[50vw]')
})
})
describe('alle entity-dialogen gebruiken entityDialogContentClasses', () => {
// Regressie-vangnet: voorkomt dat een dialog zijn eigen className meegeeft en
// daarmee de gedeelde mobile-fullscreen-classes ontwijkt.
const files = [
'app/_components/tasks/task-dialog.tsx',
'components/solo/task-detail-dialog.tsx',
'components/backlog/pbi-dialog.tsx',
'components/backlog/story-dialog.tsx',
]
for (const f of files) {
it(`${f} importeert + gebruikt entityDialogContentClasses`, () => {
const src = readFileSync(resolve(process.cwd(), f), 'utf-8')
expect(src).toContain('entityDialogContentClasses')
})
}
})

View file

@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const getSessionMock = vi.fn()
const isPairedSessionExpiredMock = vi.fn()
const redirectMock = vi.fn(() => { throw new Error('REDIRECT_CALLED') })
vi.mock('@/lib/auth', () => ({ getSession: getSessionMock }))
vi.mock('@/lib/auth/pairing', () => ({ isPairedSessionExpired: isPairedSessionExpiredMock }))
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
describe('requireSession', () => {
beforeEach(() => {
getSessionMock.mockReset()
isPairedSessionExpiredMock.mockReset()
redirectMock.mockClear()
})
afterEach(() => {
vi.resetModules()
})
it('redirect /login als userId ontbreekt', async () => {
getSessionMock.mockResolvedValue({ userId: undefined, destroy: vi.fn() })
isPairedSessionExpiredMock.mockReturnValue(false)
const { requireSession } = await import('@/lib/auth-guard')
await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED')
expect(redirectMock).toHaveBeenCalledWith('/login')
})
it('vernietigt + redirect /login als paired-sessie verlopen is', async () => {
const destroy = vi.fn().mockResolvedValue(undefined)
getSessionMock.mockResolvedValue({ userId: 'u1', destroy })
isPairedSessionExpiredMock.mockReturnValue(true)
const { requireSession } = await import('@/lib/auth-guard')
await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED')
expect(destroy).toHaveBeenCalled()
expect(redirectMock).toHaveBeenCalledWith('/login')
})
it('geeft sessie terug als alles ok', async () => {
const sess = { userId: 'u1', destroy: vi.fn() }
getSessionMock.mockResolvedValue(sess)
isPairedSessionExpiredMock.mockReturnValue(false)
const { requireSession } = await import('@/lib/auth-guard')
const result = await requireSession()
expect(result).toBe(sess)
expect(redirectMock).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { isPhoneUA } from '@/lib/user-agent'
describe('isPhoneUA', () => {
it('iPhone Safari Mobile → true', () => {
const ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1'
expect(isPhoneUA(ua)).toBe(true)
})
it('Android Chrome (phone) → true', () => {
const ua = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36'
expect(isPhoneUA(ua)).toBe(true)
})
it('iPad → false (geen Mobi)', () => {
const ua = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/604.1'
expect(isPhoneUA(ua)).toBe(false)
})
it('Android tablet (Galaxy Tab) → false', () => {
const ua = 'Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
expect(isPhoneUA(ua)).toBe(false)
})
it('Desktop Chrome → false', () => {
const ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
expect(isPhoneUA(ua)).toBe(false)
})
it('null → false', () => {
expect(isPhoneUA(null)).toBe(false)
})
it('lege string → false', () => {
expect(isPhoneUA('')).toBe(false)
})
})

View file

@ -7,6 +7,7 @@ import { z } from 'zod'
import { registerUser, verifyUser } from '@/lib/auth'
import { SessionData, sessionOptions } from '@/lib/session'
import { checkRateLimit } from '@/lib/rate-limit'
import { isPhoneUA } from '@/lib/user-agent'
async function getClientIp(): Promise<string> {
const h = await headers()
@ -74,6 +75,13 @@ export async function loginAction(_prevState: unknown, formData: FormData) {
session.isDemo = user.is_demo
await session.save()
// PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell.
// Tablets en desktop volgen het bestaande /dashboard-pad.
const ua = (await headers()).get('user-agent')
if (isPhoneUA(ua)) {
redirect(user.active_product_id ? `/m/products/${user.active_product_id}/solo` : '/m/settings')
}
redirect('/dashboard')
}

View file

@ -1,8 +1,5 @@
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { isPairedSessionExpired } from '@/lib/auth/pairing'
import { requireSession } from '@/lib/auth-guard'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { NavBar } from '@/components/shared/nav-bar'
@ -14,18 +11,7 @@ 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)
if (!session.userId) {
redirect('/login')
}
// ST-1002 (M10): paired-sessies (via QR-pairing) hebben een eigen kortere TTL.
// Vervallen → vernietig en stuur naar /login.
if (isPairedSessionExpired(session)) {
session.destroy()
redirect('/login')
}
const session = await requireSession()
const [user, userRoles, accessibleProducts] = await Promise.all([
prisma.user.findUnique({

40
app/(mobile)/layout.tsx Normal file
View file

@ -0,0 +1,40 @@
// PBI-11 / ST-1134: Mobile shell-layout. Eigen route group (mobile) — nested
// layout in (app)/ kan parent NavBar/StatusBar/MinWidthBanner niet onderdrukken.
// Auth via gedeelde requireSession() (lib/auth-guard.ts), hergebruikt door
// (app)/layout.tsx.
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { requireSession } from '@/lib/auth-guard'
import { LandscapeGuard } from '@/components/mobile/landscape-guard'
import { MobileTabBar } from '@/components/mobile/mobile-tab-bar'
export default async function MobileLayout({ children }: { children: React.ReactNode }) {
const session = await requireSession()
// Active product nodig voor de tab-bar (Backlog/Solo-tabs verbergen als geen actief product).
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: { active_product_id: true },
})
let activeProductId: string | null = null
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 },
})
activeProductId = product?.id ?? null
}
return (
<div className="h-screen bg-background flex flex-col overflow-hidden">
<LandscapeGuard>
<main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0 pb-14">
{children}
</main>
<MobileTabBar activeProductId={activeProductId} />
</LandscapeGuard>
</div>
)
}

View file

@ -1,7 +1,8 @@
// ST-1005: Mobiele bevestigingspagina voor de QR-pairing-flow (M10).
//
// Server Component achter de bestaande (app)/layout.tsx auth-guard — onbekende
// mobielen worden eerst naar /login gestuurd. Bewust géén searchParams
// Server Component achter de (mobile)/layout.tsx auth-guard (route group
// (mobile) per ST-1134/PBI-11) — onbekende mobielen worden eerst naar /login
// gestuurd. Bewust géén searchParams
// uitlezen: het mobileSecret zit in het URL-fragment (#id=…&s=…), wat alleen
// client-side leesbaar is. De Client Component PairConfirmation parseert
// location.hash en doet de Server Action-calls.

View file

@ -0,0 +1,144 @@
// PBI-11 / ST-1137: Mobile Product Backlog. Wraps de 3-paneel-backlog in de
// mobile-shell. BacklogSplitPane rendert automatisch tab-mode op <1024px
// (uit ST-1116). Cookie-key gescheiden van desktop zodat tab-mode-gebruikers
// de desktop-split niet vervuilen (beslissing C in docs/plans/PBI-11-mobile-shell.md).
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { pbiStatusToApi } from '@/lib/task-status'
import { requireSession } from '@/lib/auth-guard'
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
import { PbiList } from '@/components/backlog/pbi-list'
import { StoryPanel } from '@/components/backlog/story-panel'
import type { Story } from '@/components/backlog/story-panel'
import { TaskPanel } from '@/components/backlog/task-panel'
import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper'
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
interface Props {
params: Promise<{ id: string }>
searchParams: Promise<{ newTask?: string; storyId?: string; editTask?: string }>
}
export default async function MobileProductBacklogPage({ params, searchParams }: Props) {
const { id } = await params
const { newTask, storyId: storyIdParam, editTask } = await searchParams
const closePath = `/m/products/${id}`
const session = await requireSession()
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const pbis = await prisma.pbi.findMany({
where: { product_id: id },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
})
const [stories, tasks] = await Promise.all([
prisma.story.findMany({
where: { product_id: id },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
select: {
id: true,
code: true,
title: true,
description: true,
acceptance_criteria: true,
priority: true,
status: true,
pbi_id: true,
created_at: true,
},
}),
prisma.task.findMany({
where: { story: { pbi: { product_id: id } } },
select: {
id: true,
title: true,
description: true,
priority: true,
status: true,
sort_order: true,
story_id: true,
created_at: true,
},
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
}),
])
const storiesByPbi: Record<string, Story[]> = {}
for (const story of stories) {
if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = []
storiesByPbi[story.pbi_id].push(story)
}
const tasksByStory: Record<string, typeof tasks> = {}
for (const task of tasks) {
if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = []
tasksByStory[task.story_id].push(task)
}
const isDemo = session.isDemo ?? false
return (
<div className="flex flex-col h-full">
<BacklogHydrationWrapper
productId={id}
initialData={{
pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })),
storiesByPbi,
tasksByStory,
}}
>
<BacklogSplitPane
cookieKey={`backlog-${id}-mobile`}
defaultSplit={[20, 45, 35]}
tabLabels={['PBI\'s', 'Stories', 'Taken']}
panes={[
<PbiList
key="pbi"
productId={id}
isDemo={isDemo}
/>,
<StoryPanel
key="story"
productId={id}
isDemo={isDemo}
/>,
<TaskPanel
key="tasks"
productId={id}
isDemo={isDemo}
closePath={closePath}
/>,
]}
/>
</BacklogHydrationWrapper>
{newTask && (
<TaskDialog
storyId={storyIdParam}
productId={id}
closePath={closePath}
isDemo={isDemo}
/>
)}
{editTask && !newTask && (
<Suspense fallback={<TaskDialogSkeleton />}>
<EditTaskLoader
taskId={editTask}
userId={session.userId}
productId={id}
closePath={closePath}
isDemo={isDemo}
/>
</Suspense>
)}
</div>
)
}

View file

@ -0,0 +1,120 @@
// PBI-11 / ST-1138: Mobile Solo Paneel — wraps de bestaande SoloBoard zonder
// content-aanpassingen. 3-koloms-kanban blijft (overflow-x scrollt zijwaarts).
// TaskDetailDialog krijgt full-screen-mobile via gedeelde
// entityDialogContentClasses (beslissing A in docs/plans/PBI-11-mobile-shell.md;
// ingebouwd via ST-1133/T-317).
import { notFound } from 'next/navigation'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { requireSession } from '@/lib/auth-guard'
import { SoloBoard } from '@/components/solo/solo-board'
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
import type { SoloTask } from '@/components/solo/solo-board'
import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet'
interface Props {
params: Promise<{ id: string }>
}
export default async function MobileSoloProductPage({ params }: Props) {
const { id } = await params
const session = await requireSession()
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const sprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' },
})
if (!sprint) {
return (
<div className="flex flex-col h-full">
<NoActiveSprint productId={id} productName={product.name} />
</div>
)
}
const [rawTasks, rawUnassigned] = await Promise.all([
prisma.task.findMany({
where: {
story: {
sprint_id: sprint.id,
assignee_id: session.userId,
},
},
include: {
story: {
select: {
id: true,
code: true,
title: true,
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
},
},
},
orderBy: [
{ story: { pbi: { priority: 'asc' } } },
{ story: { pbi: { sort_order: 'asc' } } },
{ story: { sort_order: 'asc' } },
{ priority: 'asc' },
{ sort_order: 'asc' },
],
}),
prisma.story.findMany({
where: { sprint_id: sprint.id, assignee_id: null },
select: {
id: true,
code: true,
title: true,
tasks: {
select: { id: true, title: true, description: true, priority: true, status: true },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
orderBy: { sort_order: 'asc' },
}),
])
const tasks: SoloTask[] = rawTasks.map(t => ({
id: t.id,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
priority: t.priority,
sort_order: t.sort_order,
status: t.status as SoloTask['status'],
verify_only: t.verify_only,
verify_required: t.verify_required as SoloTask['verify_required'],
story_id: t.story.id,
story_code: t.story.code,
story_title: t.story.title,
task_code: t.code,
}))
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
id: s.id,
code: s.code,
title: s.title,
tasks: s.tasks.map(t => ({
id: t.id,
title: t.title,
description: t.description,
priority: t.priority,
status: t.status,
})),
}))
return (
<SoloBoard
productId={id}
sprintGoal={sprint.sprint_goal}
tasks={tasks}
unassignedStories={unassignedStories}
isDemo={session.isDemo ?? false}
currentUserId={session.userId}
repoUrl={product.repo_url}
/>
)
}

View file

@ -0,0 +1,92 @@
// PBI-11 / ST-1136: Mobile Settings — read-only account, product-selector,
// QR-pairing-instructie, logout. Eigenlijke productactivering loopt via de
// bestaande setActiveProductAction (ActivateProductButton).
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { requireSession } from '@/lib/auth-guard'
import { ActivateProductButton } from '@/components/shared/activate-product-button'
import { LogoutButton } from '@/components/mobile/logout-button'
import { Badge } from '@/components/ui/badge'
export const metadata = {
title: 'Settings',
}
export default async function MobileSettingsPage() {
const session = await requireSession()
const [user, products] = await Promise.all([
prisma.user.findUnique({
where: { id: session.userId },
select: { username: true, is_demo: true, active_product_id: true },
}),
prisma.product.findMany({
where: { archived: false, ...productAccessFilter(session.userId) },
orderBy: { name: 'asc' },
select: { id: true, name: true },
}),
])
const isDemo = user?.is_demo ?? false
return (
<div className="px-4 py-6 space-y-6 max-w-md mx-auto w-full">
<h1 className="text-xl font-semibold">Settings</h1>
<section aria-labelledby="account-heading" className="space-y-2">
<h2 id="account-heading" className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Account</h2>
<div className="flex items-center gap-2">
<span className="text-base font-medium">{user?.username ?? '—'}</span>
{isDemo && (
<Badge className="bg-status-todo/15 text-status-todo border-status-todo/30">Demo</Badge>
)}
</div>
</section>
<section aria-labelledby="product-heading" className="space-y-2">
<h2 id="product-heading" className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Actief product</h2>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground">Geen producten beschikbaar.</p>
) : (
<ul className="divide-y divide-border rounded border border-border">
{products.map((p) => {
const active = p.id === user?.active_product_id
return (
<li key={p.id} className="flex items-center justify-between px-3 py-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm truncate">{p.name}</span>
{active && (
<Badge className="bg-primary/15 text-primary border-primary/30">Actief</Badge>
)}
</div>
{!active && (
<ActivateProductButton
productId={p.id}
isDemo={isDemo}
redirectTo={`/m/products/${p.id}/solo`}
label="Activeer"
/>
)}
</li>
)
})}
</ul>
)}
</section>
<section aria-labelledby="qr-heading" className="space-y-2">
<h2 id="qr-heading" className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Inloggen op desktop</h2>
<p className="text-sm text-muted-foreground">
Open <Link href="/login" className="text-primary hover:underline">scrum4me.app/login</Link> op je desktop om in te loggen via QR-code. QR-pairing start vanaf de desktop.
</p>
</section>
<section aria-labelledby="logout-heading" className="space-y-2 pt-2">
<h2 id="logout-heading" className="sr-only">Uitloggen</h2>
<LogoutButton />
</section>
</div>
)
}

View file

@ -0,0 +1,32 @@
'use client'
import { useEffect, useState } from 'react'
import { RotateCw } from 'lucide-react'
export function LandscapeGuard({ children }: { children: React.ReactNode }) {
const [isPortrait, setIsPortrait] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(orientation: portrait)')
const update = () => setIsPortrait(mq.matches)
update()
mq.addEventListener('change', update)
return () => mq.removeEventListener('change', update)
}, [])
return (
<>
{children}
{isPortrait && (
<div
role="alert"
aria-live="assertive"
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 bg-background text-foreground p-6"
>
<RotateCw className="size-12 text-primary" />
<p className="text-base font-medium text-center">Draai je telefoon naar landscape</p>
</div>
)}
</>
)
}

View file

@ -0,0 +1,56 @@
'use client'
import { useState, useTransition } from 'react'
import { LogOut } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { logoutAction } from '@/actions/auth'
export function LogoutButton() {
const [open, setOpen] = useState(false)
const [pending, startTransition] = useTransition()
function confirm() {
startTransition(async () => {
await logoutAction()
})
}
return (
<>
<Button
variant="outline"
onClick={() => setOpen(true)}
className="w-full justify-center gap-2"
>
<LogOut className="size-4" aria-hidden="true" />
Uitloggen
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Uitloggen?</AlertDialogTitle>
<AlertDialogDescription>
Weet je zeker dat je wilt uitloggen?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOpen(false)}>Annuleren</AlertDialogCancel>
<AlertDialogAction disabled={pending} onClick={confirm}>
{pending ? 'Bezig…' : 'Uitloggen'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View file

@ -0,0 +1,68 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ListTree, Activity, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
interface MobileTabBarProps {
activeProductId: string | null
}
export function MobileTabBar({ activeProductId }: MobileTabBarProps) {
const pathname = usePathname()
const tabs: Array<{ href: string; icon: typeof ListTree; label: string; match: (p: string) => boolean }> = []
if (activeProductId) {
tabs.push(
{
href: `/m/products/${activeProductId}`,
icon: ListTree,
label: 'Backlog',
match: (p) => p === `/m/products/${activeProductId}`,
},
{
href: `/m/products/${activeProductId}/solo`,
icon: Activity,
label: 'Solo',
match: (p) => p.startsWith(`/m/products/${activeProductId}/solo`),
},
)
}
tabs.push({
href: '/m/settings',
icon: Settings,
label: 'Settings',
match: (p) => p.startsWith('/m/settings'),
})
return (
<nav
aria-label="Hoofdnavigatie"
className="fixed bottom-0 left-0 right-0 z-40 flex border-t border-border bg-surface-container-low"
>
{tabs.map((tab) => {
const Icon = tab.icon
const active = tab.match(pathname)
return (
<Link
key={tab.href}
href={tab.href}
aria-label={tab.label}
aria-current={active ? 'page' : undefined}
className={cn(
'flex-1 h-14 flex items-center justify-center transition-colors',
active
? 'bg-primary-container text-primary-container-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="size-5" aria-hidden="true" />
</Link>
)
})}
</nav>
)
}

View file

@ -3,6 +3,7 @@ import { cn } from '@/lib/utils'
export const entityDialogContentClasses = cn(
'flex flex-col p-0 gap-0',
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
'max-sm:w-screen max-sm:h-screen max-sm:max-h-screen max-sm:max-w-none max-sm:rounded-none',
'sm:max-w-[90vw] sm:max-h-[85vh]',
'lg:max-w-[50vw] lg:min-w-[480px]',
)

View file

@ -15,8 +15,8 @@ scrum4me/
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── register/page.tsx
│ ├── (app)/ # Beschermde routes
│ │ ├── layout.tsx # Auth-check + navigatie
│ ├── (app)/ # Beschermde routes (desktop + tablets)
│ │ ├── layout.tsx # Auth-check (requireSession) + navigatie
│ │ ├── dashboard/page.tsx # Productenlijst
│ │ ├── products/
│ │ │ ├── new/page.tsx
@ -31,6 +31,16 @@ scrum4me/
│ │ └── settings/
│ │ ├── page.tsx # Profiel, account, PB-overzicht, rollen, tokens
│ │ └── tokens/page.tsx
│ ├── (mobile)/ # Mobile-shell route group (telefoon-UA)
│ │ ├── layout.tsx # Auth via gedeelde requireSession; geen NavBar/StatusBar
│ │ └── m/
│ │ ├── settings/page.tsx # Account + product-selector + QR-instructie + logout
│ │ ├── pair/ # QR-pairing (verhuisd uit (app)/ — URL ongewijzigd)
│ │ │ ├── page.tsx
│ │ │ └── pair-confirmation.tsx
│ │ └── products/[id]/
│ │ ├── page.tsx # Mobile Product Backlog (tab-mode op <1024px)
│ │ └── solo/page.tsx # Mobile Solo (3-koloms-kanban)
│ ├── api/ # REST API voor Claude Code
│ │ ├── products/
│ │ │ └── [id]/
@ -54,6 +64,7 @@ scrum4me/
│ ├── sprint/ # Sprint-componenten
│ ├── products/ # ProductForm, TeamManager, ArchiveProductButton
│ ├── settings/ # RoleManager, ProfileEditor, LeaveProductButton
│ ├── mobile/ # LandscapeGuard, MobileTabBar, LogoutButton
│ └── dnd/ # dnd-kit wrappers
├── lib/
│ ├── prisma.ts # Prisma Client singleton
@ -107,6 +118,26 @@ scrum4me/
**Rationale:** De gesplitste schermen met dnd-kit vereisen client-side staat die twee panelen tegelijk aanstuurt. `useState` per component leidt tot prop drilling; Context API veroorzaakt onnodige re-renders bij 60fps drag-events. Zustand's selector-gebaseerde subscriptions updaten alleen de componenten die de gewijzigde slice observeren. De gouden regel: Zustand beheert uitsluitend ephemere UI-staat — nooit server-data. Server-data blijft in Server Components en wordt opgehaald via Prisma.
**Trade-off:** Extra abstractielaag die geïnitialiseerd moet worden vanuit server-data. Opgelost via een patroon waarbij het Server Component de initiële ids doorgeeft aan een Client Component dat de store hydrateert.
### Beslissing: Eigen route group `(mobile)` voor mobile-shell (PBI-11)
**Keuze:** Telefoon-routes leven onder `app/(mobile)/m/*` met eigen `layout.tsx`, niet als nested directory in `(app)/m/*`.
**Rationale:** Next.js layouts erven naar binnen — een nested layout in `(app)/m/` zou de NavBar/StatusBar/MinWidthBanner/SoloRealtimeBridge/NotificationsBridge erven van `(app)/layout.tsx` zonder die te kunnen onderdrukken. De mobile-shell heeft die chrome niet nodig (alleen bottom-tab-bar). Een eigen route group geeft een schone parent-layout. De auth-check is geëxtraheerd naar `lib/auth-guard.ts` `requireSession()` zodat `(app)/layout.tsx` en `(mobile)/layout.tsx` dezelfde guard delen.
**Trade-off:** Twee layouts om te onderhouden, maar elk met een duidelijk afgebakende verantwoordelijkheid. Content-componenten (PbiList, StoryPanel, TaskPanel, SoloBoard, alle entity-dialogen) blijven volledig gedeeld — geen dubbele implementatie.
### Beslissing: UA-redirect via `Mobi`-substring (PBI-11)
**Keuze:** `lib/user-agent.ts` `isPhoneUA()` test op `Mobi` in de UA-string. `loginAction` (`actions/auth.ts`) leest de header na `session.save()`; phone-UA → `/m/products/[active]/solo` (zonder actief product → `/m/settings`); tablet-UA en desktop → `/dashboard`.
**Rationale:** `Mobi` is de standaard-heuristiek — aanwezig in iPhone Safari Mobile en Android Chrome op telefoons, afwezig op iPad en Android-tablet. Exact wat we willen: alleen telefoons krijgen de mobile-shell, tablets behouden de desktop-flow.
**Trade-off:** Heuristieken zijn nooit 100%; wie via een mobile-emulatie (DevTools) wil testen kan UA spoofen.
### Beslissing: Gedeelde `entityDialogContentClasses` voor mobile-fullscreen (PBI-11)
**Keuze:** Eén Tailwind-class-string in `components/shared/entity-dialog-layout.ts` met `max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none` dekt alle entity-dialogen (PbiDialog, StoryDialog, TaskDialog, TaskDetailDialog).
**Rationale:** Dialog-fullscreen op mobile op vier plekken bewaken zou drift introduceren. De gedeelde constant geeft één bron van waarheid. Het regressie-vangnet (`__tests__/components/shared/entity-dialog-layout.test.ts`) verifieert dat elke dialog deze constant blijft gebruiken.
**Trade-off:** Eén dialog kan niet afwijken zonder de constant te verlaten — bewuste keuze voor consistentie.
### Beslissing: Gescheiden SplitPane cookie-key voor mobile (PBI-11)
**Keuze:** `BacklogSplitPane` op `app/(mobile)/m/products/[id]/page.tsx` gebruikt `cookieKey={\`backlog-${id}-mobile\`}` (versus desktop `backlog-${id}`).
**Rationale:** Op mobile rendert de `SplitPane` in tab-mode (`<1024px`), waar split-percentages niet aangepast worden. Zonder gescheiden key zou dezelfde cookie hergebruikt worden — telefoon-rotaties of orientatie-wisselingen hadden anders ongewenste interactie met de desktop-split-state.
**Trade-off:** Gebruikers die zowel mobile als desktop gebruiken hebben twee onafhankelijke split-instellingen, wat juist gewenst is.
---
## Zustand stores

View file

@ -27,7 +27,7 @@ v1 is een desktop-first fullstack webapplicatie waarmee een solo developer of kl
- Integratie met externe tools (GitHub Issues, Linear, Jira) — v2
- Notificaties en reminders — v2
- Native mobiele app — web-first; een toekomstige mobiele variant richt zich uitsluitend op taken afvinken
- Responsive layout voor schermen smaller dan 1024px — desktop-first in v1
- Responsive layout voor schermen smaller dan 1024px — desktop-first hoofdpad. Voor telefoons (UA met `Mobi`) is er een aparte mobile-shell onder `/m/*` met drie schermen — zie sectie *Mobile shell* hieronder.
---
@ -534,10 +534,44 @@ De app is deployable op Vercel + Neon PostgreSQL en lokaal draaibaar met een Neo
/todos (todo-lijst)
/settings (profiel, account, product backlogs, rollen, API-tokens)
/settings/tokens (API-tokenbeheer)
# Mobile-shell (telefoon-UA)
/m/settings (account + product-selector + QR-instructie + logout)
/m/products/:id (Product Backlog — tab-mode op <1024px)
/m/products/:id/solo (Solo Paneel — 3-koloms-kanban met horizontal scroll)
/m/pair (QR-pairing bevestiging — verhuisd uit (app)/ naar (mobile)/)
```
---
## Mobile shell
**Prioriteit:** v1 — voor on-the-go gebruik (PBI-11)
**Persona:** Lars onderweg / tussendoor
**Omschrijving:**
Telefoon-gebruikers (UA met `Mobi`-substring) krijgen een minimale mobile-shell met drie schermen onder `/m/*`. Tablets (iPad, Android-tablet zonder `Mobi`) en desktop blijven het bestaande `/dashboard`-pad volgen. De mobile-shell hergebruikt zoveel mogelijk content-componenten van de desktop-app (PbiList, StoryPanel, TaskPanel, SoloBoard, alle entity-dialogen) — er is geen aparte mobile-implementatie van de business-logica.
**Architectuur in één regel:** eigen route group `app/(mobile)/` met eigen `layout.tsx` (zonder NavBar/StatusBar/MinWidthBanner) — een nested layout in `(app)/m/*` zou de NavBar erven. Auth via gedeelde `lib/auth-guard.ts` `requireSession()`. Zie [`docs/architecture/project-structure.md`](../architecture/project-structure.md) voor de volledige architectuur.
**Acceptatiecriteria:**
- [ ] Phone-UA bij login → `/m/products/[active]/solo` (zonder actief product → `/m/settings`)
- [ ] Tablet-UA en desktop-UA blijven naar `/dashboard`
- [ ] `/m/*` rendert geen NavBar, AppIcon, MinWidthBanner of StatusBar — alleen tab-bar onderaan
- [ ] Portrait-modus toont rotate-overlay; landscape verbergt overlay
- [ ] PWA-manifest verzoekt `landscape`-orientatie (iOS Safari kan dit niet 100% afdwingen — CSS-overlay als fallback)
- [ ] Tab-bar onderaan: Backlog (ListTree), Solo (Activity), Settings — alleen iconen, geen labels, tap-target ≥44×44px
- [ ] Backlog op `<1024px` rendert in tab-mode (tabs: PBI's | Stories | Taken) met click-cascade auto-switch
- [ ] Entity-dialogen (PBI, Story, Task, Task-detail) renderen full-screen op `<640px` via gedeelde `entityDialogContentClasses`
- [ ] Solo-paneel behoudt 3-koloms-kanban met horizontal scroll (geen 1-koloms-mode)
- [ ] Settings: account-info read-only, product-selector activeert + redirect, QR-instructie naar desktop, logout met bevestiging
- [ ] `/m/pair` (QR-pairing-bevestiging) blijft werken — alleen filesystem-locatie verhuisd, URL onveranderd
- [ ] Demo-user op mobile: read-only werkt; logout staat toe
**Bekende limiet:** iOS Safari respecteert `manifest.orientation` niet altijd in PWA-modus — de CSS-overlay (`<LandscapeGuard>`) is de feitelijke afdwinging.
---
## Datamodel (schets)
| Entiteit | Sleutelvelden | Relaties / opmerkingen |

24
lib/auth-guard.ts Normal file
View file

@ -0,0 +1,24 @@
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { isPairedSessionExpired } from '@/lib/auth/pairing'
/**
* Layout-side auth guard. Returns the session when valid; otherwise redirects
* to /login (and destroys an expired paired-session first).
*
* Used by both `app/(app)/layout.tsx` (desktop) and `app/(mobile)/layout.tsx`.
*/
export async function requireSession() {
const session = await getSession()
if (!session.userId) {
redirect('/login')
}
if (isPairedSessionExpired(session)) {
await session.destroy()
redirect('/login')
}
return session
}

8
lib/user-agent.ts Normal file
View file

@ -0,0 +1,8 @@
// PBI-11 / ST-1135: detecteert telefoon-UA's voor login-redirect.
// Heuristiek: 'Mobi' in de UA-string. Zit in Android Chrome en iPhone Safari
// Mobile, NIET in iPad of Android-tablet — exact wat we willen voor de
// `/m/*`-mobile-shell (alleen telefoons, geen tablets).
export function isPhoneUA(ua: string | null): boolean {
return ua !== null && ua.includes('Mobi')
}

View file

@ -4,6 +4,7 @@
"description": "Lichtgewicht Scrum-planner voor solo developers en kleine teams",
"start_url": "/dashboard",
"display": "standalone",
"orientation": "landscape",
"background_color": "#0d0a14",
"theme_color": "#7c3aed",
"icons": [