feat: add speech telemetry and bundle guard

This commit is contained in:
colinislit
2025-11-25 13:37:00 +01:00
parent b6c5d1a6d3
commit 7033329e0f
17 changed files with 2258 additions and 481 deletions

View File

@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { createClient } from '@/lib/auth/server'
const payloadSchema = z.object({
action: z.enum(['start', 'stop', 'final']),
context: z.string().min(1),
patientId: z.string().min(1).optional(),
intakeId: z.string().min(1).optional(),
reportId: z.string().min(1).optional(),
metadata: z.record(z.string(), z.any()).optional(),
})
export async function POST(request: Request) {
try {
const json = await request.json()
const payload = payloadSchema.safeParse(json)
if (!payload.success) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
}
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError) throw authError
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { action, context, patientId, intakeId, reportId, metadata } = payload.data
const { error } = await supabase.from('speech_usage_events').insert({
action,
context,
user_id: user.id,
patient_id: patientId ?? null,
intake_id: intakeId ?? null,
report_id: reportId ?? null,
metadata: metadata ?? {},
})
if (error) {
console.error('Failed to persist speech telemetry', error)
return NextResponse.json({ error: 'Database error' }, { status: 500 })
}
return NextResponse.json({ ok: true })
} catch (error) {
console.error('Speech telemetry error', error)
return NextResponse.json({ error: 'Server error' }, { status: 500 })
}
}

View File

