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