From 7033329e0f5e453b29fa58d9e1434df28c8a3ac7 Mon Sep 17 00:00:00 2001 From: colinislit Date: Tue, 25 Nov 2025 13:37:00 +0100 Subject: [PATCH] feat: add speech telemetry and bundle guard --- app/api/telemetry/speech/route.ts | 56 ++ .../components/treatment-advice-form.tsx | 65 ++- app/epd/patients/[id]/intakes/new/page.tsx | 22 +- .../components/rapportage-workspace-v2.tsx | 240 ++++++++ .../rapportage/components/report-composer.tsx | 99 +++- .../components/report-view-edit-modal.tsx | 547 ++++++++++++++++++ app/login/components/login-form.tsx | 255 ++++++++ app/login/page.tsx | 347 +---------- components/speech-recorder-streaming.tsx | 460 +++++++++++++++ components/ui/bento-grid.tsx | 2 - docs/reports/efficiency-plan.md | 259 +++++++++ lib/supabase/database.types.ts | 222 ++++--- lib/telemetry/speech.ts | 42 ++ next.config.mjs | 5 + package.json | 3 + scripts/check-bundle-size.js | 85 +++ .../20251126_add_speech_usage_events.sql | 30 + 17 files changed, 2258 insertions(+), 481 deletions(-) create mode 100644 app/api/telemetry/speech/route.ts create mode 100644 app/epd/patients/[id]/rapportage/components/rapportage-workspace-v2.tsx create mode 100644 app/epd/patients/[id]/rapportage/components/report-view-edit-modal.tsx create mode 100644 app/login/components/login-form.tsx create mode 100644 components/speech-recorder-streaming.tsx create mode 100644 docs/reports/efficiency-plan.md create mode 100644 lib/telemetry/speech.ts create mode 100644 scripts/check-bundle-size.js create mode 100644 supabase/migrations/20251126_add_speech_usage_events.sql diff --git a/app/api/telemetry/speech/route.ts b/app/api/telemetry/speech/route.ts new file mode 100644 index 0000000..7ecd9f0 --- /dev/null +++ b/app/api/telemetry/speech/route.ts @@ -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 }) + } +} diff --git a/app/epd/patients/[id]/intakes/[intakeId]/behandeladvies/components/treatment-advice-form.tsx b/app/epd/patients/[id]/intakes/[intakeId]/behandeladvies/components/treatment-advice-form.tsx index 7e8bf71..154ce1d 100644 --- a/app/epd/patients/[id]/intakes/[intakeId]/behandeladvies/components/treatment-advice-form.tsx +++ b/app/epd/patients/[id]/intakes/[intakeId]/behandeladvies/components/treatment-advice-form.tsx @@ -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: () => } +); + +const SpeechRecorderStreaming = dynamic( + () => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming), + { ssr: false, loading: () => } +); + +function EditorSkeleton() { + return ( +
+
+
+
+ ); +} + +function RecorderSkeleton() { + return ( +
+
+
+
+ ); +} 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(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
- + + {interimText && ( +
+ {interimText} +
+ )} setForm((prev) => ({ ...prev, advice: html }))} diff --git a/app/epd/patients/[id]/intakes/new/page.tsx b/app/epd/patients/[id]/intakes/new/page.tsx index a86b3dd..5296f17 100644 --- a/app/epd/patients/[id]/intakes/new/page.tsx +++ b/app/epd/patients/[id]/intakes/new/page.tsx @@ -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: () => } +); + +function FormSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + interface NewIntakePageProps { params: Promise<{ id: string }>; } @@ -31,4 +50,3 @@ export default async function NewIntakePage({ params }: NewIntakePageProps) {
); } - diff --git a/app/epd/patients/[id]/rapportage/components/rapportage-workspace-v2.tsx b/app/epd/patients/[id]/rapportage/components/rapportage-workspace-v2.tsx new file mode 100644 index 0000000..51ba95e --- /dev/null +++ b/app/epd/patients/[id]/rapportage/components/rapportage-workspace-v2.tsx @@ -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: () => } +) + +function ModalSkeleton() { + return ( +
+
+
+
+
+
+ ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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('vrije_notitie') + const [selectedReport, setSelectedReport] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + const [duplicateContent, setDuplicateContent] = useState(null) + const [isComposerCollapsed, setIsComposerCollapsed] = useState(false) + const [panelsLib, setPanelsLib] = useState(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 = ( +
+
+
+

Nieuwe rapportage

+ {PanelGroupComponent ? ( + + ) : ( + Layout optimalisatie laden… + )} +
+ + +
+ +
+ setDuplicateContent(null)} + /> +
+
+ ) + + return ( +
+
+ {PanelGroupComponent && PanelComponent && PanelResizeHandleComponent ? ( + + + + + + {!isComposerCollapsed && ( + +
+ + )} + + {!isComposerCollapsed && ( + + {composerPanel} + + )} + + {isComposerCollapsed && ( +
+ +
+ )} + + ) : ( +
+
+ +
+
+ {composerPanel} +
+
+ )} +
+ + {isModalOpen && selectedReport && ( + + )} +
+ ) +} diff --git a/app/epd/patients/[id]/rapportage/components/report-composer.tsx b/app/epd/patients/[id]/rapportage/components/report-composer.tsx index cb20923..e4e2680 100644 --- a/app/epd/patients/[id]/rapportage/components/report-composer.tsx +++ b/app/epd/patients/[id]/rapportage/components/report-composer.tsx @@ -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: () => } +); + +function RecorderSkeleton() { + return ( +
+
+
+
+ ); +} 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(null); const [content, setContent] = useState(''); const [classification, setClassification] = useState(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(null); const [lastAutosave, setLastAutosave] = useState(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({
-
-

Nieuwe rapportage

-

Voor {patientName}

-

- Schrijf vanuit tekst of spraak en gebruik AI voor typebepaling. -

-
- {referenceSnippet && (
@@ -205,22 +255,41 @@ export function ReportComposer({