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/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..c9f2f81c2
--- /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/(dashboard)/BaseballFairwayShell.tsx b/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx
index 60fa82b09..571a28bd6 100644
--- a/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx
+++ b/src/app/baseball/(dashboard)/BaseballFairwayShell.tsx
@@ -481,7 +481,13 @@ 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 }) {
+export function BaseballFairwayShell({
+ children,
+ authVerified = false,
+}: {
+ children: React.ReactNode;
+ authVerified?: boolean;
+}) {
// SAME auth gate BaseballShellLayout uses for this route group (accepts
// both roles; requiredRole=null).
const { loading, authorized, role } = useBaseballAuth(null);
@@ -489,7 +495,7 @@ export function BaseballFairwayShell({ children }: { children: React.ReactNode }
// passes into BaseballDashboardShell.
const { navContext } = useBaseballNavContext();
- if (loading || !authorized) {
+ if (!authVerified && (loading || !authorized)) {
return ;
}
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/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/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/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/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..699e7caf5 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);
+
+ let 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..04388d8dc
--- /dev/null
+++ b/src/lib/admin/observe-action-result.ts
@@ -0,0 +1,105 @@
+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);
+
+ void 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(() => {});
+}
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__/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/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..99ac85067 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 =
@@ -147,16 +148,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,16 +210,16 @@ 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]);
@@ -213,6 +238,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));
@@ -274,29 +300,30 @@ async function captureServerTrace(
error?: Error | null,
forceException = false,
): Promise {
+ const enriched = enrichTraceContext(message, context);
const normalizedError = error ?? new Error(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] (${severity}, not persisted off-prod)`, message, 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:', message, enriched);
}
}
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);