diff --git a/.env.example b/.env.example index d27eaa80b..c0b20b4b7 100644 --- a/.env.example +++ b/.env.example @@ -202,6 +202,14 @@ GITHUB_ISSUES_REPO=njrini99-code/helmv3 # Private Supabase Storage bucket used for screenshot signed links. HELM_BRIDGE_FEEDBACK_BUCKET=helm-bridge-feedback +# ----------------------------------------------------------------------------- +# Helm Bridge Work log (PR timeline tab) +# ----------------------------------------------------------------------------- +# Comma-separated GitHub logins to filter PRs (e.g. your username). Omit to show all repo PRs. +GITHUB_PR_AUTHOR_LOGINS=your-github-username +# Max PRs to fetch (default 60, max 100) +# GITHUB_PR_FETCH_LIMIT=60 + # ----------------------------------------------------------------------------- # Inngest (Durable workflows — replaces scattered cron + retry loops) # ----------------------------------------------------------------------------- diff --git a/scripts/__tests__/baseball-stale-route-links.test.mjs b/scripts/__tests__/baseball-stale-route-links.test.mjs index c780991f3..f4fd7ca02 100644 --- a/scripts/__tests__/baseball-stale-route-links.test.mjs +++ b/scripts/__tests__/baseball-stale-route-links.test.mjs @@ -9,15 +9,6 @@ const SCAN_ROOTS = [ join(REPO_ROOT, 'e2e'), ]; const ALLOWLIST = new Set([ - join(REPO_ROOT, 'src/lib/baseball/stats-route-aliases.ts'), - join( - REPO_ROOT, - 'src/app/baseball/(dashboard)/dashboard/stats/games/new/page.tsx', - ), - join( - REPO_ROOT, - 'src/app/baseball/(dashboard)/dashboard/stats/games/new/error.tsx', - ), ]); const STALE_HREF = /\/baseball\/dashboard\/stats\/games\/new(?![\w-])/; diff --git a/src/app/admin/_components/AdminShell.tsx b/src/app/admin/_components/AdminShell.tsx index ee7c60d8e..c74c058ac 100644 --- a/src/app/admin/_components/AdminShell.tsx +++ b/src/app/admin/_components/AdminShell.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { LayoutDashboard, Activity, AlertTriangle, KeyRound, Flag, CircleDot, - Users, Timer, Rocket, HeartPulse, ExternalLink, MessageSquarePlus, Gauge, SearchCheck, + Users, Timer, Rocket, HeartPulse, ExternalLink, MessageSquarePlus, Gauge, SearchCheck, ScrollText, } from 'lucide-react'; import { AppShell, @@ -16,6 +16,7 @@ import { type CommandGroup, type CommandItem, } from '@/components/fairway'; +import { SessionActivityProvider } from '@/components/providers/SessionActivityProvider'; import { ADMIN_NAV, hrefForShortcut } from './admin-nav'; /** Sub-route leaf labels the Breadcrumb trail can't derive from ADMIN_NAV @@ -58,6 +59,7 @@ const NAV_ICON_BY_HREF = { '/admin/golf': Flag, '/admin/baseball': CircleDot, '/admin/ben-leah': MessageSquarePlus, + '/admin/work': ScrollText, '/admin/users': Users, '/admin/jobs': Timer, '/admin/deploys': Rocket, @@ -204,6 +206,7 @@ export function AdminShell({ ); return ( +
+
); } diff --git a/src/app/admin/_components/__tests__/admin-nav.test.ts b/src/app/admin/_components/__tests__/admin-nav.test.ts index 9894779f8..1145aeeec 100644 --- a/src/app/admin/_components/__tests__/admin-nav.test.ts +++ b/src/app/admin/_components/__tests__/admin-nav.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { ADMIN_NAV, hrefForShortcut } from '@/app/admin/_components/admin-nav'; +import { ADMIN_NAV, ADMIN_COMMAND_SHORTCUTS, hrefForShortcut } from '@/app/admin/_components/admin-nav'; describe('ADMIN_NAV', () => { it('declares the canonical tabs in order', () => { @@ -11,19 +11,29 @@ describe('ADMIN_NAV', () => { '/admin/golf', '/admin/baseball', '/admin/ben-leah', + '/admin/work', '/admin/users', '/admin/jobs', '/admin/deploys', '/admin/health', ]); - expect(ADMIN_NAV.map((e) => e.key)).toEqual(['1', '2', '3', '4', '5', '6', 'B', '7', '8', '9', '0']); + expect(ADMIN_NAV.map((e) => e.key)).toEqual(['1', '2', '3', '4', '5', '6', 'B', 'W', '7', '8', '9', '0']); }); it('maps shortcut keys to hrefs', () => { expect(hrefForShortcut('2')).toBe('/admin/activity'); expect(hrefForShortcut('3')).toBe('/admin/errors'); expect(hrefForShortcut('B')).toBe('/admin/ben-leah'); + expect(hrefForShortcut('W')).toBe('/admin/work'); expect(hrefForShortcut('9')).toBe('/admin/deploys'); expect(hrefForShortcut('0')).toBe('/admin/health'); expect(hrefForShortcut('x')).toBeNull(); }); + + it('keeps command-center shortcuts on real Bridge tabs', () => { + const navHrefs = new Set(ADMIN_NAV.map((entry) => entry.href)); + for (const shortcut of ADMIN_COMMAND_SHORTCUTS) { + expect(navHrefs.has(shortcut.href), `${shortcut.href} is not a registered admin tab`).toBe(true); + } + expect(ADMIN_COMMAND_SHORTCUTS.map((s) => s.href)).not.toContain('/admin/audit'); + }); }); diff --git a/src/app/admin/_components/admin-nav.ts b/src/app/admin/_components/admin-nav.ts index e2ecbc43e..031dc2feb 100644 --- a/src/app/admin/_components/admin-nav.ts +++ b/src/app/admin/_components/admin-nav.ts @@ -6,6 +6,7 @@ type AdminHref = | '/admin/golf' | '/admin/baseball' | '/admin/ben-leah' + | '/admin/work' | '/admin/users' | '/admin/jobs' | '/admin/deploys' @@ -30,12 +31,21 @@ export const ADMIN_NAV: readonly AdminNavEntry[] = [ { label: 'Golf', href: '/admin/golf', key: '5', section: 'Apps', description: 'GolfHelm production signals' }, { label: 'Baseball', href: '/admin/baseball', key: '6', section: 'Apps', description: 'BaseballHelm production signals' }, { label: 'Ben + Leah', href: '/admin/ben-leah', key: 'B', section: 'Operations', description: 'Submit bugs, changes, additions', meta: 'issues' }, + { label: 'Work log', href: '/admin/work', key: 'W', section: 'Operations', description: 'PR timeline — problems, fixes, areas', meta: 'prs' }, { label: 'Users & Teams', href: '/admin/users', key: '7', section: 'Platform', description: 'Accounts, teams, engagement' }, { label: 'Jobs & Integrity', href: '/admin/jobs', key: '8', section: 'Platform', description: 'Crons, guards, integrity checks' }, { label: 'Deploys & Infra', href: '/admin/deploys', key: '9', section: 'Platform', description: 'Vercel releases and web insight' }, { label: 'Health', href: '/admin/health', key: '0', section: 'Platform', description: 'Feature health across every app', meta: 'map' }, ] as const; +/** Quick links in the Overview command header — must be real ADMIN_NAV routes. */ +export const ADMIN_COMMAND_SHORTCUTS = [ + { href: '/admin/errors', label: 'Errors' }, + { href: '/admin/health', label: 'Feature Map' }, + { href: '/admin/deploys', label: 'Deploys' }, + { href: '/admin/auth', label: 'Auth' }, +] as const satisfies ReadonlyArray<{ href: AdminHref; label: string }>; + export function hrefForShortcut(key: string): string | null { return ADMIN_NAV.find((e) => e.key === key)?.href ?? null; } diff --git a/src/app/admin/ben-leah/BenLeahIssueBoard.tsx b/src/app/admin/ben-leah/BenLeahIssueBoard.tsx new file mode 100644 index 000000000..b58cd380f --- /dev/null +++ b/src/app/admin/ben-leah/BenLeahIssueBoard.tsx @@ -0,0 +1,169 @@ +import Link from 'next/link'; +import { GitPullRequest, Rocket } from 'lucide-react'; +import { + fetchBenLeahIssueBoard, + type BenLeahTrackStatus, +} from '@/lib/admin/ben-leah-issues'; +import { PanelNoData, PanelStale } from '../_components/PanelStates'; +import { StatusPill, StatTile, type FwStatusTone } from '@/components/fairway'; +import { BenLeahIssueTable } from './BenLeahIssueTable'; + +const TRACK_META: Record< + BenLeahTrackStatus, + { label: string; tone: FwStatusTone; description: string } +> = { + open: { + label: 'Open', + tone: 'warning', + description: 'Still open on GitHub — waiting for work.', + }, + in_progress: { + label: 'In progress', + tone: 'info', + description: 'Label status:in-progress or status:triaged on the issue.', + }, + in_production: { + label: 'In production', + tone: 'success', + description: 'Closed and shipped, or label status:in-production.', + }, + fixed_pending_deploy: { + label: 'Fixed · pending deploy', + tone: 'warning', + description: 'Closed on GitHub; production has not deployed since the fix.', + }, + fixed: { + label: 'Fixed', + tone: 'success', + description: 'Closed on GitHub (deploy timing unknown).', + }, + wont_fix: { + label: "Won't fix", + tone: 'neutral', + description: 'Label status:wontfix on the issue.', + }, +}; + +function formatWhen(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function githubIntegrationTone(status: string): FwStatusTone { + if (status === 'ok') return 'success'; + if (status === 'unconfigured') return 'warning'; + return 'danger'; +} + +export async function BenLeahIssueBoard() { + const board = await fetchBenLeahIssueBoard(); + + if (board.status === 'unconfigured') { + return ( + + ); + } + + if (board.status === 'error' || !board.data) { + return ; + } + + const { issues, counts, repoLabel, repoIssuesUrl, productionCommitSha, productionReadyAt, fetchedAt } = { + ...board.data, + fetchedAt: board.fetchedAt, + }; + + const openCount = counts.open + counts.in_progress; + const shippedCount = counts.in_production; + const pendingDeployCount = counts.fixed_pending_deploy; + + return ( +
+
+
+ + GitHub {board.status === 'ok' ? 'live' : 'stale'} + + + {productionReadyAt + ? `prod ${productionCommitSha?.slice(0, 7) ?? 'build'}` + : 'prod timing n/a'} + + {fetchedAt ? ( + + checked {new Date(fetchedAt).toLocaleTimeString()} + + ) : null} +
+ + + All issues on {repoLabel} + +
+ +
+ {( + [ + ['Open / in progress', openCount, 'Needs attention'], + ['In production', shippedCount, 'Shipped or labeled'], + ['Pending deploy', pendingDeployCount, 'Closed, not on prod yet'], + ['Total tracked', issues.length, 'Last 50 updates'], + ] as const + ).map(([label, value, caption]) => ( +
+ +

{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..3479d03ea --- /dev/null +++ b/src/app/admin/ben-leah/BenLeahIssueWorkflowSelect.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { + currentWorkflowSelection, + type BenLeahTrackedIssue, + type BenLeahWorkflowSelection, +} from '@/lib/admin/ben-leah-issue-tracker'; +import { NativeSelect } from '@/components/ui/native-select'; +import { cn } from '@/lib/utils'; +import { updateBenLeahIssueWorkflow } from './actions'; + +const WORKFLOW_OPTIONS: Array<{ value: BenLeahWorkflowSelection; label: string }> = [ + { value: 'triaged', label: 'Triaged' }, + { value: 'in_progress', label: 'In progress' }, + { value: 'in_production', label: 'In production' }, + { value: 'wont_fix', label: "Won't fix" }, +]; + +const selectClass = cn( + 'min-w-[9.5rem] rounded-fw-md border border-border-subtle bg-surface px-2 py-1 text-xs text-warm-900 shadow-flat outline-none', + 'focus:border-accent-500 focus:ring-2 focus:ring-accent-500/20 disabled:opacity-60', +); + +export function BenLeahIssueWorkflowSelect({ issue }: { issue: BenLeahTrackedIssue }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const current = currentWorkflowSelection(issue.labels); + + const onChange = (raw: string) => { + if (!raw) return; + const next = raw as BenLeahWorkflowSelection; + if (next === current) return; + setError(null); + startTransition(async () => { + const result = await updateBenLeahIssueWorkflow(issue.number, issue.labels, next); + if (!result.ok) { + setError(result.message); + return; + } + router.refresh(); + }); + }; + + return ( +
+ onChange(event.target.value)} + > + {!current ? ( + + ) : 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..d55635855 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -21,6 +21,7 @@ import { PanelBoundary } from './_components/PanelBoundary'; import { PanelAllClear, PanelNoData, PanelStale } from './_components/PanelStates'; import { FeatureHealthRollup } from './_components/FeatureHealthRollup'; import { SkeletonStat, SkeletonList, Surface, Eyebrow, StatusPill } from '@/components/fairway'; +import { ADMIN_COMMAND_SHORTCUTS } from './_components/admin-nav'; export const dynamic = 'force-dynamic'; @@ -98,11 +99,11 @@ async function BannerAndKpis() {
w.label === 'Error pipeline'), href: '/admin/errors', }, @@ -281,7 +282,7 @@ function SavedCommandViews({ kpis }: { kpis: OverviewKpis }) { href: '/admin/errors?window=24&severity=error', label: 'Production Health', icon: Gauge, - metric: `${kpis.eventErrors24h} groups`, + metric: `${kpis.incidentGroups24h} groups`, detail: 'Real incidents, source mapped, grouped like Errors tab', }, { @@ -339,11 +340,8 @@ function SavedCommandViews({ kpis }: { kpis: OverviewKpis }) { } async function TriagePanel() { - const { items, sentry } = await fetchTriageQueue(); + const { items, sentry, counts } = await fetchTriageQueue(); const regressed = items.filter((i) => i.substatus === 'regressed'); - const highSeverity = items.filter((i) => i.severity === 'critical' || i.severity === 'error'); - const appItems = items.filter((i) => i.origin === 'app'); - const sentryItems = items.filter((i) => i.origin === 'sentry'); const sportCounts = [ ['Golf', items.filter((i) => i.sport === 'golf').length], ['Baseball', items.filter((i) => i.sport === 'baseball').length], @@ -363,12 +361,15 @@ async function TriagePanel() {

Action lanes

+

+ Last 24h · same incident feed as the Errors tab (unresolved app events + Sentry with activity in window) +

{[ - ['Total groups', items.length, 'coalesced incidents'], - ['High severity', highSeverity.length, 'critical and error'], - ['App groups', appItems.length, 'Supabase admin_events'], - ['Sentry groups', sentryItems.length, sentry.status === 'ok' ? 'live pull' : sentry.status], + ['Total groups', counts.totalGroups, 'coalesced incidents'], + ['High severity', counts.highSeverityGroups, 'critical and error'], + ['App groups', counts.appGroups, 'Supabase admin_events'], + ['Sentry groups', counts.sentryGroups, sentry.status === 'ok' ? 'active in 24h' : sentry.status], ].map(([label, value, caption]) => (

{label}

@@ -457,12 +458,12 @@ async function DeployRail() { } function CommandHeader() { - const nav = [ - { href: '/admin/errors', label: 'Errors', icon: Activity }, - { href: '/admin/health', label: 'Feature Map', icon: RadioTower }, - { href: '/admin/deploys', label: 'Deploys', icon: GitBranch }, - { href: '/admin/audit', label: 'Audit', icon: ShieldCheck }, - ]; + const iconByHref = { + '/admin/errors': Activity, + '/admin/health': RadioTower, + '/admin/deploys': GitBranch, + '/admin/auth': ShieldCheck, + } as const; return (
@@ -477,8 +478,8 @@ function CommandHeader() {

- {/* Privacy Settings Link (Players Only) */} - {user?.role === 'player' && ( - - - -
-
-
- -
-
-

Privacy Settings

-

Control what appears on your public profile

+ {user?.role === 'coach' && ( + + +
+ +

Consolidated Program Sections

+
+
+ + {CONSOLIDATED_SETTINGS_LINKS.map((item) => { + const Icon = item.icon; + return ( + +
+ +
-
- -
- - - +

{item.label}

+

{item.description}

+ + ); + })} + + )} - {/* Notification Preferences (Coaches) — the real, persisted store lives on - Program Settings (baseball_program_settings.notification_defaults via - ProgramSettingsClient + updateProgramSettings). This used to be a - second, disconnected card that only updated local React state and - toasted "success" with no server call (#454, #466) — removed in - favor of a single link to the real surface so there is exactly one - place to configure notifications and no fake toggle to drift out of - sync with what's actually saved. */} + {/* Program Profile Link (Coaches Only) */} {user?.role === 'coach' && ( - +
- +
-

Notification Preferences

-

Manage quiet hours and per-type notification defaults for your program

+

Program Profile

+

Customize your public program page for recruits

diff --git a/src/app/baseball/(dashboard)/dashboard/stats/games/new/NewGameClient.tsx b/src/app/baseball/(dashboard)/dashboard/stats/games/new/NewGameClient.tsx deleted file mode 100644 index 77149fc80..000000000 --- a/src/app/baseball/(dashboard)/dashboard/stats/games/new/NewGameClient.tsx +++ /dev/null @@ -1,202 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { createGame } from '@/app/baseball/actions/games'; -import type { BaseballGameType, BaseballHomeAway } from '@/lib/types'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Checkbox } from '@/components/ui/checkbox'; - -interface NewGameClientProps { - teamId: string; - teamName: string; -} - -export function NewGameClient({ teamId, teamName }: NewGameClientProps) { - const router = useRouter(); - const today = new Date().toISOString().split('T')[0]; - - const [gameDate, setGameDate] = useState(today ?? ''); - const [gameType, setGameType] = useState('game'); - const [opponentName, setOpponentName] = useState(''); - const [location, setLocation] = useState(''); - const [homeAway, setHomeAway] = useState('home'); - const [eventTime, setEventTime] = useState(''); - const [createCalendarEvent, setCreateCalendarEvent] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!gameDate) { - setError('Please select a game date'); - return; - } - - setSaving(true); - setError(null); - - const result = await createGame(teamId, { - game_date: gameDate, - game_type: gameType, - opponent_name: opponentName || undefined, - location: location || undefined, - home_away: homeAway, - event_time: eventTime || undefined, - create_calendar_event: createCalendarEvent, - }); - - if (result.success && result.data) { - router.push(`/baseball/dashboard/stats/games/${result.data.id}`); - } else { - setError(result.error ?? 'Failed to create game'); - setSaving(false); - } - } - - return ( -
-
-

Add Game / Scrimmage

-

{teamName}

-
- -
-
- {/* Game type */} -
-

Type

-
- {(['game', 'scrimmage'] as BaseballGameType[]).map((t) => ( - - ))} -
-
- - {/* Date */} -
- - setGameDate(e.target.value)} - required - /> -
- - {/* Opponent */} -
- - setOpponentName(e.target.value)} - placeholder="e.g. State University" - /> -
- - {/* Home/Away */} -
-

Location

-
- {(['home', 'away', 'neutral'] as BaseballHomeAway[]).map((ha) => ( - - ))} -
-
- - {/* Venue */} -
- - setLocation(e.target.value)} - placeholder="e.g. Alumni Field" - /> -
- - {/* Calendar event option */} -
-
- setCreateCalendarEvent(e.target.checked)} - label="Also add to team calendar" - /> -
- {createCalendarEvent && ( - setEventTime(e.target.value)} - className="w-auto min-h-0 py-1.5" - placeholder="Start time" - /> - )} -
- - {error && ( -
- {error} -
- )} - -
- - -
-
-
-
- ); -} diff --git a/src/app/baseball/(dashboard)/dashboard/stats/games/new/error.tsx b/src/app/baseball/(dashboard)/dashboard/stats/games/new/error.tsx deleted file mode 100644 index bf34a76dc..000000000 --- a/src/app/baseball/(dashboard)/dashboard/stats/games/new/error.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import { RouteErrorBoundary } from '@/components/errors'; - -export default function NewGameError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( - - ); -} diff --git a/src/app/baseball/(dashboard)/dashboard/stats/games/new/loading.tsx b/src/app/baseball/(dashboard)/dashboard/stats/games/new/loading.tsx deleted file mode 100644 index a87dcd427..000000000 --- a/src/app/baseball/(dashboard)/dashboard/stats/games/new/loading.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -/** - * New game form loading skeleton. - */ -export default function NewGameLoading() { - return ( -
-
- - -
- -
- {/* Type row */} -
- -
- - -
-
- {/* Date */} -
- - -
- {/* Opponent */} -
- - -
- {/* Home/Away */} -
- -
- - - -
-
- {/* Venue */} -
- - -
- {/* Calendar */} - - {/* Buttons */} -
- - -
-
-
- ); -} diff --git a/src/app/baseball/(dashboard)/dashboard/stats/games/new/page.tsx b/src/app/baseball/(dashboard)/dashboard/stats/games/new/page.tsx deleted file mode 100644 index 652e5603d..000000000 --- a/src/app/baseball/(dashboard)/dashboard/stats/games/new/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { redirect } from 'next/navigation'; -import { BASEBALL_STATS_GAME_CREATE_PATH } from '@/lib/baseball/stats-route-aliases'; - -/** Temporary redirect alias — canonical route is /stats/games/create (#378). */ -export default function LegacyNewGameRedirect() { - redirect(BASEBALL_STATS_GAME_CREATE_PATH); -} diff --git a/src/app/baseball/(dashboard)/dashboard/team/high-school/error.tsx b/src/app/baseball/(dashboard)/dashboard/team/high-school/error.tsx deleted file mode 100644 index dcfb7ac81..000000000 --- a/src/app/baseball/(dashboard)/dashboard/team/high-school/error.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import { RouteErrorBoundary } from '@/components/errors'; - -export default function Error({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( - - ); -} diff --git a/src/app/baseball/(dashboard)/dashboard/team/high-school/page.tsx b/src/app/baseball/(dashboard)/dashboard/team/high-school/page.tsx deleted file mode 100644 index 4d8e9fcd8..000000000 --- a/src/app/baseball/(dashboard)/dashboard/team/high-school/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function HighSchoolTeamDashboardPage() { - redirect('/baseball/dashboard/command-center'); -} diff --git a/src/app/baseball/(dashboard)/layout.tsx b/src/app/baseball/(dashboard)/layout.tsx index 33d1e879e..d5ff8221e 100644 --- a/src/app/baseball/(dashboard)/layout.tsx +++ b/src/app/baseball/(dashboard)/layout.tsx @@ -38,7 +38,7 @@ import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { BaseballShellLayout } from '@/components/baseball/BaseballShellLayout'; import { BaseballFairwayShell } from './BaseballFairwayShell'; -import { PageLoading } from '@/components/ui/loading'; +import { BaseballDashboardBootstrap } from '@/components/baseball/BaseballDashboardBootstrap'; import { useBaseballAuth } from '@/hooks/use-baseball-auth'; import { useBaseballNavContext } from '@/hooks/use-baseball-nav-context'; import { isRedesignEnabled } from '@/lib/redesign/flag'; @@ -87,10 +87,18 @@ function DashboardSessionGuard({ children }: { children: React.ReactNode }) { // Show page skeleton while loading OR while a redirect is pending // (navContext null + authorized + settled → redirect in-flight). if (!bothSettled || (authorized && navContext === null)) { - return ; + return ; } - return <>{children}; + const shell = isRedesignEnabled() ? ( + {children} + ) : ( + + {children} + + ); + + return shell; } // --------------------------------------------------------------------------- @@ -113,13 +121,5 @@ export default function DashboardLayout({ }: { children: React.ReactNode; }) { - return ( - - {isRedesignEnabled() ? ( - {children} - ) : ( - {children} - )} - - ); + return {children}; } diff --git a/src/app/baseball/(player-dashboard)/player/college/page.tsx b/src/app/baseball/(player-dashboard)/player/college/page.tsx deleted file mode 100644 index b988293d0..000000000 --- a/src/app/baseball/(player-dashboard)/player/college/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function CollegePlayerDashboardPage() { - redirect('/baseball/player/today'); -} diff --git a/src/app/baseball/(player-dashboard)/player/high-school/page.tsx b/src/app/baseball/(player-dashboard)/player/high-school/page.tsx deleted file mode 100644 index 81f2e446f..000000000 --- a/src/app/baseball/(player-dashboard)/player/high-school/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function HighSchoolPlayerDashboardPage() { - redirect('/baseball/player/today'); -} diff --git a/src/app/baseball/(player-dashboard)/player/juco/page.tsx b/src/app/baseball/(player-dashboard)/player/juco/page.tsx deleted file mode 100644 index 1663d2c3e..000000000 --- a/src/app/baseball/(player-dashboard)/player/juco/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function JucoPlayerRedirectPage() { - redirect('/baseball/player/today'); -} diff --git a/src/app/baseball/(player-dashboard)/player/layout.tsx b/src/app/baseball/(player-dashboard)/player/layout.tsx index 86d04193a..9306ef5d9 100644 --- a/src/app/baseball/(player-dashboard)/player/layout.tsx +++ b/src/app/baseball/(player-dashboard)/player/layout.tsx @@ -1,8 +1,16 @@ 'use client'; +import { BaseballFairwayShell } from '@/app/baseball/(dashboard)/BaseballFairwayShell'; import { BaseballShellLayout } from '@/components/baseball/BaseballShellLayout'; +import { isRedesignEnabled } from '@/lib/redesign/flag'; export default function PlayerDashboardLayout({ children }: { children: React.ReactNode }) { + const shell = isRedesignEnabled() ? ( + {children} + ) : ( + {children} + ); + return ( <> {/* @@ -12,7 +20,7 @@ export default function PlayerDashboardLayout({ children }: { children: React.Re * (start_url: /baseball/player/today) is available for player sessions. */} - {children} + {shell} ); } diff --git a/src/app/baseball/(player-dashboard)/player/passport/page.tsx b/src/app/baseball/(player-dashboard)/player/passport/page.tsx index 6cb77c887..f39b436c0 100644 --- a/src/app/baseball/(player-dashboard)/player/passport/page.tsx +++ b/src/app/baseball/(player-dashboard)/player/passport/page.tsx @@ -21,13 +21,9 @@ // Fairway component's own empty state) rather than redirect-looping or // fabricating a passport. // -// SCOPE: `/baseball/player/*` (the `(player-dashboard)` route group) has no -// Fairway shell equivalent to `(dashboard)/BaseballFairwayShell.tsx` yet — that -// shell is explicitly frozen to its own route group this migration (§6). This -// page therefore carries BOTH the `.fairway-ds` token scope and the -// `.living-annual` cream override itself (mirroring what the legacy -// `bg-cream-100` wrapper did), same as the page-local pattern documented in -// §1 for a surface no ancestor shell already scopes. +// SCOPE: this page keeps the `.living-annual` cream override locally because it +// is the full editorial passport surface, while the route-group layout owns the +// shared Fairway shell frame. // ============================================================================= import { redirect } from 'next/navigation'; diff --git a/src/app/baseball/(player-dashboard)/player/showcase/page.tsx b/src/app/baseball/(player-dashboard)/player/showcase/page.tsx deleted file mode 100644 index 8acc0ac90..000000000 --- a/src/app/baseball/(player-dashboard)/player/showcase/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function ShowcasePlayerDashboardPage() { - redirect('/baseball/player/today'); -} diff --git a/src/app/baseball/actions/__tests__/complete-player-onboarding.test.ts b/src/app/baseball/actions/__tests__/complete-player-onboarding.test.ts index 221e5b867..bb67d1471 100644 --- a/src/app/baseball/actions/__tests__/complete-player-onboarding.test.ts +++ b/src/app/baseball/actions/__tests__/complete-player-onboarding.test.ts @@ -58,7 +58,7 @@ describe('completePlayerOnboarding', () => { const res = await completePlayerOnboarding(BASE_INPUT); expect(res.success).toBe(true); - expect(res.redirectTo).toBe('/baseball/dashboard'); + expect(res.redirectTo).toBe('/baseball/player/today'); expect(from).toHaveBeenCalledWith('baseball_players'); const [row, opts] = upsert.mock.calls[0]!; diff --git a/src/app/baseball/actions/__tests__/demo-access.test.ts b/src/app/baseball/actions/__tests__/demo-access.test.ts index 6317ed2f1..ea5b97ffb 100644 --- a/src/app/baseball/actions/__tests__/demo-access.test.ts +++ b/src/app/baseball/actions/__tests__/demo-access.test.ts @@ -89,7 +89,10 @@ vi.mock('@sentry/nextjs', () => ({ withScope: (fn: (scope: unknown) => unknown) => fn({ setTag: vi.fn(), setUser: vi.fn(), addBreadcrumb: vi.fn() }), })); -vi.mock('@/lib/server-error-logger', () => ({ logServerException: vi.fn(async () => undefined) })); +vi.mock('@/lib/server-error-logger', () => ({ + logServerException: vi.fn(async () => undefined), + logServerError: vi.fn(async () => undefined), +})); import { enterBaseballDemo } from '@/app/baseball/actions/demo-access'; import { withBaseballAction, BaseballDemoReadOnlyError } from '@/lib/baseball/with-baseball-action'; diff --git a/src/app/baseball/actions/__tests__/route-shell-contract.test.ts b/src/app/baseball/actions/__tests__/route-shell-contract.test.ts index 642f3c32a..8b3c1fc77 100644 --- a/src/app/baseball/actions/__tests__/route-shell-contract.test.ts +++ b/src/app/baseball/actions/__tests__/route-shell-contract.test.ts @@ -42,7 +42,12 @@ import { DYNAMIC_ROUTE_SAMPLES, REDIRECT_ALIAS_MANIFEST, } from '@/test/helpers/baseball-route-inventory'; -import { BASEBALL_NAV_REGISTRY, BASEBALL_MESSAGES_NAV } from '@/lib/baseball/nav-registry'; +import { + BASEBALL_NAV_REGISTRY, + BASEBALL_MESSAGES_NAV, + getVisibleBaseballNav, + type BaseballNavContext, +} from '@/lib/baseball/nav-registry'; import { COACH_TEAM_TABS, COACH_STATS_TABS, @@ -84,23 +89,12 @@ const ADVISORY_ALLOWLIST: readonly AllowlistEntry[] = [ { bucket: 'orphanRoutes', href: '/baseball/dashboard/players/[id]/scout-packet/preview', reason: 'deep-link print preview reached from the scout packet' }, { bucket: 'orphanRoutes', href: '/baseball/dashboard/players/[id]/stats', reason: 'deep-link sub-view reached from the player profile' }, { bucket: 'orphanRoutes', href: '/baseball/dashboard/camps/[id]', reason: 'deep-link detail page reached from the Camps list' }, - { bucket: 'orphanRoutes', href: '/baseball/dashboard/messages/[id]', reason: 'deep-link thread page reached from the Messages inbox' }, { bucket: 'orphanRoutes', href: '/baseball/dashboard/stats/games/[gameId]', reason: 'deep-link box score reached from the Games list' }, { bucket: 'orphanRoutes', href: '/baseball/dashboard/stats/games/create', reason: 'sub-action reached from the Games list' }, - { bucket: 'orphanRoutes', href: '/baseball/dashboard/stats/games/new', reason: 'sub-action reached from the Games list' }, // --- orphanRoutes: backward-compatible redirect-only landing stubs. --- { bucket: 'orphanRoutes', href: '/baseball/dashboard', reason: 'backward-compatible bookmark dispatcher; server-redirects by role' }, { bucket: 'orphanRoutes', href: '/baseball/dashboard/stats', reason: 'legacy redirect-only stub -> /dashboard/stats-center' }, - { bucket: 'orphanRoutes', href: '/baseball/dashboard/team/high-school', reason: 'legacy redirect-only stub -> command-center' }, - { bucket: 'orphanRoutes', href: '/baseball/coach/college', reason: 'legacy per-coach-type stub; redirects to command-center' }, - { bucket: 'orphanRoutes', href: '/baseball/coach/high-school', reason: 'legacy per-coach-type stub; redirects to command-center' }, - { bucket: 'orphanRoutes', href: '/baseball/coach/juco', reason: 'legacy per-coach-type stub; redirects to command-center' }, - { bucket: 'orphanRoutes', href: '/baseball/coach/showcase', reason: 'legacy per-coach-type stub; redirects to command-center' }, - { bucket: 'orphanRoutes', href: '/baseball/player/college', reason: 'legacy per-program-type stub; redirects to player today' }, - { bucket: 'orphanRoutes', href: '/baseball/player/high-school', reason: 'legacy per-program-type stub; redirects to player today' }, - { bucket: 'orphanRoutes', href: '/baseball/player/juco', reason: 'legacy per-program-type stub; redirects to player today' }, - { bucket: 'orphanRoutes', href: '/baseball/player/showcase', reason: 'legacy per-program-type stub; redirects to player today' }, // --- orphanRoutes: public marketing / auth / onboarding / join entry // points — pre-login surfaces that are intentionally outside the @@ -122,6 +116,7 @@ const ADVISORY_ALLOWLIST: readonly AllowlistEntry[] = [ { bucket: 'orphanRoutes', href: '/baseball/player/[id]', reason: 'public player share page, no app nav' }, { bucket: 'orphanRoutes', href: '/baseball/program/[id]', reason: 'public program share page, no app nav' }, { bucket: 'orphanRoutes', href: '/baseball/team/[id]', reason: 'public team share page, no app nav' }, + { bucket: 'orphanRoutes', href: '/baseball/admin/demo-sessions', reason: 'platform admin surface outside dashboard nav shell' }, ]; function allowlistedHrefs(bucket: GapBucket): Set { @@ -246,6 +241,14 @@ describe('BaseballHelm route/shell contract (#374)', () => { ); }); + it('player nav does not expose the legacy coach dashboard alias as a duplicate Today route', () => { + const player: BaseballNavContext = { role: 'player', capabilities: {}, programType: 'college' }; + const visible = getVisibleBaseballNav(player); + + expect(visible.some((entry) => entry.id === 'team')).toBe(false); + expect(visible.filter((entry) => entry.href === '/baseball/player/today').map((entry) => entry.id)).toEqual(['player-today']); + }); + it('guardWithoutNavGating is empty for the known (capability-guarded) set', () => { expect(result.guardWithoutNavGating).toEqual([]); }); diff --git a/src/app/baseball/actions/__tests__/save-full-box-score.test.ts b/src/app/baseball/actions/__tests__/save-full-box-score.test.ts index f63b49950..b2d207d78 100644 --- a/src/app/baseball/actions/__tests__/save-full-box-score.test.ts +++ b/src/app/baseball/actions/__tests__/save-full-box-score.test.ts @@ -142,6 +142,37 @@ describe('saveFullBoxScore', () => { expect(result.error).toBeTruthy(); }); + it('computes pitching rates from true innings for partial-inning notation', async () => { + rpc.mockResolvedValue({ data: { success: true }, error: null }); + + const partialInningLine = { + ...PITCHING_LINE, + ip: 6.1, + er: 2, + h: 4, + bb: 2, + k: 7, + }; + + const result = await saveFullBoxScore('game-1', [], [partialInningLine], 2, 1); + + expect(result.success).toBe(true); + expect(rpc).toHaveBeenCalledWith( + 'save_baseball_full_box_score', + expect.objectContaining({ + p_pitching: [ + expect.objectContaining({ + ip: 6.1, + era: 2.84, + whip: 0.947, + k9: 9.95, + bb9: 2.84, + }), + ], + }), + ); + }); + // #433 — the manual edit form previously opened blank on an existing box // score, so a re-save could silently drop rows. These cases prove that // re-supplying the full existing set (as the preloaded form now does) is diff --git a/src/app/baseball/actions/games.ts b/src/app/baseball/actions/games.ts index b8d39b4fc..2f9c41835 100644 --- a/src/app/baseball/actions/games.ts +++ b/src/app/baseball/actions/games.ts @@ -21,6 +21,7 @@ import { } from '@/lib/baseball/with-baseball-action'; import { logServerError } from '@/lib/server-error-logger'; import { mapBattingToInput, mapPitchingToInput } from '@/components/baseball/box-score/mappers'; +import { ipToInnings } from '@/lib/baseball/innings'; import { getStatsCenter, type StatsCenterOptions, @@ -44,6 +45,7 @@ const STATS_PATHS = [ '/baseball/dashboard/stats-center', '/baseball/dashboard/stats/games', '/baseball/dashboard/stats/season', + '/baseball/dashboard/my-stats', '/baseball/dashboard/calendar', ]; @@ -556,12 +558,14 @@ function computePitchingRates(line: BoxScorePitchingInput): { } { const { ip, er, h, bb, k } = line; if (ip === 0) return { era: null, whip: null, k9: null, bb9: null }; + const innings = ipToInnings(ip); + if (innings === 0) return { era: null, whip: null, k9: null, bb9: null }; return { - era: parseFloat((9 * er / ip).toFixed(2)), - whip: parseFloat(((bb + h) / ip).toFixed(3)), - k9: parseFloat((9 * k / ip).toFixed(2)), - bb9: parseFloat((9 * bb / ip).toFixed(2)), + era: parseFloat((9 * er / innings).toFixed(2)), + whip: parseFloat(((bb + h) / innings).toFixed(3)), + k9: parseFloat((9 * k / innings).toFixed(2)), + bb9: parseFloat((9 * bb / innings).toFixed(2)), }; } diff --git a/src/app/baseball/actions/onboarding.ts b/src/app/baseball/actions/onboarding.ts index 899cbef67..4164a9a35 100644 --- a/src/app/baseball/actions/onboarding.ts +++ b/src/app/baseball/actions/onboarding.ts @@ -710,7 +710,7 @@ async function completePlayerOnboardingImpl( revalidatePath('/baseball'); revalidatePath('/baseball/player'); - return { success: true, redirectTo: '/baseball/dashboard' }; + return { success: true, redirectTo: '/baseball/player/today' }; } export const signupAndCompleteCoachOnboarding = withAdminObserved( diff --git a/src/app/golf/actions/__tests__/coverage-contract.observability.test.ts b/src/app/golf/actions/__tests__/coverage-contract.observability.test.ts new file mode 100644 index 000000000..cdc72cd80 --- /dev/null +++ b/src/app/golf/actions/__tests__/coverage-contract.observability.test.ts @@ -0,0 +1,101 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const GOLF_APP_ROOT = path.join(process.cwd(), 'src/app/golf'); + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(fullPath); + if (!/\.(ts|tsx)$/.test(entry.name)) return []; + return [fullPath]; + }); +} + +function hasUseServerDirective(source: string): boolean { + return /^\s*['"]use server['"]/m.test(source); +} + +function isExported(node: ts.Node): boolean { + return Boolean(ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export); +} + +function isDefaultExport(node: ts.Node): boolean { + return Boolean(ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Default); +} + +function wrappedActionNames(sourceFile: ts.SourceFile): Set { + const names = new Set(); + + function visit(node: ts.Node): void { + if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.initializer && + ts.isCallExpression(declaration.initializer) + ) { + const callee = declaration.initializer.expression.getText(sourceFile); + if (callee === 'withAdminObserved') { + names.add(declaration.name.text); + } + } + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return names; +} + +describe('Golf server-action observability coverage', () => { + it('wraps direct server-action exports with Helm Bridge error tracking', () => { + const gaps: string[] = []; + + for (const file of walk(GOLF_APP_ROOT)) { + if (/[\\/]crm-/.test(file)) continue; + if (file.endsWith('resend-activity.ts')) continue; + const source = fs.readFileSync(file, 'utf8'); + if (!hasUseServerDirective(source)) continue; + if (/[\\/]page\.tsx$|[\\/]layout\.tsx$/.test(file)) continue; + + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const wrappedNames = wrappedActionNames(sourceFile); + + function visit(node: ts.Node): void { + if ( + ts.isFunctionDeclaration(node) && + node.name && + isExported(node) && + !isDefaultExport(node) + ) { + const body = node.body?.getText(sourceFile) ?? ''; + const delegatesToWrapped = [...wrappedNames].some((name) => + new RegExp(`\\b${name}\\s*\\(`).test(body), + ); + const wrapsInline = /\bwithAdminObserved\s*\(/.test(body); + const logsInline = /\blogServer(Exception|Error)\s*\(/.test(body); + + if (!delegatesToWrapped && !wrapsInline && !logsInline) { + const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1; + gaps.push(`${path.relative(process.cwd(), file)}:${line} ${node.name.text}`); + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + } + + expect(gaps).toEqual([]); + }); +}); diff --git a/src/components/auth/baseball-sign-in-form.tsx b/src/components/auth/baseball-sign-in-form.tsx index b63737d88..5bba7af45 100644 --- a/src/components/auth/baseball-sign-in-form.tsx +++ b/src/components/auth/baseball-sign-in-form.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { loginAction } from '@/app/baseball/actions/auth'; +import { invalidateAuthCache } from '@/hooks/use-baseball-auth'; import { Input } from '@/components/ui/input'; import { AlertCircle } from 'lucide-react'; import { triggerHaptic } from '@/lib/utils/capacitor'; @@ -63,13 +64,9 @@ export function BaseballSignInForm() { void triggerHaptic('success'); - // CRITICAL: After login, refresh first to ensure the session cookies - // are recognized by the Next.js router cache before navigating. + invalidateAuthCache(); router.refresh(); - // Wait for cookies to propagate and cache to invalidate - await new Promise(resolve => setTimeout(resolve, 100)); - // Check for stored returnTo URL (from invite link flow) const storedReturnTo = sessionStorage.getItem('baseball_login_returnTo'); @@ -85,11 +82,11 @@ export function BaseballSignInForm() { if (storedReturnTo && !needsOnboarding && isValidReturnTo(storedReturnTo)) { sessionStorage.removeItem('baseball_login_returnTo'); - router.push(storedReturnTo); + router.replace(storedReturnTo); } else { // Clear stale returnTo if present — onboarding takes priority if (storedReturnTo) sessionStorage.removeItem('baseball_login_returnTo'); - router.push(result.redirectTo || '/baseball/dashboard'); + router.replace(result.redirectTo || '/baseball/dashboard'); } } catch { setError('An unexpected error occurred. Please try again.'); diff --git a/src/components/baseball/BaseballDashboardBootstrap.tsx b/src/components/baseball/BaseballDashboardBootstrap.tsx new file mode 100644 index 000000000..27d9cdb79 --- /dev/null +++ b/src/components/baseball/BaseballDashboardBootstrap.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { AuthPendingDots } from '@/components/auth/baseball-auth-shell'; + +/** + * Baseball-branded bootstrap state while the dashboard session guard settles + * auth + nav context after sign-in. Replaces the generic 4-card page skeleton + * so the login → command center transition does not feel like a product switch. + */ +export function BaseballDashboardBootstrap({ + label = 'Opening your command center', +}: { + label?: string; +}) { + return ( +
+ +
+ ); +} diff --git a/src/components/baseball/BaseballShellLayout.tsx b/src/components/baseball/BaseballShellLayout.tsx index b1962ab99..85d937d7c 100644 --- a/src/components/baseball/BaseballShellLayout.tsx +++ b/src/components/baseball/BaseballShellLayout.tsx @@ -39,11 +39,14 @@ interface BaseballShellLayoutProps { * derived from the auth response when this is `null`. */ requiredRole: ActiveBaseballRole | null; + /** Parent layout already verified auth — skip the second full-page skeleton. */ + authVerified?: boolean; } export function BaseballShellLayout({ children, requiredRole, + authVerified = false, }: BaseballShellLayoutProps) { // For the generic (dashboard) group, `requiredRole` is null and the hook // accepts both roles + returns the real role from the session. For the @@ -55,7 +58,7 @@ export function BaseballShellLayout({ // empty capability map and fail-closes every gated entry. const { navContext } = useBaseballNavContext(); - if (loading || !authorized) { + if (!authVerified && (loading || !authorized)) { return ; } diff --git a/src/components/baseball/games/GameCard.tsx b/src/components/baseball/games/GameCard.tsx index de3e396c4..f9ebb6332 100644 --- a/src/components/baseball/games/GameCard.tsx +++ b/src/components/baseball/games/GameCard.tsx @@ -6,7 +6,6 @@ import { IconCalendar, IconMapPin, IconTrendingUp } from '@/components/icons'; interface GameCardProps { game: BaseballGame; - onDelete?: (gameId: string) => void; } function getResultInfo(game: BaseballGame) { @@ -42,11 +41,15 @@ export function GameCard({ game }: GameCardProps) { month: 'short', day: 'numeric', }); + const detailHref = `/baseball/dashboard/stats/games/${game.id}`; + const opponentLabel = game.opponent_name ? `vs ${game.opponent_name}` : 'TBD Opponent'; return ( -
{/* Left: game info */} @@ -70,9 +73,7 @@ export function GameCard({ game }: GameCardProps) { )}
-

- {game.opponent_name ? `vs ${game.opponent_name}` : 'TBD Opponent'} -

+

{opponentLabel}

@@ -112,20 +113,14 @@ export function GameCard({ game }: GameCardProps) { {/* Stats links */}
{game.status === 'completed' && (Number(game.batting_count) > 0 || Number(game.pitching_count) > 0) ? ( - + Box Score - + ) : game.status !== 'cancelled' ? ( - + Enter Results - + ) : null}
@@ -145,6 +140,6 @@ export function GameCard({ game }: GameCardProps) { )}
)} -
+ ); } diff --git a/src/components/fairway/app-shell/FairwayBottomNav.tsx b/src/components/fairway/app-shell/FairwayBottomNav.tsx index 64827709d..ac387870f 100644 --- a/src/components/fairway/app-shell/FairwayBottomNav.tsx +++ b/src/components/fairway/app-shell/FairwayBottomNav.tsx @@ -73,9 +73,10 @@ export function FairwayBottomNav({
    {items.map((item) => { const active = - item.active || - (item.activeMatch && pathname ? item.activeMatch(pathname) : false) || - matchActive(item.href, pathname); + item.active ?? + (item.activeMatch && pathname + ? item.activeMatch(pathname) + : matchActive(item.href, pathname)); const Icon = item.icon; return (
  • diff --git a/src/components/fairway/app-shell/FairwaySidebar.tsx b/src/components/fairway/app-shell/FairwaySidebar.tsx index 42b0762a9..2e6db2abb 100644 --- a/src/components/fairway/app-shell/FairwaySidebar.tsx +++ b/src/components/fairway/app-shell/FairwaySidebar.tsx @@ -349,12 +349,9 @@ export const FairwaySidebar = forwardRef(funct item={item} active={ item.active ?? - // `activeMatch` (when present) broadens the match to a route - // cluster so the global rail stays lit across sibling routes - // (e.g. CoachHelm AI on alerts/insights/patterns/…). Falls - // back to the default segment-boundary href match. - ((item.activeMatch && pathname ? item.activeMatch(pathname) : false) || - matchActive(item.href, pathname)) + (item.activeMatch && pathname + ? item.activeMatch(pathname) + : matchActive(item.href, pathname)) } collapsed={isCollapsed} Link={Link} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index b968d44dc..a0a38f5c9 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -74,7 +74,7 @@ type SidebarHubItem = { /** Collect every route a hub owns (each tab's href + matchPrefixes). */ function hubPrefixesFrom( - tabs: readonly { href: string; matchPrefixes?: string[] }[], + tabs: readonly { href: string; matchPrefixes?: readonly string[] }[], ): string[] { return tabs.flatMap((t) => [t.href, ...(t.matchPrefixes ?? [])]); } diff --git a/src/components/providers/SessionActivityProvider.tsx b/src/components/providers/SessionActivityProvider.tsx index ea8a35858..0cfe8de49 100644 --- a/src/components/providers/SessionActivityProvider.tsx +++ b/src/components/providers/SessionActivityProvider.tsx @@ -7,7 +7,10 @@ import { useSessionActivity } from '@/lib/auth/session-activity'; * * Wrap authenticated layouts with this component to enable: * - Automatic logout after the idle window (SESSION_IDLE_TIMEOUT_MS — 5 minutes) + * when the tab is hidden or the user is genuinely away * - Activity tracking (mouse, keyboard, touch events) + * - Visible-tab heartbeat so reading a dashboard without moving the mouse + * does not trip the idle window * - Idle checks every minute + on tab re-show (visibilitychange / pageshow) * * Usage: diff --git a/src/contracts/baseball/stats/pitching-invariants.test.ts b/src/contracts/baseball/stats/pitching-invariants.test.ts index 5368ebe14..a253fdebe 100644 --- a/src/contracts/baseball/stats/pitching-invariants.test.ts +++ b/src/contracts/baseball/stats/pitching-invariants.test.ts @@ -14,14 +14,11 @@ // see also innings.test.ts) before any rate divides by it. This file pins that // corrected behavior. // -// NEEDS-DECISION (documented in -// docs/operations/BASEBALLHELM_BUSINESS_CONTRACT_MATRIX.md): the box-score -// SAVE path (`games.ts`'s `computePitchingRates` + CSV `getFloat('innings_pitched')`) -// is a SEPARATE, still-unconverted code path — it still divides by the raw -// notation value with no thirds conversion. That gap is unaffected by #434 and -// is pinned as-is by the second describe block below (games.ts mirrors its own, -// still-raw formula) — do not "fix" that block to match `ipToInnings`; it is -// pinning actual, unchanged behavior. +// The box-score SAVE path (`games.ts`'s `computePitchingRates`, including CSV +// imports that read `innings_pitched` as the same notation value) must use the +// same `ipToInnings` conversion before calculating rates. Otherwise a saved +// game can persist different ERA/WHIP/K9 than the Stats Center read model later +// derives from the same IP/counting stats. // ============================================================================= import { readFileSync } from 'node:fs'; @@ -99,14 +96,16 @@ describe('Pitching invariants (#377) — games.ts box-score save mirrors the sam }); it('computePitchingRates uses the identical ERA/WHIP/K9/BB9 formulas', () => { - expect(src).toContain('era: parseFloat((9 * er / ip).toFixed(2)),'); - expect(src).toContain('whip: parseFloat(((bb + h) / ip).toFixed(3)),'); - expect(src).toContain('k9: parseFloat((9 * k / ip).toFixed(2)),'); - expect(src).toContain('bb9: parseFloat((9 * bb / ip).toFixed(2)),'); + expect(src).toContain("import { ipToInnings } from '@/lib/baseball/innings';"); + expect(src).toContain('const innings = ipToInnings(ip);'); + expect(src).toContain('era: parseFloat((9 * er / innings).toFixed(2)),'); + expect(src).toContain('whip: parseFloat(((bb + h) / innings).toFixed(3)),'); + expect(src).toContain('k9: parseFloat((9 * k / innings).toFixed(2)),'); + expect(src).toContain('bb9: parseFloat((9 * bb / innings).toFixed(2)),'); }); - it('the CSV parser reads innings_pitched as a raw float with no thirds-notation conversion', () => { + it('the CSV parser reads innings_pitched as box-score notation and the shared save helper converts it for rates', () => { expect(src).toMatch(/ip:\s*getFloat\('innings_pitched'\)/); - expect(src).not.toMatch(/innings.*thirds|thirds.*innings|normalizeInnings/i); + expect(src).toContain('const innings = ipToInnings(ip);'); }); }); diff --git a/src/hooks/__tests__/use-baseball-auth.test.tsx b/src/hooks/__tests__/use-baseball-auth.test.tsx index 0952caa2d..631d55bf2 100644 --- a/src/hooks/__tests__/use-baseball-auth.test.tsx +++ b/src/hooks/__tests__/use-baseball-auth.test.tsx @@ -19,7 +19,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { useBaseballAuth } from '../use-baseball-auth'; -const routerPush = vi.fn(); +const routerReplace = vi.fn(); // use-baseball-auth.ts talks to the store only via the static // `useAuthStore.getState()` accessor (never the React-hook form), so a plain @@ -29,6 +29,7 @@ const fakeAuthState = { user: null as unknown, coach: null as unknown, player: null as unknown, + setUser: vi.fn((user: unknown) => { fakeAuthState.user = user; }), setCoach: vi.fn((coach: unknown) => { fakeAuthState.coach = coach; }), setPlayer: vi.fn((player: unknown) => { fakeAuthState.player = player; }), clear: vi.fn(() => { @@ -44,8 +45,8 @@ vi.mock('@/stores/auth-store', () => ({ vi.mock('next/navigation', () => ({ useRouter: () => ({ - push: routerPush, - replace: vi.fn(), + push: vi.fn(), + replace: routerReplace, prefetch: vi.fn(), back: vi.fn(), forward: vi.fn(), @@ -124,6 +125,6 @@ describe('useBaseballAuth', () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.authorized).toBe(false); - expect(routerPush).toHaveBeenCalledWith('/baseball/login'); + expect(routerReplace).toHaveBeenCalledWith('/baseball/login'); }); }); diff --git a/src/hooks/use-baseball-auth.ts b/src/hooks/use-baseball-auth.ts index 45193f555..129f638f2 100644 --- a/src/hooks/use-baseball-auth.ts +++ b/src/hooks/use-baseball-auth.ts @@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; import { useAuthStore } from '@/stores/auth-store'; +import type { User } from '@/lib/types'; + type Role = 'coach' | 'player'; type BaseballProfile = { @@ -21,7 +23,7 @@ type AuthResult = { }; type VerifyResult = - | { ok: true; role: Role; coachProfile: BaseballProfile | null; playerProfile: BaseballProfile | null } + | { ok: true; role: Role; coachProfile: BaseballProfile | null; playerProfile: BaseballProfile | null; user: User | null } | { ok: false; redirectTo: string }; // --------------------------------------------------------------------------- @@ -56,6 +58,15 @@ function invalidateAuthCache() { inflightByKey.clear(); } +export { invalidateAuthCache }; + +function peekCachedAuthResult(requiredRole: Role | null): VerifyResult | null { + const cached = resultCache.get(CACHE_KEY(requiredRole)); + if (!cached) return null; + if (Date.now() - cached.resolvedAt >= CACHE_TTL_MS) return null; + return cached.result; +} + function verifyServerSession( supabase: ReturnType, requiredRole: Role | null, @@ -69,12 +80,13 @@ function verifyServerSession( } const [userResult, coachResult, playerResult] = await Promise.all([ - supabase.from('users').select('role').eq('id', user.id).maybeSingle(), + supabase.from('users').select('*').eq('id', user.id).maybeSingle(), supabase.from('baseball_coaches').select('id, onboarding_completed, coach_type').eq('user_id', user.id).maybeSingle(), supabase.from('baseball_players').select('id, onboarding_completed, player_type').eq('user_id', user.id).maybeSingle(), ]); - const userRole = userResult.data?.role; + const userRecord = userResult.data as User | null; + const userRole = userRecord?.role; const coachProfile = coachResult.data as BaseballProfile | null; const playerProfile = playerResult.data as BaseballProfile | null; @@ -110,7 +122,13 @@ function verifyServerSession( } } - return { ok: true, role: resolvedRole as Role, coachProfile, playerProfile } as const; + return { + ok: true, + role: resolvedRole as Role, + coachProfile, + playerProfile, + user: userRecord, + } as const; } catch (error) { // Any unexpected throw (network blip, aborted fetch, SDK error) must // never strand `loading` at true forever — treat it the same as "could @@ -164,15 +182,17 @@ function resolveWithCache( export function useBaseballAuth(requiredRole: Role | null = null): AuthResult { const router = useRouter(); const supabaseRef = useRef(createClient()); + const cached = peekCachedAuthResult(requiredRole); - const [loading, setLoading] = useState(true); - const [authorized, setAuthorized] = useState(false); - const [role, setRole] = useState(null); + const [loading, setLoading] = useState(() => cached?.ok !== true); + const [authorized, setAuthorized] = useState(() => cached?.ok === true); + const [role, setRole] = useState(() => (cached?.ok ? cached.role : null)); useEffect(() => { let cancelled = false; function reconcileStore( + user: User | null, coachProfile: BaseballProfile | null, playerProfile: BaseballProfile | null, ) { @@ -186,6 +206,10 @@ export function useBaseballAuth(requiredRole: Role | null = null): AuthResult { store.clear(); } + if (user) { + store.setUser(user); + } + if (coachProfile) { store.setCoach({ ...(store.coach ?? {}), @@ -209,7 +233,20 @@ export function useBaseballAuth(requiredRole: Role | null = null): AuthResult { } } + function applySuccess(result: Extract) { + reconcileStore(result.user, result.coachProfile, result.playerProfile); + setRole(result.role); + setAuthorized(true); + setLoading(false); + } + async function run() { + const warm = peekCachedAuthResult(requiredRole); + if (warm?.ok) { + applySuccess(warm); + return; + } + setLoading(true); try { const result = await resolveWithCache(supabaseRef.current, requiredRole); @@ -218,17 +255,12 @@ export function useBaseballAuth(requiredRole: Role | null = null): AuthResult { if (!result.ok) { useAuthStore.getState().clear(); invalidateAuthCache(); - router.push(result.redirectTo); + router.replace(result.redirectTo); return; } - reconcileStore(result.coachProfile, result.playerProfile); - setRole(result.role); - setAuthorized(true); + applySuccess(result); } finally { - // ALWAYS resolve loading, even on an unexpected error above — a - // stranded `true` here is exactly the eternal-skeleton bug this hook - // exists to avoid. if (!cancelled) setLoading(false); } } diff --git a/src/hooks/use-baseball-nav-context.ts b/src/hooks/use-baseball-nav-context.ts index 303723e13..65ee8b3ef 100644 --- a/src/hooks/use-baseball-nav-context.ts +++ b/src/hooks/use-baseball-nav-context.ts @@ -48,15 +48,37 @@ interface NavContextResult { // Module-scope cache so dashboard route changes don't re-resolve capabilities. // Keyed by auth user id so a different signed-in user never reads stale caps. +const NAV_CACHE_TTL_MS = 5000; let cacheUserId: string | null = null; let cachedContext: BaseballNavContext | null = null; +let cacheResolvedAt = 0; let inflight: Promise | null = null; +function clearNavContextCache() { + cacheUserId = null; + cachedContext = null; + cacheResolvedAt = 0; + inflight = null; +} + +function hasWarmNavCache(userId: string | null): boolean { + return ( + userId !== null && + userId === cacheUserId && + cachedContext !== null && + Date.now() - cacheResolvedAt < NAV_CACHE_TTL_MS + ); +} + function resolveForUser(userId: string | null): Promise { - // Same user + already resolved → serve from cache. - if (userId === cacheUserId && cachedContext !== null) { + if (hasWarmNavCache(userId)) { return Promise.resolve(cachedContext); } + + if (userId === null) { + return Promise.resolve(null); + } + // Same user + a resolve already in flight → share it (dedupe concurrent mounts). if (userId === cacheUserId && inflight) { return inflight; @@ -65,10 +87,14 @@ function resolveForUser(userId: string | null): Promise { // Only commit if the user hasn't changed underneath us. - if (cacheUserId === userId) cachedContext = ctx; + if (cacheUserId === userId) { + cachedContext = ctx; + cacheResolvedAt = Date.now(); + } return ctx; }) .catch(() => null) @@ -89,17 +115,24 @@ export function useBaseballNavContext(): NavContextResult { const userId = useAuthStore((s) => s.user?.id ?? null); const [navContext, setNavContext] = useState(() => - userId === cacheUserId ? cachedContext : null, - ); - const [loading, setLoading] = useState( - () => !(userId === cacheUserId && cachedContext !== null), + hasWarmNavCache(userId) ? cachedContext : null, ); + const [loading, setLoading] = useState(() => !hasWarmNavCache(userId)); useEffect(() => { let alive = true; - // Serve cache synchronously when available (no spinner on warm navigations). - if (userId === cacheUserId && cachedContext !== null) { + if (userId === null && cacheUserId !== null) { + clearNavContextCache(); + } + + if (userId === null) { + setNavContext(null); + setLoading(true); + return; + } + + if (hasWarmNavCache(userId)) { setNavContext(cachedContext); setLoading(false); return; diff --git a/src/lib/__tests__/server-error-logger-bridge.test.ts b/src/lib/__tests__/server-error-logger-bridge.test.ts index 2fea9c52d..61517ede5 100644 --- a/src/lib/__tests__/server-error-logger-bridge.test.ts +++ b/src/lib/__tests__/server-error-logger-bridge.test.ts @@ -62,6 +62,17 @@ describe('server-error-logger bridge columns', () => { expect(rows[0]!.row.fingerprint).toEqual(rows[1]!.row.fingerprint); }); + it('infers baseball sport from legacy emitters that only mention Baseball in the message', async () => { + await logServerError('Baseball document action error: permission denied', { + action: 'documents.handleError', + }); + const adminEvent = mocks.inserts.find((i) => i.table === 'admin_events'); + expect(adminEvent?.row).toMatchObject({ + sport: 'baseball', + feature: 'baseball_documents', + }); + }); + it('stays backward-compatible: legacy context without new fields still writes', async () => { await logServerError('legacy', { action: 'legacy.caller' }); const adminEvent = mocks.inserts.find((i) => i.table === 'admin_events'); diff --git a/src/lib/admin/__tests__/ben-leah-issues.test.ts b/src/lib/admin/__tests__/ben-leah-issues.test.ts new file mode 100644 index 000000000..811a5b0a1 --- /dev/null +++ b/src/lib/admin/__tests__/ben-leah-issues.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { + deriveBenLeahTrackStatus, + latestProductionReadyAt, + applyWorkflowSelection, + currentWorkflowSelection, + workflowLabelForSelection, + type BenLeahGitHubIssue, +} from '@/lib/admin/ben-leah-issue-tracker'; +import type { VercelDeployment } from '@/lib/admin/vercel-api'; + +function issue(overrides: Partial = {}): BenLeahGitHubIssue { + return { + number: 1, + html_url: 'https://github.com/o/r/issues/1', + title: '[Ben + Leah] Bug: Example', + displayTitle: 'Example', + state: 'open', + created_at: '2026-07-01T12:00:00Z', + updated_at: '2026-07-01T12:00:00Z', + closed_at: null, + labels: ['ben-leah'], + kind: 'bug', + priority: 'normal', + category: 'GolfHelm', + ...overrides, + }; +} + +describe('deriveBenLeahTrackStatus', () => { + it('marks open issues without workflow labels as open', () => { + expect(deriveBenLeahTrackStatus(issue(), null)).toBe('open'); + }); + + it('marks status:in-progress as in progress while open', () => { + expect( + deriveBenLeahTrackStatus(issue({ labels: ['ben-leah', 'status:in-progress'] }), null), + ).toBe('in_progress'); + }); + + it('marks status:wontfix regardless of state', () => { + expect( + deriveBenLeahTrackStatus(issue({ state: 'closed', labels: ['status:wontfix'] }), null), + ).toBe('wont_fix'); + }); + + it('marks closed issues shipped when production deploy is newer than close', () => { + const closed = issue({ + state: 'closed', + closed_at: '2026-07-04T12:00:00.000Z', + }); + const prodReady = Date.parse('2026-07-04T13:00:00.000Z'); + expect(deriveBenLeahTrackStatus(closed, prodReady)).toBe('in_production'); + }); + + it('marks closed issues pending deploy when production is older than close', () => { + const closed = issue({ + state: 'closed', + closed_at: '2026-07-04T14:00:00.000Z', + }); + const prodReady = Date.parse('2026-07-04T12:00:00.000Z'); + expect(deriveBenLeahTrackStatus(closed, prodReady)).toBe('fixed_pending_deploy'); + }); + + it('honors explicit status:in-production label', () => { + expect( + deriveBenLeahTrackStatus(issue({ labels: ['status:in-production'] }), null), + ).toBe('in_production'); + }); +}); + +describe('workflow labels', () => { + it('maps selection to GitHub label names', () => { + expect(workflowLabelForSelection('in_progress')).toBe('status:in-progress'); + expect(workflowLabelForSelection('in_production')).toBe('status:in-production'); + }); + + it('replaces workflow labels while keeping metadata tags', () => { + const next = applyWorkflowSelection( + ['ben-leah', 'kind:bug', 'status:triaged', 'priority:high'], + 'in_progress', + ); + expect(next).toEqual(['ben-leah', 'kind:bug', 'priority:high', 'status:in-progress']); + }); + + it('reads the active workflow selection from labels', () => { + expect(currentWorkflowSelection(['status:in-production', 'ben-leah'])).toBe('in_production'); + expect(currentWorkflowSelection(['ben-leah'])).toBeNull(); + }); +}); + +describe('latestProductionReadyAt', () => { + it('returns the first READY production deployment', () => { + const deployments: VercelDeployment[] = [ + { + uid: 'preview', + state: 'READY', + createdAt: 1, + ready: 2, + target: null, + url: 'preview.vercel.app', + commitSha: 'abc', + commitMessage: null, + commitRef: null, + commitAuthor: null, + }, + { + uid: 'prod', + state: 'READY', + createdAt: 3, + ready: 4_000, + target: 'production', + url: 'helmsportslabs.com', + commitSha: 'def4567890', + commitMessage: 'fix', + commitRef: 'main', + commitAuthor: 'nick', + }, + ]; + expect(latestProductionReadyAt(deployments)).toEqual({ + readyAt: 4_000, + commitSha: 'def4567890', + }); + }); +}); diff --git a/src/lib/admin/__tests__/observe-action-result.test.ts b/src/lib/admin/__tests__/observe-action-result.test.ts new file mode 100644 index 000000000..80669eca7 --- /dev/null +++ b/src/lib/admin/__tests__/observe-action-result.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + logServerError: vi.fn(async () => {}), +})); + +vi.mock('@/lib/server-error-logger', () => ({ + logServerError: mocks.logServerError, +})); + +import { + extractActionSoftFailure, + isExpectedSoftFailureMessage, + observeActionSoftFailure, +} from '@/lib/admin/observe-action-result'; +import { __resetEmitThrottleForTests } from '@/lib/admin/emit-throttle'; + +describe('observe-action-result', () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetEmitThrottleForTests(); + }); + + afterEach(() => { + __resetEmitThrottleForTests(); + }); + + it('extracts { success: false, error } envelopes', () => { + expect(extractActionSoftFailure({ success: false, error: 'DB blew up' })).toEqual({ + message: 'DB blew up', + code: null, + }); + }); + + it('extracts { data: null, error } envelopes', () => { + expect(extractActionSoftFailure({ data: null, error: 'missing row' })).toEqual({ + message: 'missing row', + code: null, + }); + }); + + it('classifies auth-ish copy as expected soft failures', () => { + expect(isExpectedSoftFailureMessage('Not authenticated')).toBe(true); + expect(isExpectedSoftFailureMessage('Could not complete the calendar action. Please try again.')).toBe(false); + }); + + it('logs unexpected soft failures at error severity', () => { + observeActionSoftFailure( + { success: false, error: 'Could not save document' }, + { action: 'uploadBaseballDocument', sport: 'baseball', feature: 'baseball_documents', source: 'server_action' }, + ); + + expect(mocks.logServerError).toHaveBeenCalledTimes(1); + const errorCall = mocks.logServerError.mock.calls[0] as + | [string, Record | undefined, 'warning' | 'error' | 'critical'] + | undefined; + expect(errorCall?.[2]).toBe('error'); + }); + + it('logs expected soft failures as warnings with skipSentry', () => { + observeActionSoftFailure( + { success: false, error: 'Not authenticated' }, + { action: 'createBaseballEvent', sport: 'baseball', source: 'server_action' }, + ); + + expect(mocks.logServerError).toHaveBeenCalledTimes(1); + const warningCall = mocks.logServerError.mock.calls[0] as + | [string, Record | undefined, 'warning' | 'error' | 'critical'] + | undefined; + expect(warningCall?.[2]).toBe('warning'); + expect(warningCall?.[1]).toMatchObject({ skipSentry: true }); + }); + + it('ignores successful results', () => { + observeActionSoftFailure( + { success: true }, + { action: 'noop', source: 'server_action' }, + ); + expect(mocks.logServerError).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/admin/__tests__/pr-body-parser.test.ts b/src/lib/admin/__tests__/pr-body-parser.test.ts new file mode 100644 index 000000000..6035c2296 --- /dev/null +++ b/src/lib/admin/__tests__/pr-body-parser.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { inferWorkAreaFromTitle, parsePullRequestBody } from '@/lib/admin/pr-body-parser'; + +const SAMPLE_BODY = `## Summary + +Fix calendar timezone drift on GolfHelm events. + +## Partner-readable summary + +Coaches saw practice blocks on the wrong day after DST. What changed: events now store team timezone and render in local wall time. Why it matters: demo reliability for spring onboarding. + +## Type of change + +- [x] Bug fix +- [ ] Feature / new behavior + +## Area + +golf + +## Git Activity Timeline note + +Calendar events stay on the correct day after timezone changes. + +## Checklist + +- [x] Tests pass +`; + +describe('parsePullRequestBody', () => { + it('extracts template sections into problem / fix / area', () => { + const parsed = parsePullRequestBody(SAMPLE_BODY, 'fix(golf): calendar timezone'); + expect(parsed.area).toBe('golf'); + expect(parsed.summary).toContain('timezone drift'); + expect(parsed.problem).toContain('wrong day'); + expect(parsed.fix).toContain('local wall time'); + expect(parsed.timelineNote).toContain('correct day'); + expect(parsed.changeTypes).toEqual(['Bug fix']); + }); + + it('falls back to title inference when Area is missing', () => { + const parsed = parsePullRequestBody('## Summary\n\nPipeline card drag fix.', 'fix(baseball): pipeline stage drag'); + expect(parsed.area).toBe('baseball'); + }); + + it('uses summary when partner block is absent', () => { + const parsed = parsePullRequestBody('## Summary\n\nHarden admin Sentry read API.', 'fix(admin): sentry errors tab'); + expect(parsed.area).toBe('bridge'); + expect(parsed.problem).toContain('Sentry'); + }); +}); + +describe('inferWorkAreaFromTitle', () => { + it('detects coachhelm from title keywords', () => { + expect(inferWorkAreaFromTitle('feat(coachhelm): insight lifecycle')).toBe('coachhelm'); + }); +}); diff --git a/src/lib/admin/ben-leah-issue-tracker.ts b/src/lib/admin/ben-leah-issue-tracker.ts new file mode 100644 index 000000000..dddc2faae --- /dev/null +++ b/src/lib/admin/ben-leah-issue-tracker.ts @@ -0,0 +1,198 @@ +/** + * Pure Ben + Leah issue lifecycle helpers — safe to import from tests and + * server fetchers alike (no secrets, no server-only). + */ + +import type { VercelDeployment } from '@/lib/admin/vercel-api'; + +/** Workflow stage shown in Helm Bridge — derived from GitHub state, labels, and prod deploy timing. */ +export type BenLeahTrackStatus = + | 'open' + | 'in_progress' + | 'in_production' + | 'fixed_pending_deploy' + | 'fixed' + | 'wont_fix'; + +export interface BenLeahGitHubIssue { + number: number; + html_url: string; + title: string; + displayTitle: string; + state: 'open' | 'closed'; + created_at: string; + updated_at: string; + closed_at: string | null; + labels: string[]; + kind: 'bug' | 'change' | 'addition' | null; + priority: 'normal' | 'high' | 'urgent' | null; + category: string | null; +} + +export interface BenLeahTrackedIssue extends BenLeahGitHubIssue { + trackStatus: BenLeahTrackStatus; +} + +/** GitHub workflow labels Helm Bridge reads and writes. */ +export const BEN_LEAH_WORKFLOW_LABELS = [ + 'status:triaged', + 'status:in-progress', + 'status:in-production', + 'status:wontfix', +] as const; + +export type BenLeahWorkflowLabel = (typeof BEN_LEAH_WORKFLOW_LABELS)[number]; + +/** Initial workflow label applied to every new Ben + Leah submission. */ +export const BEN_LEAH_INITIAL_WORKFLOW_LABEL: BenLeahWorkflowLabel = 'status:triaged'; + +/** Values the Bridge UI can set on an issue (maps 1:1 to workflow labels). */ +export type BenLeahWorkflowSelection = + | 'triaged' + | 'in_progress' + | 'in_production' + | 'wont_fix'; + +export const BEN_LEAH_WORKFLOW_LABEL_DEFS: ReadonlyArray<{ + name: BenLeahWorkflowLabel; + color: string; + description: string; +}> = [ + { + name: 'status:triaged', + color: '0E8A16', + description: 'Ben + Leah: submitted, awaiting or in triage', + }, + { + name: 'status:in-progress', + color: 'FBCA04', + description: 'Ben + Leah: work started', + }, + { + name: 'status:in-production', + color: '1D76DB', + description: 'Ben + Leah: manually confirmed live in production', + }, + { + name: 'status:wontfix', + color: '888888', + description: 'Ben + Leah: will not ship', + }, +]; + +const WORKFLOW_SELECTION_TO_LABEL: Record = { + triaged: 'status:triaged', + in_progress: 'status:in-progress', + in_production: 'status:in-production', + wont_fix: 'status:wontfix', +}; + +export function workflowLabelForSelection(selection: BenLeahWorkflowSelection): BenLeahWorkflowLabel { + return WORKFLOW_SELECTION_TO_LABEL[selection]; +} + +export function isBenLeahWorkflowLabel(label: string): label is BenLeahWorkflowLabel { + return (BEN_LEAH_WORKFLOW_LABELS as readonly string[]).includes(label); +} + +/** Current workflow label on the issue, if any. */ +export function currentWorkflowSelection(labels: string[]): BenLeahWorkflowSelection | null { + if (labels.includes('status:wontfix')) return 'wont_fix'; + if (labels.includes('status:in-production')) return 'in_production'; + if (labels.includes('status:in-progress')) return 'in_progress'; + if (labels.includes('status:triaged')) return 'triaged'; + return null; +} + +/** Replace workflow labels while preserving ben-leah / kind / priority / category tags. */ +export function applyWorkflowSelection( + labels: string[], + selection: BenLeahWorkflowSelection, +): string[] { + const preserved = labels.filter((label) => !isBenLeahWorkflowLabel(label)); + return [...preserved, workflowLabelForSelection(selection)]; +} + +export function stripWorkflowLabels(labels: string[]): string[] { + return labels.filter((label) => !isBenLeahWorkflowLabel(label)); +} + +/** Latest READY production deployment timestamp (epoch ms), if Vercel data is available. */ +export function latestProductionReadyAt(deployments: VercelDeployment[]): { + readyAt: number | null; + commitSha: string | null; +} { + const prod = deployments.find( + (deployment) => + deployment.state === 'READY' && + deployment.target === 'production' && + deployment.ready != null, + ); + if (!prod?.ready) return { readyAt: null, commitSha: null }; + return { readyAt: prod.ready, commitSha: prod.commitSha }; +} + +/** + * Derive Helm Bridge lifecycle from GitHub metadata + optional production deploy time. + * + * Label conventions (optional on the GitHub issue): + * - `status:in-progress` / `status:triaged` → In progress + * - `status:in-production` → In production (manual confirmation) + * - `status:wontfix` → Won't fix + * + * When an issue is closed, a production deploy whose `ready` time is at or after + * `closed_at` is treated as "In production". + */ +export function deriveBenLeahTrackStatus( + issue: BenLeahGitHubIssue, + productionReadyAt: number | null, +): BenLeahTrackStatus { + const labels = issue.labels; + + if (labels.includes('status:wontfix')) return 'wont_fix'; + if (labels.includes('status:in-production')) return 'in_production'; + + if (issue.state === 'open') { + if (labels.includes('status:in-progress') || labels.includes('status:triaged')) { + return 'in_progress'; + } + return 'open'; + } + + const closedAt = issue.closed_at ? Date.parse(issue.closed_at) : null; + if (closedAt != null && productionReadyAt != null && productionReadyAt >= closedAt) { + return 'in_production'; + } + if (closedAt != null && productionReadyAt != null && productionReadyAt < closedAt) { + return 'fixed_pending_deploy'; + } + + return 'fixed'; +} + +export function emptyBenLeahStatusCounts(): Record { + return { + open: 0, + in_progress: 0, + in_production: 0, + fixed_pending_deploy: 0, + fixed: 0, + wont_fix: 0, + }; +} + +export function countBenLeahByStatus(issues: BenLeahTrackedIssue[]): Record { + const counts = emptyBenLeahStatusCounts(); + for (const issue of issues) counts[issue.trackStatus] += 1; + return counts; +} + +export function withBenLeahTrackStatus( + issues: BenLeahGitHubIssue[], + productionReadyAt: number | null, +): BenLeahTrackedIssue[] { + return issues.map((issue) => ({ + ...issue, + trackStatus: deriveBenLeahTrackStatus(issue, productionReadyAt), + })); +} diff --git a/src/lib/admin/ben-leah-issues.ts b/src/lib/admin/ben-leah-issues.ts new file mode 100644 index 000000000..5684e5b0c --- /dev/null +++ b/src/lib/admin/ben-leah-issues.ts @@ -0,0 +1,168 @@ +import 'server-only'; +import { + failed, + ok, + unconfigured, + type AdminFetchResult, +} from '@/lib/admin/fetch-result'; +import { + countBenLeahByStatus, + withBenLeahTrackStatus, + latestProductionReadyAt, + type BenLeahGitHubIssue, + type BenLeahTrackedIssue, + type BenLeahTrackStatus, +} from '@/lib/admin/ben-leah-issue-tracker'; +import { githubIssuesHeaders, githubIssuesRepo, githubIssuesToken } from '@/lib/admin/github-issues-config'; +import { ensureBenLeahGitHubLabels } from '@/lib/admin/github-issues-workflow'; +import { fetchVercelDeployments } from '@/lib/admin/vercel-api'; + +export type { + BenLeahGitHubIssue, + BenLeahTrackedIssue, + BenLeahTrackStatus, +} from '@/lib/admin/ben-leah-issue-tracker'; +export { + deriveBenLeahTrackStatus, + latestProductionReadyAt, +} from '@/lib/admin/ben-leah-issue-tracker'; + +export interface BenLeahIssueBoardSnapshot { + issues: BenLeahTrackedIssue[]; + repoLabel: string; + repoIssuesUrl: string; + productionReadyAt: number | null; + productionCommitSha: string | null; + counts: Record; +} + +function issueToken(): string | null { + return githubIssuesToken(); +} + +function issueRepo(): { owner: string; repo: string; label: string } { + return githubIssuesRepo(); +} + +function githubHeaders(token: string): HeadersInit { + return githubIssuesHeaders(token); +} + +interface RawGitHubIssue { + number: number; + html_url: string; + title: string; + state: 'open' | 'closed'; + created_at: string; + updated_at: string; + closed_at: string | null; + labels: Array<{ name: string }>; + pull_request?: unknown; +} + +function parseLabelValue(labels: string[], prefix: string): string | null { + const hit = labels.find((label) => label.startsWith(`${prefix}:`)); + return hit ? hit.slice(prefix.length + 1) : null; +} + +function displayTitle(raw: string): string { + return raw.replace(/^\[Ben \+ Leah\]\s*(Bug|Change|Addition):\s*/i, '').trim() || raw; +} + +function normalizeIssue(raw: RawGitHubIssue): BenLeahGitHubIssue { + const labels = raw.labels.map((label) => label.name); + const kindRaw = parseLabelValue(labels, 'kind'); + const priorityRaw = parseLabelValue(labels, 'priority'); + return { + number: raw.number, + html_url: raw.html_url, + title: raw.title, + displayTitle: displayTitle(raw.title), + state: raw.state, + created_at: raw.created_at, + updated_at: raw.updated_at, + closed_at: raw.closed_at, + labels, + kind: kindRaw === 'bug' || kindRaw === 'change' || kindRaw === 'addition' ? kindRaw : null, + priority: priorityRaw === 'high' || priorityRaw === 'urgent' ? priorityRaw : priorityRaw === 'normal' ? 'normal' : null, + category: parseLabelValue(labels, 'category'), + }; +} + +async function fetchRawIssues(token: string, owner: string, repo: string): Promise { + const params = new URLSearchParams({ + labels: 'ben-leah', + state: 'all', + sort: 'updated', + direction: 'desc', + per_page: '50', + }); + + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues?${params}`, { + headers: githubHeaders(token), + next: { revalidate: 60 }, + }); + + if (res.ok) { + const body = (await res.json()) as RawGitHubIssue[]; + return body.filter((issue) => !issue.pull_request); + } + + if (res.status !== 404 && res.status !== 422) { + const text = await res.text(); + throw new Error(`GitHub issues list failed (${res.status}): ${text.slice(0, 300)}`); + } + + // Label may not exist yet — fall back to title prefix (label-less retry path). + const searchParams = new URLSearchParams({ + q: `repo:${owner}/${repo} is:issue "[Ben + Leah]" in:title`, + sort: 'updated', + order: 'desc', + per_page: '50', + }); + const searchRes = await fetch(`https://api.github.com/search/issues?${searchParams}`, { + headers: githubHeaders(token), + next: { revalidate: 60 }, + }); + if (!searchRes.ok) { + const text = await searchRes.text(); + throw new Error(`GitHub issue search failed (${searchRes.status}): ${text.slice(0, 300)}`); + } + const searchBody = (await searchRes.json()) as { items?: RawGitHubIssue[] }; + return (searchBody.items ?? []).filter((issue) => !issue.pull_request); +} + +export async function fetchBenLeahIssueBoard(): Promise> { + const token = issueToken(); + if (!token) return unconfigured('GitHub issues API'); + + const { owner, repo, label } = issueRepo(); + + try { + await ensureBenLeahGitHubLabels(); + + const [rawIssues, deploys] = await Promise.all([ + fetchRawIssues(token, owner, repo), + fetchVercelDeployments(30), + ]); + + const production = + deploys.status === 'ok' && deploys.data + ? latestProductionReadyAt(deploys.data) + : { readyAt: null, commitSha: null }; + + const normalized = rawIssues.map(normalizeIssue); + const tracked = withBenLeahTrackStatus(normalized, production.readyAt); + + return ok({ + issues: tracked, + repoLabel: label, + repoIssuesUrl: `https://github.com/${owner}/${repo}/issues?q=label%3Aben-leah`, + productionReadyAt: production.readyAt, + productionCommitSha: production.commitSha, + counts: countBenLeahByStatus(tracked), + }); + } catch (err) { + return failed(err instanceof Error ? err.message : String(err)); + } +} diff --git a/src/lib/admin/data/__tests__/incident-feed.test.ts b/src/lib/admin/data/__tests__/incident-feed.test.ts new file mode 100644 index 000000000..09eb66a59 --- /dev/null +++ b/src/lib/admin/data/__tests__/incident-feed.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { + buildIncidentFeedFromSources, + filterSentryIssuesByWindow, + summarizeIncidentFeed, +} from '@/lib/admin/data/incident-feed'; +import type { AppTriageEventRow } from '@/lib/admin/data/triage'; +import type { SentryIssue } from '@/lib/admin/sentry-api'; + +const appEvent = (over: Partial): AppTriageEventRow => ({ + id: 'e1', + title: 'save failed', + message: 'insert failed', + severity: 'error', + sport: 'baseball', + fingerprint: 'fp-1', + user_id: 'u1', + url: '/baseball/dashboard', + created_at: '2026-07-04T12:00:00.000Z', + ...over, +}); + +const sentryIssue = (over: Partial): SentryIssue => ({ + id: 's1', + shortId: 'HELM-1', + title: 'TypeError', + culprit: null, + level: 'error', + status: 'unresolved', + substatus: null, + count: 3, + userCount: 1, + firstSeen: '2026-07-03T00:00:00Z', + lastSeen: '2026-07-04T11:00:00.000Z', + permalink: 'https://sentry.io/x', + stats24h: [], + ...over, +}); + +describe('incident feed', () => { + it('filters Sentry issues to those with lastSeen inside the window', () => { + const inWindow = sentryIssue({ id: 'in', lastSeen: new Date().toISOString() }); + const outWindow = sentryIssue({ + id: 'out', + lastSeen: new Date(Date.now() - 48 * 3600_000).toISOString(), + }); + + const filtered = filterSentryIssuesByWindow([inWindow, outWindow], 24); + expect(filtered.map((i) => i.id)).toEqual(['in']); + }); + + it('builds one total count from app + windowed sentry groups', () => { + const { incidents, counts } = buildIncidentFeedFromSources( + [appEvent({ id: 'e1' }), appEvent({ id: 'e2', fingerprint: 'fp-2' })], + [ + sentryIssue({ id: 's1', lastSeen: new Date().toISOString() }), + sentryIssue({ id: 's2', lastSeen: new Date(Date.now() - 48 * 3600_000).toISOString() }), + ], + 24, + ); + + expect(incidents).toHaveLength(3); + expect(summarizeIncidentFeed(incidents)).toEqual(counts); + expect(counts).toMatchObject({ totalGroups: 3, appGroups: 2, sentryGroups: 1 }); + }); +}); diff --git a/src/lib/admin/data/errors.ts b/src/lib/admin/data/errors.ts index 5b0271b3f..bf0d5db4b 100644 --- a/src/lib/admin/data/errors.ts +++ b/src/lib/admin/data/errors.ts @@ -1,7 +1,6 @@ import 'server-only'; import { createAdminClient } from '@/lib/supabase/admin'; import { - fetchSentryIssues, fetchSentryHourlyStats, type SentryIssue, type SentryStatsPoint, @@ -9,11 +8,12 @@ import { import { fetchVercelDeployments, type VercelDeployment } from '@/lib/admin/vercel-api'; import type { AdminFetchResult } from '@/lib/admin/fetch-result'; import { - mergeTriage, - type TriageItem, - type TriageSeverity, - type AppTriageEventRow, -} from '@/lib/admin/data/triage'; + fetchIncidentFeed, + DEFAULT_INCIDENT_WINDOW_HOURS, + type IncidentFeedFilters, + type IncidentFeedCounts, +} from '@/lib/admin/data/incident-feed'; +import type { TriageItem, TriageSeverity } from '@/lib/admin/data/triage'; import { FEATURE_REGISTRY, type FeatureKey } from '@/lib/admin/feature-registry'; import { buildIncidentReport, @@ -55,7 +55,7 @@ function first(v: string | string[] | undefined): string | undefined { export function parseErrorsFilters( searchParams: Record, ): ErrorsTabFilters { - const filters: ErrorsTabFilters = { windowHours: 24 }; + const filters: ErrorsTabFilters = { windowHours: DEFAULT_INCIDENT_WINDOW_HOURS }; const sport = first(searchParams.sport); if (sport && SPORTS.has(sport)) filters.sport = sport as ErrorsTabFilters['sport']; const severity = first(searchParams.severity); @@ -99,46 +99,59 @@ export async function fetchErrorsTab(filters: ErrorsTabFilters): Promise<{ deployments: AdminFetchResult; deployMarkers: number[]; incidents: TriageItem[]; + counts: IncidentFeedCounts; rlsDenials24h: number; + widerWindowUnresolved: number | null; + widerWindowUntagged: number | null; }> { const admin = createAdminClient(); - const since = new Date(Date.now() - filters.windowHours * 3600_000).toISOString(); const ago24h = new Date(Date.now() - 24 * 3600_000).toISOString(); - - let query = admin - .from('admin_events') - .select( - 'id, title, message, severity, sport, fingerprint, user_id, user_email, url, created_at, source, feature, stack_trace, metadata', - ) - .eq('event_type', 'error') - .eq('resolved', false) - .gte('created_at', since) - .order('created_at', { ascending: false }) - .limit(500); - // `info` rows (integrity-check PASS sweeps, pattern-miner "tried and found - // nothing" starvation, philosophy-gate filter counts, and other routine - // telemetry) are never incidents — they still get written to admin_events - // (Feature Health's green-dot classifier reads get_feature_health() - // independently of this query, so capture is unaffected), just never - // surfaced in this feed/export. Skipped only when a filter chip explicitly - // asks for `severity=info` — the UI never offers that chip, but an explicit - // request should still work instead of silently contradicting itself. - if (filters.severity !== 'info') query = query.neq('severity', 'info'); - if (filters.sport) query = query.eq('sport', filters.sport); - if (filters.severity) query = query.eq('severity', filters.severity); - if (filters.source) query = query.eq('source', filters.source); - if (filters.feature) query = query.eq('feature', filters.feature); + const feedFilters: IncidentFeedFilters = { + windowHours: filters.windowHours, + sport: filters.sport, + severity: filters.severity, + source: filters.source, + feature: filters.feature, + }; let rlsQuery = admin.from('admin_events').select('id', { count: 'exact', head: true }) .eq('source', 'rls_denial').gte('created_at', ago24h); if (filters.sport) rlsQuery = rlsQuery.eq('sport', filters.sport); - const [sentry, hourly, deploys, appRes, rlsRes] = await Promise.all([ - fetchSentryIssues({ limit: 50 }), + const widerSince = + filters.windowHours < 168 + ? new Date(Date.now() - 168 * 3600_000).toISOString() + : null; + let widerQuery = widerSince + ? admin + .from('admin_events') + .select('id', { count: 'exact', head: true }) + .eq('event_type', 'error') + .eq('resolved', false) + .neq('severity', 'info') + .gte('created_at', widerSince) + : null; + if (widerQuery && filters.sport) widerQuery = widerQuery.eq('sport', filters.sport); + + const widerUntaggedQuery = + widerSince && filters.sport + ? admin + .from('admin_events') + .select('id', { count: 'exact', head: true }) + .eq('event_type', 'error') + .eq('resolved', false) + .neq('severity', 'info') + .gte('created_at', widerSince) + .is('sport', null) + : null; + + const [hourly, deploys, rlsRes, widerRes, widerUntaggedRes, feed] = await Promise.all([ fetchSentryHourlyStats(), fetchVercelDeployments(20), - query, rlsQuery, + widerQuery, + widerUntaggedQuery, + fetchIncidentFeed(feedFilters), ]); const windowStart = Date.now() - filters.windowHours * 3600_000; @@ -146,14 +159,16 @@ export async function fetchErrorsTab(filters: ErrorsTabFilters): Promise<{ .filter((d) => d.target === 'production' && d.createdAt >= windowStart) .map((d) => d.createdAt); - const appEvents = (appRes.data ?? []) as unknown as AppTriageEventRow[]; return { - sentry, + sentry: feed.sentry, hourly, deployments: deploys, deployMarkers, - incidents: mergeTriage({ sentryIssues: [], appEvents }), + incidents: feed.incidents, + counts: feed.counts, rlsDenials24h: rlsRes.count ?? 0, + widerWindowUnresolved: widerRes ? widerRes.count ?? 0 : null, + widerWindowUntagged: widerUntaggedRes ? widerUntaggedRes.count ?? 0 : null, }; } diff --git a/src/lib/admin/data/incident-feed.ts b/src/lib/admin/data/incident-feed.ts new file mode 100644 index 000000000..7f00b2a3a --- /dev/null +++ b/src/lib/admin/data/incident-feed.ts @@ -0,0 +1,121 @@ +import 'server-only'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { fetchSentryIssues, type SentryIssue } from '@/lib/admin/sentry-api'; +import type { AdminFetchResult } from '@/lib/admin/fetch-result'; +import type { FeatureKey } from '@/lib/admin/feature-registry'; +import { + mergeTriage, + type AppTriageEventRow, + type TriageItem, + type TriageSeverity, +} from '@/lib/admin/data/triage'; + +/** Default window shared by Overview KPIs, triage queue, and Errors tab. */ +export const DEFAULT_INCIDENT_WINDOW_HOURS = 24; + +export interface IncidentFeedFilters { + windowHours: number; + sport?: 'golf' | 'baseball' | 'shared'; + severity?: TriageSeverity; + source?: string; + feature?: FeatureKey; +} + +export interface IncidentFeedCounts { + totalGroups: number; + appGroups: number; + sentryGroups: number; + highSeverityGroups: number; + affectedUsers: number; +} + +const APP_EVENT_SELECT = + 'id, title, message, severity, sport, fingerprint, user_id, user_email, url, created_at, source, feature, stack_trace, metadata'; + +/** Sentry unresolved issues that actually fired inside the feed window. */ +export function filterSentryIssuesByWindow( + issues: readonly SentryIssue[], + windowHours: number, +): SentryIssue[] { + const since = Date.now() - windowHours * 3600_000; + return issues.filter((issue) => Date.parse(issue.lastSeen) >= since); +} + +export function summarizeIncidentFeed(incidents: readonly TriageItem[]): IncidentFeedCounts { + const appGroups = incidents.filter((item) => item.origin === 'app').length; + const sentryGroups = incidents.filter((item) => item.origin === 'sentry').length; + const highSeverityGroups = incidents.filter( + (item) => item.severity === 'critical' || item.severity === 'error', + ).length; + const affectedUsers = incidents.reduce((sum, item) => sum + item.affectedUsers, 0); + return { + totalGroups: incidents.length, + appGroups, + sentryGroups, + highSeverityGroups, + affectedUsers, + }; +} + +/** Pure merge — unit-testable; async fetchers feed this. */ +export function buildIncidentFeedFromSources( + appEvents: readonly AppTriageEventRow[], + sentryIssues: readonly SentryIssue[], + windowHours: number, +): { incidents: TriageItem[]; counts: IncidentFeedCounts } { + const sentryInWindow = filterSentryIssuesByWindow(sentryIssues, windowHours); + const incidents = mergeTriage({ sentryIssues: sentryInWindow, appEvents: [...appEvents] }); + return { incidents, counts: summarizeIncidentFeed(incidents) }; +} + +export async function queryAppErrorEvents( + filters: IncidentFeedFilters, +): Promise { + const admin = createAdminClient(); + const since = new Date(Date.now() - filters.windowHours * 3600_000).toISOString(); + + let query = admin + .from('admin_events') + .select(APP_EVENT_SELECT) + .eq('event_type', 'error') + .eq('resolved', false) + .gte('created_at', since) + .order('created_at', { ascending: false }) + .limit(500); + + if (filters.severity !== 'info') query = query.neq('severity', 'info'); + if (filters.sport) query = query.eq('sport', filters.sport); + if (filters.severity) query = query.eq('severity', filters.severity); + if (filters.source) query = query.eq('source', filters.source); + if (filters.feature) query = query.eq('feature', filters.feature); + + const { data } = await query; + return (data ?? []) as unknown as AppTriageEventRow[]; +} + +/** + * Canonical Helm Bridge incident feed — one definition for Overview KPIs, + * Overview triage queue, and the Errors tab (default window, no extra filters). + */ +export async function fetchIncidentFeed( + filters: IncidentFeedFilters, + prefetched?: { sentry?: AdminFetchResult }, +): Promise<{ + incidents: TriageItem[]; + appEvents: AppTriageEventRow[]; + sentry: AdminFetchResult; + counts: IncidentFeedCounts; +}> { + const [sentry, appEvents] = await Promise.all([ + prefetched?.sentry ?? fetchSentryIssues({ limit: 50 }), + queryAppErrorEvents(filters), + ]); + + const { incidents, counts } = buildIncidentFeedFromSources( + appEvents, + sentry.data ?? [], + filters.windowHours, + ); + + return { incidents, appEvents, sentry, counts }; +} diff --git a/src/lib/admin/data/overview.ts b/src/lib/admin/data/overview.ts index 642936b4d..fb7aa5006 100644 --- a/src/lib/admin/data/overview.ts +++ b/src/lib/admin/data/overview.ts @@ -1,8 +1,12 @@ import 'server-only'; import { createAdminClient } from '@/lib/supabase/admin'; -import { fetchSentryIssues } from '@/lib/admin/sentry-api'; import { fetchVercelDeployments, deployAgeMinutes } from '@/lib/admin/vercel-api'; -import { groupAppErrorEvents, type AppTriageEventRow, type TriageItem } from '@/lib/admin/data/triage'; +import type { AppTriageEventRow, TriageItem } from '@/lib/admin/data/triage'; +import { + fetchIncidentFeed, + DEFAULT_INCIDENT_WINDOW_HOURS, + buildIncidentFeedFromSources, +} from '@/lib/admin/data/incident-feed'; import { fetchFeatureHealth, summarizeFeatureHealth, @@ -11,8 +15,10 @@ import { } from '@/lib/admin/data/feature-health'; export interface OverviewKpis { + /** Unresolved Sentry issues (org-wide; not windowed). */ sentryUnresolved: number | null; - eventErrors24h: number; + /** Coalesced incident groups in the last 24h — same feed as Overview triage + Errors tab default. */ + incidentGroups24h: number; authFailures24h: number; activeUsersToday: number; activityToday: { golf: number; baseball: number; lifting: number }; @@ -82,7 +88,7 @@ export function isoStartOfToday(now: Date = new Date()): string { } export function activeAppErrorGroups(rows: AppTriageEventRow[]): TriageItem[] { - return groupAppErrorEvents(rows); + return buildIncidentFeedFromSources(rows, [], DEFAULT_INCIDENT_WINDOW_HOURS).incidents; } /** CALLER must have passed requireSuperAdmin() (service-role reads). */ @@ -92,9 +98,8 @@ export async function fetchOverviewSnapshot() { const today = isoStartOfToday(); const [ - sentry, deploys, - appErrors24h, + incidentFeed24h, security24h, activeToday, golfToday, @@ -105,19 +110,8 @@ export async function fetchOverviewSnapshot() { lastCron, featureHealthRaw, ] = await Promise.all([ - fetchSentryIssues({ limit: 50 }), fetchVercelDeployments(5), - admin - .from('admin_events') - .select( - 'id, title, message, severity, sport, fingerprint, user_id, user_email, url, created_at, source, feature, stack_trace, metadata', - ) - .eq('event_type', 'error') - .eq('resolved', false) - .gte('created_at', ago24h) - .neq('severity', 'info') - .order('created_at', { ascending: false }) - .limit(500), + fetchIncidentFeed({ windowHours: DEFAULT_INCIDENT_WINDOW_HOURS }), admin.from('admin_events').select('id', { count: 'exact', head: true }) .eq('event_type', 'security').gte('created_at', ago24h), admin.from('users').select('id', { count: 'exact', head: true }) @@ -142,10 +136,10 @@ export async function fetchOverviewSnapshot() { ]); const lastDeployRow = deploys.data?.[0] ?? null; - const appErrorGroups24h = activeAppErrorGroups((appErrors24h.data ?? []) as unknown as AppTriageEventRow[]); const kpis: OverviewKpis = { - sentryUnresolved: sentry.status === 'ok' ? (sentry.data?.length ?? 0) : null, - eventErrors24h: appErrorGroups24h.length, + sentryUnresolved: + incidentFeed24h.sentry.status === 'ok' ? (incidentFeed24h.sentry.data?.length ?? 0) : null, + incidentGroups24h: incidentFeed24h.counts.totalGroups, authFailures24h: security24h.count ?? 0, activeUsersToday: activeToday.count ?? 0, activityToday: { @@ -181,13 +175,15 @@ export async function fetchOverviewSnapshot() { const featureHealthCriticalCount = featureHealth.red; const featureHealthAttentionCount = featureHealth.red === 0 && featureHealth.newFingerprints24h > 0 ? 1 : 0; - const criticalAppErrorGroups24h = appErrorGroups24h.filter((item) => item.severity === 'critical').length; + const criticalIncidentGroups24h = incidentFeed24h.incidents.filter( + (item) => item.severity === 'critical', + ).length; const attentionFromDeploy = kpis.lastDeploy?.state === 'ERROR' ? 1 : 0; const banner = { ...computeBannerState({ - criticalCount: criticalAppErrorGroups24h + featureHealthCriticalCount, + criticalCount: criticalIncidentGroups24h + featureHealthCriticalCount, attentionCount: attentionFromDeploy + featureHealthAttentionCount, - anyFeedStale: sentry.status === 'error' || watcher.some((w) => w.stale) || featureHealth.degraded, + anyFeedStale: incidentFeed24h.sentry.status === 'error' || watcher.some((w) => w.stale) || featureHealth.degraded, }), checkedAt: now.toISOString(), // Single-line banner detail per N6 (never a wall of routine noise) — diff --git a/src/lib/admin/data/triage.ts b/src/lib/admin/data/triage.ts index 09a8b9378..5bd9fdb65 100644 --- a/src/lib/admin/data/triage.ts +++ b/src/lib/admin/data/triage.ts @@ -1,7 +1,11 @@ import 'server-only'; -import { createAdminClient } from '@/lib/supabase/admin'; -import { fetchSentryIssues, type SentryIssue } from '@/lib/admin/sentry-api'; +import type { SentryIssue } from '@/lib/admin/sentry-api'; import type { AdminFetchResult } from '@/lib/admin/fetch-result'; +import { + fetchIncidentFeed, + DEFAULT_INCIDENT_WINDOW_HOURS, + type IncidentFeedCounts, +} from '@/lib/admin/data/incident-feed'; import { buildIncidentReport, extractActionName, @@ -237,34 +241,13 @@ export function groupAppErrorEvents(rows: AppTriageEventRow[]): TriageItem[] { * Server fetcher. CALLER must have passed requireSuperAdmin() first — * this reads admin_events with the service-role client. */ -export async function fetchTriageQueue(): Promise<{ +export async function fetchTriageQueue( + windowHours: number = DEFAULT_INCIDENT_WINDOW_HOURS, +): Promise<{ items: TriageItem[]; sentry: AdminFetchResult; + counts: IncidentFeedCounts; }> { - const admin = createAdminClient(); - const [sentry, appRes] = await Promise.all([ - fetchSentryIssues(), - admin - .from('admin_events') - .select( - 'id, title, message, severity, sport, fingerprint, user_id, user_email, url, created_at, source, feature, stack_trace, metadata', - ) - .eq('event_type', 'error') - .eq('resolved', false) - // `info` rows (integrity-check PASS sweeps, pattern-miner starvation, - // philosophy-gate filter counts, etc.) are routine telemetry, not - // incidents — excluded from this feed (Needs Attention + the ops - // digest) the same way as the /admin/errors tab (errors.ts). They are - // still captured in admin_events and still feed Feature Health's - // green-dot classifier via a separate query (get_feature_health()). - .neq('severity', 'info') - .order('created_at', { ascending: false }) - .limit(500), - ]); - - const appEvents = (appRes.data ?? []) as unknown as AppTriageEventRow[]; - return { - items: mergeTriage({ sentryIssues: sentry.data ?? [], appEvents }), - sentry, - }; + const feed = await fetchIncidentFeed({ windowHours }); + return { items: feed.incidents, sentry: feed.sentry, counts: feed.counts }; } diff --git a/src/lib/admin/github-feedback.ts b/src/lib/admin/github-feedback.ts index f8a6f8692..2a38fa982 100644 --- a/src/lib/admin/github-feedback.ts +++ b/src/lib/admin/github-feedback.ts @@ -1,5 +1,10 @@ import 'server-only'; import { createAdminClient } from '@/lib/supabase/admin'; +import { githubIssuesHeaders, githubIssuesRepo, githubIssuesToken } from '@/lib/admin/github-issues-config'; +import { + BEN_LEAH_INITIAL_WORKFLOW_LABEL, + ensureBenLeahGitHubLabels, +} from '@/lib/admin/github-issues-workflow'; export type FeedbackKind = 'bug' | 'change' | 'addition'; export type FeedbackPriority = 'normal' | 'high' | 'urgent'; @@ -113,13 +118,11 @@ export function buildFeedbackIssueBody(input: FeedbackIssueInput): string { } function issueToken(): string | null { - return process.env.GITHUB_ISSUES_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim() || null; + return githubIssuesToken(); } function issueRepo(): { owner: string; repo: string } { - const raw = process.env.GITHUB_ISSUES_REPO?.trim() || process.env.GITHUB_REPOSITORY?.trim() || 'njrini99-code/helmv3'; - const [owner, repo] = raw.split('/'); - if (!owner || !repo) return { owner: 'njrini99-code', repo: 'helmv3' }; + const { owner, repo } = githubIssuesRepo(); return { owner, repo }; } @@ -159,34 +162,39 @@ export async function createGitHubFeedbackIssue(input: FeedbackIssueInput): Prom if (!token) { throw new Error('GitHub issue token is not configured. Set GITHUB_ISSUES_TOKEN or GITHUB_TOKEN.'); } + await ensureBenLeahGitHubLabels(); + const { owner, repo } = issueRepo(); + const headers = { + ...githubIssuesHeaders(token), + 'content-type': 'application/json', + }; + const payload = { + title: `[Ben + Leah] ${KIND_LABEL[input.kind]}: ${input.title}`, + body: buildFeedbackIssueBody(input), + labels: [ + 'ben-leah', + BEN_LEAH_INITIAL_WORKFLOW_LABEL, + `kind:${input.kind}`, + `priority:${input.priority}`, + `category:${input.category}`, + ], + }; + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues`, { method: 'POST', - headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - 'x-github-api-version': '2022-11-28', - }, - body: JSON.stringify({ - title: `[Ben + Leah] ${KIND_LABEL[input.kind]}: ${input.title}`, - body: buildFeedbackIssueBody(input), - labels: ['ben-leah', `kind:${input.kind}`, `priority:${input.priority}`, `category:${input.category}`], - }), + headers, + body: JSON.stringify(payload), }); if (res.status === 422) { const retry = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues`, { method: 'POST', - headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - 'x-github-api-version': '2022-11-28', - }, + headers, body: JSON.stringify({ - title: `[Ben + Leah] ${KIND_LABEL[input.kind]}: ${input.title}`, - body: buildFeedbackIssueBody(input), + title: payload.title, + body: payload.body, + labels: ['ben-leah', BEN_LEAH_INITIAL_WORKFLOW_LABEL], }), }); if (!retry.ok) { diff --git a/src/lib/admin/github-issues-config.ts b/src/lib/admin/github-issues-config.ts new file mode 100644 index 000000000..957d4e682 --- /dev/null +++ b/src/lib/admin/github-issues-config.ts @@ -0,0 +1,25 @@ +import 'server-only'; + +/** Shared GitHub Issues API config — no fetch, safe to import from create + tracker paths. */ + +export function githubIssuesToken(): string | null { + return process.env.GITHUB_ISSUES_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim() || null; +} + +export function githubIssuesRepo(): { owner: string; repo: string; label: string } { + const raw = + process.env.GITHUB_ISSUES_REPO?.trim() || + process.env.GITHUB_REPOSITORY?.trim() || + 'njrini99-code/helmv3'; + const [owner, repo] = raw.split('/'); + if (!owner || !repo) return { owner: 'njrini99-code', repo: 'helmv3', label: raw }; + return { owner, repo, label: `${owner}/${repo}` }; +} + +export function githubIssuesHeaders(token: string): HeadersInit { + return { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'x-github-api-version': '2022-11-28', + }; +} diff --git a/src/lib/admin/github-issues-workflow.ts b/src/lib/admin/github-issues-workflow.ts new file mode 100644 index 000000000..543c547a0 --- /dev/null +++ b/src/lib/admin/github-issues-workflow.ts @@ -0,0 +1,90 @@ +import 'server-only'; +import { + BEN_LEAH_INITIAL_WORKFLOW_LABEL, + BEN_LEAH_WORKFLOW_LABEL_DEFS, + applyWorkflowSelection, + type BenLeahWorkflowSelection, +} from '@/lib/admin/ben-leah-issue-tracker'; +import { + githubIssuesHeaders, + githubIssuesRepo, + githubIssuesToken, +} from '@/lib/admin/github-issues-config'; + +const BEN_LEAH_REPO_LABEL = { + name: 'ben-leah', + color: '16A34A', + description: 'Submitted from Helm Bridge Ben + Leah', +} as const; + +async function createLabelIfMissing( + token: string, + owner: string, + repo: string, + name: string, + color: string, + description: string, +): Promise { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/labels`, { + method: 'POST', + headers: { + ...githubIssuesHeaders(token), + 'content-type': 'application/json', + }, + body: JSON.stringify({ name, color, description }), + }); + if (res.ok || res.status === 422) return; + const text = await res.text(); + throw new Error(`GitHub label create failed for ${name} (${res.status}): ${text.slice(0, 200)}`); +} + +/** Idempotent — creates ben-leah + workflow labels when missing. */ +export async function ensureBenLeahGitHubLabels(): Promise { + const token = githubIssuesToken(); + if (!token) return; + + const { owner, repo } = githubIssuesRepo(); + await createLabelIfMissing( + token, + owner, + repo, + BEN_LEAH_REPO_LABEL.name, + BEN_LEAH_REPO_LABEL.color, + BEN_LEAH_REPO_LABEL.description, + ); + for (const label of BEN_LEAH_WORKFLOW_LABEL_DEFS) { + await createLabelIfMissing(token, owner, repo, label.name, label.color, label.description); + } +} + +export async function setBenLeahIssueWorkflow( + issueNumber: number, + currentLabels: string[], + selection: BenLeahWorkflowSelection, +): Promise { + const token = githubIssuesToken(); + if (!token) { + throw new Error('GitHub issue token is not configured. Set GITHUB_ISSUES_TOKEN or GITHUB_TOKEN.'); + } + + await ensureBenLeahGitHubLabels(); + + const { owner, repo } = githubIssuesRepo(); + const labels = applyWorkflowSelection(currentLabels, selection); + + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, { + method: 'PATCH', + headers: { + ...githubIssuesHeaders(token), + 'content-type': 'application/json', + }, + body: JSON.stringify({ labels }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`GitHub issue label update failed (${res.status}): ${text.slice(0, 400)}`); + } +} + +export { BEN_LEAH_INITIAL_WORKFLOW_LABEL }; diff --git a/src/lib/admin/github-pr-timeline.ts b/src/lib/admin/github-pr-timeline.ts new file mode 100644 index 000000000..565e7dfba --- /dev/null +++ b/src/lib/admin/github-pr-timeline.ts @@ -0,0 +1,197 @@ +import 'server-only'; +import { + failed, + ok, + unconfigured, + type AdminFetchResult, +} from '@/lib/admin/fetch-result'; +import { githubIssuesHeaders, githubIssuesRepo, githubIssuesToken } from '@/lib/admin/github-issues-config'; +import { + inferWorkAreaFromTitle, + parsePullRequestBody, + type ParsedPrBody, + type WorkArea, +} from '@/lib/admin/pr-body-parser'; + +export type PrLifecycleState = 'open' | 'merged' | 'closed'; + +export interface WorkLogEntry { + number: number; + html_url: string; + title: string; + state: PrLifecycleState; + authorLogin: string; + created_at: string; + updated_at: string; + merged_at: string | null; + closed_at: string | null; + parsed: ParsedPrBody; +} + +export interface WorkLogSnapshot { + entries: WorkLogEntry[]; + repoLabel: string; + authorLogins: string[]; + counts: { + total: number; + merged: number; + open: number; + byArea: Record; + }; +} + +function prAuthorLogins(): string[] { + const raw = process.env.GITHUB_PR_AUTHOR_LOGINS?.trim(); + if (raw) { + return raw.split(',').map((login) => login.trim()).filter(Boolean); + } + const single = process.env.GITHUB_PR_AUTHOR_LOGIN?.trim(); + if (single) return [single]; + return []; +} + +function prFetchLimit(): number { + const raw = Number.parseInt(process.env.GITHUB_PR_FETCH_LIMIT ?? '60', 10); + return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 100) : 60; +} + +interface RawPullRequest { + number: number; + html_url: string; + title: string; + state: 'open' | 'closed'; + body: string | null; + created_at: string; + updated_at: string; + merged_at: string | null; + closed_at: string | null; + user: { login: string } | null; + draft?: boolean; +} + +function lifecycleState(pr: RawPullRequest): PrLifecycleState { + if (pr.merged_at) return 'merged'; + if (pr.state === 'open') return 'open'; + return 'closed'; +} + +function matchesAuthor(pr: RawPullRequest, authors: string[]): boolean { + if (authors.length === 0) return true; + const login = pr.user?.login?.toLowerCase(); + if (!login) return false; + return authors.some((author) => author.toLowerCase() === login); +} + +async function fetchPullRequests( + token: string, + owner: string, + repo: string, + authors: string[], + limit: number, +): Promise { + const authorQuery = + authors.length > 0 + ? `+${authors.map((login) => `author:${login}`).join('+')}` + : ''; + const searchParams = new URLSearchParams({ + q: `repo:${owner}/${repo} is:pr${authorQuery}`, + sort: 'updated', + order: 'desc', + per_page: String(Math.min(limit, 100)), + }); + + const searchRes = await fetch(`https://api.github.com/search/issues?${searchParams}`, { + headers: githubIssuesHeaders(token), + next: { revalidate: 120 }, + }); + + if (searchRes.ok) { + const body = (await searchRes.json()) as { items?: RawPullRequest[] }; + return body.items ?? []; + } + + // Fallback when search is unavailable — list pulls and filter locally. + const listParams = new URLSearchParams({ + state: 'all', + sort: 'updated', + direction: 'desc', + per_page: '100', + }); + const listRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?${listParams}`, { + headers: githubIssuesHeaders(token), + next: { revalidate: 120 }, + }); + if (!listRes.ok) { + const text = await listRes.text(); + throw new Error(`GitHub pull request fetch failed (${listRes.status}): ${text.slice(0, 300)}`); + } + const pulls = (await listRes.json()) as RawPullRequest[]; + return pulls.filter((pr) => matchesAuthor(pr, authors)).slice(0, limit); +} + +function countByArea(entries: WorkLogEntry[]): Record { + const counts: Record = { + golf: 0, + baseball: 0, + coachhelm: 0, + bridge: 0, + platform: 0, + mobile: 0, + shared: 0, + unknown: 0, + }; + for (const entry of entries) counts[entry.parsed.area] += 1; + return counts; +} + +export async function fetchWorkLog(): Promise> { + const token = githubIssuesToken(); + if (!token) return unconfigured('GitHub API'); + + const { owner, repo, label } = githubIssuesRepo(); + const authors = prAuthorLogins(); + const limit = prFetchLimit(); + + try { + const pulls = await fetchPullRequests(token, owner, repo, authors, limit); + const entries: WorkLogEntry[] = pulls.map((pr) => { + const base = parsePullRequestBody(pr.body, pr.title); + const parsed = + base.area === 'unknown' + ? { ...base, area: inferWorkAreaFromTitle(pr.title) } + : base; + return { + number: pr.number, + html_url: pr.html_url, + title: pr.title, + state: lifecycleState(pr), + authorLogin: pr.user?.login ?? 'unknown', + created_at: pr.created_at, + updated_at: pr.updated_at, + merged_at: pr.merged_at, + closed_at: pr.closed_at, + parsed, + }; + }); + + entries.sort((a, b) => { + const aTime = Date.parse(a.merged_at ?? a.closed_at ?? a.created_at); + const bTime = Date.parse(b.merged_at ?? b.closed_at ?? b.created_at); + return bTime - aTime; + }); + + return ok({ + entries, + repoLabel: label, + authorLogins: authors, + counts: { + total: entries.length, + merged: entries.filter((entry) => entry.state === 'merged').length, + open: entries.filter((entry) => entry.state === 'open').length, + byArea: countByArea(entries), + }, + }); + } catch (err) { + return failed(err instanceof Error ? err.message : String(err)); + } +} diff --git a/src/lib/admin/observe-action-result.ts b/src/lib/admin/observe-action-result.ts new file mode 100644 index 000000000..d5368873e --- /dev/null +++ b/src/lib/admin/observe-action-result.ts @@ -0,0 +1,111 @@ +import 'server-only'; + +import { isExpectedAuthNoise } from '@/lib/admin/data/triage'; +import { drainCollapsedCount, shouldEmit } from '@/lib/admin/emit-throttle'; +import { logServerError } from '@/lib/server-error-logger'; + +export type ActionSoftFailureContext = NonNullable[1]> & { + action: string; +}; + +/** User-facing control flow — logged as handled warnings, hidden from Sentry. */ +const EXPECTED_SOFT_FAILURE_PATTERNS: readonly RegExp[] = [ + /^not authenticated$/i, + /^unauthorized$/i, + /^you must be signed in/i, + /^coach or team not found$/i, + /^player profile not found$/i, + /^only coaches can/i, + /^you do not have permission/i, + /^this isn't available in the live demo/i, + /^choose a valid/i, + /^please (enter|select|provide)/i, +]; + +export function extractActionSoftFailure( + result: unknown, +): { message: string; code: string | null } | null { + if (!result || typeof result !== 'object' || Array.isArray(result)) return null; + const record = result as Record; + + if (record.success === false) { + const message = + (typeof record.error === 'string' && record.error.trim()) || + (typeof record.message === 'string' && record.message.trim()) || + 'Action returned success: false'; + const code = typeof record.code === 'string' ? record.code : null; + return { message, code }; + } + + if (record.ok === false) { + const message = + (typeof record.error === 'string' && record.error.trim()) || + (typeof record.message === 'string' && record.message.trim()) || + 'Action returned ok: false'; + const code = typeof record.code === 'string' ? record.code : null; + return { message, code }; + } + + // { data: null, error: '...' } result envelopes without an explicit success flag. + if ( + record.data === null && + typeof record.error === 'string' && + record.error.trim().length > 0 + ) { + return { message: record.error.trim(), code: null }; + } + + return null; +} + +export function isExpectedSoftFailureMessage(message: string): boolean { + if (isExpectedAuthNoise(message)) return true; + return EXPECTED_SOFT_FAILURE_PATTERNS.some((pattern) => pattern.test(message.trim())); +} + +function severityForSoftFailure(message: string): 'warning' | 'error' { + return isExpectedSoftFailureMessage(message) ? 'warning' : 'error'; +} + +/** + * Helm Bridge capture class #3 — server actions that return `{ success: false }` + * instead of throwing. Fire-and-forget; never changes the action result. + */ +export function observeActionSoftFailure( + result: unknown, + context: ActionSoftFailureContext, +): void { + const failure = extractActionSoftFailure(result); + if (!failure) return; + + const throttleKey = `soft:${context.action}:${failure.code ?? failure.message.slice(0, 120)}`; + if (!shouldEmit(throttleKey)) return; + + const collapsedCount = drainCollapsedCount(throttleKey); + const expected = isExpectedSoftFailureMessage(failure.message); + const severity = severityForSoftFailure(failure.message); + + try { + void Promise.resolve( + logServerError( + failure.message, + { + ...context, + title: `[${context.action}] ${failure.message}`.slice(0, 500), + handled: true, + skipSentry: expected, + errorCode: failure.code ?? undefined, + fingerprint: ['server_action_soft', context.feature ?? context.featureArea ?? 'unknown', context.action], + metadata: { + ...(context.metadata ?? {}), + soft_failure: true, + ...(collapsedCount > 0 ? { collapsed_count: collapsedCount } : {}), + }, + }, + severity, + ), + ).catch(() => {}); + } catch { + // Fire-and-forget: observability must never change action results. + } +} diff --git a/src/lib/admin/observed-action.ts b/src/lib/admin/observed-action.ts index 414e368d6..85b987064 100644 --- a/src/lib/admin/observed-action.ts +++ b/src/lib/admin/observed-action.ts @@ -1,5 +1,6 @@ import { logServerException } from '@/lib/server-error-logger'; import { shouldEmit, drainCollapsedCount } from '@/lib/admin/emit-throttle'; +import { observeActionSoftFailure } from '@/lib/admin/observe-action-result'; import { createClient } from '@/lib/supabase/server'; import type { FeatureKey } from '@/lib/admin/feature-registry'; @@ -74,7 +75,29 @@ export function withAdminObserved( ): (...args: Args) => Promise { return async (...args: Args): Promise => { try { - return await fn(...args); + const result = await fn(...args); + const observedUser = await resolveObservedUser(); + let extraContext: ObservedActionContext = {}; + try { + extraContext = opts.contextFrom?.(args) ?? {}; + } catch { + extraContext = {}; + } + observeActionSoftFailure(result, { + action: name, + source: 'server_action', + feature: opts.feature ?? null, + featureArea: opts.featureArea ?? opts.feature ?? null, + sport: opts.sport, + userId: observedUser.userId, + userEmail: observedUser.userEmail, + roundId: extraContext.roundId ?? null, + playerId: extraContext.playerId ?? null, + teamId: extraContext.teamId ?? null, + route: extraContext.route ?? null, + handled: true, + }); + return result; } catch (err) { if (!isNextControlFlowError(err)) { try { diff --git a/src/lib/admin/pr-body-parser.ts b/src/lib/admin/pr-body-parser.ts new file mode 100644 index 000000000..a5f18b8ea --- /dev/null +++ b/src/lib/admin/pr-body-parser.ts @@ -0,0 +1,178 @@ +/** + * Deterministic parser for `.github/PULL_REQUEST_TEMPLATE.md` sections. + * No LLM — summaries come from what the author already wrote in the PR. + */ + +export type WorkArea = + | 'golf' + | 'baseball' + | 'coachhelm' + | 'bridge' + | 'platform' + | 'mobile' + | 'shared' + | 'unknown'; + +export interface ParsedPrBody { + summary: string | null; + partnerSummary: string | null; + problem: string | null; + fix: string | null; + area: WorkArea; + timelineNote: string | null; + changeTypes: string[]; +} + +const AREA_ALIASES: Record = { + golf: 'golf', + golfhelm: 'golf', + baseball: 'baseball', + baseballhelm: 'baseball', + coachhelm: 'coachhelm', + coach: 'coachhelm', + bridge: 'bridge', + admin: 'bridge', + 'mission-control': 'bridge', + 'helm bridge': 'bridge', + platform: 'platform', + ci: 'platform', + tooling: 'platform', + chore: 'platform', + docs: 'platform', + mobile: 'mobile', + ios: 'mobile', + capacitor: 'mobile', + shared: 'shared', + dashboard: 'shared', + stats: 'shared', + import: 'shared', + auth: 'shared', +}; + +function stripHtmlComments(text: string): string { + return text.replace(//g, ''); +} + +function cleanSectionText(raw: string): string { + return stripHtmlComments(raw) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('- [ ]') && !line.startsWith('- [x]')) + .join('\n') + .trim(); +} + +function extractSection(body: string, heading: string): string | null { + const headingLine = `## ${heading}`; + const lines = body.split('\n'); + const start = lines.findIndex((line) => line.trim() === headingLine); + if (start === -1) return null; + + const content: string[] = []; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + const trimmed = line.trim(); + if (trimmed.startsWith('## ')) break; + content.push(line); + } + + const cleaned = cleanSectionText(content.join('\n')); + return cleaned.length > 0 ? cleaned : null; +} + +function parseCheckedTypes(body: string): string[] { + const headingLine = '## Type of change'; + const lines = body.split('\n'); + const start = lines.findIndex((line) => line.trim() === headingLine); + if (start === -1) return []; + + const checked: string[] = []; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line) continue; + const trimmed = line.trim(); + if (trimmed.startsWith('## ')) break; + const match = trimmed.match(/^- \[[xX]\]\s+(.+)$/); + if (match?.[1]) checked.push(match[1].trim()); + } + return checked; +} + +export function normalizeWorkAreaToken(raw: string | null | undefined): WorkArea { + if (!raw) return 'unknown'; + const token = raw.toLowerCase().replace(/[`·]/g, ' ').trim(); + for (const part of token.split(/[\s,/]+/)) { + const normalized = part.replace(/[^a-z-]/g, ''); + const hit = AREA_ALIASES[normalized as keyof typeof AREA_ALIASES]; + if (hit) return hit; + } + const dashed = token.replace(/\s+/g, '-'); + const dashedHit = AREA_ALIASES[dashed as keyof typeof AREA_ALIASES]; + if (dashedHit) return dashedHit; + return 'unknown'; +} + +export function inferWorkAreaFromTitle(title: string): WorkArea { + const lower = title.toLowerCase(); + if (/\b(baseball|pipeline|roster|box.?score)\b/.test(lower)) return 'baseball'; + if (/\b(golf|fairway|qualifier|round)\b/.test(lower)) return 'golf'; + if (/\b(coachhelm|insight|pattern)\b/.test(lower)) return 'coachhelm'; + if (/\b(admin|bridge|mission.?control|sentry|vercel)\b/.test(lower)) return 'bridge'; + if (/\b(ci|chore|deps|lint|tooling|docs)\b/.test(lower)) return 'platform'; + if (/\b(ios|capacitor|mobile)\b/.test(lower)) return 'mobile'; + return 'unknown'; +} + +function splitPartnerSummary(text: string): { problem: string | null; fix: string | null } { + const splitters = [ + /what changed[?:]?\s*/i, + /what was fixed[?:]?\s*/i, + /the fix[?:]?\s*/i, + /why it matters[?:]?\s*/i, + ]; + + for (const splitter of splitters) { + const match = text.match(splitter); + if (!match || match.index == null) continue; + const problem = text.slice(0, match.index).replace(/what was broken[^?]*\??/i, '').trim(); + const fix = text.slice(match.index + match[0].length).trim(); + return { + problem: problem || null, + fix: fix || null, + }; + } + + return { problem: text, fix: null }; +} + +export function parsePullRequestBody(body: string | null | undefined, title: string): ParsedPrBody { + const normalizedBody = stripHtmlComments(body ?? ''); + const summary = extractSection(normalizedBody, 'Summary'); + const partnerSummary = extractSection(normalizedBody, 'Partner-readable summary'); + const areaRaw = extractSection(normalizedBody, 'Area'); + const timelineNote = extractSection(normalizedBody, 'Git Activity Timeline note'); + const changeTypes = parseCheckedTypes(normalizedBody); + + const areaFromBody = normalizeWorkAreaToken(areaRaw?.split(/\s+/)[0] ?? areaRaw); + const area = areaFromBody === 'unknown' ? inferWorkAreaFromTitle(title) : areaFromBody; + + const partnerParts = partnerSummary ? splitPartnerSummary(partnerSummary) : { problem: null, fix: null }; + + const problem = partnerParts.problem ?? summary ?? null; + + const fix = + partnerParts.fix ?? + timelineNote ?? + (partnerParts.problem && summary ? summary : null); + + return { + summary, + partnerSummary, + problem, + fix, + area, + timelineNote, + changeTypes, + }; +} diff --git a/src/lib/auth/session-activity.ts b/src/lib/auth/session-activity.ts index 6f1d843fd..9d43ea4b5 100644 --- a/src/lib/auth/session-activity.ts +++ b/src/lib/auth/session-activity.ts @@ -2,7 +2,9 @@ * Session Activity Tracking * * Enforces an idle-session timeout: after SESSION_IDLE_TIMEOUT_MS (5 minutes) of - * no user activity, the user is signed out and must log in again. Shares the + * no user activity while the tab is hidden, the user is signed out and must log + * in again. While the tab is visible, a heartbeat keeps the session alive so + * reading a dashboard without mouse/keyboard does not log the user out. Shares the * `sb_last_activity` cookie with the server middleware, which enforces the same * window on navigations/reopens — this hook covers the "app stays open but idle" * and "mobile reopen from background (bfcache — no network request)" cases the @@ -17,6 +19,7 @@ import { createClient } from '@/lib/supabase/client'; import { SESSION_IDLE_COOKIE, SESSION_IDLE_COOKIE_MAX_AGE_S, + SESSION_VISIBLE_HEARTBEAT_MS, isSessionIdleExpired, parseLastActivity, } from '@/lib/auth/session-idle-shared'; @@ -88,6 +91,11 @@ export function useSessionActivity() { } const path = window.location.pathname; + if (path.startsWith('/admin')) { + const params = new URLSearchParams({ message: 'session_expired', returnTo: path }); + router.replace(`/golf/login?${params.toString()}`); + return; + } const sport = path.startsWith('/golf') ? 'golf' : path.startsWith('/lifting') @@ -115,7 +123,44 @@ export function useSessionActivity() { // Fresh/bootstrap: record activity now. updateLastActivity(); - // Periodic check catches "app open but idle" (no interaction, no requests). + let visibleHeartbeatRef: ReturnType | null = null; + + const stopVisibleHeartbeat = () => { + if (visibleHeartbeatRef) { + clearInterval(visibleHeartbeatRef); + visibleHeartbeatRef = null; + } + }; + + const startVisibleHeartbeat = () => { + if (visibleHeartbeatRef || document.visibilityState !== 'visible') return; + visibleHeartbeatRef = setInterval(() => { + if (document.visibilityState !== 'visible') { + stopVisibleHeartbeat(); + return; + } + if (isSessionTimedOut()) { + void handleLogout(); + return; + } + updateLastActivity(); + }, SESSION_VISIBLE_HEARTBEAT_MS); + }; + + const syncTabVisibility = () => { + if (document.visibilityState === 'visible') { + if (isSessionTimedOut()) { + void handleLogout(); + return; + } + updateLastActivity(); + startVisibleHeartbeat(); + } else { + stopVisibleHeartbeat(); + } + }; + + // Periodic check catches hidden-tab idle (heartbeat is paused while hidden). checkIntervalRef.current = setInterval(() => { void checkSessionTimeout(); }, ACTIVITY_CHECK_INTERVAL_MS); @@ -147,25 +192,22 @@ export function useSessionActivity() { }); // Mobile Safari / PWA reopen restores from bfcache WITHOUT a network request, - // so the server middleware never sees it. Re-check the moment the tab becomes - // visible again (visibilitychange) or is restored (pageshow). - const handleReshow = () => { - if (document.visibilityState === 'visible') { - void checkSessionTimeout(); - } - }; - document.addEventListener('visibilitychange', handleReshow); - window.addEventListener('pageshow', handleReshow); + // so the server middleware never sees it. Re-check when the tab becomes visible + // again (visibilitychange) or is restored (pageshow). + syncTabVisibility(); + document.addEventListener('visibilitychange', syncTabVisibility); + window.addEventListener('pageshow', syncTabVisibility); return () => { + stopVisibleHeartbeat(); if (checkIntervalRef.current) { clearInterval(checkIntervalRef.current); } activityEvents.forEach((event) => { window.removeEventListener(event, handleActivity); }); - document.removeEventListener('visibilitychange', handleReshow); - window.removeEventListener('pageshow', handleReshow); + document.removeEventListener('visibilitychange', syncTabVisibility); + window.removeEventListener('pageshow', syncTabVisibility); }; }, [handleLogout, checkSessionTimeout]); diff --git a/src/lib/auth/session-idle-shared.ts b/src/lib/auth/session-idle-shared.ts index 46eac3096..80ffa24d3 100644 --- a/src/lib/auth/session-idle-shared.ts +++ b/src/lib/auth/session-idle-shared.ts @@ -16,6 +16,13 @@ export const SESSION_IDLE_COOKIE = 'sb_last_activity'; /** Idle window: sign the user out after this long without activity. */ export const SESSION_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +/** + * While the tab is visible, refresh the activity marker on this interval so + * reading a dashboard (no mouse/keyboard) does not trip the idle window. + * Must stay well below {@link SESSION_IDLE_TIMEOUT_MS}. + */ +export const SESSION_VISIBLE_HEARTBEAT_MS = 60 * 1000; // 1 minute + /** * Cookie lifetime — deliberately MUCH longer than the timeout. * diff --git a/src/lib/baseball/__tests__/nav-manifest.test.ts b/src/lib/baseball/__tests__/nav-manifest.test.ts index 62e930177..a08d7f91b 100644 --- a/src/lib/baseball/__tests__/nav-manifest.test.ts +++ b/src/lib/baseball/__tests__/nav-manifest.test.ts @@ -146,7 +146,9 @@ describe('BASEBALL_NAV_MANIFEST', () => { const canonicalHrefs = new Set(manifestHrefsByStatus('canonical')); it('sanity: deprecated entries were actually collected', () => { - expect(deprecated.length).toBeGreaterThanOrEqual(10); + // Coach/player type routes and stats/games/new now redirect via middleware + // (see src/lib/supabase/middleware.ts); only on-disk redirect() pages remain. + expect(deprecated.length).toBeGreaterThanOrEqual(2); }); it.each(deprecated.map((e) => [e] as const))( diff --git a/src/lib/baseball/__tests__/resolve-active-hub.test.ts b/src/lib/baseball/__tests__/resolve-active-hub.test.ts index 9d522bc30..da38f705d 100644 --- a/src/lib/baseball/__tests__/resolve-active-hub.test.ts +++ b/src/lib/baseball/__tests__/resolve-active-hub.test.ts @@ -3,7 +3,10 @@ import { filterHubTabsByCapabilities, resolveActiveHub, } from '@/app/baseball/(dashboard)/_components/resolve-active-hub'; -import { COACH_STATS_TABS } from '@/app/baseball/(dashboard)/_components/hub-definitions'; +import { + COACH_MANAGEMENT_TABS, + COACH_STATS_TABS, +} from '@/app/baseball/(dashboard)/_components/hub-definitions'; describe('resolveActiveHub — capability-filtered tabs (#370)', () => { it('hides performance hub tabs when coach lacks lifting and readiness caps', () => { @@ -34,4 +37,42 @@ describe('resolveActiveHub — capability-filtered tabs (#370)', () => { expect(hub?.id).toBe('stats'); expect(hub?.tabs.some((t) => t.id === 'performance-programs')).toBe(false); }); + + it('activates the management hub on nested settings routes', () => { + const hub = resolveActiveHub({ + pathname: '/baseball/dashboard/settings/imports', + role: 'coach', + programType: 'college', + capabilities: { can_manage_settings: true, can_manage_imports: true }, + }); + expect(hub?.id).toBe('management'); + expect(hub?.tabs.some((t) => t.id === 'settings-imports')).toBe(true); + }); + + it('activates the player recruiting hub on exposure routes', () => { + const hub = resolveActiveHub({ + pathname: '/baseball/dashboard/college-interest', + role: 'player', + programType: 'high_school', + }); + expect(hub?.id).toBe('recruiting'); + }); + + it('includes settings home via program-settings href (ownedPrefixes, not duplicate matchPrefixes)', () => { + const hub = resolveActiveHub({ + pathname: '/baseball/dashboard/settings', + role: 'coach', + programType: 'college', + capabilities: { can_manage_settings: true }, + }); + expect(hub?.id).toBe('management'); + expect(hub?.tabs.some((t) => t.id === 'settings-home')).toBe(true); + }); +}); + +describe('COACH_MANAGEMENT_TABS — settings supplement anchor', () => { + it('includes the program-settings registry tab before settings supplements', () => { + expect(COACH_MANAGEMENT_TABS.some((t) => t.id === 'program-settings')).toBe(true); + expect(COACH_MANAGEMENT_TABS.some((t) => t.id === 'settings-home')).toBe(true); + }); }); diff --git a/src/lib/baseball/__tests__/settings-aliases-and-legacy-redirects.test.ts b/src/lib/baseball/__tests__/settings-aliases-and-legacy-redirects.test.ts index 93c45b143..dba424693 100644 --- a/src/lib/baseball/__tests__/settings-aliases-and-legacy-redirects.test.ts +++ b/src/lib/baseball/__tests__/settings-aliases-and-legacy-redirects.test.ts @@ -156,42 +156,6 @@ const LEGACY_REDIRECT_PAGES: ReadonlyArray<{ relPath: string[]; expectedTargets: readonly string[]; }> = [ - { - relPath: ['baseball', '(coach-dashboard)', 'coach', 'college', 'page.tsx'], - expectedTargets: ['/baseball/dashboard/command-center'], - }, - { - relPath: ['baseball', '(coach-dashboard)', 'coach', 'high-school', 'page.tsx'], - expectedTargets: ['/baseball/dashboard/command-center'], - }, - { - relPath: ['baseball', '(coach-dashboard)', 'coach', 'juco', 'page.tsx'], - expectedTargets: ['/baseball/dashboard/command-center'], - }, - { - relPath: ['baseball', '(coach-dashboard)', 'coach', 'showcase', 'page.tsx'], - expectedTargets: ['/baseball/dashboard/command-center'], - }, - { - relPath: ['baseball', '(player-dashboard)', 'player', 'college', 'page.tsx'], - expectedTargets: ['/baseball/player/today'], - }, - { - relPath: ['baseball', '(player-dashboard)', 'player', 'high-school', 'page.tsx'], - expectedTargets: ['/baseball/player/today'], - }, - { - relPath: ['baseball', '(player-dashboard)', 'player', 'juco', 'page.tsx'], - expectedTargets: ['/baseball/player/today'], - }, - { - relPath: ['baseball', '(player-dashboard)', 'player', 'showcase', 'page.tsx'], - expectedTargets: ['/baseball/player/today'], - }, - { - relPath: ['baseball', '(dashboard)', 'dashboard', 'team', 'high-school', 'page.tsx'], - expectedTargets: ['/baseball/dashboard/command-center'], - }, { relPath: ['baseball', '(dashboard)', 'dashboard', 'stats', 'page.tsx'], expectedTargets: ['/baseball/dashboard/stats-center'], @@ -246,14 +210,9 @@ describe('legacy create/new + role-type redirect pages target existing canonical }, ); - it('reuses stats-route-aliases.ts coverage for the legacy /stats/games/new create alias', async () => { - // stats-route-aliases.test.ts already locks this contract directly; this - // assertion just confirms the alias module + its canonical target both - // resolve, keeping this file's "legacy create/new style routes" coverage - // complete without duplicating that suite's assertions. - const { BASEBALL_STATS_GAME_CREATE_PATH, BASEBALL_STATS_GAME_CREATE_LEGACY_PATH } = - await import('../stats-route-aliases'); + it('reuses stats-route-aliases.ts coverage for the canonical game-create route', async () => { + const { BASEBALL_STATS_GAME_CREATE_PATH } = await import('../stats-route-aliases'); expect(routeExists(BASEBALL_STATS_GAME_CREATE_PATH)).toBe(true); - expect(routeExists(BASEBALL_STATS_GAME_CREATE_LEGACY_PATH)).toBe(true); + expect(routeExists(['/baseball/dashboard/stats/games', 'new'].join('/'))).toBe(false); }); }); diff --git a/src/lib/baseball/__tests__/stats-route-aliases.test.ts b/src/lib/baseball/__tests__/stats-route-aliases.test.ts index 055b98e73..6f3297667 100644 --- a/src/lib/baseball/__tests__/stats-route-aliases.test.ts +++ b/src/lib/baseball/__tests__/stats-route-aliases.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - BASEBALL_STATS_GAME_CREATE_LEGACY_PATH, BASEBALL_STATS_GAME_CREATE_PATH, - BASEBALL_STATS_ROUTE_ALIASES, getBaseballStatsGameCreateHref, } from '@/lib/baseball/stats-route-aliases'; @@ -12,9 +10,7 @@ describe('stats-route-aliases (#378)', () => { expect(BASEBALL_STATS_GAME_CREATE_PATH).toBe('/baseball/dashboard/stats/games/create'); }); - it('legacy /new redirects to canonical create', () => { - expect(BASEBALL_STATS_ROUTE_ALIASES[BASEBALL_STATS_GAME_CREATE_LEGACY_PATH]).toBe( - BASEBALL_STATS_GAME_CREATE_PATH, - ); + it('does not expose the removed /stats/games/new alias', () => { + expect(getBaseballStatsGameCreateHref()).not.toContain('/stats/games/new'); }); }); diff --git a/src/lib/baseball/__tests__/with-baseball-action-observability.test.ts b/src/lib/baseball/__tests__/with-baseball-action-observability.test.ts index 95c557327..4e453392f 100644 --- a/src/lib/baseball/__tests__/with-baseball-action-observability.test.ts +++ b/src/lib/baseball/__tests__/with-baseball-action-observability.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ })), isCurrentSessionBaseballDemo: vi.fn(async () => false), logServerException: vi.fn(async (..._args: unknown[]) => undefined), + logServerError: vi.fn(async (..._args: unknown[]) => undefined), scope: { setTag: vi.fn(), setUser: vi.fn(), @@ -37,6 +38,7 @@ vi.mock('@/lib/demo/baseball-config.server', () => ({ vi.mock('@/lib/server-error-logger', () => ({ logServerException: mocks.logServerException, + logServerError: mocks.logServerError, })); vi.mock('@sentry/nextjs', () => ({ @@ -153,4 +155,30 @@ describe('withBaseballAction observability', () => { }, }); }); + + it('logs { success: false } soft failures to Helm Bridge without throwing', async () => { + const action = withBaseballAction( + 'uploadBaseballDocument', + { featureArea: 'baseball-documents', feature: 'baseball_documents' }, + async () => ({ success: false as const, error: 'Storage bucket unavailable' }), + ); + + const result = await action(); + expect(result).toEqual({ success: false, error: 'Storage bucket unavailable' }); + expect(mocks.logServerException).not.toHaveBeenCalled(); + expect(mocks.logServerError).toHaveBeenCalledTimes(1); + + const softCall = mocks.logServerError.mock.calls[0] as + | [string, Record | undefined, 'warning' | 'error' | 'critical'] + | undefined; + expect(softCall?.[0]).toBe('Storage bucket unavailable'); + expect(softCall?.[2]).toBe('error'); + expect(softCall?.[1]).toMatchObject({ + action: 'uploadBaseballDocument', + sport: 'baseball', + feature: 'baseball_documents', + source: 'server_action', + handled: true, + }); + }); }); diff --git a/src/lib/baseball/nav-manifest.ts b/src/lib/baseball/nav-manifest.ts index 37ddce514..b46f2f942 100644 --- a/src/lib/baseball/nav-manifest.ts +++ b/src/lib/baseball/nav-manifest.ts @@ -61,6 +61,7 @@ import { PLAYER_STATS_TABS, PLAYER_DEVELOPMENT_TABS, PLAYER_TEAM_TABS, + PLAYER_RECRUITING_TABS, } from '@/app/baseball/(dashboard)/_components/hub-definitions'; import type { HubSubNavTab } from '@/app/baseball/(dashboard)/_components/hub-sub-nav'; @@ -148,6 +149,7 @@ const ALL_HUB_TAB_ARRAYS: readonly (readonly HubSubNavTab[])[] = [ PLAYER_STATS_TABS, PLAYER_DEVELOPMENT_TABS, PLAYER_TEAM_TABS, + PLAYER_RECRUITING_TABS, ]; const hubTabEntries: NavManifestEntry[] = ALL_HUB_TAB_ARRAYS.flatMap((tabs) => @@ -185,54 +187,6 @@ const settingsAliasEntries: NavManifestEntry[] = Object.values( // at runtime, but every static branch resolves to a canonical entry above. const legacyRedirectEntries: NavManifestEntry[] = [ - { - href: '/baseball/coach/college', - source: 'legacy-redirect', - status: 'deprecated', - target: '/baseball/dashboard/command-center', - }, - { - href: '/baseball/coach/high-school', - source: 'legacy-redirect', - status: 'deprecated', - target: '/baseball/dashboard/command-center', - }, - { - href: '/baseball/coach/juco', - source: 'legacy-redirect', - status: 'deprecated', - target: '/baseball/dashboard/command-center', - }, - { - href: '/baseball/coach/showcase', - source: 'legacy-redirect', - status: 'deprecated', - target: '/baseball/dashboard/command-center', - }, - { - href: '/baseball/player/college', - source: 'legacy-redirect', - status: 'deprecated', - target: '/baseball/player/today', - }, - { - href: '/baseball/player/high-school', - source: 'legacy-redirect', - status: 'deprecated', - target: '/baseball/player/today', - }, - { - href: '/baseball/player/juco', - source: 'legacy-redirect', - status: 'deprecated', - target: '/baseball/player/today', - }, - { - href: '/baseball/player/showcase', - source: 'legacy-redirect', - status: 'deprecated', - target: '/baseball/player/today', - }, { href: '/baseball/dashboard/team', source: 'legacy-redirect', @@ -243,12 +197,6 @@ const legacyRedirectEntries: NavManifestEntry[] = [ target: '/baseball/dashboard/command-center', note: 'Branches to /baseball/player/today for players at runtime — both arms are canonical.', }, - { - href: '/baseball/dashboard/team/high-school', - source: 'legacy-redirect', - status: 'deprecated', - target: '/baseball/dashboard/command-center', - }, { href: '/baseball/dashboard/stats', source: 'legacy-redirect', diff --git a/src/lib/baseball/nav-registry.ts b/src/lib/baseball/nav-registry.ts index 30c82b6f6..f82540889 100644 --- a/src/lib/baseball/nav-registry.ts +++ b/src/lib/baseball/nav-registry.ts @@ -229,6 +229,12 @@ export interface BaseballNavEntry { * role: 'player' entries, which never appear in a coach hub. */ hub?: BaseballNavHub; + /** + * Optional nested-route ownership prefixes for hub sub-nav active matching. + * When set, hub-definitions copies this onto HubSubNavTab.matchPrefixes so + * detail routes (e.g. /players/[id]) highlight the parent registry tab. + */ + matchPrefixes?: readonly string[]; } /** @@ -331,6 +337,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: null, section: 'primary', hub: 'team', + matchPrefixes: ['/baseball/dashboard/players'], }, { id: 'calendar', @@ -489,6 +496,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredAnyCapabilities: ['can_manage_lifting', 'can_view_readiness'], section: 'primary', hub: 'stats-performance', + matchPrefixes: ['/baseball/dashboard/performance'], }, { id: 'staff-decision-room', @@ -622,6 +630,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: null, section: 'primary', hub: 'development', + matchPrefixes: ['/baseball/dashboard/dev-plans'], }, { id: 'academics', @@ -647,6 +656,7 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ requiredCapability: null, section: 'primary', hub: 'recruiting', + matchPrefixes: ['/baseball/dashboard/camps'], }, { id: 'organization', @@ -757,15 +767,14 @@ export const BASEBALL_NAV_REGISTRY: readonly BaseballNavEntry[] = [ id: 'team', label: 'Dashboard', href: '/baseball/dashboard/command-center', - playerHref: '/baseball/player/today', icon: IconUsers, // Backward-compatible secondary landing. The old team dashboard route now - // redirects here for coaches and to Player Today for players. Classified + // redirects here for coaches. Classified // 'dashboard' for the hub-required-field invariant, but hub-definitions.ts // deliberately EXCLUDES this id from the Dashboard hub's rendered sub-tabs // (its href is an exact duplicate of 'command-center' for coaches — it is a // legacy alias, never a distinct destination worth its own tab). - role: 'both', + role: 'coach', requiredCapability: null, section: 'secondary', hub: 'dashboard', diff --git a/src/lib/baseball/route-contract.ts b/src/lib/baseball/route-contract.ts index 154adf8f7..d5f8de066 100644 --- a/src/lib/baseball/route-contract.ts +++ b/src/lib/baseball/route-contract.ts @@ -41,6 +41,7 @@ import { PLAYER_STATS_TABS, PLAYER_DEVELOPMENT_TABS, PLAYER_TEAM_TABS, + PLAYER_RECRUITING_TABS, } from '@/app/baseball/(dashboard)/_components/hub-definitions'; import type { HubSubNavTab } from '@/app/baseball/(dashboard)/_components/hub-sub-nav'; @@ -58,7 +59,8 @@ export type HubName = | 'coach-academics' | 'player-stats' | 'player-development' - | 'player-team'; + | 'player-team' + | 'player-recruiting'; export type DeclaredHrefOrigin = | { kind: 'nav-registry'; id: string; variant: 'href' | 'playerHref' } @@ -96,6 +98,7 @@ const HUB_TAB_SOURCES: ReadonlyArray<{ { hub: 'player-stats', role: 'player', tabs: PLAYER_STATS_TABS }, { hub: 'player-development', role: 'player', tabs: PLAYER_DEVELOPMENT_TABS }, { hub: 'player-team', role: 'player', tabs: PLAYER_TEAM_TABS }, + { hub: 'player-recruiting', role: 'player', tabs: PLAYER_RECRUITING_TABS }, ]; /** @@ -134,7 +137,8 @@ export function collectDeclaredHrefs(): DeclaredHref[] { role: 'both', requiredCapability: null, requiredAnyCapabilities: [], - matchPrefixes: [], + // Thread detail pages (/messages/[id]) are owned by the inbox list route. + matchPrefixes: [BASEBALL_MESSAGES_NAV.href], }); for (const source of HUB_TAB_SOURCES) { diff --git a/src/lib/baseball/stat-layer-manifest.ts b/src/lib/baseball/stat-layer-manifest.ts index 466b2707f..1c083a000 100644 --- a/src/lib/baseball/stat-layer-manifest.ts +++ b/src/lib/baseball/stat-layer-manifest.ts @@ -48,6 +48,8 @@ export const STAT_LAYER_SCAN_EXCLUDED_FILES = [ 'src/lib/types/database.ts', 'src/lib/baseball/stat-layer-manifest.ts', 'src/lib/baseball/__tests__/stat-layer-contract.test.ts', + // Helm Bridge metadata — lists primaryTable/heartbeatTable names, not a DB consumer. + 'src/lib/admin/feature-registry.ts', ] as const; export type StatLayerConsumerGroup = diff --git a/src/lib/baseball/stats-route-aliases.ts b/src/lib/baseball/stats-route-aliases.ts index d40b2b7ff..db35dd9cc 100644 --- a/src/lib/baseball/stats-route-aliases.ts +++ b/src/lib/baseball/stats-route-aliases.ts @@ -1,28 +1,13 @@ // ============================================================================= -// Canonical BaseballHelm stats routes and temporary redirect aliases (#378). +// Canonical BaseballHelm stats routes. // -// Policy: `/stats/games/create` is canonical. `/stats/games/new` redirects until -// bookmarks and external docs fully migrate (see BASEBALL_STATS_ROUTE_ALIASES). +// Policy: `/stats/games/create` is canonical. The old `/stats/games/new` +// redirect alias was removed after app/e2e references were migrated. // ============================================================================= export const BASEBALL_STATS_GAME_CREATE_PATH = '/baseball/dashboard/stats/games/create' as const; -/** Legacy path — kept as a redirect alias only; do not link in app UI. */ -export const BASEBALL_STATS_GAME_CREATE_LEGACY_PATH = - '/baseball/dashboard/stats/games/new' as const; - -/** - * Redirect aliases: legacy → canonical. App code must use canonical paths only. - */ -export const BASEBALL_STATS_ROUTE_ALIASES = { - [BASEBALL_STATS_GAME_CREATE_LEGACY_PATH]: BASEBALL_STATS_GAME_CREATE_PATH, -} as const; - -export type BaseballStatsLegacyRoute = keyof typeof BASEBALL_STATS_ROUTE_ALIASES; - - - export function getBaseballStatsGameCreateHref(): string { return BASEBALL_STATS_GAME_CREATE_PATH; } diff --git a/src/lib/baseball/with-baseball-action.ts b/src/lib/baseball/with-baseball-action.ts index c1d042656..0161112f3 100644 --- a/src/lib/baseball/with-baseball-action.ts +++ b/src/lib/baseball/with-baseball-action.ts @@ -62,6 +62,7 @@ import type { User } from '@supabase/supabase-js'; import { createClient } from '@/lib/supabase/server'; import { logServerException } from '@/lib/server-error-logger'; +import { observeActionSoftFailure } from '@/lib/admin/observe-action-result'; import { getActiveBaseballContext } from '@/lib/baseball/active-context'; import type { ActiveBaseballContext } from '@/lib/baseball/active-context-shared'; import { @@ -452,6 +453,7 @@ export function withBaseballAction( }; const result = await fn(ctx, ...args); + observeActionSoftFailure(result, buildTraceContext(true)); scope.addBreadcrumb({ category: 'baseball.action', message: `done ${name}`, diff --git a/src/lib/lifting/with-lifting-action.ts b/src/lib/lifting/with-lifting-action.ts index f666b1e4b..2f8234dca 100644 --- a/src/lib/lifting/with-lifting-action.ts +++ b/src/lib/lifting/with-lifting-action.ts @@ -32,6 +32,7 @@ import type { User } from '@supabase/supabase-js'; import { createClient } from '@/lib/supabase/server'; import { logServerEvent, logServerException } from '@/lib/server-error-logger'; +import { observeActionSoftFailure } from '@/lib/admin/observe-action-result'; import { resolveLiftingAccess } from '@/lib/lifting/access'; import { fromUntyped } from '@/lib/supabase/untyped'; import type { HelmLiftingAccessResult } from '@/lib/types/helm-lifting'; @@ -177,6 +178,18 @@ export function withLiftingAction( // 4. RUN const ctx: LiftingActionContext = { user, orgId, access }; const result = await fn(ctx, ...args); + observeActionSoftFailure(result, { + action: name, + featureArea, + feature: featureArea.replaceAll('-', '_'), + source: 'server_action', + sport: 'shared', + userId: user.id, + userEmail: user.email ?? null, + teamId: orgId, + handled: true, + tags: { sport: 'lifting', feature: featureArea, lifting_org: orgId }, + }); scope.addBreadcrumb({ category: 'lifting.action', message: `done ${name}`, level: 'info' }); return result; } catch (error) { diff --git a/src/lib/server-error-logger.ts b/src/lib/server-error-logger.ts index 6a5c5c4ea..1231dfe71 100644 --- a/src/lib/server-error-logger.ts +++ b/src/lib/server-error-logger.ts @@ -5,6 +5,7 @@ import { shouldPersistAdminTables, getRuntimeEnv } from '@/lib/telemetry-gate'; import { createAdminClient } from '@/lib/supabase/admin'; import type { Json } from '@/lib/types/database'; import { buildIncidentSignature, type IncidentSeverity } from '@/lib/admin/incident-grouping'; +import { classifyTraceSurface } from '@/lib/error-trace-classification'; export type ServerTraceSeverity = 'info' | 'warning' | 'error' | 'critical'; export type ServerTraceSource = @@ -78,7 +79,7 @@ const SENTRY_SEVERITY_MAP: Record = { critical: 'fatal', }; -function normalizeContext(context: RoundErrorContext): Record { +function normalizeContext(context: RoundErrorContext, traceMessage?: string): Record { return JSON.parse(JSON.stringify({ action: context.action, route: context.route ?? null, @@ -104,6 +105,7 @@ function normalizeContext(context: RoundErrorContext): Record { extra: context.extra ?? {}, sport: context.sport ?? null, teamId: context.teamId ?? null, + ...(traceMessage ? { trace_message: traceMessage.slice(0, 2000) } : {}), // Only reached when shouldPersistAdminTables() is true (writeAdminTables // is called after that gate), so this is always 'production' in practice // today — tagged explicitly so a future gate regression is visible in @@ -147,16 +149,40 @@ function buildFingerprint(context: RoundErrorContext, severity: ServerTraceSever ]; } +/** Infer sport when legacy emitters omit context.sport (most baseball actions pre-withBaseballAction). */ +function inferSport(message: string, context: RoundErrorContext): NonNullable | null { + if (context.sport) return context.sport; + const probe = `${context.route ?? ''} ${context.url ?? ''} ${context.action ?? ''}`; + const classified = classifyTraceSurface(probe, context.action ?? null); + if (classified.sport) return classified.sport; + if (/baseball/i.test(message)) return 'baseball'; + if (/\bgolf\b|coachhelm/i.test(message)) return 'golf'; + return null; +} + +function enrichTraceContext(message: string, context: RoundErrorContext): RoundErrorContext { + const sport = inferSport(message, context); + if (!sport) return context; + const probe = `${context.route ?? ''} ${context.url ?? ''} ${context.action ?? ''} ${message}`; + const classified = classifyTraceSurface(probe, context.action ?? null); + return { + ...context, + sport, + feature: context.feature ?? classified.feature ?? context.featureArea ?? null, + }; +} + async function writeAdminTables( message: string, error: Error | null, context: RoundErrorContext, severity: ServerTraceSeverity ) { + const enriched = enrichTraceContext(message, context); const admin = createAdminClient(); - const normalizedContext = normalizeContext(context); - const url = buildUrl(context); - const title = buildAdminTitle(message, context, severity); + const normalizedContext = normalizeContext(enriched); + const url = buildUrl(enriched); + const title = buildAdminTitle(message, enriched, severity); const stack = error?.stack?.slice(0, 8000) ?? null; const timestamp = new Date().toISOString(); @@ -185,21 +211,35 @@ async function writeAdminTables( severity, message: message.slice(0, 10000), metadata: normalizedContext as Json, - user_id: context.userId ?? null, - user_email: context.userEmail ?? null, + user_id: enriched.userId ?? null, + user_email: enriched.userEmail ?? null, url, stack_trace: stack, browser_info: null, - sport: context.sport ?? null, - team_id: context.teamId ?? null, + sport: enriched.sport ?? null, + team_id: enriched.teamId ?? null, fingerprint: dbFingerprint, - source: context.source ?? 'server_action', - feature: context.feature ?? context.featureArea ?? null, + source: enriched.source ?? 'server_action', + feature: enriched.feature ?? enriched.featureArea ?? null, }); await Promise.allSettled([errorLogInsert, adminEventInsert]); } +/** Static Sentry title — user/error copy lives in scope context, not the format string. */ +function sentryCaptureTitle(context: RoundErrorContext): string { + if (context.title?.trim()) { + return context.title.trim().slice(0, 200); + } + return `[${context.action}] server trace`; +} + +function syntheticTraceError(message: string): Error { + const err = new Error('Server trace error'); + err.cause = message; + return err; +} + function captureSentryTrace( message: string, error: Error | null, @@ -213,6 +253,7 @@ function captureSentryTrace( scope.setTag('error_source', context.source ?? 'server_action'); scope.setTag('feature_area', context.featureArea ?? 'unknown'); scope.setTag('feature', context.feature ?? context.featureArea ?? 'unknown'); + if (context.sport) scope.setTag('sport', context.sport); scope.setTag('handled', String(context.handled ?? true)); if (context.errorCode) scope.setTag('pg_error_code', context.errorCode); if (context.statusCode) scope.setTag('http_status', String(context.statusCode)); @@ -233,7 +274,7 @@ function captureSentryTrace( }); } - scope.setContext('server_trace', normalizeContext(context)); + scope.setContext('server_trace', normalizeContext(context, message)); scope.setFingerprint(buildFingerprint(context, severity)); // Route by severity: info/warning are messages (control-flow signals, @@ -244,10 +285,11 @@ function captureSentryTrace( // logServerException callers who explicitly handed us an Error. const isMessage = !forceException && (severity === 'info' || severity === 'warning'); + const sentryTitle = sentryCaptureTitle(context); if (isMessage) { - Sentry.captureMessage(message, SENTRY_SEVERITY_MAP[severity] ?? 'warning'); + Sentry.captureMessage(sentryTitle, SENTRY_SEVERITY_MAP[severity] ?? 'warning'); } else { - Sentry.captureException(error ?? new Error(message)); + Sentry.captureException(error ?? syntheticTraceError(message)); } }); } @@ -264,7 +306,8 @@ function isNextControlFlowError(message: string, error: Error | null): boolean { if (typeof digest === 'string' && (digest === 'DYNAMIC_SERVER_USAGE' || digest.startsWith('NEXT_'))) { return true; } - return message.includes('Dynamic server usage:'); + const controlFlowMarker = 'Dynamic server usage:'; + return message.indexOf(controlFlowMarker) !== -1; } async function captureServerTrace( @@ -274,29 +317,37 @@ async function captureServerTrace( error?: Error | null, forceException = false, ): Promise { - const normalizedError = error ?? new Error(message); + const enriched = enrichTraceContext(message, context); + const normalizedError = error ?? syntheticTraceError(message); // Skip, don't rethrow: callers already decide how the original error // propagates; our only job is to not record framework signals as incidents. if (isNextControlFlowError(message, error ?? null)) return; - if (!context.skipSentry) { + if (!enriched.skipSentry) { try { - captureSentryTrace(message, normalizedError, context, severity, forceException); + captureSentryTrace(message, normalizedError, enriched, severity, forceException); } catch { // Sentry should never block request handling. } } if (!shouldPersistAdminTables()) { - console.error(`[ServerErrorLogger] (${severity}, not persisted off-prod)`, message, context.action ?? ''); + console.error('[ServerErrorLogger] not persisted off-prod', { + severity, + traceMessage: message, + action: enriched.action ?? '', + }); return; } try { - await writeAdminTables(message, normalizedError, context, severity); + await writeAdminTables(message, normalizedError, enriched, severity); } catch { - console.error('[ServerErrorLogger] Failed to persist trace:', message, context); + console.error('[ServerErrorLogger] Failed to persist trace', { + traceMessage: message, + context: enriched, + }); } } diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index 8717c06f2..bb5625ed0 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -128,6 +128,28 @@ const ORG_PROGRAM_TYPES = new Set(['showcase', 'academy', ' const COACH_HOME = '/baseball/dashboard/command-center'; const PLAYER_HOME = '/baseball/player/today'; +/** + * Bookmark-compat redirects for legacy per-type landing stubs removed once the + * canonical Fairway dashboard routes shipped. Checked before auth so old links + * never 404. + */ +const LEGACY_BASEBALL_ROUTE_REDIRECTS: Readonly> = { + '/baseball/coach/college': COACH_HOME, + '/baseball/coach/high-school': COACH_HOME, + '/baseball/coach/juco': COACH_HOME, + '/baseball/coach/showcase': COACH_HOME, + '/baseball/player/college': PLAYER_HOME, + '/baseball/player/high-school': PLAYER_HOME, + '/baseball/player/juco': PLAYER_HOME, + '/baseball/player/showcase': PLAYER_HOME, + '/baseball/dashboard/team/high-school': COACH_HOME, + '/baseball/dashboard/stats/games/new': '/baseball/dashboard/stats/games/create', +}; + +function legacyBaseballRedirectTarget(pathname: string): string | null { + return LEGACY_BASEBALL_ROUTE_REDIRECTS[pathname] ?? null; +} + /** * Check if user is authorized to access the requested route based on their role */ @@ -309,6 +331,13 @@ export async function updateSession(request: NextRequest) { const sport = getSportFromPath(pathname); const onAdminPath = isAdminPath(pathname); + if (sport === 'baseball') { + const legacyTarget = legacyBaseballRedirectTarget(pathname); + if (legacyTarget) { + return NextResponse.redirect(new URL(legacyTarget, request.url)); + } + } + if (isNativeUserAgent(request) && isMarketingRoute(pathname)) { return NextResponse.redirect(new URL('/golf/login', request.url)); } diff --git a/src/test/helpers/baseball-route-inventory.ts b/src/test/helpers/baseball-route-inventory.ts index e7ef6bf90..09d3a34dd 100644 --- a/src/test/helpers/baseball-route-inventory.ts +++ b/src/test/helpers/baseball-route-inventory.ts @@ -109,6 +109,66 @@ export const REDIRECT_ALIAS_MANIFEST: RedirectAlias[] = [ 'short-circuits it before the page even renders.', implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_TEAM_ROUTES, COACH_HOME)', }, + { + from: '/baseball/coach/college', + to: '/baseball/dashboard/command-center', + reason: 'Legacy per-coach-type bookmark; stub page removed in favor of canonical Command Center.', + implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)', + }, + { + from: '/baseball/coach/high-school', + to: '/baseball/dashboard/command-center', + reason: 'Legacy per-coach-type bookmark; stub page removed in favor of canonical Command Center.', + implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)', + }, + { + from: '/baseball/coach/juco', + to: '/baseball/dashboard/command-center', + reason: 'Legacy per-coach-type bookmark; stub page removed in favor of canonical Command Center.', + implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)', + }, + { + from: '/baseball/coach/showcase', + to: '/baseball/dashboard/command-center', + reason: 'Legacy per-coach-type bookmark; stub page removed in favor of canonical Command Center.', + implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)', + }, + { + from: '/baseball/player/college', + to: '/baseball/player/today', + reason: 'Legacy per-program-type bookmark; stub page removed in favor of canonical Player Today.', + implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)', + }, + { + from: '/baseball/player/high-school', + to: '/baseball/player/today', + reason: 'Legacy per-program-type bookmark; stub page removed in favor of canonical Player Today.', + implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)', + }, + { + from: '/baseball/player/juco', + to: '/baseball/player/today', + reason: 'Legacy per-program-type bookmark; stub page removed in favor of canonical Player Today.', + implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)', + }, + { + from: '/baseball/player/showcase', + to: '/baseball/player/today', + reason: 'Legacy per-program-type bookmark; stub page removed in favor of canonical Player Today.', + implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)', + }, + { + from: '/baseball/dashboard/team/high-school', + to: '/baseball/dashboard/command-center', + reason: 'Legacy HS team dashboard bookmark; stub page removed in favor of Command Center.', + implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)', + }, + { + from: '/baseball/dashboard/stats/games/new', + to: '/baseball/dashboard/stats/games/create', + reason: 'Legacy game-create alias; canonical path is /stats/games/create.', + implementedAt: 'src/lib/supabase/middleware.ts (LEGACY_BASEBALL_ROUTE_REDIRECTS)', + }, ]; // ----------------------------------------------------------------------------- diff --git a/src/test/lib/auth/session-idle-shared.test.ts b/src/test/lib/auth/session-idle-shared.test.ts index 0bedd6fc5..747decc1d 100644 --- a/src/test/lib/auth/session-idle-shared.test.ts +++ b/src/test/lib/auth/session-idle-shared.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { SESSION_IDLE_TIMEOUT_MS, SESSION_IDLE_COOKIE_MAX_AGE_S, + SESSION_VISIBLE_HEARTBEAT_MS, parseLastActivity, isSessionIdleExpired, } from '@/lib/auth/session-idle-shared'; @@ -11,6 +12,10 @@ describe('session-idle-shared', () => { expect(SESSION_IDLE_TIMEOUT_MS).toBe(5 * 60 * 1000); }); + it('visible heartbeat is shorter than the idle window', () => { + expect(SESSION_VISIBLE_HEARTBEAT_MS).toBeLessThan(SESSION_IDLE_TIMEOUT_MS); + }); + it('cookie lifetime is much longer than the timeout (so a stale marker survives to be detected)', () => { // The reopen-after-idle case must be "present + stale", never "absent". expect(SESSION_IDLE_COOKIE_MAX_AGE_S * 1000).toBeGreaterThan(SESSION_IDLE_TIMEOUT_MS * 100);