= {}
+ 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 && (
+ }>
+
+
+ )}
+
+ )
+}
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 (
+
+ )
+}
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.
+ ) : (
+
+ )}
+
+
+
+ Inloggen op desktop
+
+ Open scrum4me.app/login op je desktop om in te loggen via QR-code. QR-pairing start vanaf de desktop.
+
+
+
+
+
+ )
+}
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
+
+ )}
+ >
+ )
+}
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'}
+
+
+
+
+ >
+ )
+}
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 (
+
+ )
+}
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]',
)
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 |
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/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')
+}
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": [