feat(swift): Epic 5 Progress - Error Handling & Keyboard Shortcuts (E5.S2-S3)

Epic 5 deels compleet: Error handling en keyboard shortcuts geïmplementeerd.
Voortgang: 62 SP / 72 SP (86%) - Nog 1 story voor MVP compleet!

E5.S2 - Error Handling (2 SP)
- Gecentraliseerde error handler utility (lib/swift/error-handler.ts)
- isOffline(), isNetworkError(), getErrorInfo() functies
- safeFetch() wrapper met 30s timeout en retry logic
- retryFetch() met exponential backoff (max 3 retries)
- User-friendly Nederlandse error messages voor alle HTTP status codes
- OfflineBanner component met online/offline detection
- Alle blocks en CommandInput gebruiken nieuwe error handler
- Context bar margin adjustment voor offline banner
- parseErrorResponse() voor HTML error detection

E5.S3 - Keyboard Shortcuts (2 SP)
- ⌘Enter / Ctrl+Enter quick submit in CommandInput
- ⌘Enter / Ctrl+Enter quick save in DagnotatieBlock
- Visual hints (⌘↵) op submit button in DagnotatieBlock
- Geverifieerd: ⌘K focus, Escape close, 1-3 FallbackPicker
- Cross-platform support (macOS + Windows/Linux)
- preventDefault() voor browser conflict preventie

Components Updated:
- DagnotatieBlock: safeFetch, getErrorInfo, retryFetch, ⌘Enter save
- OverdrachtBlock: safeFetch, retryFetch voor AI generation
- ZoekenBlock: safeFetch, getErrorInfo voor patient search
- CommandInput: safeFetch, getErrorInfo, ⌘Enter submit
- CommandCenter: OfflineBanner integration
- ContextBar: useOffline hook voor margin adjustment

Nieuwe Files:
- lib/swift/error-handler.ts (301 regels) - Error handling utilities
- components/swift/command-center/offline-banner.tsx (72 regels)
- docs/swift/keyboard-shortcuts-reference.md (200+ regels)
- docs/swift/test-plan-e5-s2-error-handling.md (350+ regels)
- docs/swift/test-plan-e5-s3-keyboard-shortcuts.md (350+ regels)
- docs/swift/PROJECT-STATUS-2024-12-27.md (500+ regels) - Status report

Documentatie:
- Bouwplan bijgewerkt naar v2.5
- Epic completion details toegevoegd met progress bars
- Manual test checklist bijgewerkt (17/20 scenarios)
- Risico's sectie bijgewerkt (alle major risks gemitigeerd)
- Sprint planning status: E5 75% compleet (6/8 SP)

Technische verbeteringen:
- Offline detection met visual feedback
- Network error retry met exponential backoff
- HTTP 401/404/500/503 error messages in Nederlands
- Timeout protection (30s) op alle API calls
- Keyboard shortcuts met visual hints
- Cross-platform shortcut support

Testing:
- Error handling test plan met 8 categorieën
- Keyboard shortcuts test plan met 6 categorieën
- 40+ test scenarios gedocumenteerd
- Quick smoke test checklists

Voortgang: 62 SP / 72 SP (86%) - E5.S2 en E5.S3 compleet

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-12-27 09:27:32 +01:00
parent a9d464357b
commit 33c6698870
13 changed files with 1806 additions and 125 deletions

View File