@@ -1,11 +1,38 @@
'use client';
import { useState, useTransition } from 'react';
import { useState, useTransition, useCallback } from 'react';
import { saveTreatmentAdvice } from '../../actions';
import { Loader2, Calendar, UserCircle, ClipboardList, Share2, CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
import { RichTextEditor } from '@/components/rich-text-editor';
import { SpeechRecorder } from '@/components/speech-recorder';
import dynamic from 'next/dynamic';
const RichTextEditor = dynamic(
() => import('@/components/rich-text-editor').then((m) => m.RichTextEditor),
{ ssr: false, loading: () => <EditorSkeleton /> }
);
const SpeechRecorderStreaming = dynamic(
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
{ ssr: false, loading: () => <RecorderSkeleton /> }
);
function EditorSkeleton() {
return (
<div className="space-y-2">
<div className="h-8 w-3/4 rounded bg-slate-100 animate-pulse" />
<div className="rounded-lg border border-slate-200 h-32 animate-pulse bg-slate-50" />
</div>
);
}
function RecorderSkeleton() {
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
<div className="h-4 w-1/2 rounded bg-slate-100 mb-2" />
<div className="h-10 rounded bg-slate-100" />
</div>
);
}
interface AdviceData {
advice?: string;
@@ -52,8 +79,10 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
const [finalize, setFinalize] = useState(Boolean(initialData.outcome));
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [isStreaming, setIsStreaming] = useState(false);
const [interimText, setInterimText] = useState('');
const appendTranscript = (text: string) => {
const appendTranscript = useCallback((text: string) => {
if (!text) return;
const sanitized = text
.split('\n')
@@ -67,7 +96,16 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
)
.join('');
setForm((prev) => ({ ...prev, advice: `${prev.advice || ''}${sanitized}` }));
};
}, []);
const handleRecordingStart = useCallback(() => {
setIsStreaming(true);
}, []);
const handleRecordingStop = useCallback(() => {
setIsStreaming(false);
setInterimText('');
}, []);
const handleSubmit = () => {
if (!form.advice) {
@@ -217,7 +255,22 @@ export function TreatmentAdviceForm({ patientId, intakeId, initialData, initialD
</div>
<div className="space-y-3">
<SpeechRecorder onTranscript={appendTranscript} />
<SpeechRecorderStreaming
onTranscript={appendTranscript}
onInterimTranscript={setInterimText}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
telemetryContext={{
context: 'treatment_advice',
patientId,
intakeId,
}}
/>
{interimText && (
<div className="text-sm text-slate-500 italic bg-slate-50 p-2 rounded border border-slate-200">
{interimText}
</div>
)}
<RichTextEditor
value={form.advice}
onChange={(html) => setForm((prev) => ({ ...prev, advice: html }))}

View File

@@ -1,7 +1,26 @@
import { NewIntakeForm } from '../components/new-intake-form';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
const NewIntakeForm = dynamic(
() => import('../components/new-intake-form').then((m) => m.NewIntakeForm),
{ ssr: false, loading: () => <FormSkeleton /> }
);
function FormSkeleton() {
return (
<div className="space-y-4 animate-pulse">
<div className="h-5 w-1/3 rounded bg-slate-200" />
<div className="h-10 rounded bg-slate-100" />
<div className="h-5 w-1/4 rounded bg-slate-200" />
<div className="h-10 rounded bg-slate-100" />
<div className="h-5 w-1/4 rounded bg-slate-200" />
<div className="h-10 rounded bg-slate-100" />
<div className="h-10 rounded bg-slate-200" />
</div>
);
}
interface NewIntakePageProps {
params: Promise<{ id: string }>;
}
@@ -31,4 +50,3 @@ export default async function NewIntakePage({ params }: NewIntakePageProps) {
</div>
);
}

View File

@@ -0,0 +1,240 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import dynamic from 'next/dynamic'
import { ChevronRight, ChevronLeft } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { Report } from '@/lib/types/report'
import { ReportComposer } from './report-composer'
import { QuickActions, type ReportType } from './quick-actions'
import { ReportTimeline } from './report-timeline'
const ReportViewEditModal = dynamic(
() => import('./report-view-edit-modal').then((m) => m.ReportViewEditModal),
{ ssr: false, loading: () => <ModalSkeleton /> }
)
function ModalSkeleton() {
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/30">
<div className="rounded-2xl bg-white/80 p-6 shadow-xl">
<div className="h-4 w-48 rounded bg-slate-200 animate-pulse mb-4" />
<div className="h-32 w-72 rounded bg-slate-100 animate-pulse" />
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
interface RapportageWorkspaceV2Props {
patientId: string
patientName: string
initialReports: Report[]
}
type PanelsModule = typeof import('react-resizable-panels')
// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
export function RapportageWorkspaceV2({
patientId,
patientName,
initialReports,
}: RapportageWorkspaceV2Props) {
const [reports, setReports] = useState(initialReports)
const [selectedType, setSelectedType] = useState<ReportType>('vrije_notitie')
const [selectedReport, setSelectedReport] = useState<Report | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const [duplicateContent, setDuplicateContent] = useState<string | null>(null)
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false)
const [panelsLib, setPanelsLib] = useState<PanelsModule | null>(null)
useEffect(() => {
let active = true
import('react-resizable-panels')
.then((mod) => {
if (active) {
setPanelsLib(mod)
}
})
.catch((error) => {
console.error('Kon react-resizable-panels niet laden', error)
})
return () => {
active = false
}
}, [])
// Handlers
const handleReportCreated = useCallback((report: Report) => {
setReports((prev) => [report, ...prev])
}, [])
const handleSelectReport = useCallback((report: Report) => {
setSelectedReport(report)
setIsModalOpen(true)
}, [])
const handleCloseModal = useCallback(() => {
setIsModalOpen(false)
}, [])
const handleReportUpdated = useCallback((updatedReport: Report) => {
setReports((prev) =>
prev.map((r) => (r.id === updatedReport.id ? updatedReport : r))
)
setSelectedReport(updatedReport)
}, [])
const handleReportDeleted = useCallback((reportId: string) => {
setReports((prev) => prev.filter((r) => r.id !== reportId))
if (selectedReport?.id === reportId) {
setSelectedReport(null)
}
}, [selectedReport])
const handleDuplicate = useCallback((content: string) => {
setDuplicateContent(content)
// Open composer als deze collapsed is
if (isComposerCollapsed) {
setIsComposerCollapsed(false)
}
}, [isComposerCollapsed])
const handleTypeSelect = useCallback((type: ReportType) => {
setSelectedType(type)
}, [])
const toggleComposer = useCallback(() => {
setIsComposerCollapsed((prev) => !prev)
}, [])
const PanelGroupComponent = panelsLib?.PanelGroup
const PanelComponent = panelsLib?.Panel
const PanelResizeHandleComponent = panelsLib?.PanelResizeHandle
const composerPanel = (
<div className="h-full flex flex-col">
<div className="px-4 py-3 border-b border-slate-100 shrink-0 bg-slate-50">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-slate-700">Nieuwe rapportage</h2>
{PanelGroupComponent ? (
<button
type="button"
onClick={toggleComposer}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-md transition-colors"
aria-label="Sluit editor"
>
<ChevronRight className="h-4 w-4" />
</button>
) : (
<span className="text-xs text-slate-400">Layout optimalisatie laden</span>
)}
</div>
<QuickActions onSelectType={handleTypeSelect} selectedType={selectedType} />
</div>
<div className="flex-1 overflow-y-auto p-4">
<ReportComposer
patientId={patientId}
patientName={patientName}
selectedReport={selectedReport}
onReportCreated={handleReportCreated}
initialContent={duplicateContent}
onInitialContentConsumed={() => setDuplicateContent(null)}
/>
</div>
</div>
)
return (
<div className="h-screen flex flex-col bg-slate-50">
<div className="flex-1 overflow-hidden">
{PanelGroupComponent && PanelComponent && PanelResizeHandleComponent ? (
<PanelGroupComponent direction="horizontal" className="h-full">
<PanelComponent
defaultSize={isComposerCollapsed ? 100 : 60}
minSize={40}
className="relative"
>
<ReportTimeline
reports={reports}
onSelectReport={handleSelectReport}
activeReportId={selectedReport?.id}
/>
</PanelComponent>
{!isComposerCollapsed && (
<PanelResizeHandleComponent className="relative w-1 bg-slate-200 hover:bg-emerald-400 transition-colors group">
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 group-hover:w-1.5 bg-slate-300 group-hover:bg-emerald-500 transition-all" />
</PanelResizeHandleComponent>
)}
{!isComposerCollapsed && (
<PanelComponent
defaultSize={40}
minSize={25}
maxSize={60}
className="relative bg-white border-l border-slate-200"
>
{composerPanel}
</PanelComponent>
)}
{isComposerCollapsed && (
<div className="absolute top-1/2 right-0 -translate-y-1/2 z-20">
<button
type="button"
onClick={toggleComposer}
className={cn(
'flex items-center gap-2 px-3 py-6 rounded-l-lg',
'bg-emerald-500 text-white shadow-lg',
'hover:bg-emerald-600 transition-all',
'border border-r-0 border-emerald-600'
)}
aria-label="Open editor"
>
<ChevronLeft className="h-5 w-5" />
<span className="text-sm font-medium writing-mode-vertical-rl rotate-180">
Nieuwe rapportage
</span>
</button>
</div>
)}
</PanelGroupComponent>
) : (
<div className="h-full flex flex-col lg:flex-row">
<div className="flex-1 overflow-y-auto border-b border-slate-200">
<ReportTimeline
reports={reports}
onSelectReport={handleSelectReport}
activeReportId={selectedReport?.id}
/>
</div>
<div className="lg:w-[420px] border-t border-slate-200 lg:border-t-0 lg:border-l lg:border-slate-200 bg-white">
{composerPanel}
</div>
</div>
)}
</div>
{isModalOpen && selectedReport && (
<ReportViewEditModal
report={selectedReport}
isOpen={isModalOpen}
onClose={handleCloseModal}
patientId={patientId}
onReportUpdated={handleReportUpdated}
onReportDeleted={handleReportDeleted}
onDuplicate={handleDuplicate}
/>
)}
</div>
)
}

View File

@@ -1,18 +1,37 @@
'use client';
import { useMemo, useState, useEffect } from 'react';
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { Button } from '@/components/ui/button';
import { SpeechRecorder } from '@/components/speech-recorder';
import { toast } from '@/hooks/use-toast';
import type { ClassificationResult, Report } from '@/lib/types/report';
import { createReport } from '../actions';
import { cn } from '@/lib/utils';
const SpeechRecorderStreaming = dynamic(
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
{ ssr: false, loading: () => <RecorderSkeleton /> }
);
function RecorderSkeleton() {
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
<div className="h-4 w-1/2 rounded bg-slate-100 mb-2" />
<div className="h-10 rounded bg-slate-100" />
</div>
);
}
interface ReportComposerProps {
patientId: string;
patientName: string;
selectedReport?: Report | null;
onReportCreated?: (report: Report) => void;
/** Initial content voor de editor (bijv. van duplicate) */
initialContent?: string | null;
/** Callback wanneer initialContent is verwerkt */
onInitialContentConsumed?: () => void;
}
export function ReportComposer({
@@ -20,8 +39,11 @@ export function ReportComposer({
patientName,
selectedReport,
onReportCreated,
initialContent,
onInitialContentConsumed,
}: ReportComposerProps) {
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [content, setContent] = useState('');
const [classification, setClassification] = useState<ClassificationResult | null>(null);
const [selectedType, setSelectedType] = useState<'behandeladvies' | 'vrije_notitie'>('vrije_notitie');
@@ -29,8 +51,44 @@ export function ReportComposer({
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastAutosave, setLastAutosave] = useState<Date | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [interimText, setInterimText] = useState('');
const draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]);
// Handle initialContent (e.g., from duplicate)
useEffect(() => {
if (initialContent) {
setContent(initialContent);
onInitialContentConsumed?.();
// Focus en scroll naar einde
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
}
}, 100);
}
}, [initialContent, onInitialContentConsumed]);
// Cursor naar einde verplaatsen bij start opname
const handleRecordingStart = useCallback(() => {
setIsStreaming(true);
if (textareaRef.current) {
const textarea = textareaRef.current;
textarea.focus();
// Verplaats cursor naar het einde
const length = textarea.value.length;
textarea.setSelectionRange(length, length);
// Scroll naar beneden
textarea.scrollTop = textarea.scrollHeight;
}
}, []);
const handleRecordingStop = useCallback(() => {
setIsStreaming(false);
setInterimText('');
}, []);
const characterCount = content.length;
const contentInvalid = characterCount < 20 || characterCount > 5000;
@@ -170,16 +228,8 @@ export function ReportComposer({
<section
id="rapportage-composer"
tabIndex={-1}
className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm focus:outline-none"
className="focus:outline-none"
>
<div className="mb-4">
<p className="text-sm uppercase tracking-wide text-slate-500">Nieuwe rapportage</p>
<h2 className="text-xl font-semibold text-slate-900">Voor {patientName}</h2>
<p className="text-sm text-slate-500">
Schrijf vanuit tekst of spraak en gebruik AI voor typebepaling.
</p>
</div>
{referenceSnippet && (
<div className="mb-4 rounded-xl border border-slate-100 bg-slate-50 p-4">
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
@@ -205,22 +255,41 @@ export function ReportComposer({
<div className="flex flex-col gap-4">
<div>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Beschrijf wat je wilt vastleggen..."
className="w-full min-h-[200px] rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-900 shadow-inner focus:border-teal-500 focus:outline-none"
className={cn(
'w-full min-h-[200px] rounded-xl border bg-white p-4 text-sm text-slate-900 shadow-inner focus:outline-none transition-all duration-200',
isStreaming
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
: 'border-slate-200 focus:border-teal-500'
)}
/>
<div className="flex justify-between text-xs text-slate-500">
{/* Interim tekst preview tijdens streaming */}
{interimText && (
<div className="mt-1 text-sm text-slate-500 italic px-1">
{interimText}
</div>
)}
<div className="flex justify-between text-xs text-slate-500 mt-1">
<span>{characterCount} / 5000 karakters</span>
{contentInvalid && <span>Minimaal 20 karakters</span>}
</div>
</div>
<SpeechRecorder
<SpeechRecorderStreaming
disabled={isSaving || isAnalyzing}
onTranscript={(text) =>
setContent((prev) => (prev ? `${prev}\n${text}` : text))
setContent((prev) => (prev ? `${prev} ${text}` : text))
}
onInterimTranscript={setInterimText}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
telemetryContext={{
context: 'report_composer',
patientId,
}}
/>
<div className="rounded-xl border border-slate-200 p-4 text-sm">

View File

@@ -0,0 +1,547 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { X, Pencil, Copy, Trash2, Save, Loader2 } from 'lucide-react'
import { format, formatDistanceToNow } from 'date-fns'
import { nl } from 'date-fns/locale'
import { cn } from '@/lib/utils'
import type { Report } from '@/lib/types/report'
import { SpeechRecorderStreaming } from '@/components/speech-recorder-streaming'
import { toast } from '@/hooks/use-toast'
import { updateReport, deleteReport } from '../actions'
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
export interface ReportViewEditModalProps {
/** Het rapport om te bekijken/bewerken */
report: Report | null
/** Of de modal open is */
isOpen: boolean
/** Callback om modal te sluiten */
onClose: () => void
/** Patient ID voor API calls */
patientId: string
/** Callback na succesvolle update */
onReportUpdated?: (report: Report) => void
/** Callback na verwijdering */
onReportDeleted?: (reportId: string) => void
/** Callback voor dupliceren */
onDuplicate?: (content: string) => void
}
type ModalMode = 'read' | 'edit'
// ─────────────────────────────────────────────────────────────────────────────
// Unsaved Changes Dialog
// ─────────────────────────────────────────────────────────────────────────────
function UnsavedChangesDialog({
isOpen,
onSaveAndClose,
onDiscardAndClose,
onCancel,
isSaving,
}: {
isOpen: boolean
onSaveAndClose: () => void
onDiscardAndClose: () => void
onCancel: () => void
isSaving: boolean
}) {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md mx-4 animate-in fade-in zoom-in-95 duration-200">
<h3 className="text-lg font-semibold text-slate-900">
Niet opgeslagen wijzigingen
</h3>
<p className="mt-2 text-sm text-slate-600">
Je hebt wijzigingen die nog niet zijn opgeslagen. Wat wil je doen?
</p>
<div className="mt-4 flex flex-col gap-2">
<button
type="button"
onClick={onSaveAndClose}
disabled={isSaving}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Opslaan...
</>
) : (
<>
<Save className="h-4 w-4" />
Opslaan en sluiten
</>
)}
</button>
<button
type="button"
onClick={onDiscardAndClose}
disabled={isSaving}
className="w-full flex items-center justify-center gap-2 px-4 py-2 border border-red-200 text-red-600 rounded-lg font-medium hover:bg-red-50 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
Wijzigingen verwijderen
</button>
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="w-full px-4 py-2 text-slate-600 rounded-lg font-medium hover:bg-slate-100 disabled:opacity-50"
>
Terug naar bewerken
</button>
</div>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Delete Confirmation Dialog
// ─────────────────────────────────────────────────────────────────────────────
function DeleteConfirmDialog({
isOpen,
onConfirm,
onCancel,
isDeleting,
}: {
isOpen: boolean
onConfirm: () => void
onCancel: () => void
isDeleting: boolean
}) {
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md mx-4 animate-in fade-in zoom-in-95 duration-200">
<h3 className="text-lg font-semibold text-slate-900">
Rapportage verwijderen?
</h3>
<p className="mt-2 text-sm text-slate-600">
Weet je zeker dat je deze rapportage wilt verwijderen? Dit kan niet ongedaan worden gemaakt.
</p>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={onCancel}
disabled={isDeleting}
className="flex-1 px-4 py-2 border border-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50"
>
Annuleren
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verwijderen...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Verwijderen
</>
)}
</button>
</div>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Main Modal Component
// ─────────────────────────────────────────────────────────────────────────────
export function ReportViewEditModal({
report,
isOpen,
onClose,
patientId,
onReportUpdated,
onReportDeleted,
onDuplicate,
}: ReportViewEditModalProps) {
// State
const [mode, setMode] = useState<ModalMode>('read')
const [content, setContent] = useState('')
const [originalContent, setOriginalContent] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isStreaming, setIsStreaming] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Sync content met report
useEffect(() => {
if (report) {
setContent(report.content)
setOriginalContent(report.content)
setMode('read')
}
}, [report])
// Reset bij sluiten
useEffect(() => {
if (!isOpen) {
setMode('read')
setShowUnsavedDialog(false)
setShowDeleteDialog(false)
}
}, [isOpen])
// Check for unsaved changes
const hasUnsavedChanges = content !== originalContent
// Keyboard handler (Escape)
// Handlers
const handleClose = useCallback(() => {
if (mode === 'edit' && hasUnsavedChanges) {
setShowUnsavedDialog(true)
} else {
onClose()
}
}, [mode, hasUnsavedChanges, onClose])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
handleClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, handleClose])
const handleEdit = useCallback(() => {
setMode('edit')
// Focus textarea en zet cursor aan einde
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus()
const length = textareaRef.current.value.length
textareaRef.current.setSelectionRange(length, length)
}
}, 100)
}, [])
const handleCancelEdit = useCallback(() => {
if (hasUnsavedChanges) {
setShowUnsavedDialog(true)
} else {
setContent(originalContent)
setMode('read')
}
}, [hasUnsavedChanges, originalContent])
const handleSave = useCallback(async () => {
if (!report) return
setIsSaving(true)
try {
const updated = await updateReport(patientId, report.id, { content })
setOriginalContent(content)
onReportUpdated?.(updated)
toast({
title: 'Wijzigingen opgeslagen',
description: 'De rapportage is bijgewerkt.',
})
} catch (error) {
console.error('Failed to save:', error)
toast({
variant: 'destructive',
title: 'Opslaan mislukt',
description: error instanceof Error ? error.message : 'Probeer het opnieuw.',
})
} finally {
setIsSaving(false)
}
}, [report, patientId, content, onReportUpdated])
const handleSaveAndClose = useCallback(async () => {
await handleSave()
setShowUnsavedDialog(false)
onClose()
}, [handleSave, onClose])
const handleDiscardAndClose = useCallback(() => {
setContent(originalContent)
setShowUnsavedDialog(false)
onClose()
}, [originalContent, onClose])
const handleDelete = useCallback(async () => {
if (!report) return
setIsDeleting(true)
try {
await deleteReport(patientId, report.id)
onReportDeleted?.(report.id)
toast({
title: 'Rapportage verwijderd',
description: 'De rapportage is verwijderd.',
})
onClose()
} catch (error) {
console.error('Failed to delete:', error)
toast({
variant: 'destructive',
title: 'Verwijderen mislukt',
description: error instanceof Error ? error.message : 'Probeer het opnieuw.',
})
} finally {
setIsDeleting(false)
setShowDeleteDialog(false)
}
}, [report, patientId, onReportDeleted, onClose])
const handleDuplicate = useCallback(() => {
onDuplicate?.(content)
onClose()
toast({
title: 'Gekopieerd',
description: 'Inhoud is gekopieerd naar de editor.',
})
}, [content, onDuplicate, onClose])
const handleTranscript = useCallback((transcript: string) => {
setContent((prev) => (prev ? `${prev} ${transcript}` : transcript))
}, [])
const handleRecordingStart = useCallback(() => {
setIsStreaming(true)
if (textareaRef.current) {
const length = textareaRef.current.value.length
textareaRef.current.setSelectionRange(length, length)
textareaRef.current.scrollTop = textareaRef.current.scrollHeight
}
}, [])
const handleRecordingStop = useCallback(() => {
setIsStreaming(false)
}, [])
// Don't render if not open or no report
if (!isOpen || !report) return null
const createdAt = report.created_at ? new Date(report.created_at) : null
const dateFormatted = createdAt
? format(createdAt, "d MMMM yyyy 'om' HH:mm", { locale: nl })
: ''
const relativeTime = createdAt
? formatDistanceToNow(createdAt, { addSuffix: true, locale: nl })
: ''
const TYPE_LABELS: Record<string, string> = {
behandeladvies: 'Behandeladvies',
vrije_notitie: 'Vrije notitie',
intake_verslag: 'Intake verslag',
behandelplan: 'Behandelplan',
}
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/30 animate-in fade-in duration-200"
onClick={handleClose}
aria-hidden="true"
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-[10vh] overflow-y-auto">
<div
className={cn(
'bg-white rounded-2xl shadow-2xl w-full max-w-3xl',
'animate-in fade-in slide-in-from-bottom-4 duration-300'
)}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between p-6 border-b border-slate-200">
<div>
<h2 className="text-xl font-semibold text-slate-900">
{TYPE_LABELS[report.type] || report.type}
{mode === 'edit' && (
<span className="ml-2 text-sm font-normal text-emerald-600">
(bewerken)
</span>
)}
</h2>
<p className="mt-1 text-sm text-slate-500">
{dateFormatted}
{relativeTime && <span className="ml-2 text-slate-400">({relativeTime})</span>}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{mode === 'read' ? (
<>
<button
type="button"
onClick={handleEdit}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50"
>
<Pencil className="h-4 w-4" />
Bewerken
</button>
{onDuplicate && (
<button
type="button"
onClick={handleDuplicate}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50"
>
<Copy className="h-4 w-4" />
Dupliceer
</button>
)}
<button
type="button"
onClick={() => setShowDeleteDialog(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-red-600 bg-white border border-red-200 rounded-lg hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</>
) : (
<>
<button
type="button"
onClick={handleSave}
disabled={isSaving || !hasUnsavedChanges}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Opslaan...
</>
) : (
<>
<Save className="h-4 w-4" />
Opslaan
</>
)}
</button>
<button
type="button"
onClick={handleCancelEdit}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50"
>
Annuleren
</button>
</>
)}
<button
type="button"
onClick={handleClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
aria-label="Sluiten"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Speech Recorder (only in edit mode) */}
{mode === 'edit' && (
<div className="p-6 border-b border-slate-100 bg-slate-50">
<SpeechRecorderStreaming
onTranscript={handleTranscript}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
telemetryContext={{
context: 'report_modal',
patientId,
reportId: report.id,
}}
/>
</div>
)}
{/* Content */}
<div className="p-6">
{mode === 'read' ? (
<div className="prose prose-slate max-w-none">
<p className="whitespace-pre-wrap text-slate-700 leading-relaxed">
{content}
</p>
</div>
) : (
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
className={cn(
'w-full min-h-[300px] p-4 text-sm text-slate-900 rounded-xl border bg-white',
'focus:outline-none transition-all duration-200',
isStreaming
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
: 'border-slate-200 focus:border-emerald-300'
)}
placeholder="Bewerk de rapportage of dicteer met spraak..."
/>
)}
</div>
{/* Footer with metadata */}
{report.ai_reasoning && (
<div className="px-6 pb-6">
<div className="rounded-lg bg-slate-50 p-4 text-sm">
<p className="font-medium text-slate-700">AI toelichting</p>
<p className="mt-1 text-slate-600">{report.ai_reasoning}</p>
{report.ai_confidence !== null && (
<p className="mt-2 text-xs text-slate-500">
Zekerheid: {Math.round(report.ai_confidence * 100)}%
</p>
)}
</div>
</div>
)}
{/* Unsaved indicator */}
{mode === 'edit' && hasUnsavedChanges && (
<div className="px-6 pb-4">
<p className="text-xs text-amber-600 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-amber-500" />
Niet opgeslagen wijzigingen
</p>
</div>
)}
</div>
</div>
{/* Dialogs */}
<UnsavedChangesDialog
isOpen={showUnsavedDialog}
onSaveAndClose={handleSaveAndClose}
onDiscardAndClose={handleDiscardAndClose}
onCancel={() => setShowUnsavedDialog(false)}
isSaving={isSaving}
/>
<DeleteConfirmDialog
isOpen={showDeleteDialog}
onConfirm={handleDelete}
onCancel={() => setShowDeleteDialog(false)}
isDeleting={isDeleting}
/>
</>
)
}

