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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user