From 472d87c829d5736e611e25a15e9d16b708375e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 08:35:28 +0200 Subject: [PATCH] feat(web): add error boundaries, loading skeletons, render fixes and tree-shaking - Add error.tsx to all 13 route groups: admin, allocations, analytics, dashboard, estimates, notifications, projects, reports, resources, roles, staffing, timeline, vacations - Add loading.tsx to 9 routes that were missing them: admin, analytics, dashboard, estimates, notifications, reports, roles, staffing, vacations - ResourceDetail: wrap vacationStart in useMemo to stabilize query key, remove dead windowEnd variable - node-renderer.ts: replace barrel import (import * as THREE) with named imports for tree-shaking - next.config.ts: add framer-motion and @capakraken/shared to optimizePackageImports Co-Authored-By: Claude Sonnet 4.6 --- apps/web/next.config.ts | 3 +-- apps/web/src/app/(app)/admin/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/admin/loading.tsx | 10 ++++++++++ apps/web/src/app/(app)/allocations/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/analytics/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/analytics/loading.tsx | 10 ++++++++++ apps/web/src/app/(app)/dashboard/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/dashboard/loading.tsx | 10 ++++++++++ apps/web/src/app/(app)/estimates/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/estimates/loading.tsx | 10 ++++++++++ apps/web/src/app/(app)/notifications/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/notifications/loading.tsx | 10 ++++++++++ apps/web/src/app/(app)/projects/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/reports/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/reports/loading.tsx | 10 ++++++++++ apps/web/src/app/(app)/resources/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/roles/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/roles/loading.tsx | 10 ++++++++++ apps/web/src/app/(app)/staffing/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/staffing/loading.tsx | 10 ++++++++++ apps/web/src/app/(app)/timeline/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/vacations/error.tsx | 10 ++++++++++ apps/web/src/app/(app)/vacations/loading.tsx | 10 ++++++++++ .../analytics/computation-graph/node-renderer.ts | 14 +++++++------- .../src/components/resources/ResourceDetail.tsx | 12 +++++++----- 25 files changed, 235 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/(app)/admin/error.tsx create mode 100644 apps/web/src/app/(app)/admin/loading.tsx create mode 100644 apps/web/src/app/(app)/allocations/error.tsx create mode 100644 apps/web/src/app/(app)/analytics/error.tsx create mode 100644 apps/web/src/app/(app)/analytics/loading.tsx create mode 100644 apps/web/src/app/(app)/dashboard/error.tsx create mode 100644 apps/web/src/app/(app)/dashboard/loading.tsx create mode 100644 apps/web/src/app/(app)/estimates/error.tsx create mode 100644 apps/web/src/app/(app)/estimates/loading.tsx create mode 100644 apps/web/src/app/(app)/notifications/error.tsx create mode 100644 apps/web/src/app/(app)/notifications/loading.tsx create mode 100644 apps/web/src/app/(app)/projects/error.tsx create mode 100644 apps/web/src/app/(app)/reports/error.tsx create mode 100644 apps/web/src/app/(app)/reports/loading.tsx create mode 100644 apps/web/src/app/(app)/resources/error.tsx create mode 100644 apps/web/src/app/(app)/roles/error.tsx create mode 100644 apps/web/src/app/(app)/roles/loading.tsx create mode 100644 apps/web/src/app/(app)/staffing/error.tsx create mode 100644 apps/web/src/app/(app)/staffing/loading.tsx create mode 100644 apps/web/src/app/(app)/timeline/error.tsx create mode 100644 apps/web/src/app/(app)/vacations/error.tsx create mode 100644 apps/web/src/app/(app)/vacations/loading.tsx diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 41da849..f6972b0 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -7,7 +7,7 @@ const nextConfig: NextConfig = { outputFileTracingRoot: path.resolve(__dirname, "../.."), devIndicators: false, experimental: { - optimizePackageImports: ["recharts", "date-fns"], + optimizePackageImports: ["recharts", "date-fns", "framer-motion", "@capakraken/shared"], }, transpilePackages: [ "@capakraken/api", @@ -15,7 +15,6 @@ const nextConfig: NextConfig = { "@capakraken/engine", "@capakraken/shared", "@capakraken/staffing", - "@capakraken/ui", ], typedRoutes: true, async redirects() { diff --git a/apps/web/src/app/(app)/admin/error.tsx b/apps/web/src/app/(app)/admin/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/admin/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/admin/loading.tsx b/apps/web/src/app/(app)/admin/loading.tsx new file mode 100644 index 0000000..3c21848 --- /dev/null +++ b/apps/web/src/app/(app)/admin/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/allocations/error.tsx b/apps/web/src/app/(app)/allocations/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/allocations/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/analytics/error.tsx b/apps/web/src/app/(app)/analytics/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/analytics/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/analytics/loading.tsx b/apps/web/src/app/(app)/analytics/loading.tsx new file mode 100644 index 0000000..3c21848 --- /dev/null +++ b/apps/web/src/app/(app)/analytics/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/dashboard/error.tsx b/apps/web/src/app/(app)/dashboard/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/dashboard/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/dashboard/loading.tsx b/apps/web/src/app/(app)/dashboard/loading.tsx new file mode 100644 index 0000000..3c21848 --- /dev/null +++ b/apps/web/src/app/(app)/dashboard/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/estimates/error.tsx b/apps/web/src/app/(app)/estimates/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/estimates/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/estimates/loading.tsx b/apps/web/src/app/(app)/estimates/loading.tsx new file mode 100644 index 0000000..3c21848 --- /dev/null +++ b/apps/web/src/app/(app)/estimates/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/notifications/error.tsx b/apps/web/src/app/(app)/notifications/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/notifications/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/notifications/loading.tsx b/apps/web/src/app/(app)/notifications/loading.tsx new file mode 100644 index 0000000..3c21848 --- /dev/null +++ b/apps/web/src/app/(app)/notifications/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/projects/error.tsx b/apps/web/src/app/(app)/projects/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/projects/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/reports/error.tsx b/apps/web/src/app/(app)/reports/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/reports/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/reports/loading.tsx b/apps/web/src/app/(app)/reports/loading.tsx new file mode 100644 index 0000000..3c21848 --- /dev/null +++ b/apps/web/src/app/(app)/reports/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/resources/error.tsx b/apps/web/src/app/(app)/resources/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/resources/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/roles/error.tsx b/apps/web/src/app/(app)/roles/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/roles/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/roles/loading.tsx b/apps/web/src/app/(app)/roles/loading.tsx new file mode 100644 index 0000000..3c21848 --- /dev/null +++ b/apps/web/src/app/(app)/roles/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/staffing/error.tsx b/apps/web/src/app/(app)/staffing/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/staffing/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/staffing/loading.tsx b/apps/web/src/app/(app)/staffing/loading.tsx new file mode 100644 index 0000000..3c21848 --- /dev/null +++ b/apps/web/src/app/(app)/staffing/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/timeline/error.tsx b/apps/web/src/app/(app)/timeline/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/timeline/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/vacations/error.tsx b/apps/web/src/app/(app)/vacations/error.tsx new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/apps/web/src/app/(app)/vacations/error.tsx @@ -0,0 +1,10 @@ +"use client"; +export default function RouteError({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong

+

{error.message}

+ +
+ ); +} diff --git a/apps/web/src/app/(app)/vacations/loading.tsx b/apps/web/src/app/(app)/vacations/loading.tsx new file mode 100644 index 0000000..3c21848 --- /dev/null +++ b/apps/web/src/app/(app)/vacations/loading.tsx @@ -0,0 +1,10 @@ +export default function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/analytics/computation-graph/node-renderer.ts b/apps/web/src/components/analytics/computation-graph/node-renderer.ts index de4aa28..e3975c4 100644 --- a/apps/web/src/components/analytics/computation-graph/node-renderer.ts +++ b/apps/web/src/components/analytics/computation-graph/node-renderer.ts @@ -1,14 +1,14 @@ -import * as THREE from "three"; +import { CanvasTexture, Sprite, SpriteMaterial } from "three"; import type { PositionedNode } from "./graph-data"; // ─── Canvas-based node sprites ────────────────────────────────────────────── -const spriteCache = new Map(); +const spriteCache = new Map(); /** * Creates a Three.js sprite for a graph node: colored circle with value label. */ -export function createNodeSprite(node: PositionedNode): THREE.Sprite { +export function createNodeSprite(node: PositionedNode): Sprite { const cacheKey = `${node.id}:${node.value}:${node.color}`; const cached = spriteCache.get(cacheKey); if (cached) return cached.clone(); @@ -78,13 +78,13 @@ export function createNodeSprite(node: PositionedNode): THREE.Sprite { ctx.fillText(node.unit, cx, cy + 60); } - const texture = new THREE.CanvasTexture(canvas); - const material = new THREE.SpriteMaterial({ + const texture = new CanvasTexture(canvas); + const material = new SpriteMaterial({ map: texture, transparent: true, depthWrite: false, }); - const sprite = new THREE.Sprite(material); + const sprite = new Sprite(material); sprite.scale.set(50, 50, 1); spriteCache.set(cacheKey, sprite); @@ -94,7 +94,7 @@ export function createNodeSprite(node: PositionedNode): THREE.Sprite { /** * Creates a dimmed version of a node sprite (for non-highlighted nodes). */ -export function createDimmedNodeSprite(node: PositionedNode): THREE.Sprite { +export function createDimmedNodeSprite(node: PositionedNode): Sprite { const sprite = createNodeSprite({ ...node, color: "#4b5563" }); sprite.material.opacity = 0.3; return sprite; diff --git a/apps/web/src/components/resources/ResourceDetail.tsx b/apps/web/src/components/resources/ResourceDetail.tsx index b18ff70..7064ed0 100644 --- a/apps/web/src/components/resources/ResourceDetail.tsx +++ b/apps/web/src/components/resources/ResourceDetail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import dynamic from "next/dynamic"; import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@capakraken/shared"; @@ -96,8 +96,6 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { // Fetch allocations for this resource (all non-cancelled) const now = new Date(); - const windowEnd = new Date(now); - windowEnd.setDate(windowEnd.getDate() + 90); const _allocQuery = trpc.allocation.listView.useQuery( { resourceId }, @@ -110,8 +108,12 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { const loadingAllocations = _allocQuery.isLoading; // Fetch upcoming/recent vacations - const vacationStart = new Date(now); - vacationStart.setMonth(vacationStart.getMonth() - 1); + const vacationStart = useMemo(() => { + const d = new Date(); + d.setMonth(d.getMonth() - 1); + d.setHours(0, 0, 0, 0); + return d; + }, []); const { data: vacations, isLoading: loadingVacations } = trpc.vacation.list.useQuery( {