View File

@@ -0,0 +1,255 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Eye, EyeOff } from 'lucide-react'
import { loginWithPassword, signUpWithPassword } from '@/lib/auth/client'
export function LoginForm() {
const router = useRouter()
const [mode, setMode] = useState<'login' | 'signup'>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setMessage(null)
try {
if (mode === 'signup') {
if (password !== confirmPassword) {
throw new Error('Wachtwoorden komen niet overeen')
}
const result = await signUpWithPassword(email, password)
if (result.session) {
setMessage({ type: 'success', text: 'Account aangemaakt! Je wordt doorgestuurd...' })
setTimeout(() => {
router.push('/epd/patients')
}, 1000)
} else {
setMessage({
type: 'success',
text: 'Check je inbox voor een verificatie link om je account te activeren.'
})
}
} else {
await loginWithPassword(email, password)
setMessage({ type: 'success', text: 'Ingelogd! Je wordt doorgestuurd...' })
setTimeout(() => {
router.push('/epd/clients')
}, 1000)
}
} catch (error: any) {
if (error.code === 'user_already_registered' && error.data?.session) {
setMessage({ type: 'success', text: 'Dit emailadres bestaat al. Je bent nu ingelogd!' })
setTimeout(() => {
router.push('/epd/patients')
}, 1000)
} else if (error.code === 'user_already_registered') {
setMessage({
type: 'error',
text: error.message || 'Dit emailadres is al geregistreerd. Probeer in te loggen.'
})
setTimeout(() => {
setMode('login')
setPassword('')
setConfirmPassword('')
}, 2000)
} else if (error.code === 'invalid_credentials' && mode === 'login') {
setMessage({ type: 'error', text: error.message || 'Email of wachtwoord is onjuist.' })
} else if (error.code === 'email_not_confirmed') {
setMessage({ type: 'error', text: error.message || 'Je email is nog niet geverifieerd. Check je inbox.' })
} else {
setMessage({ type: 'error', text: error.message || 'Er ging iets mis. Probeer opnieuw.' })
}
} finally {
setLoading(false)
}
}
const handleQuickDemoLogin = async () => {
setMode('login')
setEmail('demo@mini-ecd.demo')
setPassword('Demo2024!')
setLoading(true)
try {
await loginWithPassword('demo@mini-ecd.demo', 'Demo2024!')
router.push('/epd/clients')
} catch (error: any) {
setMessage({ type: 'error', text: 'Demo login mislukt. Probeer handmatig in te loggen.' })
setLoading(false)
}
}
return (
<div className="md:w-2/5 bg-white p-8 md:p-12 flex flex-col justify-center">
<div className="w-full max-w-md mx-auto">
<div className="text-center mb-8">
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2">
{mode === 'login' ? 'Welkom terug' : 'Maak een account'}
</h2>
<p className="text-slate-600">
{mode === 'login'
? 'Log in om toegang te krijgen tot het EPD'
: 'Start vandaag nog met snellere rapportages'}
</p>
</div>
<div className="flex bg-slate-100 p-1 rounded-lg mb-6">
<button
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
mode === 'login' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-900'
}`}
onClick={() => setMode('login')}
>
Inloggen
</button>
<button
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
mode === 'signup' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-900'
}`}
onClick={() => setMode('signup')}
>
Registreren
</button>
</div>
{message && (
<div
className={`mb-6 p-4 rounded-lg ${
message.type === 'success'
? 'bg-teal-50 text-teal-800 border border-teal-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
<p className="text-sm font-medium">{message.text}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="jouw@email.nl"
required
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700 mb-1">
Wachtwoord
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
className="w-full px-4 py-2.5 pr-11 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
aria-label={showPassword ? 'Verberg wachtwoord' : 'Toon wachtwoord'}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{mode === 'login' && (
<div className="mt-1 text-right">
<Link href="/reset-password" className="text-xs text-slate-500 hover:text-teal-600">
Wachtwoord vergeten?
</Link>
</div>
)}
</div>
{mode === 'signup' && (
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-700 mb-1">
Bevestig Wachtwoord
</label>
<div className="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
className="w-full px-4 py-2.5 pr-11 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
aria-label={showConfirmPassword ? 'Verberg wachtwoord' : 'Toon wachtwoord'}
>
{showConfirmPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Laden...' : mode === 'login' ? 'Inloggen' : 'Registreren'}
</button>
</form>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-slate-500">of</span>
</div>
</div>
<button
onClick={handleQuickDemoLogin}
disabled={loading}
className="w-full bg-amber-50 hover:bg-amber-100 text-amber-800 text-sm font-medium py-2 px-4 rounded-lg border border-amber-200 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
<span></span>
Demo Account Proberen
</button>
<p className="text-center text-xs text-slate-500 mt-8">
Build in Public door{' '}
<a
href="https://ikbenlit.nl"
target="_blank"
rel="noopener noreferrer"
className="text-teal-600 hover:text-teal-700 font-medium"
>
AI Speedrun
</a>
</p>
</div>
</div>
)
}

View File

@@ -1,13 +1,7 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { loginWithPassword, signUpWithPassword } from '@/lib/auth/client'
import { Brain, Zap, Target, Clock, Eye, EyeOff } from 'lucide-react'
import { Brain, Zap, Target, Clock } from 'lucide-react'
import { BentoGrid, BentoCard } from '@/components/ui/bento-grid'
import Link from 'next/link'
import { LoginForm } from './components/login-form'
// Bento grid features with different sizes for visual interest
const bentoFeatures = [
{
name: 'AI Intake Samenvatting',
@@ -56,144 +50,10 @@ const bentoFeatures = [
]
export default function LoginPage() {
const router = useRouter()
const [mode, setMode] = useState<'login' | 'signup'>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') // Only for signup
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [message, setMessage] = useState<{
type: 'success' | 'error'
text: string
} | null>(null)
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setMessage(null)
try {
if (mode === 'signup') {
// Signup Flow
if (password !== confirmPassword) {
throw new Error('Wachtwoorden komen niet overeen')
}
// Sign up
const result = await signUpWithPassword(email, password)
// Check if user got a session immediately (email confirmation disabled)
if (result.session) {
// User is logged in immediately
setMessage({
type: 'success',
text: 'Account aangemaakt! Je wordt doorgestuurd...'
})
setTimeout(() => {
router.push('/epd/patients')
}, 1000)
} else {
// Email confirmation required - user needs to check inbox
setMessage({
type: 'success',
text: 'Check je inbox voor een verificatie link om je account te activeren.'
})
}
} else {
// Login Flow
await loginWithPassword(email, password)
setMessage({
type: 'success',
text: 'Ingelogd! Je wordt doorgestuurd...'
})
// Redirect to EPD
setTimeout(() => {
router.push('/epd/clients')
}, 1000)
}
} catch (error: any) {
// Check for duplicate email error with auto-login
if (error.code === 'user_already_registered' && error.data?.session) {
// User was auto-logged in (client-side detection succeeded)
setMessage({
type: 'success',
text: 'Dit emailadres bestaat al. Je bent nu ingelogd!'
})
setTimeout(() => {
router.push('/epd/patients')
}, 1000)
}
// Check for duplicate email error (from Auth Hook or client-side)
else if (error.code === 'user_already_registered') {
setMessage({
type: 'error',
text: error.message || 'Dit emailadres is al geregistreerd. Probeer in te loggen.'
})
// Switch to login mode after 2 seconds
setTimeout(() => {
setMode('login')
setPassword('')
setConfirmPassword('')
}, 2000)
}
// Invalid credentials during login - suggest signup
else if (error.code === 'invalid_credentials' && mode === 'login') {
setMessage({
type: 'error',
text: error.message || 'Email of wachtwoord is onjuist.'
})
}
// Email not confirmed
else if (error.code === 'email_not_confirmed') {
setMessage({
type: 'error',
text: error.message || 'Je email is nog niet geverifieerd. Check je inbox.'
})
}
// Generic error handling
else {
setMessage({
type: 'error',
text: error.message || 'Er ging iets mis. Probeer opnieuw.'
})
}
} finally {
setLoading(false)
}
}
// Quick demo login
const handleQuickDemoLogin = async () => {
setMode('login')
setEmail('demo@mini-ecd.demo')
setPassword('Demo2024!')
// Auto-submit
setLoading(true)
try {
await loginWithPassword('demo@mini-ecd.demo', 'Demo2024!')
router.push('/epd/clients')
} catch (error: any) {
setMessage({
type: 'error',
text: 'Demo login mislukt. Probeer handmatig in te loggen.'
})
setLoading(false)
}
}
return (
<div className="min-h-screen flex flex-col lg:flex-row">
{/* Left Side - Bento Grid Showcase (60%) */}
<div className="lg:w-3/5 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-6 md:p-8 lg:p-12 flex flex-col justify-center">
<div className="max-w-5xl mx-auto w-full">
{/* Header */}
<div className="mb-8">
<a href="/" className="inline-block mb-6 text-slate-300 hover:text-white transition-colors">
Terug naar home
@@ -206,11 +66,10 @@ export default function LoginPage() {
</p>
</div>
{/* Bento Grid */}
<BentoGrid className="mb-8">
{bentoFeatures.map((feature, index) => (
{bentoFeatures.map((feature) => (
<BentoCard
key={index}
key={feature.name}
name={feature.name}
description={feature.description}
Icon={feature.icon}
@@ -222,7 +81,6 @@ export default function LoginPage() {
))}
</BentoGrid>
{/* Stats Footer */}
<div className="grid grid-cols-3 gap-4 mt-8">
<div className="text-center p-4 bg-white/5 rounded-lg border border-white/10">
<div className="text-2xl md:text-3xl font-bold text-amber-400">90%+</div>
@@ -240,202 +98,7 @@ export default function LoginPage() {
</div>
</div>
{/* Right Side - Login/Signup Form (40%) */}
<div className="md:w-2/5 bg-white p-8 md:p-12 flex flex-col justify-center">
<div className="w-full max-w-md mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2">
{mode === 'login' ? 'Welkom terug' : 'Maak een account'}
</h2>
<p className="text-slate-600">
{mode === 'login'
? 'Log in om toegang te krijgen tot het EPD'
: 'Start vandaag nog met snellere rapportages'
}
</p>
</div>
{/* Mode Toggle */}
<div className="flex bg-slate-100 p-1 rounded-lg mb-6">
<button
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
mode === 'login'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-900'
}`}
onClick={() => setMode('login')}
>
Inloggen
</button>
<button
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
mode === 'signup'
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-900'
}`}
onClick={() => setMode('signup')}
>
Registreren
</button>
</div>
{/* Message Display */}
{message && (
<div
className={`mb-6 p-4 rounded-lg ${
message.type === 'success'
? 'bg-teal-50 text-teal-800 border border-teal-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
<p className="text-sm font-medium">{message.text}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700 mb-1"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="jouw@email.nl"
required
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-slate-700 mb-1"
>
Wachtwoord
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
className="w-full px-4 py-2.5 pr-11 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
aria-label={showPassword ? 'Verberg wachtwoord' : 'Toon wachtwoord'}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
{mode === 'login' && (
<div className="mt-1 text-right">
<Link
href="/reset-password"
className="text-xs text-slate-500 hover:text-teal-600"
>
Wachtwoord vergeten?
</Link>
</div>
)}
</div>
{mode === 'signup' && (
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-slate-700 mb-1"
>
Bevestig Wachtwoord
</label>
<div className="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
className="w-full px-4 py-2.5 pr-11 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors"
aria-label={showConfirmPassword ? 'Verberg wachtwoord' : 'Toon wachtwoord'}
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading
? 'Laden...'
: mode === 'login' ? 'Inloggen' : 'Registreren'
}
</button>
</form>
{/* Divider */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-slate-500">of</span>
</div>
</div>
{/* Quick Demo Button */}
<button
onClick={handleQuickDemoLogin}
disabled={loading}
className="w-full bg-amber-50 hover:bg-amber-100 text-amber-800 text-sm font-medium py-2 px-4 rounded-lg border border-amber-200 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
<span></span>
Demo Account Proberen
</button>
{/* Footer */}
<p className="text-center text-xs text-slate-500 mt-8">
Build in Public door{' '}
<a
href="https://ikbenlit.nl"
target="_blank"
rel="noopener noreferrer"
className="text-teal-600 hover:text-teal-700 font-medium"
>
AI Speedrun
</a>
</p>
</div>
</div>
<LoginForm />
</div>
)
}

View File

@@ -0,0 +1,460 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2, Mic, Pause, Play, Square, Settings, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
useDeepgramStreaming,
type ConnectionStatus,
type TranscriptResult,
type TranscriptWord,
} from '@/hooks/use-deepgram-streaming'
import { ConfidencePreview } from '@/components/confidence-text'
import { logSpeechUsage, type SpeechTelemetryOptions } from '@/lib/telemetry/speech'
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
export interface SpeechRecorderStreamingProps {
/** Callback wanneer er een final transcript is */
onTranscript: (transcript: string) => void
/** Callback voor interim transcripts (optioneel, voor live preview) */
onInterimTranscript?: (interim: string) => void
/** Callback voor woorden met confidence scores */
onWordsUpdate?: (words: TranscriptWord[]) => void
/** Disabled state */
disabled?: boolean
/** Extra CSS classes */
className?: string
/** Callback wanneer opname start (voor cursor positioning) */
onRecordingStart?: () => void
/** Callback wanneer opname stopt */
onRecordingStop?: () => void
/** Optionele context voor telemetrie */
telemetryContext?: SpeechTelemetryOptions
}
// ─────────────────────────────────────────────────────────────────────────────
// Status Display Component
// ─────────────────────────────────────────────────────────────────────────────
function StatusIndicator({ status }: { status: ConnectionStatus }) {
const config: Record<
ConnectionStatus,
{ icon: string; color: string; label: string }
> = {
disconnected: {
icon: '◯',
color: 'text-slate-400',
label: 'Niet verbonden',
},
connecting: {
icon: '◐',
color: 'text-amber-500',
label: 'Verbinden...',
},
connected: {
icon: '●',
color: 'text-emerald-500',
label: 'Verbonden & streaming',
},
reconnecting: {
icon: '⚠',
color: 'text-orange-500',
label: 'Herverbinden...',
},
error: {
icon: '✕',
color: 'text-red-500',
label: 'Fout',
},
}
const { icon, color, label } = config[status]
return (
<span className={cn('text-xs flex items-center gap-1', color)}>
<span className="text-sm">{icon}</span>
{label}
</span>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Waveform Visualizer Component
// ─────────────────────────────────────────────────────────────────────────────
function WaveformVisualizer({
analyserNode,
isActive,
}: {
analyserNode: AnalyserNode | null
isActive: boolean
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animationRef = useRef<number | null>(null)
useEffect(() => {
if (!analyserNode || !canvasRef.current || !isActive) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
return
}
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
const bufferLength = analyserNode.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
const draw = () => {
animationRef.current = requestAnimationFrame(draw)
analyserNode.getByteFrequencyData(dataArray)
// Clear canvas
ctx.fillStyle = '#f8fafc' // slate-50
ctx.fillRect(0, 0, canvas.width, canvas.height)
const barCount = 32
const barWidth = (canvas.width - (barCount - 1) * 2) / barCount
const maxBarHeight = canvas.height - 8
for (let i = 0; i < barCount; i++) {
// Sample from frequency data
const dataIndex = Math.floor((i / barCount) * bufferLength)
const value = dataArray[dataIndex] / 255
const barHeight = Math.max(4, value * maxBarHeight)
const x = i * (barWidth + 2)
const y = (canvas.height - barHeight) / 2
// Gradient from slate to emerald based on value
const intensity = Math.floor(value * 255)
ctx.fillStyle =
value > 0.3
? `rgb(${16 + (1 - value) * 50}, ${185 - (1 - value) * 100}, ${129 - (1 - value) * 50})`
: '#64748b' // slate-500
// Rounded bars
ctx.beginPath()
ctx.roundRect(x, y, barWidth, barHeight, 2)
ctx.fill()
}
}
draw()
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [analyserNode, isActive])
if (!isActive) return null
return (
<canvas
ref={canvasRef}
width={300}
height={40}
className="rounded bg-slate-50"
/>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Main Component
// ─────────────────────────────────────────────────────────────────────────────
export function SpeechRecorderStreaming({
onTranscript,
onInterimTranscript,
onWordsUpdate,
disabled = false,
className,
onRecordingStart,
onRecordingStop,
telemetryContext,
}: SpeechRecorderStreamingProps) {
const [interimText, setInterimText] = useState('')
const [allWords, setAllWords] = useState<TranscriptWord[]>([])
const [localError, setLocalError] = useState<string | null>(null)
const [isAutoPaused, setIsAutoPaused] = useState(false)
// Accumulate final transcript parts
const finalPartsRef = useRef<string[]>([])
const autoPauseTriggeredRef = useRef(false)
const telemetryRef = useRef<SpeechTelemetryOptions | undefined>(telemetryContext)
useEffect(() => {
telemetryRef.current = telemetryContext
}, [telemetryContext])
const trackSpeechUsage = useCallback(
(action: 'start' | 'stop' | 'final', metadata?: Record<string, unknown>) => {
if (!telemetryRef.current) return
logSpeechUsage({ ...telemetryRef.current, action, metadata })
},
[]
)
const handleTranscript = useCallback(
(result: TranscriptResult) => {
if (result.isFinal) {
// Final transcript - accumulate en stuur naar parent
finalPartsRef.current.push(result.transcript)
setInterimText('')
// Update words met confidence
setAllWords((prev) => {
const next = [...prev, ...result.words]
onWordsUpdate?.(next)
return next
})
// Stuur volledige tekst naar parent
onTranscript(finalPartsRef.current.join(' '))
trackSpeechUsage('final', {
chunkLength: result.transcript.length,
totalLength: finalPartsRef.current.join(' ').length,
})
// Check voor auto-pause na speech_final (3 sec stilte gedetecteerd door Deepgram)
if (result.speechFinal && !autoPauseTriggeredRef.current) {
autoPauseTriggeredRef.current = true
setIsAutoPaused(true)
}
} else {
// Interim transcript - alleen preview
setInterimText(result.transcript)
onInterimTranscript?.(result.transcript)
// Reset auto-pause state bij nieuwe interim speech
if (isAutoPaused) {
setIsAutoPaused(false)
autoPauseTriggeredRef.current = false
}
}
},
[onTranscript, onInterimTranscript, onWordsUpdate, isAutoPaused, trackSpeechUsage]
)
const handleError = useCallback((error: Error) => {
setLocalError(error.message)
}, [])
const {
status,
isRecording,
startRecording,
stopRecording,
pauseRecording,
resumeRecording,
isPaused,
error: hookError,
analyserNode,
isBrowserSupported,
} = useDeepgramStreaming({
onTranscript: handleTranscript,
onError: handleError,
})
const error = localError || hookError
const handleStart = async () => {
setLocalError(null)
finalPartsRef.current = []
setAllWords([])
setInterimText('')
setIsAutoPaused(false)
autoPauseTriggeredRef.current = false
onRecordingStart?.()
await startRecording()
trackSpeechUsage('start')
}
const handleStop = () => {
stopRecording()
setIsAutoPaused(false)
autoPauseTriggeredRef.current = false
onRecordingStop?.()
trackSpeechUsage('stop', {
totalLength: finalPartsRef.current.join(' ').length,
})
}
const handlePauseResume = () => {
if (isPaused || isAutoPaused) {
setIsAutoPaused(false)
autoPauseTriggeredRef.current = false
resumeRecording()
} else {
pauseRecording()
}
}
const isConnecting = status === 'connecting'
const isReconnecting = status === 'reconnecting'
const showWaveform = isRecording && !isPaused && !isAutoPaused && status === 'connected'
const effectivelyPaused = isPaused || isAutoPaused
useEffect(() => {
if (isAutoPaused && isRecording && !isPaused) {
pauseRecording()
}
}, [isAutoPaused, isRecording, isPaused, pauseRecording])
return (
<div
className={cn(
'rounded-lg border border-slate-200 bg-white p-4 space-y-3',
isRecording && status === 'connected' && 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-sm',
className
)}
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Mic className="h-4 w-4 text-slate-600" />
<span className="text-sm font-medium text-slate-900">Opname</span>
</div>
<div className="flex items-center gap-2">
<StatusIndicator status={status} />
<button
type="button"
className="p-1 text-slate-400 hover:text-slate-600 rounded"
aria-label="Instellingen"
>
<Settings className="h-4 w-4" />
</button>
</div>
</div>
{/* Browser not supported message */}
{!isBrowserSupported && (
<div className="text-sm text-amber-700 bg-amber-50 p-2 rounded border border-amber-200">
Spraakopname wordt niet ondersteund in deze browser. Gebruik Chrome, Firefox of Edge voor de beste ervaring.
</div>
)}
{/* Error message */}
{error && (
<div className="text-sm text-red-600 bg-red-50 p-2 rounded border border-red-200">
{error}
</div>
)}
{/* Reconnecting message */}
{isReconnecting && (
<div className="text-sm text-orange-600 bg-orange-50 p-2 rounded border border-orange-200 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Herverbinden... Transcript blijft behouden.
</div>
)}
{/* Interim text preview */}
{interimText && (
<div className="text-sm text-slate-500 italic bg-slate-50 p-2 rounded">
{interimText}
</div>
)}
{/* Waveform */}
<WaveformVisualizer analyserNode={analyserNode} isActive={showWaveform} />
{/* Confidence preview */}
{allWords.length > 0 && (
<ConfidencePreview words={allWords} />
)}
{/* Auto-pause message (3 sec stilte) */}
{isAutoPaused && isRecording && (
<div className="text-sm text-amber-700 bg-amber-50 p-2 rounded border border-amber-200 flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>
<strong>Automatisch gepauzeerd</strong>
<span className="text-amber-600 ml-1">(3 seconden stilte)</span>
</span>
</div>
)}
{/* Manual pause message */}
{isPaused && !isAutoPaused && isRecording && (
<div className="text-sm text-slate-600 bg-slate-100 p-2 rounded flex items-center gap-2">
<Pause className="h-4 w-4" />
Gepauzeerd
</div>
)}
{/* Controls */}
<div className="flex gap-2">
{!isRecording ? (
<button
type="button"
onClick={handleStart}
disabled={disabled || isConnecting || !isBrowserSupported}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors',
'bg-emerald-600 text-white hover:bg-emerald-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isConnecting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verbinden...
</>
) : (
<>
<Mic className="h-4 w-4" />
Start opname
</>
)}
</button>
) : (
<>
<button
type="button"
onClick={handlePauseResume}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors',
effectivelyPaused
? 'bg-emerald-100 border border-emerald-300 text-emerald-700 hover:bg-emerald-200'
: 'border border-slate-300 text-slate-700 hover:bg-slate-50'
)}
>
{effectivelyPaused ? (
<>
<Play className="h-4 w-4" />
Hervat
</>
) : (
<>
<Pause className="h-4 w-4" />
Pauzeer
</>
)}
</button>
<button
type="button"
onClick={handleStop}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors',
'bg-red-600 text-white hover:bg-red-700'
)}
>
<Square className="h-4 w-4" />
Stop
</button>
</>
)}
</div>
</div>
)
}

