From f3cb75bfc763c27922ecbfded7da779b74511f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 13:37:08 +0200 Subject: [PATCH] feat(mobile): add mobile summary view for 320-428px viewports (Sprint 4c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only capacity snapshot with utilization donut, top 5 active projects, open demand alert banner, and quick-link grid — single-column card layout optimised for PWA standalone mode. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/(app)/mobile/page.tsx | 9 ++ .../components/mobile/MobileCapacityCard.tsx | 64 +++++++++ .../components/mobile/MobileProjectCard.tsx | 39 ++++++ .../components/mobile/MobileSummaryClient.tsx | 131 ++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 apps/web/src/app/(app)/mobile/page.tsx create mode 100644 apps/web/src/components/mobile/MobileCapacityCard.tsx create mode 100644 apps/web/src/components/mobile/MobileProjectCard.tsx create mode 100644 apps/web/src/components/mobile/MobileSummaryClient.tsx diff --git a/apps/web/src/app/(app)/mobile/page.tsx b/apps/web/src/app/(app)/mobile/page.tsx new file mode 100644 index 0000000..833965d --- /dev/null +++ b/apps/web/src/app/(app)/mobile/page.tsx @@ -0,0 +1,9 @@ +import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js"; + +export const metadata = { + title: "CapaKraken — Mobile Summary", +}; + +export default function MobilePage() { + return ; +} diff --git a/apps/web/src/components/mobile/MobileCapacityCard.tsx b/apps/web/src/components/mobile/MobileCapacityCard.tsx new file mode 100644 index 0000000..0d594ac --- /dev/null +++ b/apps/web/src/components/mobile/MobileCapacityCard.tsx @@ -0,0 +1,64 @@ +"use client"; + +interface MobileCapacityCardProps { + totalResources: number; + activeResources: number; + avgUtilizationPct: number; + overbookedCount?: number; +} + +export function MobileCapacityCard({ + totalResources, + activeResources, + avgUtilizationPct, + overbookedCount = 0, +}: MobileCapacityCardProps) { + const pct = Math.min(100, Math.max(0, avgUtilizationPct)); + const circumference = 2 * Math.PI * 34; // radius = 34 + const dashOffset = circumference * (1 - pct / 100); + const color = + pct >= 90 ? "#d97706" : pct >= 70 ? "#059669" : "#6b7280"; + + return ( +
+
+ Team Capacity +
+
+ {/* CSS-only donut */} + + + + + {Math.round(pct)}% + + +
+
+ Resources + + {activeResources} / {totalResources} + +
+ {overbookedCount > 0 && ( +
+ Overbooked + {overbookedCount} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/mobile/MobileProjectCard.tsx b/apps/web/src/components/mobile/MobileProjectCard.tsx new file mode 100644 index 0000000..f6b57ed --- /dev/null +++ b/apps/web/src/components/mobile/MobileProjectCard.tsx @@ -0,0 +1,39 @@ +"use client"; + +import Link from "next/link"; + +const STATUS_BADGE: Record = { + ACTIVE: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300", + DRAFT: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400", + ON_HOLD: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300", + COMPLETED: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300", + CANCELLED: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", +}; + +interface MobileProjectCardProps { + id: string; + shortCode: string; + name: string; + status: string; + allocationsCount?: number; +} + +export function MobileProjectCard({ id, shortCode, name, status, allocationsCount }: MobileProjectCardProps) { + return ( + +
{shortCode}
+
+
{name}
+ {allocationsCount !== undefined && ( +
{allocationsCount} allocation{allocationsCount !== 1 ? "s" : ""}
+ )} +
+ + {status.charAt(0) + status.slice(1).toLowerCase().replace("_", " ")} + + + ); +} diff --git a/apps/web/src/components/mobile/MobileSummaryClient.tsx b/apps/web/src/components/mobile/MobileSummaryClient.tsx new file mode 100644 index 0000000..475cc85 --- /dev/null +++ b/apps/web/src/components/mobile/MobileSummaryClient.tsx @@ -0,0 +1,131 @@ +"use client"; + +import Link from "next/link"; +import type { Route } from "next"; +import { trpc } from "~/lib/trpc/client.js"; +import { MobileCapacityCard } from "./MobileCapacityCard.js"; +import { MobileProjectCard } from "./MobileProjectCard.js"; +import { EmptyState } from "~/components/ui/EmptyState.js"; + +export function MobileSummaryClient() { + const { data: overview, isLoading: overviewLoading } = trpc.dashboard.getOverview.useQuery(undefined, { + staleTime: 60_000, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: projectsData, isLoading: projectsLoading } = (trpc.project.list.useQuery as any)( + { limit: 5, status: "ACTIVE" }, + { staleTime: 60_000 }, + ) as { data: { projects: Array<{ id: string; shortCode: string; name: string; status: string }> } | undefined; isLoading: boolean }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: demandData } = (trpc.dashboard.getDemand.useQuery as any)( + undefined, + { staleTime: 60_000 }, + ) as { data: { openDemandCount?: number; openDemands?: unknown[] } | undefined }; + + const projects = projectsData?.projects ?? []; + const openDemandCount = demandData?.openDemandCount ?? demandData?.openDemands?.length ?? 0; + + const isLoading = overviewLoading || projectsLoading; + + return ( +
+ {/* Top nav bar */} +
+

CapaKraken

+ + Full Dashboard → + +
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : ( + <> + {/* Capacity snapshot */} + {overview && ( + + )} + + {/* Open demand alert */} + {openDemandCount > 0 && ( + +
+ {openDemandCount} +
+
+
+ Open Demand +
+
+ {openDemandCount} unfilled role{openDemandCount !== 1 ? "s" : ""} — tap to fill +
+
+ + )} + + {/* Active projects */} +
+
+ + Active Projects + + + See all + +
+ {projects.length === 0 ? ( + + ) : ( +
+ {projects.map((p) => ( + + ))} +
+ )} +
+ + {/* Quick links */} +
+ {( + [ + ["/timeline" as Route, "Timeline"], + ["/bench" as Route, "Bench Board"], + ["/allocations" as Route, "Allocations"], + ["/staffing" as Route, "Staffing"], + ] as [Route, string][] + ).map(([href, label]) => ( + + {label} + + ))} +
+ + )} +
+
+ ); +}