From 328af2f87f4ee7ee92eef1dbf725b5b519a5af69 Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Sat, 4 Jul 2026 14:02:30 -0400 Subject: [PATCH 1/2] fix(admin): wire Helm Bridge soft failures, unify incident feed, polish baseball auth Log { success: false } server-action returns to admin_events via observeActionSoftFailure in withBaseballAction, withLiftingAction, and withAdminObserved so toast failures surface in Bridge. Unify Overview, triage, and Errors tab on a shared 24h incident feed with consistent Sentry windowing. Fix baseball login triple-loading cascade with auth/nav caching and a branded bootstrap loader. Add Ben+Leah issue board workflow, Work log PR timeline tab, and session-activity hardening. Co-authored-by: Cursor --- .env.example | 8 + src/app/admin/_components/AdminShell.tsx | 6 +- .../_components/__tests__/admin-nav.test.ts | 4 +- src/app/admin/_components/admin-nav.ts | 2 + src/app/admin/ben-leah/BenLeahIssueBoard.tsx | 169 +++++++++++++ src/app/admin/ben-leah/BenLeahIssueTable.tsx | 100 ++++++++ .../ben-leah/BenLeahIssueWorkflowSelect.tsx | 70 ++++++ src/app/admin/ben-leah/actions.ts | 43 ++++ src/app/admin/ben-leah/page.tsx | 31 ++- src/app/admin/errors/page.tsx | 64 ++++- src/app/admin/page.tsx | 30 +-- src/app/admin/work/WorkTimeline.tsx | 226 ++++++++++++++++++ src/app/admin/work/page.tsx | 113 +++++++++ .../(dashboard)/BaseballFairwayShell.tsx | 10 +- src/app/baseball/(dashboard)/layout.tsx | 24 +- .../coverage-contract.observability.test.ts | 101 ++++++++ src/components/auth/baseball-sign-in-form.tsx | 11 +- .../baseball/BaseballDashboardBootstrap.tsx | 20 ++ .../baseball/BaseballShellLayout.tsx | 5 +- .../providers/SessionActivityProvider.tsx | 3 + .../__tests__/use-baseball-auth.test.tsx | 9 +- src/hooks/use-baseball-auth.ts | 60 +++-- src/hooks/use-baseball-nav-context.ts | 51 +++- .../server-error-logger-bridge.test.ts | 11 + .../admin/__tests__/ben-leah-issues.test.ts | 125 ++++++++++ .../__tests__/observe-action-result.test.ts | 81 +++++++ .../admin/__tests__/pr-body-parser.test.ts | 57 +++++ src/lib/admin/ben-leah-issue-tracker.ts | 198 +++++++++++++++ src/lib/admin/ben-leah-issues.ts | 168 +++++++++++++ .../data/__tests__/incident-feed.test.ts | 66 +++++ src/lib/admin/data/errors.ts | 91 ++++--- src/lib/admin/data/incident-feed.ts | 121 ++++++++++ src/lib/admin/data/overview.ts | 44 ++-- src/lib/admin/data/triage.ts | 41 +--- src/lib/admin/github-feedback.ts | 54 +++-- src/lib/admin/github-issues-config.ts | 25 ++ src/lib/admin/github-issues-workflow.ts | 90 +++++++ src/lib/admin/github-pr-timeline.ts | 197 +++++++++++++++ src/lib/admin/observe-action-result.ts | 105 ++++++++ src/lib/admin/observed-action.ts | 25 +- src/lib/admin/pr-body-parser.ts | 178 ++++++++++++++ src/lib/auth/session-activity.ts | 68 +++++- src/lib/auth/session-idle-shared.ts | 7 + ...with-baseball-action-observability.test.ts | 28 +++ src/lib/baseball/with-baseball-action.ts | 2 + src/lib/lifting/with-lifting-action.ts | 13 + src/lib/server-error-logger.ts | 55 +++-- src/test/lib/auth/session-idle-shared.test.ts | 5 + 48 files changed, 2798 insertions(+), 217 deletions(-) create mode 100644 src/app/admin/ben-leah/BenLeahIssueBoard.tsx create mode 100644 src/app/admin/ben-leah/BenLeahIssueTable.tsx create mode 100644 src/app/admin/ben-leah/BenLeahIssueWorkflowSelect.tsx create mode 100644 src/app/admin/work/WorkTimeline.tsx create mode 100644 src/app/admin/work/page.tsx create mode 100644 src/app/golf/actions/__tests__/coverage-contract.observability.test.ts create mode 100644 src/components/baseball/BaseballDashboardBootstrap.tsx create mode 100644 src/lib/admin/__tests__/ben-leah-issues.test.ts create mode 100644 src/lib/admin/__tests__/observe-action-result.test.ts create mode 100644 src/lib/admin/__tests__/pr-body-parser.test.ts create mode 100644 src/lib/admin/ben-leah-issue-tracker.ts create mode 100644 src/lib/admin/ben-leah-issues.ts create mode 100644 src/lib/admin/data/__tests__/incident-feed.test.ts create mode 100644 src/lib/admin/data/incident-feed.ts create mode 100644 src/lib/admin/github-issues-config.ts create mode 100644 src/lib/admin/github-issues-workflow.ts create mode 100644 src/lib/admin/github-pr-timeline.ts create mode 100644 src/lib/admin/observe-action-result.ts create mode 100644 src/lib/admin/pr-body-parser.ts 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..8c9b98000 100644 --- a/src/app/admin/_components/__tests__/admin-nav.test.ts +++ b/src/app/admin/_components/__tests__/admin-nav.test.ts @@ -11,17 +11,19 @@ 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(); diff --git a/src/app/admin/_components/admin-nav.ts b/src/app/admin/_components/admin-nav.ts index e2ecbc43e..c3872f115 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,6 +31,7 @@ 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' }, 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]) => ( +
+ +

