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