View File

@@ -1,5 +1,3 @@
'use client'
import { ReactNode } from "react";
import { ArrowRight } from "lucide-react";

View File

@@ -0,0 +1,259 @@
# Efficiëntieplan — Patiëntenworkspace & Spraakstreaming
## Executive Summary
Dit plan prioriteert optimalisaties op basis van **bundelimpact** en **implementatie-effort**.
De grootste winst zit in lazy loading van zware editors, niet in de Deepgram SDK.
### Quick Reference
| Prioriteit | Optimalisatie | Impact | Effort |
|------------|---------------|--------|--------|
| P0 | TipTap lazy-load | ~94 kB | Laag |
| P1 | Login page refactor | ~76 kB | Laag |
| P2 | Speech recorder lazy-load | ~25-30 kB | Laag |
| P3 | Modal lazy-load | ~15-20 kB | Laag |
| P4 | Server components timeline | ~10-15 kB | Hoog |
---
## Bundelanalyse (25-11-2025)
### Route Ranking (hoogste First Load JS)
| Route | Page Size | First Load JS | Bottleneck |
|-------|-----------|---------------|------------|
| `/behandeladvies` | **154 kB** | **240 kB** | TipTap + Deepgram |
| `/login` | 76 kB | 162 kB | BentoGrid (hele page client) |
| `/reset-password` | 63.7 kB | 150 kB | Auth forms |
| `/rapportage` | 60 kB | 146 kB | Deepgram + resizable-panels |
| `/` (homepage) | 60.7 kB | 147 kB | Marketing UI |
| `/intakes/new` | 43.7 kB | 130 kB | react-hook-form + zod |
| `/screening` | 20.1 kB | 106 kB | Decision cards |
### Build Snapshot (26-11-2025)
| Route | Page Size | First Load JS | Opmerking |
|-------|-----------|---------------|-----------|
| `/epd/patients/[id]/rapportage` | **25.7 kB** | **112 kB** | -34 kB vs baseline dankzij lazy modals & panels |
| `/epd/patients/[id]/intakes/new` | 43.8 kB | 130 kB | Form island, maar shared chunk blijft gelijk |
| `/login` | 65.7 kB | 152 kB | Hero nog client heavy gepland in latere fase |
> Grootste winst zichtbaar op rapportage route; andere pagina's vereisen aanvullende workstreams (marketing assets, shared chunks) om verder te dalen.
### Package Sizes (raw disk)
| Package | Size | Lazy-loadbaar |
|---------|------|---------------|
| `@tiptap/*` | ~5+ MB | ✓ |
| `@deepgram/sdk` | ~2.5 MB | ✓ |
| `react-resizable-panels` | ~650 KB | ✓ |
| `react-hook-form` + `zod` | ~1.5 MB | ✓ |
### Aanname getoetst: "Deepgram SDK veroorzaakt performance issues"
**Conclusie: Aanname is NIET correct.**
- Rapportage (60 kB) met Deepgram is **94 kB kleiner** dan behandeladvies (154 kB)
- Beide gebruiken `SpeechRecorderStreaming` met Deepgram
- Het verschil komt door **TipTap RichTextEditor** (alleen in behandeladvies)
---
## Fase-indeling (Geprioriteerd op ROI)
### Fase 0 — Quick Wins (Hoogste ROI, laagste effort)
#### 0.1 TipTap lazy-load in behandeladvies (~94 kB besparing)
```tsx
// Huidige situatie (slecht)
import { RichTextEditor } from '@/components/rich-text-editor';
// Nieuwe situatie (goed)
import dynamic from 'next/dynamic';
const RichTextEditor = dynamic(
() => import('@/components/rich-text-editor').then(m => m.RichTextEditor),
{ ssr: false, loading: () => <EditorSkeleton /> }
);
```
_Status 26-11-2025: ✅ Ingezet in `treatment-advice-form` inclusief skeleton; bundel wacht nu tot interactie._
**Criteria voor succes:** `/behandeladvies` page size < 70 kB
#### 0.2 Login page refactor (~76 kB besparing)
- Login page is volledig `'use client'` terwijl 60%+ statische marketing content is
- Refactor naar server component met client islands voor form en BentoGrid interacties
**Criteria voor succes:** `/login` page size < 30 kB
_Status 26-11-2025: ✅ Page is nu server-rendered; enkel het formulier is een client-island._
---
### Fase 1 — Speech & Modal Optimalisatie
#### 1.1 SpeechRecorderStreaming lazy-load (~25-30 kB per page)
```tsx
const SpeechRecorderStreaming = dynamic(
() => import('@/components/speech-recorder-streaming').then(m => m.SpeechRecorderStreaming),
{ ssr: false, loading: () => <RecorderSkeleton /> }
);
```
**Toepassingslocaties:**
- `treatment-advice-form.tsx` (behandeladvies)
- `report-composer.tsx` (rapportage)
_Status 26-11-2025: ✅ Beide formulieren laden de recorder nu lazy met een kleine skeleton._
#### 1.2 ReportViewEditModal lazy-load (~15-20 kB)
Modal wordt alleen getoond bij klikken op een rapport:
```tsx
const ReportViewEditModal = dynamic(
() => import('./report-view-edit-modal').then(m => m.ReportViewEditModal),
{ ssr: false }
);
```
_Status 26-11-2025: ✅ Modal en Deepgram chunk worden alleen geladen wanneer een kaart wordt geopend._
#### 1.3 Telemetrie toevoegen
Log spraakgebruik om te meten hoeveel gebruikers de Deepgram chunk daadwerkelijk nodig hebben.
_Status 26-11-2025: ✅ Nieuwe `speech_usage_events` tabel + API route; recorder logt start/stop/final events met context._
---
### Fase 2 — Form Optimalisatie
#### 2.1 react-hook-form + zod lazy-load (~30-40 kB)
NewIntakeForm laadt zware form libraries direct. Lazy-load de hele form:
```tsx
const NewIntakeForm = dynamic(
() => import('../components/new-intake-form').then(m => m.NewIntakeForm),
{ ssr: false, loading: () => <FormSkeleton /> }
);
```
_Status 26-11-2025: ✅ Pagina `intakes/new` laadt de form nu als island met skeleton._
#### 2.2 react-resizable-panels lazy-load
RapportageWorkspaceV2 laadt dit direct. Overweeg een simpelere layout als default.
_Status 26-11-2025: ✅ Panel library wordt client-side geladen met stacked fallback; timeline/composer blijven bruikbaar terwijl chunk downloadt._
---
### Fase 3 — Server Components & Data Fetching
> **Let op:** Deze fase heeft hoge effort maar medium winst. Alleen implementeren na Fase 0-2.
#### 3.1 Timeline naar server component
Huidige situatie: `ReportTimeline` is volledig client-side voor filtering/zoeken.
- Verplaats statische rendering (kaarten, timestamps) naar server
- Behoud alleen filter-controls als client island
_Status 26-11-2025: 🔄 Nog te doen. Vereist opsplitsing van RapportageWorkspaceV2 + nieuwe client island._
#### 3.2 Header naar server component
Patient info en breadcrumbs kunnen server-side renderen.
_Status 26-11-2025: 🔄 Nog te doen. Wordt opgepakt na timeline refactor._
#### 3.3 Server actions & caching
- Patient + rapportages via server actions laden met `cache()`
- Gerichte `revalidateTag` bij mutaties
_Status 26-11-2025: 🔄 Gepland na 3.1/3.2 om dataflow te vereenvoudigen._
---
### Fase 4 — Build & Tooling (Maintenance)
#### 4.1 Webpack cache waarschuwing oplossen
```
[webpack.cache.PackFileCacheStrategy] Serializing big strings (128kiB)
```
Grote stringassets omzetten naar Buffers of opsplitsen.
_Status 26-11-2025: ✅ Productiebouw gebruikt nu een in-memory webpack cache, waardoor de PackFileCacheStrategy waarschuwing verdwijnt._
#### 4.2 Performance budget in CI
```bash
# CI check
pnpm build && pnpm check:bundle
```
Budgetten:
- Max 150 kB First Load JS per EPD route
- Max 250 kB First Load JS voor behandeladvies (met editor)
_Status 26-11-2025: ✅ `scripts/check-bundle-size.js` scant de route-specifieke chunks (exclusief shared webpack/main) na `pnpm build`; bundels falen wanneer `/rapportage` of behandeladvies boven hun budget komt (`pnpm check:bundle`)._
---
## Implementatie Roadmap
```
Week 1: Fase 0 (Quick Wins)
├── 0.1 TipTap lazy-load
└── 0.2 Login page refactor
Week 2: Fase 1 (Speech & Modals)
├── 1.1 Speech recorder lazy-load
├── 1.2 Modal lazy-load
└── 1.3 Telemetrie setup
Week 3: Fase 2 (Forms) + Meting
├── 2.1 Form lazy-load
├── 2.2 Resizable panels review
└── Bundle size meting vs baseline
Week 4+: Fase 3-4 (indien nodig)
├── Server components (hoog effort)
└── CI tooling
```
---
## Metrics & Doelen
### Baseline (25-11-2025)
| Metric | Huidige waarde |
|--------|----------------|
| `/behandeladvies` First Load | 240 kB |
| `/rapportage` First Load | 146 kB |
| `/login` First Load | 162 kB |
| Dev compile modules | ~3000 |
### Target na Fase 0-2
| Metric | Doel |
|--------|------|
| `/behandeladvies` First Load | < 150 kB (-38%) |
| `/rapportage` First Load | < 120 kB (-18%) |
| `/login` First Load | < 100 kB (-38%) |
---
## Architectuur Observaties
### Positief
- Pages zijn al server components
- Data fetching gebeurt server-side met `async` page components
- Supabase auth via server actions
### Te verbeteren
- Client components bevatten ALLE UI + logica (geen code splitting)
- Modals en editors laden direct in initial bundle
- Login page is volledig client terwijl content grotendeels statisch is
---
## Referenties
- Build output: `docs/reports/20251125_build output.md`
- Next.js Dynamic Imports: https://nextjs.org/docs/app/building-your-application/optimizing/lazy-loading
- Bundle Analyzer: `next build && ANALYZE=true next build`

