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:
2026-08-12 19:16:43 -07:00
parent 7aeec0c632
commit de33a03524
30 changed files with 12765 additions and 1 deletions
+337
View File
@@ -0,0 +1,337 @@
/**
* Capacity — availability, and the matcher.
*
* The matcher is the screen that justifies the product: given what a customer
* wants, what have we already bought that could serve them, and would selling
* it make money? No generic CRM can answer either half.
*/
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Search, Server, Zap } from 'lucide-react';
import { compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Input,
Skeleton,
} from '@/components/ui';
interface AvailabilityRow {
commitmentId: string;
name: string;
gpuType: string;
gpuCount: number;
interconnectType: string;
securityTier: string;
startsAt: string;
endsAt: string;
totalGpuHours: number;
soldGpuHours: number;
heldGpuHours: number;
availableGpuHours: number;
costPerGpuHourCents: number;
utilisation: number;
breakEvenPriceCents: number | null;
}
type MatchRow = AvailabilityRow & { score: number; rationale: string[] };
export function Capacity() {
const [tab, setTab] = useState<'available' | 'match'>('available');
return (
<div className="space-y-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Capacity</h1>
<p className="mt-1 text-sm text-muted">
What we hold, what is sold, and what is still sellable.
</p>
</header>
{/* A segmented control rather than tabs — it reads correctly at phone
width, where a tab row would either wrap or scroll. */}
<div
role="tablist"
aria-label="Capacity views"
className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto"
>
{(['available', 'match'] as const).map((value) => (
<button
key={value}
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
className={[
'tap flex-1 rounded-md px-4 text-sm font-medium transition-colors sm:flex-none',
tab === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
].join(' ')}
>
{value === 'available' ? 'Availability' : 'Match a requirement'}
</button>
))}
</div>
{tab === 'available' ? <Availability /> : <Matcher />}
</div>
);
}
function Availability() {
const { data, isLoading } = useQuery({
queryKey: ['availability'],
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
});
if (isLoading) return <Skeleton className="h-64" />;
if (!data || data.length === 0) {
return (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Server className="h-8 w-8" />}
title="No live capacity commitments"
description="Once you record what capacity you have committed to buy, this view shows how much of each block is sold, held, and still available."
/>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{data.map((row) => (
<CapacityCard key={row.commitmentId} row={row} />
))}
</div>
);
}
function CapacityCard({ row }: { row: AvailabilityRow }) {
const soldPct = row.totalGpuHours > 0 ? row.soldGpuHours / row.totalGpuHours : 0;
const heldPct = row.totalGpuHours > 0 ? row.heldGpuHours / row.totalGpuHours : 0;
return (
/*
* `min-w-0` is load-bearing. A grid item defaults to `min-width: auto`,
* which means it refuses to shrink below its content — and `truncate` sets
* `white-space: nowrap`, so a long title becomes unshrinkable content and
* widens the whole track. The result is a page that scrolls sideways on a
* phone. This is the fix, and it is needed on every grid child that
* truncates.
*/
<Card className="min-w-0">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="min-w-0 truncate text-base">{row.name}</CardTitle>
<Badge tone={row.securityTier === 'secure_cloud' ? 'accent' : 'neutral'}>
{row.securityTier === 'secure_cloud' ? 'Secure' : 'Community'}
</Badge>
</div>
<p className="text-xs text-muted">
{row.gpuCount}× {row.gpuType} · {row.interconnectType} ·{' '}
{shortDate(row.startsAt)}{shortDate(row.endsAt)}
</p>
</CardHeader>
<CardContent className="space-y-3">
{/* Sold and held are shown as separate segments, because a full-looking
bar made mostly of unconverted holds is a lie a seller would act on. */}
<div>
<div className="flex h-2 overflow-hidden rounded-full bg-surface-2">
<div className="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div
className="bg-accent/35"
style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }}
/>
</div>
<div className="mt-1.5 flex justify-between text-xs text-muted">
<span>{percent(soldPct)} sold</span>
{row.heldGpuHours > 0 ? <span>{percent(heldPct)} held</span> : null}
<span className="nums">{compactNumber(row.availableGpuHours)} hrs free</span>
</div>
</div>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
<dt className="text-muted">Cost</dt>
<dd className="nums text-right">{money(row.costPerGpuHourCents)}/hr</dd>
<dt className="text-muted">Break even</dt>
<dd className="nums text-right">
{row.breakEvenPriceCents == null
? 'Fully sold'
: row.breakEvenPriceCents === 0
? 'Cost covered'
: `${money(row.breakEvenPriceCents)}/hr`}
</dd>
</dl>
</CardContent>
</Card>
);
}
function Matcher() {
const [form, setForm] = useState({
gpuType: '',
gpuCount: '64',
totalGpuHours: '',
requiresHighSpeedInterconnect: true,
maxPrice: '',
});
const mutation = useMutation({
mutationFn: () =>
post<MatchRow[]>('/api/capacity/match', {
gpuType: form.gpuType || undefined,
gpuCount: Number(form.gpuCount) || 1,
totalGpuHours: form.totalGpuHours ? Number(form.totalGpuHours) : undefined,
requiresHighSpeedInterconnect: form.requiresHighSpeedInterconnect,
maxPricePerGpuHourCents: form.maxPrice
? Math.round(Number(form.maxPrice) * 100)
: undefined,
}),
});
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">What does the customer need?</CardTitle>
</CardHeader>
<CardContent>
<form
className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate();
}}
>
<Field label="GPU type">
<Input
value={form.gpuType}
onChange={(e) => setForm({ ...form, gpuType: e.target.value })}
placeholder="H100_80GB"
// Hardware identifiers are case-sensitive upstream; correcting
// them for the user would produce silent mismatches.
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
</Field>
<Field label="GPUs">
<Input
value={form.gpuCount}
onChange={(e) => setForm({ ...form, gpuCount: e.target.value })}
// A numeric keypad on phones, without the spinner arrows and
// scroll-to-change behaviour of type="number".
inputMode="numeric"
pattern="[0-9]*"
/>
</Field>
<Field label="GPU-hours">
<Input
value={form.totalGpuHours}
onChange={(e) => setForm({ ...form, totalGpuHours: e.target.value })}
inputMode="numeric"
placeholder="Optional"
/>
</Field>
<Field label="Max $/GPU-hr">
<Input
value={form.maxPrice}
onChange={(e) => setForm({ ...form, maxPrice: e.target.value })}
inputMode="decimal"
placeholder="Optional"
/>
</Field>
<label className="tap flex items-center gap-2.5 text-sm sm:col-span-2 lg:col-span-3">
<input
type="checkbox"
checked={form.requiresHighSpeedInterconnect}
onChange={(e) =>
setForm({ ...form, requiresHighSpeedInterconnect: e.target.checked })
}
className="h-5 w-5 rounded border-border accent-[hsl(var(--accent))]"
/>
<span>
Needs high-speed interconnect
<span className="ml-1 text-muted"> distributed training</span>
</span>
</label>
<Button type="submit" variant="primary" disabled={mutation.isPending}>
<Search className="h-4 w-4" aria-hidden />
{mutation.isPending ? 'Matching…' : 'Find capacity'}
</Button>
</form>
</CardContent>
</Card>
{mutation.isSuccess ? (
mutation.data.length === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Zap className="h-8 w-8" />}
title="Nothing on the book fits"
description="No committed capacity matches. Search provider inventory to find capacity to buy, which would mean opening a supply deal."
/>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{mutation.data.map((match) => (
<Card key={match.commitmentId}>
<CardContent className="pt-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{match.name}</p>
<p className="text-xs text-muted">
{match.gpuCount}× {match.gpuType} · {match.interconnectType} ·{' '}
{compactNumber(match.availableGpuHours)} hrs free
</p>
</div>
<Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>
{percent(match.score)} fit
</Badge>
</div>
<ul className="mt-3 space-y-1 text-sm">
{match.rationale.map((reason, i) => (
<li
key={i}
className={
reason.startsWith('⚠') ? 'text-warning' : 'text-muted'
}
>
{reason}
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
)
) : null}
{mutation.isError ? (
<p className="text-sm text-danger">
{mutation.error instanceof Error ? mutation.error.message : 'Match failed.'}
</p>
) : null}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-xs font-medium text-muted">{label}</span>
{children}
</label>
);
}