perf: Performance optimalisaties (4 iteraties)

Iteratie 1 - Parallel Fetching:
- Patient dashboard: Promise.all voor 3 data fetches
- Resultaat: ~45% sneller (800ms → 440ms)

Iteratie 2 - Loading States:
- Skeleton loaders voor EPD, patient, agenda, rapportage
- Betere perceived performance tijdens laden

Iteratie 3 - Lazy Load FullCalendar:
- Dynamic import met next/dynamic (ssr: false)
- ~150KB minder in initiële bundle

Iteratie 4 - Reports API Optimalisatie:
- Selectieve kolommen (10 i.p.v. 20)
- Server-side pagination (limit/offset)
- Database indexes voor timeline queries
- Resultaat: 89% sneller (1671ms → 188ms)

Overige fixes:
- Type casts voor Json → Behandelstructuur/SmartGoal/etc.
- Metadata template fix in layout.tsx

Documentatie:
- docs/audit-rapport.md - PO-vriendelijk audit rapport
- docs/performance/baseline-2025-12-12.md - Metingen + resultaten

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-12-12 15:24:38 +01:00
parent 18011731c5
commit 0b2f55c1e4
13 changed files with 686 additions and 70 deletions

View File

@@ -7,10 +7,25 @@
*/
import React, { useState, useCallback, useRef, useTransition, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { startOfWeek, endOfWeek, addDays, format } from 'date-fns';
import { toast } from '@/hooks/use-toast';
import { AgendaCalendar } from './agenda-calendar';
// Lazy load FullCalendar component (~150KB+ savings)
const AgendaCalendar = dynamic(
() => import('./agenda-calendar').then((mod) => mod.AgendaCalendar),
{
ssr: false,
loading: () => (
<div className="h-full bg-slate-50 rounded-lg flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600" />
<span className="text-sm text-slate-500">Agenda laden...</span>
</div>
</div>
),
}
);
import { AgendaToolbar } from './agenda-toolbar';
import { AppointmentModal } from './appointment-modal';
import { RescheduleDialog } from './reschedule-dialog';

View File

@@ -0,0 +1,19 @@
export default function AgendaLoading() {
return (
<div className="p-6 space-y-4">
{/* Header skeleton */}
<div className="flex items-center justify-between">
<div className="h-8 w-32 bg-slate-200 rounded animate-pulse" />
<div className="flex gap-2">
<div className="h-10 w-24 bg-slate-100 rounded animate-pulse" />
<div className="h-10 w-24 bg-slate-100 rounded animate-pulse" />
</div>
</div>
{/* Calendar skeleton */}
<div className="h-[600px] bg-slate-100 rounded-lg animate-pulse flex items-center justify-center">
<span className="text-slate-400">Agenda laden...</span>
</div>
</div>
);
}

10
app/epd/loading.tsx Normal file
View File

@@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600" />
<span className="text-sm text-slate-500">Laden...</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
export default function PatientLoading() {
return (
<div className="p-6 space-y-6">
{/* Header skeleton */}
<div className="flex items-center gap-4">
<div className="h-12 w-12 bg-slate-200 rounded-full animate-pulse" />
<div className="space-y-2">
<div className="h-6 w-48 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-32 bg-slate-100 rounded animate-pulse" />
</div>
</div>
{/* Cards skeleton */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-32 bg-slate-100 rounded-lg animate-pulse" />
))}
</div>
{/* Content skeleton */}
<div className="space-y-4">
<div className="h-8 w-64 bg-slate-200 rounded animate-pulse" />
<div className="h-48 bg-slate-100 rounded-lg animate-pulse" />
</div>
</div>
);
}

View File