@@ -19,6 +19,7 @@ import { ContextBar } from './context-bar';
import { CommandInput } from './command-input';
import { RecentStrip } from './recent-strip';
import { CanvasArea } from './canvas-area';
import { OfflineBanner } from './offline-banner';
export function CommandCenter() {
const { closeBlock, activeBlock } = useSwiftStore();
@@ -49,6 +50,9 @@ export function CommandCenter() {
return (
<>
{/* Offline Banner */}
<OfflineBanner />
{/* Context Bar - 48px */}
<ContextBar />

View File

@@ -19,6 +19,8 @@ import { useSwiftStore } from '@/stores/swift-store';
import { useSwiftVoice } from '@/lib/swift/use-swift-voice';
import type { BlockType } from '@/lib/swift/types';
import { Mic, MicOff, Send, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { safeFetch, getErrorInfo } from '@/lib/swift/error-handler';
export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_, ref) {
const {
@@ -31,6 +33,7 @@ export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_
openBlock,
addRecentAction,
} = useSwiftStore();
const { toast } = useToast();
const {
isRecording,
@@ -109,8 +112,8 @@ export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_
return 'Typ of spreek je intentie... (bijv. "notitie jan medicatie")';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!hasValue || isProcessing) return;
// Stop recording if active
@@ -123,16 +126,15 @@ export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_
try {
// Call intent classification API
const response = await fetch('/api/intent/classify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: inputText }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Onbekende fout' }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
const response = await safeFetch(
'/api/intent/classify',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: inputText }),
},
{ operation: 'Intent classificeren' }
);
const result = await response.json();
const { intent, confidence, entities } = result;
@@ -158,6 +160,18 @@ export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_
}
} catch (error) {
console.error('Error processing intent:', error);
const statusCode = (error as any)?.statusCode;
const errorInfo = getErrorInfo(error, {
operation: 'Intent classificeren',
statusCode,
});
// Show error toast
toast({
variant: 'destructive',
title: errorInfo.title,
description: errorInfo.description,
});
// On error, show FallbackPicker so user can choose
// This ensures the user's input is not lost
@@ -176,6 +190,20 @@ export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_
}
};
// Keyboard shortcut: Cmd/Ctrl+Enter to submit
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd/Ctrl+Enter: submit command
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSubmit]);
const isDisabled = isProcessing || activeBlock !== null;
return (

View File

@@ -10,6 +10,7 @@
import { useSwiftStore, type ShiftType } from '@/stores/swift-store';
import { Sun, Moon, Sunrise, Sunset, X, User, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { useOffline } from './offline-banner';
const SHIFT_CONFIG: Record<ShiftType, { icon: typeof Sun; label: string; color: string }> = {
nacht: { icon: Moon, label: 'Nachtdienst', color: 'text-indigo-600' },
@@ -20,11 +21,15 @@ const SHIFT_CONFIG: Record<ShiftType, { icon: typeof Sun; label: string; color:
export function ContextBar() {
const { shift, activePatient, setActivePatient } = useSwiftStore();
const isOffline = useOffline();
const shiftConfig = SHIFT_CONFIG[shift];
const ShiftIcon = shiftConfig.icon;
return (
<header className="h-12 border-b border-slate-200 flex items-center px-4 justify-between shrink-0 bg-white">
<header
className="h-12 border-b border-slate-200 flex items-center px-4 justify-between shrink-0 bg-white"
style={isOffline ? { marginTop: '40px' } : undefined}
>
{/* Left: Logo + Shift */}
<div className="flex items-center gap-4">
<Link

View File

@@ -0,0 +1,71 @@
'use client';
/**
* Offline Banner
*
* Toont een banner wanneer de gebruiker offline is.
* E5.S2: Offline detection en melding.
*/
import { useState, useEffect } from 'react';
import { WifiOff } from 'lucide-react';
import { cn } from '@/lib/utils';
export function OfflineBanner() {
const [isOffline, setIsOffline] = useState(false);
useEffect(() => {
// Initial check
setIsOffline(!navigator.onLine);
// Listen for online/offline events
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (!isOffline) return null;
return (
<div
className={cn(
'fixed top-0 left-0 right-0 z-[100] bg-amber-500 text-white px-4 py-2',
'flex items-center justify-center gap-2 text-sm font-medium',
'shadow-md'
)}
style={{ height: '40px' }}
>
<WifiOff className="h-4 w-4" />
<span>Geen internetverbinding</span>
</div>
);
}
/**
* Hook om te checken of de gebruiker offline is
*/
export function useOffline() {
const [isOffline, setIsOffline] = useState(false);
useEffect(() => {
setIsOffline(!navigator.onLine);
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOffline;
}