import { useCallback, useEffect, useMemo, useRef } from "react";
import { useAppendedPages } from "./pages";
import type { CloudJobsMoverPayload, CloudJobsPosting, CloudJobsSummaryPayload } from "../../../api-client/types";
import {
Badge,
Button,
DataTableStackView,
DataTableView,
EmptyState,
PaneStatusBody,
SectionHeading,
StaticChartSurface,
Tabs,
usePaneFooter,
usePaneNoticeFooter,
useTableLoadMore,
type DataTableCell,
type PaneFooterSegment,
} from "../../../components";
import { resolveChartPalette } from "../../../components/chart/core/palette";
import { useAsyncResource } from "../../../react/async-resource";
import { useShortcut } from "../../../react/input";
import { blendHex, colors } from "../../../theme/colors";
import { Box, Text, TextAttributes, useRendererHost, useUiCapabilities, type ScrollBoxRenderable } from "../../../ui";
import { formatCompact, formatNumber } from "../../../utils/format";
import { isPlainKey } from "../../../utils/keyboard";
import { SignInWall } from "../cloud/auth-actions";
import { useCloudPlanAction, useCloudUpgradeAction } from "../shared/cloud-upgrade";
import { usePlanAccess } from "../shared/plan-access";
import { usePluginPaneState, usePluginTickerActions } from "../../runtime";
import { fetchJobs, fetchJobsMovers, fetchJobsPostings, type JobsCompanyState } from "./client";
import {
DEFAULT_MOVER_SORT,
DEFAULT_POSTING_SORT,
buildAgeBars,
buildMoverColumns,
buildMoverRows,
buildPostingColumns,
buildPostingRows,
buildShareBars,
changeTone,
formatChange,
formatCollectedAgo,
formatSalaryRange,
formatShare,
historyChartPoints,
moversHaveWeekHistory,
nextMoverSort,
nextPostingSort,
sortMoverRows,
sortPostingRows,
type MoverColumn,
type MoverRow,
type MoverSort,
type PostingColumn,
type PostingRow,
type PostingSort,
} from "./model";
import { ShareBars } from "./share-bars";
import { usePaneTickerIdentity } from "../../../state/hooks/pane-ticker";
export const JOBS_PANE_ID = "jobs";
const PENDING_POLL_MS = 20_000;
const POSTINGS_PAGE = 100;
const MOVERS_PAGE = 200;
type DetailTab = "roles" | "locations" | "seniority" | "salary";
function toneColor(tone: "positive" | "negative" | "neutral"): string {
return tone === "positive" ? colors.positive : tone === "negative" ? colors.negative : colors.textDim;
}
/** One headline figure: a big number with a small label under it. */
function Stat({ label, value, detail, color, width }: { label: string; value: string; detail?: string; color?: string; width: number }) {
return (
{value}
{detail ? {` ${detail}`} : null}
{label}
);
}
function ProWall({ action }: { action: string }) {
const openUpgrade = useCloudUpgradeAction();
const openPlan = useCloudPlanAction();
return (
>}
/>
);
}
// Company view ----------------------------------------------------------------
function CompanyHeader({ summary, width }: { summary: CloudJobsSummaryPayload; width: number }) {
const trend = summary.change30d;
const columns = Math.max(2, Math.min(7, Math.floor(width / 18)));
const statWidth = Math.floor((width - 2) / columns);
// Details only fit beside the figure on a roomy column.
const roomy = statWidth >= 26;
const stats: Array<{ label: string; value: string; detail?: string; color?: string }> = [
{
label: "open roles",
value: formatNumber(summary.openCount, 0),
detail: roomy && summary.openPerThousandEmployees != null ? `${formatNumber(summary.openPerThousandEmployees, 1)}/1k staff` : undefined,
},
trend
? { label: "30 days", value: formatChange(trend), color: toneColor(changeTone(trend.count)) }
: summary.posted30d != null
? { label: "posted last 30d", value: formatNumber(summary.posted30d, 0) }
: { label: "closed 30d", value: formatNumber(summary.closed30d, 0) },
{ label: "new this week", value: formatNumber(summary.new7d, 0), detail: roomy && summary.coverage.daysObserved <= 1 ? "first read" : undefined },
{ label: "remote", value: summary.remoteShare != null ? formatShare(summary.remoteShare) : "-" },
{ label: "median age", value: summary.medianAgeDays != null ? `${summary.medianAgeDays}d` : "-" },
// Standing roles the company hires for continuously; the rest are the
// openings that say something about its plans.
{
label: "evergreen",
value: summary.evergreenShare != null ? formatShare(summary.evergreenShare) : "-",
detail: roomy && summary.evergreenCount != null && summary.evergreenShare != null
? `${formatNumber(Math.max(0, summary.openCount - summary.evergreenCount), 0)} specific`
: undefined,
},
{ label: "contract", value: summary.contractShare != null ? formatShare(summary.contractShare) : "-" },
];
return (
{stats.slice(0, columns).map((stat) => (
))}
);
}
/**
* The open-roles history once a week of daily reads exists; before that,
* the backlog by posting age, which is the one thing a first read can say
* honestly about time.
*/
function Chart({ summary, width, height, focused = false }: { summary: CloudJobsSummaryPayload; width: number; height: number; focused?: boolean }) {
const points = useMemo(() => historyChartPoints(summary), [summary]);
const ageRows = useMemo(() => buildAgeBars(summary), [summary]);
if (points) {
const tone = changeTone(summary.change30d?.count ?? summary.change90d?.count);
const accent = tone === "positive" ? colors.positive : tone === "negative" ? colors.negative : colors.borderFocused;
const palette = {
...resolveChartPalette(colors, tone),
lineColor: accent,
fillColor: blendHex(colors.bg, accent, 0.22),
gridColor: blendHex(colors.bg, colors.border, 0.55),
};
return (
formatCompact(Math.round(value))}
focused={focused}
/>
);
}
return (
{ageRows.length === 0 ? (
{summary.datesReliable === false ? "No reliable posting dates." : "This careers system publishes no posting dates."}
) : (
)}
);
}
function Signals({ summary }: { summary: CloudJobsSummaryPayload }) {
const tags = summary.tags.filter((tag) => tag.count >= Math.max(2, summary.openCount * 0.02)).slice(0, 6);
if (tags.length === 0) return null;
return (
{tags.map((tag) => (
))}
);
}
function SalaryPanel({ summary, width }: { summary: CloudJobsSummaryPayload; width: number }) {
const salary = summary.salary;
if (!salary) {
return (
No pay ranges in the postings collected so far.
);
}
const rows = buildShareBars(
salary.byFunction.map((band) => ({ id: band.id, label: band.label, count: band.count, share: band.count / salary.count, previous: null })),
8,
);
const bandFor = new Map(salary.byFunction.map((band) => [band.id, band]));
return (
{formatSalaryRange(salary.medianMin, salary.medianMax, salary.currency, salary.period)}
{` median range across ${formatNumber(salary.count, 0)} postings with published pay`}
{rows.filter((row) => row.key !== "rest").map((row) => {
const band = bandFor.get(row.key);
return (
{row.label}
{band ? formatSalaryRange(band.medianMin, band.medianMax, salary.currency, salary.period) : ""}
{`${row.count} postings`}
);
})}
);
}
const DETAIL_TABS: Array<{ label: string; value: DetailTab }> = [
{ label: "Roles", value: "roles" },
{ label: "Locations", value: "locations" },
{ label: "Seniority", value: "seniority" },
{ label: "Pay", value: "salary" },
];
function CompanyView({
summary,
width,
height,
focused,
loading,
error,
registrationId,
embedded,
}: {
summary: CloudJobsSummaryPayload;
width: number;
height: number;
focused: boolean;
loading: boolean;
error: string | null;
registrationId: string;
/** Inside Ticker Research, whose own tab strip owns h/l and the arrows. */
embedded: boolean;
}) {
const { nativePaneChrome } = useUiCapabilities();
const rendererHost = useRendererHost();
const [tab, setTab] = usePluginPaneState("jobs:tab", "roles");
const [sort, setSort] = usePluginPaneState("jobs:sort", DEFAULT_POSTING_SORT);
// Keyed by posting, not by position, so a reload or a shared layout keeps the row.
const [selectedKey, setSelectedKey] = usePluginPaneState("rolesSelectedKey", null);
const rolesScrollRef = useRef(null);
// The summary carries the newest 40 roles; the rest page in on scroll.
const loadPostingsPage = useCallback(
(offset: number) => fetchJobsPostings(summary.ticker, { limit: POSTINGS_PAGE, offset }).then((page) => page.postings),
[summary.ticker],
);
const more = useAppendedPages(
`${summary.ticker}:${summary.coverage.lastCollectedAt ?? ""}`,
summary.recent.length,
summary.openCount,
loadPostingsPage,
);
const postings = useMemo(() => [...summary.recent, ...more.items], [summary.recent, more.items]);
const loadMoreFromScroll = useTableLoadMore(rolesScrollRef, tab === "roles" && more.hasMore && !more.loadingMore, more.loadMore);
const rows = useMemo(() => sortPostingRows(buildPostingRows(postings), sort), [postings, sort]);
const hasSalary = postings.some((posting) => posting.salaryMin != null || posting.salaryMax != null);
const columns = useMemo(() => buildPostingColumns(width, hasSalary), [width, hasSalary]);
const functionRows = useMemo(() => buildShareBars(summary.functions, 7), [summary]);
const countryRows = useMemo(() => buildShareBars(summary.countries, 12), [summary]);
const seniorityRows = useMemo(() => buildShareBars(summary.seniority, 8), [summary]);
const selectedIdx = Math.max(0, rows.findIndex((row) => row.key === selectedKey));
const selected = rows[selectedIdx] ?? null;
const openSelected = useCallback(() => {
if (selected?.posting.url) void rendererHost.openExternal(selected.posting.url);
}, [rendererHost, selected]);
const cycleTab = useCallback(() => {
const index = DETAIL_TABS.findIndex((entry) => entry.value === tab);
setTab(DETAIL_TABS[(index + 1) % DETAIL_TABS.length]!.value);
}, [setTab, tab]);
useShortcut((event) => {
if (!focused) return;
if (isPlainKey(event, "o") && tab === "roles") {
event.preventDefault?.();
event.stopPropagation?.();
openSelected();
} else if (isPlainKey(event, "c") && summary.coverage.careersUrl) {
event.preventDefault?.();
event.stopPropagation?.();
void rendererHost.openExternal(summary.coverage.careersUrl);
} else if (isPlainKey(event, "1", "2", "3", "4")) {
// The research pane's strip keeps h/l, so the sections have their own keys.
event.preventDefault?.();
event.stopPropagation?.();
setTab(DETAIL_TABS[Number(event.name) - 1]!.value);
}
});
usePaneFooter(registrationId, () => {
const info: PaneFooterSegment[] = [];
if (loading) info.push({ id: "loading", parts: [{ text: "refreshing", tone: "muted" }] });
if (more.loadingMore) info.push({ id: "loading-more", parts: [{ text: "loading more roles", tone: "muted" }] });
if (error) info.push({ id: "error", parts: [{ text: error.slice(0, 60), tone: "warning" }] });
// The careers platform is where the roles were read, not what they are,
// so the footer says only when; `c` opens the company's own site.
const collected = formatCollectedAgo(summary.coverage.lastCollectedAt);
if (collected) {
info.push({ id: "source", parts: [{ text: `read ${collected}`, tone: "muted" }] });
}
if (summary.coverage.daysObserved > 1) {
info.push({ id: "history", parts: [{ text: `${summary.coverage.daysObserved}d of history`, tone: "muted" }] });
}
const hints = [
...(tab === "roles" && selected?.posting.url ? [{ id: "open", key: "o", label: "pen role", onPress: openSelected }] : []),
...(summary.coverage.careersUrl
? [{ id: "careers", key: "c", label: "areers site", onPress: () => void rendererHost.openExternal(summary.coverage.careersUrl!) }]
: []),
// The key is a range, so it is bound above; a click steps to the next section.
...(embedded ? [{ id: "section", key: "1-4", label: "section", title: "Next Section", onPress: cycleTab }] : []),
];
return { info, hints };
}, [loading, more.loadingMore, error, summary, tab, selected, openSelected, rendererHost, embedded, cycleTab]);
// Unstable dates are a limitation of every age on screen, so they sit
// behind the footer notice rather than a standing line under the chart.
usePaneNoticeFooter({
registrationId: `${registrationId}:notices`,
notices: summary.datesReliable === false
? ["Posting dates on this careers system change on every read, so posting ages are not shown."]
: [],
focused,
});
const renderCell = useCallback((row: PostingRow, column: PostingColumn, _index: number, rowState: { selected: boolean }): DataTableCell => {
const selectedColor = rowState.selected ? colors.selectedText : undefined;
switch (column.id) {
case "title":
return { text: row.title, color: selectedColor ?? colors.textBright };
case "function":
return { text: row.seniority && width >= 96 ? `${row.function} · ${row.seniority}` : row.function, color: selectedColor ?? colors.text };
case "location":
return { text: row.location, color: selectedColor ?? colors.textDim };
case "posted":
return { text: row.posted, color: selectedColor ?? (row.posting.postedAt && !row.posted.includes("mo") ? colors.text : colors.textDim) };
case "salary":
return { text: row.salary, color: selectedColor ?? colors.positive };
}
}, [width]);
const wide = width >= 96;
const headerHeight = 2;
const signalsHeight = summary.tags.length > 0 ? 1 : 0;
const upperHeight = Math.max(8, Math.min(12, Math.floor((height - headerHeight - signalsHeight) * 0.42)));
const lowerHeight = Math.max(4, height - headerHeight - signalsHeight - upperHeight - 2 - (nativePaneChrome ? 1 : 0));
const chartWidth = wide ? Math.floor(width * 0.58) : width;
const barsWidth = wide ? width - chartWidth - 1 : width;
return (
{wide ? (
) : null}
{signalsHeight ? : null}
setTab(value as DetailTab)}
compact
variant="underline"
focused={focused}
keyboardNavigation={!embedded}
/>
{tab === "roles" ? (
focused={focused}
scrollRef={rolesScrollRef}
onBodyScrollActivity={loadMoreFromScroll}
selection={{ kind: "index", selectedIndex: rows.length ? selectedIdx : -1, onChange: (_index, row) => setSelectedKey(row.key) }}
onActivate={() => openSelected()}
rootWidth={width}
rootHeight={lowerHeight}
columns={columns}
freezeFirstColumn
items={rows}
sortColumnId={sort.columnId}
sortDirection={sort.direction}
onHeaderClick={(columnId) => setSort((current) => nextPostingSort(current, columnId))}
getItemKey={(row) => row.key}
renderCell={renderCell}
emptyStateTitle="No open roles"
/>
) : tab === "locations" ? (
{countryRows.length === 0
? No locations in the postings collected so far.
: }
) : tab === "seniority" ? (
{!wide ? (
) : null}
) : (
)}
);
}
/**
* One company's hiring, loaded and rendered with every state a pane needs.
* Used by the ticker-bound pane, the research tab, and the detail page of
* the coverage table.
*/
function CompanyPanel({
symbol,
companyName,
width,
height,
focused,
registrationId,
embedded = false,
}: {
symbol: string;
companyName: string | null;
width: number;
height: number;
focused: boolean;
registrationId: string;
embedded?: boolean;
}) {
const request = useCallback(
(force: boolean) => fetchJobs(symbol, { name: companyName, force }),
[symbol, companyName],
);
const resource = useAsyncResource(request);
const { data, status, error, reload } = resource;
// `r` asks again in every state, including a failed or uncovered company.
useShortcut((event) => {
if (!focused || !isPlainKey(event, "r")) return;
event.preventDefault?.();
event.stopPropagation?.();
reload();
});
// While the server is looking for the company, ask again on a timer.
useEffect(() => {
if (data?.kind !== "pending") return;
const timer = setTimeout(() => reload(), PENDING_POLL_MS);
return () => clearTimeout(timer);
}, [data, reload]);
if (data?.kind === "denied") {
if (data.status === 402) return ;
return ;
}
if ((status === "idle" || status === "loading") && !data) {
return ;
}
if (status === "error" && !data) {
return } />;
}
if (!data) return null;
if (data.kind === "pending") {
return (
);
}
if (data.kind === "uncovered") {
return (
}
/>
);
}
return (
);
}
// Coverage home ------------------------------------------------------------------
/**
* Every company under coverage, ranked; opening a row shows that company's
* hiring in place, the way a feed opens a story.
*/
function HomeView({ width, height, focused, registrationId }: { width: number; height: number; focused: boolean; registrationId: string }) {
const { nativePaneChrome } = useUiCapabilities();
const { navigateTicker } = usePluginTickerActions();
const request = useCallback(() => fetchJobsMovers(undefined, MOVERS_PAGE), []);
const resource = useAsyncResource(request);
const { data, status, error, reload } = resource;
const [sort, setSort] = usePluginPaneState("jobs:movers-sort", DEFAULT_MOVER_SORT);
// Keyed by company, not by position, so a reload or a shared layout keeps the row.
const [selectedKey, setSelectedKey] = usePluginPaneState("moversSelectedKey", null);
const [open, setOpen] = usePluginPaneState("jobs:open", null);
const tableScrollRef = useRef(null);
const loadMoversPage = useCallback(
(offset: number) => fetchJobsMovers(undefined, MOVERS_PAGE, offset).then((page) => page.movers),
[],
);
const more = useAppendedPages(
data?.asOf ?? "",
data?.movers.length ?? 0,
data?.total ?? data?.movers.length ?? 0,
loadMoversPage,
);
const movers = useMemo(() => [...(data?.movers ?? []), ...more.items], [data, more.items]);
const loadMoreFromScroll = useTableLoadMore(tableScrollRef, !!data && !open && more.hasMore && !more.loadingMore, more.loadMore);
const rows = useMemo(() => sortMoverRows(buildMoverRows(movers), sort), [movers, sort]);
const hasHistory = rows.some((row) => row.mover.change30d != null);
const hasWeek = moversHaveWeekHistory(rows);
const columns = useMemo(() => buildMoverColumns(width, hasHistory, hasWeek), [width, hasHistory, hasWeek]);
const selectedIdx = Math.max(0, rows.findIndex((row) => row.key === selectedKey));
const selected = rows[selectedIdx] ?? null;
const openMover: CloudJobsMoverPayload | null = useMemo(
() => (open ? (movers.find((mover) => mover.ticker === open) ?? null) : null),
[open, movers],
);
const detailOpen = !!open;
useShortcut((event) => {
if (!focused) return;
// With a company open, `r` belongs to that company's panel, once it is on screen.
if (isPlainKey(event, "r") && !(open && data)) {
event.preventDefault?.();
event.stopPropagation?.();
reload();
} else if (isPlainKey(event, "t")) {
const ticker = open ?? selected?.ticker;
if (!ticker) return;
event.preventDefault?.();
event.stopPropagation?.();
navigateTicker(ticker);
}
});
// Its own id: the open company's panel registers beside it, and unmounting
// either must not take the other's hints with it.
usePaneFooter(`${registrationId}:list`, () => ({
info: detailOpen
? []
: [
...(status === "loading" ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []),
...(more.loadingMore ? [{ id: "loading-more", parts: [{ text: "loading more companies", tone: "muted" as const }] }] : []),
...(error ? [{ id: "error", parts: [{ text: error.slice(0, 60), tone: "warning" as const }] }] : []),
],
hints: open || selected
? [{ id: "ticker", key: "t", label: "icker", onPress: () => navigateTicker((open ?? selected?.ticker)!) }]
: [],
}), [status, more.loadingMore, error, data, detailOpen, open, selected, navigateTicker]);
const renderCell = useCallback((row: MoverRow, column: MoverColumn, _index: number, rowState: { selected: boolean }): DataTableCell => {
const selectedColor = rowState.selected ? colors.selectedText : undefined;
switch (column.id) {
case "ticker":
return { text: row.ticker, color: selectedColor ?? colors.textBright, attributes: TextAttributes.BOLD };
case "company":
return { text: row.company, color: selectedColor ?? colors.text };
case "open":
return { text: row.open, color: selectedColor ?? colors.textBright };
case "change":
return { text: row.change, color: selectedColor ?? toneColor(changeTone(row.changeValue)) };
case "posted30d":
return { text: row.posted30d, color: selectedColor ?? colors.text };
case "new7d":
return { text: row.new7d, color: selectedColor ?? colors.text };
case "function":
return { text: row.function, color: selectedColor ?? colors.textDim };
case "country":
return { text: row.country, color: selectedColor ?? colors.textDim };
}
}, []);
if (status === "loading" && !data) return ;
if (status === "error" && !data) return } />;
const tableHeight = Math.max(3, height - (nativePaneChrome ? 1 : 0));
return (
focused={focused}
scrollRef={tableScrollRef}
onBodyScrollActivity={loadMoreFromScroll}
detailOpen={detailOpen}
onBack={() => setOpen(null)}
detailTitle={openMover ? [openMover.ticker, openMover.companyName].filter(Boolean).join(" · ") : open ?? undefined}
detailContent={open ? (
) : null}
selection={{ kind: "index", selectedIndex: rows.length ? selectedIdx : -1, onChange: (_index, row) => setSelectedKey(row.key) }}
onActivate={(row) => setOpen(row.ticker)}
rootWidth={width}
rootHeight={tableHeight}
columns={columns}
freezeFirstColumn
items={rows}
sortColumnId={sort.columnId}
sortDirection={sort.direction}
onHeaderClick={(columnId) => setSort((current) => nextMoverSort(current, columnId))}
getItemKey={(row) => row.key}
renderCell={renderCell}
emptyStateTitle={status === "loading" ? "Loading..." : "No companies covered yet"}
/>
);
}
// Pane ----------------------------------------------------------------------
export interface JobsViewProps {
width: number;
height: number;
focused: boolean;
/** The research tab always shows the company; the pane shows coverage when unbound. */
companyOnly?: boolean;
}
export function JobsView({ width, height, focused, companyOnly = false }: JobsViewProps) {
const { ticker } = usePaneTickerIdentity();
const symbol = ticker?.metadata.ticker ?? null;
const companyName = ticker?.metadata.name ?? null;
const access = usePlanAccess();
const registrationId = JOBS_PANE_ID;
if (!access.signedIn) return ;
if (!access.emailVerified) return ;
if (!access.hasProAccess) return ;
if (!symbol) {
if (companyOnly) return ;
return ;
}
return (
);
}
export function JobsPane(props: { width: number; height: number; focused: boolean }) {
return ;
}
export function JobsResearchTab(props: { width: number; height: number; focused: boolean }) {
return ;
}