@@ -18,7 +18,7 @@ import type { Intake } from '@/lib/types/intake';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import { getPatientEncounters } from '@/app/epd/agenda/actions';
import { getActiveCarePlan, getPatientIntakes } from './behandelplan/actions';
import { getActiveCarePlan } from './behandelplan/actions';
import type { SmartGoal, Intervention, Behandelstructuur, Evaluatiemoment } from '@/lib/types/behandelplan';
function extractHulpvraag(notes: string | null): string | null {
@@ -33,54 +33,39 @@ export default async function PatientDashboardPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Fetch recent intakes (optional)
let recentIntakes: Intake[] = [];
try {
const intakes = await getIntakesByPatientId(id);
recentIntakes = intakes.slice(0, 3); // Get up to 3 most recent
} catch (error) {
// Silently fail - intakes are optional for dashboard
console.error('Failed to fetch intakes for dashboard:', error);
}
// Fetch encounters (vandaag, toekomst en recente)
// Fetch all data in parallel for better performance
const [intakesResult, encountersResult, carePlanResult] = await Promise.all([
getIntakesByPatientId(id).catch(() => [] as Intake[]),
getPatientEncounters(id).catch(() => []),
getActiveCarePlan(id).catch(() => null),
]);
// Process intakes
const recentIntakes = intakesResult.slice(0, 3);
// Process encounters
let upcomingEncounters: any[] = [];
let recentEncounters: any[] = [];
try {
const allEncounters = await getPatientEncounters(id);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Split into upcoming (vandaag + toekomst) and recent (verleden)
const upcoming = allEncounters.filter(e => new Date(e.period_start) >= todayStart);
const recent = allEncounters.filter(e => new Date(e.period_start) < todayStart);
// Take 5 most relevant: prioritize upcoming, then recent
upcomingEncounters = upcoming.slice(0, 5);
if (upcomingEncounters.length < 5) {
recentEncounters = recent.slice(0, 5 - upcomingEncounters.length);
}
} catch (error) {
console.error('Failed to fetch encounters:', error);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const upcoming = encountersResult.filter((e: any) => new Date(e.period_start) >= todayStart);
const recent = encountersResult.filter((e: any) => new Date(e.period_start) < todayStart);
upcomingEncounters = upcoming.slice(0, 5);
if (upcomingEncounters.length < 5) {
recentEncounters = recent.slice(0, 5 - upcomingEncounters.length);
}
// Fetch active care plan
let activeCarePlan: any = null;
// Process care plan and get hulpvraag from already-fetched intakes
const activeCarePlan = carePlanResult;
let hulpvraag: string | null = null;
try {
activeCarePlan = await getActiveCarePlan(id);
// Get hulpvraag from linked intake
if (activeCarePlan?.based_on_intake_id) {
const intakes = await getPatientIntakes(id);
const linkedIntake = intakes.find(i => i.id === activeCarePlan.based_on_intake_id);
if (linkedIntake?.notes) {
hulpvraag = extractHulpvraag(linkedIntake.notes);
}
if (activeCarePlan?.based_on_intake_id) {
const linkedIntake = intakesResult.find((i: Intake) => i.id === activeCarePlan.based_on_intake_id);
if (linkedIntake?.notes) {
hulpvraag = extractHulpvraag(linkedIntake.notes);
}
} catch (error) {
console.error('Failed to fetch care plan:', error);
}
return (
@@ -307,18 +292,25 @@ export default async function PatientDashboardPage({
<div className="mb-4 p-3 bg-teal-50 rounded-lg">
<p className="text-xs font-medium text-teal-700 mb-2">Behandelstructuur</p>
<div className="grid grid-cols-2 gap-2 text-sm text-teal-900">
<div>
<span className="font-medium">Duur:</span> {activeCarePlan.behandelstructuur.duur}
</div>
<div>
<span className="font-medium">Frequentie:</span> {activeCarePlan.behandelstructuur.frequentie}
</div>
<div>
<span className="font-medium">Aantal sessies:</span> {activeCarePlan.behandelstructuur.aantalSessies}
</div>
<div>
<span className="font-medium">Vorm:</span> {activeCarePlan.behandelstructuur.vorm}
</div>
{(() => {
const bs = activeCarePlan.behandelstructuur as unknown as Behandelstructuur;
return (
<>
<div>
<span className="font-medium">Duur:</span> {bs.duur}
</div>
<div>
<span className="font-medium">Frequentie:</span> {bs.frequentie}
</div>
<div>
<span className="font-medium">Aantal sessies:</span> {bs.aantalSessies}
</div>
<div>
<span className="font-medium">Vorm:</span> {bs.vorm}
</div>
</>
);
})()}
</div>
</div>
)}
@@ -328,7 +320,7 @@ export default async function PatientDashboardPage({
<div className="mb-4">
<p className="text-xs font-medium text-slate-600 mb-2">Doelen ({activeCarePlan.goals.length})</p>
<div className="space-y-2">
{activeCarePlan.goals.slice(0, 3).map((goal: SmartGoal) => (
{(activeCarePlan.goals as unknown as SmartGoal[]).slice(0, 3).map((goal) => (
<div key={goal.id} className="p-2 bg-slate-50 rounded border border-slate-200">
<div className="flex items-start justify-between mb-1">
<p className="text-sm font-medium text-slate-900">{goal.title}</p>
@@ -369,7 +361,7 @@ export default async function PatientDashboardPage({
<div className="mb-4">
<p className="text-xs font-medium text-slate-600 mb-2">Interventies ({activeCarePlan.activities.length})</p>
<div className="flex flex-wrap gap-2">
{activeCarePlan.activities.slice(0, 5).map((intervention: Intervention) => (
{(activeCarePlan.activities as unknown as Intervention[]).slice(0, 5).map((intervention) => (
<span
key={intervention.id}
className="px-2 py-1 bg-purple-50 text-purple-700 rounded text-xs font-medium"
@@ -393,10 +385,10 @@ export default async function PatientDashboardPage({
<div>
<p className="text-xs font-medium text-slate-600 mb-2">Aankomende evaluatiemomenten</p>
<div className="space-y-2">
{activeCarePlan.evaluatiemomenten
.filter((evaluatie: Evaluatiemoment) => evaluatie.status === 'gepland')
{(activeCarePlan.evaluatiemomenten as unknown as Evaluatiemoment[])
.filter((evaluatie) => evaluatie.status === 'gepland')
.slice(0, 2)
.map((evaluatie: Evaluatiemoment) => (
.map((evaluatie) => (
<div key={evaluatie.id} className="p-2 bg-amber-50 rounded border border-amber-200">
<div className="flex items-center justify-between">
<div>

View File

@@ -5,11 +5,34 @@ import { redirect } from 'next/navigation';
import { authFetch, getBaseUrl } from '@/lib/server/api-client';
import type { Report, ReportListResponse, CreateReportInput } from '@/lib/types/report';
export async function getReports(patientId: string): Promise<Report[]> {
interface GetReportsOptions {
limit?: number;
offset?: number;
}
export async function getReports(
patientId: string,
options?: GetReportsOptions
): Promise<Report[]> {
const result = await getReportsPaginated(patientId, options);
return result.reports;
}
export async function getReportsPaginated(
patientId: string,
options?: GetReportsOptions
): Promise<ReportListResponse> {
const baseUrl = getBaseUrl();
const url = new URL('/api/reports', baseUrl);
url.searchParams.set('patientId', patientId);
if (options?.limit) {
url.searchParams.set('limit', options.limit.toString());
}
if (options?.offset) {
url.searchParams.set('offset', options.offset.toString());
}
const response = await authFetch(url.toString(), {
cache: 'no-store',
});
@@ -21,8 +44,7 @@ export async function getReports(patientId: string): Promise<Report[]> {
throw new Error('Fout bij ophalen rapportages');
}
const data: ReportListResponse = await response.json();
return data.reports;
return response.json();
}
export async function createReport(

View File

@@ -0,0 +1,24 @@
export default function RapportageLoading() {
return (
<div className="p-6 space-y-6">
{/* Header skeleton */}
<div className="flex items-center justify-between">
<div className="h-8 w-48 bg-slate-200 rounded animate-pulse" />
<div className="h-10 w-32 bg-teal-100 rounded animate-pulse" />
</div>
{/* Timeline skeleton */}
<div className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex gap-4">
<div className="h-4 w-4 bg-slate-200 rounded-full animate-pulse mt-1" />
<div className="flex-1 space-y-2">
<div className="h-5 w-32 bg-slate-200 rounded animate-pulse" />
<div className="h-24 bg-slate-100 rounded-lg animate-pulse" />
</div>
</div>
))}
</div>
</div>
);
}