Files
pig/apps/web/src/pages/Capacity.tsx
T
karti 2763531ce4
CI / verify (push) Successful in 2m53s
Align shadcn's accent token with what shadcn means by it
shadcn uses `bg-accent` for its SUBTLE surfaces — dropdown item hover, command
row selection, ghost and outline button hover, the dialog close affordance. The
brand colour in shadcn is `primary`.

PIG's Tailwind config mapped `accent` to `--accent`, which is the brand. That
inverted the meaning, so every shadcn hover and selection state painted a
full-strength brand block. With the monochrome "pig" palette in dark mode the
brand is near-white, so a selected command row rendered as a white slab against
a near-black sheet. Measured before the change: selected row rgb(250,250,250)
on a rgb(9,9,11) body.

`accent` now aliases `--accent-subtle` and `accent-foreground` aliases
`--accent-fg`, which is what those tokens were created for. The eleven places
where PIG's own components wanted a solid brand fill — filled chips, selected
card borders, progress bars — move to `primary`, which still resolves to
`--accent`. A `brand` alias is added for clarity.

After: selected row rgb(39,39,42) in dark and rgb(244,244,245) in light, both a
subtle tint above the body; the pipeline's active stage chip stays a solid
rgb(250,250,250) fill, unchanged.

Found by opening overlays, which earlier screenshot sweeps never did — every
route had been checked, but a dropdown or a command palette only misbehaves
once it is open. Worth remembering: page-level sweeps do not exercise portals.

Typecheck clean, 135 unit tests and e2e green, CSP hash unchanged, 0px
horizontal overflow across 12 routes at 393px and 1440px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:17:49 -07:00

397 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 type { PermissionGrant } from '@pig/core';
import { Search, Server, ShieldCheck, Zap } from 'lucide-react';
import { compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title';
import { can } from '@/lib/permissions';
import {
AllocationSheet,
type AvailabilityRow,
type MatchRow,
} from '@/components/AllocationSheet';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Input,
Skeleton,
} from '@/components/ui';
export function Capacity() {
usePageTitle('Capacity');
const [tab, setTab] = useState<'available' | 'match'>('available');
const [allocation, setAllocation] = useState<{
open: boolean;
preferredCommitmentId?: string;
matches?: MatchRow[];
defaultGpuHours?: number;
}>({ open: false });
const { data: me } = useQuery({
queryKey: ['me'],
queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'),
});
const writable = can(me, 'deal:write', 'demand');
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
writable={writable}
onAllocate={(preferredCommitmentId) =>
setAllocation({ open: true, preferredCommitmentId })
}
/>
) : (
<Matcher
writable={writable}
onAllocate={(preferredCommitmentId, matches, defaultGpuHours) =>
setAllocation({ open: true, preferredCommitmentId, matches, defaultGpuHours })
}
/>
)}
<AllocationSheet
{...allocation}
onOpenChange={(open) => setAllocation((state) => ({ ...state, open }))}
/>
</div>
);
}
function Availability({ writable, onAllocate }: { writable: boolean; onAllocate(id: string): void }) {
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} writable={writable} onAllocate={() => onAllocate(row.commitmentId)} />
))}
</div>
);
}
function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; writable: boolean; onAllocate(): void }) {
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-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div
className="bg-primary/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>
<Button variant="outline" className="w-full" disabled={!writable || row.availableGpuHours <= 0} onClick={onAllocate} title={!writable ? 'Demand-team write permission is required' : undefined}>
<ShieldCheck data-icon="inline-start" aria-hidden />
Allocate or hold
</Button>
</CardContent>
</Card>
);
}
function Matcher({ writable, onAllocate }: { writable: boolean; onAllocate(id: string, matches: MatchRow[], defaultGpuHours?: number): void }) {
const [form, setForm] = useState({
gpuType: '',
gpuCount: '64',
totalGpuHours: '',
startsAt: '',
endsAt: '',
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,
startsAt: form.startsAt ? new Date(`${form.startsAt}T00:00:00`).toISOString() : undefined,
endsAt: form.endsAt ? new Date(`${form.endsAt}T23:59:59`).toISOString() : 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>
<Field label="Needed from">
<Input
type="date"
value={form.startsAt}
onChange={(e) => setForm({ ...form, startsAt: e.target.value })}
/>
</Field>
<Field label="Needed until">
<Input
type="date"
value={form.endsAt}
min={form.startsAt || undefined}
onChange={(e) => setForm({ ...form, endsAt: e.target.value })}
/>
</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} className="lg:col-start-4">
<Search data-icon="inline-start" 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 flex flex-col gap-1 text-sm">
{match.rationale.map((reason, i) => (
<li
key={i}
className={
reason.startsWith('⚠') ? 'text-warning' : 'text-muted'
}
>
{reason}
</li>
))}
</ul>
<div className="mt-4 flex flex-col gap-2 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted">
{shortDate(match.startsAt)}{shortDate(match.endsAt)} · {money(match.breakEvenPriceCents)}/hr break even
</p>
<Button
variant="primary"
disabled={!writable}
onClick={() => onAllocate(
match.commitmentId,
mutation.data,
form.totalGpuHours ? Number(form.totalGpuHours) : undefined,
)}
title={!writable ? 'Demand-team write permission is required' : undefined}
>
<ShieldCheck data-icon="inline-start" aria-hidden />
Allocate this capacity
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)
) : null}
{mutation.isError ? (
<p role="alert" 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>
);
}