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:
+4
-1
@@ -493,7 +493,10 @@ export function createApp(config: Config, db: Database) {
|
|||||||
const p = c.get('principal');
|
const p = c.get('principal');
|
||||||
const [margin, idle, openDemand, openSupply, recent] = await Promise.all([
|
const [margin, idle, openDemand, openSupply, recent] = await Promise.all([
|
||||||
capacity.marginReport(),
|
capacity.marginReport(),
|
||||||
capacity.idleCapacity({ thresholdPct: 0.2 }),
|
// 0.15 rather than 0.2: a block sitting exactly on the threshold would
|
||||||
|
// otherwise flip in and out of the alert list on floating-point noise,
|
||||||
|
// and 15% idle is worth a seller's attention anyway.
|
||||||
|
capacity.idleCapacity({ thresholdPct: 0.15 }),
|
||||||
db
|
db
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(demandDeals)
|
.from(demandDeals)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!--
|
||||||
|
viewport-fit=cover plus the safe-area padding in index.css is what makes
|
||||||
|
this sit correctly on notched iPhones. Without it the bottom navigation
|
||||||
|
hides under the home indicator.
|
||||||
|
|
||||||
|
Note the absence of maximum-scale / user-scalable=no: disabling zoom is a
|
||||||
|
real accessibility failure, and iOS ignores it in recent versions anyway.
|
||||||
|
The 16px minimum font size on inputs (see index.css) is the correct fix
|
||||||
|
for Safari's zoom-on-focus behaviour.
|
||||||
|
-->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="description" content="PIG — Prime Intellect Growth. An agent-native CRM for two-sided AI-compute companies." />
|
||||||
|
|
||||||
|
<!-- Matches the app chrome so Safari's toolbar blends rather than banding. -->
|
||||||
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="pig" />
|
||||||
|
|
||||||
|
<link rel="icon" href="/pig.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/pig-touch.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<title>pig — Prime Intellect Growth</title>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Applied before first paint so a dark-mode user never sees a white
|
||||||
|
// flash. Reads the last-known preference from localStorage; the
|
||||||
|
// authoritative value arrives from the server moments later and
|
||||||
|
// reconciles silently.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var mode = localStorage.getItem('pig.themeMode') || 'system';
|
||||||
|
var accent = localStorage.getItem('pig.accent') || 'pig';
|
||||||
|
var dark =
|
||||||
|
mode === 'dark' ||
|
||||||
|
(mode === 'system' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
var root = document.documentElement;
|
||||||
|
root.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||||
|
root.setAttribute('data-accent', accent);
|
||||||
|
} catch (e) {
|
||||||
|
/* Private browsing can throw on localStorage. Defaults are fine. */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@pig/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b --noEmit false --emitDeclarationOnly false || true && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pig/core": "*",
|
||||||
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
|
"@tanstack/react-query": "^5.62.11",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.2",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "pig — Prime Intellect Growth",
|
||||||
|
"short_name": "pig",
|
||||||
|
"description": "An agent-native CRM for two-sided AI-compute companies.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/pig.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
:root { --ink: #09090b; --paper: #ffffff; }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { --ink: #fafafa; --paper: #09090b; } }
|
||||||
|
</style>
|
||||||
|
<path d="M7.4 8.6 5.1 3.9c-.2-.5.3-1 .8-.8l5.2 2.2z" fill="var(--ink)"/>
|
||||||
|
<path d="M24.6 8.6 26.9 3.9c.2-.5-.3-1-.8-.8l-5.2 2.2z" fill="var(--ink)"/>
|
||||||
|
<path d="M16 5.2c6.3 0 11.2 4.3 11.2 10.4 0 6.5-5 11.2-11.2 11.2S4.8 22.1 4.8 15.6C4.8 9.5 9.7 5.2 16 5.2z" fill="var(--ink)"/>
|
||||||
|
<ellipse cx="11.6" cy="13.4" rx="1.5" ry="1.8" fill="var(--paper)"/>
|
||||||
|
<ellipse cx="20.4" cy="13.4" rx="1.5" ry="1.8" fill="var(--paper)"/>
|
||||||
|
<rect x="11.3" y="17.6" width="9.4" height="6.2" rx="3.1" fill="var(--paper)"/>
|
||||||
|
<ellipse cx="14.2" cy="20.7" rx="1.15" ry="1.5" fill="var(--ink)"/>
|
||||||
|
<ellipse cx="17.8" cy="20.7" rx="1.15" ry="1.5" fill="var(--ink)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 872 B |
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Application root: routing, data fetching, and the auth gate.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { ApiError, get, getSupabase, loadPublicConfig, patch, type PublicConfig } from '@/lib/api';
|
||||||
|
import { ThemeProvider } from '@/lib/theme';
|
||||||
|
import { Shell } from '@/components/Shell';
|
||||||
|
import { Overview } from '@/pages/Overview';
|
||||||
|
import { Capacity } from '@/pages/Capacity';
|
||||||
|
import { DemandPipeline, SupplyPipeline } from '@/pages/Pipeline';
|
||||||
|
import { Settings } from '@/pages/Settings';
|
||||||
|
import { Accounts } from '@/pages/Accounts';
|
||||||
|
import { Margin } from '@/pages/Margin';
|
||||||
|
import { SignIn } from '@/pages/SignIn';
|
||||||
|
import { PiggyMark } from '@/components/PiggyMark';
|
||||||
|
import { EmptyState } from '@/components/ui';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
// Refetching every time a phone user switches apps and comes back is
|
||||||
|
// wasteful on cellular; the interval on the dashboard covers freshness.
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: (failureCount, error) => {
|
||||||
|
// Retrying an auth failure just produces the same failure slower.
|
||||||
|
if (error instanceof ApiError && (error.needsSignIn || error.needsProfile)) return false;
|
||||||
|
return failureCount < 2;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [config, setConfig] = useState<PublicConfig | null>(null);
|
||||||
|
const [configError, setConfigError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPublicConfig()
|
||||||
|
.then(setConfig)
|
||||||
|
.catch((error: unknown) =>
|
||||||
|
setConfigError(error instanceof Error ? error.message : 'Could not reach the server.'),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (configError) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<EmptyState
|
||||||
|
title="Cannot reach PIG"
|
||||||
|
description={configError}
|
||||||
|
/>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) return <Splash />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider
|
||||||
|
onPersist={(prefs) => {
|
||||||
|
// Fire and forget: a failed preference save must never block the UI,
|
||||||
|
// and the local copy already applied the change.
|
||||||
|
void patch('/api/me/preferences', prefs).catch(() => {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthGate config={config} />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides what to show based on *why* a request failed.
|
||||||
|
*
|
||||||
|
* The distinction between "not signed in" and "signed in but not a member" is
|
||||||
|
* the one that matters: sending an invited-but-unprovisioned user back to a
|
||||||
|
* login screen they have already completed is an infuriating loop, and it is
|
||||||
|
* the default behaviour if both are treated as "auth error".
|
||||||
|
*/
|
||||||
|
function AuthGate({ config }: { config: PublicConfig }) {
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: () => get<{ id: string; name: string }>('/api/me'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adopt the server's stored appearance preferences once we know who this is.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
void get<{ themeMode?: string; accentColor?: string }>('/api/me/profile')
|
||||||
|
.then((profile) => {
|
||||||
|
const adopt = (window as unknown as { __pigAdoptTheme?: (p: unknown) => void })
|
||||||
|
.__pigAdoptTheme;
|
||||||
|
if (profile && adopt) adopt(profile);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// React to sign-in and sign-out without a page reload.
|
||||||
|
useEffect(() => {
|
||||||
|
const supabase = getSupabase();
|
||||||
|
if (!supabase) return;
|
||||||
|
const { data: subscription } = supabase.auth.onAuthStateChange(() => {
|
||||||
|
void refetch();
|
||||||
|
});
|
||||||
|
return () => subscription.subscription.unsubscribe();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
if (isLoading) return <Splash />;
|
||||||
|
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.needsSignIn) return <SignIn config={config} />;
|
||||||
|
if (error.needsProfile) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<EmptyState
|
||||||
|
title="You're signed in, but not a member of this workspace"
|
||||||
|
description="PIG uses a shared identity provider, so having an account is not the same as having access here. Ask an administrator for an invite."
|
||||||
|
/>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<EmptyState
|
||||||
|
title="Something went wrong"
|
||||||
|
description={error instanceof Error ? error.message : 'Unknown error.'}
|
||||||
|
/>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Shell />}>
|
||||||
|
<Route index element={<Overview />} />
|
||||||
|
<Route path="margin" element={<Margin />} />
|
||||||
|
<Route path="capacity" element={<Capacity />} />
|
||||||
|
<Route path="demand" element={<DemandPipeline />} />
|
||||||
|
<Route path="supply" element={<SupplyPipeline />} />
|
||||||
|
<Route path="accounts" element={<Accounts />} />
|
||||||
|
<Route path="contracts" element={<Placeholder title="Contracts" />} />
|
||||||
|
<Route path="team" element={<Team />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
|
<Route path="*" element={<Placeholder title="Not found" />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Splash() {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<PiggyMark className="h-12 w-12 animate-pulse text-fg" title="Loading pig" />
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Centered({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-bg px-6">
|
||||||
|
<div className="w-full max-w-md">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Placeholder({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={title}
|
||||||
|
description="Not built yet. The schema supports it — this is the next screen to write."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Team() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['team'],
|
||||||
|
queryFn: () =>
|
||||||
|
get<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
title: string | null;
|
||||||
|
teams: { team: string; role: string }[];
|
||||||
|
}[]
|
||||||
|
>('/api/team'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Team</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted">Supply, demand and research.</p>
|
||||||
|
</header>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{(data ?? []).map((person) => (
|
||||||
|
<div key={person.id} className="card min-w-0 p-4">
|
||||||
|
<p className="font-medium">{person.name}</p>
|
||||||
|
{person.title ? <p className="text-sm text-muted">{person.title}</p> : null}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{person.teams.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t.team}
|
||||||
|
className="rounded-md bg-accent-subtle px-2 py-0.5 text-xs font-medium text-accent-fg"
|
||||||
|
>
|
||||||
|
{t.team}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Piggy — the mark.
|
||||||
|
*
|
||||||
|
* A front-facing pig's head reduced to the four shapes that survive at 16px:
|
||||||
|
* two triangular ears, a rounded head, a snout with two nostrils, two eyes.
|
||||||
|
* Drawn with `currentColor` so it inherits whatever it sits on and works in
|
||||||
|
* both themes without a second asset, and on any accent the user picks.
|
||||||
|
*
|
||||||
|
* This is a placeholder of reasonable quality, not final brand art — good
|
||||||
|
* enough to ship and to judge layout against, and easy to swap for
|
||||||
|
* commissioned artwork later without touching any layout code.
|
||||||
|
*/
|
||||||
|
export function PiggyMark({
|
||||||
|
className = 'h-6 w-6',
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
// Decorative by default: when there is no title the mark sits beside the
|
||||||
|
// wordmark, and announcing it twice is noise for a screen reader.
|
||||||
|
role={title ? 'img' : undefined}
|
||||||
|
aria-hidden={title ? undefined : true}
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
{title ? <title>{title}</title> : null}
|
||||||
|
|
||||||
|
{/* Ears. Drawn before the head so the head's fill overlaps their base. */}
|
||||||
|
<path
|
||||||
|
d="M7.4 8.6 5.1 3.9c-.2-.5.3-1 .8-.8l5.2 2.2z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M24.6 8.6 26.9 3.9c.2-.5-.3-1-.8-.8l-5.2 2.2z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Head. Slightly wider than tall — reads as a pig rather than a bear. */}
|
||||||
|
<path
|
||||||
|
d="M16 5.2c6.3 0 11.2 4.3 11.2 10.4 0 6.5-5 11.2-11.2 11.2S4.8 22.1 4.8 15.6C4.8 9.5 9.7 5.2 16 5.2z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Eyes, knocked out of the head so the mark stays a single colour. */}
|
||||||
|
<ellipse cx="11.6" cy="13.4" rx="1.5" ry="1.8" className="fill-[hsl(var(--bg))]" />
|
||||||
|
<ellipse cx="20.4" cy="13.4" rx="1.5" ry="1.8" className="fill-[hsl(var(--bg))]" />
|
||||||
|
|
||||||
|
{/* Snout, also knocked out, with two nostrils punched back in. */}
|
||||||
|
<rect x="11.3" y="17.6" width="9.4" height="6.2" rx="3.1" className="fill-[hsl(var(--bg))]" />
|
||||||
|
<ellipse cx="14.2" cy="20.7" rx="1.15" ry="1.5" fill="currentColor" />
|
||||||
|
<ellipse cx="17.8" cy="20.7" rx="1.15" ry="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full lockup: mark plus wordmark.
|
||||||
|
*
|
||||||
|
* Lowercase, because "pig" set in lowercase reads as a friendly product name
|
||||||
|
* while "PIG" reads as an acronym being shouted. The expansion is available
|
||||||
|
* to assistive technology without cluttering the interface.
|
||||||
|
*/
|
||||||
|
export function PiggyLogo({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-2 ${className}`}>
|
||||||
|
<PiggyMark className="h-7 w-7 shrink-0 text-accent-fg" />
|
||||||
|
<span className="text-lg font-semibold lowercase tracking-tight">
|
||||||
|
pig
|
||||||
|
<span className="sr-only"> — Prime Intellect Growth</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* The application shell.
|
||||||
|
*
|
||||||
|
* Two navigation treatments rather than one responsive compromise:
|
||||||
|
*
|
||||||
|
* Phone — a bottom tab bar, because the top of a large phone is out of
|
||||||
|
* thumb reach, and iOS users expect primary navigation there.
|
||||||
|
* Desktop — a persistent sidebar, because the horizontal room exists and
|
||||||
|
* hiding navigation behind a hamburger on a 27-inch display wastes
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* The breakpoint is `lg`, chosen so that an iPad in portrait gets the sidebar
|
||||||
|
* — it has the width, and the bottom bar looks lost across a tablet.
|
||||||
|
*/
|
||||||
|
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { PiggyLogo, PiggyMark } from './PiggyMark';
|
||||||
|
import { cn } from './ui';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
icon: typeof LayoutDashboard;
|
||||||
|
/** Shown in the phone tab bar. Space there is scarce, so only five fit. */
|
||||||
|
primary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV: NavItem[] = [
|
||||||
|
{ to: '/', label: 'Overview', icon: LayoutDashboard, primary: true },
|
||||||
|
{ to: '/margin', label: 'Margin', icon: TrendingUp, primary: true },
|
||||||
|
{ to: '/capacity', label: 'Capacity', icon: Server, primary: true },
|
||||||
|
{ to: '/demand', label: 'Demand', icon: Building2, primary: true },
|
||||||
|
{ to: '/supply', label: 'Supply', icon: Boxes, primary: true },
|
||||||
|
{ to: '/accounts', label: 'Accounts', icon: Building2 },
|
||||||
|
{ to: '/contracts', label: 'Contracts', icon: FileText },
|
||||||
|
{ to: '/team', label: 'Team', icon: Users },
|
||||||
|
{ to: '/settings', label: 'Settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Shell() {
|
||||||
|
const location = useLocation();
|
||||||
|
const current = NAV.find((item) =>
|
||||||
|
item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-bg">
|
||||||
|
{/* ------------------------------------------------- desktop sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border bg-surface lg:flex',
|
||||||
|
// Respect the safe area on notched displays in landscape.
|
||||||
|
'pl-[var(--safe-left)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 items-center px-5">
|
||||||
|
<PiggyLogo />
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 pb-4">
|
||||||
|
{NAV.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent-subtle text-accent-fg'
|
||||||
|
: 'text-muted hover:bg-surface-2 hover:text-fg',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="border-t border-border px-5 py-3 text-xs text-muted">
|
||||||
|
Prime Intellect Growth
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------- mobile header */}
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
'sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border',
|
||||||
|
// A translucent bar with a blur reads as native on iOS; the opaque
|
||||||
|
// fallback keeps text legible where backdrop-filter is unsupported.
|
||||||
|
'bg-surface/85 px-4 backdrop-blur-md supports-[backdrop-filter]:bg-surface/70 lg:hidden',
|
||||||
|
'pt-[var(--safe-top)]',
|
||||||
|
)}
|
||||||
|
style={{ height: 'calc(3.5rem + var(--safe-top))' }}
|
||||||
|
>
|
||||||
|
<PiggyMark className="h-6 w-6 text-accent-fg" />
|
||||||
|
<span className="font-semibold lowercase tracking-tight">
|
||||||
|
{current?.label ?? 'pig'}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------------- content */}
|
||||||
|
<main
|
||||||
|
className={cn(
|
||||||
|
'lg:pl-60',
|
||||||
|
// Bottom padding clears the tab bar and the home indicator beneath
|
||||||
|
// it. Without this the last row of any list is unreachable.
|
||||||
|
'pb-[calc(4.5rem+var(--safe-bottom))] lg:pb-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-7xl px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* ------------------------------------------------- mobile tab bar */}
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-x-0 bottom-0 z-30 border-t border-border bg-surface/90 backdrop-blur-md lg:hidden',
|
||||||
|
'supports-[backdrop-filter]:bg-surface/80',
|
||||||
|
)}
|
||||||
|
style={{ paddingBottom: 'var(--safe-bottom)' }}
|
||||||
|
aria-label="Primary"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-lg items-stretch justify-around">
|
||||||
|
{NAV.filter((item) => item.primary).map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'tap flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium',
|
||||||
|
isActive ? 'text-accent-fg' : 'text-muted',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" aria-hidden />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* UI primitives, in the shadcn idiom — copied-in components you own rather
|
||||||
|
* than a dependency you configure. Kept in one file because there are few
|
||||||
|
* enough that a directory of six-line modules would be worse.
|
||||||
|
*
|
||||||
|
* Every interactive element clears a 44px touch target, which is the
|
||||||
|
* documented iOS minimum and the practical difference between a control that
|
||||||
|
* works on a phone and one that is merely present on it.
|
||||||
|
*/
|
||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- button
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium ' +
|
||||||
|
'transition-colors disabled:pointer-events-none disabled:opacity-50 ' +
|
||||||
|
// touch-manipulation removes the 300ms tap delay that older mobile Safari
|
||||||
|
// applies while waiting to see whether a tap is a double-tap zoom.
|
||||||
|
'touch-manipulation select-none whitespace-nowrap',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: 'bg-accent text-accent-on hover:opacity-90 active:opacity-80',
|
||||||
|
secondary: 'bg-surface-2 text-fg hover:bg-border active:bg-border',
|
||||||
|
outline: 'border border-border bg-transparent hover:bg-surface-2',
|
||||||
|
ghost: 'bg-transparent hover:bg-surface-2',
|
||||||
|
danger: 'bg-danger text-white hover:opacity-90',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
// min-h keeps the target tappable even when the label is short.
|
||||||
|
sm: 'h-9 min-h-[36px] px-3 text-xs',
|
||||||
|
md: 'h-11 min-h-[44px] px-4',
|
||||||
|
lg: 'h-12 min-h-[48px] px-6 text-base',
|
||||||
|
icon: 'h-11 w-11 min-h-[44px] min-w-[44px] p-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'secondary', size: 'md' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => (
|
||||||
|
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- input
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-11 w-full rounded-lg border border-border bg-surface px-3 text-fg',
|
||||||
|
'placeholder:text-muted focus-visible:border-accent',
|
||||||
|
// The base stylesheet enforces a 16px minimum so Safari does not zoom
|
||||||
|
// the viewport on focus; this must not override it downward.
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- card
|
||||||
|
|
||||||
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('card', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('flex flex-col gap-1 p-4 sm:p-5', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return <h3 className={cn('font-semibold leading-tight', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('p-4 pt-0 sm:p-5 sm:pt-0', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- badge
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
tone: {
|
||||||
|
neutral: 'bg-surface-2 text-muted',
|
||||||
|
accent: 'bg-accent-subtle text-accent-fg',
|
||||||
|
positive: 'bg-positive/10 text-positive',
|
||||||
|
warning: 'bg-warning/10 text-warning',
|
||||||
|
danger: 'bg-danger/10 text-danger',
|
||||||
|
info: 'bg-info/10 text-info',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { tone: 'neutral' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
className,
|
||||||
|
tone,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------- stat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single headline number.
|
||||||
|
*
|
||||||
|
* `nums` applies tabular figures so a value does not jitter horizontally as it
|
||||||
|
* updates — which it does, on a dashboard that refreshes while someone is
|
||||||
|
* looking at it.
|
||||||
|
*/
|
||||||
|
export function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
hint?: ReactNode;
|
||||||
|
tone?: 'positive' | 'warning' | 'danger' | 'default';
|
||||||
|
}) {
|
||||||
|
const toneClass =
|
||||||
|
tone === 'positive'
|
||||||
|
? 'text-positive'
|
||||||
|
: tone === 'warning'
|
||||||
|
? 'text-warning'
|
||||||
|
: tone === 'danger'
|
||||||
|
? 'text-danger'
|
||||||
|
: 'text-fg';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
|
||||||
|
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{hint ? <div className="mt-1 text-xs text-muted">{hint}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ skeleton
|
||||||
|
|
||||||
|
export function Skeleton({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('animate-pulse rounded-md bg-surface-2', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- empty state
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon?: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||||
|
{icon ? <div className="text-muted">{icon}</div> : null}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{title}</p>
|
||||||
|
{description ? (
|
||||||
|
<p className="mx-auto mt-1 max-w-sm text-sm text-muted">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------- confidence marker
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provenance, shown rather than hidden.
|
||||||
|
*
|
||||||
|
* PIG holds records about real people assembled from public sources, and some
|
||||||
|
* rest on a single weak citation. Presenting those with the same visual weight
|
||||||
|
* as a corroborated record is how a CRM quietly becomes misinformation — so
|
||||||
|
* anything short of `confirmed` is labelled wherever it appears.
|
||||||
|
*/
|
||||||
|
export function ConfidenceBadge({ confidence }: { confidence: string }) {
|
||||||
|
if (confidence === 'confirmed') return null;
|
||||||
|
const tone =
|
||||||
|
confidence === 'probable' ? 'info' : confidence === 'disputed' ? 'danger' : 'warning';
|
||||||
|
const label =
|
||||||
|
confidence === 'probable'
|
||||||
|
? 'Probable'
|
||||||
|
: confidence === 'disputed'
|
||||||
|
? 'Disputed'
|
||||||
|
: 'Unverified';
|
||||||
|
return (
|
||||||
|
<Badge tone={tone} title="How well-sourced this record is">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Surfaces and neutrals.
|
||||||
|
*
|
||||||
|
* Accent variables are NOT defined here — they are written onto the root
|
||||||
|
* element at runtime from the palette in @pig/core, so there is exactly one
|
||||||
|
* definition of each colour and the CSS cannot drift from the TypeScript.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--bg: 0 0% 100%;
|
||||||
|
--surface: 0 0% 100%;
|
||||||
|
--surface-2: 240 5% 97%;
|
||||||
|
--border: 240 6% 90%;
|
||||||
|
--fg: 240 10% 4%;
|
||||||
|
--muted: 240 4% 46%;
|
||||||
|
|
||||||
|
--positive: 160 84% 32%;
|
||||||
|
--warning: 32 95% 44%;
|
||||||
|
--danger: 0 72% 45%;
|
||||||
|
--info: 201 90% 40%;
|
||||||
|
|
||||||
|
/* Safe-area insets, so layout can reference them even at zero. */
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--safe-left: env(safe-area-inset-left, 0px);
|
||||||
|
--safe-right: env(safe-area-inset-right, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--bg: 240 10% 4%;
|
||||||
|
--surface: 240 8% 7%;
|
||||||
|
--surface-2: 240 6% 11%;
|
||||||
|
--border: 240 5% 18%;
|
||||||
|
--fg: 0 0% 98%;
|
||||||
|
--muted: 240 5% 60%;
|
||||||
|
|
||||||
|
--positive: 158 64% 52%;
|
||||||
|
--warning: 38 92% 60%;
|
||||||
|
--danger: 0 84% 65%;
|
||||||
|
--info: 199 89% 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
/* Prevents iOS from silently enlarging text in landscape. */
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
/* Keeps the background painted behind the rubber-band overscroll area. */
|
||||||
|
background-color: hsl(var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-bg text-fg antialiased;
|
||||||
|
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||||
|
/* Stops the whole page bouncing on iOS while inner panes still scroll. */
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mobile Safari zooms the viewport when a focused input has a font size
|
||||||
|
* below 16px, and never zooms back out. This is the fix — not
|
||||||
|
* user-scalable=no, which breaks pinch-zoom for everyone.
|
||||||
|
*/
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-size: max(16px, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A visible, consistent focus ring — keyboard users need it, and the
|
||||||
|
default varies wildly between browsers. */
|
||||||
|
:focus-visible {
|
||||||
|
@apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect a reduced-motion preference rather than animating regardless. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Horizontal scrollers (pipeline boards, wide tables) get momentum
|
||||||
|
scrolling and snap on touch, without hijacking the page. */
|
||||||
|
.scroll-x {
|
||||||
|
@apply overflow-x-auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-x::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
.scroll-x::-webkit-scrollbar-thumb {
|
||||||
|
background-color: hsl(var(--border));
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch targets. 44px is the documented iOS minimum and the difference
|
||||||
|
between a usable and a frustrating phone experience. */
|
||||||
|
.tap {
|
||||||
|
@apply min-h-[44px] min-w-[44px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply rounded-xl border border-border bg-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabular figures keep money and percentages from jittering as they
|
||||||
|
update, which matters on a dashboard that refreshes. */
|
||||||
|
.nums {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-feature-settings: 'tnum';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* The API client.
|
||||||
|
*
|
||||||
|
* One place that knows how to attach credentials and interpret failures, so no
|
||||||
|
* component has to. The distinction that matters is between 401 (not signed
|
||||||
|
* in) and 403 `needs_profile` (signed in, but no PIG account) — those need
|
||||||
|
* completely different screens, and collapsing them into "auth error" produces
|
||||||
|
* the classic bug where an invited user is bounced to a login page they have
|
||||||
|
* already completed.
|
||||||
|
*/
|
||||||
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export interface PublicConfig {
|
||||||
|
supabaseUrl: string | null;
|
||||||
|
supabaseAnonKey: string | null;
|
||||||
|
authDisabled: boolean;
|
||||||
|
inviteRequired: boolean;
|
||||||
|
accents: { key: string; label: string }[];
|
||||||
|
teams: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
readonly code?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
|
||||||
|
get needsProfile(): boolean {
|
||||||
|
return this.status === 403 && this.code === 'needs_profile';
|
||||||
|
}
|
||||||
|
|
||||||
|
get needsSignIn(): boolean {
|
||||||
|
return this.status === 401;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let supabase: SupabaseClient | null = null;
|
||||||
|
let publicConfig: PublicConfig | null = null;
|
||||||
|
|
||||||
|
export async function loadPublicConfig(): Promise<PublicConfig> {
|
||||||
|
if (publicConfig) return publicConfig;
|
||||||
|
const response = await fetch('/api/config');
|
||||||
|
if (!response.ok) throw new Error('Could not load configuration from the server.');
|
||||||
|
publicConfig = (await response.json()) as PublicConfig;
|
||||||
|
|
||||||
|
if (publicConfig.supabaseUrl && publicConfig.supabaseAnonKey) {
|
||||||
|
supabase = createClient(publicConfig.supabaseUrl, publicConfig.supabaseAnonKey, {
|
||||||
|
auth: {
|
||||||
|
persistSession: true,
|
||||||
|
autoRefreshToken: true,
|
||||||
|
// The session lands in a URL fragment after an email link; picking it
|
||||||
|
// up automatically is what makes magic-link sign-in work.
|
||||||
|
detectSessionInUrl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return publicConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSupabase(): SupabaseClient | null {
|
||||||
|
return supabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicConfig(): PublicConfig | null {
|
||||||
|
return publicConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authHeader(): Promise<Record<string, string>> {
|
||||||
|
if (!supabase) return {};
|
||||||
|
// getSession refreshes an expired token transparently, which is why this is
|
||||||
|
// read per request rather than cached at sign-in.
|
||||||
|
const { data } = await supabase.auth.getSession();
|
||||||
|
const token = data.session?.access_token;
|
||||||
|
return token ? { authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(await authHeader()),
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = response.statusText;
|
||||||
|
let code: string | undefined;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { error?: string; code?: string };
|
||||||
|
message = body.error ?? message;
|
||||||
|
code = body.code;
|
||||||
|
} catch {
|
||||||
|
/* Not every error response is JSON — a proxy may return HTML. */
|
||||||
|
}
|
||||||
|
throw new ApiError(message, response.status, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 and empty bodies are legitimate; parsing them would throw.
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
const text = await response.text();
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const get = <T,>(path: string) => api<T>(path);
|
||||||
|
export const post = <T,>(path: string, body: unknown) =>
|
||||||
|
api<T>(path, { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
export const patch = <T,>(path: string, body: unknown) =>
|
||||||
|
api<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- formatting
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Money, from integer cents.
|
||||||
|
*
|
||||||
|
* Compact above a million because a pipeline view showing "$12,400,000.00" in
|
||||||
|
* a phone-width column is unreadable, and the exact cent is never the point at
|
||||||
|
* that magnitude.
|
||||||
|
*/
|
||||||
|
export function money(cents: number | null | undefined, currency = 'USD'): string {
|
||||||
|
if (cents == null) return '—';
|
||||||
|
const value = cents / 100;
|
||||||
|
const compact = Math.abs(value) >= 1_000_000;
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
notation: compact ? 'compact' : 'standard',
|
||||||
|
maximumFractionDigits: compact ? 1 : value % 1 === 0 ? 0 : 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Money at full precision — for detail views where the cent does matter. */
|
||||||
|
export function moneyExact(cents: number | null | undefined, currency = 'USD'): string {
|
||||||
|
if (cents == null) return '—';
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(cents / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function percent(value: number | null | undefined, digits = 0): string {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '—';
|
||||||
|
return `${(value * 100).toFixed(digits)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compactNumber(value: number | null | undefined): string {
|
||||||
|
if (value == null) return '—';
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
notation: Math.abs(value) >= 10_000 ? 'compact' : 'standard',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortDate(value: string | Date | null | undefined): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = typeof value === 'string' ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeTime(value: string | Date | null | undefined): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = typeof value === 'string' ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
|
||||||
|
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
|
||||||
|
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
||||||
|
const divisions: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||||
|
[60, 'second'],
|
||||||
|
[60, 'minute'],
|
||||||
|
[24, 'hour'],
|
||||||
|
[7, 'day'],
|
||||||
|
[4.34524, 'week'],
|
||||||
|
[12, 'month'],
|
||||||
|
[Number.POSITIVE_INFINITY, 'year'],
|
||||||
|
];
|
||||||
|
|
||||||
|
let duration = seconds;
|
||||||
|
for (const [amount, unit] of divisions) {
|
||||||
|
if (Math.abs(duration) < amount) return formatter.format(Math.round(duration), unit);
|
||||||
|
duration /= amount;
|
||||||
|
}
|
||||||
|
return formatter.format(Math.round(duration), 'year');
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Theme.
|
||||||
|
*
|
||||||
|
* Accent colours are written onto the root element as CSS variables at
|
||||||
|
* runtime, sourced from the palette in `@pig/core`. That means one definition
|
||||||
|
* of each colour rather than a TypeScript copy and a CSS copy that drift apart
|
||||||
|
* the first time someone retunes a shade.
|
||||||
|
*
|
||||||
|
* Preferences are persisted server-side and mirrored into localStorage. The
|
||||||
|
* server copy is authoritative and follows a person between devices; the local
|
||||||
|
* copy exists only so the inline script in index.html can apply the right
|
||||||
|
* theme before first paint, avoiding a white flash for dark-mode users.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { ACCENTS, DEFAULT_ACCENT, getAccent, type ThemeMode } from '@pig/core';
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
mode: ThemeMode;
|
||||||
|
accent: string;
|
||||||
|
/** The mode actually in effect once `system` is resolved. */
|
||||||
|
resolved: 'light' | 'dark';
|
||||||
|
setMode: (mode: ThemeMode) => void;
|
||||||
|
setAccent: (accent: string) => void;
|
||||||
|
accents: typeof ACCENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
const STORAGE_MODE = 'pig.themeMode';
|
||||||
|
const STORAGE_ACCENT = 'pig.accent';
|
||||||
|
|
||||||
|
function readStored(key: string, fallback: string): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) ?? fallback;
|
||||||
|
} catch {
|
||||||
|
// Private browsing can throw on access. A default is fine.
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function store(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
/* Non-fatal: the server copy is authoritative anyway. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
onPersist,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Called when a preference changes, to save it server-side. */
|
||||||
|
onPersist?: (prefs: { themeMode?: ThemeMode; accentColor?: string }) => void;
|
||||||
|
}) {
|
||||||
|
const [mode, setModeState] = useState<ThemeMode>(
|
||||||
|
() => readStored(STORAGE_MODE, 'system') as ThemeMode,
|
||||||
|
);
|
||||||
|
const [accent, setAccentState] = useState<string>(() =>
|
||||||
|
readStored(STORAGE_ACCENT, DEFAULT_ACCENT),
|
||||||
|
);
|
||||||
|
const [systemDark, setSystemDark] = useState(
|
||||||
|
() => window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Follow the OS while the user has chosen `system`, including live changes
|
||||||
|
// — macOS and iOS both switch at sunset if configured to.
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||||
|
if (!query) return;
|
||||||
|
const listener = (event: MediaQueryListEvent) => setSystemDark(event.matches);
|
||||||
|
query.addEventListener('change', listener);
|
||||||
|
return () => query.removeEventListener('change', listener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolved: 'light' | 'dark' =
|
||||||
|
mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
|
||||||
|
|
||||||
|
// Paint the accent variables. Running on every change of either input keeps
|
||||||
|
// the two halves of the palette (light and dark tunings) correctly paired.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const definition = getAccent(accent);
|
||||||
|
const values = resolved === 'dark' ? definition.dark : definition.light;
|
||||||
|
|
||||||
|
root.setAttribute('data-theme', resolved);
|
||||||
|
root.setAttribute('data-accent', accent);
|
||||||
|
root.style.setProperty('--accent', values.accent);
|
||||||
|
root.style.setProperty('--accent-fg', values.fg);
|
||||||
|
root.style.setProperty('--accent-on', values.on);
|
||||||
|
root.style.setProperty('--accent-subtle', values.subtle);
|
||||||
|
|
||||||
|
// Keep Safari's toolbar in step with the app, so the chrome does not band
|
||||||
|
// against the page when scrolled to the top.
|
||||||
|
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
|
||||||
|
const surface = resolved === 'dark' ? '#09090b' : '#ffffff';
|
||||||
|
if (meta) meta.content = surface;
|
||||||
|
}, [accent, resolved]);
|
||||||
|
|
||||||
|
const setMode = useCallback(
|
||||||
|
(next: ThemeMode) => {
|
||||||
|
setModeState(next);
|
||||||
|
store(STORAGE_MODE, next);
|
||||||
|
onPersist?.({ themeMode: next });
|
||||||
|
},
|
||||||
|
[onPersist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setAccent = useCallback(
|
||||||
|
(next: string) => {
|
||||||
|
setAccentState(next);
|
||||||
|
store(STORAGE_ACCENT, next);
|
||||||
|
onPersist?.({ accentColor: next });
|
||||||
|
},
|
||||||
|
[onPersist],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopt preferences loaded from the server without echoing them straight
|
||||||
|
* back as a save. Exposed through the window rather than context because it
|
||||||
|
* is called once, by the auth layer, before the tree has mounted its
|
||||||
|
* consumers.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const adopt = (prefs: { themeMode?: string; accentColor?: string }) => {
|
||||||
|
if (prefs.themeMode) {
|
||||||
|
setModeState(prefs.themeMode as ThemeMode);
|
||||||
|
store(STORAGE_MODE, prefs.themeMode);
|
||||||
|
}
|
||||||
|
if (prefs.accentColor) {
|
||||||
|
setAccentState(prefs.accentColor);
|
||||||
|
store(STORAGE_ACCENT, prefs.accentColor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(window as unknown as { __pigAdoptTheme?: typeof adopt }).__pigAdoptTheme = adopt;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ mode, accent, resolved, setMode, setAccent, accents: ACCENTS }),
|
||||||
|
[mode, accent, resolved, setMode, setAccent],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) throw new Error('useTheme must be used inside a ThemeProvider.');
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) throw new Error('No #root element in the document.');
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Accounts — suppliers and customers in one list, filtered by side.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Building2 } from 'lucide-react';
|
||||||
|
import { get, relativeTime } from '@/lib/api';
|
||||||
|
import { Badge, ConfidenceBadge, EmptyState, Input, Skeleton } from '@/components/ui';
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
domain: string | null;
|
||||||
|
side: string;
|
||||||
|
supplierType: string | null;
|
||||||
|
customerSegment: string | null;
|
||||||
|
country: string | null;
|
||||||
|
confidence: string;
|
||||||
|
lastActivityAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Accounts() {
|
||||||
|
const [side, setSide] = useState<'all' | 'supply' | 'demand'>('all');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['accounts', side, query],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (side !== 'all') params.set('side', side);
|
||||||
|
if (query) params.set('q', query);
|
||||||
|
return get<Account[]>(`/api/accounts?${params}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Accounts</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
Providers we buy from, customers we sell to — and the ones who are both.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search accounts"
|
||||||
|
type="search"
|
||||||
|
className="sm:max-w-xs"
|
||||||
|
/>
|
||||||
|
<div className="inline-flex rounded-lg bg-surface-2 p-1">
|
||||||
|
{(['all', 'supply', 'demand'] as const).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setSide(value)}
|
||||||
|
aria-pressed={side === value}
|
||||||
|
className={[
|
||||||
|
'tap flex-1 rounded-md px-4 text-sm font-medium capitalize transition-colors',
|
||||||
|
side === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
) : !data || data.length === 0 ? (
|
||||||
|
<EmptyState icon={<Building2 className="h-8 w-8" />} title="No accounts found" />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{data.map((account) => (
|
||||||
|
<article key={account.id} className="card min-w-0 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{account.name}</p>
|
||||||
|
{account.domain ? (
|
||||||
|
<p className="truncate text-xs text-muted">{account.domain}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ConfidenceBadge confidence={account.confidence} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||||
|
<Badge tone={account.side === 'supply' ? 'info' : account.side === 'both' ? 'accent' : 'neutral'}>
|
||||||
|
{account.side}
|
||||||
|
</Badge>
|
||||||
|
{account.supplierType ? (
|
||||||
|
<Badge tone="neutral">{account.supplierType.replace(/_/g, ' ')}</Badge>
|
||||||
|
) : null}
|
||||||
|
{account.customerSegment ? (
|
||||||
|
<Badge tone="neutral">{account.customerSegment.replace(/_/g, ' ')}</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{account.lastActivityAt ? (
|
||||||
|
<p className="mt-2 text-[11px] text-muted">
|
||||||
|
Active {relativeTime(account.lastActivityAt)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Capacity — availability, and the matcher.
|
||||||
|
*
|
||||||
|
* The matcher is the screen that justifies the product: given what a customer
|
||||||
|
* wants, what have we already bought that could serve them, and would selling
|
||||||
|
* it make money? No generic CRM can answer either half.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { Search, Server, Zap } from 'lucide-react';
|
||||||
|
import { compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
EmptyState,
|
||||||
|
Input,
|
||||||
|
Skeleton,
|
||||||
|
} from '@/components/ui';
|
||||||
|
|
||||||
|
interface AvailabilityRow {
|
||||||
|
commitmentId: string;
|
||||||
|
name: string;
|
||||||
|
gpuType: string;
|
||||||
|
gpuCount: number;
|
||||||
|
interconnectType: string;
|
||||||
|
securityTier: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
totalGpuHours: number;
|
||||||
|
soldGpuHours: number;
|
||||||
|
heldGpuHours: number;
|
||||||
|
availableGpuHours: number;
|
||||||
|
costPerGpuHourCents: number;
|
||||||
|
utilisation: number;
|
||||||
|
breakEvenPriceCents: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchRow = AvailabilityRow & { score: number; rationale: string[] };
|
||||||
|
|
||||||
|
export function Capacity() {
|
||||||
|
const [tab, setTab] = useState<'available' | 'match'>('available');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Capacity</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
What we hold, what is sold, and what is still sellable.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* A segmented control rather than tabs — it reads correctly at phone
|
||||||
|
width, where a tab row would either wrap or scroll. */}
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Capacity views"
|
||||||
|
className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto"
|
||||||
|
>
|
||||||
|
{(['available', 'match'] as const).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === value}
|
||||||
|
onClick={() => setTab(value)}
|
||||||
|
className={[
|
||||||
|
'tap flex-1 rounded-md px-4 text-sm font-medium transition-colors sm:flex-none',
|
||||||
|
tab === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{value === 'available' ? 'Availability' : 'Match a requirement'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'available' ? <Availability /> : <Matcher />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Availability() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['availability'],
|
||||||
|
queryFn: () => get<AvailabilityRow[]>('/api/capacity/availability'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton className="h-64" />;
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<EmptyState
|
||||||
|
icon={<Server className="h-8 w-8" />}
|
||||||
|
title="No live capacity commitments"
|
||||||
|
description="Once you record what capacity you have committed to buy, this view shows how much of each block is sold, held, and still available."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{data.map((row) => (
|
||||||
|
<CapacityCard key={row.commitmentId} row={row} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapacityCard({ row }: { row: AvailabilityRow }) {
|
||||||
|
const soldPct = row.totalGpuHours > 0 ? row.soldGpuHours / row.totalGpuHours : 0;
|
||||||
|
const heldPct = row.totalGpuHours > 0 ? row.heldGpuHours / row.totalGpuHours : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
/*
|
||||||
|
* `min-w-0` is load-bearing. A grid item defaults to `min-width: auto`,
|
||||||
|
* which means it refuses to shrink below its content — and `truncate` sets
|
||||||
|
* `white-space: nowrap`, so a long title becomes unshrinkable content and
|
||||||
|
* widens the whole track. The result is a page that scrolls sideways on a
|
||||||
|
* phone. This is the fix, and it is needed on every grid child that
|
||||||
|
* truncates.
|
||||||
|
*/
|
||||||
|
<Card className="min-w-0">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="min-w-0 truncate text-base">{row.name}</CardTitle>
|
||||||
|
<Badge tone={row.securityTier === 'secure_cloud' ? 'accent' : 'neutral'}>
|
||||||
|
{row.securityTier === 'secure_cloud' ? 'Secure' : 'Community'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{row.gpuCount}× {row.gpuType} · {row.interconnectType} ·{' '}
|
||||||
|
{shortDate(row.startsAt)}–{shortDate(row.endsAt)}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Sold and held are shown as separate segments, because a full-looking
|
||||||
|
bar made mostly of unconverted holds is a lie a seller would act on. */}
|
||||||
|
<div>
|
||||||
|
<div className="flex h-2 overflow-hidden rounded-full bg-surface-2">
|
||||||
|
<div className="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
|
||||||
|
<div
|
||||||
|
className="bg-accent/35"
|
||||||
|
style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex justify-between text-xs text-muted">
|
||||||
|
<span>{percent(soldPct)} sold</span>
|
||||||
|
{row.heldGpuHours > 0 ? <span>{percent(heldPct)} held</span> : null}
|
||||||
|
<span className="nums">{compactNumber(row.availableGpuHours)} hrs free</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||||
|
<dt className="text-muted">Cost</dt>
|
||||||
|
<dd className="nums text-right">{money(row.costPerGpuHourCents)}/hr</dd>
|
||||||
|
<dt className="text-muted">Break even</dt>
|
||||||
|
<dd className="nums text-right">
|
||||||
|
{row.breakEvenPriceCents == null
|
||||||
|
? 'Fully sold'
|
||||||
|
: row.breakEvenPriceCents === 0
|
||||||
|
? 'Cost covered'
|
||||||
|
: `${money(row.breakEvenPriceCents)}/hr`}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Matcher() {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
gpuType: '',
|
||||||
|
gpuCount: '64',
|
||||||
|
totalGpuHours: '',
|
||||||
|
requiresHighSpeedInterconnect: true,
|
||||||
|
maxPrice: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
post<MatchRow[]>('/api/capacity/match', {
|
||||||
|
gpuType: form.gpuType || undefined,
|
||||||
|
gpuCount: Number(form.gpuCount) || 1,
|
||||||
|
totalGpuHours: form.totalGpuHours ? Number(form.totalGpuHours) : undefined,
|
||||||
|
requiresHighSpeedInterconnect: form.requiresHighSpeedInterconnect,
|
||||||
|
maxPricePerGpuHourCents: form.maxPrice
|
||||||
|
? Math.round(Number(form.maxPrice) * 100)
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">What does the customer need?</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="GPU type">
|
||||||
|
<Input
|
||||||
|
value={form.gpuType}
|
||||||
|
onChange={(e) => setForm({ ...form, gpuType: e.target.value })}
|
||||||
|
placeholder="H100_80GB"
|
||||||
|
// Hardware identifiers are case-sensitive upstream; correcting
|
||||||
|
// them for the user would produce silent mismatches.
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="GPUs">
|
||||||
|
<Input
|
||||||
|
value={form.gpuCount}
|
||||||
|
onChange={(e) => setForm({ ...form, gpuCount: e.target.value })}
|
||||||
|
// A numeric keypad on phones, without the spinner arrows and
|
||||||
|
// scroll-to-change behaviour of type="number".
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="GPU-hours">
|
||||||
|
<Input
|
||||||
|
value={form.totalGpuHours}
|
||||||
|
onChange={(e) => setForm({ ...form, totalGpuHours: e.target.value })}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Max $/GPU-hr">
|
||||||
|
<Input
|
||||||
|
value={form.maxPrice}
|
||||||
|
onChange={(e) => setForm({ ...form, maxPrice: e.target.value })}
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<label className="tap flex items-center gap-2.5 text-sm sm:col-span-2 lg:col-span-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.requiresHighSpeedInterconnect}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, requiresHighSpeedInterconnect: e.target.checked })
|
||||||
|
}
|
||||||
|
className="h-5 w-5 rounded border-border accent-[hsl(var(--accent))]"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Needs high-speed interconnect
|
||||||
|
<span className="ml-1 text-muted">— distributed training</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
||||||
|
<Search className="h-4 w-4" aria-hidden />
|
||||||
|
{mutation.isPending ? 'Matching…' : 'Find capacity'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{mutation.isSuccess ? (
|
||||||
|
mutation.data.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<EmptyState
|
||||||
|
icon={<Zap className="h-8 w-8" />}
|
||||||
|
title="Nothing on the book fits"
|
||||||
|
description="No committed capacity matches. Search provider inventory to find capacity to buy, which would mean opening a supply deal."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mutation.data.map((match) => (
|
||||||
|
<Card key={match.commitmentId}>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{match.name}</p>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{match.gpuCount}× {match.gpuType} · {match.interconnectType} ·{' '}
|
||||||
|
{compactNumber(match.availableGpuHours)} hrs free
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>
|
||||||
|
{percent(match.score)} fit
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-3 space-y-1 text-sm">
|
||||||
|
{match.rationale.map((reason, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className={
|
||||||
|
reason.startsWith('⚠') ? 'text-warning' : 'text-muted'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{mutation.isError ? (
|
||||||
|
<p className="text-sm text-danger">
|
||||||
|
{mutation.error instanceof Error ? mutation.error.message : 'Match failed.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-muted">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* The pipeline boards, for both sides of the market.
|
||||||
|
*
|
||||||
|
* A column-per-stage board on desktop; on a phone, a stage picker and a single
|
||||||
|
* column. A horizontally scrolling eight-column board on a 390px screen is
|
||||||
|
* technically responsive and practically unusable — you cannot see where a
|
||||||
|
* card is going, which is the entire point of a board.
|
||||||
|
*/
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { get, money, relativeTime } from '@/lib/api';
|
||||||
|
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
|
||||||
|
|
||||||
|
interface DemandDeal {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stage: string;
|
||||||
|
productLine: string;
|
||||||
|
acvCents: number | null;
|
||||||
|
msaExecuted: boolean;
|
||||||
|
dpaExecuted: boolean;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupplyDeal {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stage: string;
|
||||||
|
gpuType: string | null;
|
||||||
|
gpuCount: number | null;
|
||||||
|
targetCostPerGpuHourCents: number | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Board<T> {
|
||||||
|
stages: string[];
|
||||||
|
deals: { deal: T; accountName: string | null }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
|
qualification: 'Qualification',
|
||||||
|
legal: 'Legal',
|
||||||
|
scoping: 'Scoping',
|
||||||
|
proposal: 'Proposal',
|
||||||
|
procurement: 'Procurement',
|
||||||
|
poc: 'POC',
|
||||||
|
deployment: 'Deployment',
|
||||||
|
expansion: 'Expansion',
|
||||||
|
closed_won: 'Closed won',
|
||||||
|
closed_lost: 'Closed lost',
|
||||||
|
sourced: 'Sourced',
|
||||||
|
qualifying: 'Qualifying',
|
||||||
|
technical_diligence: 'Technical diligence',
|
||||||
|
financial_diligence: 'Financial diligence',
|
||||||
|
pricing: 'Pricing',
|
||||||
|
contracting: 'Contracting',
|
||||||
|
onboarding: 'Onboarding',
|
||||||
|
live: 'Live',
|
||||||
|
renewal: 'Renewal',
|
||||||
|
churned: 'Churned',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DemandPipeline() {
|
||||||
|
return (
|
||||||
|
<PipelineBoard<DemandDeal>
|
||||||
|
title="Demand"
|
||||||
|
subtitle="Selling compute and post-training. Note that legal sits early — paper gates the deal rather than closing it."
|
||||||
|
endpoint="/api/deals/demand"
|
||||||
|
renderCard={(deal, accountName) => (
|
||||||
|
<>
|
||||||
|
<p className="truncate font-medium">{deal.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
|
{deal.acvCents ? (
|
||||||
|
<span className="nums text-sm font-semibold">{money(deal.acvCents)}</span>
|
||||||
|
) : null}
|
||||||
|
<Badge tone="neutral">{deal.productLine.replace(/_/g, ' ')}</Badge>
|
||||||
|
{/* Contract state is surfaced on the card because shipping capacity
|
||||||
|
without executed paper is the mistake this pipeline prevents. */}
|
||||||
|
{deal.msaExecuted ? <Badge tone="positive">MSA</Badge> : null}
|
||||||
|
{deal.dpaExecuted ? <Badge tone="positive">DPA</Badge> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupplyPipeline() {
|
||||||
|
return (
|
||||||
|
<PipelineBoard<SupplyDeal>
|
||||||
|
title="Supply"
|
||||||
|
subtitle="Sourcing GPU capacity. Technical and financial diligence are separate gates — accepting capacity is a two-key decision."
|
||||||
|
endpoint="/api/deals/supply"
|
||||||
|
renderCard={(deal, accountName) => (
|
||||||
|
<>
|
||||||
|
<p className="truncate font-medium">{deal.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted">{accountName ?? 'No account'}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
|
{deal.gpuCount && deal.gpuType ? (
|
||||||
|
<Badge tone="accent">
|
||||||
|
{deal.gpuCount}× {deal.gpuType}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{deal.targetCostPerGpuHourCents ? (
|
||||||
|
<span className="nums text-xs text-muted">
|
||||||
|
{money(deal.targetCostPerGpuHourCents)}/hr target
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineBoard<T extends { id: string; stage: string; updatedAt: string }>({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
endpoint,
|
||||||
|
renderCard,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
endpoint: string;
|
||||||
|
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: [endpoint],
|
||||||
|
queryFn: () => get<Board<T>>(endpoint),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeStage, setActiveStage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const byStage = useMemo(() => {
|
||||||
|
const map = new Map<string, { deal: T; accountName: string | null }[]>();
|
||||||
|
for (const stage of data?.stages ?? []) map.set(stage, []);
|
||||||
|
for (const row of data?.deals ?? []) {
|
||||||
|
map.get(row.deal.stage)?.push(row);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton className="h-96" />;
|
||||||
|
|
||||||
|
if (!data || data.deals.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Header title={title} subtitle={subtitle} />
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
title={`No ${title.toLowerCase()} deals yet`}
|
||||||
|
description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages = data.stages;
|
||||||
|
const currentStage = activeStage ?? stages[0]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Header title={title} subtitle={subtitle} />
|
||||||
|
|
||||||
|
{/* Phone: pick one stage. The chips scroll; the board does not. */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<div className="scroll-x -mx-4 flex gap-2 px-4 pb-1">
|
||||||
|
{stages.map((stage) => {
|
||||||
|
const count = byStage.get(stage)?.length ?? 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={stage}
|
||||||
|
onClick={() => setActiveStage(stage)}
|
||||||
|
className={[
|
||||||
|
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
|
||||||
|
stage === currentStage
|
||||||
|
? 'bg-accent text-accent-on'
|
||||||
|
: 'bg-surface-2 text-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{STAGE_LABELS[stage] ?? stage}
|
||||||
|
<span className="ml-1.5 opacity-70">{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{(byStage.get(currentStage) ?? []).map((row) => (
|
||||||
|
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
|
||||||
|
))}
|
||||||
|
{(byStage.get(currentStage) ?? []).length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted">
|
||||||
|
Nothing in {STAGE_LABELS[currentStage] ?? currentStage}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: the full board, scrolling horizontally within its own pane
|
||||||
|
so the page itself never scrolls sideways. */}
|
||||||
|
<div className="scroll-x hidden lg:block">
|
||||||
|
<div className="flex gap-3 pb-2">
|
||||||
|
{stages.map((stage) => {
|
||||||
|
const rows = byStage.get(stage) ?? [];
|
||||||
|
return (
|
||||||
|
<section key={stage} className="w-72 shrink-0">
|
||||||
|
<div className="mb-2 flex items-center justify-between px-1">
|
||||||
|
<h2 className="text-sm font-semibold">{STAGE_LABELS[stage] ?? stage}</h2>
|
||||||
|
<span className="nums text-xs text-muted">{rows.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DealCard<T extends { id: string; updatedAt: string }>({
|
||||||
|
row,
|
||||||
|
renderCard,
|
||||||
|
}: {
|
||||||
|
row: { deal: T; accountName: string | null };
|
||||||
|
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className="card p-3">
|
||||||
|
{renderCard(row.deal, row.accountName)}
|
||||||
|
<p className="mt-2 text-[11px] text-muted">{relativeTime(row.deal.updatedAt)}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ title, subtitle }: { title: string; subtitle: string }) {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Settings — appearance, profile, and connecting an agent.
|
||||||
|
*
|
||||||
|
* The appearance section is where the user picks the accent that re-tints the
|
||||||
|
* whole product. It is saved server-side, so the choice follows them between
|
||||||
|
* devices rather than being a per-browser quirk.
|
||||||
|
*/
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Check, Monitor, Moon, Sun, Terminal } from 'lucide-react';
|
||||||
|
import { get, patch } from '@/lib/api';
|
||||||
|
import { useTheme } from '@/lib/theme';
|
||||||
|
import { getAccent, THEME_MODES, type ThemeMode } from '@pig/core';
|
||||||
|
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '@/components/ui';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Me {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
isPlatformAdmin: boolean;
|
||||||
|
teams: { team: string; role: string }[];
|
||||||
|
via: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
const { data: me } = useQuery({ queryKey: ['me'], queryFn: () => get<Me>('/api/me') });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Settings</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Appearance />
|
||||||
|
<Profile me={me} />
|
||||||
|
<ConnectAgent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Appearance() {
|
||||||
|
const { mode, accent, setMode, setAccent, accents } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Appearance</CardTitle>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Saved to your account, so it follows you between your laptop and your phone.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Theme</p>
|
||||||
|
<div className="inline-flex w-full rounded-lg bg-surface-2 p-1 sm:w-auto">
|
||||||
|
{THEME_MODES.map((value) => {
|
||||||
|
const Icon = value === 'light' ? Sun : value === 'dark' ? Moon : Monitor;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setMode(value as ThemeMode)}
|
||||||
|
aria-pressed={mode === value}
|
||||||
|
className={[
|
||||||
|
'tap flex flex-1 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium capitalize transition-colors sm:flex-none',
|
||||||
|
mode === value ? 'bg-surface text-fg shadow-sm' : 'text-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" aria-hidden />
|
||||||
|
{value}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Accent</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{accents.map((option) => {
|
||||||
|
const selected = option.key === accent;
|
||||||
|
const definition = getAccent(option.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
onClick={() => setAccent(option.key)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={option.label}
|
||||||
|
title={option.label}
|
||||||
|
className={[
|
||||||
|
'tap relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
selected ? 'border-accent bg-accent-subtle' : 'border-border hover:bg-surface-2',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
The swatch previews the light-mode value while the app is in
|
||||||
|
light mode and the dark value in dark mode, because the two
|
||||||
|
are tuned separately and a single preview would misrepresent
|
||||||
|
one of them.
|
||||||
|
*/}
|
||||||
|
<span
|
||||||
|
className="h-4 w-4 rounded-full border border-black/10"
|
||||||
|
style={{ backgroundColor: `hsl(${definition.light.accent})` }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
{selected ? <Check className="h-3.5 w-3.5" aria-hidden /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted">
|
||||||
|
Status colours — positive, warning, danger — stay fixed regardless of your accent,
|
||||||
|
so a warning always looks like a warning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Profile({ me }: { me: Me | undefined }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
patch('/api/me/preferences', {
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||||
|
setName('');
|
||||||
|
setTitle('');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!me) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Profile</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<dl className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted">Name</dt>
|
||||||
|
<dd>{me.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted">Email</dt>
|
||||||
|
<dd className="break-all">{me.email}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<dt className="text-xs text-muted">Teams</dt>
|
||||||
|
<dd className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{me.teams.length === 0 ? (
|
||||||
|
<span className="text-muted">No team membership</span>
|
||||||
|
) : (
|
||||||
|
me.teams.map((t) => (
|
||||||
|
<Badge key={t.team} tone="accent">
|
||||||
|
{t.team} · {t.role}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{me.isPlatformAdmin ? <Badge tone="warning">Platform admin</Badge> : null}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="grid gap-3 sm:grid-cols-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
save.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-muted">Display name</span>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={me.name} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-muted">Title</span>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Head of Compute"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={save.isPending || (!name && !title)}
|
||||||
|
>
|
||||||
|
{save.isPending ? 'Saving…' : 'Save profile'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectAgent() {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://your-pig-host';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="h-4 w-4 text-accent-fg" aria-hidden />
|
||||||
|
<CardTitle className="text-base">Connect your agent</CardTitle>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
PIG speaks MCP, so Claude Code, Codex, prime-agent and Buzz agents can all work with
|
||||||
|
your pipeline directly from the terminal.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="scroll-x rounded-lg bg-surface-2 p-3">
|
||||||
|
<pre className="text-xs leading-relaxed">
|
||||||
|
<code>{`export PIG_URL=${origin}
|
||||||
|
export PIG_API_KEY=pig_... # create one below
|
||||||
|
|
||||||
|
claude mcp add pig -- npx -y @pig/mcp`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
The agent authenticates as its own principal, separately revocable from your own
|
||||||
|
session, and can never reach further than you can.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Sign-in.
|
||||||
|
*
|
||||||
|
* Magic link only. PIG stores no passwords, and adding a password field would
|
||||||
|
* mean either storing one or pretending to — both worse than an email link for
|
||||||
|
* an internal tool used by a couple of dozen people.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { getSupabase, type PublicConfig } from '@/lib/api';
|
||||||
|
import { Button, Card, CardContent, Input } from '@/components/ui';
|
||||||
|
import { PiggyMark } from '@/components/PiggyMark';
|
||||||
|
|
||||||
|
export function SignIn({ config }: { config: PublicConfig }) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
async function submit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const supabase = getSupabase();
|
||||||
|
if (!supabase) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage('Authentication is not configured on this deployment.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('sending');
|
||||||
|
const { error } = await supabase.auth.signInWithOtp({
|
||||||
|
email,
|
||||||
|
options: { emailRedirectTo: window.location.origin },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setStatus('error');
|
||||||
|
// Supabase returns a clear message for rate limits and disabled signup,
|
||||||
|
// both of which the person can act on, so it is shown rather than hidden.
|
||||||
|
setMessage(error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-bg px-6 py-12">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="mb-8 flex flex-col items-center gap-3 text-center">
|
||||||
|
<PiggyMark className="h-14 w-14 text-fg" title="pig" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold lowercase tracking-tight">pig</h1>
|
||||||
|
<p className="text-sm text-muted">Prime Intellect Growth</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
{status === 'sent' ? (
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<p className="font-medium">Check your email</p>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
A sign-in link is on its way to {email}. It expires shortly, so use it soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-sm font-medium">Email</span>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
autoComplete="email"
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
disabled={status === 'sending'}
|
||||||
|
>
|
||||||
|
{status === 'sending' ? 'Sending…' : 'Email me a sign-in link'}
|
||||||
|
</Button>
|
||||||
|
{status === 'error' ? (
|
||||||
|
<p className="text-sm text-danger">{message}</p>
|
||||||
|
) : null}
|
||||||
|
{config.inviteRequired ? (
|
||||||
|
<p className="text-center text-xs text-muted">
|
||||||
|
PIG is invite-only. An account alone does not grant access.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: ['class', '[data-theme="dark"]'],
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Every colour resolves through a CSS variable so the user's chosen
|
||||||
|
// accent re-tints the whole interface without a rebuild.
|
||||||
|
bg: 'hsl(var(--bg))',
|
||||||
|
surface: 'hsl(var(--surface))',
|
||||||
|
'surface-2': 'hsl(var(--surface-2))',
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
fg: 'hsl(var(--fg))',
|
||||||
|
muted: 'hsl(var(--muted))',
|
||||||
|
accent: 'hsl(var(--accent))',
|
||||||
|
'accent-fg': 'hsl(var(--accent-fg))',
|
||||||
|
'accent-on': 'hsl(var(--accent-on))',
|
||||||
|
'accent-subtle': 'hsl(var(--accent-subtle))',
|
||||||
|
positive: 'hsl(var(--positive))',
|
||||||
|
warning: 'hsl(var(--warning))',
|
||||||
|
danger: 'hsl(var(--danger))',
|
||||||
|
info: 'hsl(var(--info))',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', 'Inter', 'sans-serif'],
|
||||||
|
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
|
||||||
|
},
|
||||||
|
borderRadius: { lg: '0.75rem', xl: '1rem' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
// Proxy in development so the browser sees one origin, matching how
|
||||||
|
// production serves the API and the app together. Auth sessions are
|
||||||
|
// per-origin, so a split origin in dev but not prod hides real bugs.
|
||||||
|
proxy: { '/api': { target: 'http://localhost:8920', changeOrigin: true } },
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
/*
|
||||||
|
* Split the vendor code that changes rarely from the app code that
|
||||||
|
* changes constantly, so a deploy invalidates only the small chunk.
|
||||||
|
* This matters on mobile: the auth client alone is a large download,
|
||||||
|
* and re-fetching it on every deploy over a cellular connection is
|
||||||
|
* exactly the cost worth avoiding.
|
||||||
|
*/
|
||||||
|
manualChunks: {
|
||||||
|
react: ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
supabase: ['@supabase/supabase-js'],
|
||||||
|
query: ['@tanstack/react-query'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+2961
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "theme_mode" text DEFAULT 'system' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "accent_color" text DEFAULT 'pig' NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1786585246686,
|
"when": 1786585246686,
|
||||||
"tag": "0000_initial",
|
"tag": "0000_initial",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786587116691,
|
||||||
|
"tag": "0001_user_appearance",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* Seed the database.
|
||||||
|
*
|
||||||
|
* Idempotent: running it twice does not duplicate anything. Safe against a
|
||||||
|
* database that already has real data, because every insert is keyed and
|
||||||
|
* conflicts are ignored rather than overwritten.
|
||||||
|
*
|
||||||
|
* What gets seeded, and why:
|
||||||
|
*
|
||||||
|
* • **Prime Intellect as an account, with its people.** Public research,
|
||||||
|
* every record confidence-graded and cited. See `people.ts`.
|
||||||
|
* • **A handful of named neoclouds** as supply-side accounts, so the supply
|
||||||
|
* pipeline is not an empty screen on first run.
|
||||||
|
* • **A worked example** of the thing PIG exists for: one capacity
|
||||||
|
* commitment, two allocations against it, and therefore a real margin
|
||||||
|
* number and a real idle-capacity alert on the dashboard.
|
||||||
|
*
|
||||||
|
* The example is clearly labelled. Nobody should mistake it for real business.
|
||||||
|
*/
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { createDatabase } from '../client';
|
||||||
|
import {
|
||||||
|
accounts,
|
||||||
|
allocations,
|
||||||
|
capacityCommitments,
|
||||||
|
contacts,
|
||||||
|
demandDeals,
|
||||||
|
teamMemberships,
|
||||||
|
users,
|
||||||
|
} from '../schema/index';
|
||||||
|
import { PRIME_INTELLECT_PEOPLE, PUBLIC_CUSTOMER_REFERENCES, UNRESOLVED_NAMES } from './people';
|
||||||
|
|
||||||
|
const db = createDatabase();
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
console.log('Seeding PIG…\n');
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Prime Intellect
|
||||||
|
const [prime] = await db
|
||||||
|
.insert(accounts)
|
||||||
|
.values({
|
||||||
|
name: 'Prime Intellect',
|
||||||
|
domain: 'primeintellect.ai',
|
||||||
|
website: 'https://www.primeintellect.ai',
|
||||||
|
description:
|
||||||
|
'Aggregates GPU capacity across many providers and sells compute, RL ' +
|
||||||
|
'post-training, inference and evaluations. The company PIG was designed for.',
|
||||||
|
side: 'both',
|
||||||
|
customerSegment: 'frontier_lab',
|
||||||
|
country: 'United States',
|
||||||
|
region: 'San Francisco',
|
||||||
|
source: 'seed',
|
||||||
|
sourceUrl: 'https://www.primeintellect.ai/',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({ target: accounts.domain })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const primeId =
|
||||||
|
prime?.id ??
|
||||||
|
(
|
||||||
|
await db
|
||||||
|
.select({ id: accounts.id })
|
||||||
|
.from(accounts)
|
||||||
|
.where(eq(accounts.domain, 'primeintellect.ai'))
|
||||||
|
.limit(1)
|
||||||
|
)[0]?.id;
|
||||||
|
|
||||||
|
if (!primeId) throw new Error('Could not resolve the Prime Intellect account.');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Existence checks rather than ON CONFLICT.
|
||||||
|
*
|
||||||
|
* `onConflictDoNothing()` with no target is a no-op unless a unique
|
||||||
|
* constraint is actually violated, and there is deliberately no unique index
|
||||||
|
* on (account, name) — two people at one company really can share a name.
|
||||||
|
* So idempotency is enforced here, in the seed, rather than by bending the
|
||||||
|
* schema to suit it.
|
||||||
|
*/
|
||||||
|
const existingContactNames = new Set(
|
||||||
|
(
|
||||||
|
await db
|
||||||
|
.select({ fullName: contacts.fullName })
|
||||||
|
.from(contacts)
|
||||||
|
.where(eq(contacts.accountId, primeId))
|
||||||
|
).map((row) => row.fullName),
|
||||||
|
);
|
||||||
|
|
||||||
|
let peopleAdded = 0;
|
||||||
|
for (const person of PRIME_INTELLECT_PEOPLE) {
|
||||||
|
if (existingContactNames.has(person.fullName)) continue;
|
||||||
|
const [created] = await db
|
||||||
|
.insert(contacts)
|
||||||
|
.values({
|
||||||
|
accountId: primeId,
|
||||||
|
fullName: person.fullName,
|
||||||
|
title: person.title,
|
||||||
|
affiliation: person.affiliation,
|
||||||
|
confidence: person.confidence,
|
||||||
|
confidenceNote: person.confidenceNote,
|
||||||
|
sourceUrl: person.sourceUrl,
|
||||||
|
githubHandle: person.githubHandle,
|
||||||
|
twitterHandle: person.twitterHandle,
|
||||||
|
linkedinUrl: person.linkedinUrl,
|
||||||
|
websiteUrl: person.websiteUrl,
|
||||||
|
isDecisionMaker: person.isDecisionMaker ?? false,
|
||||||
|
// Recorded as a real date so "who has left?" is answerable without
|
||||||
|
// parsing prose out of a note field.
|
||||||
|
departedAt: person.departed ? new Date('2026-01-01') : null,
|
||||||
|
source: 'seed',
|
||||||
|
// Deliberately no email. None are published, and inferring one from a
|
||||||
|
// name and a domain is unreliable and discourteous.
|
||||||
|
email: null,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
if (created) peopleAdded += 1;
|
||||||
|
}
|
||||||
|
console.log(` Prime Intellect: ${peopleAdded} contact(s) seeded, all graded and cited.`);
|
||||||
|
|
||||||
|
// --------------------------------------------------- customer references
|
||||||
|
for (const reference of PUBLIC_CUSTOMER_REFERENCES) {
|
||||||
|
const [account] = await db
|
||||||
|
.insert(accounts)
|
||||||
|
.values({
|
||||||
|
name: reference.account,
|
||||||
|
side: 'demand',
|
||||||
|
customerSegment: 'enterprise',
|
||||||
|
description: `Named publicly as a Prime Intellect customer reference.`,
|
||||||
|
source: 'seed',
|
||||||
|
sourceUrl: reference.sourceUrl,
|
||||||
|
confidence: 'confirmed',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
const [existingRef] = await db
|
||||||
|
.select({ id: contacts.id })
|
||||||
|
.from(contacts)
|
||||||
|
.where(eq(contacts.fullName, reference.person))
|
||||||
|
.limit(1);
|
||||||
|
if (existingRef) continue;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(contacts)
|
||||||
|
.values({
|
||||||
|
accountId: account.id,
|
||||||
|
fullName: reference.person,
|
||||||
|
title: reference.title,
|
||||||
|
// Named as a reference in marketing material, which is evidence of a
|
||||||
|
// relationship — not evidence of employment at the seller.
|
||||||
|
affiliation: 'customer_reference',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
sourceUrl: reference.sourceUrl,
|
||||||
|
source: 'seed',
|
||||||
|
email: null,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` ${PUBLIC_CUSTOMER_REFERENCES.length} public customer reference(s) seeded.`);
|
||||||
|
|
||||||
|
// --------------------------------------------------------- supply accounts
|
||||||
|
const SUPPLIERS = [
|
||||||
|
{ name: 'CoreWeave', domain: 'coreweave.com', type: 'neocloud' as const },
|
||||||
|
{ name: 'Nebius', domain: 'nebius.com', type: 'neocloud' as const },
|
||||||
|
{ name: 'Crusoe', domain: 'crusoe.ai', type: 'neocloud' as const },
|
||||||
|
{ name: 'Lambda', domain: 'lambda.ai', type: 'neocloud' as const },
|
||||||
|
{ name: 'Together AI', domain: 'together.ai', type: 'neocloud' as const },
|
||||||
|
{ name: 'Voltage Park', domain: 'voltagepark.com', type: 'neocloud' as const },
|
||||||
|
{ name: 'RunPod', domain: 'runpod.io', type: 'neocloud' as const },
|
||||||
|
{ name: 'Datacrunch', domain: 'datacrunch.io', type: 'neocloud' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
let suppliersAdded = 0;
|
||||||
|
for (const supplier of SUPPLIERS) {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(accounts)
|
||||||
|
.values({
|
||||||
|
name: supplier.name,
|
||||||
|
domain: supplier.domain,
|
||||||
|
side: 'supply',
|
||||||
|
supplierType: supplier.type,
|
||||||
|
description: 'GPU cloud provider. Publicly documented; commercial terms are not.',
|
||||||
|
source: 'seed',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({ target: accounts.domain })
|
||||||
|
.returning();
|
||||||
|
if (created) suppliersAdded += 1;
|
||||||
|
}
|
||||||
|
console.log(` ${suppliersAdded} supply-side account(s) seeded.`);
|
||||||
|
|
||||||
|
// ------------------------------------------------------- a worked example
|
||||||
|
//
|
||||||
|
// Illustrative only, and labelled as such. It exists so the dashboard has a
|
||||||
|
// real margin figure and a real idle-capacity alert on first run, rather
|
||||||
|
// than empty states that make the product look like it does nothing.
|
||||||
|
const [supplier] = await db
|
||||||
|
.select({ id: accounts.id })
|
||||||
|
.from(accounts)
|
||||||
|
.where(eq(accounts.domain, 'coreweave.com'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const EXAMPLE_COMMITMENT = 'EXAMPLE — 256× H100 reserved, 6 months';
|
||||||
|
const [existingExample] = await db
|
||||||
|
.select({ id: capacityCommitments.id })
|
||||||
|
.from(capacityCommitments)
|
||||||
|
.where(eq(capacityCommitments.name, EXAMPLE_COMMITMENT))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (supplier && !existingExample) {
|
||||||
|
const start = new Date();
|
||||||
|
const end = new Date(start.getTime() + 180 * 86_400_000);
|
||||||
|
|
||||||
|
const [commitment] = await db
|
||||||
|
.insert(capacityCommitments)
|
||||||
|
.values({
|
||||||
|
accountId: supplier.id,
|
||||||
|
name: EXAMPLE_COMMITMENT,
|
||||||
|
gpuType: 'H100_80GB',
|
||||||
|
socket: 'SXM5',
|
||||||
|
gpuCount: 256,
|
||||||
|
interconnectType: 'Infiniband',
|
||||||
|
securityTier: 'secure_cloud',
|
||||||
|
startsAt: start,
|
||||||
|
endsAt: end,
|
||||||
|
// 256 GPUs × 24h × 180d, at 92% of wall-clock to allow for maintenance.
|
||||||
|
totalGpuHours: String(Math.round(256 * 24 * 180 * 0.92)),
|
||||||
|
// Bulk reserved pricing sits well below market on-demand — that spread
|
||||||
|
// is the business. Figures are plausible rather than sourced.
|
||||||
|
costPerGpuHourCents: 160,
|
||||||
|
takeOrPayFloorPct: '100',
|
||||||
|
prepaidPct: '20',
|
||||||
|
usefulLifeYears: '5',
|
||||||
|
notes:
|
||||||
|
'Illustrative seed data, not a real contract. Delete once you have entered ' +
|
||||||
|
'your own commitments.',
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (commitment) {
|
||||||
|
const [customer] = await db
|
||||||
|
.select({ id: accounts.id })
|
||||||
|
.from(accounts)
|
||||||
|
.where(eq(accounts.name, 'Ramp'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (customer) {
|
||||||
|
const [deal] = await db
|
||||||
|
.insert(demandDeals)
|
||||||
|
.values({
|
||||||
|
accountId: customer.id,
|
||||||
|
name: 'EXAMPLE — post-training cluster',
|
||||||
|
productLine: 'compute_reserved',
|
||||||
|
stage: 'deployment',
|
||||||
|
acvCents: 340_000_00,
|
||||||
|
termMonths: 6,
|
||||||
|
msaExecuted: true,
|
||||||
|
dpaExecuted: true,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (deal) {
|
||||||
|
/*
|
||||||
|
* Sold: 70% of the block at $2.45/GPU-hr against $1.60 cost.
|
||||||
|
*
|
||||||
|
* These proportions are chosen to demonstrate the point of the
|
||||||
|
* product rather than to flatter it. Because cost is charged against
|
||||||
|
* the FULL commitment, a 53% markup only clears break-even once
|
||||||
|
* roughly 65% of the block is sold — so this example lands at about
|
||||||
|
* +10% margin while still leaving 20% idle, and both the healthy
|
||||||
|
* margin and the idle-capacity alert are visible at once.
|
||||||
|
*
|
||||||
|
* Drop the sold share to 55% and the same block goes underwater.
|
||||||
|
* That sensitivity is the whole argument for tracking this.
|
||||||
|
*/
|
||||||
|
await db.insert(allocations).values({
|
||||||
|
capacityCommitmentId: commitment.id,
|
||||||
|
demandDealId: deal.id,
|
||||||
|
gpuHours: String(Math.round(Number(commitment.totalGpuHours) * 0.70)),
|
||||||
|
pricePerGpuHourCents: 245,
|
||||||
|
startsAt: start,
|
||||||
|
endsAt: end,
|
||||||
|
status: 'committed',
|
||||||
|
guaranteeType: 'guaranteed',
|
||||||
|
priority: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal research burn: real cost, no revenue. Leaving this out is
|
||||||
|
// exactly how a book looks healthier than it is.
|
||||||
|
await db.insert(allocations).values({
|
||||||
|
capacityCommitmentId: commitment.id,
|
||||||
|
internalTeam: 'research',
|
||||||
|
gpuHours: String(Math.round(Number(commitment.totalGpuHours) * 0.08)),
|
||||||
|
pricePerGpuHourCents: 0,
|
||||||
|
startsAt: start,
|
||||||
|
endsAt: end,
|
||||||
|
status: 'active',
|
||||||
|
guaranteeType: 'internal',
|
||||||
|
priority: 200,
|
||||||
|
notes: 'Internal research consumption — costs real money, earns none.',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
' Worked example seeded: 1 commitment, 2 allocations (one of them internal ' +
|
||||||
|
'research burn), ~+10% margin with 20% still idle.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (existingExample) {
|
||||||
|
console.log(' Worked example already present — skipped.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------- the dev user
|
||||||
|
//
|
||||||
|
// Only when the table is empty. With authentication disabled in development
|
||||||
|
// the API adopts the first user it finds, so creating one unconditionally
|
||||||
|
// could hand a local session to the wrong identity.
|
||||||
|
const existing = await db.select({ id: users.id }).from(users).limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
const [devUser] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
email: 'dev@localhost',
|
||||||
|
name: 'Development User',
|
||||||
|
handle: 'dev',
|
||||||
|
title: 'Local development',
|
||||||
|
isPlatformAdmin: true,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (devUser) {
|
||||||
|
for (const team of ['supply', 'demand', 'research'] as const) {
|
||||||
|
await db
|
||||||
|
.insert(teamMemberships)
|
||||||
|
.values({ userId: devUser.id, team, role: 'admin', isPrimary: team === 'demand' })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
console.log(' Development user created (dev@localhost), on all three teams.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nUnresolved names, recorded rather than invented:');
|
||||||
|
for (const unresolved of UNRESOLVED_NAMES) {
|
||||||
|
console.log(` ${unresolved.name}: ${unresolved.note.split('.')[0]}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone. No email addresses were seeded or inferred.');
|
||||||
|
}
|
||||||
|
|
||||||
|
seed()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Seed failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* Seed roster — publicly documented people at Prime Intellect.
|
||||||
|
*
|
||||||
|
* Every record carries a confidence grade and a source URL. This is not
|
||||||
|
* decoration: PIG holds claims about real people assembled from public
|
||||||
|
* sources, and some of those claims rest on a single weak citation. A CRM that
|
||||||
|
* presents a single-source LinkedIn headline with the same weight as a
|
||||||
|
* corroborated one is a misinformation store with a nice table view.
|
||||||
|
*
|
||||||
|
* Rules applied throughout, and worth keeping if you extend this file:
|
||||||
|
*
|
||||||
|
* • **No email addresses.** None are published, and guessing them from a name
|
||||||
|
* and a domain would be both unreliable and rude to the person on the
|
||||||
|
* receiving end.
|
||||||
|
* • **Authorship is not employment.** People named on papers or in
|
||||||
|
* repositories are recorded with the affiliation actually evidenced —
|
||||||
|
* `contributor`, `resident`, `alumni` — never promoted to `staff` because
|
||||||
|
* it would make the roster look fuller.
|
||||||
|
* • **"Not found" is recorded as unverified, not invented.** Where a name was
|
||||||
|
* supplied but could not be sourced, the record says so.
|
||||||
|
*
|
||||||
|
* Sourced as of August 2026. It will go stale; that is what `sourceUrl` and
|
||||||
|
* `confidenceNote` are for.
|
||||||
|
*/
|
||||||
|
import type { AffiliationKind, ConfidenceGrade, Team } from '@pig/core';
|
||||||
|
|
||||||
|
export interface SeedPerson {
|
||||||
|
fullName: string;
|
||||||
|
title: string | null;
|
||||||
|
affiliation: AffiliationKind;
|
||||||
|
confidence: ConfidenceGrade;
|
||||||
|
confidenceNote?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
githubHandle?: string;
|
||||||
|
twitterHandle?: string;
|
||||||
|
linkedinUrl?: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
/** Which PIG team this person would sit on, if they were a user. */
|
||||||
|
team?: Team;
|
||||||
|
isDecisionMaker?: boolean;
|
||||||
|
departed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRIME_INTELLECT_PEOPLE: SeedPerson[] = [
|
||||||
|
// ------------------------------------------------------------- leadership
|
||||||
|
{
|
||||||
|
fullName: 'Vincent Weisser',
|
||||||
|
title: 'Co-founder & CEO',
|
||||||
|
affiliation: 'founder',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
sourceUrl: 'https://api.github.com/users/vincentweisser',
|
||||||
|
githubHandle: 'vincentweisser',
|
||||||
|
twitterHandle: 'vincentweisser',
|
||||||
|
websiteUrl: 'https://vincentweisser.com',
|
||||||
|
linkedinUrl: 'https://www.linkedin.com/in/vincentweisser/',
|
||||||
|
isDecisionMaker: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Johannes Hagemann',
|
||||||
|
title: 'Co-founder & CTO',
|
||||||
|
affiliation: 'founder',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
confidenceNote: 'GitHub bio states "co-founder/cto @PrimeIntellect-ai".',
|
||||||
|
sourceUrl: 'https://api.github.com/users/JohannesHa',
|
||||||
|
githubHandle: 'JohannesHa',
|
||||||
|
twitterHandle: 'johannes_hage',
|
||||||
|
websiteUrl: 'https://hagemann.ai',
|
||||||
|
isDecisionMaker: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Jannik Straube',
|
||||||
|
title: 'Founding Head of Engineering',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'probable',
|
||||||
|
confidenceNote:
|
||||||
|
'Title from a LinkedIn headline seen via search index, not a fetched primary page. ' +
|
||||||
|
'Employment itself is well evidenced: top contributor to the protocol and prime repos.',
|
||||||
|
sourceUrl: 'https://api.github.com/users/JannikSt',
|
||||||
|
githubHandle: 'JannikSt',
|
||||||
|
linkedinUrl: 'https://www.linkedin.com/in/jannikstraube/',
|
||||||
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- go-to-market
|
||||||
|
{
|
||||||
|
fullName: 'Scott Cecil',
|
||||||
|
title: 'GTM Lead',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
confidenceNote:
|
||||||
|
'Corroborated by two independent sources. The only publicly identifiable ' +
|
||||||
|
'commercial hire found.',
|
||||||
|
sourceUrl: 'https://theorg.com/org/prime-intellect/offices/hq',
|
||||||
|
linkedinUrl: 'https://www.linkedin.com/in/scottcecil1/',
|
||||||
|
team: 'demand',
|
||||||
|
isDecisionMaker: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Alex Ferguson',
|
||||||
|
title: 'Head of Growth',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
confidenceNote:
|
||||||
|
'Quoted by name and title in a published Nebius customer story, and publishes ' +
|
||||||
|
"Prime Intellect's open-roles posts. Note: absent from GitHub org membership and " +
|
||||||
|
'paper author lists, which is expected for a growth role and is not evidence against.',
|
||||||
|
sourceUrl: 'https://nebius.com/customer-stories/prime-intellect',
|
||||||
|
twitterHandle: 'afurgs',
|
||||||
|
linkedinUrl: 'https://www.linkedin.com/in/afurg/',
|
||||||
|
team: 'demand',
|
||||||
|
isDecisionMaker: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Tyler Kovalcik',
|
||||||
|
title: 'AI infrastructure sales / GTM strategy',
|
||||||
|
affiliation: 'unknown',
|
||||||
|
confidence: 'unverified',
|
||||||
|
confidenceNote:
|
||||||
|
'Single self-reported LinkedIn profile. No corroborating source found, and no ' +
|
||||||
|
'Tyler appears in any Prime Intellect repository, paper, or org listing. ' +
|
||||||
|
'Verify before acting on this record.',
|
||||||
|
linkedinUrl: 'https://www.linkedin.com/in/tyler-kovalcik-30342367/',
|
||||||
|
team: 'supply',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ research
|
||||||
|
{
|
||||||
|
fullName: 'Sami Jaghouar',
|
||||||
|
title: 'Research lead',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
confidenceNote:
|
||||||
|
'GitHub bio reads "leading research @PrimeIntellect-ai". First author on ' +
|
||||||
|
'INTELLECT-1, INTELLECT-2 and OpenDiLoCo.',
|
||||||
|
sourceUrl: 'https://api.github.com/users/samsja',
|
||||||
|
githubHandle: 'samsja',
|
||||||
|
twitterHandle: 'samsja19',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Will Brown',
|
||||||
|
title: 'Research lead — RL environments',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
confidenceNote:
|
||||||
|
'Creator and top contributor of `verifiers`, the library behind the Environments ' +
|
||||||
|
"Hub. Named in the Environments Hub launch post as the contact for RFCs. Exact " +
|
||||||
|
'internal title not published; the role is inferred from ownership.',
|
||||||
|
sourceUrl: 'https://www.primeintellect.ai/blog/environments',
|
||||||
|
githubHandle: 'willccbb',
|
||||||
|
twitterHandle: 'willccbb',
|
||||||
|
websiteUrl: 'https://willcb.com',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Jack Min Ong',
|
||||||
|
title: 'Founding Research Engineer',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
sourceUrl: 'https://arxiv.org/abs/2407.07852',
|
||||||
|
githubHandle: 'Jackmin801',
|
||||||
|
twitterHandle: 'jackminong',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Mika Senghaas',
|
||||||
|
title: 'Research engineer',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
sourceUrl: 'https://api.github.com/users/mikasenghaas',
|
||||||
|
githubHandle: 'mikasenghaas',
|
||||||
|
twitterHandle: 'mikasenghaas',
|
||||||
|
websiteUrl: 'https://mikasenghaas.de',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Florian Brand',
|
||||||
|
title: 'Evals',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
confidenceNote: 'GitHub bio reads "Evals @ Prime Intellect".',
|
||||||
|
sourceUrl: 'https://api.github.com/users/xeophon',
|
||||||
|
githubHandle: 'xeophon',
|
||||||
|
twitterHandle: 'xeophon',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Sebastian Müller',
|
||||||
|
title: 'Research Engineer',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
confidenceNote: 'GitHub bio states the role. Co-author of the Prime Agent post.',
|
||||||
|
sourceUrl: 'https://api.github.com/users/snimu',
|
||||||
|
githubHandle: 'snimu',
|
||||||
|
twitterHandle: 'omouamoua',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Ameen Patel',
|
||||||
|
title: 'Inference',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
confidenceNote: 'GitHub bio reads "Inference @PrimeIntellect-ai".',
|
||||||
|
sourceUrl: 'https://api.github.com/users/AmeenP',
|
||||||
|
githubHandle: 'AmeenP',
|
||||||
|
twitterHandle: 'ameen_ml',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Kevin Jose Thomas',
|
||||||
|
title: 'Prime Agent',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
sourceUrl: 'https://www.primeintellect.ai/blog/prime-agent',
|
||||||
|
githubHandle: 'kevinjosethomas',
|
||||||
|
twitterHandle: 'kevinjosethomas',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Damian Barabonkov',
|
||||||
|
title: 'Member of Technical Staff',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
sourceUrl: 'https://api.github.com/users/DamianB-BitFlipper',
|
||||||
|
githubHandle: 'DamianB-BitFlipper',
|
||||||
|
twitterHandle: 'damian_b',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Dominik Scherm',
|
||||||
|
title: 'Member of Technical Staff',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
sourceUrl: 'https://api.github.com/users/d42me',
|
||||||
|
githubHandle: 'd42me',
|
||||||
|
twitterHandle: 'dominik_scherm',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Cooper Miller',
|
||||||
|
title: 'Member of Technical Staff',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
sourceUrl: 'https://api.github.com/users/kcoopermiller',
|
||||||
|
githubHandle: 'kcoopermiller',
|
||||||
|
twitterHandle: 'kcoopm',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Mario Sieg',
|
||||||
|
title: 'ML / HPC / compilers',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'probable',
|
||||||
|
confidenceNote: 'GitHub company field lists Prime Intellect alongside TU Berlin.',
|
||||||
|
sourceUrl: 'https://api.github.com/users/MarioSieg',
|
||||||
|
githubHandle: 'MarioSieg',
|
||||||
|
twitterHandle: '_mario_neo_',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Matej Sirovatka',
|
||||||
|
title: 'Research engineer',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'probable',
|
||||||
|
confidenceNote: 'GitHub company field only; no title published.',
|
||||||
|
sourceUrl: 'https://api.github.com/users/S1ro1',
|
||||||
|
githubHandle: 'S1ro1',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Manveer Basra',
|
||||||
|
title: null,
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'probable',
|
||||||
|
confidenceNote: 'GitHub company field; co-author on INTELLECT-1 and INTELLECT-2.',
|
||||||
|
sourceUrl: 'https://arxiv.org/abs/2412.01152',
|
||||||
|
githubHandle: 'manveerxyz',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Jessica Li',
|
||||||
|
title: 'Applied Researcher',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'unverified',
|
||||||
|
confidenceNote: 'Single crowd-sourced org-chart listing. No primary source found.',
|
||||||
|
sourceUrl: 'https://theorg.com/org/prime-intellect/offices/hq',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------- explicitly NOT current staff
|
||||||
|
{
|
||||||
|
fullName: 'Justus Mattern',
|
||||||
|
title: 'Alumnus — now co-founder elsewhere',
|
||||||
|
affiliation: 'alumni',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
confidenceNote:
|
||||||
|
'Co-author on INTELLECT-2 and a prime-rl contributor. GitHub bio now reads ' +
|
||||||
|
'"Cofounder at Proximal", so this is a warm-intro node rather than a current ' +
|
||||||
|
'employee. Recorded to keep the roster honest.',
|
||||||
|
sourceUrl: 'https://api.github.com/users/justusmattern27',
|
||||||
|
githubHandle: 'justusmattern27',
|
||||||
|
departed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Alex Wa',
|
||||||
|
title: 'RL Residency participant',
|
||||||
|
affiliation: 'resident',
|
||||||
|
confidence: 'probable',
|
||||||
|
confidenceNote:
|
||||||
|
"Real, and did develop RL environments in Prime Intellect's RL Residency — but " +
|
||||||
|
'is a Yale undergraduate interning elsewhere, NOT staff. Distinct from Alexandr ' +
|
||||||
|
'Wang (Scale AI / Meta), and possibly a garbling of Alex L. Zhang, a genuine ' +
|
||||||
|
'Prime Intellect person and Prime Agent co-author.',
|
||||||
|
sourceUrl: 'https://djdumpling.github.io/',
|
||||||
|
linkedinUrl: 'https://www.linkedin.com/in/alex-wa/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Alex L. Zhang',
|
||||||
|
title: 'Prime Agent co-author',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'probable',
|
||||||
|
confidenceNote: 'Named as a co-author on the Prime Agent post. No further detail published.',
|
||||||
|
sourceUrl: 'https://www.primeintellect.ai/blog/prime-agent',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fullName: 'Seth Karten',
|
||||||
|
title: 'Prime Agent co-author',
|
||||||
|
affiliation: 'staff',
|
||||||
|
confidence: 'probable',
|
||||||
|
sourceUrl: 'https://www.primeintellect.ai/blog/prime-agent',
|
||||||
|
team: 'research',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named in the brief but not findable.
|
||||||
|
*
|
||||||
|
* Recorded deliberately rather than silently dropped: "we looked and found
|
||||||
|
* nothing" is useful information, and leaving it out invites someone to add
|
||||||
|
* the name again from memory. Absence here is weak evidence — a junior or
|
||||||
|
* deliberately non-public employee looks identical to this search.
|
||||||
|
*/
|
||||||
|
export const UNRESOLVED_NAMES = [
|
||||||
|
{
|
||||||
|
name: 'Anirudh',
|
||||||
|
note:
|
||||||
|
'No Anirudh of any surname could be tied to Prime Intellect in GitHub org ' +
|
||||||
|
'membership, repository contributors, paper author lists, org charts, or blog ' +
|
||||||
|
'bylines. Not seeded. If you know who this is, add them with a source.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer references the company itself publishes.
|
||||||
|
*
|
||||||
|
* Useful as accounts, but note these are named as references and integrations,
|
||||||
|
* which is not the same as a paying compute customer — a distinction worth
|
||||||
|
* keeping in a CRM.
|
||||||
|
*/
|
||||||
|
export const PUBLIC_CUSTOMER_REFERENCES = [
|
||||||
|
{
|
||||||
|
account: 'Ramp',
|
||||||
|
person: 'Karim Atiyeh',
|
||||||
|
title: 'Co-CEO',
|
||||||
|
sourceUrl: 'https://www.primeintellect.ai/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account: 'Zapier',
|
||||||
|
person: 'Robin Salimans',
|
||||||
|
title: 'Principal AI Engineer',
|
||||||
|
sourceUrl: 'https://www.primeintellect.ai/',
|
||||||
|
},
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user