From e68552bcfd028708b50db6b51c12aba592da6ef0 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 09:51:48 +0200 Subject: [PATCH 1/9] feat(ST-1134): LandscapeGuard component (T-319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toont rotate-overlay in portrait, niets in landscape. Kinderen blijven altijd in DOM — geen unmount zodat SSE-streams overleven bij rotatie. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mobile/landscape-guard.test.tsx | 73 +++++++++++++++++++ components/mobile/landscape-guard.tsx | 32 ++++++++ 2 files changed, 105 insertions(+) create mode 100644 __tests__/components/mobile/landscape-guard.test.tsx create mode 100644 components/mobile/landscape-guard.tsx diff --git a/__tests__/components/mobile/landscape-guard.test.tsx b/__tests__/components/mobile/landscape-guard.test.tsx new file mode 100644 index 0000000..ca3a4d1 --- /dev/null +++ b/__tests__/components/mobile/landscape-guard.test.tsx @@ -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(
kids
) + expect(screen.getByText('kids')).toBeTruthy() + }) + + it('shows overlay in portrait', () => { + mockMatchMedia(true) + render(
kids
) + 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(
kids
) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('toggles overlay on orientation change', () => { + const ctl = mockMatchMedia(false) + render(
kids
) + expect(screen.queryByRole('alert')).toBeNull() + act(() => ctl.setPortrait(true)) + expect(screen.getByRole('alert')).toBeTruthy() + act(() => ctl.setPortrait(false)) + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/components/mobile/landscape-guard.tsx b/components/mobile/landscape-guard.tsx new file mode 100644 index 0000000..339d67d --- /dev/null +++ b/components/mobile/landscape-guard.tsx @@ -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 && ( +
+ +

Draai je telefoon naar landscape

+
+ )} + + ) +} From 47d57a0963c4f2509d10eb380d3d656b4bfb5308 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 09:52:33 +0200 Subject: [PATCH 2/9] feat(ST-1134): MobileTabBar component (T-320) Bottom-fixed nav-bar met 3 lucide-iconen (ListTree/Activity/Settings). Verbergt Backlog/Solo-tabs als activeProductId null is. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/mobile/mobile-tab-bar.test.tsx | 57 ++++++++++++++++ components/mobile/mobile-tab-bar.tsx | 68 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 __tests__/components/mobile/mobile-tab-bar.test.tsx create mode 100644 components/mobile/mobile-tab-bar.tsx diff --git a/__tests__/components/mobile/mobile-tab-bar.test.tsx b/__tests__/components/mobile/mobile-tab-bar.test.tsx new file mode 100644 index 0000000..66d6170 --- /dev/null +++ b/__tests__/components/mobile/mobile-tab-bar.test.tsx @@ -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() + 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() + 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() + 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() + 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() + expect(screen.getByLabelText('Settings').getAttribute('aria-current')).toBe('page') + }) + + it('tap-targets >=44x44 (h-14 = 56px breedtevulling per tab)', () => { + setPathname('/m/products/p1') + render() + const tab = screen.getByLabelText('Backlog') + expect(tab.className).toContain('h-14') + expect(tab.className).toContain('flex-1') + }) +}) diff --git a/components/mobile/mobile-tab-bar.tsx b/components/mobile/mobile-tab-bar.tsx new file mode 100644 index 0000000..5845f81 --- /dev/null +++ b/components/mobile/mobile-tab-bar.tsx @@ -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 ( + + ) +} From 7b32fc60e60f28726473a788334183c07f3fad33 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 09:55:18 +0200 Subject: [PATCH 3/9] feat(ST-1134): (mobile) route group + auth-guard helper + manifest (T-321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/auth-guard.ts (nieuw): requireSession() — gedeelde auth+paired-expiry guard, hergebruikt door (app)/layout.tsx - (app)/layout.tsx: refactor naar requireSession() (gedraagt zich identiek) - (mobile)/layout.tsx (nieuw): minimal layout met LandscapeGuard + MobileTabBar; geen NavBar/StatusBar/MinWidthBanner/bridges - /m/pair filesystem-move van (app)/ naar (mobile)/ — URL onveranderd - public/manifest.json: orientation landscape - Tests: requireSession-helper (3 paden) Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/lib/auth-guard.test.ts | 49 +++++++++++++++++++ app/(app)/layout.tsx | 18 +------ app/(mobile)/layout.tsx | 40 +++++++++++++++ app/{(app) => (mobile)}/m/pair/page.tsx | 5 +- .../m/pair/pair-confirmation.tsx | 0 lib/auth-guard.ts | 24 +++++++++ public/manifest.json | 1 + 7 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 __tests__/lib/auth-guard.test.ts create mode 100644 app/(mobile)/layout.tsx rename app/{(app) => (mobile)}/m/pair/page.tsx (79%) rename app/{(app) => (mobile)}/m/pair/pair-confirmation.tsx (100%) create mode 100644 lib/auth-guard.ts diff --git a/__tests__/lib/auth-guard.test.ts b/__tests__/lib/auth-guard.test.ts new file mode 100644 index 0000000..b162921 --- /dev/null +++ b/__tests__/lib/auth-guard.test.ts @@ -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() + }) +}) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 384828b..8f55e55 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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(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({ diff --git a/app/(mobile)/layout.tsx b/app/(mobile)/layout.tsx new file mode 100644 index 0000000..2f067dc --- /dev/null +++ b/app/(mobile)/layout.tsx @@ -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 ( +
+ +
+ {children} +
+ +
+
+ ) +} diff --git a/app/(app)/m/pair/page.tsx b/app/(mobile)/m/pair/page.tsx similarity index 79% rename from app/(app)/m/pair/page.tsx rename to app/(mobile)/m/pair/page.tsx index ee665c2..ba7430a 100644 --- a/app/(app)/m/pair/page.tsx +++ b/app/(mobile)/m/pair/page.tsx @@ -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. diff --git a/app/(app)/m/pair/pair-confirmation.tsx b/app/(mobile)/m/pair/pair-confirmation.tsx similarity index 100% rename from app/(app)/m/pair/pair-confirmation.tsx rename to app/(mobile)/m/pair/pair-confirmation.tsx diff --git a/lib/auth-guard.ts b/lib/auth-guard.ts new file mode 100644 index 0000000..8b6baf5 --- /dev/null +++ b/lib/auth-guard.ts @@ -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 +} diff --git a/public/manifest.json b/public/manifest.json index b21a14f..aeb6cf0 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -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": [ From 479a502dfd890de72dcacc3958311d60d82fb4ad Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 10:06:21 +0200 Subject: [PATCH 4/9] =?UTF-8?q?feat(ST-1133):=20entityDialogContentClasses?= =?UTF-8?q?=20=E2=86=92=20full-screen=20op=20<640px=20(T-316/T-317/T-318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eén edit op de gedeelde constant dekt PbiDialog, StoryDialog, TaskDialog, TaskDetailDialog (allen renderen DialogContent met dezelfde className). Toegevoegd: max-sm:w-screen max-sm:h-screen max-sm:max-h-screen max-sm:max-w-none max-sm:rounded-none. Desktop-classes (sm:max-w-[90vw], lg:max-w-[50vw]) blijven onveranderd. Tests: smoke op constant + regressie-vangnet dat de 4 entity-dialogen entityDialogContentClasses blijven gebruiken. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shared/entity-dialog-layout.test.ts | 38 +++++++++++++++++++ components/shared/entity-dialog-layout.ts | 1 + 2 files changed, 39 insertions(+) create mode 100644 __tests__/components/shared/entity-dialog-layout.test.ts diff --git a/__tests__/components/shared/entity-dialog-layout.test.ts b/__tests__/components/shared/entity-dialog-layout.test.ts new file mode 100644 index 0000000..294ed3a --- /dev/null +++ b/__tests__/components/shared/entity-dialog-layout.test.ts @@ -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') + }) + } +}) diff --git a/components/shared/entity-dialog-layout.ts b/components/shared/entity-dialog-layout.ts index c97ddfb..e70e24e 100644 --- a/components/shared/entity-dialog-layout.ts +++ b/components/shared/entity-dialog-layout.ts @@ -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]', ) From 13ab53ab8d8dacabe0b16be65016653914344cc6 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 10:09:09 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat(ST-1135):=20UA-redirect=20bij=20login?= =?UTF-8?q?=20=E2=80=94=20phone=20naar=20/m/*=20(T-322/T-323/T-324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/user-agent.ts (nieuw): isPhoneUA() — Mobi-substring heuristiek (telefoons hebben Mobi, tablets/desktop niet) - actions/auth.ts loginAction: leest user-agent header na session.save(); phone-UA + actief product → /m/products/[id]/solo, zonder → /m/settings; tablet/desktop/null-UA → /dashboard (ongewijzigd) - Tests: 7 helper-cases + 6 loginAction-paden incl. demo-user Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/auth.test.ts | 85 ++++++++++++++++++++++++++++++++ __tests__/lib/user-agent.test.ts | 37 ++++++++++++++ actions/auth.ts | 8 +++ lib/user-agent.ts | 8 +++ 4 files changed, 138 insertions(+) create mode 100644 __tests__/actions/auth.test.ts create mode 100644 __tests__/lib/user-agent.test.ts create mode 100644 lib/user-agent.ts diff --git a/__tests__/actions/auth.test.ts b/__tests__/actions/auth.test.ts new file mode 100644 index 0000000..424e31b --- /dev/null +++ b/__tests__/actions/auth.test.ts @@ -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') + }) +}) diff --git a/__tests__/lib/user-agent.test.ts b/__tests__/lib/user-agent.test.ts new file mode 100644 index 0000000..f322b5c --- /dev/null +++ b/__tests__/lib/user-agent.test.ts @@ -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) + }) +}) diff --git a/actions/auth.ts b/actions/auth.ts index fc4a163..575178e 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -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 { 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') } diff --git a/lib/user-agent.ts b/lib/user-agent.ts new file mode 100644 index 0000000..6fc1836 --- /dev/null +++ b/lib/user-agent.ts @@ -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') +} From 0a3dc401b759221f3409ed41c48ffdc2fc061299 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 10:12:19 +0200 Subject: [PATCH 6/9] feat(ST-1136): mobile Settings-pagina + LogoutButton (T-325/T-326/T-327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/(mobile)/m/settings/page.tsx — read-only account-info, product-selector (hergebruikt ActivateProductButton + setActiveProductAction met redirectTo /m/products/[id]/solo), QR-pairing-instructie, logout - components/mobile/logout-button.tsx — AlertDialog "Uitloggen?" met bevestig + annuleer; demo-user mag uitloggen (geen demo-block) - Tests: LogoutButton render + open + bevestig (logoutAction) + annuleer Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/mobile/logout-button.test.tsx | 46 ++++++++++ app/(mobile)/m/settings/page.tsx | 92 +++++++++++++++++++ components/mobile/logout-button.tsx | 56 +++++++++++ 3 files changed, 194 insertions(+) create mode 100644 __tests__/components/mobile/logout-button.test.tsx create mode 100644 app/(mobile)/m/settings/page.tsx create mode 100644 components/mobile/logout-button.tsx diff --git a/__tests__/components/mobile/logout-button.test.tsx b/__tests__/components/mobile/logout-button.test.tsx new file mode 100644 index 0000000..bffa8fe --- /dev/null +++ b/__tests__/components/mobile/logout-button.test.tsx @@ -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() + expect(screen.getByRole('button', { name: /Uitloggen/ })).toBeTruthy() + expect(screen.queryByText(/Weet je zeker/)).toBeNull() + }) + + it('opent AlertDialog bij klikken op de knop', () => { + render() + 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() + 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() + fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ })) + fireEvent.click(screen.getByText('Annuleren')) + expect(logoutMock).not.toHaveBeenCalled() + }) +}) diff --git a/app/(mobile)/m/settings/page.tsx b/app/(mobile)/m/settings/page.tsx new file mode 100644 index 0000000..d1a7070 --- /dev/null +++ b/app/(mobile)/m/settings/page.tsx @@ -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 ( +
+

Settings

+ +
+

Account

+
+ {user?.username ?? '—'} + {isDemo && ( + Demo + )} +
+
+ +
+

Actief product

+ {products.length === 0 ? ( +

Geen producten beschikbaar.

+ ) : ( +
    + {products.map((p) => { + const active = p.id === user?.active_product_id + return ( +
  • +
    + {p.name} + {active && ( + Actief + )} +
    + {!active && ( + + )} +
  • + ) + })} +
+ )} +
+ +
+

Inloggen op desktop

+

+ Open scrum4me.app/login op je desktop om in te loggen via QR-code. QR-pairing start vanaf de desktop. +

+
+ +
+

Uitloggen

+ +
+
+ ) +} diff --git a/components/mobile/logout-button.tsx b/components/mobile/logout-button.tsx new file mode 100644 index 0000000..82f0819 --- /dev/null +++ b/components/mobile/logout-button.tsx @@ -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 ( + <> + + + + + Uitloggen? + + Weet je zeker dat je wilt uitloggen? + + + + setOpen(false)}>Annuleren + + {pending ? 'Bezig…' : 'Uitloggen'} + + + + + + ) +} From 5b4274046129d30d26907e4c819a79c73b7b423e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 10:14:20 +0200 Subject: [PATCH 7/9] feat(ST-1137): mobile Product Backlog-pagina (T-328/T-329/T-330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/(mobile)/m/products/[id]/page.tsx — hergebruikt BacklogHydrationWrapper + BacklogSplitPane + PbiList/StoryPanel/TaskPanel (1:1 zelfde data-fetch als desktop-page; demo blijft read-only via PbiList/StoryPanel) - Cookie-key gescheiden: `backlog-${id}-mobile` (beslissing C in docs/plans/PBI-11-mobile-shell.md) — tab-mode-gebruikers vervuilen de desktop-split-percentages niet - closePath en redirect-targets blijven onder /m/products/ - Tab-mode rendert automatisch op <1024px via SplitPane (uit ST-1116) - Tests: regressie-vangnet op cookie-key, /m/-paden, hergebruik Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/app/m-products-page.test.ts | 38 +++++++ app/(mobile)/m/products/[id]/page.tsx | 144 ++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 __tests__/app/m-products-page.test.ts create mode 100644 app/(mobile)/m/products/[id]/page.tsx diff --git a/__tests__/app/m-products-page.test.ts b/__tests__/app/m-products-page.test.ts new file mode 100644 index 0000000..8fd22c5 --- /dev/null +++ b/__tests__/app/m-products-page.test.ts @@ -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 &&') + }) +}) diff --git a/app/(mobile)/m/products/[id]/page.tsx b/app/(mobile)/m/products/[id]/page.tsx new file mode 100644 index 0000000..136188d --- /dev/null +++ b/app/(mobile)/m/products/[id]/page.tsx @@ -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 = {} + for (const story of stories) { + if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] + storiesByPbi[story.pbi_id].push(story) + } + + const tasksByStory: Record = {} + 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 ( +
+ ({ 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, + }} + > + , + , + , + ]} + /> + + + {newTask && ( + + )} + + {editTask && !newTask && ( + }> + + + )} +
+ ) +} From b327fbdf09da5630ca54687afa21f59fe6a938b3 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 10:52:41 +0200 Subject: [PATCH 8/9] feat(ST-1138): mobile Solo-pagina + verify TaskDetailDialog (T-331/T-332/T-333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/(mobile)/m/products/[id]/solo/page.tsx — hergebruikt SoloBoard 1:1 met desktop. 3-koloms-kanban blijft, NoActiveSprint-fallback ongewijzigd - T-332 verify-only: TaskDetailDialog regel 383 gebruikt entityDialogContentClasses → mobile-fullscreen erft automatisch uit ST-1133 - Tests: regressie-vangnet op SoloBoard-hergebruik, requireSession, NoActiveSprint, en op TaskDetailDialog-className-wiring (geen override) Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/app/m-solo-page.test.ts | 35 ++++++ app/(mobile)/m/products/[id]/solo/page.tsx | 120 +++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 __tests__/app/m-solo-page.test.ts create mode 100644 app/(mobile)/m/products/[id]/solo/page.tsx diff --git a/__tests__/app/m-solo-page.test.ts b/__tests__/app/m-solo-page.test.ts new file mode 100644 index 0000000..a464eb2 --- /dev/null +++ b/__tests__/app/m-solo-page.test.ts @@ -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}') + }) +}) diff --git a/app/(mobile)/m/products/[id]/solo/page.tsx b/app/(mobile)/m/products/[id]/solo/page.tsx new file mode 100644 index 0000000..ce8aa19 --- /dev/null +++ b/app/(mobile)/m/products/[id]/solo/page.tsx @@ -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 ( +
+ +
+ ) + } + + 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 ( + + ) +} From 19724eac5aa972f779d54f9fe53b64e39b9e210b Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 10:56:15 +0200 Subject: [PATCH 9/9] docs(ST-1139): mobile-shell sync in functional spec + architectuur (T-334/T-335/T-336) - docs/specs/functional.md: nieuwe sectie "Mobile shell" met routestructuur, acceptance-criteria, bekende iOS-limiet; desktop-first-clausule herzien naar "desktop-first hoofdpad + mobile-shell voor /m/*" - docs/architecture/project-structure.md: route-tree onder app/(mobile)/, components/mobile/ in tree, vier nieuwe sleutelbeslissingen (route group, UA-redirect, gedeelde dialog-classes, gescheiden cookie-key) - docs/INDEX.md regenerated, doc-links 86/86 valid - T-336 E2E: lint/test/build groen; manuele DevTools/PWA-checks gelogd Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/architecture/project-structure.md | 35 +++++++++++++++++++++++-- docs/specs/functional.md | 36 +++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/docs/architecture/project-structure.md b/docs/architecture/project-structure.md index bce5d7a..453bb47 100644 --- a/docs/architecture/project-structure.md +++ b/docs/architecture/project-structure.md @@ -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 diff --git a/docs/specs/functional.md b/docs/specs/functional.md index 4674dd7..f405ce0 100644 --- a/docs/specs/functional.md +++ b/docs/specs/functional.md @@ -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 (``) is de feitelijke afdwinging. + +--- + ## Datamodel (schets) | Entiteit | Sleutelvelden | Relaties / opmerkingen |