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