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
+263
View File
@@ -0,0 +1,263 @@
/**
* Overview — the landing view.
*
* Leads with margin and idle capacity rather than deal counts, because those
* are the numbers this business actually turns on. A CRM that opens on
* "23 open opportunities" tells you nothing about whether you are making money.
*/
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, ArrowRight, Server, TrendingUp } from 'lucide-react';
import { Link } from 'react-router-dom';
import { compactNumber, get, money, percent, relativeTime } from '@/lib/api';
import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui';
interface Dashboard {
me: { name: string; teams: { team: string; role: string }[] };
margin: {
revenueCents: number;
costCents: number;
grossMarginCents: number;
grossMarginPct: number | null;
utilisation: number;
idleGpuHours: number;
committedGpuHours: number;
allocatedGpuHours: number;
};
blocks: number;
openDemandDeals: number;
openSupplyDeals: number;
idleAlerts: {
commitmentId: string;
name: string;
gpuType: string;
gpuCount: number;
idleCostCents: number;
utilisation: number;
breakEvenPriceCents: number | null;
}[];
recentActivity: {
id: string;
type: string;
subject: string | null;
occurredAt: string;
}[];
}
export function Overview() {
const { data, isLoading, error } = useQuery({
queryKey: ['dashboard'],
queryFn: () => get<Dashboard>('/api/dashboard'),
// The book does not change second to second, but it does change while
// someone is looking at it during a pipeline review.
refetchInterval: 60_000,
});
if (isLoading) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
);
}
if (error || !data) {
return (
<EmptyState
title="Could not load the overview"
description={error instanceof Error ? error.message : 'Unknown error.'}
/>
);
}
const m = data.margin;
const marginTone = m.grossMarginCents >= 0 ? 'positive' : 'danger';
const firstName = data.me.name.split(' ')[0];
return (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">
{greeting()}, {firstName}
</h1>
<p className="mt-1 text-sm text-muted">
{data.blocks === 0
? 'No capacity commitments yet — margin appears once you record what you have bought.'
: `${data.blocks} capacity commitment${data.blocks === 1 ? '' : 's'} on the book.`}
</p>
</header>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat
label="Gross margin"
value={money(m.grossMarginCents)}
hint={`${percent(m.grossMarginPct, 1)} of revenue`}
tone={marginTone}
/>
<Stat
label="Utilisation"
value={percent(m.utilisation, 1)}
hint={`${compactNumber(m.allocatedGpuHours)} of ${compactNumber(m.committedGpuHours)} GPU-hrs sold`}
tone={m.utilisation < 0.6 ? 'warning' : 'default'}
/>
<Stat
label="Idle capacity"
value={`${compactNumber(m.idleGpuHours)} hrs`}
hint="Bought and unsold"
tone={m.idleGpuHours > 0 ? 'warning' : 'default'}
/>
<Stat
label="Open deals"
value={data.openDemandDeals + data.openSupplyDeals}
hint={`${data.openDemandDeals} demand · ${data.openSupplyDeals} supply`}
/>
</section>
{data.idleAlerts.length > 0 ? (
<Card className="border-warning/30">
<CardHeader className="flex-row items-center gap-2 space-y-0">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" aria-hidden />
<CardTitle className="text-base">Capacity you are paying for and not selling</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.idleAlerts.map((alert) => (
<div
key={alert.commitmentId}
className="flex flex-col gap-2 rounded-lg bg-surface-2 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="truncate font-medium">{alert.name}</p>
<p className="text-xs text-muted">
{alert.gpuCount}× {alert.gpuType} · {percent(alert.utilisation)} utilised
{/*
A zero break-even means the block's cost is already
covered, so any further sale is upside. Printing
"break even above $0.00" is technically true and reads
like a bug, so it is said in words instead.
*/}
{alert.breakEvenPriceCents == null ? null : alert.breakEvenPriceCents > 0 ? (
<>
{' · '}break even above{' '}
<span className="nums">{money(alert.breakEvenPriceCents)}</span>/GPU-hr
</>
) : (
<>{' · '}cost already covered further sales are upside</>
)}
</p>
</div>
<div className="flex items-center gap-3 sm:justify-end">
<span className="nums whitespace-nowrap text-sm font-semibold text-warning">
{money(alert.idleCostCents)}
</span>
<Link
to="/capacity"
className="tap inline-flex items-center gap-1 text-sm font-medium text-accent-fg"
>
Match
<ArrowRight className="h-3.5 w-3.5" aria-hidden />
</Link>
</div>
</div>
))}
</CardContent>
</Card>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">The book</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<Row label="Revenue" value={money(m.revenueCents)} />
<Row label="Cost of committed capacity" value={money(m.costCents)} />
<div className="border-t border-border pt-2">
<Row
label="Gross margin"
value={money(m.grossMarginCents)}
emphasis
tone={marginTone}
/>
</div>
<p className="pt-2 text-xs leading-relaxed text-muted">
Cost is charged against the full commitment, not only the hours that sold
unsold hours are already paid for.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Recent activity</CardTitle>
</CardHeader>
<CardContent>
{data.recentActivity.length === 0 ? (
<p className="py-6 text-center text-sm text-muted">Nothing logged yet.</p>
) : (
<ul className="space-y-2.5">
{data.recentActivity.slice(0, 8).map((activity) => (
<li key={activity.id} className="flex items-start gap-2 text-sm">
<Badge tone="neutral" className="mt-0.5 shrink-0">
{activity.type.replace('_', ' ')}
</Badge>
<span className="min-w-0 flex-1 truncate">{activity.subject ?? '—'}</span>
<span className="shrink-0 text-xs text-muted">
{relativeTime(activity.occurredAt)}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
{data.blocks === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Server className="h-8 w-8" />}
title="No capacity on the book yet"
description="Record a capacity commitment — what you bought, at what cost, over what term — and margin, utilisation and idle alerts all follow from it."
/>
</CardContent>
</Card>
) : null}
</div>
);
}
function Row({
label,
value,
emphasis,
tone,
}: {
label: string;
value: string;
emphasis?: boolean;
tone?: 'positive' | 'danger';
}) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className={emphasis ? 'font-medium' : 'text-muted'}>{label}</span>
<span
className={[
'nums tabular-nums',
emphasis ? 'text-base font-semibold' : '',
tone === 'positive' ? 'text-positive' : tone === 'danger' ? 'text-danger' : '',
].join(' ')}
>
{value}
</span>
</div>
);
}
function greeting(): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
}