feat(mobile): add mobile summary view for 320-428px viewports (Sprint 4c)

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 13:37:08 +02:00
parent ab4ec91e02
commit f3cb75bfc7
4 changed files with 243 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js";
export const metadata = {
title: "CapaKraken — Mobile Summary",
};
export default function MobilePage() {
return <MobileSummaryClient />;
}
@@ -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 (
<div className="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
<div className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-4">
Team Capacity
</div>
<div className="flex items-center gap-5">
{/* CSS-only donut */}
<svg width="80" height="80" viewBox="0 0 80 80" className="shrink-0">
<circle cx="40" cy="40" r="34" fill="none" stroke="#e5e7eb" strokeWidth="8" className="dark:stroke-gray-700" />
<circle
cx="40"
cy="40"
r="34"
fill="none"
stroke={color}
strokeWidth="8"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
strokeLinecap="round"
transform="rotate(-90 40 40)"
/>
<text x="40" y="40" textAnchor="middle" dominantBaseline="middle" fontSize="15" fontWeight="700" fill={color}>
{Math.round(pct)}%
</text>
</svg>
<div className="space-y-2 flex-1">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">Resources</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{activeResources} / {totalResources}
</span>
</div>
{overbookedCount > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-amber-600 dark:text-amber-400">Overbooked</span>
<span className="font-semibold text-amber-600 dark:text-amber-400">{overbookedCount}</span>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
const STATUS_BADGE: Record<string, string> = {
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 (
<Link
href={`/projects/${id}`}
className="flex items-center gap-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div className="font-mono text-xs text-gray-500 dark:text-gray-400 w-16 shrink-0">{shortCode}</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{name}</div>
{allocationsCount !== undefined && (
<div className="text-xs text-gray-500 dark:text-gray-400">{allocationsCount} allocation{allocationsCount !== 1 ? "s" : ""}</div>
)}
</div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_BADGE[status] ?? STATUS_BADGE["DRAFT"]}`}>
{status.charAt(0) + status.slice(1).toLowerCase().replace("_", " ")}
</span>
</Link>
);
}
@@ -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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Top nav bar */}
<div className="sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between">
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-100">CapaKraken</h1>
<Link href="/dashboard" className="text-xs font-medium text-brand-600 dark:text-brand-400">
Full Dashboard
</Link>
</div>
<div className="max-w-[428px] mx-auto px-4 py-5 space-y-4">
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-32 shimmer-skeleton rounded-2xl" />
))}
</div>
) : (
<>
{/* Capacity snapshot */}
{overview && (
<MobileCapacityCard
totalResources={overview.totalResources}
activeResources={overview.activeResources}
avgUtilizationPct={overview.budgetSummary?.avgUtilizationPercent ?? 0}
/>
)}
{/* Open demand alert */}
{openDemandCount > 0 && (
<Link
href="/timeline"
className="flex items-center gap-3 rounded-xl border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 px-4 py-3"
>
<div className="h-8 w-8 shrink-0 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
<span className="text-sm font-bold text-amber-700 dark:text-amber-300">{openDemandCount}</span>
</div>
<div>
<div className="text-sm font-semibold text-amber-800 dark:text-amber-300">
Open Demand
</div>
<div className="text-xs text-amber-700 dark:text-amber-400">
{openDemandCount} unfilled role{openDemandCount !== 1 ? "s" : ""} tap to fill
</div>
</div>
</Link>
)}
{/* Active projects */}
<div>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Active Projects
</span>
<Link href="/projects" className="text-xs text-brand-600 dark:text-brand-400">
See all
</Link>
</div>
{projects.length === 0 ? (
<EmptyState title="No active projects" detail="Create a project to get started." />
) : (
<div className="space-y-2">
{projects.map((p) => (
<MobileProjectCard
key={p.id}
id={p.id}
shortCode={p.shortCode}
name={p.name}
status={p.status}
/>
))}
</div>
)}
</div>
{/* Quick links */}
<div className="grid grid-cols-2 gap-3">
{(
[
["/timeline" as Route, "Timeline"],
["/bench" as Route, "Bench Board"],
["/allocations" as Route, "Allocations"],
["/staffing" as Route, "Staffing"],
] as [Route, string][]
).map(([href, label]) => (
<Link
key={href}
href={href}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 text-center hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{label}
</Link>
))}
</div>
</>
)}
</div>
</div>
);
}