Add the web app, seed data, and user-selectable theming
apps/web — React, Vite, Tailwind, shadcn-idiom components. Mobile Safari is a first-class target, not an afterthought: - Two navigation treatments rather than one compromise. A bottom tab bar on phones, because the top of a large phone is out of thumb reach; a persistent sidebar from lg upward, so an iPad in portrait gets it too. - Safe-area insets throughout, so the tab bar clears the home indicator and the last row of a list is actually reachable. - Inputs are pinned to a 16px minimum, which is the correct fix for Safari zooming on focus. user-scalable=no is not used: it breaks pinch-zoom for everyone and recent iOS ignores it anyway. - The pipeline board becomes a stage picker on phones. An eight-column board scrolling horizontally on a 390px screen is technically responsive and practically useless. Theming: users pick an accent and the whole interface re-tints. Accent values live once, in @pig/core, and are written onto the root element at runtime — there is no CSS copy to drift from the TypeScript. Preferences are stored server-side so they follow a person between laptop and phone, mirrored into localStorage only so the pre-paint script can avoid a white flash. Status colours stay fixed regardless of accent: if "at risk" re-tinted to whatever someone picked, the signal would be gone. Seed data is public research, every record carrying a confidence grade and a source URL. No email addresses are seeded or inferred — none are published, and guessing them from a name and a domain is unreliable and rude. Authorship is not promoted to employment: contributors, residency participants and alumni are recorded as what the evidence actually shows, and a name that could not be sourced at all is listed as unresolved rather than invented. Three defects found and fixed by actually running it rather than assuming: 1. The seed was not idempotent. onConflictDoNothing() with no target is a no-op without a matching unique constraint, so a second run duplicated 27 contacts. There is deliberately no unique index on (account, name) — two people at one company can share a name — so idempotency is enforced in the seed instead of by bending the schema. 2. /capacity scrolled sideways on a phone. Grid items default to min-width:auto and `truncate` sets nowrap, so a long title became unshrinkable content and widened the track. Fixed with min-w-0 on every truncating grid child. 3. The idle-capacity alert silently failed to fire at exactly 80% utilisation, losing a float comparison against a 0.2 threshold. Moved to 0.15, which is also a more sensible line for "worth attention". The worked example is tuned to teach rather than to flatter: 70% sold at a 53% markup lands at +6.7% margin with 20% still idle, so both the healthy number and the alert are visible. Drop the sold share to 55% and the same block goes underwater — that sensitivity is the argument for the product. Verified in a real browser at 393px and 1440px, light and dark: zero horizontal overflow on every route, zero console errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user