diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 1a5b3b9..fa41d4a 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -9,6 +9,7 @@ import { NavBar } from '@/components/shared/nav-bar' import { MinWidthBanner } from '@/components/shared/min-width-banner' import { StatusBar } from '@/components/shared/status-bar' import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge' +import { NotificationsBridge } from '@/components/notifications/notifications-bridge' import { AlertToast } from '@/components/shared/alert-toast' import { Suspense } from 'react' @@ -92,6 +93,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod + diff --git a/components/notifications/answer-modal.tsx b/components/notifications/answer-modal.tsx new file mode 100644 index 0000000..cbe574d --- /dev/null +++ b/components/notifications/answer-modal.tsx @@ -0,0 +1,157 @@ +'use client' + +// ST-1105: Modal waar de gebruiker een Claude-vraag beantwoordt (M11). +// +// Free-text Textarea (max 4000) of multiple-choice via knoppen wanneer de +// vraag `options` heeft. Submit roept answerQuestion-Server-Action aan via +// useTransition; bij succes wordt de vraag uit de store verwijderd +// (optimistisch) en sluit de modal. Demo-modus: textarea readOnly + submit +// disabled met tooltip. + +import { useState, useTransition } from 'react' +import Link from 'next/link' +import { ExternalLink } from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { answerQuestion } from '@/actions/questions' +import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' + +const MAX_ANSWER_CHARS = 4000 + +interface AnswerModalProps { + question: NotificationQuestion | null + isDemo: boolean + onClose: () => void +} + +export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) { + const [answer, setAnswer] = useState('') + const [pending, startTransition] = useTransition() + + if (!question) return null + + const charsLeft = MAX_ANSWER_CHARS - answer.length + const tooLong = charsLeft < 0 + const submitDisabled = isDemo || pending || answer.trim().length === 0 || tooLong + + function submit(text: string) { + if (!question) return + if (isDemo) { + toast.error('Niet beschikbaar in demo-modus') + return + } + startTransition(async () => { + const res = await answerQuestion(question.id, text) + if (!res.ok) { + toast.error(res.error) + return + } + // Optimistisch verwijderen — SSE-event komt anders later met dezelfde + // remove en kost een extra render + useNotificationsStore.getState().remove(question.id) + toast.success('Antwoord verstuurd') + setAnswer('') + onClose() + }) + } + + return ( + !open && onClose()}> + + + Beantwoord Claude + + {question.story_code ?? 'story'} + {' — '} + {question.story_title} + + + + + Open in Sprint + + + + {question.question} + + + {question.options && question.options.length > 0 ? ( + + Kies een van de opties: + + {question.options.map((opt) => ( + submit(opt)} + > + {opt} + + ))} + + + ) : ( + + setAnswer(e.target.value)} + placeholder="Typ je antwoord…" + rows={5} + maxLength={MAX_ANSWER_CHARS} + readOnly={isDemo} + aria-label="Antwoord op Claude's vraag" + /> + + {charsLeft} tekens over + + + )} + + + + Annuleren + + {(!question.options || question.options.length === 0) && ( + + + }> + submit(answer)} + disabled={submitDisabled} + > + {pending ? 'Bezig…' : 'Verstuur'} + + + {isDemo && ( + Niet beschikbaar in demo-modus + )} + + + )} + + + + ) +} diff --git a/components/notifications/notifications-bridge.tsx b/components/notifications/notifications-bridge.tsx new file mode 100644 index 0000000..11128b2 --- /dev/null +++ b/components/notifications/notifications-bridge.tsx @@ -0,0 +1,62 @@ +// ST-1105: Mount-component voor de notifications-realtime hook (M11). +// +// Server Component dat de initial open-questions fetch't met +// productAccessFilter en doorgeeft aan een minimal client-island; client opent +// daarna de SSE-stream voor live updates. + +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { NotificationsRealtimeMount } from './notifications-realtime-mount' +import type { NotificationQuestion } from '@/stores/notifications-store' + +interface NotificationsBridgeProps { + userId: string +} + +export async function NotificationsBridge({ userId }: NotificationsBridgeProps) { + const products = await prisma.product.findMany({ + where: { archived: false, ...productAccessFilter(userId) }, + select: { id: true }, + }) + const productIds = products.map((p) => p.id) + + const openQuestions = + productIds.length === 0 + ? [] + : await prisma.claudeQuestion.findMany({ + where: { + status: 'open', + expires_at: { gt: new Date() }, + product_id: { in: productIds }, + }, + orderBy: { created_at: 'desc' }, + take: 100, + select: { + id: true, + product_id: true, + story_id: true, + task_id: true, + question: true, + options: true, + created_at: true, + expires_at: true, + story: { select: { code: true, title: true, assignee_id: true } }, + }, + }) + + const initial: NotificationQuestion[] = openQuestions.map((q) => ({ + id: q.id, + product_id: q.product_id, + story_id: q.story_id, + task_id: q.task_id, + story_code: q.story.code, + story_title: q.story.title, + assignee_id: q.story.assignee_id, + question: q.question, + options: Array.isArray(q.options) ? (q.options as string[]) : null, + created_at: q.created_at.toISOString(), + expires_at: q.expires_at.toISOString(), + })) + + return +} diff --git a/components/notifications/notifications-realtime-mount.tsx b/components/notifications/notifications-realtime-mount.tsx new file mode 100644 index 0000000..fa75550 --- /dev/null +++ b/components/notifications/notifications-realtime-mount.tsx @@ -0,0 +1,23 @@ +// ST-1105: Tiny client island dat de notifications-store hydrateert met +// server-side fetched initial questions en de SSE-realtime hook activeert. + +'use client' + +import { useEffect } from 'react' +import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' +import { useNotificationsRealtime } from '@/lib/realtime/use-notifications-realtime' + +interface Props { + initial: NotificationQuestion[] +} + +export function NotificationsRealtimeMount({ initial }: Props) { + // Hydrate de store met server-side-rendered data zodat de bell-count direct + // klopt zonder te wachten op de SSE state-event. + useEffect(() => { + useNotificationsStore.getState().init(initial) + }, [initial]) + + useNotificationsRealtime() + return null +} diff --git a/components/notifications/notifications-sheet.tsx b/components/notifications/notifications-sheet.tsx new file mode 100644 index 0000000..cd617da --- /dev/null +++ b/components/notifications/notifications-sheet.tsx @@ -0,0 +1,106 @@ +'use client' + +// ST-1105: Slide-over (rechts) met de lijst van openstaande Claude-vragen (M11). +// +// Story-assignee = currentUser krijgt een primary-container accent ("voor jou"). +// Klik op een item opent de AnswerModal voor die specifieke vraag. Sheet blijft +// open na een succesvol antwoord zodat meerdere antwoorden achter elkaar kunnen. + +import { useState } from 'react' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet' +import { useNotificationsStore } from '@/stores/notifications-store' +import { AnswerModal } from './answer-modal' +import { cn } from '@/lib/utils' +import type { NotificationQuestion } from '@/stores/notifications-store' + +interface NotificationsSheetProps { + trigger: React.ReactNode + currentUserId: string + isDemo: boolean +} + +export function NotificationsSheet({ + trigger, + currentUserId, + isDemo, +}: NotificationsSheetProps) { + const [open, setOpen] = useState(false) + const [activeQuestion, setActiveQuestion] = useState(null) + const questions = useNotificationsStore((s) => s.questions) + + return ( + <> + + + + + Vragen van Claude ({questions.length}) + + Beantwoord open vragen om Claude verder te laten werken. + + + + {questions.length === 0 ? ( + + Geen openstaande vragen. Lekker bezig! + + ) : ( + + {questions.map((q) => { + const forYou = q.assignee_id === currentUserId + return ( + + setActiveQuestion(q)} + className={cn( + 'border-border w-full rounded-md border p-3 text-left transition-colors hover:bg-surface-container', + forYou && + 'bg-primary-container text-primary-container-foreground border-primary/30 hover:bg-primary-container/80', + )} + > + + + {q.story_code ?? '—'} + + + {q.story_title} + + + + {q.question} + + {forYou && ( + + Voor jou + + )} + + + ) + })} + + )} + + + + setActiveQuestion(null)} + /> + > + ) +} diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index 9f38668..d56cf99 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -16,6 +16,7 @@ import { } from '@/components/ui/dropdown-menu' import { AppIcon } from '@/components/shared/app-icon' import { UserMenu } from '@/components/shared/user-menu' +import { NotificationsBell } from '@/components/shared/notifications-bell' import { cn } from '@/lib/utils' import { setActiveProductAction } from '@/actions/active-product' @@ -179,8 +180,9 @@ export function NavBar({ )} - {/* Rechts: account-menu */} + {/* Rechts: notifications + account-menu */} + diff --git a/components/shared/notifications-bell.tsx b/components/shared/notifications-bell.tsx new file mode 100644 index 0000000..8120844 --- /dev/null +++ b/components/shared/notifications-bell.tsx @@ -0,0 +1,54 @@ +'use client' + +// ST-1105: Bell-icon in de NavBar met badge van het aantal open Claude-vragen +// voor de ingelogde gebruiker (M11). +// +// Klik opent . Story-assignee-vragen ("voor jou") worden +// gemarkeerd met een ring-accent op het badge zodat ook bij gelijke totaal- +// count de prioriteit zichtbaar is. + +import { Bell } from 'lucide-react' +import { useNotificationsStore } from '@/stores/notifications-store' +import { NotificationsSheet } from '@/components/notifications/notifications-sheet' +import { cn } from '@/lib/utils' + +interface NotificationsBellProps { + currentUserId: string + isDemo: boolean +} + +export function NotificationsBell({ currentUserId, isDemo }: NotificationsBellProps) { + const total = useNotificationsStore((s) => s.questions.length) + const forYou = useNotificationsStore((s) => + s.questions.filter((q) => q.assignee_id === currentUserId).length, + ) + + return ( + 0 ? `, ${forYou} voor jou` : ''}`} + className="relative inline-flex h-9 w-9 items-center justify-center rounded-full hover:bg-surface-container focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background" + > + + {total > 0 && ( + 0 + ? 'bg-error text-error-foreground ring-2 ring-background' + : 'bg-primary text-primary-foreground', + )} + > + {total > 9 ? '9+' : total} + + )} + + } + /> + ) +} diff --git a/lib/realtime/use-notifications-realtime.ts b/lib/realtime/use-notifications-realtime.ts new file mode 100644 index 0000000..8f58e12 --- /dev/null +++ b/lib/realtime/use-notifications-realtime.ts @@ -0,0 +1,126 @@ +// ST-1105: Client hook die de notificatie-SSE stream beheert (M11). +// +// Mount via in (app)/layout zodat hij Server Action- +// refreshes overleeft. Opent EventSource('/api/realtime/notifications'), +// dispatcht state-/message-events naar de notifications-store. +// +// Vereenvoudigde versie van useSoloRealtime — geen view-transitions, geen +// connecting-indicator-debounce. Wel: reconnect met exponential backoff en +// pause bij hidden tab. + +'use client' + +import { useEffect, useRef } from 'react' +import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' + +const BACKOFF_START_MS = 1_000 +const BACKOFF_MAX_MS = 30_000 + +interface NotifyPayload { + op: 'I' | 'U' + entity: 'question' + id: string + product_id: string + story_id: string + task_id: string | null + assignee_id: string | null + status: 'open' | 'answered' | 'cancelled' | 'expired' +} + +interface StateEvent { + questions: NotificationQuestion[] +} + +export function useNotificationsRealtime() { + const sourceRef = useRef(null) + const backoffRef = useRef(BACKOFF_START_MS) + const reconnectTimerRef = useRef | null>(null) + + useEffect(() => { + const init = useNotificationsStore.getState().init + const remove = useNotificationsStore.getState().remove + + const close = () => { + if (sourceRef.current) { + sourceRef.current.close() + sourceRef.current = null + } + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + } + + const connect = () => { + close() + const source = new EventSource('/api/realtime/notifications', { + withCredentials: true, + }) + sourceRef.current = source + + source.addEventListener('open', () => { + backoffRef.current = BACKOFF_START_MS + }) + + source.addEventListener('state', (ev) => { + try { + const data = JSON.parse((ev as MessageEvent).data) as StateEvent + init(data.questions ?? []) + } catch { + // ignore malformed + } + }) + + source.addEventListener('message', (ev) => { + try { + const payload = JSON.parse(ev.data) as NotifyPayload + if (payload.entity !== 'question') return + // Bij open of nieuwe insert → upsert (server stuurt geen vraag-tekst + // mee in de payload, dus we doen een mini-fetch via de same SSE's + // initial-state on reconnect; hier voor MVP alleen status-handling). + if (payload.status === 'open') { + // Inkomende open vraag: we hebben de details nog niet — beste optie is + // herfetchen door opnieuw te verbinden, of via een API. Voor v1 + // forceren we een reconnect zodat het volgende state-event de + // volledige details meelevert. + close() + connect() + return + } + // Niet-open status (answered/cancelled/expired) → verwijderen uit lijst + remove(payload.id) + } catch { + // ignore malformed + } + }) + + source.addEventListener('error', () => { + // EventSource herconnect zelf bij netwerkfouten; voor server-close + // (after 240s) doen we een eigen backoff + if (sourceRef.current?.readyState === EventSource.CLOSED) { + const delay = backoffRef.current + backoffRef.current = Math.min(delay * 2, BACKOFF_MAX_MS) + reconnectTimerRef.current = setTimeout(connect, delay) + } + }) + } + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + if (!sourceRef.current || sourceRef.current.readyState === EventSource.CLOSED) { + connect() + } + } else { + close() + } + } + + connect() + document.addEventListener('visibilitychange', onVisibilityChange) + + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange) + close() + } + }, []) +} diff --git a/stores/notifications-store.ts b/stores/notifications-store.ts new file mode 100644 index 0000000..7ad4c8a --- /dev/null +++ b/stores/notifications-store.ts @@ -0,0 +1,61 @@ +// ST-1105: Zustand store voor het Claude vraag-antwoord-kanaal (M11). +// +// Houdt de huidige set van **open** vragen voor de ingelogde gebruiker bij — +// gevoed door: +// - Initial-state-event van /api/realtime/notifications (init) +// - Realtime SSE-events op scrum4me_changes (upsert/remove) +// - Server Action answerQuestion (optimistic remove via removeOptimistic) +// +// Eenvoudiger dan solo-store: geen drag-and-drop, geen view-transitions, geen +// pending-ops om optimistic-echo te onderdrukken — antwoorden zijn discrete +// acties en de stream kan met dubbele update-events leven. + +import { create } from 'zustand' + +export interface NotificationQuestion { + id: string + product_id: string + story_id: string + task_id: string | null + story_code: string | null + story_title: string + assignee_id: string | null + question: string + options: string[] | null + created_at: string + expires_at: string +} + +interface NotificationsState { + questions: NotificationQuestion[] + init: (qs: NotificationQuestion[]) => void + upsert: (q: NotificationQuestion) => void + remove: (id: string) => void + openCount: () => number + forYouCount: (userId: string | null) => number +} + +export const useNotificationsStore = create((set, get) => ({ + questions: [], + + init: (qs) => set({ questions: qs }), + + upsert: (q) => + set((state) => { + const idx = state.questions.findIndex((x) => x.id === q.id) + if (idx === -1) return { questions: [q, ...state.questions] } + const next = state.questions.slice() + next[idx] = q + return { questions: next } + }), + + remove: (id) => + set((state) => ({ questions: state.questions.filter((q) => q.id !== id) })), + + openCount: () => get().questions.length, + + forYouCount: (userId) => { + if (!userId) return 0 + return get().questions.filter((q) => q.assignee_id === userId).length + }, +}))
Kies een van de opties:
+ {q.question} +