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
+138
View File
@@ -0,0 +1,138 @@
/**
* Margin — the ledger, per block and in total.
*
* The table scrolls inside its own pane on narrow screens rather than making
* the page scroll sideways; a card list would lose the column comparison that
* is the entire value of this view.
*/
import { useQuery } from '@tanstack/react-query';
import { compactNumber, get, money, moneyExact, percent } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
interface MarginReport {
totals: {
committedGpuHours: number;
allocatedGpuHours: number;
idleGpuHours: number;
utilisation: number;
costCents: number;
revenueCents: number;
grossMarginCents: number;
grossMarginPct: number | null;
marginPerAllocatedGpuHourCents: number | null;
};
blocks: {
commitmentId: string;
name: string;
gpuType: string;
gpuCount: number;
totalGpuHours: number;
soldGpuHours: number;
availableGpuHours: number;
costPerGpuHourCents: number;
utilisation: number;
breakEvenPriceCents: number | null;
}[];
}
export function Margin() {
const { data, isLoading } = useQuery({
queryKey: ['margin'],
queryFn: () => get<MarginReport>('/api/capacity/margin'),
});
if (isLoading) return <Skeleton className="h-96" />;
if (!data || data.blocks.length === 0) {
return (
<EmptyState
title="No capacity to report on"
description="Margin is computed from capacity commitments and the allocations against them."
/>
);
}
const t = data.totals;
return (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Margin</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
Revenue from what we sold, against the full cost of what we bought.
</p>
</header>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="Revenue" value={money(t.revenueCents)} />
<Stat label="Cost" value={money(t.costCents)} hint="Full commitment" />
<Stat
label="Gross margin"
value={money(t.grossMarginCents)}
hint={percent(t.grossMarginPct, 1)}
tone={t.grossMarginCents >= 0 ? 'positive' : 'danger'}
/>
<Stat
label="Per sold GPU-hour"
value={moneyExact(t.marginPerAllocatedGpuHourCents)}
hint={`${percent(t.utilisation, 1)} utilised`}
/>
</section>
<Card>
<CardHeader>
<CardTitle className="text-base">By commitment</CardTitle>
</CardHeader>
<CardContent className="px-0 sm:px-0">
<div className="scroll-x">
<table className="w-full min-w-[720px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted">
<th className="px-4 pb-2 font-medium sm:px-5">Commitment</th>
<th className="px-4 pb-2 text-right font-medium">Sold</th>
<th className="px-4 pb-2 text-right font-medium">Free</th>
<th className="px-4 pb-2 text-right font-medium">Utilisation</th>
<th className="px-4 pb-2 text-right font-medium">Cost/hr</th>
<th className="px-4 pb-2 text-right font-medium sm:px-5">Break even</th>
</tr>
</thead>
<tbody>
{data.blocks.map((block) => (
<tr key={block.commitmentId} className="border-b border-border/60 last:border-0">
<td className="px-4 py-3 sm:px-5">
<div className="font-medium">{block.name}</div>
<div className="text-xs text-muted">
{block.gpuCount}× {block.gpuType}
</div>
</td>
<td className="nums px-4 py-3 text-right">
{compactNumber(block.soldGpuHours)}
</td>
<td className="nums px-4 py-3 text-right">
{compactNumber(block.availableGpuHours)}
</td>
<td
className={[
'nums px-4 py-3 text-right font-medium',
block.utilisation < 0.5 ? 'text-warning' : '',
].join(' ')}
>
{percent(block.utilisation)}
</td>
<td className="nums px-4 py-3 text-right">
{moneyExact(block.costPerGpuHourCents)}
</td>
<td className="nums px-4 py-3 text-right sm:px-5">
{block.breakEvenPriceCents == null
? '—'
: moneyExact(block.breakEvenPriceCents)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}