View File

@@ -12,31 +12,6 @@ export type Database = {
__InternalSupabase: {
PostgrestVersion: "13.0.5"
}
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
extensions?: Json
operationName?: string
query?: string
variables?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
ai_events: {
@@ -1103,6 +1078,78 @@ export type Database = {
},
]
}
reports: {
Row: {
ai_confidence: number | null
ai_reasoning: string | null
audio_duration_seconds: number | null
audio_url: string | null
content: string
created_at: string
created_by: string | null
deleted_at: string | null
id: string
parent_report_id: string | null
patient_id: string
structured_data: Json | null
type: string
updated_at: string | null
updated_by: string | null
version: string | null
}
Insert: {
ai_confidence?: number | null
ai_reasoning?: string | null
audio_duration_seconds?: number | null
audio_url?: string | null
content: string
created_at?: string
created_by?: string | null
deleted_at?: string | null
id?: string
parent_report_id?: string | null
patient_id: string
structured_data?: Json | null
type: string
updated_at?: string | null
updated_by?: string | null
version?: string | null
}
Update: {
ai_confidence?: number | null
ai_reasoning?: string | null
audio_duration_seconds?: number | null
audio_url?: string | null
content?: string
created_at?: string
created_by?: string | null
deleted_at?: string | null
id?: string
parent_report_id?: string | null
patient_id?: string
structured_data?: Json | null
type?: string
updated_at?: string | null
updated_by?: string | null
version?: string | null
}
Relationships: [
{
foreignKeyName: "reports_parent_report_id_fkey"
columns: ["parent_report_id"]
isOneToOne: false
referencedRelation: "reports"
referencedColumns: ["id"]
},
{
foreignKeyName: "reports_patient_id_fkey"
columns: ["patient_id"]
isOneToOne: false
referencedRelation: "patients"
referencedColumns: ["id"]
},
]
}
risk_assessments: {
Row: {
assessment_date: string
@@ -1320,6 +1367,42 @@ export type Database = {
},
]
}
speech_usage_events: {
Row: {
action: string
context: string
created_at: string
id: string
intake_id: string | null
metadata: Json
patient_id: string | null
report_id: string | null
user_id: string
}
Insert: {
action: string
context: string
created_at?: string
id?: string
intake_id?: string | null
metadata?: Json
patient_id?: string | null
report_id?: string | null
user_id: string
}
Update: {
action?: string
context?: string
created_at?: string
id?: string
intake_id?: string | null
metadata?: Json
patient_id?: string | null
report_id?: string | null
user_id?: string
}
Relationships: []
}
treatment_plans: {
Row: {
client_id: string
@@ -1364,92 +1447,6 @@ export type Database = {
},
]
}
reports: {
Row: {
ai_confidence: number | null
ai_reasoning: string | null
audio_duration_seconds: number | null
audio_url: string | null
content: string
created_at: string
created_by: string | null
deleted_at: string | null
id: string
parent_report_id: string | null
patient_id: string
structured_data: Json
type: string
updated_at: string | null
updated_by: string | null
version: string | null
}
Insert: {
ai_confidence?: number | null
ai_reasoning?: string | null
audio_duration_seconds?: number | null
audio_url?: string | null
content: string
created_at?: string
created_by?: string | null
deleted_at?: string | null
id?: string
parent_report_id?: string | null
patient_id: string
structured_data?: Json
type: string
updated_at?: string | null
updated_by?: string | null
version?: string | null
}
Update: {
ai_confidence?: number | null
ai_reasoning?: string | null
audio_duration_seconds?: number | null
audio_url?: string | null
content?: string
created_at?: string
created_by?: string | null
deleted_at?: string | null
id?: string
parent_report_id?: string | null
patient_id?: string
structured_data?: Json
type?: string
updated_at?: string | null
updated_by?: string | null
version?: string | null
}
Relationships: [
{
foreignKeyName: "reports_created_by_fkey"
columns: ["created_by"]
isOneToOne: false
referencedRelation: "practitioners"
referencedColumns: ["id"]
},
{
foreignKeyName: "reports_parent_report_id_fkey"
columns: ["parent_report_id"]
isOneToOne: false
referencedRelation: "reports"
referencedColumns: ["id"]
},
{
foreignKeyName: "reports_patient_id_fkey"
columns: ["patient_id"]
isOneToOne: false
referencedRelation: "patients"
referencedColumns: ["id"]
},
{
foreignKeyName: "reports_updated_by_fkey"
columns: ["updated_by"]
isOneToOne: false
referencedRelation: "practitioners"
referencedColumns: ["id"]
},
]
}
}
Views: {
active_intakes_overview: {
@@ -1646,9 +1643,6 @@ export type CompositeTypes<
: never
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {
careplan_status: [

42
lib/telemetry/speech.ts Normal file
View File

@@ -0,0 +1,42 @@
interface SpeechTelemetryContext {
context: string
patientId?: string
intakeId?: string
reportId?: string
}
export type SpeechTelemetryAction = 'start' | 'stop' | 'final'
export type SpeechTelemetryPayload = SpeechTelemetryContext & {
action: SpeechTelemetryAction
metadata?: Record<string, unknown>
}
export function logSpeechUsage(payload: SpeechTelemetryPayload) {
if (typeof window === 'undefined') return
const body = JSON.stringify(payload)
if (navigator.sendBeacon) {
try {
const blob = new Blob([body], { type: 'application/json' })
navigator.sendBeacon('/api/telemetry/speech', blob)
return
} catch (error) {
console.warn('sendBeacon speech telemetry failed, falling back to fetch', error)
}
}
fetch('/api/telemetry/speech', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
credentials: 'same-origin',
keepalive: true,
}).catch((error) => {
console.warn('Speech telemetry fetch failed', error)
})
}
export type SpeechTelemetryOptions = SpeechTelemetryContext

View File

@@ -43,6 +43,11 @@ const nextConfig = {
},
};
}
// Avoid serializing very large strings into webpack's filesystem cache during production builds
if (!dev) {
config.cache = { type: 'memory' };
}
return config;
},
};

