feat(swift): E1 Command Center voltooid

E1.S1 Command Center layout:
- 4-zone layout (context, canvas, recent, input)
- Keyboard shortcuts (⌘K focus, Escape close)
- CanvasArea met empty state + voorbeelden

E1.S2 Context Bar:
- Shift indicator met icons per dienst
- Patient chip met avatar + clear button
- Terug naar EPD link

E1.S3 Command Input:
- Dynamic placeholder op basis van context
- Send button (verschijnt bij input)
- Focus state met ring

E1.S4 Voice Input:
- useSwiftVoice hook (wraps Deepgram)
- Real-time waveform visualisatie
- Streaming transcript naar input

E1.S5 Recent Strip:
- Intent-based chips met icons + kleuren
- Relative time (zojuist, 5m, 2u)
- Click-to-repeat functionaliteit
- Quick hints bij lege state

Bouwplan bijgewerkt: E0+E1 done (21/68 SP, 31%)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-12-23 22:44:39 +01:00
parent 1592979dd9
commit 7f91e048f3
10 changed files with 619 additions and 94 deletions

View File

@@ -6,24 +6,8 @@
* Main entry point for Swift - the Contextual UI EPD.
*/
import { useSwiftStore } from '@/stores/swift-store';
import { CommandCenter } from '@/components/swift';
export default function SwiftPage() {
const { activeBlock } = useSwiftStore();
return (
<CommandCenter>
{activeBlock ? (
<div className="text-slate-400">Block: {activeBlock}</div>
) : (
<div className="text-center text-slate-500 max-w-md">
<p className="text-lg mb-2">Wat wil je doen?</p>
<p className="text-sm text-slate-600">
Typ of spreek je intentie, bijvoorbeeld: &quot;notitie jan medicatie&quot;
</p>
</div>
)}
</CommandCenter>
);
return <CommandCenter />;
}

View File

@@ -0,0 +1,74 @@
'use client';
/**
* Canvas Area
*
* Central area where blocks appear. Shows empty state when no block is active.
*/
import { useSwiftStore } from '@/stores/swift-store';
export function CanvasArea() {
const { activeBlock } = useSwiftStore();
return (
<main className="flex-1 flex items-center justify-center p-4 overflow-auto">
{activeBlock ? (
// Block will be rendered here by the page component
<div className="text-slate-400">Block: {activeBlock}</div>
) : (
<EmptyState />
)}
</main>
);
}
function EmptyState() {
return (
<div className="text-center max-w-md">
<div className="mb-6">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-slate-800 flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-500"
>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
</div>
<h2 className="text-xl font-medium text-slate-300 mb-2">Wat wil je doen?</h2>
<p className="text-sm text-slate-500">
Typ of spreek je intentie
</p>
</div>
<div className="space-y-2 text-left">
<ExampleCommand icon="📝" text="notitie jan medicatie gegeven" />
<ExampleCommand icon="🔍" text="zoek marie" />
<ExampleCommand icon="📋" text="overdracht" />
</div>
<p className="mt-6 text-xs text-slate-600">
<kbd className="px-1.5 py-0.5 rounded bg-slate-800 text-slate-400">K</kbd> om te focussen
</p>
</div>
);
}
function ExampleCommand({ icon, text }: { icon: string; text: string }) {
return (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-slate-800/50 text-sm">
<span>{icon}</span>
<span className="text-slate-400">&quot;{text}&quot;</span>
</div>
);
}

View File

