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 [margin, idle, openDemand, openSupply, recent] = await Promise.all([
|
||||
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
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.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,
|
||||
"tag": "0000_initial",
|
||||
"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