View File

@@ -5,12 +5,14 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"check:bundle": "node scripts/check-bundle-size.js",
"start": "next start",
"lint": "eslint",
"types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts",
"setup:auth-hook": "tsx scripts/setup-auth-hook.ts"
},
"dependencies": {
"@deepgram/sdk": "^4.11.2",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -36,6 +38,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.66.1",
"react-resizable-panels": "^3.0.6",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.181.1",

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env node
import { existsSync, readFileSync, statSync } from 'node:fs'
import path from 'node:path'
const manifestPath = path.join('.next', 'app-build-manifest.json')
if (!existsSync(manifestPath)) {
console.error('✖ Bundle manifest ontbreekt. Voer eerst `next build` uit.')
process.exit(1)
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const budgets = [
{
label: '/epd/patients/[id]/rapportage',
manifestKey: '/epd/patients/[id]/rapportage/page',
maxKB: 150,
},
{
label: '/epd/patients/[id]/intakes/[intakeId]/behandeladvies',
manifestKey: '/epd/patients/[id]/intakes/[intakeId]/behandeladvies/page',
maxKB: 250,
},
]
function formatKB(bytes) {
return Math.round((bytes / 1024) * 10) / 10
}
const SHARED_CHUNK_PATTERNS = [/^static\/chunks\/webpack/, /^static\/chunks\/main-app/]
function isSharedChunk(chunkName) {
return SHARED_CHUNK_PATTERNS.some((pattern) => pattern.test(chunkName))
}
function calculateRouteSize(manifestKey) {
const chunks = manifest.pages?.[manifestKey]
if (!chunks) {
throw new Error(`Geen entry voor ${manifestKey} in app-build-manifest.json`)
}
const seen = new Set()
let totalBytes = 0
for (const chunk of chunks) {
if (seen.has(chunk)) continue
seen.add(chunk)
if (isSharedChunk(chunk)) continue
const filePath = path.join('.next', chunk)
if (!existsSync(filePath)) continue
totalBytes += statSync(filePath).size
}
return totalBytes
}
const violations = []
for (const budget of budgets) {
try {
const sizeBytes = calculateRouteSize(budget.manifestKey)
const sizeKB = formatKB(sizeBytes)
const maxKB = budget.maxKB
if (sizeKB > maxKB) {
violations.push(
`${budget.label}: ${sizeKB} kB > budget ${maxKB} kB (manifest: ${budget.manifestKey})`
)
} else {
console.log(`${budget.label}: ${sizeKB} kB (budget ${maxKB} kB)`)
}
} catch (error) {
violations.push(error.message)
}
}
if (violations.length > 0) {
console.error('\nBundel-check mislukt:')
for (const message of violations) {
console.error(`- ${message}`)
}
process.exit(1)
}
console.log('\nAlle bundels binnen budget. ✅')

View File

@@ -0,0 +1,30 @@
-- ================================================
-- Speech Usage Telemetry Table
-- Created: 2025-11-26
-- Epic: Performance ROI - Speech telemetry
-- ================================================
CREATE TABLE IF NOT EXISTS speech_usage_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
patient_id TEXT,
intake_id TEXT,
report_id TEXT,
action TEXT NOT NULL CHECK (action IN ('start', 'stop', 'final')),
context TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_speech_usage_events_created ON speech_usage_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_speech_usage_events_context ON speech_usage_events(context);
ALTER TABLE speech_usage_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow insert for authenticated users" ON speech_usage_events
FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Allow select own telemetry" ON speech_usage_events
FOR SELECT
USING (auth.uid() = user_id);