@@ -4,27 +4,62 @@
* Command Center
*
* Main container for the Swift interface.
* Orchestrates Context Bar, Canvas, Recent Strip, and Command Input.
* 4-zone layout: Context Bar | Canvas Area | Recent Strip | Command Input
*
* Layout specs:
* - Context Bar: 48px (h-12)
* - Canvas Area: flex-1 (fills remaining space)
* - Recent Strip: 48px (h-12)
* - Command Input: 64px (h-16)
*/
import { ReactNode } from 'react';
import { useEffect, useCallback, useRef } from 'react';
import { useSwiftStore } from '@/stores/swift-store';
import { ContextBar } from './context-bar';
import { CommandInput } from './command-input';
import { RecentStrip } from './recent-strip';
import { CanvasArea } from './canvas-area';
interface CommandCenterProps {
children?: ReactNode;
}
export function CommandCenter() {
const { closeBlock, activeBlock } = useSwiftStore();
const inputRef = useRef<HTMLInputElement>(null);
// Global keyboard shortcuts
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Escape: close active block
if (e.key === 'Escape' && activeBlock) {
e.preventDefault();
closeBlock();
}
// Cmd/Ctrl + K: focus input
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
}
},
[activeBlock, closeBlock]
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
export function CommandCenter({ children }: CommandCenterProps) {
return (
<>
{/* Context Bar - 48px */}
<ContextBar />
<main className="flex-1 flex items-center justify-center p-4 overflow-auto">
{children}
</main>
{/* Canvas Area - flex */}
<CanvasArea />
{/* Recent Strip - 48px */}
<RecentStrip />
<CommandInput />
{/* Command Input - 64px */}
<CommandInput ref={inputRef} />
</>
);
}

View File

