- {/* Header */}
- {/* Bento Grid */}
- {bentoFeatures.map((feature, index) => (
+ {bentoFeatures.map((feature) => (
- {/* Stats Footer */}
90%+
@@ -240,202 +98,7 @@ export default function LoginPage() {
- {/* Right Side - Login/Signup Form (40%) */}
-
-
- {/* Header */}
-
-
- {mode === 'login' ? 'Welkom terug' : 'Maak een account'}
-
-
- {mode === 'login'
- ? 'Log in om toegang te krijgen tot het EPD'
- : 'Start vandaag nog met snellere rapportages'
- }
-
-
-
- {/* Mode Toggle */}
-
-
-
-
-
- {/* Message Display */}
- {message && (
-
- )}
-
-
-
- {/* Divider */}
-
-
- {/* Quick Demo Button */}
-
-
- {/* Footer */}
-
- Build in Public door{' '}
-
- AI Speedrun
-
-
-
-
+
)
}
diff --git a/components/speech-recorder-streaming.tsx b/components/speech-recorder-streaming.tsx
new file mode 100644
index 0000000..01067d5
--- /dev/null
+++ b/components/speech-recorder-streaming.tsx
@@ -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 (
+
+ {icon}
+ {label}
+
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Waveform Visualizer Component
+// ─────────────────────────────────────────────────────────────────────────────
+
+function WaveformVisualizer({
+ analyserNode,
+ isActive,
+}: {
+ analyserNode: AnalyserNode | null
+ isActive: boolean
+}) {
+ const canvasRef = useRef
(null)
+ const animationRef = useRef(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 (
+
+ )
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Main Component
+// ─────────────────────────────────────────────────────────────────────────────
+
+export function SpeechRecorderStreaming({
+ onTranscript,
+ onInterimTranscript,
+ onWordsUpdate,
+ disabled = false,
+ className,
+ onRecordingStart,
+ onRecordingStop,
+ telemetryContext,
+}: SpeechRecorderStreamingProps) {
+ const [interimText, setInterimText] = useState('')
+ const [allWords, setAllWords] = useState([])
+ const [localError, setLocalError] = useState(null)
+ const [isAutoPaused, setIsAutoPaused] = useState(false)
+
+ // Accumulate final transcript parts
+ const finalPartsRef = useRef([])
+ const autoPauseTriggeredRef = useRef(false)
+ const telemetryRef = useRef(telemetryContext)
+
+ useEffect(() => {
+ telemetryRef.current = telemetryContext
+ }, [telemetryContext])
+
+ const trackSpeechUsage = useCallback(
+ (action: 'start' | 'stop' | 'final', metadata?: Record) => {
+ 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 (
+
+ {/* Header */}
+
+
+
+ Opname
+
+
+
+
+
+
+
+ {/* Browser not supported message */}
+ {!isBrowserSupported && (
+
+ ⚠ Spraakopname wordt niet ondersteund in deze browser. Gebruik Chrome, Firefox of Edge voor de beste ervaring.
+
+ )}
+
+ {/* Error message */}
+ {error && (
+
+ ⚠ {error}
+
+ )}
+
+ {/* Reconnecting message */}
+ {isReconnecting && (
+
+
+ Herverbinden... Transcript blijft behouden.
+
+ )}
+
+ {/* Interim text preview */}
+ {interimText && (
+
+ {interimText}
+
+ )}
+
+ {/* Waveform */}
+
+
+ {/* Confidence preview */}
+ {allWords.length > 0 && (
+
+ )}
+
+ {/* Auto-pause message (3 sec stilte) */}
+ {isAutoPaused && isRecording && (
+
+
+
+ Automatisch gepauzeerd
+ (3 seconden stilte)
+
+
+ )}
+
+ {/* Manual pause message */}
+ {isPaused && !isAutoPaused && isRecording && (
+
+ )}
+
+ {/* Controls */}
+
+ {!isRecording ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/components/ui/bento-grid.tsx b/components/ui/bento-grid.tsx
index 256749b..3dfb3a0 100644
--- a/components/ui/bento-grid.tsx
+++ b/components/ui/bento-grid.tsx
@@ -1,5 +1,3 @@
-'use client'
-
import { ReactNode } from "react";
import { ArrowRight } from "lucide-react";
diff --git a/docs/reports/efficiency-plan.md b/docs/reports/efficiency-plan.md
new file mode 100644
index 0000000..9cba84c
--- /dev/null
+++ b/docs/reports/efficiency-plan.md
@@ -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: () => }
+);
+```
+
+_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: () => }
+);
+```
+
+**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: () => }
+);
+```
+
+_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`
diff --git a/lib/supabase/database.types.ts b/lib/supabase/database.types.ts
index c2b6e63..5c2a818 100644
--- a/lib/supabase/database.types.ts
+++ b/lib/supabase/database.types.ts
@@ -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: [
diff --git a/lib/telemetry/speech.ts b/lib/telemetry/speech.ts
new file mode 100644
index 0000000..d0363f6
--- /dev/null
+++ b/lib/telemetry/speech.ts
@@ -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
+}
+
+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
diff --git a/next.config.mjs b/next.config.mjs
index 81905b8..63e8fc8 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -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;
},
};
diff --git a/package.json b/package.json
index 65905c9..8c9c05d 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/check-bundle-size.js b/scripts/check-bundle-size.js
new file mode 100644
index 0000000..ffe363b
--- /dev/null
+++ b/scripts/check-bundle-size.js
@@ -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. ✅')
diff --git a/supabase/migrations/20251126_add_speech_usage_events.sql b/supabase/migrations/20251126_add_speech_usage_events.sql
new file mode 100644
index 0000000..d1e34d9
--- /dev/null
+++ b/supabase/migrations/20251126_add_speech_usage_events.sql
@@ -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);