Files
pig/apps/web/src/pages/Margin.tsx
T
karti de33a03524 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>
2026-08-12 19:16:43 -07:00

139 lines
5.0 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.
/**
* 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>
);
}