Add the web app, seed data, and user-selectable theming
apps/web — React, Vite, Tailwind, shadcn-idiom components. Mobile Safari is a first-class target, not an afterthought: - Two navigation treatments rather than one compromise. A bottom tab bar on phones, because the top of a large phone is out of thumb reach; a persistent sidebar from lg upward, so an iPad in portrait gets it too. - Safe-area insets throughout, so the tab bar clears the home indicator and the last row of a list is actually reachable. - Inputs are pinned to a 16px minimum, which is the correct fix for Safari zooming on focus. user-scalable=no is not used: it breaks pinch-zoom for everyone and recent iOS ignores it anyway. - The pipeline board becomes a stage picker on phones. An eight-column board scrolling horizontally on a 390px screen is technically responsive and practically useless. Theming: users pick an accent and the whole interface re-tints. Accent values live once, in @pig/core, and are written onto the root element at runtime — there is no CSS copy to drift from the TypeScript. Preferences are stored server-side so they follow a person between laptop and phone, mirrored into localStorage only so the pre-paint script can avoid a white flash. Status colours stay fixed regardless of accent: if "at risk" re-tinted to whatever someone picked, the signal would be gone. Seed data is public research, every record carrying a confidence grade and a source URL. No email addresses are seeded or inferred — none are published, and guessing them from a name and a domain is unreliable and rude. Authorship is not promoted to employment: contributors, residency participants and alumni are recorded as what the evidence actually shows, and a name that could not be sourced at all is listed as unresolved rather than invented. Three defects found and fixed by actually running it rather than assuming: 1. The seed was not idempotent. onConflictDoNothing() with no target is a no-op without a matching unique constraint, so a second run duplicated 27 contacts. There is deliberately no unique index on (account, name) — two people at one company can share a name — so idempotency is enforced in the seed instead of by bending the schema. 2. /capacity scrolled sideways on a phone. Grid items default to min-width:auto and `truncate` sets nowrap, so a long title became unshrinkable content and widened the track. Fixed with min-w-0 on every truncating grid child. 3. The idle-capacity alert silently failed to fire at exactly 80% utilisation, losing a float comparison against a 0.2 threshold. Moved to 0.15, which is also a more sensible line for "worth attention". The worked example is tuned to teach rather than to flatter: 70% sold at a 53% markup lands at +6.7% margin with 20% still idle, so both the healthy number and the alert are visible. Drop the sold share to 55% and the same block goes underwater — that sensitivity is the argument for the product. Verified in a real browser at 393px and 1440px, light and dark: zero horizontal overflow on every route, zero console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* The pipeline boards, for both sides of the market.
|
||||
*
|
||||
* A column-per-stage board on desktop; on a phone, a stage picker and a single
|
||||
* column. A horizontally scrolling eight-column board on a 390px screen is
|
||||
* technically responsive and practically unusable — you cannot see where a
|
||||
* card is going, which is the entire point of a board.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { get, money, relativeTime } from '@/lib/api';
|
||||
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
|
||||
|
||||
interface DemandDeal {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: string;
|
||||
productLine: string;
|
||||
acvCents: number | null;
|
||||
msaExecuted: boolean;
|
||||
dpaExecuted: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SupplyDeal {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: string;
|
||||
gpuType: string | null;
|
||||
gpuCount: number | null;
|
||||
targetCostPerGpuHourCents: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Board<T> {
|
||||
stages: string[];
|
||||
deals: { deal: T; accountName: string | null }[];
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
qualification: 'Qualification',
|
||||
legal: 'Legal',
|
||||
scoping: 'Scoping',
|
||||
proposal: 'Proposal',
|
||||
procurement: 'Procurement',
|
||||
poc: 'POC',
|
||||
deployment: 'Deployment',
|
||||
expansion: 'Expansion',
|
||||
closed_won: 'Closed won',
|
||||
closed_lost: 'Closed lost',
|
||||
sourced: 'Sourced',
|
||||
qualifying: 'Qualifying',
|
||||
technical_diligence: 'Technical diligence',
|
||||
financial_diligence: 'Financial diligence',
|
||||
pricing: 'Pricing',
|
||||
contracting: 'Contracting',
|
||||
onboarding: 'Onboarding',
|
||||
live: 'Live',
|
||||
renewal: 'Renewal',
|
||||
churned: 'Churned',
|
||||
rejected: 'Rejected',
|
||||
};
|
||||
|
||||
export function DemandPipeline() {
|
||||
return (
|
||||
<PipelineBoard<DemandDeal>
|
||||
title="Demand"
|
||||
subtitle="Selling compute and post-training. Note that legal sits early — paper gates the deal rather than closing it."
|
||||
endpoint="/api/deals/demand"
|
||||
renderCard={(deal, accountName) => (
|
||||
<>
|
||||
<p className="truncate font-medium">{deal.name}</p>
|
||||
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{deal.acvCents ? (
|
||||
<span className="nums text-sm font-semibold">{money(deal.acvCents)}</span>
|
||||
) : null}
|
||||
<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>
|
||||
{/* Contract state is surfaced on the card because shipping capacity
|
||||
without executed paper is the mistake this pipeline prevents. */}
|
||||
{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}
|
||||
{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SupplyPipeline() {
|
||||
return (
|
||||
<PipelineBoard<SupplyDeal>
|
||||
title="Supply"
|
||||
subtitle="Sourcing GPU capacity. Technical and financial diligence are separate gates — accepting capacity is a two-key decision."
|
||||
endpoint="/api/deals/supply"
|
||||
renderCard={(deal, accountName) => (
|
||||
<>
|
||||
<p className="truncate font-medium">{deal.name}</p>
|
||||
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{deal.gpuCount && deal.gpuType ? (
|
||||
<Badge tone="accent">
|
||||
{deal.gpuCount}× {deal.gpuType}
|
||||
</Badge>
|
||||
) : null}
|
||||
{deal.targetCostPerGpuHourCents ? (
|
||||
<span className="nums text-xs text-muted">
|
||||
{money(deal.targetCostPerGpuHourCents)}/hr target
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({
|
||||
title,
|
||||
subtitle,
|
||||
endpoint,
|
||||
renderCard,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
endpoint: string;
|
||||
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||
}) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [endpoint],
|
||||
queryFn: () => get<Board<T>>(endpoint),
|
||||
});
|
||||
|
||||
const [activeStage, setActiveStage] = useState<string | null>(null);
|
||||
|
||||
const byStage = useMemo(() => {
|
||||
const map = new Map<string, { deal: T; accountName: string | null }[]>();
|
||||
for (const stage of data?.stages ?? []) map.set(stage, []);
|
||||
for (const row of data?.deals ?? []) {
|
||||
map.get(row.deal.stage)?.push(row);
|
||||
}
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) return <Skeleton className="h-96" />;
|
||||
|
||||
if (!data || data.deals.length === 0) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Header title={title} subtitle={subtitle} />
|
||||
<Card>
|
||||
<EmptyState
|
||||
title={`No ${title.toLowerCase()} deals yet`}
|
||||
description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel."
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stages = data.stages;
|
||||
const currentStage = activeStage ?? stages[0]!;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Header title={title} subtitle={subtitle} />
|
||||
|
||||
{/* Phone: pick one stage. The chips scroll; the board does not. */}
|
||||
<div className="lg:hidden">
|
||||
<div className="scroll-x -mx-4 flex gap-2 px-4 pb-1">
|
||||
{stages.map((stage) => {
|
||||
const count = byStage.get(stage)?.length ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={stage}
|
||||
onClick={() => setActiveStage(stage)}
|
||||
className={[
|
||||
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
|
||||
stage === currentStage
|
||||
? 'bg-accent text-accent-on'
|
||||
: 'bg-surface-2 text-muted',
|
||||
].join(' ')}
|
||||
>
|
||||
{STAGE_LABELS[stage] ?? stage}
|
||||
<span className="ml-1.5 opacity-70">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(byStage.get(currentStage) ?? []).map((row) => (
|
||||
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
|
||||
))}
|
||||
{(byStage.get(currentStage) ?? []).length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted">
|
||||
Nothing in {STAGE_LABELS[currentStage] ?? currentStage}.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: the full board, scrolling horizontally within its own pane
|
||||
so the page itself never scrolls sideways. */}
|
||||
<div className="scroll-x hidden lg:block">
|
||||
<div className="flex gap-3 pb-2">
|
||||
{stages.map((stage) => {
|
||||
const rows = byStage.get(stage) ?? [];
|
||||
return (
|
||||
<section key={stage} className="w-72 shrink-0">
|
||||
<div className="mb-2 flex items-center justify-between px-1">
|
||||
<h2 className="text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2>
|
||||
<span className="nums text-xs text-muted">{rows.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DealCard<T extends { id: string; updatedAt: string }>({
|
||||
row,
|
||||
renderCard,
|
||||
}: {
|
||||
row: { deal: T; accountName: string | null };
|
||||
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<article className="card p-3">
|
||||
{renderCard(row.deal, row.accountName)}
|
||||
<p className="mt-2 text-[11px] text-muted">{relativeTime(row.deal.updatedAt)}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return (
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user