{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 ( +
+ + + + + + + + + + + + + + + {issues.map((issue) => ( + + ))} + +
IssueDerived statusSet workflowTypePriorityCategoryUpdatedClosed
+
+ ); +} 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 ? ( + + ) : null} + {WORKFLOW_OPTIONS.map((option) => ( + + ))} + + {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..015d41d08 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -98,11 +98,11 @@ async function BannerAndKpis() {
w.label === 'Error pipeline'), href: '/admin/errors', }, @@ -281,7 +281,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 +339,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 +360,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}

diff --git a/src/app/admin/work/WorkTimeline.tsx b/src/app/admin/work/WorkTimeline.tsx new file mode 100644 index 000000000..53335f9c9 --- /dev/null +++ b/src/app/admin/work/WorkTimeline.tsx @@ -0,0 +1,226 @@ +import Link from 'next/link'; +import { ExternalLink, GitPullRequest } from 'lucide-react'; +import type { WorkLogEntry } from '@/lib/admin/github-pr-timeline'; +import type { WorkArea } from '@/lib/admin/pr-body-parser'; +import { StatusPill, Surface, StatTile, type FwStatusTone } from '@/components/fairway'; + +const AREA_META: Record = { + 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} +

+
+
+ +
+
+

Problem

+

{problem}

+
+
+

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 ? ( + + ) : ( + + )} + + + +
+ + +
+ ); +} 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/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); From b529d2e6ee54c763f3d3f027a9d8f0869ef85737 Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Sat, 4 Jul 2026 14:05:41 -0400 Subject: [PATCH 2/2] fix(admin): repair broken Bridge command shortcuts and player onboarding redirect Replace phantom /admin/audit link with /admin/auth, centralize command-header shortcuts in admin-nav, and send completed players to /baseball/player/today instead of an extra /baseball/dashboard dispatcher hop. Co-authored-by: Cursor --- .../_components/__tests__/admin-nav.test.ts | 10 +++++++++- src/app/admin/_components/admin-nav.ts | 8 ++++++++ src/app/admin/page.tsx | 17 +++++++++-------- .../complete-player-onboarding.test.ts | 2 +- src/app/baseball/actions/onboarding.ts | 2 +- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/app/admin/_components/__tests__/admin-nav.test.ts b/src/app/admin/_components/__tests__/admin-nav.test.ts index 8c9b98000..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', () => { @@ -28,4 +28,12 @@ describe('ADMIN_NAV', () => { 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 c3872f115..031dc2feb 100644 --- a/src/app/admin/_components/admin-nav.ts +++ b/src/app/admin/_components/admin-nav.ts @@ -38,6 +38,14 @@ export const ADMIN_NAV: readonly AdminNavEntry[] = [ { 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/page.tsx b/src/app/admin/page.tsx index 015d41d08..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'; @@ -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() {