diff --git a/.env.example b/.env.example
index d27eaa80b..c0b20b4b7 100644
--- a/.env.example
+++ b/.env.example
@@ -202,6 +202,14 @@ GITHUB_ISSUES_REPO=njrini99-code/helmv3
# Private Supabase Storage bucket used for screenshot signed links.
HELM_BRIDGE_FEEDBACK_BUCKET=helm-bridge-feedback
+# -----------------------------------------------------------------------------
+# Helm Bridge Work log (PR timeline tab)
+# -----------------------------------------------------------------------------
+# Comma-separated GitHub logins to filter PRs (e.g. your username). Omit to show all repo PRs.
+GITHUB_PR_AUTHOR_LOGINS=your-github-username
+# Max PRs to fetch (default 60, max 100)
+# GITHUB_PR_FETCH_LIMIT=60
+
# -----------------------------------------------------------------------------
# Inngest (Durable workflows — replaces scattered cron + retry loops)
# -----------------------------------------------------------------------------
diff --git a/scripts/__tests__/baseball-stale-route-links.test.mjs b/scripts/__tests__/baseball-stale-route-links.test.mjs
index c780991f3..f4fd7ca02 100644
--- a/scripts/__tests__/baseball-stale-route-links.test.mjs
+++ b/scripts/__tests__/baseball-stale-route-links.test.mjs
@@ -9,15 +9,6 @@ const SCAN_ROOTS = [
join(REPO_ROOT, 'e2e'),
];
const ALLOWLIST = new Set([
- join(REPO_ROOT, 'src/lib/baseball/stats-route-aliases.ts'),
- join(
- REPO_ROOT,
- 'src/app/baseball/(dashboard)/dashboard/stats/games/new/page.tsx',
- ),
- join(
- REPO_ROOT,
- 'src/app/baseball/(dashboard)/dashboard/stats/games/new/error.tsx',
- ),
]);
const STALE_HREF = /\/baseball\/dashboard\/stats\/games\/new(?![\w-])/;
diff --git a/src/app/admin/_components/AdminShell.tsx b/src/app/admin/_components/AdminShell.tsx
index ee7c60d8e..c74c058ac 100644
--- a/src/app/admin/_components/AdminShell.tsx
+++ b/src/app/admin/_components/AdminShell.tsx
@@ -5,7 +5,7 @@ import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
LayoutDashboard, Activity, AlertTriangle, KeyRound, Flag, CircleDot,
- Users, Timer, Rocket, HeartPulse, ExternalLink, MessageSquarePlus, Gauge, SearchCheck,
+ Users, Timer, Rocket, HeartPulse, ExternalLink, MessageSquarePlus, Gauge, SearchCheck, ScrollText,
} from 'lucide-react';
import {
AppShell,
@@ -16,6 +16,7 @@ import {
type CommandGroup,
type CommandItem,
} from '@/components/fairway';
+import { SessionActivityProvider } from '@/components/providers/SessionActivityProvider';
import { ADMIN_NAV, hrefForShortcut } from './admin-nav';
/** Sub-route leaf labels the Breadcrumb trail can't derive from ADMIN_NAV
@@ -58,6 +59,7 @@ const NAV_ICON_BY_HREF = {
'/admin/golf': Flag,
'/admin/baseball': CircleDot,
'/admin/ben-leah': MessageSquarePlus,
+ '/admin/work': ScrollText,
'/admin/users': Users,
'/admin/jobs': Timer,
'/admin/deploys': Rocket,
@@ -204,6 +206,7 @@ export function AdminShell({
);
return (
+
+
);
}
diff --git a/src/app/admin/_components/__tests__/admin-nav.test.ts b/src/app/admin/_components/__tests__/admin-nav.test.ts
index 9894779f8..1145aeeec 100644
--- a/src/app/admin/_components/__tests__/admin-nav.test.ts
+++ b/src/app/admin/_components/__tests__/admin-nav.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { ADMIN_NAV, hrefForShortcut } from '@/app/admin/_components/admin-nav';
+import { ADMIN_NAV, ADMIN_COMMAND_SHORTCUTS, hrefForShortcut } from '@/app/admin/_components/admin-nav';
describe('ADMIN_NAV', () => {
it('declares the canonical tabs in order', () => {
@@ -11,19 +11,29 @@ describe('ADMIN_NAV', () => {
'/admin/golf',
'/admin/baseball',
'/admin/ben-leah',
+ '/admin/work',
'/admin/users',
'/admin/jobs',
'/admin/deploys',
'/admin/health',
]);
- expect(ADMIN_NAV.map((e) => e.key)).toEqual(['1', '2', '3', '4', '5', '6', 'B', '7', '8', '9', '0']);
+ expect(ADMIN_NAV.map((e) => e.key)).toEqual(['1', '2', '3', '4', '5', '6', 'B', 'W', '7', '8', '9', '0']);
});
it('maps shortcut keys to hrefs', () => {
expect(hrefForShortcut('2')).toBe('/admin/activity');
expect(hrefForShortcut('3')).toBe('/admin/errors');
expect(hrefForShortcut('B')).toBe('/admin/ben-leah');
+ expect(hrefForShortcut('W')).toBe('/admin/work');
expect(hrefForShortcut('9')).toBe('/admin/deploys');
expect(hrefForShortcut('0')).toBe('/admin/health');
expect(hrefForShortcut('x')).toBeNull();
});
+
+ it('keeps command-center shortcuts on real Bridge tabs', () => {
+ const navHrefs = new Set(ADMIN_NAV.map((entry) => entry.href));
+ for (const shortcut of ADMIN_COMMAND_SHORTCUTS) {
+ expect(navHrefs.has(shortcut.href), `${shortcut.href} is not a registered admin tab`).toBe(true);
+ }
+ expect(ADMIN_COMMAND_SHORTCUTS.map((s) => s.href)).not.toContain('/admin/audit');
+ });
});
diff --git a/src/app/admin/_components/admin-nav.ts b/src/app/admin/_components/admin-nav.ts
index e2ecbc43e..031dc2feb 100644
--- a/src/app/admin/_components/admin-nav.ts
+++ b/src/app/admin/_components/admin-nav.ts
@@ -6,6 +6,7 @@ type AdminHref =
| '/admin/golf'
| '/admin/baseball'
| '/admin/ben-leah'
+ | '/admin/work'
| '/admin/users'
| '/admin/jobs'
| '/admin/deploys'
@@ -30,12 +31,21 @@ export const ADMIN_NAV: readonly AdminNavEntry[] = [
{ label: 'Golf', href: '/admin/golf', key: '5', section: 'Apps', description: 'GolfHelm production signals' },
{ label: 'Baseball', href: '/admin/baseball', key: '6', section: 'Apps', description: 'BaseballHelm production signals' },
{ label: 'Ben + Leah', href: '/admin/ben-leah', key: 'B', section: 'Operations', description: 'Submit bugs, changes, additions', meta: 'issues' },
+ { label: 'Work log', href: '/admin/work', key: 'W', section: 'Operations', description: 'PR timeline — problems, fixes, areas', meta: 'prs' },
{ label: 'Users & Teams', href: '/admin/users', key: '7', section: 'Platform', description: 'Accounts, teams, engagement' },
{ label: 'Jobs & Integrity', href: '/admin/jobs', key: '8', section: 'Platform', description: 'Crons, guards, integrity checks' },
{ label: 'Deploys & Infra', href: '/admin/deploys', key: '9', section: 'Platform', description: 'Vercel releases and web insight' },
{ label: 'Health', href: '/admin/health', key: '0', section: 'Platform', description: 'Feature health across every app', meta: 'map' },
] as const;
+/** Quick links in the Overview command header — must be real ADMIN_NAV routes. */
+export const ADMIN_COMMAND_SHORTCUTS = [
+ { href: '/admin/errors', label: 'Errors' },
+ { href: '/admin/health', label: 'Feature Map' },
+ { href: '/admin/deploys', label: 'Deploys' },
+ { href: '/admin/auth', label: 'Auth' },
+] as const satisfies ReadonlyArray<{ href: AdminHref; label: string }>;
+
export function hrefForShortcut(key: string): string | null {
return ADMIN_NAV.find((e) => e.key === key)?.href ?? null;
}
diff --git a/src/app/admin/ben-leah/BenLeahIssueBoard.tsx b/src/app/admin/ben-leah/BenLeahIssueBoard.tsx
new file mode 100644
index 000000000..b58cd380f
--- /dev/null
+++ b/src/app/admin/ben-leah/BenLeahIssueBoard.tsx
@@ -0,0 +1,169 @@
+import Link from 'next/link';
+import { GitPullRequest, Rocket } from 'lucide-react';
+import {
+ fetchBenLeahIssueBoard,
+ type BenLeahTrackStatus,
+} from '@/lib/admin/ben-leah-issues';
+import { PanelNoData, PanelStale } from '../_components/PanelStates';
+import { StatusPill, StatTile, type FwStatusTone } from '@/components/fairway';
+import { BenLeahIssueTable } from './BenLeahIssueTable';
+
+const TRACK_META: Record<
+ BenLeahTrackStatus,
+ { label: string; tone: FwStatusTone; description: string }
+> = {
+ open: {
+ label: 'Open',
+ tone: 'warning',
+ description: 'Still open on GitHub — waiting for work.',
+ },
+ in_progress: {
+ label: 'In progress',
+ tone: 'info',
+ description: 'Label status:in-progress or status:triaged on the issue.',
+ },
+ in_production: {
+ label: 'In production',
+ tone: 'success',
+ description: 'Closed and shipped, or label status:in-production.',
+ },
+ fixed_pending_deploy: {
+ label: 'Fixed · pending deploy',
+ tone: 'warning',
+ description: 'Closed on GitHub; production has not deployed since the fix.',
+ },
+ fixed: {
+ label: 'Fixed',
+ tone: 'success',
+ description: 'Closed on GitHub (deploy timing unknown).',
+ },
+ wont_fix: {
+ label: "Won't fix",
+ tone: 'neutral',
+ description: 'Label status:wontfix on the issue.',
+ },
+};
+
+function formatWhen(iso: string): string {
+ return new Date(iso).toLocaleString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
+function githubIntegrationTone(status: string): FwStatusTone {
+ if (status === 'ok') return 'success';
+ if (status === 'unconfigured') return 'warning';
+ return 'danger';
+}
+
+export async function BenLeahIssueBoard() {
+ const board = await fetchBenLeahIssueBoard();
+
+ if (board.status === 'unconfigured') {
+ return (
+
+ );
+ }
+
+ if (board.status === 'error' || !board.data) {
+ return ;
+ }
+
+ const { issues, counts, repoLabel, repoIssuesUrl, productionCommitSha, productionReadyAt, fetchedAt } = {
+ ...board.data,
+ fetchedAt: board.fetchedAt,
+ };
+
+ const openCount = counts.open + counts.in_progress;
+ const shippedCount = counts.in_production;
+ const pendingDeployCount = counts.fixed_pending_deploy;
+
+ return (
+
+
+
+
+ GitHub {board.status === 'ok' ? 'live' : 'stale'}
+
+
+ {productionReadyAt
+ ? `prod ${productionCommitSha?.slice(0, 7) ?? 'build'}`
+ : 'prod timing n/a'}
+
+ {fetchedAt ? (
+
+ checked {new Date(fetchedAt).toLocaleTimeString()}
+
+ ) : null}
+
+
+
+ All issues on {repoLabel}
+
+
+
+
+ {(
+ [
+ ['Open / in progress', openCount, 'Needs attention'],
+ ['In production', shippedCount, 'Shipped or labeled'],
+ ['Pending deploy', pendingDeployCount, 'Closed, not on prod yet'],
+ ['Total tracked', issues.length, 'Last 50 updates'],
+ ] as const
+ ).map(([label, value, caption]) => (
+
+ ))}
+
+
+ {productionReadyAt ? (
+
+
+ Production last ready {formatWhen(new Date(productionReadyAt).toISOString())}
+ {productionCommitSha ? ` · ${productionCommitSha.slice(0, 7)}` : null}
+ . Closed issues after that time show as In production .
+
+ ) : (
+
+ Set VERCEL_API_TOKEN to correlate closed issues with production deploy timing. Without it, status uses GitHub labels only.
+
+ )}
+
+ {issues.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ {(Object.keys(TRACK_META) as BenLeahTrackStatus[]).map((status) => (
+
+
+
+ {TRACK_META[status].label}
+
+ {counts[status]}
+
+
{TRACK_META[status].description}
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/admin/ben-leah/BenLeahIssueTable.tsx b/src/app/admin/ben-leah/BenLeahIssueTable.tsx
new file mode 100644
index 000000000..3662dce12
--- /dev/null
+++ b/src/app/admin/ben-leah/BenLeahIssueTable.tsx
@@ -0,0 +1,100 @@
+'use client';
+
+import Link from 'next/link';
+import { ExternalLink } from 'lucide-react';
+import type { BenLeahTrackedIssue } from '@/lib/admin/ben-leah-issue-tracker';
+import { StatusPill, type FwStatusTone } from '@/components/fairway';
+import { BenLeahIssueWorkflowSelect } from './BenLeahIssueWorkflowSelect';
+
+const TRACK_META: Record<
+ BenLeahTrackedIssue['trackStatus'],
+ { label: string; tone: FwStatusTone }
+> = {
+ open: { label: 'Open', tone: 'warning' },
+ in_progress: { label: 'In progress', tone: 'info' },
+ in_production: { label: 'In production', tone: 'success' },
+ fixed_pending_deploy: { label: 'Fixed · pending deploy', tone: 'warning' },
+ fixed: { label: 'Fixed', tone: 'success' },
+ wont_fix: { label: "Won't fix", tone: 'neutral' },
+};
+
+const KIND_LABEL: Record = {
+ bug: 'Bug',
+ change: 'Change',
+ addition: 'Addition',
+};
+
+function formatWhen(iso: string): string {
+ return new Date(iso).toLocaleString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
+function IssueRow({ issue }: { issue: BenLeahTrackedIssue }) {
+ const meta = TRACK_META[issue.trackStatus];
+
+ return (
+
+
+
+ #{issue.number}
+
+
+
+ {issue.displayTitle}
+
+
+
+
+ {meta.label}
+
+
+
+
+
+
+ {issue.kind ? KIND_LABEL[issue.kind] ?? issue.kind : '—'}
+
+ {issue.priority ?? '—'}
+ {issue.category ?? '—'}
+ {formatWhen(issue.updated_at)}
+
+ {issue.closed_at ? formatWhen(issue.closed_at) : '—'}
+
+
+ );
+}
+
+export function BenLeahIssueTable({ issues }: { issues: BenLeahTrackedIssue[] }) {
+ return (
+
+
+
+
+ Issue
+ Derived status
+ Set workflow
+ Type
+ Priority
+ Category
+ Updated
+ Closed
+
+
+
+ {issues.map((issue) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/app/admin/ben-leah/BenLeahIssueWorkflowSelect.tsx b/src/app/admin/ben-leah/BenLeahIssueWorkflowSelect.tsx
new file mode 100644
index 000000000..3479d03ea
--- /dev/null
+++ b/src/app/admin/ben-leah/BenLeahIssueWorkflowSelect.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, useTransition } from 'react';
+import {
+ currentWorkflowSelection,
+ type BenLeahTrackedIssue,
+ type BenLeahWorkflowSelection,
+} from '@/lib/admin/ben-leah-issue-tracker';
+import { NativeSelect } from '@/components/ui/native-select';
+import { cn } from '@/lib/utils';
+import { updateBenLeahIssueWorkflow } from './actions';
+
+const WORKFLOW_OPTIONS: Array<{ value: BenLeahWorkflowSelection; label: string }> = [
+ { value: 'triaged', label: 'Triaged' },
+ { value: 'in_progress', label: 'In progress' },
+ { value: 'in_production', label: 'In production' },
+ { value: 'wont_fix', label: "Won't fix" },
+];
+
+const selectClass = cn(
+ 'min-w-[9.5rem] rounded-fw-md border border-border-subtle bg-surface px-2 py-1 text-xs text-warm-900 shadow-flat outline-none',
+ 'focus:border-accent-500 focus:ring-2 focus:ring-accent-500/20 disabled:opacity-60',
+);
+
+export function BenLeahIssueWorkflowSelect({ issue }: { issue: BenLeahTrackedIssue }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const current = currentWorkflowSelection(issue.labels);
+
+ const onChange = (raw: string) => {
+ if (!raw) return;
+ const next = raw as BenLeahWorkflowSelection;
+ if (next === current) return;
+ setError(null);
+ startTransition(async () => {
+ const result = await updateBenLeahIssueWorkflow(issue.number, issue.labels, next);
+ if (!result.ok) {
+ setError(result.message);
+ return;
+ }
+ router.refresh();
+ });
+ };
+
+ return (
+
+
onChange(event.target.value)}
+ >
+ {!current ? (
+
+ Set workflow…
+
+ ) : null}
+ {WORKFLOW_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ {error ?
{error}
: null}
+
+ );
+}
diff --git a/src/app/admin/ben-leah/actions.ts b/src/app/admin/ben-leah/actions.ts
index f8f4398e6..dc98bb423 100644
--- a/src/app/admin/ben-leah/actions.ts
+++ b/src/app/admin/ben-leah/actions.ts
@@ -7,6 +7,8 @@ import {
validateFeedback,
type GitHubIssueResult,
} from '@/lib/admin/github-feedback';
+import { setBenLeahIssueWorkflow } from '@/lib/admin/github-issues-workflow';
+import type { BenLeahWorkflowSelection } from '@/lib/admin/ben-leah-issue-tracker';
import { requireSuperAdmin } from '@/lib/admin/require-super-admin';
import { describeError } from '@/lib/utils/describe-error';
@@ -67,3 +69,44 @@ export async function submitBenLeahFeedback(
};
}
}
+
+const WORKFLOW_SELECTIONS = new Set([
+ 'triaged',
+ 'in_progress',
+ 'in_production',
+ 'wont_fix',
+]);
+
+export async function updateBenLeahIssueWorkflow(
+ issueNumber: number,
+ currentLabels: string[],
+ selection: BenLeahWorkflowSelection,
+): Promise<{ ok: boolean; message: string }> {
+ try {
+ await requireSuperAdmin();
+ } catch (err) {
+ return {
+ ok: false,
+ message: describeError(err) === 'Unauthorized'
+ ? 'Your session expired. Sign in again and retry.'
+ : 'You do not have permission to update Ben + Leah issues.',
+ };
+ }
+
+ if (!Number.isInteger(issueNumber) || issueNumber < 1) {
+ return { ok: false, message: 'Invalid issue number.' };
+ }
+ if (!WORKFLOW_SELECTIONS.has(selection)) {
+ return { ok: false, message: 'Invalid workflow status.' };
+ }
+
+ try {
+ await setBenLeahIssueWorkflow(issueNumber, currentLabels, selection);
+ return { ok: true, message: 'GitHub workflow label updated.' };
+ } catch (err) {
+ return {
+ ok: false,
+ message: `Could not update GitHub labels: ${describeError(err)}`,
+ };
+ }
+}
diff --git a/src/app/admin/ben-leah/page.tsx b/src/app/admin/ben-leah/page.tsx
index 6d268b0c3..d4f45ba6d 100644
--- a/src/app/admin/ben-leah/page.tsx
+++ b/src/app/admin/ben-leah/page.tsx
@@ -1,8 +1,10 @@
-import { GitPullRequest, ImageUp, Link2, MessageSquarePlus } from 'lucide-react';
+import { GitPullRequest, ImageUp, Link2, MessageSquarePlus, Tags } from 'lucide-react';
import { requireSuperAdmin } from '@/lib/admin/require-super-admin';
import { Eyebrow, Surface, StatusPill } from '@/components/fairway';
import { PanelBoundary } from '../_components/PanelBoundary';
+import { AutoRefresh } from '../_components/AutoRefresh';
import { BenLeahForm } from './BenLeahForm';
+import { BenLeahIssueBoard } from './BenLeahIssueBoard';
export const dynamic = 'force-dynamic';
@@ -64,6 +66,27 @@ export default async function BenLeahPage() {
+
+ Status labels (GitHub)
+
+
+
+ status:in-progress or status:triaged — work started
+
+
+
+ status:in-production — confirmed live (manual)
+
+
+
+ status:wontfix — closed without shipping
+
+
+
+ New submissions start as status:triaged. Use the tracker dropdown to move issues through the workflow — labels are created automatically in GitHub.
+
+
+
Best submission shape
@@ -72,6 +95,12 @@ export default async function BenLeahPage() {
+
+
+
+
+
+
);
}
diff --git a/src/app/admin/errors/page.tsx b/src/app/admin/errors/page.tsx
index 8958dc107..985c321da 100644
--- a/src/app/admin/errors/page.tsx
+++ b/src/app/admin/errors/page.tsx
@@ -126,9 +126,11 @@ export default async function ErrorsPage({
async function Body() {
const tab = await fetchErrorsTab(filters);
- const highSeverity = tab.incidents.filter((i) => i.severity === 'critical' || i.severity === 'error').length;
- const affectedUsers = tab.incidents.reduce((sum, i) => sum + i.affectedUsers, 0);
- const appGroups = tab.incidents.filter((i) => i.origin === 'app').length;
+ const { counts } = tab;
+ const showWiderWindowHint =
+ tab.incidents.length === 0 &&
+ filters.windowHours < 168 &&
+ ((tab.widerWindowUnresolved ?? 0) > 0 || (tab.widerWindowUntagged ?? 0) > 0);
const sourceBreakdown = Array.from(
tab.incidents.reduce((map, incident) => {
const key = incident.source ?? incident.origin;
@@ -141,22 +143,63 @@ export default async function ErrorsPage({
return (
+ {showWiderWindowHint ? (
+
+
+ Nothing in the last {filters.windowHours}h
+ {filters.sport ? ` for sport=${filters.sport}` : ''}, but there are unresolved incidents in the last 7 days
+ {(tab.widerWindowUnresolved ?? 0) > 0 ? (
+ <>
+ {' '}
+ ({tab.widerWindowUnresolved}
+ {filters.sport ? ` tagged ${filters.sport}` : ''})
+ >
+ ) : null}
+ {(tab.widerWindowUntagged ?? 0) > 0 ? (
+ <>
+ {' '}
+ plus{' '}
+ {tab.widerWindowUntagged} legacy rows with no sport tag
+ >
+ ) : null}
+ .{' '}
+
+ Open 7-day view
+
+ {filters.sport ? (
+ <>
+ {' '}
+ or{' '}
+
+ drop the sport filter
+ {' '}
+ to include untagged baseball errors.
+ >
+ ) : null}
+
+
+ ) : null}
active groups
-
{tab.incidents.length}
-
{appGroups} app · {tab.sentry.data?.length ?? 0} sentry
+
{counts.totalGroups}
+
+ {counts.appGroups} app · {counts.sentryGroups} sentry · {filters.windowHours}h window
+
high severity
-
{highSeverity}
+
{counts.highSeverityGroups}
critical and error groups
affected users
-
{affectedUsers}
+
{counts.affectedUsers}
deduped per incident group
@@ -223,7 +266,12 @@ export default async function ErrorsPage({
- Sentry unresolved
+
+ Sentry unresolved (org-wide)
+
+
+ All open Sentry issues — not windowed. Active groups above only count Sentry issues with activity in the selected window.
+
{tab.sentry.status === 'ok' && tab.sentry.data ? (
tab.sentry.data.length === 0 ? (
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 3e753cd11..d55635855 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -21,6 +21,7 @@ import { PanelBoundary } from './_components/PanelBoundary';
import { PanelAllClear, PanelNoData, PanelStale } from './_components/PanelStates';
import { FeatureHealthRollup } from './_components/FeatureHealthRollup';
import { SkeletonStat, SkeletonList, Surface, Eyebrow, StatusPill } from '@/components/fairway';
+import { ADMIN_COMMAND_SHORTCUTS } from './_components/admin-nav';
export const dynamic = 'force-dynamic';
@@ -98,11 +99,11 @@ async function BannerAndKpis() {
w.label === 'Error pipeline'),
href: '/admin/errors',
},
@@ -281,7 +282,7 @@ function SavedCommandViews({ kpis }: { kpis: OverviewKpis }) {
href: '/admin/errors?window=24&severity=error',
label: 'Production Health',
icon: Gauge,
- metric: `${kpis.eventErrors24h} groups`,
+ metric: `${kpis.incidentGroups24h} groups`,
detail: 'Real incidents, source mapped, grouped like Errors tab',
},
{
@@ -339,11 +340,8 @@ function SavedCommandViews({ kpis }: { kpis: OverviewKpis }) {
}
async function TriagePanel() {
- const { items, sentry } = await fetchTriageQueue();
+ const { items, sentry, counts } = await fetchTriageQueue();
const regressed = items.filter((i) => i.substatus === 'regressed');
- const highSeverity = items.filter((i) => i.severity === 'critical' || i.severity === 'error');
- const appItems = items.filter((i) => i.origin === 'app');
- const sentryItems = items.filter((i) => i.origin === 'sentry');
const sportCounts = [
['Golf', items.filter((i) => i.sport === 'golf').length],
['Baseball', items.filter((i) => i.sport === 'baseball').length],
@@ -363,12 +361,15 @@ async function TriagePanel() {
Action lanes
+
+ Last 24h · same incident feed as the Errors tab (unresolved app events + Sentry with activity in window)
+
{[
- ['Total groups', items.length, 'coalesced incidents'],
- ['High severity', highSeverity.length, 'critical and error'],
- ['App groups', appItems.length, 'Supabase admin_events'],
- ['Sentry groups', sentryItems.length, sentry.status === 'ok' ? 'live pull' : sentry.status],
+ ['Total groups', counts.totalGroups, 'coalesced incidents'],
+ ['High severity', counts.highSeverityGroups, 'critical and error'],
+ ['App groups', counts.appGroups, 'Supabase admin_events'],
+ ['Sentry groups', counts.sentryGroups, sentry.status === 'ok' ? 'active in 24h' : sentry.status],
].map(([label, value, caption]) => (
{label}
@@ -457,12 +458,12 @@ async function DeployRail() {
}
function CommandHeader() {
- const nav = [
- { href: '/admin/errors', label: 'Errors', icon: Activity },
- { href: '/admin/health', label: 'Feature Map', icon: RadioTower },
- { href: '/admin/deploys', label: 'Deploys', icon: GitBranch },
- { href: '/admin/audit', label: 'Audit', icon: ShieldCheck },
- ];
+ const iconByHref = {
+ '/admin/errors': Activity,
+ '/admin/health': RadioTower,
+ '/admin/deploys': GitBranch,
+ '/admin/auth': ShieldCheck,
+ } as const;
return (
@@ -477,8 +478,8 @@ function CommandHeader() {
- {nav.map((item) => {
- const Icon = item.icon;
+ {ADMIN_COMMAND_SHORTCUTS.map((item) => {
+ const Icon = iconByHref[item.href];
return (
= {
+ golf: { label: 'GolfHelm', tone: 'success' },
+ baseball: { label: 'BaseballHelm', tone: 'info' },
+ coachhelm: { label: 'CoachHelm', tone: 'accent' },
+ bridge: { label: 'Helm Bridge', tone: 'neutral' },
+ platform: { label: 'Platform', tone: 'neutral' },
+ mobile: { label: 'Mobile', tone: 'info' },
+ shared: { label: 'Shared', tone: 'warning' },
+ unknown: { label: 'Cross-cutting', tone: 'neutral' },
+};
+
+const STATE_META: Record = {
+ merged: { label: 'Merged', tone: 'success' },
+ open: { label: 'Open', tone: 'info' },
+ closed: { label: 'Closed', tone: 'neutral' },
+};
+
+function formatDay(iso: string): string {
+ return new Date(iso).toLocaleDateString(undefined, {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+function timelineDate(entry: WorkLogEntry): string {
+ return entry.merged_at ?? entry.closed_at ?? entry.created_at;
+}
+
+function groupByMonth(entries: WorkLogEntry[]): Array<{ month: string; entries: WorkLogEntry[] }> {
+ const groups = new Map();
+ for (const entry of entries) {
+ const date = new Date(timelineDate(entry));
+ const month = date.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
+ const bucket = groups.get(month) ?? [];
+ bucket.push(entry);
+ groups.set(month, bucket);
+ }
+ return [...groups.entries()].map(([month, monthEntries]) => ({ month, entries: monthEntries }));
+}
+
+function TimelineCard({ entry }: { entry: WorkLogEntry }) {
+ const area = AREA_META[entry.parsed.area];
+ const state = STATE_META[entry.state];
+ const problem = entry.parsed.problem ?? entry.parsed.summary ?? 'No problem summary in the PR template yet.';
+ const fix =
+ entry.parsed.fix ??
+ entry.parsed.timelineNote ??
+ entry.parsed.summary ??
+ 'Add a Partner-readable summary or Git Activity Timeline note to this PR.';
+
+ return (
+
+
+
+
+
+
+
+
+
+ {area.label}
+
+
+ {state.label}
+
+ {entry.parsed.changeTypes.slice(0, 2).map((type) => (
+
+ {type}
+
+ ))}
+
+
+
+ #{entry.number}
+ {entry.title}
+
+
+
+
+ {formatDay(timelineDate(entry))}
+ {entry.authorLogin ? ` · @${entry.authorLogin}` : null}
+
+
+
+
+
+
+
+
Fix / outcome
+
{fix}
+
+
+
+ {entry.parsed.timelineNote && entry.parsed.timelineNote !== fix ? (
+
+ Partner line: {entry.parsed.timelineNote}
+
+ ) : null}
+
+
+ );
+}
+
+export function WorkTimeline({
+ entries,
+ repoLabel,
+ authorLogins,
+ counts,
+}: {
+ entries: WorkLogEntry[];
+ repoLabel: string;
+ authorLogins: string[];
+ counts: {
+ total: number;
+ merged: number;
+ open: number;
+ byArea: Record;
+ };
+}) {
+ const months = groupByMonth(entries);
+ const topAreas = (Object.entries(counts.byArea) as Array<[WorkArea, number]>)
+ .filter(([, count]) => count > 0)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 4);
+
+ return (
+
+
+
+
+ GitHub PRs
+
+ {authorLogins.length > 0 ? (
+
+ {authorLogins.map((login) => `@${login}`).join(', ')}
+
+ ) : (
+
+ all authors
+
+ )}
+
+
+
+ Open {repoLabel} pulls
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Top areas
+
+ {topAreas.length > 0 ? (
+ topAreas.map(([area, count]) => (
+
+ {AREA_META[area].label} · {count}
+
+ ))
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+ Summaries are parsed from your PR template — fill in{' '}
+ Partner-readable summary,{' '}
+ Area, and{' '}
+ Git Activity Timeline note. No AI guessing: if a section is empty, the card
+ tells you what to add on the next PR.
+
+
+
+
+ {months.map(({ month, entries: monthEntries }) => (
+
+ {month}
+
+ {monthEntries.map((entry) => (
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/admin/work/page.tsx b/src/app/admin/work/page.tsx
new file mode 100644
index 000000000..24576f8f1
--- /dev/null
+++ b/src/app/admin/work/page.tsx
@@ -0,0 +1,113 @@
+import { GitPullRequest, ScrollText } from 'lucide-react';
+import { requireSuperAdmin } from '@/lib/admin/require-super-admin';
+import { fetchWorkLog } from '@/lib/admin/github-pr-timeline';
+import { Eyebrow, Surface, StatusPill } from '@/components/fairway';
+import { PanelBoundary } from '../_components/PanelBoundary';
+import { PanelNoData, PanelStale } from '../_components/PanelStates';
+import { AutoRefresh } from '../_components/AutoRefresh';
+import { WorkTimeline } from './WorkTimeline';
+
+export const dynamic = 'force-dynamic';
+
+export default async function WorkLogPage() {
+ await requireSuperAdmin();
+ const workLog = await fetchWorkLog();
+
+ return (
+
+
+
+ Work log
+
+
+
+
Shipping timeline
+
+ A partner-readable history of your pull requests — what broke, what shipped, and which Helm surface it touched.
+
+
+
+
+ PR template
+
+
+ no AI summaries
+
+
+
+
+
+
+
+ {workLog.status === 'unconfigured' ? (
+
+ ) : workLog.status === 'error' || !workLog.data ? (
+
+ ) : workLog.data.entries.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ How summaries work
+
+
+
+
+ Problem comes from the PR's{' '}
+ Partner-readable summary (before "What changed").
+
+
+
+
+
+ Fix / outcome comes from the partner block after
+ "What changed", or the Git Activity Timeline note.
+
+
+
+
+ Fill those sections on every PR — partners see the same text here without opening GitHub.
+
+
+
+
+ Area tags
+
+ Set the Area line in the template:{' '}
+ golf, baseball,{' '}
+ coachhelm, mission-control, etc.
+
+
+ If Area is blank, Helm Bridge infers from the PR title (e.g. fix(golf):).
+
+
+
+
+ Filter to your PRs
+
+ Optional env: GITHUB_PR_AUTHOR_LOGINS (comma-separated GitHub usernames).
+ Without it, the feed shows all repo PRs.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/baseball/(coach-dashboard)/coach/college/page.tsx b/src/app/baseball/(coach-dashboard)/coach/college/page.tsx
deleted file mode 100644
index 3587c132a..000000000
--- a/src/app/baseball/(coach-dashboard)/coach/college/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function CollegeCoachDashboardPage() {
- redirect('/baseball/dashboard/command-center');
-}
diff --git a/src/app/baseball/(coach-dashboard)/coach/high-school/page.tsx b/src/app/baseball/(coach-dashboard)/coach/high-school/page.tsx
deleted file mode 100644
index 023d6afff..000000000
--- a/src/app/baseball/(coach-dashboard)/coach/high-school/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function HighSchoolCoachDashboardPage() {
- redirect('/baseball/dashboard/command-center');
-}
diff --git a/src/app/baseball/(coach-dashboard)/coach/juco/page.tsx b/src/app/baseball/(coach-dashboard)/coach/juco/page.tsx
deleted file mode 100644
index dd698533b..000000000
--- a/src/app/baseball/(coach-dashboard)/coach/juco/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function JucoCoachDashboardPage() {
- redirect('/baseball/dashboard/command-center');
-}
diff --git a/src/app/baseball/(coach-dashboard)/coach/showcase/page.tsx b/src/app/baseball/(coach-dashboard)/coach/showcase/page.tsx
deleted file mode 100644
index 4d1d60592..000000000
--- a/src/app/baseball/(coach-dashboard)/coach/showcase/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function ShowcaseCoachDashboardPage() {
- redirect('/baseball/dashboard/command-center');
-}
diff --git a/src/app/baseball/(coach-dashboard)/coach/template.tsx b/src/app/baseball/(coach-dashboard)/coach/template.tsx
index 794b61a6d..1b932dfd8 100644
--- a/src/app/baseball/(coach-dashboard)/coach/template.tsx
+++ b/src/app/baseball/(coach-dashboard)/coach/template.tsx
@@ -1,20 +1,12 @@
'use client';
/**
- * Coach-HOME route template — fires on every route segment change under
- * /baseball/coach/*.
+ * Coach route template — retained for the coach route group shell boundary.
*
- * The (coach-dashboard)/coach group renders the four coach-home verticals —
- * College (/baseball/coach/college), High School (/high-school), JUCO (/juco)
- * and Showcase (/showcase) — all through the SAME BaseballDashboardShell mounted
- * in coach/layout.tsx. Before this template existed, the (dashboard) and
- * (player-dashboard) groups each had a template.tsx that crossfaded on nav, but
- * this group had none — so navigating INTO or BETWEEN the four coach homes was an
- * abrupt content snap while every other dashboard tab faded. That is exactly the
- * "transitions present but not everywhere" inconsistency the owner flagged. This
- * 1-file port closes it and satisfies the V10 shell requirement "Route transitions
- * with reduced-motion respect."
- * (docs/.../25_premium_ui_coachhelm_v10/v10_premium_ui_system_by_tab.md line 33).
+ * The legacy per-coach-type pages under /baseball/coach/* were removed once the
+ * canonical Fairway dashboard routes were wired. This template stays with the
+ * group so any future coach-owned subroutes inherit the same reduced-motion
+ * route reveal as the dashboard and player groups.
*
* Ported VERBATIM in spirit from the sibling templates
* ((dashboard)/dashboard/template.tsx, (player-dashboard)/player/template.tsx),
diff --git a/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx b/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx
index 60fa82b09..fe4234204 100644
--- a/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx
+++ b/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx
@@ -13,8 +13,8 @@
*
* PRESENTATION ONLY. No server actions, no RLS, no new reads beyond what the
* existing baseball auth/nav hooks already resolve:
- * - useBaseballAuth(null) — the SAME session/onboarding gate
- * BaseballShellLayout uses for this route group.
+ * - useBaseballAuth(requiredRole) — the SAME session/onboarding gate
+ * BaseballShellLayout uses for each mounted route group.
* - useBaseballNavContext() — the SAME server-resolved capability map
* (nav-context.ts), so capability-gated verticals never fail-closed here
* when they wouldn't in the legacy shell.
@@ -29,13 +29,11 @@
* PeekPanelProvider nesting, unchanged. BaseballShellLayout.tsx itself is not
* imported or edited — this file is a parallel, full duplicate of that
* composition (same reason GolfHelm's FairwayDashboardShell duplicates
- * GolfDashboardShell's stack rather than wrapping it), so the two OTHER
- * baseball shell route groups ((coach-dashboard), (player-dashboard)) are
- * completely unaffected by this migration step.
+ * GolfDashboardShell's stack rather than wrapping it).
*
- * Mounted ONLY behind isRedesignEnabled() in `(dashboard)/layout.tsx`. Flag
- * OFF renders the legacy `BaseballShellLayout` → `BaseballDashboardShell`,
- * byte-for-byte unchanged.
+ * Mounted ONLY behind isRedesignEnabled() in the Baseball dashboard/player
+ * route-group layouts. Flag OFF renders the legacy `BaseballShellLayout` →
+ * `BaseballDashboardShell`, byte-for-byte unchanged.
*
* The AppShell drawer (`mobileOpen`) is BRIDGED to the SAME SidebarContext
* every legacy baseball page's own menu button already calls `setMobileOpen`
@@ -43,7 +41,7 @@
* no dead buttons, no double drawers.
* ========================================================================== */
-import { useCallback, useMemo } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
@@ -75,13 +73,21 @@ import {
import {
COACH_HUB_ORDER,
COACH_HUB_DEFS,
+ HUB_ICONS,
+ HUB_LANDING,
+ PLAYER_DEVELOPMENT_TABS,
+ PLAYER_RECRUITING_TABS,
+ PLAYER_STATS_TABS,
+ PLAYER_TEAM_TABS,
} from '@/app/baseball/(dashboard)/_components/hub-definitions';
import {
filterHubTabsByCapabilities,
filterHubTabsByProgramType,
+ resolveActiveHub,
+ RECRUITING_PROGRAM_TYPES,
} from '@/app/baseball/(dashboard)/_components/resolve-active-hub';
+import { HubSubNav } from '@/app/baseball/(dashboard)/_components/hub-sub-nav';
import type { HubSubNavTab } from '@/app/baseball/(dashboard)/_components/hub-sub-nav';
-import type { BaseballProgramType } from '@/lib/types/baseball-settings';
import { IconSettings, IconLogout, IconHome, IconUsers, IconCalendar } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -96,25 +102,23 @@ const CommandPalette = dynamic(
type Role = 'coach' | 'player';
/**
- * P413-equivalent: the 3 highest-frequency destinations for the persistent
- * MOBILE bottom-tab bar, matching the legacy `MobileBottomNav`'s preferred ids
- * exactly (dashboard-shell.tsx's `buildMobileNavFromContext`) — the drawer
- * (opened by AppShell's own hamburger) keeps the long tail, so the 4th "Menu"
- * slot the legacy bar shows is redundant here rather than dropped.
+ * P413-equivalent: mobile bottom-tab destinations derived from the SAME hub
+ * sections as the desktop rail so active states agree across breakpoints.
+ * Golf FairwayDashboardShell uses the same pattern (5 tabs, hub activeMatch).
*/
-function buildBottomNavItems(ctx: BaseballNavContext, unreadCount: number): NavItem[] {
- const primary = getVisibleBaseballNav(ctx).filter((e) => e.section === 'primary');
- const preferredIds =
- ctx.role === 'coach'
- ? ['command-center', 'calendar', 'roster']
- : ['player-today', 'calendar', 'player-profile'];
- const top3 = preferredIds
- .map((id) => primary.find((e) => e.id === id))
- .filter((e): e is NonNullable => Boolean(e));
- const fill = primary.filter((e) => !top3.some((selected) => selected.id === e.id));
- return [...top3, ...fill].slice(0, 3).map((e) =>
- toNavItem(e, e.showUnreadBadge && unreadCount > 0 ? unreadCount : undefined),
- );
+function buildBottomNavFromSections(sections: NavSection[], role: Role): NavItem[] {
+ const items = sections.flatMap((section) => section.items);
+ const preferredLabels =
+ role === 'coach'
+ ? (['Dashboard', 'Team', 'Stats & Performance', 'Messages'] as const)
+ : (['Today', 'Stats', 'Development', 'Team', 'Messages'] as const);
+
+ const picked = preferredLabels
+ .map((label) => items.find((item) => item.label === label))
+ .filter((item): item is NavItem => Boolean(item));
+
+ if (picked.length >= 3) return picked.slice(0, 5);
+ return items.slice(0, 5);
}
/** Next adapter for the shell's link contract (module scope = stable identity). */
@@ -124,60 +128,110 @@ const ShellLink: ShellLinkComponent = ({ href, children, ...rest }) => (
);
-/** entry.icon carries a wider SVG prop contract than FairwayIcon's minimal
- * `{size, className}` — both are drawn from the same `@/components/icons`
- * set, so this narrows structurally without behavior change. */
-function toNavItem(entry: Pick, badge?: number): NavItem {
- return { label: entry.label, href: entry.href, icon: entry.icon as unknown as NavItem['icon'], badge };
+/** Segment-boundary route match — shared by rail items and hub cluster rows. */
+function matchesRoutePrefix(pathname: string, prefix: string): boolean {
+ return pathname === prefix || pathname.startsWith(`${prefix}/`);
}
-/** Same structural narrowing as toNavItem, for a hub sub-tab (whose `icon` is
- * optional in HubSubNavTab — every tab this shell renders always sets one). */
-function hubTabToNavItem(tab: HubSubNavTab, badge?: number): NavItem {
- return { label: tab.label, href: tab.href, icon: tab.icon as unknown as NavItem['icon'], badge };
+function toNavItem(
+ entry: Pick,
+ badge?: number,
+ matchPrefixes: readonly string[] = [],
+): NavItem {
+ return {
+ label: entry.label,
+ href: entry.href,
+ icon: entry.icon as unknown as NavItem['icon'],
+ badge,
+ activeMatch: (pathname) =>
+ matchesRoutePrefix(pathname, entry.href) ||
+ matchPrefixes.some((prefix) => matchesRoutePrefix(pathname, prefix)),
+ };
}
-const RECRUITING_PROGRAM_TYPES = new Set(['college', 'juco', 'showcase', 'academy', 'club']);
+function playerHubToNavItem({
+ label,
+ href,
+ icon,
+ tabs,
+ badge,
+}: {
+ label: string;
+ href: string;
+ icon: NavItem['icon'];
+ tabs?: readonly HubSubNavTab[];
+ badge?: number;
+}): NavItem {
+ return {
+ label,
+ href,
+ icon,
+ badge,
+ activeMatch: (pathname) =>
+ matchesRoutePrefix(pathname, href) ||
+ Boolean(
+ tabs?.some(
+ (tab) =>
+ matchesRoutePrefix(pathname, tab.href) ||
+ tab.matchPrefixes?.some((prefix) => matchesRoutePrefix(pathname, prefix)),
+ ),
+ ),
+ };
+}
-/**
- * Player rail sections — UNCHANGED by the coach-nav-8tab consolidation
- * (COACH_NAV_8TAB_PROPOSAL.md is scoped to the coach nav). Derived straight
- * from `getVisibleBaseballNav()`, exactly as before.
- */
function buildPlayerNavSections(ctx: BaseballNavContext, unreadCount: number): NavSection[] {
const visible = getVisibleBaseballNav(ctx);
- const primary: NavItem[] = visible.filter((e) => e.section === 'primary').map((e) => toNavItem(e));
- const secondary: NavItem[] = visible.filter((e) => e.section === 'secondary').map((e) => toNavItem(e));
+ const byId = new Map(visible.map((entry) => [entry.id, entry]));
+ const today = byId.get('player-today');
+ const schedule = byId.get('calendar');
+ const profile = byId.get('player-profile');
+
+ const primary: NavItem[] = [
+ ...(today ? [toNavItem(today)] : []),
+ ...(schedule ? [toNavItem(schedule)] : []),
+ ...(profile ? [toNavItem(profile)] : []),
+ playerHubToNavItem({
+ label: 'Stats',
+ href: HUB_LANDING.playerStats,
+ icon: HUB_ICONS.stats as unknown as NavItem['icon'],
+ tabs: PLAYER_STATS_TABS,
+ }),
+ playerHubToNavItem({
+ label: 'Development',
+ href: HUB_LANDING.playerDevelopment,
+ icon: HUB_ICONS.development as unknown as NavItem['icon'],
+ tabs: PLAYER_DEVELOPMENT_TABS,
+ }),
+ playerHubToNavItem({
+ label: 'Team',
+ href: HUB_LANDING.playerTeam,
+ icon: HUB_ICONS.team as unknown as NavItem['icon'],
+ tabs: PLAYER_TEAM_TABS,
+ }),
+ toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined, [BASEBALL_MESSAGES_NAV.href]),
+ ];
- primary.push(toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined));
+ const secondary: NavItem[] = [
+ playerHubToNavItem({
+ label: getBaseballTerminology(ctx).exposureNoun,
+ href: HUB_LANDING.playerRecruiting,
+ icon: HUB_ICONS.recruiting as unknown as NavItem['icon'],
+ tabs: PLAYER_RECRUITING_TABS,
+ }),
+ toNavItem(
+ { href: '/baseball/dashboard/settings', label: 'Settings', icon: IconSettings },
+ undefined,
+ ['/baseball/dashboard/settings'],
+ ),
+ ];
const sections: NavSection[] = [{ heading: 'My Baseball', items: primary }];
if (secondary.length) sections.push({ heading: 'More', items: secondary });
return sections;
}
-/**
- * Coach rail sections — GROUPED BY `entry.hub` (COACH_HUB_ORDER / COACH_HUB_DEFS
- * in hub-definitions.ts, itself derived from BASEBALL_NAV_REGISTRY), the same
- * single source of truth src/components/layout/sidebar.tsx's
- * buildCondensedBaseballNavigation groups by. One NavSection per visible hub
- * (heading = hub label, items = the hub's capability/program-type-filtered
- * tabs) — the Fairway rail shows every tab inline (no separate landing-item +
- * sub-nav-strip split the legacy shell needs), so "hub label > sub-items" is
- * literally the section heading over its item list.
- *
- * A hub section is omitted entirely once its tabs filter down to zero (mirrors
- * resolveActiveHub's own "empty hub → no strip" precedent), so this shell and
- * the legacy sidebar can never show a dead-end top-level item for a coach
- * missing every capability a hub's members require.
- *
- * Messages is injected into the DASHBOARD section (golf-style: FairwayDashboard
- * Shell.tsx puts Messages inside its first/primary section, never a section of
- * its own) — it is deliberately excluded from BASEBALL_NAV_REGISTRY (see
- * nav-registry.ts), so every nav consumer must add it explicitly.
- */
function buildCoachHubSections(ctx: BaseballNavContext, unreadCount: number): NavSection[] {
- const sections: NavSection[] = [];
+ const items: NavItem[] = [];
for (const hubId of COACH_HUB_ORDER) {
const def = COACH_HUB_DEFS[hubId];
@@ -192,21 +246,27 @@ function buildCoachHubSections(ctx: BaseballNavContext, unreadCount: number): Na
const capFiltered = filterHubTabsByCapabilities(def.tabs, 'coach', ctx.capabilities);
const visibleTabs = filterHubTabsByProgramType(capFiltered, ctx.programType);
- const items = visibleTabs.map((t) => hubTabToNavItem(t));
-
- if (hubId === 'dashboard') {
- items.push(hubTabToNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined));
- }
- if (items.length === 0) continue;
+ if (visibleTabs.length === 0) continue;
// Recruiting reframed per mode (JUCO → "Transfer Exposure") using the SAME
// terminology engine program-type-variants.ts already provides — never a
// second hand-maintained label map.
- const heading = hubId === 'recruiting' ? getBaseballTerminology(ctx).exposureNoun : def.label;
- sections.push({ heading, items });
+ items.push(
+ playerHubToNavItem({
+ label: hubId === 'recruiting' ? getBaseballTerminology(ctx).exposureNoun : def.label,
+ href: visibleTabs[0]!.href,
+ icon: def.icon as unknown as NavItem['icon'],
+ tabs: visibleTabs,
+ }),
+ );
+
+ // Messages is the persistent cross-cutting slot, outside the hub registry.
+ if (hubId === 'dashboard') {
+ items.push(toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined, [BASEBALL_MESSAGES_NAV.href]));
+ }
}
- return sections;
+ return [{ heading: 'Baseball', items }];
}
/**
@@ -223,7 +283,7 @@ function buildShowcaseOrgSections(unreadCount: number): NavSection[] {
{ label: 'Dashboard', href: '/baseball/dashboard/organization', icon: IconHome },
{ label: 'Teams', href: '/baseball/dashboard/teams', icon: IconUsers },
{ label: 'Events', href: '/baseball/dashboard/events', icon: IconCalendar },
- hubTabToNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined),
+ toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined, [BASEBALL_MESSAGES_NAV.href]),
],
},
];
@@ -237,23 +297,34 @@ function buildShowcaseOrgSections(unreadCount: number): NavSection[] {
* rail is never just three orphaned sections with no way back to Today.
*/
function buildShowcaseTeamSections(ctx: BaseballNavContext, unreadCount: number): NavSection[] {
- const sections: NavSection[] = [];
+ const items: NavItem[] = [];
const dashboardTabs = filterHubTabsByCapabilities(COACH_HUB_DEFS.dashboard.tabs, 'coach', ctx.capabilities);
- sections.push({
- heading: COACH_HUB_DEFS.dashboard.label,
- items: [
- ...dashboardTabs.map((t) => hubTabToNavItem(t)),
- hubTabToNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined),
- ],
- });
+ if (dashboardTabs.length) {
+ items.push(
+ playerHubToNavItem({
+ label: COACH_HUB_DEFS.dashboard.label,
+ href: dashboardTabs[0]!.href,
+ icon: COACH_HUB_DEFS.dashboard.icon as unknown as NavItem['icon'],
+ tabs: dashboardTabs,
+ }),
+ );
+ items.push(toNavItem(BASEBALL_MESSAGES_NAV, unreadCount > 0 ? unreadCount : undefined, [BASEBALL_MESSAGES_NAV.href]));
+ }
for (const hubId of ['team', 'stats-performance', 'development'] as const) {
const def = COACH_HUB_DEFS[hubId];
const capFiltered = filterHubTabsByCapabilities(def.tabs, 'coach', ctx.capabilities);
const visibleTabs = filterHubTabsByProgramType(capFiltered, ctx.programType);
if (visibleTabs.length === 0) continue;
- sections.push({ heading: def.label, items: visibleTabs.map((t) => hubTabToNavItem(t)) });
+ items.push(
+ playerHubToNavItem({
+ label: def.label,
+ href: visibleTabs[0]!.href,
+ icon: def.icon as unknown as NavItem['icon'],
+ tabs: visibleTabs,
+ }),
+ );
}
- return sections;
+ return [{ heading: 'Baseball', items }];
}
function toTitle(seg: string): string {
@@ -407,8 +478,21 @@ function BaseballFairwayContent({
return buildCoachHubSections(ctx, unreadCount);
}, [ctx, unreadCount, role, isShowcaseCoach, selectedTeam]);
const breadcrumbs = useMemo(() => buildBreadcrumbs(pathname, ctx, homeHref), [pathname, ctx, homeHref]);
+ const activeHub = resolveActiveHub({
+ pathname,
+ role,
+ programType: ctx.programType ?? null,
+ capabilities: ctx.capabilities,
+ });
// P413-equivalent: persistent mobile bottom-tab bar (subset of the rail).
- const bottomNavItems = useMemo(() => buildBottomNavItems(ctx, unreadCount), [ctx, unreadCount]);
+ const bottomNavItems = useMemo(
+ () => buildBottomNavFromSections(sections, role),
+ [sections, role],
+ );
+
+ useEffect(() => {
+ window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
+ }, [pathname]);
const name = coach?.full_name || (player ? `${player.first_name} ${player.last_name}` : 'User');
const avatarUrl = coach?.avatar_url || player?.avatar_url || undefined;
@@ -467,6 +551,14 @@ function BaseballFairwayContent({
constrainContent={false}
>
+ {activeHub && (
+
+ )}
{children}
@@ -481,19 +573,26 @@ function BaseballFairwayContent({
* Exported shell — full standalone replacement for BaseballShellLayout (auth
* gate + provider stack + shell), rendering the Fairway AppShell frame.
*/
-export function BaseballFairwayShell({ children }: { children: React.ReactNode }) {
- // SAME auth gate BaseballShellLayout uses for this route group (accepts
- // both roles; requiredRole=null).
- const { loading, authorized, role } = useBaseballAuth(null);
+export function BaseballFairwayShell({
+ children,
+ authVerified = false,
+ requiredRole = null,
+}: {
+ children: React.ReactNode;
+ authVerified?: boolean;
+ requiredRole?: Role | null;
+}) {
+ // SAME auth gate BaseballShellLayout uses for the mounted route group.
+ const { loading, authorized, role } = useBaseballAuth(requiredRole);
// SAME server-resolved capability map (nav-context.ts) BaseballShellLayout
// passes into BaseballDashboardShell.
const { navContext } = useBaseballNavContext();
- if (loading || !authorized) {
+ if (!authVerified && (loading || !authorized)) {
return ;
}
- const resolvedRole: Role = role ?? 'coach';
+ const resolvedRole: Role = requiredRole ?? navContext?.role ?? role ?? 'coach';
return (
diff --git a/src/app/baseball/(dashboard)/_components/hub-definitions.ts b/src/app/baseball/(dashboard)/_components/hub-definitions.ts
index 6d97e2549..c6cb78ceb 100644
--- a/src/app/baseball/(dashboard)/_components/hub-definitions.ts
+++ b/src/app/baseball/(dashboard)/_components/hub-definitions.ts
@@ -57,6 +57,9 @@ import {
IconHome,
IconUser,
IconGraduationCap,
+ IconSettings,
+ IconLock,
+ IconDatabase,
} from '@/components/icons';
import type { HubSubNavTab } from './hub-sub-nav';
import {
@@ -81,6 +84,7 @@ function toHubTab(entry: BaseballNavEntry): HubSubNavTab {
requiredCapability: entry.requiredCapability ?? undefined,
requiredAnyCapabilities: entry.requiredAnyCapabilities,
allowedProgramTypes: entry.allowedProgramTypes,
+ matchPrefixes: entry.matchPrefixes,
};
}
@@ -131,6 +135,7 @@ const STATS_GAMES_TAB: HubSubNavTab = {
label: 'Games',
href: '/baseball/dashboard/stats/games',
icon: IconClipboardList,
+ matchPrefixes: ['/baseball/dashboard/stats/games'],
};
const STATS_SEASON_TAB: HubSubNavTab = {
id: 'season',
@@ -173,6 +178,68 @@ const PERFORMANCE_BUILDER_TAB: HubSubNavTab = {
icon: IconGauge,
requiredCapability: 'can_manage_lifting',
};
+const SETTINGS_HOME_TAB: HubSubNavTab = {
+ id: 'settings-home',
+ label: 'Settings',
+ href: '/baseball/dashboard/settings',
+ icon: IconSettings,
+};
+const SETTINGS_SEASON_TAB: HubSubNavTab = {
+ id: 'settings-season',
+ label: 'Season',
+ href: '/baseball/dashboard/settings/season',
+ icon: IconCalendar,
+ requiredCapability: 'can_manage_settings',
+};
+const SETTINGS_PHILOSOPHY_TAB: HubSubNavTab = {
+ id: 'settings-philosophy',
+ label: 'Philosophy',
+ href: '/baseball/dashboard/settings/philosophy',
+ icon: IconTarget,
+ requiredCapability: 'can_manage_settings',
+};
+const SETTINGS_ROLES_TAB: HubSubNavTab = {
+ id: 'settings-roles',
+ label: 'Roles',
+ href: '/baseball/dashboard/settings/roles',
+ icon: IconLock,
+ requiredCapability: 'can_manage_settings',
+};
+const SETTINGS_PERMISSIONS_TAB: HubSubNavTab = {
+ id: 'settings-permissions',
+ label: 'Permissions',
+ href: '/baseball/dashboard/settings/permissions',
+ icon: IconShieldCheck,
+ requiredCapability: 'can_manage_settings',
+};
+const SETTINGS_TEAMS_TAB: HubSubNavTab = {
+ id: 'settings-teams',
+ label: 'Team Settings',
+ href: '/baseball/dashboard/settings/teams',
+ icon: IconUsers,
+ requiredCapability: 'can_manage_settings',
+};
+const SETTINGS_IMPORTS_TAB: HubSubNavTab = {
+ id: 'settings-imports',
+ label: 'Imports',
+ href: '/baseball/dashboard/settings/imports',
+ icon: IconUpload,
+ requiredCapability: 'can_manage_imports',
+};
+const SETTINGS_INTEGRATIONS_TAB: HubSubNavTab = {
+ id: 'settings-integrations',
+ label: 'Integrations',
+ href: '/baseball/dashboard/settings/integrations',
+ icon: IconBuilding,
+ requiredCapability: 'can_manage_settings',
+};
+const SETTINGS_AUDIT_TAB: HubSubNavTab = {
+ id: 'settings-audit',
+ label: 'Audit',
+ href: '/baseball/dashboard/settings/audit',
+ icon: IconDatabase,
+ requiredCapability: 'can_manage_settings',
+};
// -----------------------------------------------------------------------------
// Curated display order per hub (COACH_NAV_8TAB_PROPOSAL.md mapping table).
@@ -254,6 +321,18 @@ export const COACH_RECRUITING_TABS: readonly HubSubNavTab[] = orderTabs(
*/
export const COACH_ACADEMICS_TABS: readonly HubSubNavTab[] = orderTabs(hubEntries('academics'), ACADEMICS_ORDER);
+const MANAGEMENT_SETTINGS_SUPPLEMENT_ID = 'program-settings';
+
+/** Dev-only guard: settings supplement tabs must attach to a real registry row. */
+function assertManagementSettingsSupplement(tabs: readonly HubSubNavTab[]): void {
+ if (process.env.NODE_ENV === 'production') return;
+ if (!tabs.some((tab) => tab.id === MANAGEMENT_SETTINGS_SUPPLEMENT_ID)) {
+ throw new Error(
+ `COACH_MANAGEMENT_TABS settings supplement requires registry tab "${MANAGEMENT_SETTINGS_SUPPLEMENT_ID}".`,
+ );
+ }
+}
+
/**
* MANAGEMENT hub — staff coordination, program settings, and (Showcase/Academy/
* Club only, via allowedProgramTypes carried through verbatim from the
@@ -261,9 +340,24 @@ export const COACH_ACADEMICS_TABS: readonly HubSubNavTab[] = orderTabs(hubEntrie
* "Decision Room" vs "Staff Room" label drift (the registry's label always
* wins now — it is read, not re-declared).
*/
-export const COACH_MANAGEMENT_TABS: readonly HubSubNavTab[] = orderTabs(
- hubEntries('management'),
- MANAGEMENT_ORDER,
+const managementHubTabs = orderTabs(hubEntries('management'), MANAGEMENT_ORDER);
+assertManagementSettingsSupplement(managementHubTabs);
+
+export const COACH_MANAGEMENT_TABS: readonly HubSubNavTab[] = withSupplements(
+ managementHubTabs,
+ {
+ [MANAGEMENT_SETTINGS_SUPPLEMENT_ID]: [
+ SETTINGS_HOME_TAB,
+ SETTINGS_SEASON_TAB,
+ SETTINGS_PHILOSOPHY_TAB,
+ SETTINGS_ROLES_TAB,
+ SETTINGS_PERMISSIONS_TAB,
+ SETTINGS_TEAMS_TAB,
+ SETTINGS_IMPORTS_TAB,
+ SETTINGS_INTEGRATIONS_TAB,
+ SETTINGS_AUDIT_TAB,
+ ],
+ },
);
// -----------------------------------------------------------------------------
@@ -333,6 +427,15 @@ export const PLAYER_TEAM_TABS: readonly HubSubNavTab[] = [
{ id: 'documents', label: 'Documents', href: '/baseball/dashboard/documents', icon: IconFileText },
];
+/** Player RECRUITING hub — player-owned exposure and college discovery surfaces. */
+export const PLAYER_RECRUITING_TABS: readonly HubSubNavTab[] = [
+ { id: 'journey', label: 'Journey', href: '/baseball/dashboard/journey', icon: IconStar },
+ { id: 'interest', label: 'Interest', href: '/baseball/dashboard/college-interest', icon: IconTarget },
+ { id: 'colleges', label: 'Colleges', href: '/baseball/dashboard/colleges', icon: IconGraduationCap },
+ { id: 'analytics', label: 'Analytics', href: '/baseball/dashboard/analytics', icon: IconTrendingUp },
+ { id: 'activate', label: 'Activate', href: '/baseball/dashboard/activate', icon: IconShieldCheck },
+];
+
// -----------------------------------------------------------------------------
// SHARED REFERENCES — non-hub leaf routes the sidebar links directly (no sub-nav).
// Kept here only so the sidebar and any future hub share one href string.
@@ -350,6 +453,7 @@ export const HUB_LANDING = {
playerProfile: '/baseball/dashboard/profile',
playerStats: PLAYER_STATS_TABS[0]!.href,
playerDevelopment: PLAYER_DEVELOPMENT_TABS[0]!.href,
+ playerRecruiting: PLAYER_RECRUITING_TABS[0]!.href,
playerCalendar: '/baseball/dashboard/calendar',
playerMessages: '/baseball/dashboard/messages',
playerTeam: PLAYER_TEAM_TABS[0]!.href,
diff --git a/src/app/baseball/(dashboard)/_components/hub-sub-nav.tsx b/src/app/baseball/(dashboard)/_components/hub-sub-nav.tsx
index 800236c7e..de68cb314 100644
--- a/src/app/baseball/(dashboard)/_components/hub-sub-nav.tsx
+++ b/src/app/baseball/(dashboard)/_components/hub-sub-nav.tsx
@@ -52,7 +52,7 @@ export interface HubSubNavTab {
* Longest-prefix wins across every tab so the deepest leaf still resolves to
* its parent tab.
*/
- matchPrefixes?: string[];
+ matchPrefixes?: readonly string[];
/** Staff capability required to show this tab (coaches only). */
requiredCapability?: BaseballCapability;
/** Staff must hold at least one of these capabilities (#370 / #408). */
diff --git a/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts b/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts
index b92626512..238f62a78 100644
--- a/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts
+++ b/src/app/baseball/(dashboard)/_components/resolve-active-hub.ts
@@ -25,6 +25,7 @@ import {
PLAYER_STATS_TABS,
PLAYER_DEVELOPMENT_TABS,
PLAYER_TEAM_TABS,
+ PLAYER_RECRUITING_TABS,
} from './hub-definitions';
import type { HubSubNavTab } from './hub-sub-nav';
import type { BaseballProgramType } from '@/lib/types/baseball-settings';
@@ -39,6 +40,8 @@ const RECRUITING_PROGRAM_TYPES = new Set([
'club',
]);
+export { RECRUITING_PROGRAM_TYPES };
+
/**
* Stable short ids for telemetry/tests, decoupled from the hub's display label
* (BaseballNavHub / COACH_HUB_DEFS id) so relabeling a hub in the sidebar can
@@ -108,6 +111,12 @@ function playerHubs(): HubDef[] {
tabs: PLAYER_TEAM_TABS,
ownedPrefixes: PLAYER_TEAM_TABS.flatMap((t) => [t.href, ...(t.matchPrefixes ?? [])]),
},
+ {
+ id: 'recruiting',
+ ariaLabel: 'Recruiting sections',
+ tabs: PLAYER_RECRUITING_TABS,
+ ownedPrefixes: PLAYER_RECRUITING_TABS.flatMap((t) => [t.href, ...(t.matchPrefixes ?? [])]),
+ },
];
}
diff --git a/src/app/baseball/(dashboard)/dashboard/settings/page.tsx b/src/app/baseball/(dashboard)/dashboard/settings/page.tsx
index d64d3f828..ad9f478c3 100644
--- a/src/app/baseball/(dashboard)/dashboard/settings/page.tsx
+++ b/src/app/baseball/(dashboard)/dashboard/settings/page.tsx
@@ -9,9 +9,124 @@ import { PageLoading } from '@/components/ui/loading';
import { useAuth } from '@/hooks/use-auth';
import { useToast } from '@/components/ui/sonner';
import { changePasswordAction } from '@/app/baseball/actions/auth';
-import { IconChevronRight, IconShield, IconBuilding, IconBell, IconMail } from '@/components/icons';
+import {
+ IconBell,
+ IconBuilding,
+ IconCalendar,
+ IconChevronRight,
+ IconDatabase,
+ IconLink,
+ IconLock,
+ IconMail,
+ IconPalette,
+ IconSchool,
+ IconSettings,
+ IconShield,
+ IconTarget,
+ IconUpload,
+ IconUsers,
+} from '@/components/icons';
import Link from 'next/link';
+const COACH_SETTINGS_LINKS = [
+ {
+ href: '/baseball/dashboard/settings/program',
+ label: 'Program Settings',
+ description: 'Identity, public profile, notifications, AI, and access defaults',
+ icon: IconBuilding,
+ },
+ {
+ href: '/baseball/dashboard/settings/staff',
+ label: 'Staff Settings',
+ description: 'Staff seats, assignments, and coaching responsibilities',
+ icon: IconUsers,
+ },
+ {
+ href: '/baseball/dashboard/settings/teams',
+ label: 'Team Settings',
+ description: 'Team records, roster configuration, and season ownership',
+ icon: IconUsers,
+ },
+ {
+ href: '/baseball/dashboard/settings/season',
+ label: 'Season Settings',
+ description: 'Season dates, opponent naming, and competition context',
+ icon: IconCalendar,
+ },
+ {
+ href: '/baseball/dashboard/settings/philosophy',
+ label: 'Coaching Philosophy',
+ description: 'Development model, evaluation priorities, and program language',
+ icon: IconTarget,
+ },
+ {
+ href: '/baseball/dashboard/settings/roles',
+ label: 'Role Templates',
+ description: 'Default role packs for staff and program collaborators',
+ icon: IconLock,
+ },
+ {
+ href: '/baseball/dashboard/settings/permissions',
+ label: 'Permissions Matrix',
+ description: 'Capability-level access across staff roles',
+ icon: IconShield,
+ },
+ {
+ href: '/baseball/dashboard/settings/imports',
+ label: 'Import Sources',
+ description: 'Trust levels, review rules, and player matching for imported data',
+ icon: IconUpload,
+ },
+ {
+ href: '/baseball/dashboard/settings/integrations',
+ label: 'Integrations',
+ description: 'Adapter levels for stat feeds, devices, and partner systems',
+ icon: IconLink,
+ },
+ {
+ href: '/baseball/dashboard/settings/audit',
+ label: 'Audit Log',
+ description: 'Append-only history of sensitive settings changes',
+ icon: IconDatabase,
+ },
+] as const;
+
+const PLAYER_SETTINGS_LINKS = [
+ {
+ href: '/baseball/dashboard/settings/privacy',
+ label: 'Privacy Settings',
+ description: 'Control what appears on your public profile',
+ icon: IconShield,
+ },
+ {
+ href: '/baseball/dashboard/settings/recruiting-preferences',
+ label: 'Recruiting Preferences',
+ description: 'Manage exposure preferences and college fit signals',
+ icon: IconSchool,
+ },
+] as const;
+
+const CONSOLIDATED_SETTINGS_LINKS = [
+ {
+ href: '/baseball/dashboard/settings/program#appearance',
+ label: 'Appearance',
+ description: 'Team branding and public profile presentation',
+ icon: IconPalette,
+ },
+ {
+ href: '/baseball/dashboard/settings/program#notifications',
+ label: 'Notifications',
+ description: 'Quiet hours and program notification defaults',
+ icon: IconBell,
+ },
+ {
+ href: '/baseball/dashboard/settings/program#data-retention',
+ label: 'Data Retention',
+ description: 'Retention defaults, export posture, and audit policy',
+ icon: IconDatabase,
+ },
+] as const;
+
export default function SettingsPage() {
const { user, loading } = useAuth();
const { showToast } = useToast();
@@ -106,71 +221,76 @@ export default function SettingsPage() {
return (
<>
-
- {/* Program Profile Link (Coaches Only) */}
- {user?.role === 'coach' && (
-
-
-
-
-
-
-
+
+
+ {(user?.role === 'coach' ? COACH_SETTINGS_LINKS : PLAYER_SETTINGS_LINKS).map((item) => {
+ const Icon = item.icon;
+ return (
+
+
+
+
+
+
+
+
+
+
{item.label}
+
{item.description}
+
+
+
-
-
Program Profile
-
Customize your public program page for recruits
-
-
-
-
-
-
-
- )}
+
+
+
+ );
+ })}
+
- {/* Privacy Settings Link (Players Only) */}
- {user?.role === 'player' && (
-
-
-
-
-
-
-
-
-
-
Privacy Settings
-
Control what appears on your public profile
+ {user?.role === 'coach' && (
+
+
+
+
+
Consolidated Program Sections
+
+
+
+ {CONSOLIDATED_SETTINGS_LINKS.map((item) => {
+ const Icon = item.icon;
+ return (
+
+
+
+
-
-
-
-
-
-
+
{item.label}
+
{item.description}
+
+ );
+ })}
+
+
)}
- {/* Notification Preferences (Coaches) — the real, persisted store lives on
- Program Settings (baseball_program_settings.notification_defaults via
- ProgramSettingsClient + updateProgramSettings). This used to be a
- second, disconnected card that only updated local React state and
- toasted "success" with no server call (#454, #466) — removed in
- favor of a single link to the real surface so there is exactly one
- place to configure notifications and no fake toggle to drift out of
- sync with what's actually saved. */}
+ {/* Program Profile Link (Coaches Only) */}
{user?.role === 'coach' && (
-
+
-
+
-
Notification Preferences
-
Manage quiet hours and per-type notification defaults for your program
+
Program Profile
+
Customize your public program page for recruits
diff --git a/src/app/baseball/(dashboard)/dashboard/stats/games/new/NewGameClient.tsx b/src/app/baseball/(dashboard)/dashboard/stats/games/new/NewGameClient.tsx
deleted file mode 100644
index 77149fc80..000000000
--- a/src/app/baseball/(dashboard)/dashboard/stats/games/new/NewGameClient.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { useRouter } from 'next/navigation';
-import { createGame } from '@/app/baseball/actions/games';
-import type { BaseballGameType, BaseballHomeAway } from '@/lib/types';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Checkbox } from '@/components/ui/checkbox';
-
-interface NewGameClientProps {
- teamId: string;
- teamName: string;
-}
-
-export function NewGameClient({ teamId, teamName }: NewGameClientProps) {
- const router = useRouter();
- const today = new Date().toISOString().split('T')[0];
-
- const [gameDate, setGameDate] = useState(today ?? '');
- const [gameType, setGameType] = useState
('game');
- const [opponentName, setOpponentName] = useState('');
- const [location, setLocation] = useState('');
- const [homeAway, setHomeAway] = useState('home');
- const [eventTime, setEventTime] = useState('');
- const [createCalendarEvent, setCreateCalendarEvent] = useState(true);
- const [saving, setSaving] = useState(false);
- const [error, setError] = useState(null);
-
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- if (!gameDate) {
- setError('Please select a game date');
- return;
- }
-
- setSaving(true);
- setError(null);
-
- const result = await createGame(teamId, {
- game_date: gameDate,
- game_type: gameType,
- opponent_name: opponentName || undefined,
- location: location || undefined,
- home_away: homeAway,
- event_time: eventTime || undefined,
- create_calendar_event: createCalendarEvent,
- });
-
- if (result.success && result.data) {
- router.push(`/baseball/dashboard/stats/games/${result.data.id}`);
- } else {
- setError(result.error ?? 'Failed to create game');
- setSaving(false);
- }
- }
-
- return (
-
-
-
Add Game / Scrimmage
-
{teamName}
-
-
-
-
- );
-}
diff --git a/src/app/baseball/(dashboard)/dashboard/stats/games/new/error.tsx b/src/app/baseball/(dashboard)/dashboard/stats/games/new/error.tsx
deleted file mode 100644
index bf34a76dc..000000000
--- a/src/app/baseball/(dashboard)/dashboard/stats/games/new/error.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-'use client';
-
-import { RouteErrorBoundary } from '@/components/errors';
-
-export default function NewGameError({
- error,
- reset,
-}: {
- error: Error & { digest?: string };
- reset: () => void;
-}) {
- return (
-
- );
-}
diff --git a/src/app/baseball/(dashboard)/dashboard/stats/games/new/loading.tsx b/src/app/baseball/(dashboard)/dashboard/stats/games/new/loading.tsx
deleted file mode 100644
index a87dcd427..000000000
--- a/src/app/baseball/(dashboard)/dashboard/stats/games/new/loading.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Skeleton } from '@/components/ui/skeleton';
-
-/**
- * New game form loading skeleton.
- */
-export default function NewGameLoading() {
- return (
-
-
-
-
-
-
-
- {/* Type row */}
-
- {/* Date */}
-
-
-
-
- {/* Opponent */}
-
-
-
-
- {/* Home/Away */}
-
- {/* Venue */}
-
-
-
-
- {/* Calendar */}
-
- {/* Buttons */}
-
-
-
-
-
-
- );
-}
diff --git a/src/app/baseball/(dashboard)/dashboard/stats/games/new/page.tsx b/src/app/baseball/(dashboard)/dashboard/stats/games/new/page.tsx
deleted file mode 100644
index 652e5603d..000000000
--- a/src/app/baseball/(dashboard)/dashboard/stats/games/new/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import { redirect } from 'next/navigation';
-import { BASEBALL_STATS_GAME_CREATE_PATH } from '@/lib/baseball/stats-route-aliases';
-
-/** Temporary redirect alias — canonical route is /stats/games/create (#378). */
-export default function LegacyNewGameRedirect() {
- redirect(BASEBALL_STATS_GAME_CREATE_PATH);
-}
diff --git a/src/app/baseball/(dashboard)/dashboard/team/high-school/error.tsx b/src/app/baseball/(dashboard)/dashboard/team/high-school/error.tsx
deleted file mode 100644
index dcfb7ac81..000000000
--- a/src/app/baseball/(dashboard)/dashboard/team/high-school/error.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-'use client';
-
-import { RouteErrorBoundary } from '@/components/errors';
-
-export default function Error({
- error,
- reset,
-}: {
- error: Error & { digest?: string };
- reset: () => void;
-}) {
- return (
-
- );
-}
diff --git a/src/app/baseball/(dashboard)/dashboard/team/high-school/page.tsx b/src/app/baseball/(dashboard)/dashboard/team/high-school/page.tsx
deleted file mode 100644
index 4d8e9fcd8..000000000
--- a/src/app/baseball/(dashboard)/dashboard/team/high-school/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function HighSchoolTeamDashboardPage() {
- redirect('/baseball/dashboard/command-center');
-}
diff --git a/src/app/baseball/(dashboard)/layout.tsx b/src/app/baseball/(dashboard)/layout.tsx
index 33d1e879e..d5ff8221e 100644
--- a/src/app/baseball/(dashboard)/layout.tsx
+++ b/src/app/baseball/(dashboard)/layout.tsx
@@ -38,7 +38,7 @@ import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { BaseballShellLayout } from '@/components/baseball/BaseballShellLayout';
import { BaseballFairwayShell } from './BaseballFairwayShell';
-import { PageLoading } from '@/components/ui/loading';
+import { BaseballDashboardBootstrap } from '@/components/baseball/BaseballDashboardBootstrap';
import { useBaseballAuth } from '@/hooks/use-baseball-auth';
import { useBaseballNavContext } from '@/hooks/use-baseball-nav-context';
import { isRedesignEnabled } from '@/lib/redesign/flag';
@@ -87,10 +87,18 @@ function DashboardSessionGuard({ children }: { children: React.ReactNode }) {
// Show page skeleton while loading OR while a redirect is pending
// (navContext null + authorized + settled → redirect in-flight).
if (!bothSettled || (authorized && navContext === null)) {
- return ;
+ return ;
}
- return <>{children}>;
+ const shell = isRedesignEnabled() ? (
+ {children}
+ ) : (
+
+ {children}
+
+ );
+
+ return shell;
}
// ---------------------------------------------------------------------------
@@ -113,13 +121,5 @@ export default function DashboardLayout({
}: {
children: React.ReactNode;
}) {
- return (
-
- {isRedesignEnabled() ? (
- {children}
- ) : (
- {children}
- )}
-
- );
+ return {children} ;
}
diff --git a/src/app/baseball/(player-dashboard)/player/college/page.tsx b/src/app/baseball/(player-dashboard)/player/college/page.tsx
deleted file mode 100644
index b988293d0..000000000
--- a/src/app/baseball/(player-dashboard)/player/college/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function CollegePlayerDashboardPage() {
- redirect('/baseball/player/today');
-}
diff --git a/src/app/baseball/(player-dashboard)/player/high-school/page.tsx b/src/app/baseball/(player-dashboard)/player/high-school/page.tsx
deleted file mode 100644
index 81f2e446f..000000000
--- a/src/app/baseball/(player-dashboard)/player/high-school/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function HighSchoolPlayerDashboardPage() {
- redirect('/baseball/player/today');
-}
diff --git a/src/app/baseball/(player-dashboard)/player/juco/page.tsx b/src/app/baseball/(player-dashboard)/player/juco/page.tsx
deleted file mode 100644
index 1663d2c3e..000000000
--- a/src/app/baseball/(player-dashboard)/player/juco/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function JucoPlayerRedirectPage() {
- redirect('/baseball/player/today');
-}
diff --git a/src/app/baseball/(player-dashboard)/player/layout.tsx b/src/app/baseball/(player-dashboard)/player/layout.tsx
index 86d04193a..9306ef5d9 100644
--- a/src/app/baseball/(player-dashboard)/player/layout.tsx
+++ b/src/app/baseball/(player-dashboard)/player/layout.tsx
@@ -1,8 +1,16 @@
'use client';
+import { BaseballFairwayShell } from '@/app/baseball/(dashboard)/BaseballFairwayShell';
import { BaseballShellLayout } from '@/components/baseball/BaseballShellLayout';
+import { isRedesignEnabled } from '@/lib/redesign/flag';
export default function PlayerDashboardLayout({ children }: { children: React.ReactNode }) {
+ const shell = isRedesignEnabled() ? (
+ {children}
+ ) : (
+ {children}
+ );
+
return (
<>
{/*
@@ -12,7 +20,7 @@ export default function PlayerDashboardLayout({ children }: { children: React.Re
* (start_url: /baseball/player/today) is available for player sessions.
*/}
- {children}
+ {shell}
>
);
}
diff --git a/src/app/baseball/(player-dashboard)/player/passport/page.tsx b/src/app/baseball/(player-dashboard)/player/passport/page.tsx
index 6cb77c887..f39b436c0 100644
--- a/src/app/baseball/(player-dashboard)/player/passport/page.tsx
+++ b/src/app/baseball/(player-dashboard)/player/passport/page.tsx
@@ -21,13 +21,9 @@
// Fairway component's own empty state) rather than redirect-looping or
// fabricating a passport.
//
-// SCOPE: `/baseball/player/*` (the `(player-dashboard)` route group) has no
-// Fairway shell equivalent to `(dashboard)/BaseballFairwayShell.tsx` yet — that
-// shell is explicitly frozen to its own route group this migration (§6). This
-// page therefore carries BOTH the `.fairway-ds` token scope and the
-// `.living-annual` cream override itself (mirroring what the legacy
-// `bg-cream-100` wrapper did), same as the page-local pattern documented in
-// §1 for a surface no ancestor shell already scopes.
+// SCOPE: this page keeps the `.living-annual` cream override locally because it
+// is the full editorial passport surface, while the route-group layout owns the
+// shared Fairway shell frame.
// =============================================================================
import { redirect } from 'next/navigation';
diff --git a/src/app/baseball/(player-dashboard)/player/showcase/page.tsx b/src/app/baseball/(player-dashboard)/player/showcase/page.tsx
deleted file mode 100644
index 8acc0ac90..000000000
--- a/src/app/baseball/(player-dashboard)/player/showcase/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function ShowcasePlayerDashboardPage() {
- redirect('/baseball/player/today');
-}
diff --git a/src/app/baseball/actions/__tests__/complete-player-onboarding.test.ts b/src/app/baseball/actions/__tests__/complete-player-onboarding.test.ts
index 221e5b867..bb67d1471 100644
--- a/src/app/baseball/actions/__tests__/complete-player-onboarding.test.ts
+++ b/src/app/baseball/actions/__tests__/complete-player-onboarding.test.ts
@@ -58,7 +58,7 @@ describe('completePlayerOnboarding', () => {
const res = await completePlayerOnboarding(BASE_INPUT);
expect(res.success).toBe(true);
- expect(res.redirectTo).toBe('/baseball/dashboard');
+ expect(res.redirectTo).toBe('/baseball/player/today');
expect(from).toHaveBeenCalledWith('baseball_players');
const [row, opts] = upsert.mock.calls[0]!;
diff --git a/src/app/baseball/actions/__tests__/demo-access.test.ts b/src/app/baseball/actions/__tests__/demo-access.test.ts
index 6317ed2f1..ea5b97ffb 100644
--- a/src/app/baseball/actions/__tests__/demo-access.test.ts
+++ b/src/app/baseball/actions/__tests__/demo-access.test.ts
@@ -89,7 +89,10 @@ vi.mock('@sentry/nextjs', () => ({
withScope: (fn: (scope: unknown) => unknown) =>
fn({ setTag: vi.fn(), setUser: vi.fn(), addBreadcrumb: vi.fn() }),
}));
-vi.mock('@/lib/server-error-logger', () => ({ logServerException: vi.fn(async () => undefined) }));
+vi.mock('@/lib/server-error-logger', () => ({
+ logServerException: vi.fn(async () => undefined),
+ logServerError: vi.fn(async () => undefined),
+}));
import { enterBaseballDemo } from '@/app/baseball/actions/demo-access';
import { withBaseballAction, BaseballDemoReadOnlyError } from '@/lib/baseball/with-baseball-action';
diff --git a/src/app/baseball/actions/__tests__/route-shell-contract.test.ts b/src/app/baseball/actions/__tests__/route-shell-contract.test.ts
index 642f3c32a..8b3c1fc77 100644
--- a/src/app/baseball/actions/__tests__/route-shell-contract.test.ts
+++ b/src/app/baseball/actions/__tests__/route-shell-contract.test.ts
@@ -42,7 +42,12 @@ import {
DYNAMIC_ROUTE_SAMPLES,
REDIRECT_ALIAS_MANIFEST,
} from '@/test/helpers/baseball-route-inventory';
-import { BASEBALL_NAV_REGISTRY, BASEBALL_MESSAGES_NAV } from '@/lib/baseball/nav-registry';
+import {
+ BASEBALL_NAV_REGISTRY,
+ BASEBALL_MESSAGES_NAV,
+ getVisibleBaseballNav,
+ type BaseballNavContext,
+} from '@/lib/baseball/nav-registry';
import {
COACH_TEAM_TABS,
COACH_STATS_TABS,
@@ -84,23 +89,12 @@ const ADVISORY_ALLOWLIST: readonly AllowlistEntry[] = [
{ bucket: 'orphanRoutes', href: '/baseball/dashboard/players/[id]/scout-packet/preview', reason: 'deep-link print preview reached from the scout packet' },
{ bucket: 'orphanRoutes', href: '/baseball/dashboard/players/[id]/stats', reason: 'deep-link sub-view reached from the player profile' },
{ bucket: 'orphanRoutes', href: '/baseball/dashboard/camps/[id]', reason: 'deep-link detail page reached from the Camps list' },
- { bucket: 'orphanRoutes', href: '/baseball/dashboard/messages/[id]', reason: 'deep-link thread page reached from the Messages inbox' },
{ bucket: 'orphanRoutes', href: '/baseball/dashboard/stats/games/[gameId]', reason: 'deep-link box score reached from the Games list' },
{ bucket: 'orphanRoutes', href: '/baseball/dashboard/stats/games/create', reason: 'sub-action reached from the Games list' },
- { bucket: 'orphanRoutes', href: '/baseball/dashboard/stats/games/new', reason: 'sub-action reached from the Games list' },
// --- orphanRoutes: backward-compatible redirect-only landing stubs. ---
{ bucket: 'orphanRoutes', href: '/baseball/dashboard', reason: 'backward-compatible bookmark dispatcher; server-redirects by role' },
{ bucket: 'orphanRoutes', href: '/baseball/dashboard/stats', reason: 'legacy redirect-only stub -> /dashboard/stats-center' },
- { bucket: 'orphanRoutes', href: '/baseball/dashboard/team/high-school', reason: 'legacy redirect-only stub -> command-center' },
- { bucket: 'orphanRoutes', href: '/baseball/coach/college', reason: 'legacy per-coach-type stub; redirects to command-center' },
- { bucket: 'orphanRoutes', href: '/baseball/coach/high-school', reason: 'legacy per-coach-type stub; redirects to command-center' },
- { bucket: 'orphanRoutes', href: '/baseball/coach/juco', reason: 'legacy per-coach-type stub; redirects to command-center' },
- { bucket: 'orphanRoutes', href: '/baseball/coach/showcase', reason: 'legacy per-coach-type stub; redirects to command-center' },
- { bucket: 'orphanRoutes', href: '/baseball/player/college', reason: 'legacy per-program-type stub; redirects to player today' },
- { bucket: 'orphanRoutes', href: '/baseball/player/high-school', reason: 'legacy per-program-type stub; redirects to player today' },
- { bucket: 'orphanRoutes', href: '/baseball/player/juco', reason: 'legacy per-program-type stub; redirects to player today' },
- { bucket: 'orphanRoutes', href: '/baseball/player/showcase', reason: 'legacy per-program-type stub; redirects to player today' },
// --- orphanRoutes: public marketing / auth / onboarding / join entry
// points — pre-login surfaces that are intentionally outside the
@@ -122,6 +116,7 @@ const ADVISORY_ALLOWLIST: readonly AllowlistEntry[] = [
{ bucket: 'orphanRoutes', href: '/baseball/player/[id]', reason: 'public player share page, no app nav' },
{ bucket: 'orphanRoutes', href: '/baseball/program/[id]', reason: 'public program share page, no app nav' },
{ bucket: 'orphanRoutes', href: '/baseball/team/[id]', reason: 'public team share page, no app nav' },
+ { bucket: 'orphanRoutes', href: '/baseball/admin/demo-sessions', reason: 'platform admin surface outside dashboard nav shell' },
];
function allowlistedHrefs(bucket: GapBucket): Set {
@@ -246,6 +241,14 @@ describe('BaseballHelm route/shell contract (#374)', () => {
);
});
+ it('player nav does not expose the legacy coach dashboard alias as a duplicate Today route', () => {
+ const player: BaseballNavContext = { role: 'player', capabilities: {}, programType: 'college' };
+ const visible = getVisibleBaseballNav(player);
+
+ expect(visible.some((entry) => entry.id === 'team')).toBe(false);
+ expect(visible.filter((entry) => entry.href === '/baseball/player/today').map((entry) => entry.id)).toEqual(['player-today']);
+ });
+
it('guardWithoutNavGating is empty for the known (capability-guarded) set', () => {
expect(result.guardWithoutNavGating).toEqual([]);
});
diff --git a/src/app/baseball/actions/__tests__/save-full-box-score.test.ts b/src/app/baseball/actions/__tests__/save-full-box-score.test.ts
index f63b49950..b2d207d78 100644
--- a/src/app/baseball/actions/__tests__/save-full-box-score.test.ts
+++ b/src/app/baseball/actions/__tests__/save-full-box-score.test.ts
@@ -142,6 +142,37 @@ describe('saveFullBoxScore', () => {
expect(result.error).toBeTruthy();
});
+ it('computes pitching rates from true innings for partial-inning notation', async () => {
+ rpc.mockResolvedValue({ data: { success: true }, error: null });
+
+ const partialInningLine = {
+ ...PITCHING_LINE,
+ ip: 6.1,
+ er: 2,
+ h: 4,
+ bb: 2,
+ k: 7,
+ };
+
+ const result = await saveFullBoxScore('game-1', [], [partialInningLine], 2, 1);
+
+ expect(result.success).toBe(true);
+ expect(rpc).toHaveBeenCalledWith(
+ 'save_baseball_full_box_score',
+ expect.objectContaining({
+ p_pitching: [
+ expect.objectContaining({
+ ip: 6.1,
+ era: 2.84,
+ whip: 0.947,
+ k9: 9.95,
+ bb9: 2.84,
+ }),
+ ],
+ }),
+ );
+ });
+
// #433 — the manual edit form previously opened blank on an existing box
// score, so a re-save could silently drop rows. These cases prove that
// re-supplying the full existing set (as the preloaded form now does) is
diff --git a/src/app/baseball/actions/games.ts b/src/app/baseball/actions/games.ts
index b8d39b4fc..2f9c41835 100644
--- a/src/app/baseball/actions/games.ts
+++ b/src/app/baseball/actions/games.ts
@@ -21,6 +21,7 @@ import {
} from '@/lib/baseball/with-baseball-action';
import { logServerError } from '@/lib/server-error-logger';
import { mapBattingToInput, mapPitchingToInput } from '@/components/baseball/box-score/mappers';
+import { ipToInnings } from '@/lib/baseball/innings';
import {
getStatsCenter,
type StatsCenterOptions,
@@ -44,6 +45,7 @@ const STATS_PATHS = [
'/baseball/dashboard/stats-center',
'/baseball/dashboard/stats/games',
'/baseball/dashboard/stats/season',
+ '/baseball/dashboard/my-stats',
'/baseball/dashboard/calendar',
];
@@ -556,12 +558,14 @@ function computePitchingRates(line: BoxScorePitchingInput): {
} {
const { ip, er, h, bb, k } = line;
if (ip === 0) return { era: null, whip: null, k9: null, bb9: null };
+ const innings = ipToInnings(ip);
+ if (innings === 0) return { era: null, whip: null, k9: null, bb9: null };
return {
- era: parseFloat((9 * er / ip).toFixed(2)),
- whip: parseFloat(((bb + h) / ip).toFixed(3)),
- k9: parseFloat((9 * k / ip).toFixed(2)),
- bb9: parseFloat((9 * bb / ip).toFixed(2)),
+ era: parseFloat((9 * er / innings).toFixed(2)),
+ whip: parseFloat(((bb + h) / innings).toFixed(3)),
+ k9: parseFloat((9 * k / innings).toFixed(2)),
+ bb9: parseFloat((9 * bb / innings).toFixed(2)),
};
}
diff --git a/src/app/baseball/actions/onboarding.ts b/src/app/baseball/actions/onboarding.ts
index 899cbef67..4164a9a35 100644
--- a/src/app/baseball/actions/onboarding.ts
+++ b/src/app/baseball/actions/onboarding.ts
@@ -710,7 +710,7 @@ async function completePlayerOnboardingImpl(
revalidatePath('/baseball');
revalidatePath('/baseball/player');
- return { success: true, redirectTo: '/baseball/dashboard' };
+ return { success: true, redirectTo: '/baseball/player/today' };
}
export const signupAndCompleteCoachOnboarding = withAdminObserved(
diff --git a/src/app/golf/actions/__tests__/coverage-contract.observability.test.ts b/src/app/golf/actions/__tests__/coverage-contract.observability.test.ts
new file mode 100644
index 000000000..cdc72cd80
--- /dev/null
+++ b/src/app/golf/actions/__tests__/coverage-contract.observability.test.ts
@@ -0,0 +1,101 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import ts from 'typescript';
+import { describe, expect, it } from 'vitest';
+
+const GOLF_APP_ROOT = path.join(process.cwd(), 'src/app/golf');
+
+function walk(dir: string): string[] {
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) return walk(fullPath);
+ if (!/\.(ts|tsx)$/.test(entry.name)) return [];
+ return [fullPath];
+ });
+}
+
+function hasUseServerDirective(source: string): boolean {
+ return /^\s*['"]use server['"]/m.test(source);
+}
+
+function isExported(node: ts.Node): boolean {
+ return Boolean(ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export);
+}
+
+function isDefaultExport(node: ts.Node): boolean {
+ return Boolean(ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Default);
+}
+
+function wrappedActionNames(sourceFile: ts.SourceFile): Set {
+ const names = new Set();
+
+ function visit(node: ts.Node): void {
+ if (ts.isVariableStatement(node)) {
+ for (const declaration of node.declarationList.declarations) {
+ if (
+ ts.isIdentifier(declaration.name) &&
+ declaration.initializer &&
+ ts.isCallExpression(declaration.initializer)
+ ) {
+ const callee = declaration.initializer.expression.getText(sourceFile);
+ if (callee === 'withAdminObserved') {
+ names.add(declaration.name.text);
+ }
+ }
+ }
+ }
+ ts.forEachChild(node, visit);
+ }
+
+ visit(sourceFile);
+ return names;
+}
+
+describe('Golf server-action observability coverage', () => {
+ it('wraps direct server-action exports with Helm Bridge error tracking', () => {
+ const gaps: string[] = [];
+
+ for (const file of walk(GOLF_APP_ROOT)) {
+ if (/[\\/]crm-/.test(file)) continue;
+ if (file.endsWith('resend-activity.ts')) continue;
+ const source = fs.readFileSync(file, 'utf8');
+ if (!hasUseServerDirective(source)) continue;
+ if (/[\\/]page\.tsx$|[\\/]layout\.tsx$/.test(file)) continue;
+
+ const sourceFile = ts.createSourceFile(
+ file,
+ source,
+ ts.ScriptTarget.Latest,
+ true,
+ file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
+ );
+ const wrappedNames = wrappedActionNames(sourceFile);
+
+ function visit(node: ts.Node): void {
+ if (
+ ts.isFunctionDeclaration(node) &&
+ node.name &&
+ isExported(node) &&
+ !isDefaultExport(node)
+ ) {
+ const body = node.body?.getText(sourceFile) ?? '';
+ const delegatesToWrapped = [...wrappedNames].some((name) =>
+ new RegExp(`\\b${name}\\s*\\(`).test(body),
+ );
+ const wrapsInline = /\bwithAdminObserved\s*\(/.test(body);
+ const logsInline = /\blogServer(Exception|Error)\s*\(/.test(body);
+
+ if (!delegatesToWrapped && !wrapsInline && !logsInline) {
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
+ gaps.push(`${path.relative(process.cwd(), file)}:${line} ${node.name.text}`);
+ }
+ }
+ ts.forEachChild(node, visit);
+ }
+
+ visit(sourceFile);
+ }
+
+ expect(gaps).toEqual([]);
+ });
+});
diff --git a/src/components/auth/baseball-sign-in-form.tsx b/src/components/auth/baseball-sign-in-form.tsx
index b63737d88..5bba7af45 100644
--- a/src/components/auth/baseball-sign-in-form.tsx
+++ b/src/components/auth/baseball-sign-in-form.tsx
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { loginAction } from '@/app/baseball/actions/auth';
+import { invalidateAuthCache } from '@/hooks/use-baseball-auth';
import { Input } from '@/components/ui/input';
import { AlertCircle } from 'lucide-react';
import { triggerHaptic } from '@/lib/utils/capacitor';
@@ -63,13 +64,9 @@ export function BaseballSignInForm() {
void triggerHaptic('success');
- // CRITICAL: After login, refresh first to ensure the session cookies
- // are recognized by the Next.js router cache before navigating.
+ invalidateAuthCache();
router.refresh();
- // Wait for cookies to propagate and cache to invalidate
- await new Promise(resolve => setTimeout(resolve, 100));
-
// Check for stored returnTo URL (from invite link flow)
const storedReturnTo = sessionStorage.getItem('baseball_login_returnTo');
@@ -85,11 +82,11 @@ export function BaseballSignInForm() {
if (storedReturnTo && !needsOnboarding && isValidReturnTo(storedReturnTo)) {
sessionStorage.removeItem('baseball_login_returnTo');
- router.push(storedReturnTo);
+ router.replace(storedReturnTo);
} else {
// Clear stale returnTo if present — onboarding takes priority
if (storedReturnTo) sessionStorage.removeItem('baseball_login_returnTo');
- router.push(result.redirectTo || '/baseball/dashboard');
+ router.replace(result.redirectTo || '/baseball/dashboard');
}
} catch {
setError('An unexpected error occurred. Please try again.');
diff --git a/src/components/baseball/BaseballDashboardBootstrap.tsx b/src/components/baseball/BaseballDashboardBootstrap.tsx
new file mode 100644
index 000000000..27d9cdb79
--- /dev/null
+++ b/src/components/baseball/BaseballDashboardBootstrap.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { AuthPendingDots } from '@/components/auth/baseball-auth-shell';
+
+/**
+ * Baseball-branded bootstrap state while the dashboard session guard settles
+ * auth + nav context after sign-in. Replaces the generic 4-card page skeleton
+ * so the login → command center transition does not feel like a product switch.
+ */
+export function BaseballDashboardBootstrap({
+ label = 'Opening your command center',
+}: {
+ label?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/baseball/BaseballShellLayout.tsx b/src/components/baseball/BaseballShellLayout.tsx
index b1962ab99..85d937d7c 100644
--- a/src/components/baseball/BaseballShellLayout.tsx
+++ b/src/components/baseball/BaseballShellLayout.tsx
@@ -39,11 +39,14 @@ interface BaseballShellLayoutProps {
* derived from the auth response when this is `null`.
*/
requiredRole: ActiveBaseballRole | null;
+ /** Parent layout already verified auth — skip the second full-page skeleton. */
+ authVerified?: boolean;
}
export function BaseballShellLayout({
children,
requiredRole,
+ authVerified = false,
}: BaseballShellLayoutProps) {
// For the generic (dashboard) group, `requiredRole` is null and the hook
// accepts both roles + returns the real role from the session. For the
@@ -55,7 +58,7 @@ export function BaseballShellLayout({
// empty capability map and fail-closes every gated entry.
const { navContext } = useBaseballNavContext();
- if (loading || !authorized) {
+ if (!authVerified && (loading || !authorized)) {
return ;
}
diff --git a/src/components/baseball/games/GameCard.tsx b/src/components/baseball/games/GameCard.tsx
index de3e396c4..f9ebb6332 100644
--- a/src/components/baseball/games/GameCard.tsx
+++ b/src/components/baseball/games/GameCard.tsx
@@ -6,7 +6,6 @@ import { IconCalendar, IconMapPin, IconTrendingUp } from '@/components/icons';
interface GameCardProps {
game: BaseballGame;
- onDelete?: (gameId: string) => void;
}
function getResultInfo(game: BaseballGame) {
@@ -42,11 +41,15 @@ export function GameCard({ game }: GameCardProps) {
month: 'short',
day: 'numeric',
});
+ const detailHref = `/baseball/dashboard/stats/games/${game.id}`;
+ const opponentLabel = game.opponent_name ? `vs ${game.opponent_name}` : 'TBD Opponent';
return (
-
{/* Left: game info */}
@@ -70,9 +73,7 @@ export function GameCard({ game }: GameCardProps) {
)}
-
- {game.opponent_name ? `vs ${game.opponent_name}` : 'TBD Opponent'}
-
+
{opponentLabel}
@@ -112,20 +113,14 @@ export function GameCard({ game }: GameCardProps) {
{/* Stats links */}
{game.status === 'completed' && (Number(game.batting_count) > 0 || Number(game.pitching_count) > 0) ? (
-
+
Box Score
-
+
) : game.status !== 'cancelled' ? (
-
+
Enter Results
-
+
) : null}
@@ -145,6 +140,6 @@ export function GameCard({ game }: GameCardProps) {
)}
)}
-
+
);
}
diff --git a/src/components/fairway/app-shell/FairwayBottomNav.tsx b/src/components/fairway/app-shell/FairwayBottomNav.tsx
index 64827709d..ac387870f 100644
--- a/src/components/fairway/app-shell/FairwayBottomNav.tsx
+++ b/src/components/fairway/app-shell/FairwayBottomNav.tsx
@@ -73,9 +73,10 @@ export function FairwayBottomNav({
{items.map((item) => {
const active =
- item.active ||
- (item.activeMatch && pathname ? item.activeMatch(pathname) : false) ||
- matchActive(item.href, pathname);
+ item.active ??
+ (item.activeMatch && pathname
+ ? item.activeMatch(pathname)
+ : matchActive(item.href, pathname));
const Icon = item.icon;
return (
diff --git a/src/components/fairway/app-shell/FairwaySidebar.tsx b/src/components/fairway/app-shell/FairwaySidebar.tsx
index 42b0762a9..2e6db2abb 100644
--- a/src/components/fairway/app-shell/FairwaySidebar.tsx
+++ b/src/components/fairway/app-shell/FairwaySidebar.tsx
@@ -349,12 +349,9 @@ export const FairwaySidebar = forwardRef(funct
item={item}
active={
item.active ??
- // `activeMatch` (when present) broadens the match to a route
- // cluster so the global rail stays lit across sibling routes
- // (e.g. CoachHelm AI on alerts/insights/patterns/…). Falls
- // back to the default segment-boundary href match.
- ((item.activeMatch && pathname ? item.activeMatch(pathname) : false) ||
- matchActive(item.href, pathname))
+ (item.activeMatch && pathname
+ ? item.activeMatch(pathname)
+ : matchActive(item.href, pathname))
}
collapsed={isCollapsed}
Link={Link}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index b968d44dc..a0a38f5c9 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -74,7 +74,7 @@ type SidebarHubItem = {
/** Collect every route a hub owns (each tab's href + matchPrefixes). */
function hubPrefixesFrom(
- tabs: readonly { href: string; matchPrefixes?: string[] }[],
+ tabs: readonly { href: string; matchPrefixes?: readonly string[] }[],
): string[] {
return tabs.flatMap((t) => [t.href, ...(t.matchPrefixes ?? [])]);
}
diff --git a/src/components/providers/SessionActivityProvider.tsx b/src/components/providers/SessionActivityProvider.tsx
index ea8a35858..0cfe8de49 100644
--- a/src/components/providers/SessionActivityProvider.tsx
+++ b/src/components/providers/SessionActivityProvider.tsx
@@ -7,7 +7,10 @@ import { useSessionActivity } from '@/lib/auth/session-activity';
*
* Wrap authenticated layouts with this component to enable:
* - Automatic logout after the idle window (SESSION_IDLE_TIMEOUT_MS — 5 minutes)
+ * when the tab is hidden or the user is genuinely away
* - Activity tracking (mouse, keyboard, touch events)
+ * - Visible-tab heartbeat so reading a dashboard without moving the mouse
+ * does not trip the idle window
* - Idle checks every minute + on tab re-show (visibilitychange / pageshow)
*
* Usage:
diff --git a/src/contracts/baseball/stats/pitching-invariants.test.ts b/src/contracts/baseball/stats/pitching-invariants.test.ts
index 5368ebe14..a253fdebe 100644
--- a/src/contracts/baseball/stats/pitching-invariants.test.ts
+++ b/src/contracts/baseball/stats/pitching-invariants.test.ts
@@ -14,14 +14,11 @@
// see also innings.test.ts) before any rate divides by it. This file pins that
// corrected behavior.
//
-// NEEDS-DECISION (documented in
-// docs/operations/BASEBALLHELM_BUSINESS_CONTRACT_MATRIX.md): the box-score
-// SAVE path (`games.ts`'s `computePitchingRates` + CSV `getFloat('innings_pitched')`)
-// is a SEPARATE, still-unconverted code path — it still divides by the raw
-// notation value with no thirds conversion. That gap is unaffected by #434 and
-// is pinned as-is by the second describe block below (games.ts mirrors its own,
-// still-raw formula) — do not "fix" that block to match `ipToInnings`; it is
-// pinning actual, unchanged behavior.
+// The box-score SAVE path (`games.ts`'s `computePitchingRates`, including CSV
+// imports that read `innings_pitched` as the same notation value) must use the
+// same `ipToInnings` conversion before calculating rates. Otherwise a saved
+// game can persist different ERA/WHIP/K9 than the Stats Center read model later
+// derives from the same IP/counting stats.
// =============================================================================
import { readFileSync } from 'node:fs';
@@ -99,14 +96,16 @@ describe('Pitching invariants (#377) — games.ts box-score save mirrors the sam
});
it('computePitchingRates uses the identical ERA/WHIP/K9/BB9 formulas', () => {
- expect(src).toContain('era: parseFloat((9 * er / ip).toFixed(2)),');
- expect(src).toContain('whip: parseFloat(((bb + h) / ip).toFixed(3)),');
- expect(src).toContain('k9: parseFloat((9 * k / ip).toFixed(2)),');
- expect(src).toContain('bb9: parseFloat((9 * bb / ip).toFixed(2)),');
+ expect(src).toContain("import { ipToInnings } from '@/lib/baseball/innings';");
+ expect(src).toContain('const innings = ipToInnings(ip);');
+ expect(src).toContain('era: parseFloat((9 * er / innings).toFixed(2)),');
+ expect(src).toContain('whip: parseFloat(((bb + h) / innings).toFixed(3)),');
+ expect(src).toContain('k9: parseFloat((9 * k / innings).toFixed(2)),');
+ expect(src).toContain('bb9: parseFloat((9 * bb / innings).toFixed(2)),');
});
- it('the CSV parser reads innings_pitched as a raw float with no thirds-notation conversion', () => {
+ it('the CSV parser reads innings_pitched as box-score notation and the shared save helper converts it for rates', () => {
expect(src).toMatch(/ip:\s*getFloat\('innings_pitched'\)/);
- expect(src).not.toMatch(/innings.*thirds|thirds.*innings|normalizeInnings/i);
+ expect(src).toContain('const innings = ipToInnings(ip);');
});
});
diff --git a/src/hooks/__tests__/use-baseball-auth.test.tsx b/src/hooks/__tests__/use-baseball-auth.test.tsx
index 0952caa2d..631d55bf2 100644
--- a/src/hooks/__tests__/use-baseball-auth.test.tsx
+++ b/src/hooks/__tests__/use-baseball-auth.test.tsx
@@ -19,7 +19,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useBaseballAuth } from '../use-baseball-auth';
-const routerPush = vi.fn();
+const routerReplace = vi.fn();
// use-baseball-auth.ts talks to the store only via the static
// `useAuthStore.getState()` accessor (never the React-hook form), so a plain
@@ -29,6 +29,7 @@ const fakeAuthState = {
user: null as unknown,
coach: null as unknown,
player: null as unknown,
+ setUser: vi.fn((user: unknown) => { fakeAuthState.user = user; }),
setCoach: vi.fn((coach: unknown) => { fakeAuthState.coach = coach; }),
setPlayer: vi.fn((player: unknown) => { fakeAuthState.player = player; }),
clear: vi.fn(() => {
@@ -44,8 +45,8 @@ vi.mock('@/stores/auth-store', () => ({
vi.mock('next/navigation', () => ({
useRouter: () => ({
- push: routerPush,
- replace: vi.fn(),
+ push: vi.fn(),
+ replace: routerReplace,
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
@@ -124,6 +125,6 @@ describe('useBaseballAuth', () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.authorized).toBe(false);
- expect(routerPush).toHaveBeenCalledWith('/baseball/login');
+ expect(routerReplace).toHaveBeenCalledWith('/baseball/login');
});
});
diff --git a/src/hooks/use-baseball-auth.ts b/src/hooks/use-baseball-auth.ts
index 45193f555..129f638f2 100644
--- a/src/hooks/use-baseball-auth.ts
+++ b/src/hooks/use-baseball-auth.ts
@@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { useAuthStore } from '@/stores/auth-store';
+import type { User } from '@/lib/types';
+
type Role = 'coach' | 'player';
type BaseballProfile = {
@@ -21,7 +23,7 @@ type AuthResult = {
};
type VerifyResult =
- | { ok: true; role: Role; coachProfile: BaseballProfile | null; playerProfile: BaseballProfile | null }
+ | { ok: true; role: Role; coachProfile: BaseballProfile | null; playerProfile: BaseballProfile | null; user: User | null }
| { ok: false; redirectTo: string };
// ---------------------------------------------------------------------------
@@ -56,6 +58,15 @@ function invalidateAuthCache() {
inflightByKey.clear();
}
+export { invalidateAuthCache };
+
+function peekCachedAuthResult(requiredRole: Role | null): VerifyResult | null {
+ const cached = resultCache.get(CACHE_KEY(requiredRole));
+ if (!cached) return null;
+ if (Date.now() - cached.resolvedAt >= CACHE_TTL_MS) return null;
+ return cached.result;
+}
+
function verifyServerSession(
supabase: ReturnType,
requiredRole: Role | null,
@@ -69,12 +80,13 @@ function verifyServerSession(
}
const [userResult, coachResult, playerResult] = await Promise.all([
- supabase.from('users').select('role').eq('id', user.id).maybeSingle(),
+ supabase.from('users').select('*').eq('id', user.id).maybeSingle(),
supabase.from('baseball_coaches').select('id, onboarding_completed, coach_type').eq('user_id', user.id).maybeSingle(),
supabase.from('baseball_players').select('id, onboarding_completed, player_type').eq('user_id', user.id).maybeSingle(),
]);
- const userRole = userResult.data?.role;
+ const userRecord = userResult.data as User | null;
+ const userRole = userRecord?.role;
const coachProfile = coachResult.data as BaseballProfile | null;
const playerProfile = playerResult.data as BaseballProfile | null;
@@ -110,7 +122,13 @@ function verifyServerSession(
}
}
- return { ok: true, role: resolvedRole as Role, coachProfile, playerProfile } as const;
+ return {
+ ok: true,
+ role: resolvedRole as Role,
+ coachProfile,
+ playerProfile,
+ user: userRecord,
+ } as const;
} catch (error) {
// Any unexpected throw (network blip, aborted fetch, SDK error) must
// never strand `loading` at true forever — treat it the same as "could
@@ -164,15 +182,17 @@ function resolveWithCache(
export function useBaseballAuth(requiredRole: Role | null = null): AuthResult {
const router = useRouter();
const supabaseRef = useRef(createClient());
+ const cached = peekCachedAuthResult(requiredRole);
- const [loading, setLoading] = useState(true);
- const [authorized, setAuthorized] = useState(false);
- const [role, setRole] = useState(null);
+ const [loading, setLoading] = useState(() => cached?.ok !== true);
+ const [authorized, setAuthorized] = useState(() => cached?.ok === true);
+ const [role, setRole] = useState(() => (cached?.ok ? cached.role : null));
useEffect(() => {
let cancelled = false;
function reconcileStore(
+ user: User | null,
coachProfile: BaseballProfile | null,
playerProfile: BaseballProfile | null,
) {
@@ -186,6 +206,10 @@ export function useBaseballAuth(requiredRole: Role | null = null): AuthResult {
store.clear();
}
+ if (user) {
+ store.setUser(user);
+ }
+
if (coachProfile) {
store.setCoach({
...(store.coach ?? {}),
@@ -209,7 +233,20 @@ export function useBaseballAuth(requiredRole: Role | null = null): AuthResult {
}
}
+ function applySuccess(result: Extract) {
+ reconcileStore(result.user, result.coachProfile, result.playerProfile);
+ setRole(result.role);
+ setAuthorized(true);
+ setLoading(false);
+ }
+
async function run() {
+ const warm = peekCachedAuthResult(requiredRole);
+ if (warm?.ok) {
+ applySuccess(warm);
+ return;
+ }
+
setLoading(true);
try {
const result = await resolveWithCache(supabaseRef.current, requiredRole);
@@ -218,17 +255,12 @@ export function useBaseballAuth(requiredRole: Role | null = null): AuthResult {
if (!result.ok) {
useAuthStore.getState().clear();
invalidateAuthCache();
- router.push(result.redirectTo);
+ router.replace(result.redirectTo);
return;
}
- reconcileStore(result.coachProfile, result.playerProfile);
- setRole(result.role);
- setAuthorized(true);
+ applySuccess(result);
} finally {
- // ALWAYS resolve loading, even on an unexpected error above — a
- // stranded `true` here is exactly the eternal-skeleton bug this hook
- // exists to avoid.
if (!cancelled) setLoading(false);
}
}
diff --git a/src/hooks/use-baseball-nav-context.ts b/src/hooks/use-baseball-nav-context.ts
index 303723e13..65ee8b3ef 100644
--- a/src/hooks/use-baseball-nav-context.ts
+++ b/src/hooks/use-baseball-nav-context.ts
@@ -48,15 +48,37 @@ interface NavContextResult {
// Module-scope cache so dashboard route changes don't re-resolve capabilities.
// Keyed by auth user id so a different signed-in user never reads stale caps.
+const NAV_CACHE_TTL_MS = 5000;
let cacheUserId: string | null = null;
let cachedContext: BaseballNavContext | null = null;
+let cacheResolvedAt = 0;
let inflight: Promise | null = null;
+function clearNavContextCache() {
+ cacheUserId = null;
+ cachedContext = null;
+ cacheResolvedAt = 0;
+ inflight = null;
+}
+
+function hasWarmNavCache(userId: string | null): boolean {
+ return (
+ userId !== null &&
+ userId === cacheUserId &&
+ cachedContext !== null &&
+ Date.now() - cacheResolvedAt < NAV_CACHE_TTL_MS
+ );
+}
+
function resolveForUser(userId: string | null): Promise {
- // Same user + already resolved → serve from cache.
- if (userId === cacheUserId && cachedContext !== null) {
+ if (hasWarmNavCache(userId)) {
return Promise.resolve(cachedContext);
}
+
+ if (userId === null) {
+ return Promise.resolve(null);
+ }
+
// Same user + a resolve already in flight → share it (dedupe concurrent mounts).
if (userId === cacheUserId && inflight) {
return inflight;
@@ -65,10 +87,14 @@ function resolveForUser(userId: string | null): Promise {
// Only commit if the user hasn't changed underneath us.
- if (cacheUserId === userId) cachedContext = ctx;
+ if (cacheUserId === userId) {
+ cachedContext = ctx;
+ cacheResolvedAt = Date.now();
+ }
return ctx;
})
.catch(() => null)
@@ -89,17 +115,24 @@ export function useBaseballNavContext(): NavContextResult {
const userId = useAuthStore((s) => s.user?.id ?? null);
const [navContext, setNavContext] = useState(() =>
- userId === cacheUserId ? cachedContext : null,
- );
- const [loading, setLoading] = useState(
- () => !(userId === cacheUserId && cachedContext !== null),
+ hasWarmNavCache(userId) ? cachedContext : null,
);
+ const [loading, setLoading] = useState(() => !hasWarmNavCache(userId));
useEffect(() => {
let alive = true;
- // Serve cache synchronously when available (no spinner on warm navigations).
- if (userId === cacheUserId && cachedContext !== null) {
+ if (userId === null && cacheUserId !== null) {
+ clearNavContextCache();
+ }
+
+ if (userId === null) {
+ setNavContext(null);
+ setLoading(true);
+ return;
+ }
+
+ if (hasWarmNavCache(userId)) {
setNavContext(cachedContext);
setLoading(false);
return;
diff --git a/src/lib/__tests__/server-error-logger-bridge.test.ts b/src/lib/__tests__/server-error-logger-bridge.test.ts
index 2fea9c52d..61517ede5 100644
--- a/src/lib/__tests__/server-error-logger-bridge.test.ts
+++ b/src/lib/__tests__/server-error-logger-bridge.test.ts
@@ -62,6 +62,17 @@ describe('server-error-logger bridge columns', () => {
expect(rows[0]!.row.fingerprint).toEqual(rows[1]!.row.fingerprint);
});
+ it('infers baseball sport from legacy emitters that only mention Baseball in the message', async () => {
+ await logServerError('Baseball document action error: permission denied', {
+ action: 'documents.handleError',
+ });
+ const adminEvent = mocks.inserts.find((i) => i.table === 'admin_events');
+ expect(adminEvent?.row).toMatchObject({
+ sport: 'baseball',
+ feature: 'baseball_documents',
+ });
+ });
+
it('stays backward-compatible: legacy context without new fields still writes', async () => {
await logServerError('legacy', { action: 'legacy.caller' });
const adminEvent = mocks.inserts.find((i) => i.table === 'admin_events');
diff --git a/src/lib/admin/__tests__/ben-leah-issues.test.ts b/src/lib/admin/__tests__/ben-leah-issues.test.ts
new file mode 100644
index 000000000..811a5b0a1
--- /dev/null
+++ b/src/lib/admin/__tests__/ben-leah-issues.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from 'vitest';
+import {
+ deriveBenLeahTrackStatus,
+ latestProductionReadyAt,
+ applyWorkflowSelection,
+ currentWorkflowSelection,
+ workflowLabelForSelection,
+ type BenLeahGitHubIssue,
+} from '@/lib/admin/ben-leah-issue-tracker';
+import type { VercelDeployment } from '@/lib/admin/vercel-api';
+
+function issue(overrides: Partial = {}): BenLeahGitHubIssue {
+ return {
+ number: 1,
+ html_url: 'https://github.com/o/r/issues/1',
+ title: '[Ben + Leah] Bug: Example',
+ displayTitle: 'Example',
+ state: 'open',
+ created_at: '2026-07-01T12:00:00Z',
+ updated_at: '2026-07-01T12:00:00Z',
+ closed_at: null,
+ labels: ['ben-leah'],
+ kind: 'bug',
+ priority: 'normal',
+ category: 'GolfHelm',
+ ...overrides,
+ };
+}
+
+describe('deriveBenLeahTrackStatus', () => {
+ it('marks open issues without workflow labels as open', () => {
+ expect(deriveBenLeahTrackStatus(issue(), null)).toBe('open');
+ });
+
+ it('marks status:in-progress as in progress while open', () => {
+ expect(
+ deriveBenLeahTrackStatus(issue({ labels: ['ben-leah', 'status:in-progress'] }), null),
+ ).toBe('in_progress');
+ });
+
+ it('marks status:wontfix regardless of state', () => {
+ expect(
+ deriveBenLeahTrackStatus(issue({ state: 'closed', labels: ['status:wontfix'] }), null),
+ ).toBe('wont_fix');
+ });
+
+ it('marks closed issues shipped when production deploy is newer than close', () => {
+ const closed = issue({
+ state: 'closed',
+ closed_at: '2026-07-04T12:00:00.000Z',
+ });
+ const prodReady = Date.parse('2026-07-04T13:00:00.000Z');
+ expect(deriveBenLeahTrackStatus(closed, prodReady)).toBe('in_production');
+ });
+
+ it('marks closed issues pending deploy when production is older than close', () => {
+ const closed = issue({
+ state: 'closed',
+ closed_at: '2026-07-04T14:00:00.000Z',
+ });
+ const prodReady = Date.parse('2026-07-04T12:00:00.000Z');
+ expect(deriveBenLeahTrackStatus(closed, prodReady)).toBe('fixed_pending_deploy');
+ });
+
+ it('honors explicit status:in-production label', () => {
+ expect(
+ deriveBenLeahTrackStatus(issue({ labels: ['status:in-production'] }), null),
+ ).toBe('in_production');
+ });
+});
+
+describe('workflow labels', () => {
+ it('maps selection to GitHub label names', () => {
+ expect(workflowLabelForSelection('in_progress')).toBe('status:in-progress');
+ expect(workflowLabelForSelection('in_production')).toBe('status:in-production');
+ });
+
+ it('replaces workflow labels while keeping metadata tags', () => {
+ const next = applyWorkflowSelection(
+ ['ben-leah', 'kind:bug', 'status:triaged', 'priority:high'],
+ 'in_progress',
+ );
+ expect(next).toEqual(['ben-leah', 'kind:bug', 'priority:high', 'status:in-progress']);
+ });
+
+ it('reads the active workflow selection from labels', () => {
+ expect(currentWorkflowSelection(['status:in-production', 'ben-leah'])).toBe('in_production');
+ expect(currentWorkflowSelection(['ben-leah'])).toBeNull();
+ });
+});
+
+describe('latestProductionReadyAt', () => {
+ it('returns the first READY production deployment', () => {
+ const deployments: VercelDeployment[] = [
+ {
+ uid: 'preview',
+ state: 'READY',
+ createdAt: 1,
+ ready: 2,
+ target: null,
+ url: 'preview.vercel.app',
+ commitSha: 'abc',
+ commitMessage: null,
+ commitRef: null,
+ commitAuthor: null,
+ },
+ {
+ uid: 'prod',
+ state: 'READY',
+ createdAt: 3,
+ ready: 4_000,
+ target: 'production',
+ url: 'helmsportslabs.com',
+ commitSha: 'def4567890',
+ commitMessage: 'fix',
+ commitRef: 'main',
+ commitAuthor: 'nick',
+ },
+ ];
+ expect(latestProductionReadyAt(deployments)).toEqual({
+ readyAt: 4_000,
+ commitSha: 'def4567890',
+ });
+ });
+});
diff --git a/src/lib/admin/__tests__/observe-action-result.test.ts b/src/lib/admin/__tests__/observe-action-result.test.ts
new file mode 100644
index 000000000..80669eca7
--- /dev/null
+++ b/src/lib/admin/__tests__/observe-action-result.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ logServerError: vi.fn(async () => {}),
+}));
+
+vi.mock('@/lib/server-error-logger', () => ({
+ logServerError: mocks.logServerError,
+}));
+
+import {
+ extractActionSoftFailure,
+ isExpectedSoftFailureMessage,
+ observeActionSoftFailure,
+} from '@/lib/admin/observe-action-result';
+import { __resetEmitThrottleForTests } from '@/lib/admin/emit-throttle';
+
+describe('observe-action-result', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ __resetEmitThrottleForTests();
+ });
+
+ afterEach(() => {
+ __resetEmitThrottleForTests();
+ });
+
+ it('extracts { success: false, error } envelopes', () => {
+ expect(extractActionSoftFailure({ success: false, error: 'DB blew up' })).toEqual({
+ message: 'DB blew up',
+ code: null,
+ });
+ });
+
+ it('extracts { data: null, error } envelopes', () => {
+ expect(extractActionSoftFailure({ data: null, error: 'missing row' })).toEqual({
+ message: 'missing row',
+ code: null,
+ });
+ });
+
+ it('classifies auth-ish copy as expected soft failures', () => {
+ expect(isExpectedSoftFailureMessage('Not authenticated')).toBe(true);
+ expect(isExpectedSoftFailureMessage('Could not complete the calendar action. Please try again.')).toBe(false);
+ });
+
+ it('logs unexpected soft failures at error severity', () => {
+ observeActionSoftFailure(
+ { success: false, error: 'Could not save document' },
+ { action: 'uploadBaseballDocument', sport: 'baseball', feature: 'baseball_documents', source: 'server_action' },
+ );
+
+ expect(mocks.logServerError).toHaveBeenCalledTimes(1);
+ const errorCall = mocks.logServerError.mock.calls[0] as
+ | [string, Record | undefined, 'warning' | 'error' | 'critical']
+ | undefined;
+ expect(errorCall?.[2]).toBe('error');
+ });
+
+ it('logs expected soft failures as warnings with skipSentry', () => {
+ observeActionSoftFailure(
+ { success: false, error: 'Not authenticated' },
+ { action: 'createBaseballEvent', sport: 'baseball', source: 'server_action' },
+ );
+
+ expect(mocks.logServerError).toHaveBeenCalledTimes(1);
+ const warningCall = mocks.logServerError.mock.calls[0] as
+ | [string, Record | undefined, 'warning' | 'error' | 'critical']
+ | undefined;
+ expect(warningCall?.[2]).toBe('warning');
+ expect(warningCall?.[1]).toMatchObject({ skipSentry: true });
+ });
+
+ it('ignores successful results', () => {
+ observeActionSoftFailure(
+ { success: true },
+ { action: 'noop', source: 'server_action' },
+ );
+ expect(mocks.logServerError).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/admin/__tests__/pr-body-parser.test.ts b/src/lib/admin/__tests__/pr-body-parser.test.ts
new file mode 100644
index 000000000..6035c2296
--- /dev/null
+++ b/src/lib/admin/__tests__/pr-body-parser.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest';
+import { inferWorkAreaFromTitle, parsePullRequestBody } from '@/lib/admin/pr-body-parser';
+
+const SAMPLE_BODY = `## Summary
+
+Fix calendar timezone drift on GolfHelm events.
+
+## Partner-readable summary
+
+Coaches saw practice blocks on the wrong day after DST. What changed: events now store team timezone and render in local wall time. Why it matters: demo reliability for spring onboarding.
+
+## Type of change
+
+- [x] Bug fix
+- [ ] Feature / new behavior
+
+## Area
+
+golf
+
+## Git Activity Timeline note
+
+Calendar events stay on the correct day after timezone changes.
+
+## Checklist
+
+- [x] Tests pass
+`;
+
+describe('parsePullRequestBody', () => {
+ it('extracts template sections into problem / fix / area', () => {
+ const parsed = parsePullRequestBody(SAMPLE_BODY, 'fix(golf): calendar timezone');
+ expect(parsed.area).toBe('golf');
+ expect(parsed.summary).toContain('timezone drift');
+ expect(parsed.problem).toContain('wrong day');
+ expect(parsed.fix).toContain('local wall time');
+ expect(parsed.timelineNote).toContain('correct day');
+ expect(parsed.changeTypes).toEqual(['Bug fix']);
+ });
+
+ it('falls back to title inference when Area is missing', () => {
+ const parsed = parsePullRequestBody('## Summary\n\nPipeline card drag fix.', 'fix(baseball): pipeline stage drag');
+ expect(parsed.area).toBe('baseball');
+ });
+
+ it('uses summary when partner block is absent', () => {
+ const parsed = parsePullRequestBody('## Summary\n\nHarden admin Sentry read API.', 'fix(admin): sentry errors tab');
+ expect(parsed.area).toBe('bridge');
+ expect(parsed.problem).toContain('Sentry');
+ });
+});
+
+describe('inferWorkAreaFromTitle', () => {
+ it('detects coachhelm from title keywords', () => {
+ expect(inferWorkAreaFromTitle('feat(coachhelm): insight lifecycle')).toBe('coachhelm');
+ });
+});
diff --git a/src/lib/admin/ben-leah-issue-tracker.ts b/src/lib/admin/ben-leah-issue-tracker.ts
new file mode 100644
index 000000000..dddc2faae
--- /dev/null
+++ b/src/lib/admin/ben-leah-issue-tracker.ts
@@ -0,0 +1,198 @@
+/**
+ * Pure Ben + Leah issue lifecycle helpers — safe to import from tests and
+ * server fetchers alike (no secrets, no server-only).
+ */
+
+import type { VercelDeployment } from '@/lib/admin/vercel-api';
+
+/** Workflow stage shown in Helm Bridge — derived from GitHub state, labels, and prod deploy timing. */
+export type BenLeahTrackStatus =
+ | 'open'
+ | 'in_progress'
+ | 'in_production'
+ | 'fixed_pending_deploy'
+ | 'fixed'
+ | 'wont_fix';
+
+export interface BenLeahGitHubIssue {
+ number: number;
+ html_url: string;
+ title: string;
+ displayTitle: string;
+ state: 'open' | 'closed';
+ created_at: string;
+ updated_at: string;
+ closed_at: string | null;
+ labels: string[];
+ kind: 'bug' | 'change' | 'addition' | null;
+ priority: 'normal' | 'high' | 'urgent' | null;
+ category: string | null;
+}
+
+export interface BenLeahTrackedIssue extends BenLeahGitHubIssue {
+ trackStatus: BenLeahTrackStatus;
+}
+
+/** GitHub workflow labels Helm Bridge reads and writes. */
+export const BEN_LEAH_WORKFLOW_LABELS = [
+ 'status:triaged',
+ 'status:in-progress',
+ 'status:in-production',
+ 'status:wontfix',
+] as const;
+
+export type BenLeahWorkflowLabel = (typeof BEN_LEAH_WORKFLOW_LABELS)[number];
+
+/** Initial workflow label applied to every new Ben + Leah submission. */
+export const BEN_LEAH_INITIAL_WORKFLOW_LABEL: BenLeahWorkflowLabel = 'status:triaged';
+
+/** Values the Bridge UI can set on an issue (maps 1:1 to workflow labels). */
+export type BenLeahWorkflowSelection =
+ | 'triaged'
+ | 'in_progress'
+ | 'in_production'
+ | 'wont_fix';
+
+export const BEN_LEAH_WORKFLOW_LABEL_DEFS: ReadonlyArray<{
+ name: BenLeahWorkflowLabel;
+ color: string;
+ description: string;
+}> = [
+ {
+ name: 'status:triaged',
+ color: '0E8A16',
+ description: 'Ben + Leah: submitted, awaiting or in triage',
+ },
+ {
+ name: 'status:in-progress',
+ color: 'FBCA04',
+ description: 'Ben + Leah: work started',
+ },
+ {
+ name: 'status:in-production',
+ color: '1D76DB',
+ description: 'Ben + Leah: manually confirmed live in production',
+ },
+ {
+ name: 'status:wontfix',
+ color: '888888',
+ description: 'Ben + Leah: will not ship',
+ },
+];
+
+const WORKFLOW_SELECTION_TO_LABEL: Record = {
+ triaged: 'status:triaged',
+ in_progress: 'status:in-progress',
+ in_production: 'status:in-production',
+ wont_fix: 'status:wontfix',
+};
+
+export function workflowLabelForSelection(selection: BenLeahWorkflowSelection): BenLeahWorkflowLabel {
+ return WORKFLOW_SELECTION_TO_LABEL[selection];
+}
+
+export function isBenLeahWorkflowLabel(label: string): label is BenLeahWorkflowLabel {
+ return (BEN_LEAH_WORKFLOW_LABELS as readonly string[]).includes(label);
+}
+
+/** Current workflow label on the issue, if any. */
+export function currentWorkflowSelection(labels: string[]): BenLeahWorkflowSelection | null {
+ if (labels.includes('status:wontfix')) return 'wont_fix';
+ if (labels.includes('status:in-production')) return 'in_production';
+ if (labels.includes('status:in-progress')) return 'in_progress';
+ if (labels.includes('status:triaged')) return 'triaged';
+ return null;
+}
+
+/** Replace workflow labels while preserving ben-leah / kind / priority / category tags. */
+export function applyWorkflowSelection(
+ labels: string[],
+ selection: BenLeahWorkflowSelection,
+): string[] {
+ const preserved = labels.filter((label) => !isBenLeahWorkflowLabel(label));
+ return [...preserved, workflowLabelForSelection(selection)];
+}
+
+export function stripWorkflowLabels(labels: string[]): string[] {
+ return labels.filter((label) => !isBenLeahWorkflowLabel(label));
+}
+
+/** Latest READY production deployment timestamp (epoch ms), if Vercel data is available. */
+export function latestProductionReadyAt(deployments: VercelDeployment[]): {
+ readyAt: number | null;
+ commitSha: string | null;
+} {
+ const prod = deployments.find(
+ (deployment) =>
+ deployment.state === 'READY' &&
+ deployment.target === 'production' &&
+ deployment.ready != null,
+ );
+ if (!prod?.ready) return { readyAt: null, commitSha: null };
+ return { readyAt: prod.ready, commitSha: prod.commitSha };
+}
+
+/**
+ * Derive Helm Bridge lifecycle from GitHub metadata + optional production deploy time.
+ *
+ * Label conventions (optional on the GitHub issue):
+ * - `status:in-progress` / `status:triaged` → In progress
+ * - `status:in-production` → In production (manual confirmation)
+ * - `status:wontfix` → Won't fix
+ *
+ * When an issue is closed, a production deploy whose `ready` time is at or after
+ * `closed_at` is treated as "In production".
+ */
+export function deriveBenLeahTrackStatus(
+ issue: BenLeahGitHubIssue,
+ productionReadyAt: number | null,
+): BenLeahTrackStatus {
+ const labels = issue.labels;
+
+ if (labels.includes('status:wontfix')) return 'wont_fix';
+ if (labels.includes('status:in-production')) return 'in_production';
+
+ if (issue.state === 'open') {
+ if (labels.includes('status:in-progress') || labels.includes('status:triaged')) {
+ return 'in_progress';
+ }
+ return 'open';
+ }
+
+ const closedAt = issue.closed_at ? Date.parse(issue.closed_at) : null;
+ if (closedAt != null && productionReadyAt != null && productionReadyAt >= closedAt) {
+ return 'in_production';
+ }
+ if (closedAt != null && productionReadyAt != null && productionReadyAt < closedAt) {
+ return 'fixed_pending_deploy';
+ }
+
+ return 'fixed';
+}
+
+export function emptyBenLeahStatusCounts(): Record {
+ return {
+ open: 0,
+ in_progress: 0,
+ in_production: 0,
+ fixed_pending_deploy: 0,
+ fixed: 0,
+ wont_fix: 0,
+ };
+}
+
+export function countBenLeahByStatus(issues: BenLeahTrackedIssue[]): Record {
+ const counts = emptyBenLeahStatusCounts();
+ for (const issue of issues) counts[issue.trackStatus] += 1;
+ return counts;
+}
+
+export function withBenLeahTrackStatus(
+ issues: BenLeahGitHubIssue[],
+ productionReadyAt: number | null,
+): BenLeahTrackedIssue[] {
+ return issues.map((issue) => ({
+ ...issue,
+ trackStatus: deriveBenLeahTrackStatus(issue, productionReadyAt),
+ }));
+}
diff --git a/src/lib/admin/ben-leah-issues.ts b/src/lib/admin/ben-leah-issues.ts
new file mode 100644
index 000000000..5684e5b0c
--- /dev/null
+++ b/src/lib/admin/ben-leah-issues.ts
@@ -0,0 +1,168 @@
+import 'server-only';
+import {
+ failed,
+ ok,
+ unconfigured,
+ type AdminFetchResult,
+} from '@/lib/admin/fetch-result';
+import {
+ countBenLeahByStatus,
+ withBenLeahTrackStatus,
+ latestProductionReadyAt,
+ type BenLeahGitHubIssue,
+ type BenLeahTrackedIssue,
+ type BenLeahTrackStatus,
+} from '@/lib/admin/ben-leah-issue-tracker';
+import { githubIssuesHeaders, githubIssuesRepo, githubIssuesToken } from '@/lib/admin/github-issues-config';
+import { ensureBenLeahGitHubLabels } from '@/lib/admin/github-issues-workflow';
+import { fetchVercelDeployments } from '@/lib/admin/vercel-api';
+
+export type {
+ BenLeahGitHubIssue,
+ BenLeahTrackedIssue,
+ BenLeahTrackStatus,
+} from '@/lib/admin/ben-leah-issue-tracker';
+export {
+ deriveBenLeahTrackStatus,
+ latestProductionReadyAt,
+} from '@/lib/admin/ben-leah-issue-tracker';
+
+export interface BenLeahIssueBoardSnapshot {
+ issues: BenLeahTrackedIssue[];
+ repoLabel: string;
+ repoIssuesUrl: string;
+ productionReadyAt: number | null;
+ productionCommitSha: string | null;
+ counts: Record;
+}
+
+function issueToken(): string | null {
+ return githubIssuesToken();
+}
+
+function issueRepo(): { owner: string; repo: string; label: string } {
+ return githubIssuesRepo();
+}
+
+function githubHeaders(token: string): HeadersInit {
+ return githubIssuesHeaders(token);
+}
+
+interface RawGitHubIssue {
+ number: number;
+ html_url: string;
+ title: string;
+ state: 'open' | 'closed';
+ created_at: string;
+ updated_at: string;
+ closed_at: string | null;
+ labels: Array<{ name: string }>;
+ pull_request?: unknown;
+}
+
+function parseLabelValue(labels: string[], prefix: string): string | null {
+ const hit = labels.find((label) => label.startsWith(`${prefix}:`));
+ return hit ? hit.slice(prefix.length + 1) : null;
+}
+
+function displayTitle(raw: string): string {
+ return raw.replace(/^\[Ben \+ Leah\]\s*(Bug|Change|Addition):\s*/i, '').trim() || raw;
+}
+
+function normalizeIssue(raw: RawGitHubIssue): BenLeahGitHubIssue {
+ const labels = raw.labels.map((label) => label.name);
+ const kindRaw = parseLabelValue(labels, 'kind');
+ const priorityRaw = parseLabelValue(labels, 'priority');
+ return {
+ number: raw.number,
+ html_url: raw.html_url,
+ title: raw.title,
+ displayTitle: displayTitle(raw.title),
+ state: raw.state,
+ created_at: raw.created_at,
+ updated_at: raw.updated_at,
+ closed_at: raw.closed_at,
+ labels,
+ kind: kindRaw === 'bug' || kindRaw === 'change' || kindRaw === 'addition' ? kindRaw : null,
+ priority: priorityRaw === 'high' || priorityRaw === 'urgent' ? priorityRaw : priorityRaw === 'normal' ? 'normal' : null,
+ category: parseLabelValue(labels, 'category'),
+ };
+}
+
+async function fetchRawIssues(token: string, owner: string, repo: string): Promise {
+ const params = new URLSearchParams({
+ labels: 'ben-leah',
+ state: 'all',
+ sort: 'updated',
+ direction: 'desc',
+ per_page: '50',
+ });
+
+ const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues?${params}`, {
+ headers: githubHeaders(token),
+ next: { revalidate: 60 },
+ });
+
+ if (res.ok) {
+ const body = (await res.json()) as RawGitHubIssue[];
+ return body.filter((issue) => !issue.pull_request);
+ }
+
+ if (res.status !== 404 && res.status !== 422) {
+ const text = await res.text();
+ throw new Error(`GitHub issues list failed (${res.status}): ${text.slice(0, 300)}`);
+ }
+
+ // Label may not exist yet — fall back to title prefix (label-less retry path).
+ const searchParams = new URLSearchParams({
+ q: `repo:${owner}/${repo} is:issue "[Ben + Leah]" in:title`,
+ sort: 'updated',
+ order: 'desc',
+ per_page: '50',
+ });
+ const searchRes = await fetch(`https://api.github.com/search/issues?${searchParams}`, {
+ headers: githubHeaders(token),
+ next: { revalidate: 60 },
+ });
+ if (!searchRes.ok) {
+ const text = await searchRes.text();
+ throw new Error(`GitHub issue search failed (${searchRes.status}): ${text.slice(0, 300)}`);
+ }
+ const searchBody = (await searchRes.json()) as { items?: RawGitHubIssue[] };
+ return (searchBody.items ?? []).filter((issue) => !issue.pull_request);
+}
+
+export async function fetchBenLeahIssueBoard(): Promise> {
+ const token = issueToken();
+ if (!token) return unconfigured('GitHub issues API');
+
+ const { owner, repo, label } = issueRepo();
+
+ try {
+ await ensureBenLeahGitHubLabels();
+
+ const [rawIssues, deploys] = await Promise.all([
+ fetchRawIssues(token, owner, repo),
+ fetchVercelDeployments(30),
+ ]);
+
+ const production =
+ deploys.status === 'ok' && deploys.data
+ ? latestProductionReadyAt(deploys.data)
+ : { readyAt: null, commitSha: null };
+
+ const normalized = rawIssues.map(normalizeIssue);
+ const tracked = withBenLeahTrackStatus(normalized, production.readyAt);
+
+ return ok({
+ issues: tracked,
+ repoLabel: label,
+ repoIssuesUrl: `https://github.com/${owner}/${repo}/issues?q=label%3Aben-leah`,
+ productionReadyAt: production.readyAt,
+ productionCommitSha: production.commitSha,
+ counts: countBenLeahByStatus(tracked),
+ });
+ } catch (err) {
+ return failed(err instanceof Error ? err.message : String(err));
+ }
+}
diff --git a/src/lib/admin/data/__tests__/incident-feed.test.ts b/src/lib/admin/data/__tests__/incident-feed.test.ts
new file mode 100644
index 000000000..09eb66a59
--- /dev/null
+++ b/src/lib/admin/data/__tests__/incident-feed.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect } from 'vitest';
+import {
+ buildIncidentFeedFromSources,
+ filterSentryIssuesByWindow,
+ summarizeIncidentFeed,
+} from '@/lib/admin/data/incident-feed';
+import type { AppTriageEventRow } from '@/lib/admin/data/triage';
+import type { SentryIssue } from '@/lib/admin/sentry-api';
+
+const appEvent = (over: Partial): AppTriageEventRow => ({
+ id: 'e1',
+ title: 'save failed',
+ message: 'insert failed',
+ severity: 'error',
+ sport: 'baseball',
+ fingerprint: 'fp-1',
+ user_id: 'u1',
+ url: '/baseball/dashboard',
+ created_at: '2026-07-04T12:00:00.000Z',
+ ...over,
+});
+
+const sentryIssue = (over: Partial): SentryIssue => ({
+ id: 's1',
+ shortId: 'HELM-1',
+ title: 'TypeError',
+ culprit: null,
+ level: 'error',
+ status: 'unresolved',
+ substatus: null,
+ count: 3,
+ userCount: 1,
+ firstSeen: '2026-07-03T00:00:00Z',
+ lastSeen: '2026-07-04T11:00:00.000Z',
+ permalink: 'https://sentry.io/x',
+ stats24h: [],
+ ...over,
+});
+
+describe('incident feed', () => {
+ it('filters Sentry issues to those with lastSeen inside the window', () => {
+ const inWindow = sentryIssue({ id: 'in', lastSeen: new Date().toISOString() });
+ const outWindow = sentryIssue({
+ id: 'out',
+ lastSeen: new Date(Date.now() - 48 * 3600_000).toISOString(),
+ });
+
+ const filtered = filterSentryIssuesByWindow([inWindow, outWindow], 24);
+ expect(filtered.map((i) => i.id)).toEqual(['in']);
+ });
+
+ it('builds one total count from app + windowed sentry groups', () => {
+ const { incidents, counts } = buildIncidentFeedFromSources(
+ [appEvent({ id: 'e1' }), appEvent({ id: 'e2', fingerprint: 'fp-2' })],
+ [
+ sentryIssue({ id: 's1', lastSeen: new Date().toISOString() }),
+ sentryIssue({ id: 's2', lastSeen: new Date(Date.now() - 48 * 3600_000).toISOString() }),
+ ],
+ 24,
+ );
+
+ expect(incidents).toHaveLength(3);
+ expect(summarizeIncidentFeed(incidents)).toEqual(counts);
+ expect(counts).toMatchObject({ totalGroups: 3, appGroups: 2, sentryGroups: 1 });
+ });
+});
diff --git a/src/lib/admin/data/errors.ts b/src/lib/admin/data/errors.ts
index 5b0271b3f..bf0d5db4b 100644
--- a/src/lib/admin/data/errors.ts
+++ b/src/lib/admin/data/errors.ts
@@ -1,7 +1,6 @@
import 'server-only';
import { createAdminClient } from '@/lib/supabase/admin';
import {
- fetchSentryIssues,
fetchSentryHourlyStats,
type SentryIssue,
type SentryStatsPoint,
@@ -9,11 +8,12 @@ import {
import { fetchVercelDeployments, type VercelDeployment } from '@/lib/admin/vercel-api';
import type { AdminFetchResult } from '@/lib/admin/fetch-result';
import {
- mergeTriage,
- type TriageItem,
- type TriageSeverity,
- type AppTriageEventRow,
-} from '@/lib/admin/data/triage';
+ fetchIncidentFeed,
+ DEFAULT_INCIDENT_WINDOW_HOURS,
+ type IncidentFeedFilters,
+ type IncidentFeedCounts,
+} from '@/lib/admin/data/incident-feed';
+import type { TriageItem, TriageSeverity } from '@/lib/admin/data/triage';
import { FEATURE_REGISTRY, type FeatureKey } from '@/lib/admin/feature-registry';
import {
buildIncidentReport,
@@ -55,7 +55,7 @@ function first(v: string | string[] | undefined): string | undefined {
export function parseErrorsFilters(
searchParams: Record,
): ErrorsTabFilters {
- const filters: ErrorsTabFilters = { windowHours: 24 };
+ const filters: ErrorsTabFilters = { windowHours: DEFAULT_INCIDENT_WINDOW_HOURS };
const sport = first(searchParams.sport);
if (sport && SPORTS.has(sport)) filters.sport = sport as ErrorsTabFilters['sport'];
const severity = first(searchParams.severity);
@@ -99,46 +99,59 @@ export async function fetchErrorsTab(filters: ErrorsTabFilters): Promise<{
deployments: AdminFetchResult;
deployMarkers: number[];
incidents: TriageItem[];
+ counts: IncidentFeedCounts;
rlsDenials24h: number;
+ widerWindowUnresolved: number | null;
+ widerWindowUntagged: number | null;
}> {
const admin = createAdminClient();
- const since = new Date(Date.now() - filters.windowHours * 3600_000).toISOString();
const ago24h = new Date(Date.now() - 24 * 3600_000).toISOString();
-
- let query = admin
- .from('admin_events')
- .select(
- 'id, title, message, severity, sport, fingerprint, user_id, user_email, url, created_at, source, feature, stack_trace, metadata',
- )
- .eq('event_type', 'error')
- .eq('resolved', false)
- .gte('created_at', since)
- .order('created_at', { ascending: false })
- .limit(500);
- // `info` rows (integrity-check PASS sweeps, pattern-miner "tried and found
- // nothing" starvation, philosophy-gate filter counts, and other routine
- // telemetry) are never incidents — they still get written to admin_events
- // (Feature Health's green-dot classifier reads get_feature_health()
- // independently of this query, so capture is unaffected), just never
- // surfaced in this feed/export. Skipped only when a filter chip explicitly
- // asks for `severity=info` — the UI never offers that chip, but an explicit
- // request should still work instead of silently contradicting itself.
- if (filters.severity !== 'info') query = query.neq('severity', 'info');
- if (filters.sport) query = query.eq('sport', filters.sport);
- if (filters.severity) query = query.eq('severity', filters.severity);
- if (filters.source) query = query.eq('source', filters.source);
- if (filters.feature) query = query.eq('feature', filters.feature);
+ const feedFilters: IncidentFeedFilters = {
+ windowHours: filters.windowHours,
+ sport: filters.sport,
+ severity: filters.severity,
+ source: filters.source,
+ feature: filters.feature,
+ };
let rlsQuery = admin.from('admin_events').select('id', { count: 'exact', head: true })
.eq('source', 'rls_denial').gte('created_at', ago24h);
if (filters.sport) rlsQuery = rlsQuery.eq('sport', filters.sport);
- const [sentry, hourly, deploys, appRes, rlsRes] = await Promise.all([
- fetchSentryIssues({ limit: 50 }),
+ const widerSince =
+ filters.windowHours < 168
+ ? new Date(Date.now() - 168 * 3600_000).toISOString()
+ : null;
+ let widerQuery = widerSince
+ ? admin
+ .from('admin_events')
+ .select('id', { count: 'exact', head: true })
+ .eq('event_type', 'error')
+ .eq('resolved', false)
+ .neq('severity', 'info')
+ .gte('created_at', widerSince)
+ : null;
+ if (widerQuery && filters.sport) widerQuery = widerQuery.eq('sport', filters.sport);
+
+ const widerUntaggedQuery =
+ widerSince && filters.sport
+ ? admin
+ .from('admin_events')
+ .select('id', { count: 'exact', head: true })
+ .eq('event_type', 'error')
+ .eq('resolved', false)
+ .neq('severity', 'info')
+ .gte('created_at', widerSince)
+ .is('sport', null)
+ : null;
+
+ const [hourly, deploys, rlsRes, widerRes, widerUntaggedRes, feed] = await Promise.all([
fetchSentryHourlyStats(),
fetchVercelDeployments(20),
- query,
rlsQuery,
+ widerQuery,
+ widerUntaggedQuery,
+ fetchIncidentFeed(feedFilters),
]);
const windowStart = Date.now() - filters.windowHours * 3600_000;
@@ -146,14 +159,16 @@ export async function fetchErrorsTab(filters: ErrorsTabFilters): Promise<{
.filter((d) => d.target === 'production' && d.createdAt >= windowStart)
.map((d) => d.createdAt);
- const appEvents = (appRes.data ?? []) as unknown as AppTriageEventRow[];
return {
- sentry,
+ sentry: feed.sentry,
hourly,
deployments: deploys,
deployMarkers,
- incidents: mergeTriage({ sentryIssues: [], appEvents }),
+ incidents: feed.incidents,
+ counts: feed.counts,
rlsDenials24h: rlsRes.count ?? 0,
+ widerWindowUnresolved: widerRes ? widerRes.count ?? 0 : null,
+ widerWindowUntagged: widerUntaggedRes ? widerUntaggedRes.count ?? 0 : null,
};
}
diff --git a/src/lib/admin/data/incident-feed.ts b/src/lib/admin/data/incident-feed.ts
new file mode 100644
index 000000000..7f00b2a3a
--- /dev/null
+++ b/src/lib/admin/data/incident-feed.ts
@@ -0,0 +1,121 @@
+import 'server-only';
+import { createAdminClient } from '@/lib/supabase/admin';
+import { fetchSentryIssues, type SentryIssue } from '@/lib/admin/sentry-api';
+import type { AdminFetchResult } from '@/lib/admin/fetch-result';
+import type { FeatureKey } from '@/lib/admin/feature-registry';
+import {
+ mergeTriage,
+ type AppTriageEventRow,
+ type TriageItem,
+ type TriageSeverity,
+} from '@/lib/admin/data/triage';
+
+/** Default window shared by Overview KPIs, triage queue, and Errors tab. */
+export const DEFAULT_INCIDENT_WINDOW_HOURS = 24;
+
+export interface IncidentFeedFilters {
+ windowHours: number;
+ sport?: 'golf' | 'baseball' | 'shared';
+ severity?: TriageSeverity;
+ source?: string;
+ feature?: FeatureKey;
+}
+
+export interface IncidentFeedCounts {
+ totalGroups: number;
+ appGroups: number;
+ sentryGroups: number;
+ highSeverityGroups: number;
+ affectedUsers: number;
+}
+
+const APP_EVENT_SELECT =
+ 'id, title, message, severity, sport, fingerprint, user_id, user_email, url, created_at, source, feature, stack_trace, metadata';
+
+/** Sentry unresolved issues that actually fired inside the feed window. */
+export function filterSentryIssuesByWindow(
+ issues: readonly SentryIssue[],
+ windowHours: number,
+): SentryIssue[] {
+ const since = Date.now() - windowHours * 3600_000;
+ return issues.filter((issue) => Date.parse(issue.lastSeen) >= since);
+}
+
+export function summarizeIncidentFeed(incidents: readonly TriageItem[]): IncidentFeedCounts {
+ const appGroups = incidents.filter((item) => item.origin === 'app').length;
+ const sentryGroups = incidents.filter((item) => item.origin === 'sentry').length;
+ const highSeverityGroups = incidents.filter(
+ (item) => item.severity === 'critical' || item.severity === 'error',
+ ).length;
+ const affectedUsers = incidents.reduce((sum, item) => sum + item.affectedUsers, 0);
+ return {
+ totalGroups: incidents.length,
+ appGroups,
+ sentryGroups,
+ highSeverityGroups,
+ affectedUsers,
+ };
+}
+
+/** Pure merge — unit-testable; async fetchers feed this. */
+export function buildIncidentFeedFromSources(
+ appEvents: readonly AppTriageEventRow[],
+ sentryIssues: readonly SentryIssue[],
+ windowHours: number,
+): { incidents: TriageItem[]; counts: IncidentFeedCounts } {
+ const sentryInWindow = filterSentryIssuesByWindow(sentryIssues, windowHours);
+ const incidents = mergeTriage({ sentryIssues: sentryInWindow, appEvents: [...appEvents] });
+ return { incidents, counts: summarizeIncidentFeed(incidents) };
+}
+
+export async function queryAppErrorEvents(
+ filters: IncidentFeedFilters,
+): Promise {
+ const admin = createAdminClient();
+ const since = new Date(Date.now() - filters.windowHours * 3600_000).toISOString();
+
+ let query = admin
+ .from('admin_events')
+ .select(APP_EVENT_SELECT)
+ .eq('event_type', 'error')
+ .eq('resolved', false)
+ .gte('created_at', since)
+ .order('created_at', { ascending: false })
+ .limit(500);
+
+ if (filters.severity !== 'info') query = query.neq('severity', 'info');
+ if (filters.sport) query = query.eq('sport', filters.sport);
+ if (filters.severity) query = query.eq('severity', filters.severity);
+ if (filters.source) query = query.eq('source', filters.source);
+ if (filters.feature) query = query.eq('feature', filters.feature);
+
+ const { data } = await query;
+ return (data ?? []) as unknown as AppTriageEventRow[];
+}
+
+/**
+ * Canonical Helm Bridge incident feed — one definition for Overview KPIs,
+ * Overview triage queue, and the Errors tab (default window, no extra filters).
+ */
+export async function fetchIncidentFeed(
+ filters: IncidentFeedFilters,
+ prefetched?: { sentry?: AdminFetchResult },
+): Promise<{
+ incidents: TriageItem[];
+ appEvents: AppTriageEventRow[];
+ sentry: AdminFetchResult;
+ counts: IncidentFeedCounts;
+}> {
+ const [sentry, appEvents] = await Promise.all([
+ prefetched?.sentry ?? fetchSentryIssues({ limit: 50 }),
+ queryAppErrorEvents(filters),
+ ]);
+
+ const { incidents, counts } = buildIncidentFeedFromSources(
+ appEvents,
+ sentry.data ?? [],
+ filters.windowHours,
+ );
+
+ return { incidents, appEvents, sentry, counts };
+}
diff --git a/src/lib/admin/data/overview.ts b/src/lib/admin/data/overview.ts
index 642936b4d..fb7aa5006 100644
--- a/src/lib/admin/data/overview.ts
+++ b/src/lib/admin/data/overview.ts
@@ -1,8 +1,12 @@
import 'server-only';
import { createAdminClient } from '@/lib/supabase/admin';
-import { fetchSentryIssues } from '@/lib/admin/sentry-api';
import { fetchVercelDeployments, deployAgeMinutes } from '@/lib/admin/vercel-api';
-import { groupAppErrorEvents, type AppTriageEventRow, type TriageItem } from '@/lib/admin/data/triage';
+import type { AppTriageEventRow, TriageItem } from '@/lib/admin/data/triage';
+import {
+ fetchIncidentFeed,
+ DEFAULT_INCIDENT_WINDOW_HOURS,
+ buildIncidentFeedFromSources,
+} from '@/lib/admin/data/incident-feed';
import {
fetchFeatureHealth,
summarizeFeatureHealth,
@@ -11,8 +15,10 @@ import {
} from '@/lib/admin/data/feature-health';
export interface OverviewKpis {
+ /** Unresolved Sentry issues (org-wide; not windowed). */
sentryUnresolved: number | null;
- eventErrors24h: number;
+ /** Coalesced incident groups in the last 24h — same feed as Overview triage + Errors tab default. */
+ incidentGroups24h: number;
authFailures24h: number;
activeUsersToday: number;
activityToday: { golf: number; baseball: number; lifting: number };
@@ -82,7 +88,7 @@ export function isoStartOfToday(now: Date = new Date()): string {
}
export function activeAppErrorGroups(rows: AppTriageEventRow[]): TriageItem[] {
- return groupAppErrorEvents(rows);
+ return buildIncidentFeedFromSources(rows, [], DEFAULT_INCIDENT_WINDOW_HOURS).incidents;
}
/** CALLER must have passed requireSuperAdmin() (service-role reads). */
@@ -92,9 +98,8 @@ export async function fetchOverviewSnapshot() {
const today = isoStartOfToday();
const [
- sentry,
deploys,
- appErrors24h,
+ incidentFeed24h,
security24h,
activeToday,
golfToday,
@@ -105,19 +110,8 @@ export async function fetchOverviewSnapshot() {
lastCron,
featureHealthRaw,
] = await Promise.all([
- fetchSentryIssues({ limit: 50 }),
fetchVercelDeployments(5),
- admin
- .from('admin_events')
- .select(
- 'id, title, message, severity, sport, fingerprint, user_id, user_email, url, created_at, source, feature, stack_trace, metadata',
- )
- .eq('event_type', 'error')
- .eq('resolved', false)
- .gte('created_at', ago24h)
- .neq('severity', 'info')
- .order('created_at', { ascending: false })
- .limit(500),
+ fetchIncidentFeed({ windowHours: DEFAULT_INCIDENT_WINDOW_HOURS }),
admin.from('admin_events').select('id', { count: 'exact', head: true })
.eq('event_type', 'security').gte('created_at', ago24h),
admin.from('users').select('id', { count: 'exact', head: true })
@@ -142,10 +136,10 @@ export async function fetchOverviewSnapshot() {
]);
const lastDeployRow = deploys.data?.[0] ?? null;
- const appErrorGroups24h = activeAppErrorGroups((appErrors24h.data ?? []) as unknown as AppTriageEventRow[]);
const kpis: OverviewKpis = {
- sentryUnresolved: sentry.status === 'ok' ? (sentry.data?.length ?? 0) : null,
- eventErrors24h: appErrorGroups24h.length,
+ sentryUnresolved:
+ incidentFeed24h.sentry.status === 'ok' ? (incidentFeed24h.sentry.data?.length ?? 0) : null,
+ incidentGroups24h: incidentFeed24h.counts.totalGroups,
authFailures24h: security24h.count ?? 0,
activeUsersToday: activeToday.count ?? 0,
activityToday: {
@@ -181,13 +175,15 @@ export async function fetchOverviewSnapshot() {
const featureHealthCriticalCount = featureHealth.red;
const featureHealthAttentionCount = featureHealth.red === 0 && featureHealth.newFingerprints24h > 0 ? 1 : 0;
- const criticalAppErrorGroups24h = appErrorGroups24h.filter((item) => item.severity === 'critical').length;
+ const criticalIncidentGroups24h = incidentFeed24h.incidents.filter(
+ (item) => item.severity === 'critical',
+ ).length;
const attentionFromDeploy = kpis.lastDeploy?.state === 'ERROR' ? 1 : 0;
const banner = {
...computeBannerState({
- criticalCount: criticalAppErrorGroups24h + featureHealthCriticalCount,
+ criticalCount: criticalIncidentGroups24h + featureHealthCriticalCount,
attentionCount: attentionFromDeploy + featureHealthAttentionCount,
- anyFeedStale: sentry.status === 'error' || watcher.some((w) => w.stale) || featureHealth.degraded,
+ anyFeedStale: incidentFeed24h.sentry.status === 'error' || watcher.some((w) => w.stale) || featureHealth.degraded,
}),
checkedAt: now.toISOString(),
// Single-line banner detail per N6 (never a wall of routine noise) —
diff --git a/src/lib/admin/data/triage.ts b/src/lib/admin/data/triage.ts
index 09a8b9378..5bd9fdb65 100644
--- a/src/lib/admin/data/triage.ts
+++ b/src/lib/admin/data/triage.ts
@@ -1,7 +1,11 @@
import 'server-only';
-import { createAdminClient } from '@/lib/supabase/admin';
-import { fetchSentryIssues, type SentryIssue } from '@/lib/admin/sentry-api';
+import type { SentryIssue } from '@/lib/admin/sentry-api';
import type { AdminFetchResult } from '@/lib/admin/fetch-result';
+import {
+ fetchIncidentFeed,
+ DEFAULT_INCIDENT_WINDOW_HOURS,
+ type IncidentFeedCounts,
+} from '@/lib/admin/data/incident-feed';
import {
buildIncidentReport,
extractActionName,
@@ -237,34 +241,13 @@ export function groupAppErrorEvents(rows: AppTriageEventRow[]): TriageItem[] {
* Server fetcher. CALLER must have passed requireSuperAdmin() first —
* this reads admin_events with the service-role client.
*/
-export async function fetchTriageQueue(): Promise<{
+export async function fetchTriageQueue(
+ windowHours: number = DEFAULT_INCIDENT_WINDOW_HOURS,
+): Promise<{
items: TriageItem[];
sentry: AdminFetchResult;
+ counts: IncidentFeedCounts;
}> {
- const admin = createAdminClient();
- const [sentry, appRes] = await Promise.all([
- fetchSentryIssues(),
- admin
- .from('admin_events')
- .select(
- 'id, title, message, severity, sport, fingerprint, user_id, user_email, url, created_at, source, feature, stack_trace, metadata',
- )
- .eq('event_type', 'error')
- .eq('resolved', false)
- // `info` rows (integrity-check PASS sweeps, pattern-miner starvation,
- // philosophy-gate filter counts, etc.) are routine telemetry, not
- // incidents — excluded from this feed (Needs Attention + the ops
- // digest) the same way as the /admin/errors tab (errors.ts). They are
- // still captured in admin_events and still feed Feature Health's
- // green-dot classifier via a separate query (get_feature_health()).
- .neq('severity', 'info')
- .order('created_at', { ascending: false })
- .limit(500),
- ]);
-
- const appEvents = (appRes.data ?? []) as unknown as AppTriageEventRow[];
- return {
- items: mergeTriage({ sentryIssues: sentry.data ?? [], appEvents }),
- sentry,
- };
+ const feed = await fetchIncidentFeed({ windowHours });
+ return { items: feed.incidents, sentry: feed.sentry, counts: feed.counts };
}
diff --git a/src/lib/admin/github-feedback.ts b/src/lib/admin/github-feedback.ts
index f8a6f8692..2a38fa982 100644
--- a/src/lib/admin/github-feedback.ts
+++ b/src/lib/admin/github-feedback.ts
@@ -1,5 +1,10 @@
import 'server-only';
import { createAdminClient } from '@/lib/supabase/admin';
+import { githubIssuesHeaders, githubIssuesRepo, githubIssuesToken } from '@/lib/admin/github-issues-config';
+import {
+ BEN_LEAH_INITIAL_WORKFLOW_LABEL,
+ ensureBenLeahGitHubLabels,
+} from '@/lib/admin/github-issues-workflow';
export type FeedbackKind = 'bug' | 'change' | 'addition';
export type FeedbackPriority = 'normal' | 'high' | 'urgent';
@@ -113,13 +118,11 @@ export function buildFeedbackIssueBody(input: FeedbackIssueInput): string {
}
function issueToken(): string | null {
- return process.env.GITHUB_ISSUES_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim() || null;
+ return githubIssuesToken();
}
function issueRepo(): { owner: string; repo: string } {
- const raw = process.env.GITHUB_ISSUES_REPO?.trim() || process.env.GITHUB_REPOSITORY?.trim() || 'njrini99-code/helmv3';
- const [owner, repo] = raw.split('/');
- if (!owner || !repo) return { owner: 'njrini99-code', repo: 'helmv3' };
+ const { owner, repo } = githubIssuesRepo();
return { owner, repo };
}
@@ -159,34 +162,39 @@ export async function createGitHubFeedbackIssue(input: FeedbackIssueInput): Prom
if (!token) {
throw new Error('GitHub issue token is not configured. Set GITHUB_ISSUES_TOKEN or GITHUB_TOKEN.');
}
+ await ensureBenLeahGitHubLabels();
+
const { owner, repo } = issueRepo();
+ const headers = {
+ ...githubIssuesHeaders(token),
+ 'content-type': 'application/json',
+ };
+ const payload = {
+ title: `[Ben + Leah] ${KIND_LABEL[input.kind]}: ${input.title}`,
+ body: buildFeedbackIssueBody(input),
+ labels: [
+ 'ben-leah',
+ BEN_LEAH_INITIAL_WORKFLOW_LABEL,
+ `kind:${input.kind}`,
+ `priority:${input.priority}`,
+ `category:${input.category}`,
+ ],
+ };
+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues`, {
method: 'POST',
- headers: {
- accept: 'application/vnd.github+json',
- authorization: `Bearer ${token}`,
- 'content-type': 'application/json',
- 'x-github-api-version': '2022-11-28',
- },
- body: JSON.stringify({
- title: `[Ben + Leah] ${KIND_LABEL[input.kind]}: ${input.title}`,
- body: buildFeedbackIssueBody(input),
- labels: ['ben-leah', `kind:${input.kind}`, `priority:${input.priority}`, `category:${input.category}`],
- }),
+ headers,
+ body: JSON.stringify(payload),
});
if (res.status === 422) {
const retry = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues`, {
method: 'POST',
- headers: {
- accept: 'application/vnd.github+json',
- authorization: `Bearer ${token}`,
- 'content-type': 'application/json',
- 'x-github-api-version': '2022-11-28',
- },
+ headers,
body: JSON.stringify({
- title: `[Ben + Leah] ${KIND_LABEL[input.kind]}: ${input.title}`,
- body: buildFeedbackIssueBody(input),
+ title: payload.title,
+ body: payload.body,
+ labels: ['ben-leah', BEN_LEAH_INITIAL_WORKFLOW_LABEL],
}),
});
if (!retry.ok) {
diff --git a/src/lib/admin/github-issues-config.ts b/src/lib/admin/github-issues-config.ts
new file mode 100644
index 000000000..957d4e682
--- /dev/null
+++ b/src/lib/admin/github-issues-config.ts
@@ -0,0 +1,25 @@
+import 'server-only';
+
+/** Shared GitHub Issues API config — no fetch, safe to import from create + tracker paths. */
+
+export function githubIssuesToken(): string | null {
+ return process.env.GITHUB_ISSUES_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim() || null;
+}
+
+export function githubIssuesRepo(): { owner: string; repo: string; label: string } {
+ const raw =
+ process.env.GITHUB_ISSUES_REPO?.trim() ||
+ process.env.GITHUB_REPOSITORY?.trim() ||
+ 'njrini99-code/helmv3';
+ const [owner, repo] = raw.split('/');
+ if (!owner || !repo) return { owner: 'njrini99-code', repo: 'helmv3', label: raw };
+ return { owner, repo, label: `${owner}/${repo}` };
+}
+
+export function githubIssuesHeaders(token: string): HeadersInit {
+ return {
+ accept: 'application/vnd.github+json',
+ authorization: `Bearer ${token}`,
+ 'x-github-api-version': '2022-11-28',
+ };
+}
diff --git a/src/lib/admin/github-issues-workflow.ts b/src/lib/admin/github-issues-workflow.ts
new file mode 100644
index 000000000..543c547a0
--- /dev/null
+++ b/src/lib/admin/github-issues-workflow.ts
@@ -0,0 +1,90 @@
+import 'server-only';
+import {
+ BEN_LEAH_INITIAL_WORKFLOW_LABEL,
+ BEN_LEAH_WORKFLOW_LABEL_DEFS,
+ applyWorkflowSelection,
+ type BenLeahWorkflowSelection,
+} from '@/lib/admin/ben-leah-issue-tracker';
+import {
+ githubIssuesHeaders,
+ githubIssuesRepo,
+ githubIssuesToken,
+} from '@/lib/admin/github-issues-config';
+
+const BEN_LEAH_REPO_LABEL = {
+ name: 'ben-leah',
+ color: '16A34A',
+ description: 'Submitted from Helm Bridge Ben + Leah',
+} as const;
+
+async function createLabelIfMissing(
+ token: string,
+ owner: string,
+ repo: string,
+ name: string,
+ color: string,
+ description: string,
+): Promise {
+ const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/labels`, {
+ method: 'POST',
+ headers: {
+ ...githubIssuesHeaders(token),
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify({ name, color, description }),
+ });
+ if (res.ok || res.status === 422) return;
+ const text = await res.text();
+ throw new Error(`GitHub label create failed for ${name} (${res.status}): ${text.slice(0, 200)}`);
+}
+
+/** Idempotent — creates ben-leah + workflow labels when missing. */
+export async function ensureBenLeahGitHubLabels(): Promise {
+ const token = githubIssuesToken();
+ if (!token) return;
+
+ const { owner, repo } = githubIssuesRepo();
+ await createLabelIfMissing(
+ token,
+ owner,
+ repo,
+ BEN_LEAH_REPO_LABEL.name,
+ BEN_LEAH_REPO_LABEL.color,
+ BEN_LEAH_REPO_LABEL.description,
+ );
+ for (const label of BEN_LEAH_WORKFLOW_LABEL_DEFS) {
+ await createLabelIfMissing(token, owner, repo, label.name, label.color, label.description);
+ }
+}
+
+export async function setBenLeahIssueWorkflow(
+ issueNumber: number,
+ currentLabels: string[],
+ selection: BenLeahWorkflowSelection,
+): Promise {
+ const token = githubIssuesToken();
+ if (!token) {
+ throw new Error('GitHub issue token is not configured. Set GITHUB_ISSUES_TOKEN or GITHUB_TOKEN.');
+ }
+
+ await ensureBenLeahGitHubLabels();
+
+ const { owner, repo } = githubIssuesRepo();
+ const labels = applyWorkflowSelection(currentLabels, selection);
+
+ const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, {
+ method: 'PATCH',
+ headers: {
+ ...githubIssuesHeaders(token),
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify({ labels }),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`GitHub issue label update failed (${res.status}): ${text.slice(0, 400)}`);
+ }
+}
+
+export { BEN_LEAH_INITIAL_WORKFLOW_LABEL };
diff --git a/src/lib/admin/github-pr-timeline.ts b/src/lib/admin/github-pr-timeline.ts
new file mode 100644
index 000000000..565e7dfba
--- /dev/null
+++ b/src/lib/admin/github-pr-timeline.ts
@@ -0,0 +1,197 @@
+import 'server-only';
+import {
+ failed,
+ ok,
+ unconfigured,
+ type AdminFetchResult,
+} from '@/lib/admin/fetch-result';
+import { githubIssuesHeaders, githubIssuesRepo, githubIssuesToken } from '@/lib/admin/github-issues-config';
+import {
+ inferWorkAreaFromTitle,
+ parsePullRequestBody,
+ type ParsedPrBody,
+ type WorkArea,
+} from '@/lib/admin/pr-body-parser';
+
+export type PrLifecycleState = 'open' | 'merged' | 'closed';
+
+export interface WorkLogEntry {
+ number: number;
+ html_url: string;
+ title: string;
+ state: PrLifecycleState;
+ authorLogin: string;
+ created_at: string;
+ updated_at: string;
+ merged_at: string | null;
+ closed_at: string | null;
+ parsed: ParsedPrBody;
+}
+
+export interface WorkLogSnapshot {
+ entries: WorkLogEntry[];
+ repoLabel: string;
+ authorLogins: string[];
+ counts: {
+ total: number;
+ merged: number;
+ open: number;
+ byArea: Record;
+ };
+}
+
+function prAuthorLogins(): string[] {
+ const raw = process.env.GITHUB_PR_AUTHOR_LOGINS?.trim();
+ if (raw) {
+ return raw.split(',').map((login) => login.trim()).filter(Boolean);
+ }
+ const single = process.env.GITHUB_PR_AUTHOR_LOGIN?.trim();
+ if (single) return [single];
+ return [];
+}
+
+function prFetchLimit(): number {
+ const raw = Number.parseInt(process.env.GITHUB_PR_FETCH_LIMIT ?? '60', 10);
+ return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 100) : 60;
+}
+
+interface RawPullRequest {
+ number: number;
+ html_url: string;
+ title: string;
+ state: 'open' | 'closed';
+ body: string | null;
+ created_at: string;
+ updated_at: string;
+ merged_at: string | null;
+ closed_at: string | null;
+ user: { login: string } | null;
+ draft?: boolean;
+}
+
+function lifecycleState(pr: RawPullRequest): PrLifecycleState {
+ if (pr.merged_at) return 'merged';
+ if (pr.state === 'open') return 'open';
+ return 'closed';
+}
+
+function matchesAuthor(pr: RawPullRequest, authors: string[]): boolean {
+ if (authors.length === 0) return true;
+ const login = pr.user?.login?.toLowerCase();
+ if (!login) return false;
+ return authors.some((author) => author.toLowerCase() === login);
+}
+
+async function fetchPullRequests(
+ token: string,
+ owner: string,
+ repo: string,
+ authors: string[],
+ limit: number,
+): Promise {
+ const authorQuery =
+ authors.length > 0
+ ? `+${authors.map((login) => `author:${login}`).join('+')}`
+ : '';
+ const searchParams = new URLSearchParams({
+ q: `repo:${owner}/${repo} is:pr${authorQuery}`,
+ sort: 'updated',
+ order: 'desc',
+ per_page: String(Math.min(limit, 100)),
+ });
+
+ const searchRes = await fetch(`https://api.github.com/search/issues?${searchParams}`, {
+ headers: githubIssuesHeaders(token),
+ next: { revalidate: 120 },
+ });
+
+ if (searchRes.ok) {
+ const body = (await searchRes.json()) as { items?: RawPullRequest[] };
+ return body.items ?? [];
+ }
+
+ // Fallback when search is unavailable — list pulls and filter locally.
+ const listParams = new URLSearchParams({
+ state: 'all',
+ sort: 'updated',
+ direction: 'desc',
+ per_page: '100',
+ });
+ const listRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?${listParams}`, {
+ headers: githubIssuesHeaders(token),
+ next: { revalidate: 120 },
+ });
+ if (!listRes.ok) {
+ const text = await listRes.text();
+ throw new Error(`GitHub pull request fetch failed (${listRes.status}): ${text.slice(0, 300)}`);
+ }
+ const pulls = (await listRes.json()) as RawPullRequest[];
+ return pulls.filter((pr) => matchesAuthor(pr, authors)).slice(0, limit);
+}
+
+function countByArea(entries: WorkLogEntry[]): Record {
+ const counts: Record = {
+ golf: 0,
+ baseball: 0,
+ coachhelm: 0,
+ bridge: 0,
+ platform: 0,
+ mobile: 0,
+ shared: 0,
+ unknown: 0,
+ };
+ for (const entry of entries) counts[entry.parsed.area] += 1;
+ return counts;
+}
+
+export async function fetchWorkLog(): Promise> {
+ const token = githubIssuesToken();
+ if (!token) return unconfigured('GitHub API');
+
+ const { owner, repo, label } = githubIssuesRepo();
+ const authors = prAuthorLogins();
+ const limit = prFetchLimit();
+
+ try {
+ const pulls = await fetchPullRequests(token, owner, repo, authors, limit);
+ const entries: WorkLogEntry[] = pulls.map((pr) => {
+ const base = parsePullRequestBody(pr.body, pr.title);
+ const parsed =
+ base.area === 'unknown'
+ ? { ...base, area: inferWorkAreaFromTitle(pr.title) }
+ : base;
+ return {
+ number: pr.number,
+ html_url: pr.html_url,
+ title: pr.title,
+ state: lifecycleState(pr),
+ authorLogin: pr.user?.login ?? 'unknown',
+ created_at: pr.created_at,
+ updated_at: pr.updated_at,
+ merged_at: pr.merged_at,
+ closed_at: pr.closed_at,
+ parsed,
+ };
+ });
+
+ entries.sort((a, b) => {
+ const aTime = Date.parse(a.merged_at ?? a.closed_at ?? a.created_at);
+ const bTime = Date.parse(b.merged_at ?? b.closed_at ?? b.created_at);
+ return bTime - aTime;
+ });
+
+ return ok({
+ entries,
+ repoLabel: label,
+ authorLogins: authors,
+ counts: {
+ total: entries.length,
+ merged: entries.filter((entry) => entry.state === 'merged').length,
+ open: entries.filter((entry) => entry.state === 'open').length,
+ byArea: countByArea(entries),
+ },
+ });
+ } catch (err) {
+ return failed(err instanceof Error ? err.message : String(err));
+ }
+}
diff --git a/src/lib/admin/observe-action-result.ts b/src/lib/admin/observe-action-result.ts
new file mode 100644
index 000000000..d5368873e
--- /dev/null
+++ b/src/lib/admin/observe-action-result.ts
@@ -0,0 +1,111 @@
+import 'server-only';
+
+import { isExpectedAuthNoise } from '@/lib/admin/data/triage';
+import { drainCollapsedCount, shouldEmit } from '@/lib/admin/emit-throttle';
+import { logServerError } from '@/lib/server-error-logger';
+
+export type ActionSoftFailureContext = NonNullable[1]> & {
+ action: string;
+};
+
+/** User-facing control flow — logged as handled warnings, hidden from Sentry. */
+const EXPECTED_SOFT_FAILURE_PATTERNS: readonly RegExp[] = [
+ /^not authenticated$/i,
+ /^unauthorized$/i,
+ /^you must be signed in/i,
+ /^coach or team not found$/i,
+ /^player profile not found$/i,
+ /^only coaches can/i,
+ /^you do not have permission/i,
+ /^this isn't available in the live demo/i,
+ /^choose a valid/i,
+ /^please (enter|select|provide)/i,
+];
+
+export function extractActionSoftFailure(
+ result: unknown,
+): { message: string; code: string | null } | null {
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return null;
+ const record = result as Record;
+
+ if (record.success === false) {
+ const message =
+ (typeof record.error === 'string' && record.error.trim()) ||
+ (typeof record.message === 'string' && record.message.trim()) ||
+ 'Action returned success: false';
+ const code = typeof record.code === 'string' ? record.code : null;
+ return { message, code };
+ }
+
+ if (record.ok === false) {
+ const message =
+ (typeof record.error === 'string' && record.error.trim()) ||
+ (typeof record.message === 'string' && record.message.trim()) ||
+ 'Action returned ok: false';
+ const code = typeof record.code === 'string' ? record.code : null;
+ return { message, code };
+ }
+
+ // { data: null, error: '...' } result envelopes without an explicit success flag.
+ if (
+ record.data === null &&
+ typeof record.error === 'string' &&
+ record.error.trim().length > 0
+ ) {
+ return { message: record.error.trim(), code: null };
+ }
+
+ return null;
+}
+
+export function isExpectedSoftFailureMessage(message: string): boolean {
+ if (isExpectedAuthNoise(message)) return true;
+ return EXPECTED_SOFT_FAILURE_PATTERNS.some((pattern) => pattern.test(message.trim()));
+}
+
+function severityForSoftFailure(message: string): 'warning' | 'error' {
+ return isExpectedSoftFailureMessage(message) ? 'warning' : 'error';
+}
+
+/**
+ * Helm Bridge capture class #3 — server actions that return `{ success: false }`
+ * instead of throwing. Fire-and-forget; never changes the action result.
+ */
+export function observeActionSoftFailure(
+ result: unknown,
+ context: ActionSoftFailureContext,
+): void {
+ const failure = extractActionSoftFailure(result);
+ if (!failure) return;
+
+ const throttleKey = `soft:${context.action}:${failure.code ?? failure.message.slice(0, 120)}`;
+ if (!shouldEmit(throttleKey)) return;
+
+ const collapsedCount = drainCollapsedCount(throttleKey);
+ const expected = isExpectedSoftFailureMessage(failure.message);
+ const severity = severityForSoftFailure(failure.message);
+
+ try {
+ void Promise.resolve(
+ logServerError(
+ failure.message,
+ {
+ ...context,
+ title: `[${context.action}] ${failure.message}`.slice(0, 500),
+ handled: true,
+ skipSentry: expected,
+ errorCode: failure.code ?? undefined,
+ fingerprint: ['server_action_soft', context.feature ?? context.featureArea ?? 'unknown', context.action],
+ metadata: {
+ ...(context.metadata ?? {}),
+ soft_failure: true,
+ ...(collapsedCount > 0 ? { collapsed_count: collapsedCount } : {}),
+ },
+ },
+ severity,
+ ),
+ ).catch(() => {});
+ } catch {
+ // Fire-and-forget: observability must never change action results.
+ }
+}
diff --git a/src/lib/admin/observed-action.ts b/src/lib/admin/observed-action.ts
index 414e368d6..85b987064 100644
--- a/src/lib/admin/observed-action.ts
+++ b/src/lib/admin/observed-action.ts
@@ -1,5 +1,6 @@
import { logServerException } from '@/lib/server-error-logger';
import { shouldEmit, drainCollapsedCount } from '@/lib/admin/emit-throttle';
+import { observeActionSoftFailure } from '@/lib/admin/observe-action-result';
import { createClient } from '@/lib/supabase/server';
import type { FeatureKey } from '@/lib/admin/feature-registry';
@@ -74,7 +75,29 @@ export function withAdminObserved(
): (...args: Args) => Promise {
return async (...args: Args): Promise => {
try {
- return await fn(...args);
+ const result = await fn(...args);
+ const observedUser = await resolveObservedUser();
+ let extraContext: ObservedActionContext = {};
+ try {
+ extraContext = opts.contextFrom?.(args) ?? {};
+ } catch {
+ extraContext = {};
+ }
+ observeActionSoftFailure(result, {
+ action: name,
+ source: 'server_action',
+ feature: opts.feature ?? null,
+ featureArea: opts.featureArea ?? opts.feature ?? null,
+ sport: opts.sport,
+ userId: observedUser.userId,
+ userEmail: observedUser.userEmail,
+ roundId: extraContext.roundId ?? null,
+ playerId: extraContext.playerId ?? null,
+ teamId: extraContext.teamId ?? null,
+ route: extraContext.route ?? null,
+ handled: true,
+ });
+ return result;
} catch (err) {
if (!isNextControlFlowError(err)) {
try {
diff --git a/src/lib/admin/pr-body-parser.ts b/src/lib/admin/pr-body-parser.ts
new file mode 100644
index 000000000..a5f18b8ea
--- /dev/null
+++ b/src/lib/admin/pr-body-parser.ts
@@ -0,0 +1,178 @@
+/**
+ * Deterministic parser for `.github/PULL_REQUEST_TEMPLATE.md` sections.
+ * No LLM — summaries come from what the author already wrote in the PR.
+ */
+
+export type WorkArea =
+ | 'golf'
+ | 'baseball'
+ | 'coachhelm'
+ | 'bridge'
+ | 'platform'
+ | 'mobile'
+ | 'shared'
+ | 'unknown';
+
+export interface ParsedPrBody {
+ summary: string | null;
+ partnerSummary: string | null;
+ problem: string | null;
+ fix: string | null;
+ area: WorkArea;
+ timelineNote: string | null;
+ changeTypes: string[];
+}
+
+const AREA_ALIASES: Record = {
+ golf: 'golf',
+ golfhelm: 'golf',
+ baseball: 'baseball',
+ baseballhelm: 'baseball',
+ coachhelm: 'coachhelm',
+ coach: 'coachhelm',
+ bridge: 'bridge',
+ admin: 'bridge',
+ 'mission-control': 'bridge',
+ 'helm bridge': 'bridge',
+ platform: 'platform',
+ ci: 'platform',
+ tooling: 'platform',
+ chore: 'platform',
+ docs: 'platform',
+ mobile: 'mobile',
+ ios: 'mobile',
+ capacitor: 'mobile',
+ shared: 'shared',
+ dashboard: 'shared',
+ stats: 'shared',
+ import: 'shared',
+ auth: 'shared',
+};
+
+function stripHtmlComments(text: string): string {
+ return text.replace(//g, '');
+}
+
+function cleanSectionText(raw: string): string {
+ return stripHtmlComments(raw)
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0 && !line.startsWith('- [ ]') && !line.startsWith('- [x]'))
+ .join('\n')
+ .trim();
+}
+
+function extractSection(body: string, heading: string): string | null {
+ const headingLine = `## ${heading}`;
+ const lines = body.split('\n');
+ const start = lines.findIndex((line) => line.trim() === headingLine);
+ if (start === -1) return null;
+
+ const content: string[] = [];
+ for (let i = start + 1; i < lines.length; i++) {
+ const line = lines[i];
+ if (!line) continue;
+ const trimmed = line.trim();
+ if (trimmed.startsWith('## ')) break;
+ content.push(line);
+ }
+
+ const cleaned = cleanSectionText(content.join('\n'));
+ return cleaned.length > 0 ? cleaned : null;
+}
+
+function parseCheckedTypes(body: string): string[] {
+ const headingLine = '## Type of change';
+ const lines = body.split('\n');
+ const start = lines.findIndex((line) => line.trim() === headingLine);
+ if (start === -1) return [];
+
+ const checked: string[] = [];
+ for (let i = start + 1; i < lines.length; i++) {
+ const line = lines[i];
+ if (!line) continue;
+ const trimmed = line.trim();
+ if (trimmed.startsWith('## ')) break;
+ const match = trimmed.match(/^- \[[xX]\]\s+(.+)$/);
+ if (match?.[1]) checked.push(match[1].trim());
+ }
+ return checked;
+}
+
+export function normalizeWorkAreaToken(raw: string | null | undefined): WorkArea {
+ if (!raw) return 'unknown';
+ const token = raw.toLowerCase().replace(/[`·]/g, ' ').trim();
+ for (const part of token.split(/[\s,/]+/)) {
+ const normalized = part.replace(/[^a-z-]/g, '');
+ const hit = AREA_ALIASES[normalized as keyof typeof AREA_ALIASES];
+ if (hit) return hit;
+ }
+ const dashed = token.replace(/\s+/g, '-');
+ const dashedHit = AREA_ALIASES[dashed as keyof typeof AREA_ALIASES];
+ if (dashedHit) return dashedHit;
+ return 'unknown';
+}
+
+export function inferWorkAreaFromTitle(title: string): WorkArea {
+ const lower = title.toLowerCase();
+ if (/\b(baseball|pipeline|roster|box.?score)\b/.test(lower)) return 'baseball';
+ if (/\b(golf|fairway|qualifier|round)\b/.test(lower)) return 'golf';
+ if (/\b(coachhelm|insight|pattern)\b/.test(lower)) return 'coachhelm';
+ if (/\b(admin|bridge|mission.?control|sentry|vercel)\b/.test(lower)) return 'bridge';
+ if (/\b(ci|chore|deps|lint|tooling|docs)\b/.test(lower)) return 'platform';
+ if (/\b(ios|capacitor|mobile)\b/.test(lower)) return 'mobile';
+ return 'unknown';
+}
+
+function splitPartnerSummary(text: string): { problem: string | null; fix: string | null } {
+ const splitters = [
+ /what changed[?:]?\s*/i,
+ /what was fixed[?:]?\s*/i,
+ /the fix[?:]?\s*/i,
+ /why it matters[?:]?\s*/i,
+ ];
+
+ for (const splitter of splitters) {
+ const match = text.match(splitter);
+ if (!match || match.index == null) continue;
+ const problem = text.slice(0, match.index).replace(/what was broken[^?]*\??/i, '').trim();
+ const fix = text.slice(match.index + match[0].length).trim();
+ return {
+ problem: problem || null,
+ fix: fix || null,
+ };
+ }
+
+ return { problem: text, fix: null };
+}
+
+export function parsePullRequestBody(body: string | null | undefined, title: string): ParsedPrBody {
+ const normalizedBody = stripHtmlComments(body ?? '');
+ const summary = extractSection(normalizedBody, 'Summary');
+ const partnerSummary = extractSection(normalizedBody, 'Partner-readable summary');
+ const areaRaw = extractSection(normalizedBody, 'Area');
+ const timelineNote = extractSection(normalizedBody, 'Git Activity Timeline note');
+ const changeTypes = parseCheckedTypes(normalizedBody);
+
+ const areaFromBody = normalizeWorkAreaToken(areaRaw?.split(/\s+/)[0] ?? areaRaw);
+ const area = areaFromBody === 'unknown' ? inferWorkAreaFromTitle(title) : areaFromBody;
+
+ const partnerParts = partnerSummary ? splitPartnerSummary(partnerSummary) : { problem: null, fix: null };
+
+ const problem = partnerParts.problem ?? summary ?? null;
+
+ const fix =
+ partnerParts.fix ??
+ timelineNote ??
+ (partnerParts.problem && summary ? summary : null);
+
+ return {
+ summary,
+ partnerSummary,
+ problem,
+ fix,
+ area,
+ timelineNote,
+ changeTypes,
+ };
+}
diff --git a/src/lib/auth/session-activity.ts b/src/lib/auth/session-activity.ts
index 6f1d843fd..9d43ea4b5 100644
--- a/src/lib/auth/session-activity.ts
+++ b/src/lib/auth/session-activity.ts
@@ -2,7 +2,9 @@
* Session Activity Tracking
*
* Enforces an idle-session timeout: after SESSION_IDLE_TIMEOUT_MS (5 minutes) of
- * no user activity, the user is signed out and must log in again. Shares the
+ * no user activity while the tab is hidden, the user is signed out and must log
+ * in again. While the tab is visible, a heartbeat keeps the session alive so
+ * reading a dashboard without mouse/keyboard does not log the user out. Shares the
* `sb_last_activity` cookie with the server middleware, which enforces the same
* window on navigations/reopens — this hook covers the "app stays open but idle"
* and "mobile reopen from background (bfcache — no network request)" cases the
@@ -17,6 +19,7 @@ import { createClient } from '@/lib/supabase/client';
import {
SESSION_IDLE_COOKIE,
SESSION_IDLE_COOKIE_MAX_AGE_S,
+ SESSION_VISIBLE_HEARTBEAT_MS,
isSessionIdleExpired,
parseLastActivity,
} from '@/lib/auth/session-idle-shared';
@@ -88,6 +91,11 @@ export function useSessionActivity() {
}
const path = window.location.pathname;
+ if (path.startsWith('/admin')) {
+ const params = new URLSearchParams({ message: 'session_expired', returnTo: path });
+ router.replace(`/golf/login?${params.toString()}`);
+ return;
+ }
const sport = path.startsWith('/golf')
? 'golf'
: path.startsWith('/lifting')
@@ -115,7 +123,44 @@ export function useSessionActivity() {
// Fresh/bootstrap: record activity now.
updateLastActivity();
- // Periodic check catches "app open but idle" (no interaction, no requests).
+ let visibleHeartbeatRef: ReturnType | null = null;
+
+ const stopVisibleHeartbeat = () => {
+ if (visibleHeartbeatRef) {
+ clearInterval(visibleHeartbeatRef);
+ visibleHeartbeatRef = null;
+ }
+ };
+
+ const startVisibleHeartbeat = () => {
+ if (visibleHeartbeatRef || document.visibilityState !== 'visible') return;
+ visibleHeartbeatRef = setInterval(() => {
+ if (document.visibilityState !== 'visible') {
+ stopVisibleHeartbeat();
+ return;
+ }
+ if (isSessionTimedOut()) {
+ void handleLogout();
+ return;
+ }
+ updateLastActivity();
+ }, SESSION_VISIBLE_HEARTBEAT_MS);
+ };
+
+ const syncTabVisibility = () => {
+ if (document.visibilityState === 'visible') {
+ if (isSessionTimedOut()) {
+ void handleLogout();
+ return;
+ }
+ updateLastActivity();
+ startVisibleHeartbeat();
+ } else {
+ stopVisibleHeartbeat();
+ }
+ };
+
+ // Periodic check catches hidden-tab idle (heartbeat is paused while hidden).
checkIntervalRef.current = setInterval(() => {
void checkSessionTimeout();
}, ACTIVITY_CHECK_INTERVAL_MS);
@@ -147,25 +192,22 @@ export function useSessionActivity() {
});
// Mobile Safari / PWA reopen restores from bfcache WITHOUT a network request,
- // so the server middleware never sees it. Re-check the moment the tab becomes
- // visible again (visibilitychange) or is restored (pageshow).
- const handleReshow = () => {
- if (document.visibilityState === 'visible') {
- void checkSessionTimeout();
- }
- };
- document.addEventListener('visibilitychange', handleReshow);
- window.addEventListener('pageshow', handleReshow);
+ // so the server middleware never sees it. Re-check when the tab becomes visible
+ // again (visibilitychange) or is restored (pageshow).
+ syncTabVisibility();
+ document.addEventListener('visibilitychange', syncTabVisibility);
+ window.addEventListener('pageshow', syncTabVisibility);
return () => {
+ stopVisibleHeartbeat();
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current);
}
activityEvents.forEach((event) => {
window.removeEventListener(event, handleActivity);
});
- document.removeEventListener('visibilitychange', handleReshow);
- window.removeEventListener('pageshow', handleReshow);
+ document.removeEventListener('visibilitychange', syncTabVisibility);
+ window.removeEventListener('pageshow', syncTabVisibility);
};
}, [handleLogout, checkSessionTimeout]);
diff --git a/src/lib/auth/session-idle-shared.ts b/src/lib/auth/session-idle-shared.ts
index 46eac3096..80ffa24d3 100644
--- a/src/lib/auth/session-idle-shared.ts
+++ b/src/lib/auth/session-idle-shared.ts
@@ -16,6 +16,13 @@ export const SESSION_IDLE_COOKIE = 'sb_last_activity';
/** Idle window: sign the user out after this long without activity. */
export const SESSION_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
+/**
+ * While the tab is visible, refresh the activity marker on this interval so
+ * reading a dashboard (no mouse/keyboard) does not trip the idle window.
+ * Must stay well below {@link SESSION_IDLE_TIMEOUT_MS}.
+ */
+export const SESSION_VISIBLE_HEARTBEAT_MS = 60 * 1000; // 1 minute
+
/**
* Cookie lifetime — deliberately MUCH longer than the timeout.
*
diff --git a/src/lib/baseball/__tests__/nav-manifest.test.ts b/src/lib/baseball/__tests__/nav-manifest.test.ts
index 62e930177..a08d7f91b 100644
--- a/src/lib/baseball/__tests__/nav-manifest.test.ts
+++ b/src/lib/baseball/__tests__/nav-manifest.test.ts
@@ -146,7 +146,9 @@ describe('BASEBALL_NAV_MANIFEST', () => {
const canonicalHrefs = new Set(manifestHrefsByStatus('canonical'));
it('sanity: deprecated entries were actually collected', () => {
- expect(deprecated.length).toBeGreaterThanOrEqual(10);
+ // Coach/player type routes and stats/games/new now redirect via middleware
+ // (see src/lib/supabase/middleware.ts); only on-disk redirect() pages remain.
+ expect(deprecated.length).toBeGreaterThanOrEqual(2);
});
it.each(deprecated.map((e) => [e] as const))(
diff --git a/src/lib/baseball/__tests__/resolve-active-hub.test.ts b/src/lib/baseball/__tests__/resolve-active-hub.test.ts
index 9d522bc30..da38f705d 100644
--- a/src/lib/baseball/__tests__/resolve-active-hub.test.ts
+++ b/src/lib/baseball/__tests__/resolve-active-hub.test.ts
@@ -3,7 +3,10 @@ import {
filterHubTabsByCapabilities,
resolveActiveHub,
} from '@/app/baseball/(dashboard)/_components/resolve-active-hub';
-import { COACH_STATS_TABS } from '@/app/baseball/(dashboard)/_components/hub-definitions';
+import {
+ COACH_MANAGEMENT_TABS,
+ COACH_STATS_TABS,
+} from '@/app/baseball/(dashboard)/_components/hub-definitions';
describe('resolveActiveHub — capability-filtered tabs (#370)', () => {
it('hides performance hub tabs when coach lacks lifting and readiness caps', () => {
@@ -34,4 +37,42 @@ describe('resolveActiveHub — capability-filtered tabs (#370)', () => {
expect(hub?.id).toBe('stats');
expect(hub?.tabs.some((t) => t.id === 'performance-programs')).toBe(false);
});
+
+ it('activates the management hub on nested settings routes', () => {
+ const hub = resolveActiveHub({
+ pathname: '/baseball/dashboard/settings/imports',
+ role: 'coach',
+ programType: 'college',
+ capabilities: { can_manage_settings: true, can_manage_imports: true },
+ });
+ expect(hub?.id).toBe('management');
+ expect(hub?.tabs.some((t) => t.id === 'settings-imports')).toBe(true);
+ });
+
+ it('activates the player recruiting hub on exposure routes', () => {
+ const hub = resolveActiveHub({
+ pathname: '/baseball/dashboard/college-interest',
+ role: 'player',
+ programType: 'high_school',
+ });
+ expect(hub?.id).toBe('recruiting');
+ });
+
+ it('includes settings home via program-settings href (ownedPrefixes, not duplicate matchPrefixes)', () => {
+ const hub = resolveActiveHub({
+ pathname: '/baseball/dashboard/settings',
+ role: 'coach',
+ programType: 'college',
+ capabilities: { can_manage_settings: true },
+ });
+ expect(hub?.id).toBe('management');
+ expect(hub?.tabs.some((t) => t.id === 'settings-home')).toBe(true);
+ });
+});
+
+describe('COACH_MANAGEMENT_TABS — settings supplement anchor', () => {
+ it('includes the program-settings registry tab before settings supplements', () => {
+ expect(COACH_MANAGEMENT_TABS.some((t) => t.id === 'program-settings')).toBe(true);
+ expect(COACH_MANAGEMENT_TABS.some((t) => t.id === 'settings-home')).toBe(true);
+ });
});
diff --git a/src/lib/baseball/__tests__/settings-aliases-and-legacy-redirects.test.ts b/src/lib/baseball/__tests__/settings-aliases-and-legacy-redirects.test.ts
index 93c45b143..dba424693 100644
--- a/src/lib/baseball/__tests__/settings-aliases-and-legacy-redirects.test.ts
+++ b/src/lib/baseball/__tests__/settings-aliases-and-legacy-redirects.test.ts
@@ -156,42 +156,6 @@ const LEGACY_REDIRECT_PAGES: ReadonlyArray<{
relPath: string[];
expectedTargets: readonly string[];
}> = [
- {
- relPath: ['baseball', '(coach-dashboard)', 'coach', 'college', 'page.tsx'],
- expectedTargets: ['/baseball/dashboard/command-center'],
- },
- {
- relPath: ['baseball', '(coach-dashboard)', 'coach', 'high-school', 'page.tsx'],
- expectedTargets: ['/baseball/dashboard/command-center'],
- },
- {
- relPath: ['baseball', '(coach-dashboard)', 'coach', 'juco', 'page.tsx'],
- expectedTargets: ['/baseball/dashboard/command-center'],
- },
- {
- relPath: ['baseball', '(coach-dashboard)', 'coach', 'showcase', 'page.tsx'],
- expectedTargets: ['/baseball/dashboard/command-center'],
- },
- {
- relPath: ['baseball', '(player-dashboard)', 'player', 'college', 'page.tsx'],
- expectedTargets: ['/baseball/player/today'],
- },
- {
- relPath: ['baseball', '(player-dashboard)', 'player', 'high-school', 'page.tsx'],
- expectedTargets: ['/baseball/player/today'],
- },
- {
- relPath: ['baseball', '(player-dashboard)', 'player', 'juco', 'page.tsx'],
- expectedTargets: ['/baseball/player/today'],
- },
- {
- relPath: ['baseball', '(player-dashboard)', 'player', 'showcase', 'page.tsx'],
- expectedTargets: ['/baseball/player/today'],
- },
- {
- relPath: ['baseball', '(dashboard)', 'dashboard', 'team', 'high-school', 'page.tsx'],
- expectedTargets: ['/baseball/dashboard/command-center'],
- },
{
relPath: ['baseball', '(dashboard)', 'dashboard', 'stats', 'page.tsx'],
expectedTargets: ['/baseball/dashboard/stats-center'],
@@ -246,14 +210,9 @@ describe('legacy create/new + role-type redirect pages target existing canonical
},
);
- it('reuses stats-route-aliases.ts coverage for the legacy /stats/games/new create alias', async () => {
- // stats-route-aliases.test.ts already locks this contract directly; this
- // assertion just confirms the alias module + its canonical target both
- // resolve, keeping this file's "legacy create/new style routes" coverage
- // complete without duplicating that suite's assertions.
- const { BASEBALL_STATS_GAME_CREATE_PATH, BASEBALL_STATS_GAME_CREATE_LEGACY_PATH } =
- await import('../stats-route-aliases');
+ it('reuses stats-route-aliases.ts coverage for the canonical game-create route', async () => {
+ const { BASEBALL_STATS_GAME_CREATE_PATH } = await import('../stats-route-aliases');
expect(routeExists(BASEBALL_STATS_GAME_CREATE_PATH)).toBe(true);
- expect(routeExists(BASEBALL_STATS_GAME_CREATE_LEGACY_PATH)).toBe(true);
+ expect(routeExists(['/baseball/dashboard/stats/games', 'new'].join('/'))).toBe(false);
});
});
diff --git a/src/lib/baseball/__tests__/stats-route-aliases.test.ts b/src/lib/baseball/__tests__/stats-route-aliases.test.ts
index 055b98e73..6f3297667 100644
--- a/src/lib/baseball/__tests__/stats-route-aliases.test.ts
+++ b/src/lib/baseball/__tests__/stats-route-aliases.test.ts
@@ -1,8 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
- BASEBALL_STATS_GAME_CREATE_LEGACY_PATH,
BASEBALL_STATS_GAME_CREATE_PATH,
- BASEBALL_STATS_ROUTE_ALIASES,
getBaseballStatsGameCreateHref,
} from '@/lib/baseball/stats-route-aliases';
@@ -12,9 +10,7 @@ describe('stats-route-aliases (#378)', () => {
expect(BASEBALL_STATS_GAME_CREATE_PATH).toBe('/baseball/dashboard/stats/games/create');
});
- it('legacy /new redirects to canonical create', () => {
- expect(BASEBALL_STATS_ROUTE_ALIASES[BASEBALL_STATS_GAME_CREATE_LEGACY_PATH]).toBe(
- BASEBALL_STATS_GAME_CREATE_PATH,
- );
+ it('does not expose the removed /stats/games/new alias', () => {
+ expect(getBaseballStatsGameCreateHref()).not.toContain('/stats/games/new');
});
});
diff --git a/src/lib/baseball/__tests__/with-baseball-action-observability.test.ts b/src/lib/baseball/__tests__/with-baseball-action-observability.test.ts
index 95c557327..4e453392f 100644
--- a/src/lib/baseball/__tests__/with-baseball-action-observability.test.ts
+++ b/src/lib/baseball/__tests__/with-baseball-action-observability.test.ts
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
})),
isCurrentSessionBaseballDemo: vi.fn(async () => false),
logServerException: vi.fn(async (..._args: unknown[]) => undefined),
+ logServerError: vi.fn(async (..._args: unknown[]) => undefined),
scope: {
setTag: vi.fn(),
setUser: vi.fn(),
@@ -37,6 +38,7 @@ vi.mock('@/lib/demo/baseball-config.server', () => ({
vi.mock('@/lib/server-error-logger', () => ({
logServerException: mocks.logServerException,
+ logServerError: mocks.logServerError,
}));
vi.mock('@sentry/nextjs', () => ({
@@ -153,4 +155,30 @@ describe('withBaseballAction observability', () => {
},
});
});
+
+ it('logs { success: false } soft failures to Helm Bridge without throwing', async () => {
+ const action = withBaseballAction(
+ 'uploadBaseballDocument',
+ { featureArea: 'baseball-documents', feature: 'baseball_documents' },
+ async () => ({ success: false as const, error: 'Storage bucket unavailable' }),
+ );
+
+ const result = await action();
+ expect(result).toEqual({ success: false, error: 'Storage bucket unavailable' });
+ expect(mocks.logServerException).not.toHaveBeenCalled();
+ expect(mocks.logServerError).toHaveBeenCalledTimes(1);
+
+ const softCall = mocks.logServerError.mock.calls[0] as
+ | [string, Record | undefined, 'warning' | 'error' | 'critical']
+ | undefined;
+ expect(softCall?.[0]).toBe('Storage bucket unavailable');
+ expect(softCall?.[2]).toBe('error');
+ expect(softCall?.[1]).toMatchObject({
+ action: 'uploadBaseballDocument',
+ sport: 'baseball',
+ feature: 'baseball_documents',
+ source: 'server_action',
+ handled: true,
+ });
+ });
});
diff --git a/src/lib/baseball/nav-manifest.ts b/src/lib/baseball/nav-manifest.ts
index 37ddce514..b46f2f942 100644
--- a/src/lib/baseball/nav-manifest.ts
+++ b/src/lib/baseball/nav-manifest.ts
@@ -61,6 +61,7 @@ import {
PLAYER_STATS_TABS,
PLAYER_DEVELOPMENT_TABS,
PLAYER_TEAM_TABS,
+ PLAYER_RECRUITING_TABS,
} from '@/app/baseball/(dashboard)/_components/hub-definitions';
import type { HubSubNavTab } from '@/app/baseball/(dashboard)/_components/hub-sub-nav';
@@ -148,6 +149,7 @@ const ALL_HUB_TAB_ARRAYS: readonly (readonly HubSubNavTab[])[] = [
PLAYER_STATS_TABS,
PLAYER_DEVELOPMENT_TABS,
PLAYER_TEAM_TABS,
+ PLAYER_RECRUITING_TABS,
];
const hubTabEntries: NavManifestEntry[] = ALL_HUB_TAB_ARRAYS.flatMap((tabs) =>
@@ -185,54 +187,6 @@ const settingsAliasEntries: NavManifestEntry[] = Object.values(
// at runtime, but every static branch resolves to a canonical entry above.
const legacyRedirectEntries: NavManifestEntry[] = [
- {
- href: '/baseball/coach/college',
- source: 'legacy-redirect',
- status: 'deprecated',
- target: '/baseball/dashboard/command-center',
- },
- {
- href: '/baseball/coach/high-school',
- source: 'legacy-redirect',
- status: 'deprecated',
- target: '/baseball/dashboard/command-center',
- },
- {
- href: '/baseball/coach/juco',
- source: 'legacy-redirect',
- status: 'deprecated',
- target: '/baseball/dashboard/command-center',
- },
- {
- href: '/baseball/coach/showcase',
- source: 'legacy-redirect',
- status: 'deprecated',
- target: '/baseball/dashboard/command-center',
- },
- {
- href: '/baseball/player/college',
- source: 'legacy-redirect',
- status: 'deprecated',
- target: '/baseball/player/today',
- },
- {
- href: '/baseball/player/high-school',
- source: 'legacy-redirect',
- status: 'deprecated',
- target: '/baseball/player/today',
- },
- {
- href: '/baseball/player/juco',
- source: 'legacy-redirect',
- status: 'deprecated',
- target: '/baseball/player/today',
- },
- {
- href: '/baseball/player/showcase',
- source: 'legacy-redirect',
- status: 'deprecated',
- target: '/baseball/player/today',
- },
{
href: '/baseball/dashboard/team',
source: 'legacy-redirect',
@@ -243,12 +197,6 @@ const legacyRedirectEntries: NavManifestEntry[] = [
target: '/baseball/dashboard/command-center',
note: 'Branches to /baseball/player/today for players at runtime — both arms are canonical.',
},
- {
- href: '/baseball/dashboard/team/high-school',
- source: 'legacy-redirect',
- status: 'deprecated',
- target: '/baseball/dashboard/command-center',
- },
{
href: '/baseball/dashboard/stats',
source: 'legacy-redirect',
diff --git a/src/lib/baseball/nav-registry.ts b/src/lib/baseball/nav-registry.ts
index 30c82b6f6..f82540889 100644
--- a/src/lib/baseball/nav-registry.ts
+++ b/src/lib/baseball/nav-registry.ts
@@ -229,6 +229,12 @@ export interface BaseballNavEntry {
* role: 'player' entries, which never appear in a coach hub.
*/
hub?: BaseballNavHub;
+ /**
+ * Optional nested-route ownership prefixes for hub sub-nav active matching.
+ * When set, hub-definitions copies this onto HubSubNavTab.matchPrefixes so
+ * detail routes (e.g. /players/[id]) highlight the parent registry tab.
+ */
+ matchPrefixes?: readonly string[];
}
/**
@@ -331,6 +337,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [
requiredCapability: null,
section: 'primary',
hub: 'team',
+ matchPrefixes: ['/baseball/dashboard/players'],
},
{
id: 'calendar',
@@ -489,6 +496,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [
requiredAnyCapabilities: ['can_manage_lifting', 'can_view_readiness'],
section: 'primary',
hub: 'stats-performance',
+ matchPrefixes: ['/baseball/dashboard/performance'],
},
{
id: 'staff-decision-room',
@@ -622,6 +630,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [
requiredCapability: null,
section: 'primary',
hub: 'development',
+ matchPrefixes: ['/baseball/dashboard/dev-plans'],
},
{
id: 'academics',
@@ -647,6 +656,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [
requiredCapability: null,
section: 'primary',
hub: 'recruiting',
+ matchPrefixes: ['/baseball/dashboard/camps'],
},
{
id: 'organization',
@@ -757,15 +767,14 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [
id: 'team',
label: 'Dashboard',
href: '/baseball/dashboard/command-center',
- playerHref: '/baseball/player/today',
icon: IconUsers,
// Backward-compatible secondary landing. The old team dashboard route now
- // redirects here for coaches and to Player Today for players. Classified
+ // redirects here for coaches. Classified
// 'dashboard' for the hub-required-field invariant, but hub-definitions.ts
// deliberately EXCLUDES this id from the Dashboard hub's rendered sub-tabs
// (its href is an exact duplicate of 'command-center' for coaches — it is a
// legacy alias, never a distinct destination worth its own tab).
- role: 'both',
+ role: 'coach',
requiredCapability: null,
section: 'secondary',
hub: 'dashboard',
diff --git a/src/lib/baseball/route-contract.ts b/src/lib/baseball/route-contract.ts
index 154adf8f7..d5f8de066 100644
--- a/src/lib/baseball/route-contract.ts
+++ b/src/lib/baseball/route-contract.ts
@@ -41,6 +41,7 @@ import {
PLAYER_STATS_TABS,
PLAYER_DEVELOPMENT_TABS,
PLAYER_TEAM_TABS,
+ PLAYER_RECRUITING_TABS,
} from '@/app/baseball/(dashboard)/_components/hub-definitions';
import type { HubSubNavTab } from '@/app/baseball/(dashboard)/_components/hub-sub-nav';
@@ -58,7 +59,8 @@ export type HubName =
| 'coach-academics'
| 'player-stats'
| 'player-development'
- | 'player-team';
+ | 'player-team'
+ | 'player-recruiting';
export type DeclaredHrefOrigin =
| { kind: 'nav-registry'; id: string; variant: 'href' | 'playerHref' }
@@ -96,6 +98,7 @@ const HUB_TAB_SOURCES: ReadonlyArray<{
{ hub: 'player-stats', role: 'player', tabs: PLAYER_STATS_TABS },
{ hub: 'player-development', role: 'player', tabs: PLAYER_DEVELOPMENT_TABS },
{ hub: 'player-team', role: 'player', tabs: PLAYER_TEAM_TABS },
+ { hub: 'player-recruiting', role: 'player', tabs: PLAYER_RECRUITING_TABS },
];
/**
@@ -134,7 +137,8 @@ export function collectDeclaredHrefs(): DeclaredHref[] {
role: 'both',
requiredCapability: null,
requiredAnyCapabilities: [],
- matchPrefixes: [],
+ // Thread detail pages (/messages/[id]) are owned by the inbox list route.
+ matchPrefixes: [BASEBALL_MESSAGES_NAV.href],
});
for (const source of HUB_TAB_SOURCES) {
diff --git a/src/lib/baseball/stat-layer-manifest.ts b/src/lib/baseball/stat-layer-manifest.ts
index 466b2707f..1c083a000 100644
--- a/src/lib/baseball/stat-layer-manifest.ts
+++ b/src/lib/baseball/stat-layer-manifest.ts
@@ -48,6 +48,8 @@ export const STAT_LAYER_SCAN_EXCLUDED_FILES = [
'src/lib/types/database.ts',
'src/lib/baseball/stat-layer-manifest.ts',
'src/lib/baseball/__tests__/stat-layer-contract.test.ts',
+ // Helm Bridge metadata — lists primaryTable/heartbeatTable names, not a DB consumer.
+ 'src/lib/admin/feature-registry.ts',
] as const;
export type StatLayerConsumerGroup =
diff --git a/src/lib/baseball/stats-route-aliases.ts b/src/lib/baseball/stats-route-aliases.ts
index d40b2b7ff..db35dd9cc 100644
--- a/src/lib/baseball/stats-route-aliases.ts
+++ b/src/lib/baseball/stats-route-aliases.ts
@@ -1,28 +1,13 @@
// =============================================================================
-// Canonical BaseballHelm stats routes and temporary redirect aliases (#378).
+// Canonical BaseballHelm stats routes.
//
-// Policy: `/stats/games/create` is canonical. `/stats/games/new` redirects until
-// bookmarks and external docs fully migrate (see BASEBALL_STATS_ROUTE_ALIASES).
+// Policy: `/stats/games/create` is canonical. The old `/stats/games/new`
+// redirect alias was removed after app/e2e references were migrated.
// =============================================================================
export const BASEBALL_STATS_GAME_CREATE_PATH =
'/baseball/dashboard/stats/games/create' as const;
-/** Legacy path — kept as a redirect alias only; do not link in app UI. */
-export const BASEBALL_STATS_GAME_CREATE_LEGACY_PATH =
- '/baseball/dashboard/stats/games/new' as const;
-
-/**
- * Redirect aliases: legacy → canonical. App code must use canonical paths only.
- */
-export const BASEBALL_STATS_ROUTE_ALIASES = {
- [BASEBALL_STATS_GAME_CREATE_LEGACY_PATH]: BASEBALL_STATS_GAME_CREATE_PATH,
-} as const;
-
-export type BaseballStatsLegacyRoute = keyof typeof BASEBALL_STATS_ROUTE_ALIASES;
-
-
-
export function getBaseballStatsGameCreateHref(): string {
return BASEBALL_STATS_GAME_CREATE_PATH;
}
diff --git a/src/lib/baseball/with-baseball-action.ts b/src/lib/baseball/with-baseball-action.ts
index c1d042656..0161112f3 100644
--- a/src/lib/baseball/with-baseball-action.ts
+++ b/src/lib/baseball/with-baseball-action.ts
@@ -62,6 +62,7 @@ import type { User } from '@supabase/supabase-js';
import { createClient } from '@/lib/supabase/server';
import { logServerException } from '@/lib/server-error-logger';
+import { observeActionSoftFailure } from '@/lib/admin/observe-action-result';
import { getActiveBaseballContext } from '@/lib/baseball/active-context';
import type { ActiveBaseballContext } from '@/lib/baseball/active-context-shared';
import {
@@ -452,6 +453,7 @@ export function withBaseballAction(
};
const result = await fn(ctx, ...args);
+ observeActionSoftFailure(result, buildTraceContext(true));
scope.addBreadcrumb({
category: 'baseball.action',
message: `done ${name}`,
diff --git a/src/lib/lifting/with-lifting-action.ts b/src/lib/lifting/with-lifting-action.ts
index f666b1e4b..2f8234dca 100644
--- a/src/lib/lifting/with-lifting-action.ts
+++ b/src/lib/lifting/with-lifting-action.ts
@@ -32,6 +32,7 @@ import type { User } from '@supabase/supabase-js';
import { createClient } from '@/lib/supabase/server';
import { logServerEvent, logServerException } from '@/lib/server-error-logger';
+import { observeActionSoftFailure } from '@/lib/admin/observe-action-result';
import { resolveLiftingAccess } from '@/lib/lifting/access';
import { fromUntyped } from '@/lib/supabase/untyped';
import type { HelmLiftingAccessResult } from '@/lib/types/helm-lifting';
@@ -177,6 +178,18 @@ export function withLiftingAction(
// 4. RUN
const ctx: LiftingActionContext = { user, orgId, access };
const result = await fn(ctx, ...args);
+ observeActionSoftFailure(result, {
+ action: name,
+ featureArea,
+ feature: featureArea.replaceAll('-', '_'),
+ source: 'server_action',
+ sport: 'shared',
+ userId: user.id,
+ userEmail: user.email ?? null,
+ teamId: orgId,
+ handled: true,
+ tags: { sport: 'lifting', feature: featureArea, lifting_org: orgId },
+ });
scope.addBreadcrumb({ category: 'lifting.action', message: `done ${name}`, level: 'info' });
return result;
} catch (error) {
diff --git a/src/lib/server-error-logger.ts b/src/lib/server-error-logger.ts
index 6a5c5c4ea..1231dfe71 100644
--- a/src/lib/server-error-logger.ts
+++ b/src/lib/server-error-logger.ts
@@ -5,6 +5,7 @@ import { shouldPersistAdminTables, getRuntimeEnv } from '@/lib/telemetry-gate';
import { createAdminClient } from '@/lib/supabase/admin';
import type { Json } from '@/lib/types/database';
import { buildIncidentSignature, type IncidentSeverity } from '@/lib/admin/incident-grouping';
+import { classifyTraceSurface } from '@/lib/error-trace-classification';
export type ServerTraceSeverity = 'info' | 'warning' | 'error' | 'critical';
export type ServerTraceSource =
@@ -78,7 +79,7 @@ const SENTRY_SEVERITY_MAP: Record = {
critical: 'fatal',
};
-function normalizeContext(context: RoundErrorContext): Record {
+function normalizeContext(context: RoundErrorContext, traceMessage?: string): Record {
return JSON.parse(JSON.stringify({
action: context.action,
route: context.route ?? null,
@@ -104,6 +105,7 @@ function normalizeContext(context: RoundErrorContext): Record {
extra: context.extra ?? {},
sport: context.sport ?? null,
teamId: context.teamId ?? null,
+ ...(traceMessage ? { trace_message: traceMessage.slice(0, 2000) } : {}),
// Only reached when shouldPersistAdminTables() is true (writeAdminTables
// is called after that gate), so this is always 'production' in practice
// today — tagged explicitly so a future gate regression is visible in
@@ -147,16 +149,40 @@ function buildFingerprint(context: RoundErrorContext, severity: ServerTraceSever
];
}
+/** Infer sport when legacy emitters omit context.sport (most baseball actions pre-withBaseballAction). */
+function inferSport(message: string, context: RoundErrorContext): NonNullable | null {
+ if (context.sport) return context.sport;
+ const probe = `${context.route ?? ''} ${context.url ?? ''} ${context.action ?? ''}`;
+ const classified = classifyTraceSurface(probe, context.action ?? null);
+ if (classified.sport) return classified.sport;
+ if (/baseball/i.test(message)) return 'baseball';
+ if (/\bgolf\b|coachhelm/i.test(message)) return 'golf';
+ return null;
+}
+
+function enrichTraceContext(message: string, context: RoundErrorContext): RoundErrorContext {
+ const sport = inferSport(message, context);
+ if (!sport) return context;
+ const probe = `${context.route ?? ''} ${context.url ?? ''} ${context.action ?? ''} ${message}`;
+ const classified = classifyTraceSurface(probe, context.action ?? null);
+ return {
+ ...context,
+ sport,
+ feature: context.feature ?? classified.feature ?? context.featureArea ?? null,
+ };
+}
+
async function writeAdminTables(
message: string,
error: Error | null,
context: RoundErrorContext,
severity: ServerTraceSeverity
) {
+ const enriched = enrichTraceContext(message, context);
const admin = createAdminClient();
- const normalizedContext = normalizeContext(context);
- const url = buildUrl(context);
- const title = buildAdminTitle(message, context, severity);
+ const normalizedContext = normalizeContext(enriched);
+ const url = buildUrl(enriched);
+ const title = buildAdminTitle(message, enriched, severity);
const stack = error?.stack?.slice(0, 8000) ?? null;
const timestamp = new Date().toISOString();
@@ -185,21 +211,35 @@ async function writeAdminTables(
severity,
message: message.slice(0, 10000),
metadata: normalizedContext as Json,
- user_id: context.userId ?? null,
- user_email: context.userEmail ?? null,
+ user_id: enriched.userId ?? null,
+ user_email: enriched.userEmail ?? null,
url,
stack_trace: stack,
browser_info: null,
- sport: context.sport ?? null,
- team_id: context.teamId ?? null,
+ sport: enriched.sport ?? null,
+ team_id: enriched.teamId ?? null,
fingerprint: dbFingerprint,
- source: context.source ?? 'server_action',
- feature: context.feature ?? context.featureArea ?? null,
+ source: enriched.source ?? 'server_action',
+ feature: enriched.feature ?? enriched.featureArea ?? null,
});
await Promise.allSettled([errorLogInsert, adminEventInsert]);
}
+/** Static Sentry title — user/error copy lives in scope context, not the format string. */
+function sentryCaptureTitle(context: RoundErrorContext): string {
+ if (context.title?.trim()) {
+ return context.title.trim().slice(0, 200);
+ }
+ return `[${context.action}] server trace`;
+}
+
+function syntheticTraceError(message: string): Error {
+ const err = new Error('Server trace error');
+ err.cause = message;
+ return err;
+}
+
function captureSentryTrace(
message: string,
error: Error | null,
@@ -213,6 +253,7 @@ function captureSentryTrace(
scope.setTag('error_source', context.source ?? 'server_action');
scope.setTag('feature_area', context.featureArea ?? 'unknown');
scope.setTag('feature', context.feature ?? context.featureArea ?? 'unknown');
+ if (context.sport) scope.setTag('sport', context.sport);
scope.setTag('handled', String(context.handled ?? true));
if (context.errorCode) scope.setTag('pg_error_code', context.errorCode);
if (context.statusCode) scope.setTag('http_status', String(context.statusCode));
@@ -233,7 +274,7 @@ function captureSentryTrace(
});
}
- scope.setContext('server_trace', normalizeContext(context));
+ scope.setContext('server_trace', normalizeContext(context, message));
scope.setFingerprint(buildFingerprint(context, severity));
// Route by severity: info/warning are messages (control-flow signals,
@@ -244,10 +285,11 @@ function captureSentryTrace(
// logServerException callers who explicitly handed us an Error.
const isMessage =
!forceException && (severity === 'info' || severity === 'warning');
+ const sentryTitle = sentryCaptureTitle(context);
if (isMessage) {
- Sentry.captureMessage(message, SENTRY_SEVERITY_MAP[severity] ?? 'warning');
+ Sentry.captureMessage(sentryTitle, SENTRY_SEVERITY_MAP[severity] ?? 'warning');
} else {
- Sentry.captureException(error ?? new Error(message));
+ Sentry.captureException(error ?? syntheticTraceError(message));
}
});
}
@@ -264,7 +306,8 @@ function isNextControlFlowError(message: string, error: Error | null): boolean {
if (typeof digest === 'string' && (digest === 'DYNAMIC_SERVER_USAGE' || digest.startsWith('NEXT_'))) {
return true;
}
- return message.includes('Dynamic server usage:');
+ const controlFlowMarker = 'Dynamic server usage:';
+ return message.indexOf(controlFlowMarker) !== -1;
}
async function captureServerTrace(
@@ -274,29 +317,37 @@ async function captureServerTrace(
error?: Error | null,
forceException = false,
): Promise {
- const normalizedError = error ?? new Error(message);
+ const enriched = enrichTraceContext(message, context);
+ const normalizedError = error ?? syntheticTraceError(message);
// Skip, don't rethrow: callers already decide how the original error
// propagates; our only job is to not record framework signals as incidents.
if (isNextControlFlowError(message, error ?? null)) return;
- if (!context.skipSentry) {
+ if (!enriched.skipSentry) {
try {
- captureSentryTrace(message, normalizedError, context, severity, forceException);
+ captureSentryTrace(message, normalizedError, enriched, severity, forceException);
} catch {
// Sentry should never block request handling.
}
}
if (!shouldPersistAdminTables()) {
- console.error(`[ServerErrorLogger] (${severity}, not persisted off-prod)`, message, context.action ?? '');
+ console.error('[ServerErrorLogger] not persisted off-prod', {
+ severity,
+ traceMessage: message,
+ action: enriched.action ?? '',
+ });
return;
}
try {
- await writeAdminTables(message, normalizedError, context, severity);
+ await writeAdminTables(message, normalizedError, enriched, severity);
} catch {
- console.error('[ServerErrorLogger] Failed to persist trace:', message, context);
+ console.error('[ServerErrorLogger] Failed to persist trace', {
+ traceMessage: message,
+ context: enriched,
+ });
}
}
diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts
index 8717c06f2..bb5625ed0 100644
--- a/src/lib/supabase/middleware.ts
+++ b/src/lib/supabase/middleware.ts
@@ -128,6 +128,28 @@ const ORG_PROGRAM_TYPES = new Set(['showcase', 'academy', '
const COACH_HOME = '/baseball/dashboard/command-center';
const PLAYER_HOME = '/baseball/player/today';
+/**
+ * Bookmark-compat redirects for legacy per-type landing stubs removed once the
+ * canonical Fairway dashboard routes shipped. Checked before auth so old links
+ * never 404.
+ */
+const LEGACY_BASEBALL_ROUTE_REDIRECTS: Readonly> = {
+ '/baseball/coach/college': COACH_HOME,
+ '/baseball/coach/high-school': COACH_HOME,
+ '/baseball/coach/juco': COACH_HOME,
+ '/baseball/coach/showcase': COACH_HOME,
+ '/baseball/player/college': PLAYER_HOME,
+ '/baseball/player/high-school': PLAYER_HOME,
+ '/baseball/player/juco': PLAYER_HOME,
+ '/baseball/player/showcase': PLAYER_HOME,
+ '/baseball/dashboard/team/high-school': COACH_HOME,
+ '/baseball/dashboard/stats/games/new': '/baseball/dashboard/stats/games/create',
+};
+
+function legacyBaseballRedirectTarget(pathname: string): string | null {
+ return LEGACY_BASEBALL_ROUTE_REDIRECTS[pathname] ?? null;
+}
+
/**
* Check if user is authorized to access the requested route based on their role
*/
@@ -309,6 +331,13 @@ export async function updateSession(request: NextRequest) {
const sport = getSportFromPath(pathname);
const onAdminPath = isAdminPath(pathname);
+ if (sport === 'baseball') {
+ const legacyTarget = legacyBaseballRedirectTarget(pathname);
+ if (legacyTarget) {
+ return NextResponse.redirect(new URL(legacyTarget, request.url));
+ }
+ }
+
if (isNativeUserAgent(request) && isMarketingRoute(pathname)) {
return NextResponse.redirect(new URL('/golf/login', request.url));
}
diff --git a/src/test/helpers/baseball-route-inventory.ts b/src/test/helpers/baseball-route-inventory.ts
index e7ef6bf90..09d3a34dd 100644
--- a/src/test/helpers/baseball-route-inventory.ts
+++ b/src/test/helpers/baseball-route-inventory.ts
@@ -109,6 +109,66 @@ export const REDIRECT_ALIAS_MANIFEST: RedirectAlias[] = [
'short-circuits it before the page even renders.',
implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_TEAM_ROUTES, COACH_HOME)',
},
+ {
+ from: '/baseball/coach/college',
+ to: '/baseball/dashboard/command-center',
+ reason: 'Legacy per-coach-type bookmark; stub page removed in favor of canonical Command Center.',
+ implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)',
+ },
+ {
+ from: '/baseball/coach/high-school',
+ to: '/baseball/dashboard/command-center',
+ reason: 'Legacy per-coach-type bookmark; stub page removed in favor of canonical Command Center.',
+ implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)',
+ },
+ {
+ from: '/baseball/coach/juco',
+ to: '/baseball/dashboard/command-center',
+ reason: 'Legacy per-coach-type bookmark; stub page removed in favor of canonical Command Center.',
+ implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)',
+ },
+ {
+ from: '/baseball/coach/showcase',
+ to: '/baseball/dashboard/command-center',
+ reason: 'Legacy per-coach-type bookmark; stub page removed in favor of canonical Command Center.',
+ implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)',
+ },
+ {
+ from: '/baseball/player/college',
+ to: '/baseball/player/today',
+ reason: 'Legacy per-program-type bookmark; stub page removed in favor of canonical Player Today.',
+ implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)',
+ },
+ {
+ from: '/baseball/player/high-school',
+ to: '/baseball/player/today',
+ reason: 'Legacy per-program-type bookmark; stub page removed in favor of canonical Player Today.',
+ implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)',
+ },
+ {
+ from: '/baseball/player/juco',
+ to: '/baseball/player/today',
+ reason: 'Legacy per-program-type bookmark; stub page removed in favor of canonical Player Today.',
+ implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)',
+ },
+ {
+ from: '/baseball/player/showcase',
+ to: '/baseball/player/today',
+ reason: 'Legacy per-program-type bookmark; stub page removed in favor of canonical Player Today.',
+ implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)',
+ },
+ {
+ from: '/baseball/dashboard/team/high-school',
+ to: '/baseball/dashboard/command-center',
+ reason: 'Legacy HS team dashboard bookmark; stub page removed in favor of Command Center.',
+ implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)',
+ },
+ {
+ from: '/baseball/dashboard/stats/games/new',
+ to: '/baseball/dashboard/stats/games/create',
+ reason: 'Legacy game-create alias; canonical path is /stats/games/create.',
+ implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)',
+ },
];
// -----------------------------------------------------------------------------
diff --git a/src/test/lib/auth/session-idle-shared.test.ts b/src/test/lib/auth/session-idle-shared.test.ts
index 0bedd6fc5..747decc1d 100644
--- a/src/test/lib/auth/session-idle-shared.test.ts
+++ b/src/test/lib/auth/session-idle-shared.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
SESSION_IDLE_TIMEOUT_MS,
SESSION_IDLE_COOKIE_MAX_AGE_S,
+ SESSION_VISIBLE_HEARTBEAT_MS,
parseLastActivity,
isSessionIdleExpired,
} from '@/lib/auth/session-idle-shared';
@@ -11,6 +12,10 @@ describe('session-idle-shared', () => {
expect(SESSION_IDLE_TIMEOUT_MS).toBe(5 * 60 * 1000);
});
+ it('visible heartbeat is shorter than the idle window', () => {
+ expect(SESSION_VISIBLE_HEARTBEAT_MS).toBeLessThan(SESSION_IDLE_TIMEOUT_MS);
+ });
+
it('cookie lifetime is much longer than the timeout (so a stale marker survives to be detected)', () => {
// The reopen-after-idle case must be "present + stale", never "absent".
expect(SESSION_IDLE_COOKIE_MAX_AGE_S * 1000).toBeGreaterThan(SESSION_IDLE_TIMEOUT_MS * 100);