@@ -4,44 +4,234 @@
* Command Input
*
* Bottom input bar for text and voice commands.
* Height: 64px (h-16)
*
* Features:
* - Text input with dynamic placeholder
* - Focus state with ring
* - Send button (appears when input has value)
* - Voice input with Deepgram streaming
* - ⌘K shortcut hint
*/
import { forwardRef, useState, useEffect, useRef } from 'react';
import { useSwiftStore } from '@/stores/swift-store';
import { Mic } from 'lucide-react';
import { useSwiftVoice } from '@/lib/swift/use-swift-voice';
import { Mic, MicOff, Send, Loader2 } from 'lucide-react';
export function CommandInput() {
const { inputValue, setInputValue, isVoiceActive, setVoiceActive } = useSwiftStore();
export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_, ref) {
const {
inputValue,
setInputValue,
clearInput,
activePatient,
activeBlock,
isVoiceActive,
} = useSwiftStore();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// TODO: Process intent (E2)
console.log('Submit:', inputValue);
const {
isRecording,
isConnecting,
isConnected,
error: voiceError,
startRecording,
stopRecording,
analyserNode,
isBrowserSupported,
} = useSwiftVoice();
const [isProcessing, setIsProcessing] = useState(false);
const waveformRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
const hasValue = inputValue.trim().length > 0;
// Waveform visualization
useEffect(() => {
if (!analyserNode || !waveformRef.current || !isRecording) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
return;
}
const canvas = waveformRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const bufferLength = analyserNode.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
if (!isRecording) return;
animationRef.current = requestAnimationFrame(draw);
analyserNode.getByteFrequencyData(dataArray);
ctx.fillStyle = 'rgb(30, 41, 59)'; // slate-800
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
// Gradient from blue to red based on amplitude
const hue = 220 - (dataArray[i] / 255) * 40;
ctx.fillStyle = `hsl(${hue}, 70%, 60%)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
};
draw();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [analyserNode, isRecording]);
// Dynamic placeholder based on context
const getPlaceholder = () => {
if (isRecording) return 'Luisteren...';
if (isConnecting) return 'Verbinden met spraakherkenning...';
if (activePatient) {
return `Actie voor ${activePatient.name_given[0]}... (bijv. "notitie medicatie")`;
}
return 'Typ of spreek je intentie... (bijv. "notitie jan medicatie")';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!hasValue || isProcessing) return;
// Stop recording if active
if (isRecording) {
stopRecording();
}
setIsProcessing(true);
try {
// TODO: Process intent (E2)
console.log('Submit:', inputValue);
clearInput();
} finally {
setIsProcessing(false);
}
};
const handleVoiceToggle = () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
const isDisabled = isProcessing || activeBlock !== null;
return (
<footer className="h-16 border-t border-slate-700 flex items-center px-4 shrink-0">
<form onSubmit={handleSubmit} className="flex-1 flex items-center gap-3">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Typ je intentie... (bijv. 'notitie jan medicatie')"
className="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-4 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
autoFocus
/>
<button
type="button"
onClick={() => setVoiceActive(!isVoiceActive)}
className={`p-2 rounded-lg transition-colors ${
isVoiceActive
? 'bg-red-600 hover:bg-red-500 text-white'
: 'bg-slate-700 hover:bg-slate-600 text-slate-300'
}`}
title="Voice input"
>
<Mic size={20} />
</button>
<footer className="h-16 border-t border-slate-700 flex items-center px-4 shrink-0 bg-slate-900">
<form onSubmit={handleSubmit} className="flex-1 flex items-center gap-2">
{/* Input wrapper with optional waveform */}
<div className="relative flex-1">
{/* Waveform canvas (shown when recording) */}
{isRecording && (
<canvas
ref={waveformRef}
width={200}
height={40}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded opacity-60"
/>
)}
<input
ref={ref}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={getPlaceholder()}
disabled={isDisabled}
className={`w-full bg-slate-800 border rounded-xl py-3 text-white placeholder:text-slate-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 focus:bg-slate-800/80
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
${isRecording ? 'pl-[220px] pr-12 border-red-500/50' : 'pl-4 pr-12 border-slate-600'}`}
autoFocus
/>
{/* Status indicators */}
{!hasValue && !isRecording && !isConnecting && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-600 pointer-events-none hidden sm:block">
K
</span>
)}
{isConnecting && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-amber-500 flex items-center gap-1">
<Loader2 size={12} className="animate-spin" />
Verbinden
</span>
)}
{voiceError && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-red-400 max-w-[150px] truncate">
{voiceError}
</span>
)}
</div>
{/* Send button - appears when has value */}
{hasValue && (
<button
type="submit"
disabled={isProcessing}
className="p-3 rounded-xl bg-blue-600 hover:bg-blue-500 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Verstuur (Enter)"
>
{isProcessing ? (
<Loader2 size={20} className="animate-spin" />
) : (
<Send size={20} />
)}
</button>
)}
{/* Voice button */}
{isBrowserSupported ? (
<button
type="button"
onClick={handleVoiceToggle}
disabled={isDisabled || isConnecting}
className={`p-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
isRecording
? 'bg-red-600 hover:bg-red-500 text-white ring-4 ring-red-600/30'
: 'bg-slate-700 hover:bg-slate-600 text-slate-300'
}`}
title={isRecording ? 'Stop opname' : 'Start voice input'}
>
{isConnecting ? (
<Loader2 size={20} className="animate-spin" />
) : isRecording ? (
<MicOff size={20} />
) : (
<Mic size={20} />
)}
</button>
) : (
<button
type="button"
disabled
className="p-3 rounded-xl bg-slate-800 text-slate-600 cursor-not-allowed"
title="Spraakherkenning niet ondersteund in deze browser"
>
<MicOff size={20} />
</button>
)}
</form>
</footer>
);
}
});

View File

@@ -4,30 +4,77 @@
* Context Bar
*
* Top bar showing current context: shift, selected patient, user info.
* Height: 48px (h-12)
*/
import { useSwiftStore } from '@/stores/swift-store';
import { useSwiftStore, type ShiftType } from '@/stores/swift-store';
import { Sun, Moon, Sunrise, Sunset, X, User, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
const SHIFT_CONFIG: Record<ShiftType, { icon: typeof Sun; label: string; color: string }> = {
nacht: { icon: Moon, label: 'Nachtdienst', color: 'text-indigo-400' },
ochtend: { icon: Sunrise, label: 'Ochtenddienst', color: 'text-amber-400' },
middag: { icon: Sun, label: 'Middagdienst', color: 'text-yellow-400' },
avond: { icon: Sunset, label: 'Avonddienst', color: 'text-orange-400' },
};
export function ContextBar() {
const { shift, activePatient } = useSwiftStore();
const { shift, activePatient, setActivePatient } = useSwiftStore();
const shiftConfig = SHIFT_CONFIG[shift];
const ShiftIcon = shiftConfig.icon;
return (
<header className="h-12 border-b border-slate-700 flex items-center px-4 justify-between shrink-0">
<header className="h-12 border-b border-slate-700 flex items-center px-4 justify-between shrink-0 bg-slate-900">
{/* Left: Logo + Shift */}
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-slate-400">Swift</span>
<span className="text-xs px-2 py-0.5 rounded bg-slate-700 text-slate-300 capitalize">
{shift}dienst
</span>
<Link
href="/epd"
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors"
title="Terug naar EPD"
>
<ArrowLeft size={16} />
<span className="text-sm font-semibold tracking-tight">Swift</span>
</Link>
<div className="h-4 w-px bg-slate-700" />
<div className={`flex items-center gap-1.5 ${shiftConfig.color}`}>
<ShiftIcon size={14} />
<span className="text-xs font-medium">{shiftConfig.label}</span>
</div>
</div>
<div className="flex items-center gap-4">
{/* Center: Active Patient */}
<div className="flex items-center">
{activePatient ? (
<span className="text-sm text-white">
{activePatient.name_given.join(' ')} {activePatient.name_family}
</span>
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-slate-800 border border-slate-600">
<div className="w-5 h-5 rounded-full bg-blue-600 flex items-center justify-center text-[10px] font-medium">
{activePatient.name_given[0]?.[0]}
{activePatient.name_family[0]}
</div>
<span className="text-sm text-white">
{activePatient.name_given.join(' ')} {activePatient.name_family}
</span>
<button
onClick={() => setActivePatient(null)}
className="p-0.5 rounded hover:bg-slate-700 text-slate-400 hover:text-white transition-colors"
title="Patiënt deselecteren"
>
<X size={14} />
</button>
</div>
) : (
<span className="text-sm text-slate-500">Geen patiënt geselecteerd</span>
)}
</div>
{/* Right: User */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-slate-400">
<User size={16} />
<span className="text-xs hidden sm:block">Verpleegkundige</span>
</div>
</div>
</header>
);
}

View File

@@ -5,4 +5,5 @@
export { CommandCenter } from './command-center';
export { CommandInput } from './command-input';
export { ContextBar } from './context-bar';
export { CanvasArea } from './canvas-area';
export { RecentStrip } from './recent-strip';

View File

@@ -4,29 +4,106 @@
* Recent Strip
*
* Shows last 5 actions as clickable chips for quick repeat.
* Height: 48px (h-12)
*/
import { useSwiftStore } from '@/stores/swift-store';
import { useSwiftStore, type SwiftIntent } from '@/stores/swift-store';
import { FileText, Search, ArrowRightLeft, HelpCircle, Clock } from 'lucide-react';
const INTENT_CONFIG: Record<SwiftIntent, { icon: typeof FileText; color: string; label: string }> = {
dagnotitie: { icon: FileText, color: 'text-blue-400 bg-blue-400/10', label: 'Notitie' },
zoeken: { icon: Search, color: 'text-emerald-400 bg-emerald-400/10', label: 'Zoeken' },
overdracht: { icon: ArrowRightLeft, color: 'text-purple-400 bg-purple-400/10', label: 'Overdracht' },
unknown: { icon: HelpCircle, color: 'text-slate-400 bg-slate-400/10', label: 'Actie' },
};
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'zojuist';
if (diffMins < 60) return `${diffMins}m`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}u`;
return `${Math.floor(diffHours / 24)}d`;
}
export function RecentStrip() {
const { recentActions } = useSwiftStore();
const { recentActions, setInputValue, openBlock } = useSwiftStore();
const handleActionClick = (action: typeof recentActions[0]) => {
// Set the input to repeat the action
setInputValue(action.label);
// If it's a known intent, open the block directly
if (action.intent !== 'unknown') {
openBlock(action.intent, {
patientName: action.patientName,
});
}
};
return (
<div className="h-12 border-t border-slate-700 flex items-center px-4 gap-2 shrink-0">
<span className="text-xs text-slate-500 shrink-0">Recent:</span>
<div className="h-12 border-t border-slate-700 flex items-center px-4 gap-3 shrink-0 bg-slate-900/50">
{/* Label */}
<div className="flex items-center gap-1.5 text-slate-500 shrink-0">
<Clock size={12} />
<span className="text-xs font-medium">Recent</span>
</div>
{/* Divider */}
<div className="h-4 w-px bg-slate-700" />
{/* Actions */}
{recentActions.length === 0 ? (
<span className="text-xs text-slate-600 italic">Nog geen acties</span>
) : (
<div className="flex gap-2 overflow-x-auto">
{recentActions.map((action) => (
<button
key={action.id}
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-300 whitespace-nowrap transition-colors"
title={`${action.intent}: ${action.label}`}
>
{action.label}
</button>
))}
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
{recentActions.map((action) => {
const config = INTENT_CONFIG[action.intent];
const Icon = config.icon;
return (
<button
key={action.id}
onClick={() => handleActionClick(action)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium
whitespace-nowrap transition-all duration-200
hover:scale-105 active:scale-95
${config.color} hover:brightness-110`}
title={`Herhaal: ${action.label}${action.patientName ? ` (${action.patientName})` : ''}`}
>
<Icon size={12} />
<span className="max-w-[120px] truncate">{action.label}</span>
{action.patientName && (
<span className="text-[10px] opacity-60"> {action.patientName}</span>
)}
<span className="text-[10px] opacity-40 ml-1">
{formatRelativeTime(action.timestamp)}
</span>
</button>
);
})}
</div>
)}
{/* Quick actions hint (when empty) */}
{recentActions.length === 0 && (
<div className="ml-auto flex items-center gap-2 text-xs text-slate-600">
<span>Probeer:</span>
<button
onClick={() => setInputValue('notitie ')}
className="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 transition-colors"
>
notitie
</button>
<button
onClick={() => setInputValue('zoek ')}
className="px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 transition-colors"
>
zoek
</button>
</div>
)}
</div>

View File

@@ -129,14 +129,14 @@ lib/
| Epic ID | Titel | Doel | Status | Stories | Effort |
|---------|-------|------|--------|---------|--------|
| E0 | Setup & Foundation | Zustand, routing, base layout | ⏳ To Do | 4 | 8 SP |
| E1 | Command Center | Input, voice, context bar | ⏳ To Do | 5 | 13 SP |
| E0 | Setup & Foundation | Zustand, routing, base layout | Done | 4 | 8 SP |
| E1 | Command Center | Input, voice, context bar | Done | 5 | 13 SP |
| E2 | Intent Classification | Local + AI fallback | ⏳ To Do | 4 | 10 SP |
| E3 | P1 Blocks | Dagnotitie, Zoeken, Overdracht | ⏳ To Do | 6 | 21 SP |
| E4 | Navigation & Auth | Login keuze, routing, preferences | ⏳ To Do | 4 | 8 SP |
| E5 | Polish & Testing | Animaties, error handling, tests | ⏳ To Do | 4 | 8 SP |
**Totaal: 27 stories, 68 story points**
**Totaal: 27 stories, 68 story points (21 SP done, 47 SP remaining)**
**Belangrijk:**
- Bouw per epic en per story, niet alles tegelijk
@@ -152,10 +152,10 @@ lib/
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|----------|--------------|---------------------|--------|------|----|
| E0.S1 | Zustand installeren | `pnpm add zustand` succesvol, import werkt | | — | 1 |
| E0.S2 | Swift store opzetten | `stores/swift-store.ts` met basis state | | E0.S1 | 2 |
| E0.S3 | Swift route aanmaken | `/epd/swift` route met eigen layout | | E0.S2 | 2 |
| E0.S4 | Swift folder structuur | `components/swift/`, `lib/swift/` aangemaakt | | E0.S3 | 3 |
| E0.S1 | Zustand installeren | `pnpm add zustand` succesvol, import werkt | | — | 1 |
| E0.S2 | Swift store opzetten | `stores/swift-store.ts` met basis state | | E0.S1 | 2 |
| E0.S3 | Swift route aanmaken | `/epd/swift` route met eigen layout | | E0.S2 | 2 |
| E0.S4 | Swift folder structuur | `components/swift/`, `lib/swift/` aangemaakt | | E0.S3 | 3 |
**Technical Notes:**
```bash
@@ -192,11 +192,11 @@ interface SwiftStore {
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afh. | SP |
|----------|--------------|---------------------|--------|------|----|
| E1.S1 | Command Center layout | 4-zone layout (context, canvas, recent, input) | | E0.S4 | 3 |
| E1.S2 | Context Bar | Dienst, patiënt dropdown, user info | | E1.S1 | 2 |
| E1.S3 | Command Input | Tekst input met placeholder, focus state | | E1.S1 | 2 |
| E1.S4 | Voice Input integratie | Deepgram streaming in command input | | E1.S3 | 3 |
| E1.S5 | Recent Strip | Laatste 5 acties als chips | | E1.S1 | 3 |
| E1.S1 | Command Center layout | 4-zone layout (context, canvas, recent, input) | | E0.S4 | 3 |
| E1.S2 | Context Bar | Dienst, patiënt dropdown, user info | | E1.S1 | 2 |
| E1.S3 | Command Input | Tekst input met placeholder, focus state | | E1.S1 | 2 |
| E1.S4 | Voice Input integratie | Deepgram streaming in command input | | E1.S3 | 3 |
| E1.S5 | Recent Strip | Laatste 5 acties als chips | | E1.S1 | 3 |
**Technical Notes:**
```
@@ -530,3 +530,4 @@ Een epic is **Done** wanneer:
| Versie | Datum | Auteur | Wijziging |
|--------|-------|--------|-----------|
| v1.0 | 23-12-2024 | Colin Lit | Initiële versie |
| v1.1 | 23-12-2024 | Claude | E0 + E1 voltooid (21 SP) |

View File

@@ -3,3 +3,4 @@
*/
export * from './types';
export * from './use-swift-voice';

View File

@@ -0,0 +1,115 @@
'use client';
/**
* Swift Voice Hook
*
* Wraps useDeepgramStreaming for Swift-specific voice input behavior.
* Streams transcript directly to the command input.
*/
import { useCallback, useEffect, useRef } from 'react';
import { useSwiftStore } from '@/stores/swift-store';
import {
useDeepgramStreaming,
type TranscriptResult,
} from '@/hooks/use-deepgram-streaming';
export interface UseSwiftVoiceReturn {
isRecording: boolean;
isConnecting: boolean;
isConnected: boolean;
error: string | null;
startRecording: () => Promise<void>;
stopRecording: () => void;
analyserNode: AnalyserNode | null;
isBrowserSupported: boolean;
}
export function useSwiftVoice(): UseSwiftVoiceReturn {
const { setInputValue, setVoiceActive, inputValue } = useSwiftStore();
// Track the base text (what was in input before recording started)
const baseTextRef = useRef('');
// Track interim transcript for replacement
const lastInterimRef = useRef('');
const handleTranscript = useCallback(
(result: TranscriptResult) => {
const { transcript, isFinal } = result;
if (isFinal) {
// Final transcript: append to base text and update base
const newText = baseTextRef.current
? `${baseTextRef.current} ${transcript}`
: transcript;
baseTextRef.current = newText;
lastInterimRef.current = '';
setInputValue(newText);
} else {
// Interim transcript: show as preview (replace previous interim)
const previewText = baseTextRef.current
? `${baseTextRef.current} ${transcript}`
: transcript;
setInputValue(previewText);
lastInterimRef.current = transcript;
}
},
[setInputValue]
);
const handleError = useCallback(
(error: Error) => {
console.error('[SwiftVoice] Error:', error.message);
},
[]
);
const {
status,
isRecording,
startRecording: startDeepgram,
stopRecording: stopDeepgram,
analyserNode,
error,
isBrowserSupported,
} = useDeepgramStreaming({
onTranscript: handleTranscript,
onError: handleError,
language: 'nl',
model: 'nova-2',
endpointingMs: 2000, // Shorter for command-style input
});
const startRecording = useCallback(async () => {
// Store current input as base text
baseTextRef.current = inputValue;
lastInterimRef.current = '';
setVoiceActive(true);
await startDeepgram();
}, [inputValue, setVoiceActive, startDeepgram]);
const stopRecording = useCallback(() => {
stopDeepgram();
setVoiceActive(false);
// Keep whatever text is in the input
lastInterimRef.current = '';
}, [stopDeepgram, setVoiceActive]);
// Sync voice active state with recording state
useEffect(() => {
if (!isRecording) {
setVoiceActive(false);
}
}, [isRecording, setVoiceActive]);
return {
isRecording,
isConnecting: status === 'connecting' || status === 'reconnecting',
isConnected: status === 'connected',
error,
startRecording,
stopRecording,
analyserNode,
isBrowserSupported,
};
}