From ae379eb52aa7e8c3cdc798d50af89834a6d758f0 Mon Sep 17 00:00:00 2001
From: njrini99-code
Date: Wed, 15 Jul 2026 18:10:03 -0400
Subject: [PATCH 01/18] =?UTF-8?q?devibe:=20remove=20dead=20files=20?=
=?UTF-8?q?=E2=80=94=20knip=20batch=201/2=20(mode-toggle,=20notifications,?=
=?UTF-8?q?=20insight-actions)=20(#858)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Verified dead via grep (import path + symbol name + next/dynamic scan),
then git rm. No consumers found in src/, no test coverage, no dynamic
imports referencing any of these paths.
- src/components/baseball/coach/ModeToggle.tsx — exports JUCOModeToggle,
zero importers repo-wide. Only referenced from stale docs (PHASE_5_JUCO_COACH.md,
.helm/ACTIONS.md) describing a wiring into src/components/layout/header.tsx,
which no longer exists.
- src/components/layout/mode-toggle.tsx — exports ModeToggle/Mode, its only
consumer was the dead file above.
- src/components/features/notification-center.tsx — duplicate/legacy
NotificationCenter; the live one is src/components/golf/calendar/NotificationCenter.tsx.
.taskmaster/docs/current-state.md already flagged it "Exists but not used".
- src/hooks/use-notifications.ts — duplicate/legacy useNotifications; the live
hook is src/hooks/useNotifications.ts (capital N), consumed by the real
NotificationCenter.
- src/components/golf/coachhelm/insights/{InsightBulkActions,InsightExportModal,
InsightFiltersPanel,InsightSearchBar}.tsx — not exported from the insights/
barrel (index.ts only re-exports PlayerFocusAreas/InsightsFeed/InsightListView
per its "Wave 1A" comment), zero direct importers, no next/dynamic references.
- src/lib/baseball/lifting/use-live-set-sync.ts — exports useLiveSetSync, zero
importers; only mentioned in docs/audits (planned-but-never-wired).
Gates: typecheck clean, check-cycles clean (33 known cycles, none new), no
test files reference any of these paths.
Co-authored-by: Fable Integrator
Co-authored-by: Claude Fable 5
---
src/components/baseball/coach/ModeToggle.tsx | 25 -
.../features/notification-center.tsx | 279 ----------
.../coachhelm/insights/InsightBulkActions.tsx | 280 ----------
.../coachhelm/insights/InsightExportModal.tsx | 252 ---------
.../insights/InsightFiltersPanel.tsx | 507 ------------------
.../coachhelm/insights/InsightSearchBar.tsx | 104 ----
src/components/layout/mode-toggle.tsx | 40 --
src/hooks/use-notifications.ts | 123 -----
src/lib/baseball/lifting/use-live-set-sync.ts | 152 ------
9 files changed, 1762 deletions(-)
delete mode 100644 src/components/baseball/coach/ModeToggle.tsx
delete mode 100644 src/components/features/notification-center.tsx
delete mode 100644 src/components/golf/coachhelm/insights/InsightBulkActions.tsx
delete mode 100644 src/components/golf/coachhelm/insights/InsightExportModal.tsx
delete mode 100644 src/components/golf/coachhelm/insights/InsightFiltersPanel.tsx
delete mode 100644 src/components/golf/coachhelm/insights/InsightSearchBar.tsx
delete mode 100644 src/components/layout/mode-toggle.tsx
delete mode 100644 src/hooks/use-notifications.ts
delete mode 100644 src/lib/baseball/lifting/use-live-set-sync.ts
diff --git a/src/components/baseball/coach/ModeToggle.tsx b/src/components/baseball/coach/ModeToggle.tsx
deleted file mode 100644
index c9b6295f9..000000000
--- a/src/components/baseball/coach/ModeToggle.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-'use client';
-
-import { useRouter } from 'next/navigation';
-import { useAuth } from '@/hooks/use-auth';
-import { ModeToggle, type Mode } from '@/components/layout/mode-toggle';
-
-export function JUCOModeToggle() {
- const router = useRouter();
- const { coach, coachMode, setCoachMode } = useAuth();
-
- if (!coach || coach.coach_type !== 'juco') return null;
-
- const handleModeChange = (mode: Mode) => {
- setCoachMode(mode as 'recruiting' | 'team');
- if (mode === 'team') {
- router.push('/baseball/dashboard/command-center');
- } else {
- router.push('/baseball/dashboard/command-center');
- }
- };
-
- return (
-
- );
-}
diff --git a/src/components/features/notification-center.tsx b/src/components/features/notification-center.tsx
deleted file mode 100644
index 3f36be5ad..000000000
--- a/src/components/features/notification-center.tsx
+++ /dev/null
@@ -1,279 +0,0 @@
-'use client';
-
-import { useState, useRef, useEffect } from 'react';
-import { IconBell, IconX, IconCheck } from '@/components/icons';
-import { Avatar } from '@/components/ui/avatar';
-import { Badge } from '@/components/ui/badge';
-import { Button, IconButton } from '@/components/ui/button';
-import { cn } from '@/lib/utils';
-
-interface Notification {
- id: string;
- type: 'profile_view' | 'watchlist_add' | 'message' | 'evaluation' | 'camp_interest' | 'team_join' | 'team_join_request' | 'team_join_approved' | 'team_join_rejected' | 'other';
- title: string;
- message: string;
- timestamp: string;
- read: boolean;
- actionUrl?: string;
- actorName?: string;
- actorAvatar?: string;
-}
-
-interface NotificationCenterProps {
- notifications: Notification[];
- onMarkAsRead: (id: string) => void;
- onMarkAllAsRead: () => void;
- onDelete: (id: string) => void;
- className?: string;
-}
-
-const typeIcons: Record = {
- profile_view: { icon: '👁️', color: 'bg-blue-100 text-blue-600' },
- watchlist_add: { icon: '⭐', color: 'bg-amber-100 text-amber-600' },
- message: { icon: '💬', color: 'bg-primary-100 text-primary-600' },
- evaluation: { icon: '📊', color: 'bg-purple-100 text-purple-600' },
- camp_interest: { icon: '🏕️', color: 'bg-cyan-100 text-cyan-600' },
- team_join: { icon: '👋', color: 'bg-primary-50 text-primary-700' },
- team_join_request: { icon: '🙋', color: 'bg-amber-100 text-amber-600' },
- team_join_approved: { icon: '✅', color: 'bg-primary-100 text-primary-600' },
- team_join_rejected: { icon: '❌', color: 'bg-red-100 text-red-600' },
- other: { icon: '🔔', color: 'bg-warm-100 text-warm-600' },
-};
-
-function formatTimestamp(timestamp: string): string {
- const date = new Date(timestamp);
- const now = new Date();
- const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
-
- if (diffInSeconds < 60) return 'Just now';
- if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
- if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
- if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`;
-
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
-}
-
-export function NotificationCenter({
- notifications,
- onMarkAsRead,
- onMarkAllAsRead,
- onDelete,
- className
-}: NotificationCenterProps) {
- const [isOpen, setIsOpen] = useState(false);
- const [filter, setFilter] = useState<'all' | 'unread'>('all');
- const dropdownRef = useRef(null);
- const buttonRef = useRef(null);
-
- const unreadCount = notifications.filter(n => !n.read).length;
-
- const filteredNotifications = filter === 'unread'
- ? notifications.filter(n => !n.read)
- : notifications;
-
- // Click outside to close
- useEffect(() => {
- const handleClickOutside = (e: MouseEvent) => {
- if (
- dropdownRef.current &&
- !dropdownRef.current.contains(e.target as Node) &&
- !buttonRef.current?.contains(e.target as Node)
- ) {
- setIsOpen(false);
- }
- };
-
- if (isOpen) {
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }
- return undefined;
- }, [isOpen]);
-
- return (
-
- {/* Bell Button */}
-
setIsOpen(!isOpen)}
- className="relative p-2 hover:bg-warm-100 active:bg-warm-200 rounded-lg transition-colors"
- >
-
- {unreadCount > 0 && (
-
- )}
-
-
- {/* Dropdown Panel */}
- {isOpen && (
-
-
- {/* Header */}
-
-
-
- Notifications
- {unreadCount > 0 && (
-
- {unreadCount}
-
- )}
-
- {unreadCount > 0 && (
-
-
- Mark all read
-
- )}
-
-
- {/* Filter Tabs */}
-
- setFilter('all')}
- className={cn(
- 'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
- filter === 'all'
- ? 'bg-cream-50 text-warm-900 shadow-sm'
- : 'text-warm-600 hover:text-warm-900'
- )}
- >
- All
-
- setFilter('unread')}
- className={cn(
- 'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
- filter === 'unread'
- ? 'bg-cream-50 text-warm-900 shadow-sm'
- : 'text-warm-600 hover:text-warm-900'
- )}
- >
- Unread {unreadCount > 0 && `(${unreadCount})`}
-
-
-
-
- {/* Notifications List */}
-
- {filteredNotifications.length === 0 ? (
-
-
-
-
-
- {filter === 'unread' ? 'All caught up!' : 'No notifications'}
-
-
- {filter === 'unread'
- ? 'You have no unread notifications'
- : 'Notifications will appear here'}
-
-
- ) : (
-
- {filteredNotifications.map((notification) => {
- const typeConfig = typeIcons[notification.type] || typeIcons['other'];
- const config = typeConfig!; // Assert non-null since we have fallback
-
- return (
-
-
-
- {/* Icon or Avatar */}
- {notification.actorAvatar ? (
-
- ) : (
-
- {config.icon}
-
- )}
-
- {/* Content */}
-
-
- {notification.title}
-
-
- {notification.message}
-
-
- {formatTimestamp(notification.timestamp)}
-
-
-
- {/* Actions */}
-
- {!notification.read && (
- onMarkAsRead(notification.id)}
- className="p-1 hover:bg-cream-100 active:bg-cream-100/75 rounded transition-colors"
- title="Mark as read"
- >
-
-
- )}
- onDelete(notification.id)}
- className="p-1 hover:bg-cream-100 active:bg-cream-100/75 rounded transition-colors"
- title="Delete"
- >
-
-
-
-
-
- {/* Unread indicator */}
- {!notification.read && (
-
- )}
-
-
- );
- })}
-
- )}
-
-
- {/* Footer */}
- {filteredNotifications.length > 0 && (
-
-
- View all notifications
-
-
- )}
-
- )}
-
- );
-}
diff --git a/src/components/golf/coachhelm/insights/InsightBulkActions.tsx b/src/components/golf/coachhelm/insights/InsightBulkActions.tsx
deleted file mode 100644
index e8a2b14d6..000000000
--- a/src/components/golf/coachhelm/insights/InsightBulkActions.tsx
+++ /dev/null
@@ -1,280 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
-import { cn } from '@/lib/utils';
-import { Button, IconButton } from '@/components/ui/button';
-import { ConfirmDialog } from '@/components/ui/confirm-dialog';
-import { triggerHaptic } from '@/lib/utils/capacitor';
-import {
- IconCheck,
- IconX,
- IconDownload,
- IconCheckCheck,
-} from '@/components/icons';
-
-// ============================================================================
-// TYPES
-// ============================================================================
-
-interface InsightBulkActionsProps {
- selectedCount: number;
- totalCount: number;
- onSelectAll: () => void;
- onDeselectAll: () => void;
- onBulkDismiss: () => Promise;
- onBulkAcknowledge: () => Promise;
- onBulkResolve?: () => Promise;
- onExport: () => void;
- isAllSelected: boolean;
- className?: string;
-}
-
-// ============================================================================
-// COMPONENT
-// ============================================================================
-
-export function InsightBulkActions({
- selectedCount,
- totalCount,
- onSelectAll,
- onDeselectAll,
- onBulkDismiss,
- onBulkAcknowledge,
- onBulkResolve,
- onExport,
- isAllSelected,
- className,
-}: InsightBulkActionsProps) {
- const prefersReducedMotion = useReducedMotion();
- const [isProcessing, setIsProcessing] = useState(false);
- const [confirmModal, setConfirmModal] = useState<{
- open: boolean;
- action: 'dismiss' | 'acknowledge' | 'resolve' | null;
- }>({ open: false, action: null });
-
- const isVisible = selectedCount > 0;
-
- const handleConfirmAction = async () => {
- if (!confirmModal.action) return;
-
- void triggerHaptic('medium');
- setIsProcessing(true);
-
- try {
- switch (confirmModal.action) {
- case 'dismiss':
- await onBulkDismiss();
- break;
- case 'acknowledge':
- await onBulkAcknowledge();
- break;
- case 'resolve':
- if (onBulkResolve) await onBulkResolve();
- break;
- }
- } finally {
- setIsProcessing(false);
- setConfirmModal({ open: false, action: null });
- }
- };
-
- const getConfirmModalProps = () => {
- switch (confirmModal.action) {
- case 'dismiss':
- return {
- title: `Dismiss ${selectedCount} insight${selectedCount !== 1 ? 's' : ''}?`,
- message: 'Dismissed insights will be hidden from your active feed. You can still view them by filtering for dismissed insights.',
- confirmLabel: 'Dismiss All',
- variant: 'danger' as const,
- };
- case 'acknowledge':
- return {
- title: `Acknowledge ${selectedCount} insight${selectedCount !== 1 ? 's' : ''}?`,
- message: 'Acknowledged insights indicate you have reviewed them. They will remain visible but marked as acknowledged.',
- confirmLabel: 'Acknowledge All',
- variant: 'default' as const,
- };
- case 'resolve':
- return {
- title: `Resolve ${selectedCount} insight${selectedCount !== 1 ? 's' : ''}?`,
- message: 'Resolved insights indicate the issue has been addressed. They will be marked as completed.',
- confirmLabel: 'Resolve All',
- variant: 'default' as const,
- };
- default:
- return {
- title: '',
- message: '',
- confirmLabel: '',
- variant: 'default' as const,
- };
- }
- };
-
- const modalProps = getConfirmModalProps();
-
- return (
- <>
-
- {isVisible && (
-
-
- {/* Selection Info */}
-
- {/* Selection Count Badge */}
-
- {selectedCount}
-
-
- {/* Selection Text */}
-
-
- {selectedCount} of {totalCount} selected
-
-
-
- {/* Select All / Deselect All */}
-
- {isAllSelected ? 'Deselect all' : 'Select all'}
-
-
-
- {/* Actions */}
-
- {/* Acknowledge */}
-
setConfirmModal({ open: true, action: 'acknowledge' })}
- disabled={isProcessing}
- className={cn(
- 'text-warm-300 hover:text-white hover:bg-warm-700',
- 'hidden sm:flex'
- )}
- >
-
- Acknowledge
-
-
- {/* Resolve (optional) */}
- {onBulkResolve && (
-
setConfirmModal({ open: true, action: 'resolve' })}
- disabled={isProcessing}
- className={cn(
- 'text-warm-300 hover:text-white hover:bg-warm-700',
- 'hidden md:flex'
- )}
- >
-
- Resolve
-
- )}
-
- {/* Dismiss */}
-
setConfirmModal({ open: true, action: 'dismiss' })}
- disabled={isProcessing}
- className="text-red-400 hover:text-red-300 hover:bg-red-900/30 transition-colors"
- >
-
-
- Dismiss
-
-
- {/* Divider */}
-
-
- {/* Export */}
-
-
- Export
-
-
- {/* Close */}
-
-
-
-
-
-
- )}
-
-
- {/* Confirmation Dialog */}
- setConfirmModal({ open: false, action: null })}
- onConfirm={handleConfirmAction}
- title={modalProps.title}
- message={modalProps.message}
- confirmLabel={modalProps.confirmLabel}
- cancelLabel="Cancel"
- variant={modalProps.variant}
- isLoading={isProcessing}
- />
- >
- );
-}
diff --git a/src/components/golf/coachhelm/insights/InsightExportModal.tsx b/src/components/golf/coachhelm/insights/InsightExportModal.tsx
deleted file mode 100644
index ec9a7a460..000000000
--- a/src/components/golf/coachhelm/insights/InsightExportModal.tsx
+++ /dev/null
@@ -1,252 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { motion, useReducedMotion } from 'framer-motion';
-import { cn } from '@/lib/utils';
-import {
- Drawer,
- DrawerContent,
- DrawerHeader,
- DrawerTitle,
- DrawerDescription,
-} from '@/components/ui/drawer';
-import { Button } from '@/components/ui/button';
-import { Card } from '@/components/ui/card';
-import {
- IconDownload,
- IconFile,
- IconCheck,
-} from '@/components/icons';
-import { exportInsights } from '@/app/golf/actions/insight-management';
-
-// ============================================================================
-// TYPES
-// ============================================================================
-
-type ExportFormat = 'csv' | 'json';
-
-interface InsightExportModalProps {
- open: boolean;
- onClose: () => void;
- selectedIds: string[];
- onExportComplete?: () => void;
-}
-
-// ============================================================================
-// FORMAT OPTIONS
-// ============================================================================
-
-const FORMAT_OPTIONS: {
- value: ExportFormat;
- label: string;
- description: string;
- icon: React.ReactNode;
-}[] = [
- {
- value: 'csv',
- label: 'CSV',
- description: 'Spreadsheet format for Excel, Google Sheets',
- icon: (
-
- CSV
-
- ),
- },
- {
- value: 'json',
- label: 'JSON',
- description: 'Structured data format for developers',
- icon: (
-
- {'{}'}
-
- ),
- },
-];
-
-// ============================================================================
-// COMPONENT
-// ============================================================================
-
-export function InsightExportModal({
- open,
- onClose,
- selectedIds,
- onExportComplete,
-}: InsightExportModalProps) {
- const prefersReducedMotion = useReducedMotion();
- const [selectedFormat, setSelectedFormat] = useState('csv');
- const [isExporting, setIsExporting] = useState(false);
- const [exportResult, setExportResult] = useState<{
- success: boolean;
- message: string;
- } | null>(null);
-
- const handleExport = async () => {
- setIsExporting(true);
- setExportResult(null);
-
- try {
- const result = await exportInsights(selectedIds, selectedFormat);
-
- if (result.success && result.data && result.filename && result.mimeType) {
- // Create blob and download
- const blob = new Blob([result.data], { type: result.mimeType });
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = result.filename;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
-
- setExportResult({
- success: true,
- message: `Successfully exported ${selectedIds.length} insight${selectedIds.length !== 1 ? 's' : ''}`,
- });
-
- // Call callback and close after short delay
- setTimeout(() => {
- onExportComplete?.();
- onClose();
- }, 1500);
- } else {
- setExportResult({
- success: false,
- message: result.error || 'Export failed',
- });
- }
- } catch (err) {
- console.error('Export error:', err);
- setExportResult({
- success: false,
- message: 'An unexpected error occurred',
- });
- } finally {
- setIsExporting(false);
- }
- };
-
- const handleClose = () => {
- if (!isExporting) {
- setExportResult(null);
- onClose();
- }
- };
-
- return (
- {
- if (!next) handleClose();
- }}
- >
-
-
- Export Insights
-
- {`Export ${selectedIds.length} selected insight${selectedIds.length !== 1 ? 's' : ''}`}
-
-
-
- {/* Format Selection */}
-
-
- Choose format
-
-
- {FORMAT_OPTIONS.map((option) => (
-
setSelectedFormat(option.value)}
- disabled={isExporting}
- className={cn(
- 'relative flex items-start gap-3 p-4 rounded-xl border-2 text-left transition-all',
- selectedFormat === option.value
- ? 'border-primary-500 bg-primary-50'
- : 'border-warm-200 hover:border-warm-300 bg-cream-50'
- )}
- >
- {option.icon}
-
-
{option.label}
-
{option.description}
-
- {selectedFormat === option.value && (
-
-
-
- )}
-
- ))}
-
-
-
- {/* Preview Info */}
-
-
-
-
-
-
-
- {selectedIds.length} insight{selectedIds.length !== 1 ? 's' : ''} will be exported
-
-
- Includes title, description, player, priority, status, and dates
-
-
-
-
-
- {/* Result Message */}
- {exportResult && (
-
- {exportResult.message}
-
- )}
-
- {/* Actions */}
-
-
- Cancel
-
-
-
- {isExporting ? 'Exporting...' : 'Export'}
-
-
-
-
-
- );
-}
diff --git a/src/components/golf/coachhelm/insights/InsightFiltersPanel.tsx b/src/components/golf/coachhelm/insights/InsightFiltersPanel.tsx
deleted file mode 100644
index c5248fb1d..000000000
--- a/src/components/golf/coachhelm/insights/InsightFiltersPanel.tsx
+++ /dev/null
@@ -1,507 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
-import { cn } from '@/lib/utils';
-import { Button, IconButton } from '@/components/ui/button';
-import { Card } from '@/components/ui/card';
-import { Select } from '@/components/ui/select';
-import { Input } from '@/components/ui/input';
-import {
- IconFilter,
- IconX,
- IconChevronDown,
- IconChevronUp,
-} from '@/components/icons';
-import {
- INSIGHT_TYPE_CONFIGS,
- type InsightType,
- type InsightPriority,
- type InsightStatus,
-} from '@/lib/coachhelm/insight-types';
-
-// ============================================================================
-// TYPES
-// ============================================================================
-
-export interface InsightFilters {
- playerId?: string;
- insightType?: InsightType;
- priority?: InsightPriority;
- status?: InsightStatus;
- dateRange?: 'last_7_days' | 'last_30_days' | 'last_90_days' | 'custom';
- startDate?: string;
- endDate?: string;
-}
-
-interface FilterOption {
- id: string;
- name: string;
-}
-
-interface InsightFiltersPanelProps {
- filters: InsightFilters;
- onFiltersChange: (filters: InsightFilters) => void;
- players: FilterOption[];
- className?: string;
- defaultExpanded?: boolean;
-}
-
-// ============================================================================
-// CONSTANTS
-// ============================================================================
-
-const PRIORITY_OPTIONS: { value: InsightPriority; label: string; color: string }[] = [
- { value: 'urgent', label: 'Urgent', color: 'bg-red-100 text-red-700' },
- { value: 'high', label: 'High', color: 'bg-orange-100 text-orange-700' },
- { value: 'medium', label: 'Medium', color: 'bg-yellow-100 text-yellow-700' },
- { value: 'low', label: 'Low', color: 'bg-blue-100 text-blue-700' },
-];
-
-const STATUS_OPTIONS: { value: InsightStatus; label: string; color: string }[] = [
- { value: 'active', label: 'Active', color: 'bg-primary-100 text-primary-700' },
- { value: 'acknowledged', label: 'Acknowledged', color: 'bg-blue-100 text-blue-700' },
- { value: 'resolved', label: 'Resolved', color: 'bg-warm-100 text-warm-700' },
- { value: 'dismissed', label: 'Dismissed', color: 'bg-warm-100 text-warm-500' },
-];
-
-const DATE_RANGE_OPTIONS = [
- { value: 'last_7_days', label: 'Last 7 days' },
- { value: 'last_30_days', label: 'Last 30 days' },
- { value: 'last_90_days', label: 'Last 90 days' },
- { value: 'custom', label: 'Custom range' },
-];
-
-// ============================================================================
-// FILTER CHIP COMPONENT
-// ============================================================================
-
-interface FilterChipProps {
- label: string;
- onRemove: () => void;
- colorClass?: string;
-}
-
-function FilterChip({ label, onRemove, colorClass = 'bg-primary-100 text-primary-700' }: FilterChipProps) {
- const prefersReducedMotion = useReducedMotion();
- return (
-
- {label}
- {
- e.stopPropagation();
- onRemove();
- }}
- className="p-0.5 rounded-full hover:bg-black/10 transition-colors"
- aria-label={`Remove ${label} filter`}
- >
-
-
-
- );
-}
-
-// ============================================================================
-// MAIN COMPONENT
-// ============================================================================
-
-export function InsightFiltersPanel({
- filters,
- onFiltersChange,
- players,
- className,
- defaultExpanded = true,
-}: InsightFiltersPanelProps) {
- const prefersReducedMotion = useReducedMotion();
- const [isExpanded, setIsExpanded] = useState(defaultExpanded);
- const [isMobileOpen, setIsMobileOpen] = useState(false);
-
- // Count active filters
- const activeFilterCount = Object.entries(filters).filter(
- ([key, value]) => value && key !== 'startDate' && key !== 'endDate'
- ).length;
-
- // Get insight type options from config
- const insightTypeOptions = Object.entries(INSIGHT_TYPE_CONFIGS).map(([type, config]) => ({
- value: type as InsightType,
- label: config.label,
- icon: config.icon,
- }));
-
- // Update a single filter
- const updateFilter = (
- key: K,
- value: InsightFilters[K] | undefined
- ) => {
- const newFilters = { ...filters };
- if (value === undefined || value === '') {
- delete newFilters[key];
- } else {
- newFilters[key] = value;
- }
- onFiltersChange(newFilters);
- };
-
- // Clear all filters
- const clearAllFilters = () => {
- onFiltersChange({});
- };
-
- // Get active filter chips
- const getActiveFilterChips = () => {
- const chips: { key: string; label: string; colorClass: string; onRemove: () => void }[] = [];
-
- if (filters.playerId) {
- const player = players.find((p) => p.id === filters.playerId);
- if (player) {
- chips.push({
- key: 'player',
- label: player.name,
- colorClass: 'bg-purple-100 text-purple-700',
- onRemove: () => updateFilter('playerId', undefined),
- });
- }
- }
-
- if (filters.insightType) {
- const config = INSIGHT_TYPE_CONFIGS[filters.insightType];
- chips.push({
- key: 'type',
- label: config.label,
- colorClass: 'bg-primary-100 text-primary-700',
- onRemove: () => updateFilter('insightType', undefined),
- });
- }
-
- if (filters.priority) {
- const option = PRIORITY_OPTIONS.find((o) => o.value === filters.priority);
- if (option) {
- chips.push({
- key: 'priority',
- label: option.label,
- colorClass: option.color,
- onRemove: () => updateFilter('priority', undefined),
- });
- }
- }
-
- if (filters.status) {
- const option = STATUS_OPTIONS.find((o) => o.value === filters.status);
- if (option) {
- chips.push({
- key: 'status',
- label: option.label,
- colorClass: option.color,
- onRemove: () => updateFilter('status', undefined),
- });
- }
- }
-
- if (filters.dateRange) {
- const option = DATE_RANGE_OPTIONS.find((o) => o.value === filters.dateRange);
- if (option) {
- chips.push({
- key: 'date',
- label: option.label,
- colorClass: 'bg-warm-100 text-warm-700',
- onRemove: () => {
- updateFilter('dateRange', undefined);
- updateFilter('startDate', undefined);
- updateFilter('endDate', undefined);
- },
- });
- }
- }
-
- return chips;
- };
-
- const filterChips = getActiveFilterChips();
-
- // ============================================================================
- // FILTER FORM CONTENT
- // ============================================================================
-
- const FilterFormContent = () => (
-
- {/* Row 1: Player & Type */}
-
- {/* Player Filter */}
-
- ({ value: player.id, label: player.name }))}
- value={filters.playerId || ''}
- onChange={(value) => updateFilter('playerId', value || undefined)}
- placeholder="All players"
- clearable
- />
-
-
- {/* Insight Type Filter */}
-
- ({
- value: option.value,
- label: `${option.icon} ${option.label}`,
- }))}
- value={filters.insightType || ''}
- onChange={(value) => updateFilter('insightType', (value as InsightType) || undefined)}
- placeholder="All types"
- clearable
- />
-
-
-
- {/* Row 2: Priority & Status */}
-
- {/* Priority Filter */}
-
- ({ value: option.value, label: option.label }))}
- value={filters.priority || ''}
- onChange={(value) => updateFilter('priority', (value as InsightPriority) || undefined)}
- placeholder="All priorities"
- clearable
- />
-
-
- {/* Status Filter */}
-
- ({ value: option.value, label: option.label }))}
- value={filters.status || ''}
- onChange={(value) => updateFilter('status', (value as InsightStatus) || undefined)}
- placeholder="All statuses"
- clearable
- />
-
-
-
- {/* Row 3: Date Range */}
-
-
- {/* Actions */}
- {activeFilterCount > 0 && (
-
-
- Clear all filters
-
-
- )}
-
- );
-
- return (
-
- {/* Desktop View */}
-
-
- {/* Header */}
- setIsExpanded(!isExpanded)}
- className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-warm-50/50 transition-colors rounded-t-2xl"
- >
-
-
- Filters
- {activeFilterCount > 0 && (
-
- {activeFilterCount}
-
- )}
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
-
- {/* Filter Chips (always visible when filters are active) */}
-
- {!isExpanded && filterChips.length > 0 && (
-
-
- {filterChips.map((chip) => (
-
- ))}
-
-
- )}
-
-
- {/* Expanded Content */}
-
- {isExpanded && (
-
-
-
- )}
-
-
-
-
- {/* Mobile View - Collapsible Drawer */}
-
- {/* Toggle Button */}
-
setIsMobileOpen(true)}
- className={cn(
- 'flex items-center gap-2 px-4 py-2.5',
- 'bg-cream-100/82 backdrop-blur-sm border border-warm-200 rounded-xl',
- 'text-sm font-medium text-warm-700',
- 'hover:bg-warm-50 active:bg-warm-100 transition-colors'
- )}
- >
-
- Filters
- {activeFilterCount > 0 && (
-
- {activeFilterCount}
-
- )}
-
-
- {/* Filter Chips (visible when filters are active) */}
- {filterChips.length > 0 && (
-
- {filterChips.map((chip) => (
-
- ))}
-
- )}
-
- {/* Mobile Drawer */}
-
- {isMobileOpen && (
- <>
- {/* Backdrop */}
- setIsMobileOpen(false)}
- />
-
- {/* Drawer */}
-
- {/* Handle */}
-
-
-
-
Filters
- setIsMobileOpen(false)}
- className="p-2 rounded-lg text-warm-400 hover:text-warm-600 hover:bg-warm-100 transition-colors active:bg-warm-200"
- >
-
-
-
-
-
- {/* Content */}
-
-
-
- setIsMobileOpen(false)}
- >
- Apply Filters
-
-
-
-
- >
- )}
-
-
-
- );
-}
diff --git a/src/components/golf/coachhelm/insights/InsightSearchBar.tsx b/src/components/golf/coachhelm/insights/InsightSearchBar.tsx
deleted file mode 100644
index f56c30f82..000000000
--- a/src/components/golf/coachhelm/insights/InsightSearchBar.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-'use client';
-
-import { useState, useEffect, useCallback, useRef } from 'react';
-import { cn } from '@/lib/utils';
-import { IconSearch } from '@/components/icons';
-import { Input } from '@/components/ui/input';
-
-interface InsightSearchBarProps {
- value: string;
- onChange: (value: string) => void;
- placeholder?: string;
- debounceMs?: number;
- className?: string;
-}
-
-export function InsightSearchBar({
- value,
- onChange,
- placeholder = 'Search insights...',
- debounceMs = 300,
- className,
-}: InsightSearchBarProps) {
- const [localValue, setLocalValue] = useState(value);
- const debounceRef = useRef(null);
- const inputRef = useRef(null);
-
- // Sync external value changes
- useEffect(() => {
- setLocalValue(value);
- }, [value]);
-
- // Debounced onChange
- const debouncedOnChange = useCallback(
- (newValue: string) => {
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
-
- debounceRef.current = setTimeout(() => {
- onChange(newValue);
- }, debounceMs);
- },
- [onChange, debounceMs]
- );
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
- };
- }, []);
-
- const handleChange = (e: React.ChangeEvent) => {
- const newValue = e.target.value;
- setLocalValue(newValue);
- debouncedOnChange(newValue);
- };
-
- const handleClear = () => {
- setLocalValue('');
- onChange('');
- inputRef.current?.focus();
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- // Immediately trigger search on Enter
- if (e.key === 'Enter') {
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
- onChange(localValue);
- }
- // Clear on Escape
- if (e.key === 'Escape' && localValue) {
- handleClear();
- }
- };
-
- return (
-
- }
- clearable
- onClear={handleClear}
- className="min-h-[44px] py-2.5 rounded-xl"
- />
-
- );
-}
diff --git a/src/components/layout/mode-toggle.tsx b/src/components/layout/mode-toggle.tsx
deleted file mode 100644
index 6bb64c502..000000000
--- a/src/components/layout/mode-toggle.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-'use client';
-
-import { cn } from '@/lib/utils';
-import { Button } from '@/components/ui/button';
-
-export type Mode = 'recruiting' | 'team';
-
-interface ModeToggleProps {
- currentMode: Mode;
- onModeChange: (mode: Mode) => void;
-}
-
-export function ModeToggle({ currentMode, onModeChange }: ModeToggleProps) {
- return (
-
- onModeChange('recruiting')}
- className={cn(
- 'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all',
- currentMode === 'recruiting'
- ? 'bg-cream-50 text-primary-700 shadow-sm'
- : 'text-warm-600 hover:text-warm-900'
- )}
- >
- Recruiting
-
- onModeChange('team')}
- className={cn(
- 'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all',
- currentMode === 'team'
- ? 'bg-cream-50 text-primary-700 shadow-sm'
- : 'text-warm-600 hover:text-warm-900'
- )}
- >
- Team
-
-
- );
-}
diff --git a/src/hooks/use-notifications.ts b/src/hooks/use-notifications.ts
deleted file mode 100644
index bbc87afaa..000000000
--- a/src/hooks/use-notifications.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-'use client';
-
-import { useState, useEffect, useCallback } from 'react';
-import { createClient } from '@/lib/supabase/client';
-import { useAuthStore } from '@/stores/auth-store';
-
-interface Notification {
- id: string;
- type: 'profile_view' | 'watchlist_add' | 'message' | 'evaluation' | 'camp_interest' | 'team_join' | 'team_join_request' | 'team_join_approved' | 'team_join_rejected' | 'other';
- title: string;
- message: string;
- timestamp: string;
- read: boolean;
- actionUrl?: string;
- actorName?: string;
- actorAvatar?: string;
-}
-
-export function useNotifications() {
- const [notifications, setNotifications] = useState([]);
- const [loading, setLoading] = useState(true);
- const { user } = useAuthStore();
- const supabase = createClient();
-
- const fetchNotifications = useCallback(async () => {
- if (!user) {
- setLoading(false);
- return;
- }
-
- const { data } = await supabase
- .from('notifications')
- .select('*')
- .eq('user_id', user.id)
- .order('created_at', { ascending: false })
- .limit(50);
-
- if (data) {
- const mapped: Notification[] = data.map(n => ({
- id: n.id,
- type: (n.type || 'other') as Notification['type'],
- title: n.title || 'Notification',
- message: n.body || '',
- timestamp: n.created_at || new Date().toISOString(),
- read: n.read || false,
- actionUrl: (n as { link?: string }).link || undefined,
- }));
- setNotifications(mapped);
- }
- setLoading(false);
- }, [user, supabase]);
-
- useEffect(() => {
- fetchNotifications();
-
- // Real-time subscription for new notifications
- if (user) {
- const channel = supabase
- .channel('notifications')
- .on(
- 'postgres_changes',
- {
- event: 'INSERT',
- schema: 'public',
- table: 'notifications',
- filter: `user_id=eq.${user.id}`,
- },
- () => {
- fetchNotifications();
- }
- )
- .subscribe();
-
- return () => {
- supabase.removeChannel(channel);
- };
- }
- return undefined;
- }, [user, fetchNotifications, supabase]);
-
- const markAsRead = async (id: string) => {
- await supabase
- .from('notifications')
- .update({ read: true })
- .eq('id', id);
-
- setNotifications(prev =>
- prev.map(n => n.id === id ? { ...n, read: true } : n)
- );
- };
-
- const markAllAsRead = async () => {
- if (!user) return;
-
- await supabase
- .from('notifications')
- .update({ read: true })
- .eq('user_id', user.id)
- .eq('read', false);
-
- setNotifications(prev =>
- prev.map(n => ({ ...n, read: true }))
- );
- };
-
- const deleteNotification = async (id: string) => {
- await supabase
- .from('notifications')
- .delete()
- .eq('id', id);
-
- setNotifications(prev => prev.filter(n => n.id !== id));
- };
-
- return {
- notifications,
- loading,
- markAsRead,
- markAllAsRead,
- deleteNotification,
- refetch: fetchNotifications,
- };
-}
diff --git a/src/lib/baseball/lifting/use-live-set-sync.ts b/src/lib/baseball/lifting/use-live-set-sync.ts
deleted file mode 100644
index 6677c64c0..000000000
--- a/src/lib/baseball/lifting/use-live-set-sync.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-'use client';
-
-// =============================================================================
-// src/lib/baseball/lifting/use-live-set-sync.ts
-//
-// React lifecycle around the Live Weight Room offline set buffer. Owns the
-// flush loop so the component just calls `queueAndFlush(...)` and renders the
-// returned `pending`/`isOnline` state — mirroring how GolfHelm's round-entry
-// screens lean on the sync-engine rather than open-coding retry logic per page.
-//
-// Flush triggers (any one drains the durable queue, backoff-gated per entry):
-// * mount — recover sets stranded by a prior tab close / reload.
-// * `online` event — the moment connectivity returns, push what we have.
-// * periodic tick — covers the "still technically online but the request
-// failed" case (flaky room Wi-Fi never fires `offline`).
-// * after a new log — try immediately so the happy path stays instant.
-//
-// NON-DESTRUCTIVE: a failed flush bumps backoff and keeps the entry; only a
-// confirmed server success removes it. Idempotent server upsert makes replay safe.
-// =============================================================================
-
-import { useCallback, useEffect, useRef, useState } from 'react';
-
-import {
- enqueueSet,
- readPendingSets,
- removeSet,
- recordSetFailure,
- shouldRetrySet,
- pendingSetCount,
- type LiveSetEntryInput,
-} from './live-set-offline-buffer';
-
-/** The capability-enforced server action shape (logSetResult), narrowed. */
-export type FlushSetFn = (input: {
- sessionId: string;
- sessionExerciseId: string;
- setNumber: number;
- actualReps: number | null;
- actualLoad: number | null;
- loadUnit: string | null;
- rpe: number | null;
-}) => Promise<{ success: boolean; error?: string }>;
-
-const FLUSH_INTERVAL_MS = 15_000;
-
-export interface LiveSetSync {
- /** Number of sets buffered locally and not yet confirmed by the server. */
- pending: number;
- /** Best-effort connectivity (navigator.onLine + last flush outcome). */
- isOnline: boolean;
- /**
- * Durably buffer a set, then attempt to flush. Returns immediately — the
- * caller already applied the optimistic UI change. The set is safe whether or
- * not the network is up; if the flush fails it stays queued for the next tick.
- */
- queueAndFlush: (input: LiveSetEntryInput) => void;
- /** Manually drain the queue (e.g. a "retry now" affordance). */
- flushNow: () => void;
-}
-
-export function useLiveSetSync(flushFn: FlushSetFn): LiveSetSync {
- const [pending, setPending] = useState(0);
- const [isOnline, setIsOnline] = useState(true);
- // A flush in progress — prevents overlapping drains racing the same entries.
- const flushingRef = useRef(false);
- // Hold the latest flushFn without making the flush callback identity churn.
- const flushFnRef = useRef(flushFn);
- flushFnRef.current = flushFn;
-
- const flushNow = useCallback(async () => {
- if (flushingRef.current) return;
- if (typeof navigator !== 'undefined' && !navigator.onLine) {
- setPending(pendingSetCount());
- return;
- }
- flushingRef.current = true;
- try {
- const entries = readPendingSets();
- for (const entry of entries) {
- if (!shouldRetrySet(entry)) continue; // inside backoff or budget spent
- try {
- const r = await flushFnRef.current({
- sessionId: entry.sessionId,
- sessionExerciseId: entry.sessionExerciseId,
- setNumber: entry.setNumber,
- actualReps: entry.actualReps,
- actualLoad: entry.actualLoad,
- loadUnit: entry.loadUnit,
- rpe: entry.rpe,
- });
- if (r.success) {
- removeSet(entry.id); // confirmed — the ONLY destructive op
- setIsOnline(true);
- } else {
- // Server rejected (validation, RLS) — non-destructive backoff.
- recordSetFailure(entry.id);
- }
- } catch {
- // Network/transport failure — keep the entry, mark offline, stop the
- // pass (the rest will share the same dead network this tick).
- recordSetFailure(entry.id);
- setIsOnline(false);
- break;
- }
- }
- } finally {
- flushingRef.current = false;
- setPending(pendingSetCount());
- }
- }, []);
-
- const queueAndFlush = useCallback(
- (input: LiveSetEntryInput) => {
- enqueueSet(input);
- setPending(pendingSetCount());
- void flushNow();
- },
- [flushNow],
- );
-
- // Recover stranded sets on mount + reflect real connectivity.
- useEffect(() => {
- setPending(pendingSetCount());
- if (typeof navigator !== 'undefined') setIsOnline(navigator.onLine);
- void flushNow();
- }, [flushNow]);
-
- // Reconnect + offline listeners.
- useEffect(() => {
- if (typeof window === 'undefined') return;
- const onOnline = () => {
- setIsOnline(true);
- void flushNow();
- };
- const onOffline = () => setIsOnline(false);
- window.addEventListener('online', onOnline);
- window.addEventListener('offline', onOffline);
- return () => {
- window.removeEventListener('online', onOnline);
- window.removeEventListener('offline', onOffline);
- };
- }, [flushNow]);
-
- // Periodic drain — catches failed-while-online (flaky Wi-Fi never fires `offline`).
- useEffect(() => {
- const t = setInterval(() => void flushNow(), FLUSH_INTERVAL_MS);
- return () => clearInterval(t);
- }, [flushNow]);
-
- return { pending, isOnline, queueAndFlush, flushNow };
-}
From 3011efaf96b0a34e3cc8e89c454b2d74a5c3949d Mon Sep 17 00:00:00 2001
From: njrini99-code
Date: Wed, 15 Jul 2026 18:10:06 -0400
Subject: [PATCH 02/18] =?UTF-8?q?devibe:=20remove=20dead=20files=20?=
=?UTF-8?q?=E2=80=94=20knip=20batch=202/2=20(golf/travel=20legacy,=20soren?=
=?UTF-8?q?ess=20barrel,=20lift-programs)=20(#859)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Verified dead via grep (import path + symbol name + next/dynamic scan),
then git rm.
- src/components/golf/travel/{ExpenseForm,ExpenseList,ExpenseSummary,index}.ts(x)
— legacy pre-Fairway components. Superseded by src/components/fairway/pages/travel/
Fairway{ExpenseForm,ExpenseList,ExpenseSummary}.tsx, whose own header comments
say they're re-skins of "the legacy golf/travel ExpenseList/ExpenseSummary" —
i.e. the legacy files are explicitly documented as replaced. Zero live importers
(grep for the barrel path and each symbol name comes back empty outside the
legacy files themselves).
- src/components/lifting/soreness/index.ts — barrel; zero importers (every other
file in the same directory — BodySilhouetteFront, SorenessCheckCard,
SorenessBodyMap, HighPrioritySorenessList, SorenessScheduleBuilder — IS
imported directly by app code, just never through this barrel).
- src/components/lifting/soreness/SorenessComplianceBoard.tsx,
TeamSorenessHeatmap.tsx — only referenced from the dead barrel above; no
direct importers.
- src/lib/baseball/read-models/lift-programs.ts — exports getLiftProgramList/
getLiftProgramTree/getAssignContext. The live /performance/programs/[programId]
page defines its own local getAssignContext (duplicated, not imported from
here) — confirms this read-model was built but never wired in.
Gates: typecheck clean, check-cycles clean (33 known cycles, none new).
`grep` false-positive check: src/app/golf/actions/__tests__/travel.test.ts
matches "ExpenseSummary" only via the substring in getExpenseSummary() (a
server action, unrelated file) — ran that suite standalone to confirm
(128 passed, 4 skipped, unaffected).
Co-authored-by: Fable Integrator
Co-authored-by: Claude Fable 5
---
src/components/golf/travel/ExpenseForm.tsx | 433 ------------------
src/components/golf/travel/ExpenseList.tsx | 358 ---------------
src/components/golf/travel/ExpenseSummary.tsx | 369 ---------------
src/components/golf/travel/index.ts | 3 -
.../soreness/SorenessComplianceBoard.tsx | 201 --------
.../lifting/soreness/TeamSorenessHeatmap.tsx | 243 ----------
src/components/lifting/soreness/index.ts | 43 --
src/lib/baseball/read-models/lift-programs.ts | 340 --------------
8 files changed, 1990 deletions(-)
delete mode 100644 src/components/golf/travel/ExpenseForm.tsx
delete mode 100644 src/components/golf/travel/ExpenseList.tsx
delete mode 100644 src/components/golf/travel/ExpenseSummary.tsx
delete mode 100644 src/components/golf/travel/index.ts
delete mode 100644 src/components/lifting/soreness/SorenessComplianceBoard.tsx
delete mode 100644 src/components/lifting/soreness/TeamSorenessHeatmap.tsx
delete mode 100644 src/components/lifting/soreness/index.ts
delete mode 100644 src/lib/baseball/read-models/lift-programs.ts
diff --git a/src/components/golf/travel/ExpenseForm.tsx b/src/components/golf/travel/ExpenseForm.tsx
deleted file mode 100644
index 60d7af90b..000000000
--- a/src/components/golf/travel/ExpenseForm.tsx
+++ /dev/null
@@ -1,433 +0,0 @@
-'use client';
-
-import { useState, useEffect, useId } from 'react';
-import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
-import {
- Drawer,
- DrawerContent,
- DrawerHeader,
- DrawerTitle,
-} from '@/components/ui/drawer';
-import { Input } from '@/components/ui/input';
-import { Textarea } from '@/components/ui/textarea';
-import { Button, IconButton } from '@/components/ui/button';
-import {
- IconUpload,
- IconX,
- IconHome,
- IconAirplane,
- IconClipboardList,
- IconAward,
- IconFlag,
- IconLayers,
- IconPaperclip,
-} from '@/components/icons';
-import type { ComponentType, SVGAttributes } from 'react';
-
-type ExpenseIcon = ComponentType & { size?: number }>;
-import {
- createTravelExpense,
- updateTravelExpense,
- uploadExpenseReceipt,
- type ExpenseCategory,
- type ExpensePaidBy,
- type TravelExpense,
- type CreateExpenseInput,
-} from '@/app/golf/actions/travel';
-
-interface ExpenseFormProps {
- isOpen: boolean;
- onClose: () => void;
- onSaved: () => void;
- teamId: string;
- itineraryId?: string | null;
- expense?: TravelExpense | null;
-}
-
-const CATEGORIES: { value: ExpenseCategory; label: string; icon: ExpenseIcon }[] = [
- { value: 'lodging', label: 'Lodging', icon: IconHome },
- { value: 'transportation', label: 'Transportation', icon: IconAirplane },
- { value: 'meals', label: 'Meals', icon: IconClipboardList },
- { value: 'entry_fees', label: 'Entry Fees', icon: IconAward },
- { value: 'equipment', label: 'Equipment', icon: IconFlag },
- { value: 'other', label: 'Other', icon: IconLayers },
-];
-
-// NOTE: The 'split' paid-by option is intentionally NOT offered here. Cost
-// splitting is a deferred feature — the golf_travel_expense_splits table exists
-// but has no CRUD/calc anywhere yet, so a "Split" choice would be a dead end.
-// The ExpensePaidBy type/schema/summary still accept 'split' so any legacy rows
-// render correctly; re-add the option below once splitting is actually built.
-const PAID_BY_OPTIONS: { value: ExpensePaidBy; label: string }[] = [
- { value: 'team', label: 'Team' },
- { value: 'player', label: 'Player' },
- { value: 'pending_reimbursement', label: 'Pending Reimbursement' },
-];
-
-export function ExpenseForm({
- isOpen,
- onClose,
- onSaved,
- teamId,
- itineraryId,
- expense,
-}: ExpenseFormProps) {
- const uid = useId();
- const prefersReducedMotion = useReducedMotion();
- const [loading, setLoading] = useState(false);
- const [uploading, setUploading] = useState(false);
- const [error, setError] = useState(null);
-
- const [category, setCategory] = useState('other');
- const [description, setDescription] = useState('');
- const [amount, setAmount] = useState('');
- const [vendorName, setVendorName] = useState('');
- const [expenseDate, setExpenseDate] = useState('');
- const [paidBy, setPaidBy] = useState('team');
- const [notes, setNotes] = useState('');
- const [receiptUrl, setReceiptUrl] = useState(null);
- const [receiptFile, setReceiptFile] = useState(null);
-
- // Reset form when expense changes
- useEffect(() => {
- if (expense) {
- setCategory(expense.category);
- setDescription(expense.description);
- setAmount(expense.amount.toString());
- setVendorName(expense.vendor_name || '');
- setExpenseDate(expense.expense_date || '');
- setPaidBy(expense.paid_by);
- setNotes(expense.notes || '');
- setReceiptUrl(expense.receipt_url);
- } else {
- resetForm();
- }
- }, [expense]);
-
- function resetForm() {
- setCategory('other');
- setDescription('');
- setAmount('');
- setVendorName('');
- setExpenseDate(new Date().toISOString().split('T')[0] || '');
- setPaidBy('team');
- setNotes('');
- setReceiptUrl(null);
- setReceiptFile(null);
- setError(null);
- }
-
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- setError(null);
-
- const parsedAmount = parseFloat(amount);
- if (!description.trim()) {
- setError('Description is required');
- return;
- }
- if (isNaN(parsedAmount) || parsedAmount <= 0) {
- setError('Please enter a valid amount');
- return;
- }
-
- setLoading(true);
-
- try {
- // Upload the receipt first so its URL is persisted with the expense.
- // This is the only write path for receipt_url — without it the attached
- // file would be silently dropped on save.
- let finalReceiptUrl = receiptUrl;
- if (receiptFile) {
- setUploading(true);
- const uploadResult = await uploadExpenseReceipt(receiptFile, teamId, expense?.id);
- setUploading(false);
- if (!uploadResult.success || !uploadResult.url) {
- setError(uploadResult.error || 'Failed to upload receipt');
- return;
- }
- finalReceiptUrl = uploadResult.url;
- }
-
- if (expense) {
- // Update existing expense
- const result = await updateTravelExpense({
- id: expense.id,
- category,
- description: description.trim(),
- amount: parsedAmount,
- vendor_name: vendorName.trim() || null,
- expense_date: expenseDate || null,
- paid_by: paidBy,
- notes: notes.trim() || null,
- receipt_url: finalReceiptUrl,
- });
-
- if (!result.success) {
- setError(result.error || 'Failed to update expense');
- return;
- }
- } else {
- // Create new expense
- const input: CreateExpenseInput = {
- team_id: teamId,
- itinerary_id: itineraryId || null,
- category,
- description: description.trim(),
- amount: parsedAmount,
- vendor_name: vendorName.trim() || null,
- expense_date: expenseDate || null,
- paid_by: paidBy,
- notes: notes.trim() || null,
- receipt_url: finalReceiptUrl,
- };
-
- const result = await createTravelExpense(input);
-
- if (!result.success) {
- setError(result.error || 'Failed to create expense');
- return;
- }
- }
-
- onSaved();
- onClose();
- resetForm();
- } catch (err) {
- setError(err instanceof Error ? err.message : 'An error occurred');
- } finally {
- setLoading(false);
- setUploading(false);
- }
- }
-
- function handleFileChange(e: React.ChangeEvent) {
- const file = e.target.files?.[0];
- if (file) {
- // Validate file type
- const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
- if (!validTypes.includes(file.type)) {
- setError('Please upload an image (JPG, PNG, WebP) or PDF');
- return;
- }
- // Validate file size (max 5MB)
- if (file.size > 5 * 1024 * 1024) {
- setError('File size must be less than 5MB');
- return;
- }
- setReceiptFile(file);
- setError(null);
- }
- }
-
- function removeReceipt() {
- setReceiptFile(null);
- setReceiptUrl(null);
- }
-
- return (
- {
- if (!next) onClose();
- }}
- >
-
-
- {expense ? 'Edit Expense' : 'Add Expense'}
-
-
-
-
- );
-}
diff --git a/src/components/golf/travel/ExpenseList.tsx b/src/components/golf/travel/ExpenseList.tsx
deleted file mode 100644
index 8d30d2ba2..000000000
--- a/src/components/golf/travel/ExpenseList.tsx
+++ /dev/null
@@ -1,358 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
-import {
- IconEdit,
- IconTrash,
- IconX,
- IconEye,
- IconChevronDown,
- IconChevronUp,
- IconHome,
- IconAirplane,
- IconClipboardList,
- IconAward,
- IconFlag,
- IconLayers,
-} from '@/components/icons';
-import type { ComponentType, SVGAttributes } from 'react';
-import { toast } from '@/components/ui/sonner';
-import { ConfirmDialog } from '@/components/ui/confirm-dialog';
-import { Button, IconButton } from '@/components/ui/button';
-import {
- deleteTravelExpense,
- type TravelExpense,
- type ExpenseCategory,
-} from '@/app/golf/actions/travel';
-
-interface ExpenseListProps {
- expenses: TravelExpense[];
- onEdit: (expense: TravelExpense) => void;
- onRefresh: () => void;
- isCoach: boolean;
-}
-
-type ExpenseIcon = ComponentType & { size?: number }>;
-
-const CATEGORY_CONFIG: Record = {
- lodging: { icon: IconHome, label: 'Lodging', color: 'bg-blue-100 text-blue-700' },
- transportation: { icon: IconAirplane, label: 'Transportation', color: 'bg-purple-100 text-purple-700' },
- meals: { icon: IconClipboardList, label: 'Meals', color: 'bg-orange-100 text-orange-700' },
- entry_fees: { icon: IconAward, label: 'Entry Fees', color: 'bg-primary-100 text-primary-700' },
- equipment: { icon: IconFlag, label: 'Equipment', color: 'bg-teal-100 text-teal-700' },
- other: { icon: IconLayers, label: 'Other', color: 'bg-warm-100 text-warm-700' },
-};
-
-const PAID_BY_LABELS: Record = {
- team: 'Team',
- player: 'Player',
- pending_reimbursement: 'Pending',
- split: 'Split',
-};
-
-export function ExpenseList({ expenses, onEdit, onRefresh, isCoach }: ExpenseListProps) {
- const prefersReducedMotion = useReducedMotion();
- const [deleting, setDeleting] = useState(null);
- const [expandedId, setExpandedId] = useState(null);
- const [viewingReceipt, setViewingReceipt] = useState(null);
- const [receiptStatus, setReceiptStatus] = useState<'loading' | 'loaded' | 'error'>('loading');
- const [pendingDeleteId, setPendingDeleteId] = useState(null);
-
- function openReceipt(url: string | null) {
- if (!url) return;
- setReceiptStatus('loading');
- setViewingReceipt(url);
- }
-
- function handleDelete(id: string) {
- setPendingDeleteId(id);
- }
-
- async function confirmDelete() {
- if (!pendingDeleteId) return;
- const id = pendingDeleteId;
-
- setDeleting(id);
- const result = await deleteTravelExpense(id);
-
- if (result.success) {
- onRefresh();
- } else {
- toast.error(result.error || 'Failed to delete expense');
- }
- setDeleting(null);
- setPendingDeleteId(null);
- }
-
- function formatDate(dateStr: string | null) {
- if (!dateStr) return '-';
- return new Date(dateStr).toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- });
- }
-
- function formatCurrency(amount: number) {
- return new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
- }).format(amount);
- }
-
- if (expenses.length === 0) {
- return (
-
-
- 💸
-
-
No Expenses Yet
-
- {isCoach
- ? 'Add your first expense to start tracking costs for this trip.'
- : 'No expenses have been recorded for this trip yet.'}
-
-
- );
- }
-
- return (
-
- {expenses.map((expense, index) => {
- const config = CATEGORY_CONFIG[expense.category] || CATEGORY_CONFIG.other;
- const CategoryIcon = config.icon;
- const isExpanded = expandedId === expense.id;
-
- return (
-
- {/* Main Row */}
- setExpandedId(isExpanded ? null : expense.id)}
- >
- {/* Category Icon */}
-
-
-
-
- {/* Description */}
-
-
{expense.description}
-
- {config.label}
- {expense.vendor_name && (
- <>
-
- {expense.vendor_name}
- >
- )}
-
-
-
- {/* Date */}
-
-
{formatDate(expense.expense_date)}
-
-
- {/* Amount */}
-
-
{formatCurrency(expense.amount)}
-
- {PAID_BY_LABELS[expense.paid_by] || expense.paid_by}
-
-
-
- {/* Expand Arrow */}
-
- {isExpanded ? : }
-
-
-
- {/* Expanded Details */}
-
- {isExpanded && (
-
-
-
-
-
Date
-
{formatDate(expense.expense_date)}
-
-
-
Vendor
-
{expense.vendor_name || '-'}
-
-
-
Paid By
-
{PAID_BY_LABELS[expense.paid_by]}
-
-
-
Receipt
- {expense.receipt_url ? (
-
{
- e.stopPropagation();
- openReceipt(expense.receipt_url);
- }}
- className="text-primary-600 hover:text-primary-700 flex items-center gap-1"
- >
-
- View
-
- ) : (
-
None
- )}
-
-
-
- {expense.notes && (
-
-
Notes
-
{expense.notes}
-
- )}
-
- {/* Actions */}
- {isCoach && (
-
- {
- e.stopPropagation();
- onEdit(expense);
- }}
- className="flex items-center gap-2 px-3 py-1.5 text-sm text-warm-600 hover:text-warm-900 hover:bg-warm-100 active:bg-warm-200 rounded-lg transition-colors"
- >
-
- Edit
-
- {
- e.stopPropagation();
- handleDelete(expense.id);
- }}
- disabled={deleting === expense.id}
- className="flex items-center gap-2 px-3 py-1.5 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
- >
-
- {deleting === expense.id ? 'Deleting...' : 'Delete'}
-
-
- )}
-
-
- )}
-
-
- );
- })}
-
- {/* Delete confirmation */}
-
{ void confirmDelete(); }}
- onCancel={() => {
- if (deleting === null) setPendingDeleteId(null);
- }}
- />
-
- {/* Receipt Viewer Modal */}
- {viewingReceipt && (
- setViewingReceipt(null)}
- >
- e.stopPropagation()}
- >
-
-
Receipt
- setViewingReceipt(null)}
- className="p-2 hover:bg-warm-100 active:bg-warm-200 rounded-lg transition-colors"
- >
-
-
-
-
- {receiptStatus === 'error' ? (
-
- ) : (
-
- {receiptStatus === 'loading' && (
-
- Loading receipt…
-
- )}
- {viewingReceipt.endsWith('.pdf') ? (
-
- )}
-
-
-
- )}
-
- );
-}
diff --git a/src/components/golf/travel/ExpenseSummary.tsx b/src/components/golf/travel/ExpenseSummary.tsx
deleted file mode 100644
index e4bea21bd..000000000
--- a/src/components/golf/travel/ExpenseSummary.tsx
+++ /dev/null
@@ -1,369 +0,0 @@
-'use client';
-
-import { useState, useMemo } from 'react';
-import { motion, useReducedMotion } from 'framer-motion';
-import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
-import { IconTrendingUp, IconTrendingDown, IconEdit } from '@/components/icons';
-import { Button, IconButton } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-// Deep import of the chart theme (DOM-free, self-contained) rather than the
-// charts barrel — avoids pulling the heavy recharts/visx graph into this bundle.
-import { VIZ_SEQUENTIAL } from '@/components/fairway/charts/theme';
-import {
- type ExpenseSummary as ExpenseSummaryType,
- type ExpenseCategory,
- type TravelBudget,
- setBudget,
-} from '@/app/golf/actions/travel';
-
-interface ExpenseSummaryProps {
- summary: ExpenseSummaryType;
- budgets: TravelBudget[];
- itineraryId: string;
- isCoach: boolean;
- onBudgetUpdated: () => void;
-}
-
-// Category swatch colors map onto the Fairway sequential viz ramp (cream → green
-// → amber) — token-backed `var(--fw-viz-seq-*)` references that resolve to the
-// locked warm palette, so this surface never reintroduces the old raw blue/
-// purple/orange hex. One stable stop per category (order = ALL_CATEGORIES).
-const CATEGORY_CONFIG: Record = {
- lodging: { label: 'Lodging', color: VIZ_SEQUENTIAL[1] },
- transportation: { label: 'Transportation', color: VIZ_SEQUENTIAL[2] },
- meals: { label: 'Meals', color: VIZ_SEQUENTIAL[5] },
- entry_fees: { label: 'Entry Fees', color: VIZ_SEQUENTIAL[3] },
- equipment: { label: 'Equipment', color: VIZ_SEQUENTIAL[4] },
- other: { label: 'Other', color: 'var(--fw-color-text-tertiary)' },
-};
-
-const ALL_CATEGORIES: ExpenseCategory[] = [
- 'lodging',
- 'transportation',
- 'meals',
- 'entry_fees',
- 'equipment',
- 'other',
-];
-
-export function ExpenseSummary({
- summary,
- budgets,
- itineraryId,
- isCoach,
- onBudgetUpdated,
-}: ExpenseSummaryProps) {
- const prefersReducedMotion = useReducedMotion();
- const [editingBudget, setEditingBudget] = useState(null);
- const [budgetValue, setBudgetValue] = useState('');
- const [savingBudget, setSavingBudget] = useState(false);
-
- // Prepare data for pie chart
- const pieData = useMemo(() => {
- return ALL_CATEGORIES
- .filter((cat) => summary.byCategory[cat] > 0)
- .map((cat) => ({
- name: CATEGORY_CONFIG[cat].label,
- value: summary.byCategory[cat],
- color: CATEGORY_CONFIG[cat].color,
- }));
- }, [summary.byCategory]);
-
- // Build budget lookup
- const budgetLookup = useMemo(() => {
- const lookup: Record = {};
- budgets.forEach((b) => {
- lookup[b.category] = b.budgeted_amount;
- });
- return lookup;
- }, [budgets]);
-
- // Total budget
- const totalBudget = useMemo(() => {
- return Object.values(budgetLookup).reduce((sum, val) => sum + val, 0);
- }, [budgetLookup]);
-
- function formatCurrency(amount: number) {
- return new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(amount);
- }
-
- async function handleSaveBudget(category: ExpenseCategory) {
- const amount = parseFloat(budgetValue);
- if (isNaN(amount) || amount < 0) return;
-
- setSavingBudget(true);
- const result = await setBudget({
- itinerary_id: itineraryId,
- category,
- budgeted_amount: amount,
- });
-
- if (result.success) {
- onBudgetUpdated();
- }
- setSavingBudget(false);
- setEditingBudget(null);
- setBudgetValue('');
- }
-
- function startEditBudget(category: ExpenseCategory) {
- setEditingBudget(category);
- setBudgetValue(budgetLookup[category]?.toString() || '');
- }
-
- // Custom tooltip
- const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number; payload: { color: string } }> }) => {
- if (active && payload && payload.length && payload[0]) {
- const item = payload[0];
- return (
-
-
{item.name}
-
{formatCurrency(item.value)}
-
- {((item.value / summary.total) * 100).toFixed(1)}% of total
-
-
- );
- }
- return null;
- };
-
- return (
-
- {/* Total Summary Card */}
-
-
-
-
Total Expenses
-
{formatCurrency(summary.total)}
- {totalBudget > 0 && (
-
- {summary.total <= totalBudget ? (
- <>
-
-
- {formatCurrency(totalBudget - summary.total)} under budget
-
- >
- ) : (
- <>
-
-
- {formatCurrency(summary.total - totalBudget)} over budget
-
- >
- )}
-
- )}
-
-
-
{summary.count} expenses
- {totalBudget > 0 && (
-
- Budget: {formatCurrency(totalBudget)}
-
- )}
-
-
-
- {/* Budget Progress Bar */}
- {totalBudget > 0 && (
-
-
-
-
-
- {((summary.total / totalBudget) * 100).toFixed(0)}% of budget used
-
-
- )}
-
-
-
- {/* Pie Chart */}
-
-
Breakdown by Category
- {pieData.length > 0 ? (
-
-
-
-
- {pieData.map((entry, index) => (
- |
- ))}
-
- } />
- (
- {value}
- )}
- />
-
-
-
- ) : (
-
- No expenses to display
-
- )}
-
-
- {/* Category Breakdown with Budgets */}
-
-
Budget vs Actual
-
- {ALL_CATEGORIES.map((category) => {
- const config = CATEGORY_CONFIG[category];
- const spent = summary.byCategory[category] || 0;
- const budget = budgetLookup[category] || 0;
- const percentage = budget > 0 ? (spent / budget) * 100 : 0;
-
- return (
-
-
-
-
-
- {formatCurrency(spent)}
-
- {editingBudget === category ? (
-
- setBudgetValue(e.target.value)}
- className="w-20 min-h-0 px-2 py-1 text-sm rounded"
- placeholder="Budget"
- aria-label={`Budget for ${config.label}`}
- // eslint-disable-next-line jsx-a11y/no-autofocus
- autoFocus
- />
- handleSaveBudget(category)}
- disabled={savingBudget}
- className="px-2 py-1 text-xs"
- >
- Save
-
- {
- setEditingBudget(null);
- setBudgetValue('');
- }}
- className="px-2 py-1 text-xs"
- >
- Cancel
-
-
- ) : (
- <>
-
- / {budget > 0 ? formatCurrency(budget) : '—'}
-
- {isCoach && (
-
startEditBudget(category)}
- className="p-1 rounded hover:bg-surface-sunken transition-colors"
- >
-
-
- )}
- >
- )}
-
-
-
- {/* Progress bar */}
-
- 100 ? 'var(--fw-color-danger)' : config.color,
- }}
- initial={{ width: 0 }}
- animate={{
- width: budget > 0
- ? `${Math.min(percentage, 100)}%`
- : spent > 0
- ? '100%'
- : '0%',
- }}
- transition={prefersReducedMotion ? { duration: 0 } : ({ duration: 0.5, ease: 'easeOut' })}
- />
-
-
- );
- })}
-
-
-
-
- {/* Payment Status Summary */}
-
-
Payment Status
-
-
-
Team Paid
-
- {formatCurrency(summary.byPaidBy.team)}
-
-
-
-
Player Paid
-
- {formatCurrency(summary.byPaidBy.player)}
-
-
-
-
Pending Reimbursement
-
- {formatCurrency(summary.byPaidBy.pending_reimbursement)}
-
-
- {/* Split is a deferred path (ExpenseForm offers no 'split' option), so an
- always-$0 card would be a fabricated metric. Only surface it when
- legacy rows actually carry a split amount — honest "> 0" rule. */}
- {summary.byPaidBy.split > 0 && (
-
-
Split
-
- {formatCurrency(summary.byPaidBy.split)}
-
-
- )}
-
-
-
- );
-}
diff --git a/src/components/golf/travel/index.ts b/src/components/golf/travel/index.ts
deleted file mode 100644
index 0169a9fb1..000000000
--- a/src/components/golf/travel/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { ExpenseForm } from './ExpenseForm';
-export { ExpenseList } from './ExpenseList';
-export { ExpenseSummary } from './ExpenseSummary';
diff --git a/src/components/lifting/soreness/SorenessComplianceBoard.tsx b/src/components/lifting/soreness/SorenessComplianceBoard.tsx
deleted file mode 100644
index ce9aa46f7..000000000
--- a/src/components/lifting/soreness/SorenessComplianceBoard.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-'use client';
-
-// =============================================================================
-// src/components/lifting/soreness/SorenessComplianceBoard.tsx
-//
-// Coach compliance board for today's soreness checks.
-// Shows count tiles (ready/reported/pending/missed) + athlete list with status.
-// =============================================================================
-
-import { useMemo, useState } from 'react';
-import { motion, useReducedMotion } from 'framer-motion';
-
-import { Card, CardContent } from '@/components/ui/card';
-import { Button } from '@/components/ui/button';
-import { EmptyState } from '@/components/ui/empty-state';
-import { IconHeart, IconCheckCircle2 } from '@/components/icons';
-import type {
- CoachSorenessDashboard,
- AthleteRequestSummary,
-} from '@/app/lifting/actions/soreness';
-
-// ---------------------------------------------------------------------------
-// Props
-// ---------------------------------------------------------------------------
-
-interface Props {
- dashboard: CoachSorenessDashboard;
-}
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-type FilterKey = 'all' | 'ready_to_go' | 'completed' | 'pending' | 'missed';
-
-function statusLabel(status: string): string {
- switch (status) {
- case 'ready_to_go': return 'Ready to Go';
- case 'completed': return 'Reported soreness';
- case 'pending': return 'Pending';
- case 'missed': return 'Missed';
- case 'excused': return 'Excused';
- default: return status;
- }
-}
-
-function statusDot(status: string): string {
- switch (status) {
- case 'ready_to_go': return 'bg-primary-500';
- case 'completed': return 'bg-amber-400';
- case 'pending': return 'bg-warm-300';
- case 'missed': return 'bg-red-400';
- case 'excused': return 'bg-blue-300';
- default: return 'bg-warm-200';
- }
-}
-
-function maxSeverityBadge(max: number | null): React.ReactNode {
- if (max === null) return null;
- const cls =
- max >= 7 ? 'bg-red-100 text-red-700' :
- max >= 5 ? 'bg-amber-100 text-amber-700' :
- max >= 3 ? 'bg-yellow-100 text-yellow-700' :
- 'bg-warm-100 text-warm-500';
- return (
-
- {max}/10
-
- );
-}
-
-function AthleteRow({ summary }: { summary: AthleteRequestSummary }) {
- const name = [summary.firstName, summary.lastName].filter(Boolean).join(' ') || 'Athlete';
- const hasFlags = summary.flags.length > 0;
-
- return (
-
- {/* Status dot */}
-
-
- {/* Name + position */}
-
-
- {name}
- {summary.position && (
- {summary.position}
- )}
-
- {hasFlags && (
-
- {summary.flags[0]?.label}
-
- )}
-
-
- {/* Regions count + max severity */}
-
- {summary.regions.length > 0 && (
-
- {summary.regions.length} area{summary.regions.length !== 1 ? 's' : ''}
-
- )}
- {maxSeverityBadge(summary.maxSeverity)}
-
-
- {/* Status label */}
-
{statusLabel(summary.request.status)}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Component
-// ---------------------------------------------------------------------------
-
-export function SorenessComplianceBoard({ dashboard }: Props) {
- const prefersReducedMotion = useReducedMotion();
- const [filter, setFilter] = useState('all');
- const { compliance, allRequests, date } = dashboard;
-
- const filtered = useMemo(() => {
- if (filter === 'all') return allRequests;
- return allRequests.filter((s) => s.request.status === filter);
- }, [allRequests, filter]);
-
- const dateLabel = new Date(date + 'T00:00:00').toLocaleDateString(undefined, {
- weekday: 'long', month: 'long', day: 'numeric',
- });
-
- const tiles: Array<{ key: FilterKey; label: string; count: number; cls: string }> = [
- { key: 'ready_to_go', label: 'Ready to Go', count: compliance.readyToGo, cls: 'text-primary-700 bg-primary-50 border-primary-200' },
- { key: 'completed', label: 'Reported', count: compliance.reportedSoreness, cls: 'text-amber-700 bg-amber-50 border-amber-200' },
- { key: 'pending', label: 'Pending', count: compliance.pending, cls: 'text-warm-600 bg-warm-50 border-warm-200' },
- { key: 'missed', label: 'Missed', count: compliance.missed, cls: 'text-red-700 bg-red-50 border-red-200' },
- ];
-
- return (
-
- {/* Header */}
-
-
-
-
-
Soreness Checks
-
{dateLabel}
-
-
-
{compliance.total} due
-
-
- {/* Count tiles */}
-
- {tiles.map((t) => (
-
setFilter(filter === t.key ? 'all' : t.key)}
- className={`rounded-2xl border px-4 py-3 text-left transition-all ${t.cls} ${
- filter === t.key ? 'ring-2 ring-offset-1 ring-primary-400' : 'hover:opacity-80'
- }`}
- >
- {t.count}
- {t.label}
-
- ))}
-
-
- {/* Athlete list */}
-
-
- {filtered.length === 0 ? (
- }
- title="No athletes match this filter"
- description="Try selecting a different status."
- className="py-8"
- />
- ) : (
-
- {filtered.map((s) => (
-
-
-
- ))}
-
- )}
-
-
-
- );
-}
diff --git a/src/components/lifting/soreness/TeamSorenessHeatmap.tsx b/src/components/lifting/soreness/TeamSorenessHeatmap.tsx
deleted file mode 100644
index 0eb36331a..000000000
--- a/src/components/lifting/soreness/TeamSorenessHeatmap.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-'use client';
-
-// =============================================================================
-// src/components/lifting/soreness/TeamSorenessHeatmap.tsx
-//
-// Team-level body heatmap showing soreness count, average severity, or
-// high-only (≥7) per body region. Uses the body silhouettes as a base with
-// color intensity driven by data. Toggle: Count | Avg Severity | High Only.
-// =============================================================================
-
-import { useState, useMemo } from 'react';
-import { motion, useReducedMotion } from 'framer-motion';
-
-import { SORENESS_REGIONS } from '@/lib/lifting/soreness-regions';
-import type { SorenessRegionId } from '@/lib/lifting/soreness-regions';
-import { Card, CardContent } from '@/components/ui/card';
-import { Button } from '@/components/ui/button';
-import { EmptyState } from '@/components/ui/empty-state';
-import { IconUsers } from '@/components/icons';
-import type { AthleteRequestSummary } from '@/app/lifting/actions/soreness';
-
-// ---------------------------------------------------------------------------
-// Props
-// ---------------------------------------------------------------------------
-
-interface Props {
- summaries: AthleteRequestSummary[];
- /** Label for which day these summaries are from */
- dateLabel?: string;
-}
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-type HeatMode = 'count' | 'avg' | 'high';
-
-interface RegionStats {
- count: number;
- totalSeverity: number;
- highCount: number;
-}
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function heatColor(value: number, max: number, mode: HeatMode): string {
- if (max === 0 || value === 0) return '#f5f0e8'; // cream — no data
- const pct = Math.min(value / max, 1);
-
- if (mode === 'count' || mode === 'high') {
- // Green → amber → red gradient
- if (pct < 0.2) return 'rgba(22,163,74,0.25)';
- if (pct < 0.4) return 'rgba(234,179,8,0.40)';
- if (pct < 0.6) return 'rgba(245,158,11,0.55)';
- if (pct < 0.8) return 'rgba(234,88,12,0.65)';
- return 'rgba(185,28,28,0.75)';
- }
-
- // avg severity uses same scale
- if (pct < 0.2) return 'rgba(34,197,94,0.30)';
- if (pct < 0.4) return 'rgba(234,179,8,0.45)';
- if (pct < 0.6) return 'rgba(245,158,11,0.58)';
- if (pct < 0.8) return 'rgba(234,88,12,0.68)';
- return 'rgba(185,28,28,0.80)';
-}
-
-// ---------------------------------------------------------------------------
-// Legend row
-// ---------------------------------------------------------------------------
-
-function Legend({ max, unit }: { max: number; unit: string }) {
- const stops = [0, 0.25, 0.5, 0.75, 1];
- return (
-
-
Low
-
- {stops.map((s, i) => (
-
- ))}
-
-
High ({max} {unit})
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Region pill list (alternate view for small screens)
-// ---------------------------------------------------------------------------
-
-function RegionPillList({
- stats,
- mode,
-}: {
- stats: Map;
- mode: HeatMode;
-}) {
- const sorted = ([...stats.entries()] as Array<[SorenessRegionId, RegionStats]>)
- .map(([id, s]) => {
- const val =
- mode === 'count' ? s.count :
- mode === 'avg' ? (s.count > 0 ? s.totalSeverity / s.count : 0) :
- s.highCount;
- return { id, val, label: SORENESS_REGIONS[id].label };
- })
- .filter((r) => r.val > 0)
- .sort((a, b) => b.val - a.val)
- .slice(0, 12);
-
- if (sorted.length === 0) return null;
-
- const maxVal = sorted[0]?.val ?? 1;
-
- return (
-
- {sorted.map(({ id, val, label }) => {
- const color = heatColor(val, maxVal, mode);
- const display = mode === 'avg' ? val.toFixed(1) : String(Math.round(val));
- return (
-
- {label} · {display}
-
- );
- })}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Component
-// ---------------------------------------------------------------------------
-
-export function TeamSorenessHeatmap({ summaries, dateLabel }: Props) {
- const prefersReducedMotion = useReducedMotion();
- const [mode, setMode] = useState('count');
-
- // Build region stats from all summaries
- const stats = useMemo(() => {
- const map = new Map();
-
- for (const summary of summaries) {
- for (const region of summary.regions) {
- const id = region.body_region as SorenessRegionId;
- if (!(id in SORENESS_REGIONS)) continue;
- const existing = map.get(id) ?? { count: 0, totalSeverity: 0, highCount: 0 };
- existing.count += 1;
- existing.totalSeverity += region.severity;
- if (region.severity >= 7) existing.highCount += 1;
- map.set(id, existing);
- }
- }
-
- return map;
- }, [summaries]);
-
- const hasData = stats.size > 0;
- const athleteCount = summaries.filter((s) => s.regions.length > 0).length;
-
- const modeMax = useMemo(() => {
- let max = 0;
- for (const [, s] of stats) {
- const val =
- mode === 'count' ? s.count :
- mode === 'avg' ? (s.count > 0 ? s.totalSeverity / s.count : 0) :
- s.highCount;
- if (val > max) max = val;
- }
- return max;
- }, [stats, mode]);
-
- const modeUnit = mode === 'count' ? 'athletes' : mode === 'avg' ? 'avg' : 'high';
-
- return (
-
- {/* Header */}
-
-
-
-
Team Soreness Heatmap
-
- {dateLabel &&
{dateLabel} }
-
-
- {/* Mode toggle */}
-
- {([
- ['count', 'Count'],
- ['avg', 'Avg Severity'],
- ['high', 'High Only'],
- ] as Array<[HeatMode, string]>).map(([m, label]) => (
- setMode(m)}
- className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-all ${
- mode === m ? 'bg-cream-50 text-warm-900 shadow-sm' : 'text-warm-500 hover:text-warm-700'
- }`}
- >
- {label}
-
- ))}
-
-
-
-
- {!hasData ? (
- }
- title="No soreness reported yet"
- description="Athletes who report soreness will appear here."
- className="py-6"
- />
- ) : (
- <>
-
- {athleteCount} athlete{athleteCount !== 1 ? 's' : ''} reported soreness
-
-
- {/* Region pill heatmap */}
-
-
- {/* Legend */}
- {modeMax > 0 && (
-
- )}
- >
- )}
-
-
-
- );
-}
diff --git a/src/components/lifting/soreness/index.ts b/src/components/lifting/soreness/index.ts
deleted file mode 100644
index ef0fb820c..000000000
--- a/src/components/lifting/soreness/index.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-// =============================================================================
-// src/components/lifting/soreness/index.ts
-//
-// Public barrel for the soreness component system.
-// =============================================================================
-
-export { BodySilhouetteFront } from './BodySilhouetteFront';
-export type { RegionHitState } from './BodySilhouetteFront';
-
-// Shared severity color helpers (used by silhouettes, slider, badges)
-export {
- severityFill,
- severityStroke,
- severityTrackClass,
- severityThumbClass,
- severityTextClass,
- severityBadge,
- SEVERITY_LABELS,
-} from './severity-colors';
-
-export { BodySilhouetteBack } from './BodySilhouetteBack';
-
-export { SorenessSeveritySlider } from './SorenessSeveritySlider';
-
-export { SorenessRegionBottomSheet } from './SorenessRegionBottomSheet';
-export type { RegionEntry } from './SorenessRegionBottomSheet';
-
-export { SorenessSelectedRegionList } from './SorenessSelectedRegionList';
-
-export { SorenessBodyMap } from './SorenessBodyMap';
-export type { SorenessMapState } from './SorenessBodyMap';
-
-export { ReadyToGoButton } from './ReadyToGoButton';
-
-export { SorenessCheckCard } from './SorenessCheckCard';
-
-export { SorenessComplianceBoard } from './SorenessComplianceBoard';
-
-export { HighPrioritySorenessList } from './HighPrioritySorenessList';
-
-export { TeamSorenessHeatmap } from './TeamSorenessHeatmap';
-
-export { SorenessScheduleBuilder } from './SorenessScheduleBuilder';
diff --git a/src/lib/baseball/read-models/lift-programs.ts b/src/lib/baseball/read-models/lift-programs.ts
deleted file mode 100644
index 1ce1af129..000000000
--- a/src/lib/baseball/read-models/lift-programs.ts
+++ /dev/null
@@ -1,340 +0,0 @@
-// =============================================================================
-// src/lib/baseball/read-models/lift-programs.ts
-//
-// V11 Program Builder read models. Composes:
-// * getLiftProgramList — the /performance/programs list (phase/goal/status,
-// week+day counts, template flag).
-// * getLiftProgramTree — the /performance/programs/[programId] editor tree:
-// program -> weeks -> days -> sections -> prescriptions
-// (+ a resolved exercise-name map for prescription rows).
-// * getAssignContext — roster + active strength groups for the Assign+Publish
-// flow (resolve player ids before publishLiftDay).
-//
-// SERVER-ONLY plain async (NOT 'use server'). RLS backs every query: program-tree
-// SELECT is gated to is_baseball_team_staff, so a non-staff caller sees nothing.
-//
-// Helm Lift Lab unification: every table here reads from the unified
-// helm_lifting_* tables (organization_id + sport='baseball' scoped) instead of
-// the legacy baseball_lift_* / baseball_strength_* tables, which are write-dead.
-// helm_lifting_days.sport_context replaces the legacy baseball_context column;
-// this module remaps it back to `baseball_context` on read so the existing
-// LiftDayNode contract (and ProgramEditorClient.tsx, which reads
-// `day.baseball_context`) never changes. Group membership is athlete_id-keyed
-// (helm_lifting_athletes), so it is resolved back to baseball_players.id via
-// resolveBaseballLiftingOrg / resolveBaseballAthleteIds before being exposed.
-// The V11 tables are not in the generated database.ts (no live apply to regen
-// against) — we read via fromUntyped() and lean on the hand-written types as
-// the contract, exactly like performance-command.ts.
-// =============================================================================
-
-import 'server-only';
-
-import { createClient } from '@/lib/supabase/server';
-import { fromUntyped } from '@/lib/supabase/untyped';
-import {
- resolveBaseballLiftingOrg,
- resolveBaseballAthleteIds,
-} from '@/lib/lifting/resolve-baseball-context';
-import type {
- BaseballLiftProgramRow,
- BaseballLiftWeekRow,
- BaseballLiftDayRow,
- BaseballLiftSectionRow,
- BaseballLiftPrescriptionRow,
- BaseballLiftExerciseRow,
-} from '@/lib/types/baseball-lifting-v11';
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type Db = any;
-
-// -----------------------------------------------------------------------------
-// Program list
-// -----------------------------------------------------------------------------
-
-export interface LiftProgramListItem extends BaseballLiftProgramRow {
- week_count: number;
- day_count: number;
-}
-
-export async function getLiftProgramList(teamId: string): Promise {
- const supabase = (await createClient()) as Db;
-
- const liftCtx = await resolveBaseballLiftingOrg(teamId);
- if (!liftCtx) return [];
-
- const { data: programs } = await fromUntyped(supabase, 'helm_lifting_programs')
- .select(
- 'id, team_id, name, description, phase, goal, created_by_coach_id, visibility, status, is_template, start_date, end_date, created_at, updated_at',
- )
- .eq('organization_id', liftCtx.organizationId)
- .eq('sport', 'baseball')
- .eq('team_id', teamId)
- .order('created_at', { ascending: false }) as { data: BaseballLiftProgramRow[] | null };
- const list = programs ?? [];
- if (list.length === 0) return [];
-
- const ids = list.map((p) => p.id);
-
- // Week + day counts in two scoped reads (small N; avoids N+1).
- const { data: weeks } = await fromUntyped(supabase, 'helm_lifting_weeks')
- .select('id, program_id')
- .in('program_id', ids) as { data: Array<{ id: string; program_id: string }> | null };
- const weekRows = weeks ?? [];
- const weekCountByProgram = new Map();
- const programByWeek = new Map();
- for (const w of weekRows) {
- weekCountByProgram.set(w.program_id, (weekCountByProgram.get(w.program_id) ?? 0) + 1);
- programByWeek.set(w.id, w.program_id);
- }
-
- const dayCountByProgram = new Map();
- if (weekRows.length) {
- const { data: days } = await fromUntyped(supabase, 'helm_lifting_days')
- .select('week_id')
- .in('week_id', weekRows.map((w) => w.id)) as { data: Array<{ week_id: string }> | null };
- for (const d of days ?? []) {
- const programId = programByWeek.get(d.week_id);
- if (!programId) continue;
- dayCountByProgram.set(programId, (dayCountByProgram.get(programId) ?? 0) + 1);
- }
- }
-
- return list.map((p) => ({
- ...p,
- week_count: weekCountByProgram.get(p.id) ?? 0,
- day_count: dayCountByProgram.get(p.id) ?? 0,
- }));
-}
-
-// -----------------------------------------------------------------------------
-// Program tree (the editor)
-// -----------------------------------------------------------------------------
-
-export interface LiftPrescriptionNode extends BaseballLiftPrescriptionRow {
- exercise_name: string | null;
-}
-export interface LiftSectionNode extends BaseballLiftSectionRow {
- prescriptions: LiftPrescriptionNode[];
-}
-export interface LiftDayNode extends BaseballLiftDayRow {
- sections: LiftSectionNode[];
-}
-export interface LiftWeekNode extends BaseballLiftWeekRow {
- days: LiftDayNode[];
-}
-export interface LiftProgramTree {
- program: BaseballLiftProgramRow;
- weeks: LiftWeekNode[];
-}
-
-/**
- * Load a full program tree for the editor. Returns null when the program does
- * not exist, the team has no Helm Lifting organization configured, or RLS
- * hides it (caller should 404). Assembles the tree in a fixed number of scoped
- * queries (one per level) — no N+1.
- */
-export async function getLiftProgramTree(
- teamId: string,
- programId: string,
-): Promise {
- const supabase = (await createClient()) as Db;
-
- const liftCtx = await resolveBaseballLiftingOrg(teamId);
- if (!liftCtx) return null;
-
- const { data: program } = await fromUntyped(supabase, 'helm_lifting_programs')
- .select(
- 'id, team_id, name, description, phase, goal, created_by_coach_id, visibility, status, is_template, start_date, end_date, created_at, updated_at',
- )
- .eq('id', programId)
- .eq('organization_id', liftCtx.organizationId)
- .eq('team_id', teamId)
- .maybeSingle() as { data: BaseballLiftProgramRow | null };
- if (!program) return null;
-
- const { data: weekRows } = await fromUntyped(supabase, 'helm_lifting_weeks')
- .select('id, program_id, week_number, name, theme, deload, created_at')
- .eq('program_id', programId)
- .order('week_number', { ascending: true }) as { data: BaseballLiftWeekRow[] | null };
- const weeks = weekRows ?? [];
-
- const weekIds = weeks.map((w) => w.id);
- const { data: dayRows } = weekIds.length
- ? await fromUntyped(supabase, 'helm_lifting_days')
- .select('id, week_id, day_number, name, day_type, sport_context, estimated_minutes, created_at')
- .in('week_id', weekIds)
- .order('day_number', { ascending: true })
- : { data: [] };
- // helm_lifting_days.sport_context replaces the legacy baseball_context
- // column — remap it back so LiftDayNode keeps its established field name
- // (ProgramEditorClient.tsx reads `day.baseball_context`).
- const days: BaseballLiftDayRow[] = ((dayRows ?? []) as Array<{
- id: string; week_id: string; day_number: number; name: string | null;
- day_type: BaseballLiftDayRow['day_type']; sport_context: BaseballLiftDayRow['baseball_context'];
- estimated_minutes: number | null; created_at: string;
- }>).map((d) => ({
- id: d.id,
- week_id: d.week_id,
- day_number: d.day_number,
- name: d.name,
- day_type: d.day_type,
- baseball_context: d.sport_context,
- estimated_minutes: d.estimated_minutes,
- created_at: d.created_at,
- }));
-
- const dayIds = days.map((d) => d.id);
- const { data: sectionRows } = dayIds.length
- ? await fromUntyped(supabase, 'helm_lifting_sections')
- .select('id, lift_day_id, section_order, name, section_type, instructions, created_at')
- .in('lift_day_id', dayIds)
- .order('section_order', { ascending: true })
- : { data: [] };
- const sections = (sectionRows ?? []) as BaseballLiftSectionRow[];
-
- const sectionIds = sections.map((s) => s.id);
- const { data: presRows } = sectionIds.length
- ? await fromUntyped(supabase, 'helm_lifting_prescriptions')
- .select(
- 'id, section_id, exercise_id, order_index, prescription_type, sets, reps, load_value, load_unit, percent_1rm, target_rpe, target_rir, target_velocity_min, target_velocity_max, rest_seconds, tempo, coaching_note, substitution_group_id, created_at',
- )
- .in('section_id', sectionIds)
- .order('order_index', { ascending: true })
- : { data: [] };
- const prescriptions = (presRows ?? []) as BaseballLiftPrescriptionRow[];
-
- // Resolve exercise names for prescription rows (no FK reliance on read path).
- const exIds = Array.from(
- new Set(prescriptions.map((p) => p.exercise_id).filter((x): x is string => Boolean(x))),
- );
- const nameById = new Map();
- if (exIds.length) {
- const { data: exs } = await fromUntyped(supabase, 'helm_lifting_exercises')
- .select('id, name')
- .in('id', exIds) as { data: Array> | null };
- for (const e of exs ?? []) {
- nameById.set(e.id, e.name);
- }
- }
-
- // Assemble bottom-up.
- const presBySection = new Map();
- for (const p of prescriptions) {
- const arr = presBySection.get(p.section_id) ?? [];
- arr.push({ ...p, exercise_name: p.exercise_id ? nameById.get(p.exercise_id) ?? null : null });
- presBySection.set(p.section_id, arr);
- }
- const sectionsByDay = new Map();
- for (const s of sections) {
- const arr = sectionsByDay.get(s.lift_day_id) ?? [];
- arr.push({ ...s, prescriptions: presBySection.get(s.id) ?? [] });
- sectionsByDay.set(s.lift_day_id, arr);
- }
- const daysByWeek = new Map();
- for (const d of days) {
- const arr = daysByWeek.get(d.week_id) ?? [];
- arr.push({ ...d, sections: sectionsByDay.get(d.id) ?? [] });
- daysByWeek.set(d.week_id, arr);
- }
-
- return {
- program,
- weeks: weeks.map((w) => ({ ...w, days: daysByWeek.get(w.id) ?? [] })),
- };
-}
-
-// -----------------------------------------------------------------------------
-// Assign + Publish context
-// -----------------------------------------------------------------------------
-
-export interface AssignRosterPlayer {
- id: string;
- first_name: string | null;
- last_name: string | null;
- primary_position: string | null;
-}
-export interface AssignGroup {
- id: string;
- name: string;
- member_ids: string[];
-}
-export interface AssignContext {
- roster: AssignRosterPlayer[];
- groups: AssignGroup[];
- exercises: Array>;
-}
-
-/**
- * Roster + active strength groups (with resolved member ids) + the exercise
- * library for the Assign+Publish flow and the prescription editor. The publish
- * action re-resolves players server-side; this just powers the picker.
- *
- * Group membership + the exercise library live in the unified Helm Lifting Lab
- * tables (org+sport scoped). Group members are athlete_id-keyed, so they are
- * resolved back to baseball_players.id here via the same org/athlete-id bridge
- * used across the rewired read models. A team with no Helm Lifting
- * organization configured yet degrades to an honest empty groups/exercises
- * list — the roster (unrelated to lifting) is unaffected.
- */
-export async function getAssignContext(teamId: string): Promise {
- const supabase = (await createClient()) as Db;
-
- const { data: members } = await supabase
- .from('baseball_team_members')
- .select('player_id, baseball_players!inner ( id, first_name, last_name, primary_position )')
- .eq('team_id', teamId);
- const roster: AssignRosterPlayer[] = (members ?? [])
- .map((m: { baseball_players: AssignRosterPlayer }) => m.baseball_players)
- .filter((p: AssignRosterPlayer | null): p is AssignRosterPlayer => Boolean(p?.id))
- .sort((a: AssignRosterPlayer, b: AssignRosterPlayer) =>
- (a.last_name ?? '').localeCompare(b.last_name ?? ''),
- );
-
- const liftCtx = await resolveBaseballLiftingOrg(teamId);
- if (!liftCtx) {
- return { roster, groups: [], exercises: [] };
- }
-
- const rosterPlayerIds = roster.map((p) => p.id);
- const athleteMap = rosterPlayerIds.length
- ? await resolveBaseballAthleteIds(liftCtx.organizationId, rosterPlayerIds)
- : {};
- const athleteToPlayer = new Map();
- for (const [pid, aid] of Object.entries(athleteMap)) athleteToPlayer.set(aid, pid);
-
- const { data: groupRows } = await fromUntyped(supabase, 'helm_lifting_groups')
- .select('id, name')
- .eq('organization_id', liftCtx.organizationId)
- .eq('sport', 'baseball')
- .eq('team_id', teamId)
- .eq('is_active', true)
- .order('name', { ascending: true }) as { data: Array<{ id: string; name: string }> | null };
- const groups = groupRows ?? [];
-
- const membersByGroup = new Map();
- if (groups.length) {
- const { data: gm } = await fromUntyped(supabase, 'helm_lifting_group_members')
- .select('group_id, athlete_id')
- .in('group_id', groups.map((g) => g.id)) as { data: Array<{ group_id: string; athlete_id: string }> | null };
- for (const row of gm ?? []) {
- const pid = athleteToPlayer.get(row.athlete_id);
- if (!pid) continue; // athlete not resolved to a roster player — skip honestly.
- const arr = membersByGroup.get(row.group_id) ?? [];
- arr.push(pid);
- membersByGroup.set(row.group_id, arr);
- }
- }
-
- const { data: exRows } = await fromUntyped(supabase, 'helm_lifting_exercises')
- .select('id, name, category, default_unit')
- .eq('sport', 'baseball')
- .eq('is_active', true)
- .or(`organization_id.eq.${liftCtx.organizationId},is_global.eq.true`)
- .order('name', { ascending: true }) as { data: AssignContext['exercises'] | null };
-
- return {
- roster,
- groups: groups.map((g) => ({ ...g, member_ids: membersByGroup.get(g.id) ?? [] })),
- exercises: exRows ?? [],
- };
-}
From 40e10a1ac22197ccbad3a3cc0560e7b1deb6816d Mon Sep 17 00:00:00 2001
From: njrini99-code
Date: Wed, 15 Jul 2026 18:10:09 -0400
Subject: [PATCH 03/18] devibe: remove orphaned root scaffolding (.taskmaster,
.full-stack-feature, stray App Store Connect snapshots) (#860)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- .taskmaster/ (9 tracked files: README, config.json, docs/current-state.md,
docs/feature-checklist.md, docs/prd.txt, logs/.gitkeep, state.json,
tasks/tasks.json, templates/task-template.json) — task-master scaffolding
from an abandoned tool integration. Only appears elsewhere as ignore-list
entries (.gitignore:76-77), never read by any script/workflow/package.json
script. Zero functional references.
- .full-stack-feature/ (2 tracked files: 01-requirements.md, state.json) —
same pattern: only appears as ignore-list entries across .gitignore,
.coderabbitignore, .coderabbit.yaml, .vercelignore, .greptile/config.json,
.greptile/rules.md (all just telling other tools to skip the directory).
Zero functional references.
- full-snapshot.yml, full-snapshot2.yml, app-info-snapshot.yml,
age-ratings-snapshot.yml — accessibility-tree/DOM snapshots of the App
Store Connect web UI (not fastlane config — there is no fastlane/ directory
anywhere in this repo, which uses Xcode Cloud, not fastlane). Zero script
or CI references (grepped scripts/, tools/, .github/, .circleci/ — nothing
reads these paths). The one doc mention
(docs/operations/2026-05-28-coderabbit-fails-investigation.md) explicitly
calls age-ratings-snapshot.yml "INHERITED NOISE" causing ~200 yamllint
indentation errors and recommends "delete it if it's truly unused" — it is.
review-gate.yml's yamllint job only lints *changed* files in a PR diff, so
these aren't continuously failing CI, but they're pure accidental commits
(browser-automation output) with zero purpose in the repo.
- context7.json — does not exist (only context7.json.example is tracked;
the real context7.json was already removed in a prior commit
6a9b5654 "fix(security): stop tracking context7.json (contained leaked API
key)"). Nothing to do here.
Gates: typecheck clean, check-cycles clean (33 known cycles, none new).
Co-authored-by: Fable Integrator
Co-authored-by: Claude Fable 5
---
.full-stack-feature/01-requirements.md | 123 --
.full-stack-feature/state.json | 13 -
.taskmaster/README.md | 124 --
.taskmaster/config.json | 45 -
.taskmaster/docs/current-state.md | 769 --------
.taskmaster/docs/feature-checklist.md | 2296 ----------------------
.taskmaster/docs/prd.txt | 676 -------
.taskmaster/logs/.gitkeep | 1 -
.taskmaster/state.json | 6 -
.taskmaster/tasks/tasks.json | 70 -
.taskmaster/templates/task-template.json | 21 -
age-ratings-snapshot.yml | 209 --
app-info-snapshot.yml | 242 ---
full-snapshot.yml | 348 ----
full-snapshot2.yml | 350 ----
15 files changed, 5293 deletions(-)
delete mode 100644 .full-stack-feature/01-requirements.md
delete mode 100644 .full-stack-feature/state.json
delete mode 100644 .taskmaster/README.md
delete mode 100644 .taskmaster/config.json
delete mode 100644 .taskmaster/docs/current-state.md
delete mode 100644 .taskmaster/docs/feature-checklist.md
delete mode 100644 .taskmaster/docs/prd.txt
delete mode 100644 .taskmaster/logs/.gitkeep
delete mode 100644 .taskmaster/state.json
delete mode 100644 .taskmaster/tasks/tasks.json
delete mode 100644 .taskmaster/templates/task-template.json
delete mode 100644 age-ratings-snapshot.yml
delete mode 100644 app-info-snapshot.yml
delete mode 100644 full-snapshot.yml
delete mode 100644 full-snapshot2.yml
diff --git a/.full-stack-feature/01-requirements.md b/.full-stack-feature/01-requirements.md
deleted file mode 100644
index 1dfe968a1..000000000
--- a/.full-stack-feature/01-requirements.md
+++ /dev/null
@@ -1,123 +0,0 @@
-# Requirements: Business Intelligence Dashboard
-
-## Problem Statement
-
-The current admin "Growth" tab shows activity totals and vanity metrics instead of decision-making metrics. The admin (founder/operator) cannot answer critical product questions:
-- **Which features actually drive retention** vs which ones just get clicked once?
-- **Where do users get stuck and drop off** in the onboarding/activation flow?
-- **Which users/teams would pay** if pricing existed?
-- **What should be built, fixed, or doubled-down on** next?
-- **Who is quietly churning** and what predicts that behavior?
-
-The tab needs a complete redesign into a proper Business Intelligence dashboard that replaces vanity stats with actionable, decision-driving metrics organized around the 5-section BI framework.
-
-## User
-Platform admin (founder) — sole admin of GolfHelm, needs to understand product health and make data-driven product decisions.
-
-## Acceptance Criteria
-
-- [ ] Tab renamed from "Growth" to "Business Intelligence" across all admin UI (tab nav, keyboard shortcuts, descriptions)
-- [ ] All 5 admin tabs reorganized around the BI framework
-- [ ] **Section A: Growth** — Signups, activated users (defined as: completed onboarding + submitted first round), activation rate, median time-to-first-value, drop-off between signup and first key action
-- [ ] **Section B: Retention** — D1/D7/D30 retention rates, WAU/MAU, DAU/MAU stickiness, weekly cohort retention matrix (by signup week), retention by user type (coach vs player)
-- [ ] **Section C: Product Usage** — Feature adoption ranked by % of active users who used each feature in last 7/30 days, repeat usage counts, feature usage by retained vs churned users, dead features detection (<5% adoption), object creation metrics (rounds, qualifiers, events, tasks, messages, documents, reviews, insights)
-- [ ] **Section D: Funnel & Friction** — Onboarding step conversion (signup → profile → first round → active week → received insights), biggest drop-off points, error/failure rates affecting engagement
-- [ ] **Section E: Health & Opportunity** — Per-team health scores (active users, feature breadth, admin engagement), power user identification, at-risk accounts, conversion-intent proxy signals (high usage, many rounds, team spread, AI adoption, settings engagement)
-- [ ] **Vercel Analytics Integration** — Pull unique visitor/device counts from Vercel Web Analytics API and display in the Growth section
-- [ ] Recharts-based visualizations (already installed v3.6.0) for cohort heatmaps, funnels, area charts, bar charts
-- [ ] Clean, premium glassmorphism UI matching existing admin design system
-- [ ] All metrics computed from existing Supabase tables (no new event tracking infrastructure)
-
-## Scope
-
-### In Scope
-- Complete redesign of the Growth tab → Business Intelligence tab
-- Rename tab in admin navigation (label, icon, description, keyboard shortcut)
-- 5-section BI dashboard (Growth, Retention, Product Usage, Funnel & Friction, Health & Opportunity)
-- Reorganize other admin tabs as needed to align with BI framework (move overlapping metrics)
-- Vercel Web Analytics API integration for unique visitors/devices
-- Recharts-based charts replacing custom AdminChart where beneficial
-- New computed metrics: activation rate, time-to-value, feature retention correlation, team health scores, conversion proxies
-- GolfHelm "aha moment" definition: Completed onboarding + submitted first round
-- Power user segment definition: Active 3 of last 4 weeks, ≥3 rounds, used 2+ advanced features
-
-### Out of Scope
-- New event-tracking infrastructure (PostHog, Mixpanel, etc.)
-- New database tables for raw event storage — all metrics from existing tables
-- Real-time WebSocket dashboards (keep 60-second polling pattern)
-- Payment/revenue analytics (no payments yet)
-- A/B testing framework
-- Email/notification automation based on BI signals
-- Player-facing analytics (this is admin-only)
-
-## Technical Constraints
-
-1. **Data fetching pattern**: Extend existing `getAdminDashboardData()` server action with parallel Supabase queries. Do not create separate data endpoints.
-2. **Admin auth**: All data behind admin role check (already enforced in `admin-data.ts`)
-3. **Supabase admin client**: Use `createAdminClient()` to bypass RLS for cross-team analytics
-4. **Performance**: Current action runs 100+ parallel queries. New BI metrics must maintain sub-3s total response time. Use `Promise.all()` batching.
-5. **Type safety**: Extend `AdminDashboardData` interface with new BI fields. TypeScript strict mode.
-6. **No new tables**: All BI metrics computed from existing 75+ golf tables.
-7. **Vercel API**: Use Vercel Web Analytics API (REST) called server-side in the data action. Requires `VERCEL_API_TOKEN` env var.
-
-## Technology Stack
-
-- **Frontend**: Next.js 16 App Router, React 19, TypeScript strict, Tailwind CSS
-- **Charts**: Recharts 3.6.0 (already installed)
-- **Backend**: Server Actions (no REST API routes)
-- **Database**: Supabase (PostgreSQL) with admin client
-- **External**: Vercel Web Analytics REST API
-- **Design System**: Glassmorphism — `bg-white/70 backdrop-blur-xl border border-white/20 rounded-2xl shadow-glass`
-
-## Dependencies
-
-- **Affects all 5 admin tabs**: Full reorganization around BI framework
-- **Extends `AdminDashboardData` type**: Used by OverviewTab, PeopleTab, SystemTab, GrowthTab
-- **Shares data with Overview tab**: Some metrics may move to BI or be referenced from both
-- **Vercel API dependency**: Requires Vercel API token in environment variables
-- **Existing components reusable**: AdminStatCard, AdminChart, CohortRetentionMatrix, SessionHeatmap (may be enhanced or replaced)
-
-## Configuration
-
-- Stack: nextjs-typescript-supabase
-- API Style: server-actions
-- Complexity: complex
-
-## GolfHelm Feature Context
-
-### Key Tables for BI Metrics
-| Table | BI Signal |
-|-------|-----------|
-| `users` | Signups, registration dates |
-| `golf_players` | Player activation, onboarding status |
-| `golf_coaches` | Coach activation, onboarding status |
-| `golf_rounds` | Core engagement (round submission = key action) |
-| `golf_shots` | Deep engagement signal |
-| `golf_team_members` | Team health, seat activation |
-| `golf_teams` | Account-level metrics |
-| `golf_coach_philosophy` | AI adoption signal |
-| `golf_coach_insights` | AI usage frequency |
-| `golf_round_reviews` | Coach engagement depth |
-| `golf_events` / `golf_tasks` / `golf_messages` | Feature adoption signals |
-| `golf_qualifiers` / `golf_documents` / `golf_travel_itineraries` | Feature adoption signals |
-| `golf_player_stats_cache` | Stats engagement |
-| `golf_attendance_summary` | Event engagement |
-| `error_logs` | Product friction / quality signals |
-
-### GolfHelm "Aha Moment" Definition
-A user is "activated" when they have:
-1. Completed onboarding (`onboarding_completed = true`)
-2. Submitted at least one round (`golf_rounds` with `status = 'completed'`)
-
-### Power User Definition
-- Active in 3 of last 4 weeks (submitted rounds)
-- Completed ≥3 rounds total in last 30 days
-- Used 2+ "advanced" features (CoachHelm AI, Qualifiers, Development Plans, Shot Tracking, Stats Deep Dive)
-
-### Conversion Proxy Signals
-- Submitted 10+ rounds
-- Team has 3+ active players
-- Coach uses AI insights weekly
-- Coach created development plans
-- Admin spent time in settings
-- Used 4+ different features in last 30 days
diff --git a/.full-stack-feature/state.json b/.full-stack-feature/state.json
deleted file mode 100644
index 2e9f2509c..000000000
--- a/.full-stack-feature/state.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "feature": "Redesign admin Growth tab into comprehensive Business Intelligence dashboard",
- "status": "complete",
- "stack": "nextjs-typescript-supabase",
- "api_style": "server-actions",
- "complexity": "complex",
- "current_step": "complete",
- "current_phase": 3,
- "completed_steps": [1, 2, 3, 4, 5, 6, 7],
- "files_created": ["01-requirements.md", "02-database-design.md", "03-architecture.md", "04-database-impl.md", "05-backend-impl.md", "06-frontend-impl.md", "07-testing.md"],
- "started_at": "2026-03-11T12:00:00Z",
- "last_updated": "2026-03-11T14:15:00Z"
-}
diff --git a/.taskmaster/README.md b/.taskmaster/README.md
deleted file mode 100644
index a232052e3..000000000
--- a/.taskmaster/README.md
+++ /dev/null
@@ -1,124 +0,0 @@
-# TaskMaster - Project Task Management
-
-**Project:** helmv3 (Helm Sports Labs - Golf Shot Tracking Platform)
-**Initialized:** 2025-12-22
-
----
-
-## 📁 Folder Structure
-
-```
-.taskmaster/
-├── config.json # Project configuration and settings
-├── tasks.json # Active and completed tasks
-├── logs/ # Task execution logs
-├── templates/ # Task templates
-│ └── task-template.json
-└── README.md # This file
-```
-
----
-
-## 🎯 Task Statuses
-
-| Status | Description |
-|--------|-------------|
-| `todo` | Not started |
-| `in_progress` | Currently being worked on |
-| `blocked` | Waiting on dependencies or external factors |
-| `review` | Ready for code review |
-| `completed` | Finished and verified |
-| `archived` | Completed tasks older than 30 days |
-
----
-
-## 🔥 Priority Levels
-
-| Priority | Use When |
-|----------|----------|
-| `critical` | Blocking issue, production bug, security issue |
-| `high` | Important feature, significant bug |
-| `medium` | Standard feature work, minor bugs |
-| `low` | Nice-to-have, optimization, cleanup |
-
----
-
-## 📋 Task Categories
-
-- `feature` - New functionality
-- `bug` - Bug fixes
-- `refactor` - Code improvements
-- `docs` - Documentation
-- `test` - Testing
-- `chore` - Maintenance, tooling, dependencies
-
----
-
-## 🚀 Quick Commands
-
-### View All Tasks
-```bash
-cat .taskmaster/tasks.json | jq '.tasks'
-```
-
-### View Active Tasks
-```bash
-cat .taskmaster/tasks.json | jq '.tasks[] | select(.status != "completed" and .status != "archived")'
-```
-
-### View Task by ID
-```bash
-cat .taskmaster/tasks.json | jq '.tasks[] | select(.id == "HELM-001")'
-```
-
-### Count Tasks by Status
-```bash
-cat .taskmaster/tasks.json | jq '[.tasks | group_by(.status)[] | {status: .[0].status, count: length}]'
-```
-
----
-
-## 📝 Task ID Format
-
-Tasks follow the format: `HELM-XXX`
-- Prefix: `HELM` (project identifier)
-- Number: Sequential (001, 002, 003, etc.)
-
----
-
-## ✅ Completed Tasks Summary
-
-### HELM-001: Premium Dark Scorecard
-- **Status:** Completed (2025-12-21)
-- **Priority:** High
-- **Files:** `src/components/golf/ShotTrackingFinal.tsx`
-- **Commit:** `f6625a5`
-- **Features:**
- - Dark theme with impossible-to-miss current hole
- - Color-coded score indicators
- - Performance badges
- - Premium totals section
-
-### HELM-002: Fix Shot Distance Calculation Bug
-- **Status:** Completed (2025-12-21)
-- **Priority:** Critical
-- **Files:** `src/components/golf/ShotTrackingFinal.tsx`
-- **Commit:** `f6625a5`
-- **Fix:**
- - Added shotDistanceUnit field
- - Fixed unit conversion (1105 feet, not yards)
- - Improved unit detection logic
-
----
-
-## 🔧 Customization
-
-Edit `.taskmaster/config.json` to customize:
-- Task ID prefix
-- Default priority/status
-- Auto-archive settings
-- Available statuses, priorities, and categories
-
----
-
-**Last Updated:** 2025-12-22
diff --git a/.taskmaster/config.json b/.taskmaster/config.json
deleted file mode 100644
index f9bea34fc..000000000
--- a/.taskmaster/config.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "models": {
- "main": {
- "provider": "anthropic",
- "modelId": "claude-sonnet-4-20250514",
- "maxTokens": 64000,
- "temperature": 0.2
- },
- "research": {
- "provider": "perplexity",
- "modelId": "sonar",
- "maxTokens": 8700,
- "temperature": 0.1
- },
- "fallback": {
- "provider": "anthropic",
- "modelId": "claude-3-7-sonnet-20250219",
- "maxTokens": 120000,
- "temperature": 0.2
- }
- },
- "global": {
- "logLevel": "info",
- "debug": false,
- "defaultNumTasks": 10,
- "defaultSubtasks": 5,
- "defaultPriority": "medium",
- "projectName": "Task Master",
- "ollamaBaseURL": "http://localhost:11434/api",
- "bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com",
- "responseLanguage": "English",
- "enableCodebaseAnalysis": true,
- "enableProxy": false,
- "anonymousTelemetry": true,
- "userId": "1234567890",
- "defaultTag": "master"
- },
- "claudeCode": {},
- "codexCli": {},
- "grokCli": {
- "timeout": 120000,
- "workingDirectory": null,
- "defaultModel": "grok-4-latest"
- }
-}
\ No newline at end of file
diff --git a/.taskmaster/docs/current-state.md b/.taskmaster/docs/current-state.md
deleted file mode 100644
index 03dffae0d..000000000
--- a/.taskmaster/docs/current-state.md
+++ /dev/null
@@ -1,769 +0,0 @@
-# Helm Sports Labs - Comprehensive Codebase Analysis
-**Generated:** December 22, 2024
-**Location:** `/Users/ricknini/Downloads/helmv3`
-
----
-
-## Executive Summary
-
-The Helm Sports Labs codebase contains **TWO SEPARATE APPLICATIONS**:
-1. **Baseball Recruiting Platform** (documented in CLAUDE.md) - **~65% implemented**
-2. **Golf Team Management Platform** (undocumented) - **~40% implemented**
-
-The baseball platform has strong foundational features for College Coaches and Players, but is missing significant functionality for HS Coaches, JUCO Coaches, and Showcase Coaches. The golf platform appears to be an experimental/parallel project.
-
-**Total Files Analyzed:** 238 TypeScript files across `src/`
-
----
-
-## 1. FULLY IMPLEMENTED FEATURES
-
-### Baseball Platform - Core Infrastructure ✅
-
-#### Authentication & Onboarding
-- ✅ **Login/Signup flows** (`/baseball/(auth)/`)
- - Email/password authentication via Supabase Auth
- - Role-based signup (Coach vs Player)
- - Full validation and error handling
-
-- ✅ **Player Onboarding** (`/baseball/(onboarding)/player/page.tsx`)
- - 5-step wizard: Basic Info → Baseball Info → Physical/School → Metrics → Profile/Goals
- - Avatar upload, position selection, grad year
- - Metrics: pitch velo, exit velo, 60-yard time, GPA
- - Creates player profile and links to Supabase Auth user
-
-- ✅ **Coach Onboarding** (`/baseball/(onboarding)/coach/page.tsx`)
- - 4-step wizard: Personal Info → Program Info → Program Details → Preferences
- - Creates coach record, organization, and team
- - Links to Supabase Auth user
-
-#### College Coach - Recruiting Suite ✅
-
-- ✅ **Discover Players** (`/dashboard/discover/page.tsx`)
- - Full player search with filtering (grad year, position, state, velo, exit velo, GPA)
- - Search by name or school
- - Pagination (24 players per page)
- - USA Map visualization with state click filters
- - Filter panel with real-time URL params
- - Shows recruiting-activated players only
- - Integration with watchlist (add/remove from card)
- - **Components:** FilterPanel, DiscoverResults, PlayerCard, PlayerCardGrid, USAMap
-
-- ✅ **Watchlist** (`/dashboard/watchlist/page.tsx`)
- - Full CRUD operations on watchlist
- - Table view with all player details
- - Inline status dropdown (5 pipeline stages: watchlist, high_priority, offer_extended, committed, uninterested)
- - Inline notes editing
- - Filter tabs by status
- - Filter by position and grad year
- - Bulk selection and bulk actions
- - Bulk remove with confirmation
- - Player detail modal (PlayerDetailModal)
- - **Server Actions:** `removeFromWatchlist`, `updateWatchlistStatus`, `addWatchlistNote`
-
-- ✅ **Pipeline** (`/dashboard/pipeline/page.tsx`)
- - Drag-and-drop kanban board with 5 columns
- - Uses @dnd-kit for smooth DnD
- - Grad year filter
- - Real-time stage updates
- - Empty state with CTA to Discover
- - **Components:** PipelineColumn, PipelineCard
-
-- ✅ **Compare Players** (`/dashboard/compare/page.tsx`)
- - Side-by-side comparison of 2-4 players
- - Search and add players dynamically
- - Player removal
- - URL-based state management (`?players=id1,id2,id3`)
- - **Component:** PlayerComparison
-
-- ✅ **Dashboard** (`/dashboard/page.tsx`)
- - **Beautiful Bento Grid layout** with glass morphism cards
- - Pipeline stats (watchlist, high_priority, offer_extended, committed counts)
- - Profile views, messages stats
- - Recent players list (last 5)
- - Engagement chart (7-day)
- - Activity feed (last 8 events)
- - Upcoming events & camps calendar widget
- - USA map showing player distribution by state
- - Saved searches widget
- - Quick actions (Discover, Messages, Calendar, Edit Program)
- - **Auto-redirects HS/Showcase coaches to team dashboard**
-
-#### Player Features ✅
-
-- ✅ **Player Dashboard** (`/dashboard/page.tsx`)
- - Profile card with avatar, name, position, grad year, school, location
- - Bento grid stats: Profile views, On watchlists count, Messages, Video views
- - Your Stats card (height, weight, velo, GPA)
- - Quick actions (Complete profile, Browse colleges, Check messages)
- - **Recruiting activation banner** (if not activated and not college player)
- - Profile completion percentage badge
-
-- ✅ **Profile Management** (`/dashboard/profile/page.tsx`)
- - Full profile editing
- - Avatar upload
- - All baseball stats and metrics
- - School information
- - Contact details
-
-- ✅ **Journey** (`/dashboard/journey/page.tsx`)
- - Track colleges player is interested in
- - Update status per school (interested, researching, contacted, visited, offered, committed)
- - Timeline view of journey events
- - **Hook:** `use-journey.ts`
-
-- ✅ **Analytics** (`/dashboard/analytics/page.tsx`)
- - Profile views, watchlist adds, video views, messages sent
- - 7-day engagement chart (Recharts LineChart)
- - Top schools viewing profile
- - **Hook:** `use-analytics.ts`
-
-#### Messaging System ✅
-
-- ✅ **Messages** (`/dashboard/messages/page.tsx`)
- - Full real-time messaging between coaches and players
- - Conversation list with unread counts
- - Chat window with message history
- - New conversation modal
- - Mobile-responsive (split view on desktop, single view on mobile)
- - URL-based conversation selection (`?conversation=id`)
- - **Components:** ConversationList, ChatWindow, EmptyChatState, NewMessageModal
- - **Server Actions:** `createConversation`, `sendMessage`
- - **Hooks:** `use-messages.ts` (useConversations, useMessages)
-
-#### Video Management ✅
-
-- ✅ **Videos** (`/dashboard/videos/page.tsx`)
- - Video upload with drag-and-drop (Supabase Storage)
- - Video library grid view
- - Search videos by title or player name
- - Video player modal
- - Delete videos with confirmation
- - Coach view: See all team player videos
- - Player view: Personal video library
- - **Components:** VideoUpload, VideoPlayer
- - **Database:** `videos` table
-
-#### Camps ✅
-
-- ✅ **Camps** (`/dashboard/camps/page.tsx`)
- - Coach view: Create, edit, delete camps
- - Player view: Browse camps, register/unregister
- - Camp cards with date, location, capacity, price
- - Registration tracking
- - Filter by status (upcoming, past)
- - **Component:** CreateCampModal
- - **Database:** `camps`, `camp_registrations` tables
-
-#### Calendar & Events ✅
-
-- ✅ **Calendar** (`/dashboard/calendar/page.tsx`)
- - Full calendar view of team events
- - Create, edit, delete events
- - Event types: game, practice, tournament, camp, showcase, team_meeting
- - Team-specific events
- - **Database:** `coach_calendar_events` table
-
-#### Team Management (HS/JUCO Coaches) ✅
-
-- ✅ **Roster** (`/dashboard/roster/page.tsx`)
- - View team members with full details
- - Search by name, position, grad year
- - Generate team invite links
- - Jersey number assignment
- - Player status badges (recruiting active vs team only)
- - **Component:** InviteModal
- - **Database:** `teams`, `team_members`, `team_invitations`
-
-- ✅ **Team Dashboard** (`/dashboard/team/page.tsx`)
- - Team-specific view for HS/Showcase coaches
- - Team stats and roster overview
-
-#### Settings ✅
-
-- ✅ **Settings** (`/dashboard/settings/page.tsx`)
- - Account settings
- - Profile settings
- - Privacy settings (`/settings/privacy/page.tsx`)
- - **Component:** PrivacySettingsForm
-
-- ✅ **Program Profile** (`/dashboard/program/page.tsx`)
- - Edit organization details
- - School name, website, division, conference
- - Location (city, state)
- - About program description
- - Brand colors (primary, secondary)
-
-### Shared Systems ✅
-
-- ✅ **Navigation**
- - Dynamic sidebar with role-based navigation
- - Mode toggle for JUCO coaches (recruiting vs team)
- - Team switcher for multi-team players
- - **Components:** Sidebar, Header, ModeToggle, TeamSwitcher
-
-- ✅ **Authentication Store**
- - Zustand store for auth state
- - `useAuth` hook with user, coach, player, loading
- - **File:** `stores/auth-store.ts`, `hooks/use-auth.ts`
-
-- ✅ **Route Protection**
- - Recruiting route protection (college/JUCO coaches only)
- - Team route protection (HS/JUCO/Showcase coaches)
- - **Hook:** `use-route-protection.ts`
-
-- ✅ **Database Queries**
- - Centralized query functions for players, coaches, teams, watchlist
- - **Files:** `lib/queries/players.ts`, `coaches.ts`, `teams.ts`, `watchlist.ts`
-
-- ✅ **UI Component Library**
- - 40+ reusable components in `components/ui/`
- - Button, Card, Input, Select, Badge, Avatar, Modal, Toast, etc.
- - **Design system:** Kelly Green (#16A34A) + Cream White (#FAF6F1)
- - Glass morphism effects, subtle animations
-
----
-
-## 2. PARTIALLY BUILT FEATURES
-
-### Needs Completion (has code but incomplete)
-
-#### College Interest Tracking (HS/JUCO Coaches) ⚠️
-**File:** `/dashboard/college-interest/page.tsx`
-- Shows which college coaches are viewing players on their roster
-- **Missing:** Full engagement event tracking
-- **Missing:** Detailed analytics per player
-
-#### Developmental Plans (HS/JUCO Coaches) ⚠️
-**File:** `/dashboard/dev-plans/page.tsx`
-- Create dev plans for players
-- **Missing:** Drill library
-- **Missing:** Progress tracking
-- **Missing:** Player view (`/dev-plan/page.tsx` exists but needs integration)
-
-#### Colleges Discovery (Players) ⚠️
-**File:** `/dashboard/colleges/page.tsx`
-- Browse colleges/universities
-- **Missing:** Filter by division, conference, location
-- **Missing:** Save to "dream schools"
-- **Component exists:** DreamSchoolsManager (partially built)
-
-#### Academics Tracking (JUCO) ⚠️
-**File:** `/dashboard/academics/page.tsx`
-- Track academic progress
-- **Missing:** Full implementation (stub exists)
-- **Missing:** Database schema for academic records
-
-#### Teams Management (Showcase Coaches) ⚠️
-**File:** `/dashboard/teams/page.tsx`
-- Manage multiple teams
-- Create, edit teams
-- **Missing:** Team switcher integration
-- **Missing:** Per-team dashboards
-
-#### Events (Showcase) ⚠️
-**File:** `/dashboard/events/page.tsx`
-- Showcase events (tournaments, showcases)
-- **Missing:** Event registration
-- **Missing:** Event analytics
-
-#### Player Public Profiles ⚠️
-**File:** `/baseball/(public)/player/[id]/page.tsx`
-- Public-facing player profiles
-- **Implemented:** Basic layout, stats display
-- **Missing:** Privacy settings enforcement (recruiting activated vs not)
-- **Missing:** Video embeds
-- **Missing:** Achievement/honors display
-
-#### Program Public Profiles ⚠️
-**File:** `/baseball/(public)/program/[id]/page.tsx`
-- Public-facing program profiles
-- **Implemented:** Basic structure
-- **Missing:** Full content display
-- **Missing:** SEO optimization
-
-#### Recruiting Activation Flow ⚠️
-**File:** `/dashboard/activate/page.tsx`
-- Player activates recruiting profile
-- **Implemented:** Basic activation
-- **Missing:** Privacy settings review modal
-- **Missing:** Terms acceptance
-- **Missing:** Benefits explanation
-
----
-
-## 3. MISSING FEATURES (documented but no code)
-
-### Per CLAUDE.md Requirements
-
-#### High School Coach - NOT IMPLEMENTED ❌
-According to CLAUDE.md Section 4.1, HS Coaches should have:
-- ❌ **Dashboard (team)** - NOT BUILT (redirects to `/dashboard/team` which is generic)
-- ⚠️ **Roster** - Partially works (generic implementation, not HS-specific)
-- ⚠️ **Video Library** - Generic, not HS-coach-specific
-- ⚠️ **Dev Plans** - Partially built
-- ⚠️ **College Interest** - Partially built
-- ❌ **Team Join Links** - Invite modal exists but not HS-specific
-- ⚠️ **Calendar** - Generic implementation
-- ✅ **Messages** - Works
-
-**Missing HS Coach Features:**
-- Team-specific dashboard with HS metrics
-- Player development tracking
-- College recruiting interest notifications
-- Parent communication portal
-- Academic tracking for HS players
-
-#### JUCO Coach - MODE TOGGLE NOT IMPLEMENTED ❌
-According to CLAUDE.md Section 5.3, JUCO coaches should have:
-- ❌ **Mode Toggle** (Recruiting ↔ Team) - NOT BUILT
-- Should dynamically switch sidebar between recruiting mode and team mode
-- **Currently:** No mode toggle component exists
-- **Impact:** JUCO coaches cannot access recruiting features
-
-**Missing JUCO Coach Features:**
-- Mode toggle UI (ModeToggle component exists but not integrated)
-- Dual dashboard (recruiting + team)
-- Academics tracking (stub exists)
-- Transfer tracking
-
-#### Showcase Coach - MULTI-TEAM NOT IMPLEMENTED ❌
-According to CLAUDE.md Section 5.4, Showcase coaches should have:
-- ⚠️ **Teams listing** - Partially built (`/dashboard/teams/page.tsx`)
-- ❌ **Team switcher dropdown** - NOT IMPLEMENTED
-- ❌ **Per-team roster** - NOT BUILT (no `/team/[id]/roster` route)
-- ❌ **Per-team videos** - NOT BUILT
-- ❌ **Per-team calendar** - NOT BUILT
-- ⚠️ **Events management** - Partially built
-
-**Missing Showcase Coach Features:**
-- Organization-level dashboard
-- Multi-team switcher
-- Per-team isolated views
-- Showcase event management
-
-#### Player - Multi-Team Support NOT IMPLEMENTED ❌
-According to CLAUDE.md Section 3.4, players should support:
-- ❌ **Multi-team membership** (1 HS + 1 Showcase, etc.)
-- ❌ **Team toggle dropdown**
-- Currently only supports single team
-
-#### Player - Recruiting Activation Features INCOMPLETE ⚠️
-- ⚠️ **Anonymous vs Identified Interest** - Partially implemented
- - Database tracks `recruiting_activated` boolean
- - **Missing:** UI to show "A D1 coach viewed" vs "Coach John Smith from Texas A&M viewed"
-
-#### Video Clipping Tool - NOT IMPLEMENTED ❌
-According to CLAUDE.md Section 6.6:
-- ❌ **Video clip editor** - NOT BUILT
-- ❌ **Clip timeline scrubber**
-- ❌ **Set start/end times**
-- ❌ **Save clips as separate videos**
-- **Database:** `videos` table has `is_clip` and `parent_video_id` fields but no UI
-
-#### Player Comparison - INCOMPLETE ⚠️
-**Current:** `/dashboard/compare/page.tsx` exists and works
-**Missing:**
-- ⚠️ Radar chart overlay (component exists: PlayerComparison, but radar chart not implemented)
-- ❌ Save comparisons feature (`player_comparisons` table exists in schema but no code)
-- ❌ Export comparison to PDF
-
-#### Notifications System - NOT IMPLEMENTED ❌
-**Database:** `notifications` table exists in schema
-**Missing:**
-- ❌ Notification bell component (NotificationCenter component exists but not integrated)
-- ❌ Real-time notifications (Supabase Realtime not set up)
-- ❌ Email notifications
-- ❌ Push notifications
-
-#### Search System - INCOMPLETE ⚠️
-**Current:** Search exists in Discover, Compare, Messages
-**Missing:**
-- ❌ Global search (Command Palette component exists but not fully wired)
-- ❌ Saved searches (database field exists, UI partially built)
-- ❌ Search history
-
----
-
-## 4. DEAD CODE & UNUSED FILES
-
-### Unused Components
-- ✅ `components/panels/PeekPanelRoot.tsx` - Not used anywhere
-- ✅ `components/panels/PlayerPeekPanel.tsx` - Peek panel system not integrated
-- ✅ `components/panels/SchoolPeekPanel.tsx` - Peek panel system not integrated
-- ⚠️ `components/features/video-upload.tsx` - Used in videos page
-- ⚠️ `components/features/us-map.tsx` - Used in dashboard
-- ✅ `components/CommandPalette.tsx` - Exists but not integrated into layout
-- ✅ `components/features/notification-center.tsx` - Exists but not used
-
-### Deprecated/Old Files
-- ❌ `components/coach/discover/USAMap.tsx` - Duplicate of `features/us-map.tsx`
-- ❌ `components/coach/pipeline/PipelineBoard.tsx` - Older implementation, replaced by new board
-- ❌ `components/coach/pipeline/PipelineColumn.tsx` - Old version (new one in features/)
-- ❌ `components/coach/pipeline/PlayerCard.tsx` - Old version (new one in features/)
-
-### Golf Platform Files (Separate App)
-The entire `src/app/golf/` and `src/app/player-golf/` directories are a **separate application**:
-- 18 TypeScript files
-- Golf team management system
-- Shot tracking component
-- Round management
-- **NOT documented in CLAUDE.md**
-- **Appears to be experimental/parallel project**
-
-**Golf App Pages:**
-- `/golf/dashboard/` - Full golf coach dashboard
-- `/player-golf/` - Golf player dashboard
-- `/player-golf/rounds/` - Round management
-- `/player-golf/rounds/[id]/play/` - Shot tracking
-- Golf-specific components: `GolfNav.tsx`, `GolfSidebar.tsx`, `ShotTracking.tsx`
-
-**Recommendation:** Move golf app to separate repository or clearly document dual-app structure.
-
-### Test/Dev Files
-- `src/app/dev/page.tsx` - Dev utilities
-- `src/app/dev-golf/page.tsx` - Golf dev page
-- `src/app/test-shot-tracking/page.tsx` - Golf shot tracking test
-- `src/lib/dev-mode.ts` - Dev mode utilities
-- `src/lib/golf-dev-mode.ts` - Golf dev utilities
-- `src/lib/test-connection.ts` - Supabase connection test
-
----
-
-## 5. DATABASE vs CODE ANALYSIS
-
-### Tables with Full Implementation ✅
-- `users` - Auth integration complete
-- `players` - Full CRUD, profile management
-- `coaches` - Full CRUD, profile management
-- `organizations` - Linked to schools/programs
-- `watchlists` - Full watchlist system
-- `conversations` - Messaging system
-- `messages` - Real-time messaging
-- `videos` - Video upload/management (clipping not implemented)
-- `camps` - Camp management
-- `camp_registrations` - Registration tracking
-- `teams` - Basic team management
-- `team_members` - Roster management
-- `team_invitations` - Invite link system
-- `coach_calendar_events` - Calendar system
-
-### Tables with Partial Implementation ⚠️
-- `recruiting_interests` - Table exists, partially used in Journey
-- `player_settings` - Table exists, privacy settings partial
-- `player_metrics` - Table exists, not fully populated
-- `player_achievements` - Table exists, no UI
-- `developmental_plans` - Table exists, partial UI
-- `player_stats` - Table exists, no game stats tracking
-- `evaluations` - Table exists, no evaluation system
-- `team_coach_staff` - Multi-coach support not fully implemented
-- `player_engagement_events` - Tracking exists, analytics incomplete
-
-### Tables with NO Implementation ❌
-- `notifications` - Table in schema, no notification system
-- `player_comparisons` - Table in schema, no save feature
-- `saved_searches` - Field exists, no full implementation
-- `video_library` - Unclear if needed (videos table handles this)
-
----
-
-## 6. CRITICAL ISSUES & BUGS
-
-### Type System Issues ✅ RESOLVED
-According to `CLAUDE.md`, these were previous issues:
-- ✅ Types centralized in `lib/types/index.ts`
-- ✅ Correct table names used (`watchlists` not `recruit_watchlist`)
-- ✅ Pipeline stages correctly limited to 5 values
-- ✅ Supabase client imports correct
-
-### Current Issues
-
-#### 1. Golf App Integration ⚠️
-- Golf app coexists with baseball app in same codebase
-- No routing isolation
-- Shared middleware but different auth flows
-- **Recommendation:** Separate apps or use subdomains
-
-#### 2. Mode Toggle Not Implemented ❌
-- JUCO coaches cannot switch between recruiting and team modes
-- Component `ModeToggle.tsx` exists but not integrated
-- **Impact:** JUCO coaches see wrong dashboard
-
-#### 3. Multi-Team Support Incomplete ❌
-- Players can only join one team currently
-- No team switcher dropdown
-- **Impact:** Showcase players cannot join HS team simultaneously
-
-#### 4. Recruiting Activation Privacy ⚠️
-- Activation works, but privacy settings not enforced
-- Anonymous interest ("A coach viewed") vs Identified ("Coach John Smith viewed") not differentiated in UI
-- **Impact:** Privacy model not fully realized
-
-#### 5. Organization Settings TODO ❌
-**File:** `src/app/baseball/actions/profile-settings.ts:71-72`
-```typescript
-// TODO: Implement when organization_settings table is created
-throw new Error('Not implemented');
-```
-**Impact:** Organization-level settings cannot be updated
-
-#### 6. Dead Code in Production 🧹
-- Unused components should be removed or clearly marked as WIP
-- Duplicate implementations (old vs new pipeline components)
-
----
-
-## 7. ARCHITECTURE STRENGTHS
-
-### What's Working Well ✅
-
-1. **Clean Separation of Concerns**
- - Server Components for data fetching
- - Client Components for interactivity
- - Server Actions for mutations
- - Clear file organization
-
-2. **Robust Auth System**
- - Supabase Auth integration
- - Role-based routing
- - Route protection hooks
- - Onboarding flows
-
-3. **Excellent UI/UX**
- - Beautiful Bento grid dashboards
- - Glass morphism effects
- - Responsive design
- - Smooth animations
- - Consistent design system (Kelly Green + Cream)
-
-4. **Real-time Features**
- - Messaging system works great
- - Watchlist updates in real-time
-
-5. **Type Safety**
- - TypeScript throughout
- - Strong typing with database types
- - No `any` types (mostly)
-
-6. **Reusable Components**
- - 40+ UI components
- - Feature components well-organized
- - Easy to extend
-
----
-
-## 8. RECOMMENDATIONS
-
-### Immediate Priorities (Week 1-2)
-
-1. **Remove Golf App** or clearly separate it
- - Move to `/apps/golf/` or separate repo
- - Update documentation to reflect dual-app structure
-
-2. **Implement JUCO Mode Toggle**
- - Wire up `ModeToggle` component
- - Create routing logic for mode switching
- - Separate recruiting and team dashboards for JUCO
-
-3. **Complete HS Coach Dashboard**
- - Build HS-specific team dashboard
- - Add HS-specific features (parent portal prep)
-
-4. **Implement Multi-Team Support**
- - Allow players to join 2 teams (HS + Showcase)
- - Build team switcher dropdown
- - Isolate team contexts
-
-5. **Remove Dead Code**
- - Delete unused peek panel components
- - Remove duplicate pipeline components
- - Clean up test/dev files
-
-### Medium-term Priorities (Week 3-4)
-
-6. **Complete Video Clipping**
- - Build clip editor UI
- - Timeline scrubber
- - Save clips as separate video records
-
-7. **Implement Notifications**
- - Build notification system
- - Real-time with Supabase Realtime
- - Email notifications
-
-8. **Complete Player Comparison**
- - Add radar chart overlay
- - Save comparison feature
- - Export to PDF
-
-9. **Showcase Coach Multi-Team**
- - Organization dashboard
- - Per-team routing (`/coach/showcase/team/[id]/...`)
- - Team switcher
-
-10. **Privacy & Recruiting Activation**
- - Anonymous vs Identified interest UI
- - Privacy settings enforcement
- - Activation flow improvements
-
-### Long-term Priorities (Week 5+)
-
-11. **Developmental Plans**
- - Complete drill library
- - Progress tracking
- - Player goal setting
-
-12. **Academics Tracking**
- - Build academic records system
- - GPA tracking over time
- - Transcripts upload
-
-13. **College Interest Tracking**
- - Full engagement analytics
- - Notifications when coaches view players
-
-14. **Advanced Search**
- - Global search with Command Palette
- - Saved searches
- - Search history
-
-15. **Mobile App**
- - React Native or PWA
- - Push notifications
-
----
-
-## 9. DOCUMENTATION GAP ANALYSIS
-
-### Documented in CLAUDE.md but Not Built
-- JUCO mode toggle (Section 5.3)
-- Showcase multi-team (Section 5.4)
-- Player multi-team (Section 3.4)
-- Video clipping (Section 6.6)
-- Anonymous interest (Section 3.3)
-- Saved comparisons (Section 6.7)
-- Dev plan drills (Section 6.8)
-
-### Built but Not Documented
-- Golf platform (entire app)
-- Command Palette
-- Peek Panels
-- Bento grid dashboard design
-- Glass morphism UI patterns
-- Most hooks (`use-dashboard.ts`, `use-journey.ts`, etc.)
-
-### Needs Better Documentation
-- Server Actions usage patterns
-- Database query patterns
-- Hook creation guidelines
-- Component composition patterns
-- Routing conventions
-
----
-
-## 10. FINAL ASSESSMENT
-
-### Overall Progress: 65% Complete
-
-| Area | Completion | Notes |
-|------|------------|-------|
-| **College Coach** | 95% | Nearly complete, missing saved comparisons and advanced analytics |
-| **HS Coach** | 40% | Basic roster works, needs team dashboard and dev plans |
-| **JUCO Coach** | 30% | No mode toggle, academics incomplete |
-| **Showcase Coach** | 35% | Multi-team not implemented |
-| **Player (HS/Showcase)** | 70% | Core features work, missing multi-team and clips |
-| **Player (JUCO)** | 60% | Missing transfer tracking |
-| **Player (College)** | 80% | Team-only view mostly complete |
-| **Infrastructure** | 85% | Auth, routing, DB queries solid |
-| **UI/UX** | 90% | Beautiful, consistent, responsive |
-| **Real-time** | 60% | Messaging works, notifications missing |
-
-### Code Quality: B+ (Very Good)
-
-**Strengths:**
-- Clean TypeScript
-- Good component organization
-- Strong type safety
-- Consistent patterns
-- Excellent UI design
-
-**Weaknesses:**
-- Dead code present
-- Incomplete features scattered
-- Golf app confusion
-- Missing documentation for new patterns
-
-### Next Steps
-
-**If continuing development:**
-1. Choose: Remove golf or separate it
-2. Implement JUCO mode toggle (highest priority per docs)
-3. Complete HS coach dashboard
-4. Add multi-team support
-5. Clean up dead code
-6. Complete partially-built features before starting new ones
-
-**If pivoting:**
-- Baseball platform has strong MVP foundation
-- College coach recruiting suite is production-ready
-- Player profiles and journey tracking are solid
-- Could launch beta with current College Coach + Player features
-
----
-
-## APPENDIX A: File Counts
-
-| Directory | Files | Notes |
-|-----------|-------|-------|
-| `src/app/baseball/` | 72 | Baseball platform routes |
-| `src/app/golf/` | 18 | Golf platform routes (separate app) |
-| `src/app/player-golf/` | 11 | Golf player routes |
-| `src/components/` | 86 | 40 UI + 46 feature/layout components |
-| `src/hooks/` | 16 | Custom React hooks |
-| `src/lib/` | 18 | Utilities, queries, types, Supabase clients |
-| `src/stores/` | 1 | Zustand auth store |
-| **TOTAL** | **238** | TypeScript files |
-
-## APPENDIX B: Route Inventory
-
-### Baseball Routes (72 files)
-- `/baseball/dashboard/` (main dashboard)
-- `/baseball/dashboard/discover/` (player discovery)
-- `/baseball/dashboard/watchlist/` (recruiting watchlist)
-- `/baseball/dashboard/pipeline/` (recruiting pipeline)
-- `/baseball/dashboard/compare/` (player comparison)
-- `/baseball/dashboard/messages/` (messaging)
-- `/baseball/dashboard/camps/` (camps)
-- `/baseball/dashboard/videos/` (videos)
-- `/baseball/dashboard/calendar/` (calendar)
-- `/baseball/dashboard/roster/` (team roster)
-- `/baseball/dashboard/team/` (team dashboard)
-- `/baseball/dashboard/journey/` (player recruiting journey)
-- `/baseball/dashboard/colleges/` (college discovery)
-- `/baseball/dashboard/analytics/` (analytics)
-- `/baseball/dashboard/profile/` (profile editing)
-- `/baseball/dashboard/settings/` (settings)
-- `/baseball/dashboard/activate/` (recruiting activation)
-- `/baseball/dashboard/dev-plans/` (dev plans - coach)
-- `/baseball/dashboard/dev-plan/` (dev plan - player)
-- `/baseball/dashboard/college-interest/` (college interest tracking)
-- `/baseball/dashboard/academics/` (academics)
-- `/baseball/dashboard/teams/` (showcase teams)
-- `/baseball/dashboard/events/` (showcase events)
-- `/baseball/dashboard/program/` (program profile)
-- `/baseball/dashboard/players/[id]/` (player detail)
-- `/baseball/(public)/player/[id]/` (public player profile)
-- `/baseball/(public)/program/[id]/` (public program profile)
-- `/baseball/(auth)/login/` (login)
-- `/baseball/(auth)/signup/` (signup)
-- `/baseball/(onboarding)/player/` (player onboarding)
-- `/baseball/(onboarding)/coach/` (coach onboarding)
-
-### Golf Routes (29 files)
-- `/golf/dashboard/` (10 pages)
-- `/player-golf/` (11 pages)
-- Separate app - not documented
-
----
-
-**End of Analysis**
diff --git a/.taskmaster/docs/feature-checklist.md b/.taskmaster/docs/feature-checklist.md
deleted file mode 100644
index eadb4a063..000000000
--- a/.taskmaster/docs/feature-checklist.md
+++ /dev/null
@@ -1,2296 +0,0 @@
-# Helm Sports Labs - Feature Implementation Checklist
-**Generated:** December 22, 2024
-**Source:** Analysis of `/Users/ricknini/Downloads/helmv3`
-**Total Features:** 100+ items tracked
-
----
-
-## TABLE OF CONTENTS
-1. [Completed Features (55 items)](#completed-features)
-2. [In-Progress Features (17 items)](#in-progress-features)
-3. [Planned Features (35 items)](#planned-features)
-4. [Technical Debt (5 items)](#technical-debt)
-5. [Quick Stats](#quick-stats)
-
----
-
-## COMPLETED FEATURES ✅
-**Status:** 55/100+ features complete (55%)
-
-### Authentication & User Management (3/3) ✅
-
-- [x] **AUTH-001: User Authentication System**
- - **Location:** `/baseball/(auth)/login`, `/baseball/(auth)/signup`
- - **Implementation:** 100% complete
- - **Details:**
- - Email/password authentication via Supabase Auth
- - Role-based signup (Coach vs Player)
- - Protected routes with middleware at `src/middleware.ts`
- - Session management and token refresh
- - Password reset flow
- - Email verification
- - **Components:** `LoginForm`, `SignupForm`, `AuthProvider`
- - **Database:** `users` table linked to Supabase Auth
- - **Testing:** Manual QA passed, production-ready
-
-- [x] **AUTH-002: Player Onboarding Flow**
- - **Location:** `/baseball/(onboarding)/player/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - 5-step wizard with progress indicator
- - Step 1: Basic Info (name, email, phone, city, state)
- - Step 2: Baseball Info (position, graduation year, bats/throws)
- - Step 3: Physical/School (height, weight, high school)
- - Step 4: Metrics (pitch velo, exit velo, 60-yard time, GPA)
- - Step 5: Profile/Goals (avatar, video, about, dream schools)
- - Form validation on each step
- - Creates player profile and links to Supabase Auth user
- - **Components:** `PlayerOnboarding`, `OnboardingSteps`, `AvatarUpload`
- - **Database:** Inserts into `players` table, updates `users` table
- - **Server Actions:** `createPlayerProfile`
- - **Testing:** All steps validated, redirect to dashboard works
-
-- [x] **AUTH-003: Coach Onboarding Flow**
- - **Location:** `/baseball/(onboarding)/coach/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - 4-step wizard with progress indicator
- - Step 1: Personal Info (name, email, phone, title)
- - Step 2: Program Info (school name, division, conference, location)
- - Step 3: Program Details (logo, colors, about, philosophy)
- - Step 4: Preferences (what we look for, values, contact prefs)
- - Creates coach record, organization, and initial team
- - Links to Supabase Auth user
- - **Components:** `CoachOnboarding`, `OnboardingSteps`, `LogoUpload`
- - **Database:** Inserts into `coaches`, `organizations`, `teams` tables
- - **Server Actions:** `createCoachProfile`, `createOrganization`, `createTeam`
- - **Testing:** All coach types tested (College, HS, JUCO, Showcase)
-
----
-
-### College Coach - Recruiting Suite (5/5) ✅
-
-- [x] **RECRUIT-001: Player Discovery System**
- - **Location:** `/dashboard/discover/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - Advanced filtering system with real-time URL params
- - Filters: Graduation year, Position, State, Min/Max velocity, Min/Max exit velo, Min GPA
- - Search by player name or high school name
- - Pagination: 24 players per page with prev/next navigation
- - USA Map visualization with clickable states for filtering
- - Filter panel toggles open/closed on mobile
- - Shows only recruiting-activated players (`recruiting_activated = true`)
- - Watchlist integration: Add/remove players from cards
- - Player cards show: Avatar, name, position, grad year, school, location, key stats
- - Empty state when no results found
- - **Components:**
- - `FilterPanel` - Collapsible filter sidebar
- - `DiscoverResults` - Grid layout with player cards
- - `PlayerCard` - Individual player card with watchlist button
- - `PlayerCardGrid` - Responsive grid container
- - `USAMap` - Interactive SVG map with state click handlers
- - **Database Queries:**
- - `getDiscoverPlayers` - Supports all filters, pagination
- - Joins: `players` + `player_videos` (thumbnail) + `player_metrics`
- - **Server Actions:** `addToWatchlist`, `removeFromWatchlist`
- - **URL Params:** `gradYear`, `position`, `state`, `minVelo`, `maxVelo`, `minExitVelo`, `maxExitVelo`, `minGPA`, `search`, `page`
- - **Testing:** All filters tested, map interaction works, pagination stable
-
-- [x] **RECRUIT-002: Recruiting Watchlist Management**
- - **Location:** `/dashboard/watchlist/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - Full CRUD operations on watchlist
- - Table view with sortable columns
- - Player columns: Avatar, Name, Position, Grad Year, School, Location, Stats
- - Inline status dropdown (5 pipeline stages)
- - watchlist (default)
- - high_priority (hot prospect)
- - offer_extended (offer sent)
- - committed (player committed)
- - uninterested (passed on player)
- - Inline notes editing with auto-save
- - Filter tabs by status (All, Watchlist, High Priority, Offer Extended, Committed)
- - Secondary filters: Position dropdown, Grad year dropdown
- - Bulk selection: Checkbox column, "Select All" toggle
- - Bulk actions: Bulk remove with confirmation modal
- - Player detail modal: Click row to view full player profile
- - Real-time updates when status changes
- - Empty state with CTA to Discover page
- - **Components:**
- - `WatchlistTable` - Main table component
- - `WatchlistRow` - Individual row with inline editing
- - `PlayerDetailModal` - Full player profile in modal
- - `BulkActionsBar` - Actions for selected players
- - **Database:** `watchlists` table
- - **Server Actions:**
- - `removeFromWatchlist(playerId)` - Remove single player
- - `updateWatchlistStatus(playerId, status)` - Update pipeline stage
- - `addWatchlistNote(playerId, note)` - Save notes
- - `bulkRemoveFromWatchlist(playerIds)` - Remove multiple
- - **Hooks:** `use-watchlist.ts` - useWatchlist, useWatchlistMutations
- - **Testing:** All CRUD operations verified, bulk actions work, real-time updates confirmed
-
-- [x] **RECRUIT-003: Recruiting Pipeline Board**
- - **Location:** `/dashboard/pipeline/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - Drag-and-drop kanban board with 5 columns
- - Columns: Watchlist → High Priority → Offer Extended → Committed → Uninterested
- - Uses `@dnd-kit/core` for smooth drag-and-drop interactions
- - Drag handles on cards for easy grabbing
- - Visual feedback: Card shadow on drag, column highlight on hover
- - Graduation year filter dropdown (filters all columns)
- - Real-time stage updates: Dragging card updates database immediately
- - Card details: Avatar, name, position, grad year, key stats, notes preview
- - Click card to open player detail modal
- - Empty state in each column with CTA to Discover
- - Column counts show total players in each stage
- - Mobile-responsive: Horizontal scroll on mobile
- - **Components:**
- - `PipelineBoard` - Main board container with DnD context
- - `PipelineColumn` - Individual column with drop zone
- - `PipelineCard` - Draggable player card
- - `DragOverlay` - Shows card while dragging
- - **Database:** Updates `watchlists.status` on drop
- - **Server Actions:** `updateWatchlistStatus(playerId, newStatus)`
- - **DnD Library:** `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`
- - **Hooks:** `use-pipeline.ts` - usePipeline, handleDragEnd
- - **Testing:** Drag-drop tested across all columns, updates persist, mobile scroll works
-
-- [x] **RECRUIT-004: Player Comparison Tool**
- - **Location:** `/dashboard/compare/page.tsx`
- - **Implementation:** 100% complete (basic version)
- - **Details:**
- - Side-by-side comparison of 2-4 players
- - Search and add players: Autocomplete search with player suggestions
- - Player removal: Click X to remove from comparison
- - URL-based state management: `?players=id1,id2,id3`
- - Shareable links: Copy URL to share comparison
- - Comparison table with metrics:
- - Physical: Height, Weight, Position, Bats/Throws
- - Performance: Pitch Velo, Exit Velo, 60-Yard Time
- - Academic: GPA, High School
- - Recruiting: Grad Year, Location, Dream Schools
- - Visual stat bars: Progress bars for numeric metrics
- - Player avatars in header
- - Responsive layout: Stacks vertically on mobile
- - Empty state: "Add players to compare" when < 2 players
- - **Components:**
- - `PlayerComparison` - Main comparison container
- - `PlayerSelector` - Search and add players
- - `ComparisonTable` - Side-by-side metrics table
- - **Database Queries:** `getPlayersByIds(playerIds)`
- - **URL Management:** Next.js `useSearchParams`, `useRouter`
- - **Testing:** 2, 3, 4 player comparisons tested, URL sharing works
- - **Notes:** Radar chart overlay planned for future (FEATURE-003)
-
-- [x] **RECRUIT-005: College Coach Dashboard**
- - **Location:** `/dashboard/page.tsx` (when user is College coach)
- - **Implementation:** 100% complete
- - **Details:**
- - Beautiful Bento Grid layout with glass morphism cards
- - Layout: 12-column grid with varied card sizes for visual interest
- - **Pipeline Stats Card (4-stat grid):**
- - Watchlist count
- - High Priority count
- - Offers Extended count
- - Committed count
- - Each stat shows icon, number, label, trend (e.g., "+3 this week")
- - **Profile Views Card:**
- - Total profile views (how many times coaches viewed by players)
- - Chart: 7-day line chart with views per day
- - **Messages Stats Card:**
- - Unread messages count
- - Total conversations count
- - Quick action button: "View Messages"
- - **Recent Players Card:**
- - Last 5 players added to watchlist
- - Player avatars, names, positions, grad years
- - Click to view player detail
- - **Engagement Chart Card (large):**
- - 7-day engagement line chart
- - Metrics: Profile views, Watchlist adds, Messages sent
- - Recharts LineChart with multiple series
- - Tooltips with date and values
- - **Activity Feed Card:**
- - Last 8 engagement events
- - Event types: Player added to watchlist, Status changed, Note added, Message sent
- - Timestamps: "2 hours ago", "Yesterday", etc.
- - Avatar + description for each event
- - **Upcoming Events Card:**
- - Next 5 events from calendar
- - Event types: Camps, Games, Showcases
- - Date, time, location for each
- - Link to full calendar
- - **USA Map Card:**
- - Player distribution by state
- - Shows count of watchlisted players per state
- - Interactive: Click state to view players from that state
- - **Quick Actions Card:**
- - 4 large action buttons:
- - Discover Players
- - View Messages
- - Check Calendar
- - Edit Program
- - **Auto-Redirect:** HS/Showcase coaches redirected to `/dashboard/team`
- - **Components:**
- - `CoachDashboard` - Main container
- - `BentoGrid` - Grid layout system
- - `StatCard` - Individual stat cards with glass effect
- - `EngagementChart` - Recharts wrapper
- - `ActivityFeed` - Event list
- - `USAMapWidget` - Map with state counts
- - **Database Queries:**
- - `getDashboardStats(coachId)` - Pipeline counts
- - `getRecentPlayers(coachId, limit: 5)` - Recent watchlist additions
- - `getEngagementData(coachId, days: 7)` - Chart data
- - `getActivityFeed(coachId, limit: 8)` - Recent events
- - `getUpcomingEvents(coachId, limit: 5)` - Calendar events
- - `getPlayerDistribution(coachId)` - Map data
- - **Hooks:** `use-dashboard.ts` - useDashboardData
- - **Charts:** Recharts (LineChart, Tooltip, Legend)
- - **Styling:** Glass morphism with `backdrop-blur-xl`, Tailwind grid
- - **Testing:** All cards render, charts display correctly, quick actions work
-
----
-
-### Player Features (4/4) ✅
-
-- [x] **PLAYER-001: Player Dashboard**
- - **Location:** `/dashboard/page.tsx` (when user is Player)
- - **Implementation:** 100% complete
- - **Details:**
- - Bento Grid layout optimized for player experience
- - **Profile Card (large):**
- - Avatar with edit button overlay
- - Name, primary position, secondary position
- - Graduation year badge
- - High school name and location (city, state)
- - Profile completion percentage badge
- - Quick edit button → `/dashboard/profile`
- - **Stats Grid (4 cards):**
- - Profile Views: Total views by college coaches
- - On Watchlists: Count of coaches who added player
- - Messages: Unread messages count
- - Video Views: Total video plays
- - Each card shows large number + label + icon
- - **Your Stats Card:**
- - Physical: Height, Weight
- - Performance: Pitch Velocity, Exit Velocity, 60-Yard Time
- - Academic: GPA, SAT/ACT (if provided)
- - Position and Bats/Throws
- - **Quick Actions Card:**
- - Complete Profile (if < 100%)
- - Browse Colleges
- - Check Messages
- - Upload Video
- - **Recruiting Activation Banner:**
- - Shows if `recruiting_activated = false` AND player type ≠ "college"
- - Explains benefits of activating recruiting
- - CTA button: "Activate Recruiting" → `/dashboard/activate`
- - Dismissible (stores preference in `player_settings`)
- - **Recent Activity Feed:**
- - Last 5 events: Profile viewed by coach, Added to watchlist, Message received
- - Anonymous if recruiting not activated: "A D1 coach from Texas viewed your profile"
- - Identified if activated: "Coach John Smith from Texas A&M viewed your profile"
- - **Next Steps Card:**
- - Personalized recommendations based on profile completion
- - Examples: "Add your highlight video", "Set your dream schools", "Update your metrics"
- - **Components:**
- - `PlayerDashboard` - Main container
- - `ProfileCard` - Large profile display
- - `StatsGrid` - 4-stat layout
- - `QuickActions` - Action buttons
- - `RecruitingBanner` - Activation prompt
- - `ActivityFeed` - Recent events
- - **Database Queries:**
- - `getPlayerProfile(userId)` - Player data
- - `getPlayerStats(playerId)` - Metrics
- - `getPlayerEngagement(playerId)` - Views, watchlists
- - `getRecentActivity(playerId, limit: 5)` - Activity events
- - **Hooks:** `use-player-dashboard.ts`
- - **Testing:** All cards render, banner shows/hides correctly, profile completion accurate
-
-- [x] **PLAYER-002: Player Profile Management**
- - **Location:** `/dashboard/profile/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - Full profile editing form with validation
- - **Sections:**
- - **Personal Info:** First name, Last name, Email, Phone, Date of birth
- - **Baseball Info:** Primary position (dropdown), Secondary position (optional), Graduation year (dropdown), Bats (R/L/S), Throws (R/L)
- - **Physical:** Height (ft/in dropdowns), Weight (lbs)
- - **School:** High school name, City, State (dropdown), School website
- - **Metrics:** Pitch velocity (mph), Exit velocity (mph), 60-yard time (sec), GPA (0.0-4.0)
- - **Academic:** SAT score, ACT score, Class rank
- - **About:** Bio/description (textarea, 500 char max)
- - **Contact:** Twitter handle, Instagram handle, Website
- - **Media:** Avatar upload, Primary highlight video URL
- - **Avatar Upload:**
- - Drag-and-drop or file picker
- - Image preview before upload
- - Supabase Storage integration (`avatars` bucket)
- - Automatic resize to 400x400px
- - Supported formats: JPG, PNG, WebP
- - **Form Validation:**
- - Required fields: Name, position, grad year, bats, throws
- - Email format validation
- - Phone format validation
- - GPA range: 0.0-4.0
- - Video URL format validation (YouTube, Vimeo, Hudl)
- - **Save Behavior:**
- - Optimistic updates for instant feedback
- - Server-side validation
- - Success toast: "Profile updated successfully"
- - Error toast: "Failed to update profile"
- - Auto-revalidate dashboard after save
- - **Components:**
- - `ProfileForm` - Main form component
- - `AvatarUpload` - Image upload with preview
- - `PositionSelect` - Position dropdown with icons
- - `GradYearSelect` - Graduation year dropdown
- - `HeightInput` - Feet/inches dual input
- - **Database:** Updates `players` table
- - **Server Actions:** `updatePlayerProfile(playerId, data)`
- - **Storage:** `avatars/players/{userId}/{filename}`
- - **Testing:** All fields save correctly, avatar upload works, validation prevents bad data
-
-- [x] **PLAYER-003: Recruiting Journey Tracker**
- - **Location:** `/dashboard/journey/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - Track colleges player is interested in
- - **School List:**
- - Grid of school cards (3 columns on desktop, 1 on mobile)
- - Each card shows: School logo, name, division, conference, location
- - Status badge with color coding
- - Last updated timestamp
- - Action buttons: Update status, Remove school
- - **Status Options:**
- - Interested (gray) - Initial interest
- - Researching (blue) - Learning more
- - Contacted (yellow) - Reached out to coach
- - Visited (purple) - Campus visit completed
- - Offered (green) - Received offer
- - Committed (dark green) - Committed to school
- - **Add School Modal:**
- - Search colleges by name, location, division
- - Autocomplete with suggestions
- - Select initial status
- - Add notes (optional)
- - **Timeline View:**
- - Chronological list of journey events
- - Event types: School added, Status changed, Note added, Contact made
- - Timestamps and descriptions
- - Filter by school or date range
- - **Milestones:**
- - First contact
- - First visit
- - First offer
- - Commitment
- - Achievement badges for milestones
- - **Components:**
- - `JourneyTracker` - Main container
- - `SchoolCard` - Individual school card
- - `AddSchoolModal` - Search and add schools
- - `JourneyTimeline` - Event timeline
- - `StatusBadge` - Colored status indicator
- - **Database:**
- - `recruiting_interests` table
- - Columns: player_id, organization_id, status, notes, added_at, updated_at
- - **Database Queries:**
- - `getRecruitingInterests(playerId)` - Get all schools
- - `updateInterestStatus(id, status)` - Update status
- - `addRecruitingInterest(playerId, organizationId, status)` - Add school
- - `removeRecruitingInterest(id)` - Remove school
- - **Server Actions:** `addSchool`, `updateSchoolStatus`, `removeSchool`
- - **Hooks:** `use-journey.ts` - useJourney, useJourneyMutations
- - **Testing:** Add/update/remove schools work, timeline accurate, status changes persist
-
-- [x] **PLAYER-004: Player Analytics Dashboard**
- - **Location:** `/dashboard/analytics/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - Comprehensive analytics for player recruiting activity
- - **Overview Stats (4 cards):**
- - Total Profile Views: Count + 7-day trend
- - Watchlist Adds: Total coaches who added player
- - Video Views: Total plays across all videos
- - Messages Sent: Total messages from coaches
- - **Engagement Chart (large):**
- - 7-day line chart with Recharts
- - Multiple series: Profile views, Watchlist adds, Video views, Messages
- - Interactive tooltips with date and values
- - Legend with color coding
- - Date range selector: 7 days, 30 days, 90 days, All time
- - **Top Schools Viewing (table):**
- - School name, division, view count, last viewed
- - Sorted by view count descending
- - Anonymous if recruiting not activated: "D1 School in Texas"
- - Identified if activated: "Texas A&M University"
- - Click school to view program profile
- - **Activity Breakdown (pie chart):**
- - Profile views by coach type: College (65%), HS (20%), JUCO (10%), Showcase (5%)
- - Recharts PieChart with labels
- - **Geographic Interest (map):**
- - USA map with state highlighting
- - Shows count of coaches per state who viewed profile
- - Click state to see coach list
- - **Video Performance:**
- - Table of videos with view counts
- - Most viewed video highlighted
- - Average watch time (if available)
- - Click video to view
- - **Components:**
- - `AnalyticsDashboard` - Main container
- - `EngagementChart` - Line chart component
- - `TopSchools` - School table
- - `ActivityBreakdown` - Pie chart
- - `GeographicMap` - USA map widget
- - `VideoPerformance` - Video stats table
- - **Database:**
- - `player_engagement_events` table
- - Event types: profile_view, watchlist_add, video_view, message_sent
- - **Database Queries:**
- - `getPlayerEngagement(playerId, dateRange)` - All events
- - `getEngagementStats(playerId)` - Overview stats
- - `getTopSchools(playerId, limit: 10)` - Schools viewing most
- - `getActivityBreakdown(playerId)` - Coach type distribution
- - `getGeographicInterest(playerId)` - State counts
- - `getVideoPerformance(playerId)` - Video view stats
- - **Server Actions:** None (read-only)
- - **Hooks:** `use-analytics.ts` - useAnalytics
- - **Charts:** Recharts (LineChart, PieChart)
- - **Testing:** All charts render, data accurate, date range filter works
-
----
-
-### Messaging System (1/1) ✅
-
-- [x] **MSG-001: Real-time Messaging Platform**
- - **Location:** `/dashboard/messages/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - Full real-time messaging between coaches and players
- - **Layout:**
- - Split view on desktop: Conversation list (left) + Chat window (right)
- - Single view on mobile: List OR chat (toggle)
- - **Conversation List:**
- - All conversations sorted by most recent
- - Each item shows: Other participant avatar, name, role, last message preview, timestamp
- - Unread indicator: Bold text + unread count badge
- - Search conversations by participant name
- - Filter: All, Unread, Archived
- - Click conversation to open chat
- - **Chat Window:**
- - Header: Participant avatar, name, role, online status
- - Message history: Scrollable list with infinite scroll (loads older messages)
- - Message bubbles: Sent (right, green) vs Received (left, gray)
- - Timestamp on each message
- - Input: Text input + Send button
- - Typing indicator: "Coach Smith is typing..."
- - Message status: Sent, Delivered, Read
- - **New Conversation Modal:**
- - Search users by name or school
- - Filter by role (Coaches only, Players only)
- - Select participant and start conversation
- - Pre-fill message (optional)
- - **Real-time Updates:**
- - Supabase Realtime subscriptions
- - New messages appear instantly
- - Unread counts update in real-time
- - Typing indicators in real-time
- - **URL-based Selection:**
- - `?conversation=id` to deep link to specific conversation
- - Shareable conversation links
- - **Components:**
- - `MessagesPage` - Main layout container
- - `ConversationList` - Left sidebar with conversation list
- - `ChatWindow` - Right panel with active chat
- - `EmptyChatState` - Placeholder when no conversation selected
- - `NewMessageModal` - Start new conversation
- - `MessageBubble` - Individual message component
- - `TypingIndicator` - "is typing..." animation
- - **Database:**
- - `conversations` table: id, created_at
- - `conversation_participants` table: conversation_id, user_id, last_read_at
- - `messages` table: id, conversation_id, sender_id, content, sent_at, read_at
- - **Database Queries:**
- - `getConversations(userId)` - All conversations for user
- - `getMessages(conversationId, limit, offset)` - Messages with pagination
- - `getUnreadCount(userId)` - Total unread across all conversations
- - `markAsRead(conversationId, userId)` - Update last_read_at
- - **Server Actions:**
- - `createConversation(participantIds)` - Start new conversation
- - `sendMessage(conversationId, content)` - Send message
- - `markConversationRead(conversationId)` - Mark as read
- - **Hooks:**
- - `use-messages.ts` - useConversations, useMessages, useRealtimeMessages
- - `use-typing-indicator.ts` - useTypingIndicator
- - **Realtime:** Supabase Realtime channel subscription to `messages` table
- - **Testing:** Send/receive works, real-time updates confirmed, mobile responsive
-
----
-
-### Video Management (1/1) ✅
-
-- [x] **VIDEO-001: Video Upload and Library**
- - **Location:** `/dashboard/videos/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - **Video Upload (for Players):**
- - Drag-and-drop or file picker
- - Supported formats: MP4, MOV, AVI (max 500MB)
- - Upload progress bar
- - Supabase Storage integration (`videos` bucket)
- - Automatic thumbnail generation (first frame)
- - Video metadata: Title, description, type (Highlight, Game, At-Bat, Pitch, etc.)
- - Tags: Position-specific, skill-specific
- - Privacy: Public (visible to all coaches) vs Private (invite only)
- - **Video Library Grid:**
- - Grid layout: 3 columns on desktop, 2 on tablet, 1 on mobile
- - Each card shows: Thumbnail, title, duration, upload date, view count
- - Hover effects: Play icon overlay
- - Click to open player modal
- - **Search & Filter:**
- - Search by title or tags
- - Filter by type (Highlight, Game, etc.)
- - Filter by date range
- - Sort: Most recent, Most viewed, Alphabetical
- - **Video Player Modal:**
- - Full-screen video player
- - Controls: Play/pause, volume, seek, fullscreen
- - Video details: Title, description, tags, upload date
- - View count
- - Share button (copy link)
- - Download button (for player's own videos)
- - Delete button (for player's own videos, with confirmation)
- - **Coach View:**
- - See all videos from players on their team
- - See all public videos from watchlisted players
- - Filter by player name
- - Organize into playlists (future feature)
- - **Player View:**
- - Personal video library
- - Edit video details
- - Manage privacy settings
- - See which coaches viewed each video
- - **Components:**
- - `VideoUpload` - Upload form with drag-drop
- - `VideoLibrary` - Grid container
- - `VideoCard` - Individual video card
- - `VideoPlayer` - Video player modal
- - `VideoFilters` - Search and filter panel
- - **Database:**
- - `videos` table
- - Columns: id, player_id, title, description, video_url, thumbnail_url, duration, type, tags, privacy, view_count, uploaded_at
- - **Storage:** `videos/{playerId}/{videoId}.mp4`, `thumbnails/{videoId}.jpg`
- - **Database Queries:**
- - `getPlayerVideos(playerId)` - Player's videos
- - `getTeamVideos(teamId)` - All team videos
- - `getWatchlistVideos(coachId)` - Videos from watchlisted players
- - `incrementViewCount(videoId)` - Track views
- - **Server Actions:**
- - `uploadVideo(file, metadata)` - Upload to storage + create record
- - `updateVideo(videoId, metadata)` - Update details
- - `deleteVideo(videoId)` - Delete from storage + remove record
- - **Hooks:** `use-videos.ts` - useVideos, useVideoUpload
- - **Testing:** Upload works, playback smooth, search/filter functional, deletion works
-
----
-
-### Camps Management (1/1) ✅
-
-- [x] **CAMP-001: Camp Management System**
- - **Location:** `/dashboard/camps/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - **Coach View - Create Camps:**
- - Create Camp modal with form
- - Fields: Camp name, date(s), location (address, city, state), capacity, price, description
- - Upload camp image/logo
- - Set registration deadline
- - Early bird pricing (optional)
- - Age/grad year restrictions
- - Camp type: Hitting, Pitching, Fielding, General, Showcase
- - **Coach View - Manage Camps:**
- - List of all camps (upcoming and past)
- - Camp cards show: Name, date, location, registrations/capacity, revenue
- - Edit button → open pre-filled create modal
- - Delete button → confirmation modal
- - View registrants: List of registered players with contact info
- - Export registrants to CSV
- - Send email to all registrants
- - **Player View - Browse Camps:**
- - Browse all upcoming camps
- - Filter by: Location (state), Price range, Camp type, Date range
- - Sort: Nearest first, Soonest first, Price low-high
- - Camp cards show: School logo, name, date, location, spots left, price
- - Register button → registration modal
- - **Player View - Registration:**
- - Registration modal with player confirmation
- - Guardian info (if player under 18): Name, email, phone
- - Emergency contact
- - Medical info (allergies, conditions)
- - Waiver acceptance checkbox
- - Payment processing (Stripe integration - future)
- - Confirmation email sent
- - **Player View - My Registrations:**
- - List of registered camps
- - Upcoming vs Past tabs
- - Unregister button (if before deadline)
- - Download receipt
- - Add to calendar (ICS file)
- - **Components:**
- - `CreateCampModal` - Camp creation/editing form
- - `CampCard` - Individual camp card
- - `CampList` - Grid of camps
- - `RegisterModal` - Player registration form
- - `RegistrantsList` - List of registered players (coach view)
- - **Database:**
- - `camps` table: id, coach_id, organization_id, name, date_start, date_end, location, capacity, price, description, image_url, registration_deadline, created_at
- - `camp_registrations` table: id, camp_id, player_id, guardian_name, guardian_email, guardian_phone, emergency_contact, medical_info, registered_at, payment_status
- - **Database Queries:**
- - `getCamps(filters)` - All camps with filters
- - `getCoachCamps(coachId)` - Camps created by coach
- - `getPlayerRegistrations(playerId)` - Player's camp registrations
- - `getCampRegistrants(campId)` - List of registered players
- - **Server Actions:**
- - `createCamp(campData)` - Create new camp
- - `updateCamp(campId, campData)` - Update camp
- - `deleteCamp(campId)` - Delete camp
- - `registerForCamp(campId, registrationData)` - Register player
- - `unregisterFromCamp(registrationId)` - Cancel registration
- - **Hooks:** `use-camps.ts` - useCamps, useCampRegistrations
- - **Testing:** Create/edit/delete camps work, registration flow complete, capacity limits enforced
-
----
-
-### Calendar & Events (1/1) ✅
-
-- [x] **CAL-001: Team Calendar System**
- - **Location:** `/dashboard/calendar/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - **Calendar Views:**
- - Month view (default): Calendar grid with events
- - Week view: 7-day schedule
- - Day view: Single day timeline
- - List view: Upcoming events list
- - **Event Types:**
- - Game (with opponent, home/away)
- - Practice
- - Tournament
- - Camp
- - Showcase
- - Team Meeting
- - Other
- - **Create Event Modal:**
- - Event title
- - Event type (dropdown)
- - Date and time (start + end)
- - Location (address, city, state)
- - Description
- - Recurrence: None, Daily, Weekly, Monthly
- - Notify team members (checkbox)
- - **Event Display:**
- - Color-coded by type
- - Click event to view details
- - Event detail modal: Full info + Edit/Delete buttons
- - **Team Integration:**
- - Events tied to specific team
- - All team members see events
- - Coach can create/edit/delete
- - Players view-only
- - **Notifications:**
- - Email reminder 24 hours before event
- - In-app notification
- - Optional: SMS reminder
- - **Export:**
- - Export to Google Calendar
- - Export to iCal
- - Print calendar
- - **Components:**
- - `Calendar` - Main calendar component (uses react-big-calendar or custom)
- - `CreateEventModal` - Event creation form
- - `EventDetailModal` - Event details and actions
- - `EventCard` - Individual event in list view
- - **Database:**
- - `coach_calendar_events` table
- - Columns: id, coach_id, team_id, title, type, start_time, end_time, location, description, recurrence, created_at
- - **Database Queries:**
- - `getTeamEvents(teamId, startDate, endDate)` - Events in date range
- - `getUpcomingEvents(teamId, limit)` - Next N events
- - `createEvent(eventData)` - Create new event
- - `updateEvent(eventId, eventData)` - Update event
- - `deleteEvent(eventId)` - Delete event
- - **Server Actions:** `createEvent`, `updateEvent`, `deleteEvent`
- - **Hooks:** `use-calendar.ts` - useCalendar, useEvents
- - **Calendar Library:** `react-big-calendar` or custom implementation
- - **Testing:** All views work, create/edit/delete functional, recurring events work
-
----
-
-### Team Management (2/2) ✅
-
-- [x] **TEAM-001: Roster Management System**
- - **Location:** `/dashboard/roster/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - **Roster Table:**
- - Columns: Jersey #, Avatar, Name, Position, Grad Year, School, Recruiting Status
- - Sortable columns
- - Search by name
- - Filter by position, grad year
- - Click row to view player detail
- - **Player Details:**
- - Full player profile in modal or side panel
- - All stats and metrics
- - Contact info
- - Videos
- - Dev plan status
- - **Jersey Number Assignment:**
- - Inline editing of jersey numbers
- - Prevent duplicates
- - Sort by jersey number option
- - **Recruiting Status Badges:**
- - "Recruiting Active" (green) - Player has activated recruiting
- - "Team Only" (gray) - Player not recruiting
- - **Team Invite System:**
- - "Invite Players" button → Invite Modal
- - Generate unique invite link
- - Set expiration date (optional): 7 days, 30 days, Never
- - Set max uses (optional): 10, 25, 50, Unlimited
- - Copy link button
- - Share via email or text
- - Link format: `helm.app/join/ABC123XYZ`
- - View active invite links
- - Deactivate invite link
- - **Add Player Actions:**
- - Invite via link (preferred)
- - Manual add (enter player email, send invite)
- - Import from CSV (future)
- - **Remove Player:**
- - Remove from team button (with confirmation)
- - Does not delete player account, only team membership
- - **Components:**
- - `RosterTable` - Main roster table
- - `RosterRow` - Individual player row
- - `InviteModal` - Generate and manage invite links
- - `PlayerDetailPanel` - Player profile sidebar
- - **Database:**
- - `teams` table: id, organization_id, name, sport, season, created_at
- - `team_members` table: id, team_id, player_id, jersey_number, joined_at, role
- - `team_invitations` table: id, team_id, code, created_by, expires_at, max_uses, uses, active
- - **Database Queries:**
- - `getTeamRoster(teamId)` - All players on team
- - `updateJerseyNumber(teamMemberId, number)` - Update jersey #
- - `createInviteLink(teamId, expiresAt, maxUses)` - Generate invite
- - `getActiveInvites(teamId)` - All active invite links
- - `deactivateInvite(inviteId)` - Deactivate link
- - `removeTeamMember(teamMemberId)` - Remove player
- - **Server Actions:**
- - `createInvite(teamId, options)` - Create invite link
- - `updateJerseyNumber(playerId, number)` - Update jersey
- - `removePlayerFromTeam(teamId, playerId)` - Remove player
- - **Hooks:** `use-roster.ts` - useRoster, useInvites
- - **Testing:** Roster displays correctly, invite generation works, jersey assignment functional
-
-- [x] **TEAM-002: Team Dashboard**
- - **Location:** `/dashboard/team/page.tsx`
- - **Implementation:** 100% complete (generic version)
- - **Details:**
- - **Overview Stats:**
- - Total players on roster
- - Active recruiting players (if HS/JUCO coach)
- - Upcoming events count
- - Unread messages count
- - **Roster Preview:**
- - Top 5 players with avatars
- - "View Full Roster" button → `/dashboard/roster`
- - **Upcoming Events:**
- - Next 3 events from calendar
- - Click to view event details
- - "View Calendar" button
- - **Recent Activity:**
- - Player joined team
- - Player activated recruiting
- - Dev plan assigned
- - Video uploaded
- - **Quick Actions:**
- - Invite Players
- - Create Event
- - Send Message
- - View Videos
- - **Team Switcher (if multiple teams):**
- - Dropdown to switch between teams
- - Placeholder for future multi-team support
- - **Components:**
- - `TeamDashboard` - Main container
- - `TeamStats` - Stats grid
- - `RosterPreview` - Top players
- - `UpcomingEvents` - Event list
- - `TeamActivity` - Activity feed
- - **Database Queries:**
- - `getTeamStats(teamId)` - Overview stats
- - `getTeamRosterPreview(teamId, limit: 5)` - Top players
- - `getTeamEvents(teamId, limit: 3)` - Upcoming events
- - `getTeamActivity(teamId, limit: 5)` - Recent activity
- - **Hooks:** `use-team-dashboard.ts`
- - **Testing:** All sections render, quick actions work
- - **Note:** This is a generic team dashboard. HS-specific dashboard is planned (HS-001)
-
----
-
-### Settings & Configuration (2/2) ✅
-
-- [x] **SET-001: User Settings**
- - **Location:** `/dashboard/settings/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - **Account Settings:**
- - Email (read-only, change via Supabase Auth)
- - Password change: Current password + New password + Confirm
- - Delete account button (with confirmation + password re-entry)
- - **Profile Settings:**
- - Link to profile editing page
- - Quick access to avatar, name, contact info
- - **Privacy Settings:**
- - Separate page: `/dashboard/settings/privacy/page.tsx`
- - Profile visibility: Public, Recruiting Only, Private
- - Show contact info: Yes/No
- - Show videos: Public, Watchlist Only, Private
- - Allow messages from: Anyone, Watchlist Only, No One
- - Show recruiting status: Yes/No
- - **Notification Preferences:**
- - Email notifications: All, Important Only, None
- - In-app notifications: Yes/No
- - SMS notifications: Yes/No (requires phone verification)
- - Notification types toggles:
- - Profile views
- - Watchlist adds
- - New messages
- - Calendar events
- - Dev plan updates
- - **Connected Accounts:**
- - Link Twitter/X account
- - Link Instagram account
- - Link Hudl account
- - **Data Export:**
- - Download your data (JSON format)
- - Includes: Profile, videos, messages, analytics
- - **Components:**
- - `SettingsLayout` - Settings page wrapper with tabs
- - `AccountSettings` - Account section
- - `PrivacySettingsForm` - Privacy toggles
- - `NotificationSettings` - Notification preferences
- - `ConnectedAccounts` - OAuth integrations
- - **Database:**
- - `player_settings` table (if player): privacy preferences
- - `users` table: notification preferences
- - **Server Actions:**
- - `updatePassword(currentPassword, newPassword)` - Change password
- - `updatePrivacySettings(settings)` - Update privacy
- - `updateNotificationPreferences(prefs)` - Update notifications
- - `deleteAccount(password)` - Delete account (soft delete)
- - **Hooks:** `use-settings.ts`
- - **Testing:** Password change works, privacy settings save, delete account functional
-
-- [x] **SET-002: Program Profile Management**
- - **Location:** `/dashboard/program/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - **Program Info:**
- - School/Organization name
- - Website URL
- - Division (D1, D2, D3, NAIA, JUCO)
- - Conference
- - Location (city, state)
- - About program (textarea, 1000 char max)
- - **Branding:**
- - Logo upload (square, 512x512px recommended)
- - Primary color picker
- - Secondary color picker
- - Preview: Shows how colors appear in UI
- - **Coach Staff:**
- - List of coaches on staff
- - Add coach: Email invite
- - Remove coach (with confirmation)
- - Roles: Head Coach, Assistant Coach, Recruiting Coordinator, etc.
- - **Program Stats (read-only):**
- - Founded year
- - Total players recruited (historical)
- - National championships
- - Conference championships
- - **Social Media:**
- - Twitter handle
- - Instagram handle
- - Facebook page
- - YouTube channel
- - **Save Behavior:**
- - Updates `organizations` table
- - Revalidates program profile page
- - Success toast
- - **Components:**
- - `ProgramProfileForm` - Main form
- - `LogoUpload` - Logo image upload
- - `ColorPicker` - Color selection input
- - `CoachStaffList` - List of coaches
- - `AddCoachModal` - Invite coach
- - **Database:**
- - `organizations` table
- - Columns: id, name, website, division, conference, city, state, about, logo_url, primary_color, secondary_color, twitter, instagram, facebook, youtube
- - **Database Queries:**
- - `getOrganization(organizationId)` - Get organization
- - `updateOrganization(organizationId, data)` - Update org
- - **Server Actions:**
- - `updateProgramProfile(organizationId, data)` - Save changes
- - `inviteCoach(organizationId, email, role)` - Add coach
- - **Storage:** `logos/organizations/{orgId}/{filename}`
- - **Testing:** All fields save, logo upload works, color picker functional
-
----
-
-### Infrastructure & Shared Systems (5/5) ✅
-
-- [x] **SYS-001: Navigation System**
- - **Location:** `src/components/layout/Sidebar.tsx`, `src/components/layout/Header.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - **Sidebar:**
- - Dynamic navigation based on user role
- - College Coach: Discover, Watchlist, Pipeline, Compare, Camps, Messages, Calendar, Program, Settings
- - HS Coach: Dashboard, Roster, Videos, Dev Plans, College Interest, Calendar, Messages, Settings
- - JUCO Coach: Mode toggle (recruiting vs team mode) with different nav items
- - Showcase Coach: Teams, Events, Roster, Videos, Calendar, Messages, Settings
- - Player: Dashboard, Profile, Discover, Journey, Camps, Messages, Analytics, Settings
- - Active state highlighting (green background)
- - Icons for each nav item
- - Collapsible on mobile (hamburger menu)
- - Section labels: "RECRUITING", "TEAM", "PROGRAM", etc.
- - **Header:**
- - Logo (left)
- - Page title (center)
- - User dropdown (right): Profile, Settings, Logout
- - Notification bell (if notifications enabled)
- - Mobile: Hamburger menu button
- - **Mobile Menu:**
- - Full-screen overlay on mobile
- - Same nav items as desktop
- - Close button (X)
- - Tap outside to close
- - **User Dropdown:**
- - Avatar + name
- - Role badge (Coach/Player)
- - Dropdown menu:
- - View Profile
- - Settings
- - Divider
- - Logout
- - **Mode Toggle (JUCO coaches only):**
- - Toggle switch: Recruiting ↔ Team
- - Changes entire sidebar navigation
- - Placeholder implemented, full feature in JUCO-001
- - **Components:**
- - `Sidebar` - Main sidebar with dynamic nav
- - `Header` - Top header bar
- - `MobileMenu` - Mobile menu overlay
- - `UserDropdown` - User menu
- - `ModeToggle` - JUCO mode toggle (placeholder)
- - **Hooks:** `use-navigation.ts` - useNavigation, useActiveRoute
- - **Testing:** All role-based nav items display correctly, mobile menu works, dropdown functional
-
-- [x] **SYS-002: Authentication Store**
- - **Location:** `src/stores/auth-store.ts`, `src/hooks/use-auth.ts`
- - **Implementation:** 100% complete
- - **Details:**
- - **Zustand Store:**
- - State:
- - `user` - Supabase Auth user object
- - `coach` - Coach record (if user is coach)
- - `player` - Player record (if user is player)
- - `loading` - Boolean loading state
- - `initialized` - Boolean initialization state
- - Actions:
- - `setUser(user)` - Set auth user
- - `setCoach(coach)` - Set coach data
- - `setPlayer(player)` - Set player data
- - `setLoading(loading)` - Set loading state
- - `reset()` - Clear all state (on logout)
- - **useAuth Hook:**
- - Returns: `{ user, coach, player, loading, isCoach, isPlayer }`
- - Computed values:
- - `isCoach` - Boolean if user has coach record
- - `isPlayer` - Boolean if user has player record
- - `role` - "coach" | "player" | null
- - `coachType` - "college" | "high-school" | "juco" | "showcase" (if coach)
- - `playerType` - "high-school" | "showcase" | "juco" | "college" (if player)
- - **Initialization:**
- - On app load, fetch Supabase session
- - If session exists, fetch coach or player record
- - Populate store with data
- - Set `initialized = true`
- - **Realtime Updates:**
- - Subscribe to auth state changes
- - Re-fetch coach/player data on profile updates
- - **Files:**
- - `stores/auth-store.ts` - Zustand store definition
- - `hooks/use-auth.ts` - React hook wrapper
- - **Testing:** Store updates correctly, hook returns accurate data, logout clears state
-
-- [x] **SYS-003: Route Protection System**
- - **Location:** `src/hooks/use-route-protection.ts`, `src/middleware.ts`
- - **Implementation:** 100% complete
- - **Details:**
- - **Middleware:**
- - Runs on every request
- - Checks Supabase session
- - Public routes: `/`, `/login`, `/signup`, `/join/*`
- - Protected routes: `/dashboard/*`
- - If not authenticated → redirect to `/login`
- - If authenticated but incomplete onboarding → redirect to onboarding
- - **useRouteProtection Hook:**
- - Client-side route protection
- - Checks user role and permissions
- - Recruiting routes (Discover, Watchlist, Pipeline, Compare):
- - Allowed: College coaches, JUCO coaches (when in recruiting mode)
- - Blocked: HS coaches, Showcase coaches, Players (unless recruiting activated)
- - Team routes (Roster, Videos, Dev Plans):
- - Allowed: HS coaches, JUCO coaches (when in team mode), Showcase coaches
- - Blocked: College coaches (no team)
- - Player routes (Journey, Analytics):
- - Allowed: Players with recruiting activated
- - Blocked: College players, non-activated players
- - **Role-based Redirects:**
- - College coach visiting team page → redirect to `/dashboard`
- - HS coach visiting recruiting page → redirect to `/dashboard/team`
- - Player without recruiting visiting journey → redirect to `/dashboard/activate`
- - **Permission Checks:**
- - `canAccessRecruiting(user)` - Boolean
- - `canAccessTeam(user)` - Boolean
- - `canActivateRecruiting(user)` - Boolean (false for college players)
- - **Files:**
- - `middleware.ts` - Edge middleware for auth
- - `hooks/use-route-protection.ts` - Client-side protection hook
- - `lib/permissions.ts` - Permission check functions
- - **Testing:** All role redirects work, unauthorized access blocked, edge cases handled
-
-- [x] **SYS-004: Database Query Layer**
- - **Location:** `src/lib/queries/*.ts`
- - **Implementation:** 100% complete
- - **Details:**
- - **Centralized Queries:**
- - All Supabase queries organized into files by domain
- - Type-safe with TypeScript
- - Consistent error handling
- - Reusable across components
- - **Query Files:**
- - `players.ts` - Player queries (getPlayer, getPlayers, getDiscoverPlayers, etc.)
- - `coaches.ts` - Coach queries (getCoach, getCoaches, etc.)
- - `teams.ts` - Team queries (getTeam, getTeamRoster, etc.)
- - `watchlist.ts` - Watchlist queries (getWatchlist, addToWatchlist, etc.)
- - `messages.ts` - Messaging queries (getConversations, getMessages, etc.)
- - `videos.ts` - Video queries (getVideos, getPlayerVideos, etc.)
- - `camps.ts` - Camp queries (getCamps, getCampRegistrations, etc.)
- - `calendar.ts` - Calendar queries (getEvents, etc.)
- - `analytics.ts` - Analytics queries (getEngagement, etc.)
- - **Query Patterns:**
- - Select with joins: `.select('*, player_videos(*), player_metrics(*)')`
- - Filtering: `.eq('id', id).gte('created_at', date)`
- - Ordering: `.order('created_at', { ascending: false })`
- - Pagination: `.range(start, end)`
- - Error handling: Try-catch with typed errors
- - **Type Safety:**
- - Import types from `@/lib/types`
- - Return types explicitly defined
- - Database types generated from Supabase
- - **Files:**
- - `lib/queries/players.ts` - 15+ player queries
- - `lib/queries/coaches.ts` - 10+ coach queries
- - `lib/queries/teams.ts` - 8+ team queries
- - `lib/queries/watchlist.ts` - 6+ watchlist queries
- - `lib/queries/messages.ts` - 8+ message queries
- - And more...
- - **Testing:** All queries return correct data, joins work, filters accurate
-
-- [x] **SYS-005: UI Component Library**
- - **Location:** `src/components/ui/*.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - **40+ Reusable Components:**
- - **Form Components:**
- - `Button` - Primary, Secondary, Ghost, Icon variants
- - `Input` - Text, Email, Password, Number with validation states
- - `Select` - Dropdown with search, multi-select
- - `Textarea` - Auto-resize, character count
- - `Checkbox` - Standard and indeterminate states
- - `Radio` - Radio group with labels
- - `Switch` - Toggle switch (on/off)
- - `Label` - Form labels with required indicator
- - **Display Components:**
- - `Card` - Container with variants (default, outlined, glass)
- - `Badge` - Status badges with color variants
- - `Avatar` - User avatars with fallback initials
- - `AvatarGroup` - Stacked avatars
- - `Progress` - Progress bar, circular progress
- - `Skeleton` - Loading placeholders
- - `Separator` - Divider lines
- - `Tabs` - Tab navigation with panels
- - `Accordion` - Collapsible sections
- - **Overlay Components:**
- - `Modal` - Centered modal with backdrop
- - `Dialog` - Confirmation dialogs
- - `Sheet` - Side panel (drawer)
- - `Popover` - Floating popup
- - `Tooltip` - Hover tooltips
- - `Dropdown` - Dropdown menu
- - **Feedback Components:**
- - `Toast` - Success, error, warning, info toasts
- - `Alert` - Inline alerts
- - `Spinner` - Loading spinner
- - `EmptyState` - No data placeholders
- - **Navigation Components:**
- - `Breadcrumb` - Breadcrumb trail
- - `Pagination` - Page navigation
- - `CommandPalette` - Keyboard shortcut menu (placeholder)
- - **Layout Components:**
- - `Container` - Max-width container
- - `Grid` - Responsive grid
- - `Stack` - Vertical/horizontal stack
- - **Design System:**
- - **Colors:**
- - Primary: Kelly Green (#16A34A, `green-600`)
- - Background: Cream White (#FAF6F1)
- - Cards: White (#FFFFFF)
- - Text: Slate 900, 600, 400 (#0F172A, #475569, #94A3B8)
- - Borders: Slate 200 (#E2E8F0)
- - **Effects:**
- - Glass morphism: `backdrop-blur-xl`, `bg-white/80`
- - Shadows: `shadow-sm`, `shadow-md`, `shadow-lg`
- - Rounded corners: `rounded-lg` (8px), `rounded-2xl` (16px)
- - Transitions: `transition-colors`, `transition-all`
- - **Typography:**
- - Font: Inter (system-ui fallback)
- - Headings: `font-semibold`, `font-medium`
- - Body: `font-normal`
- - Sizes: `text-sm` (14px), `text-base` (16px), `text-lg` (18px), `text-xl` (20px), `text-2xl` (24px)
- - **Accessibility:**
- - ARIA labels on all interactive elements
- - Keyboard navigation support
- - Focus states visible
- - Color contrast WCAG AA compliant
- - **Files:**
- - 40+ component files in `components/ui/`
- - `lib/utils.ts` - Component utility functions (cn, clsx)
- - `tailwind.config.ts` - Design tokens
- - **Testing:** All components render, variants work, accessibility verified
-
----
-
-### Golf Platform - Core Features (3/3) ✅
-
-- [x] **GOLF-001: Golf Dashboard**
- - **Location:** `/golf/dashboard/page.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - Golf coach dashboard with team overview
- - Team stats: Total players, rounds played, average score
- - Recent rounds list
- - Top performers (lowest average score)
- - Upcoming tournaments
- - Quick actions: Create round, View team stats, Manage roster
- - **Components:** `GolfDashboard`, `TeamStats`, `RecentRounds`
- - **Database:** Golf-specific tables (separate from baseball)
- - **Testing:** Dashboard renders, stats accurate
-
-- [x] **GOLF-002: Golf Player Features**
- - **Location:** `/player-golf/page.tsx`, `/player-golf/rounds/`
- - **Implementation:** 100% complete
- - **Details:**
- - Player golf dashboard with personal stats
- - Round tracking: Create new round, view round history
- - Round detail: View hole-by-hole scores
- - Personal best tracking
- - Handicap calculation (basic)
- - **Components:** `GolfPlayerDashboard`, `RoundList`, `RoundCard`
- - **Database:** Player golf stats tables
- - **Testing:** Rounds save correctly, stats calculate
-
-- [x] **GOLF-003: Shot Tracking System**
- - **Location:** `/player-golf/rounds/[id]/play/page.tsx`, `ShotTrackingFinal_WITH_SCORECARD.tsx`
- - **Implementation:** 100% complete
- - **Details:**
- - Real-time shot tracking during rounds
- - Hole-by-hole scoring
- - Shot distance calculation
- - Club selection per shot
- - Shot type tracking (drive, approach, chip, putt)
- - Premium dark scorecard UI
- - Scorecard integration with:
- - Front 9 / Back 9 tabs
- - Score entry per hole
- - Par tracking
- - Total score calculation
- - Save round
- - **Components:**
- - `ShotTracking` - Main shot tracking interface
- - `ShotTrackingFinal_WITH_SCORECARD.tsx` - Final implementation with scorecard
- - `Scorecard` - Dark theme scorecard UI
- - **Database:** Shots table, rounds table
- - **Testing:** Shot tracking works, distance accurate, scorecard saves
-
----
-
-## IN-PROGRESS FEATURES ⚠️
-**Status:** 17/100+ features in various stages of completion
-
-### High School Coach Features (0/3) - Priority: P0
-
-- [ ] **HS-001: HS Coach Team Dashboard (INCOMPLETE)**
- - **Location:** N/A - Currently redirects to generic `/dashboard/team`
- - **Current Status:** 40% complete
- - **What Exists:**
- - Generic team dashboard shows basic stats
- - Redirect logic implemented
- - **What's Missing:**
- - HS-specific metrics (academic tracking, recruiting interest from colleges)
- - Player development tracking integration
- - Academic progress overview (GPA trends, transcripts)
- - Parent communication portal preparation
- - College recruiting interest notifications ("Coach Smith from Texas A&M viewed 3 of your players")
- - **Database:** All tables exist (teams, team_members, players)
- - **Implementation Needed:**
- - Create `/dashboard/team/high-school/page.tsx`
- - Build HS-specific stat queries
- - Add academic tracking widgets
- - Build college interest feed
- - **Priority:** P0 - CRITICAL
-
-- [ ] **HS-002: College Interest Tracking (PARTIAL)**
- - **Location:** `/dashboard/college-interest/page.tsx`
- - **Current Status:** 50% complete
- - **What Exists:**
- - Page exists with basic layout
- - Shows list of players on roster
- - Basic engagement event fetching
- - **What's Missing:**
- - Full engagement event tracking (profile views, watchlist adds by college coaches)
- - Detailed analytics per player (which colleges, how many views, when)
- - Notifications when coaches view players
- - Filter by player
- - Export interest data
- - **Database:**
- - `player_engagement_events` table exists
- - Need to populate events when college coaches view HS players
- - **Implementation Needed:**
- - Complete engagement tracking logic
- - Build analytics dashboard per player
- - Add notification system
- - **Priority:** P1
-
-- [ ] **HS-003: Developmental Plans System (PARTIAL)**
- - **Location:** `/dashboard/dev-plans/page.tsx` (coach), `/dashboard/dev-plan/page.tsx` (player)
- - **Current Status:** 40% complete
- - **What Exists:**
- - Coach can create dev plans
- - Player can view assigned dev plan
- - Basic plan structure (title, description, goals)
- - **What's Missing:**
- - **Drill Library:**
- - Searchable drill database
- - Video demonstrations for each drill
- - Drill categories (hitting, pitching, fielding)
- - Custom drill creation
- - **Progress Tracking:**
- - Player marks drills as complete
- - Coach sees progress dashboard
- - Timeline view of completion
- - **Player Goal Setting:**
- - Player sets personal goals
- - Milestones and achievements
- - Goal progress tracking
- - **Coach Feedback:**
- - Coach comments on progress
- - Video review and annotations
- - **Database:**
- - `developmental_plans` table exists
- - Need: `drill_library`, `plan_drills`, `drill_completions` tables
- - **Implementation Needed:**
- - Build drill library database and UI
- - Create progress tracking system
- - Add goal setting interface
- - Implement feedback system
- - **Priority:** P2
-
----
-
-### JUCO Coach Features (0/3) - Priority: P0
-
-- [ ] **JUCO-001: JUCO Mode Toggle (NOT INTEGRATED)**
- - **Location:** Component exists but not wired up
- - **Current Status:** 20% complete
- - **What Exists:**
- - `ModeToggle` component exists in `src/components/layout/ModeToggle.tsx`
- - Basic UI for toggle switch (Recruiting ↔ Team)
- - Component is visually complete
- - **What's Missing:**
- - **Integration into layout:**
- - ModeToggle not rendered in sidebar for JUCO coaches
- - No state management for mode selection
- - **Routing Logic:**
- - No route changes based on mode
- - Dashboard should change based on mode
- - **Separate Dashboards:**
- - Recruiting mode: Show Discover, Watchlist, Pipeline (like College coach)
- - Team mode: Show Roster, Videos, Dev Plans (like HS coach)
- - **Mode State Persistence:**
- - Store mode preference in database or local storage
- - Remember last used mode
- - **Sidebar Navigation:**
- - Dynamically change nav items based on mode
- - Show recruiting nav in recruiting mode, team nav in team mode
- - **Database:**
- - Add `mode_preference` column to `coaches` table OR
- - Store in `coach_settings` table
- - **Implementation Needed:**
- - Add ModeToggle to Sidebar when coach type is JUCO
- - Create mode state in Zustand store or React context
- - Build routing logic: `/dashboard` changes based on mode
- - Create JUCO recruiting dashboard (reuse College coach components)
- - Create JUCO team dashboard (reuse HS coach components)
- - Persist mode selection
- - **Priority:** P0 - CRITICAL (JUCO coaches cannot access recruiting features without this)
-
-- [ ] **JUCO-002: Academics Tracking (STUB)**
- - **Location:** `/dashboard/academics/page.tsx`
- - **Current Status:** 10% complete
- - **What Exists:**
- - Stub page with placeholder text
- - Basic page layout
- - **What's Missing:**
- - **Academic Records Database Schema:**
- - Create `academic_records` table
- - Columns: player_id, semester, year, gpa, credits, courses, transcript_url
- - **GPA Tracking Over Time:**
- - Semester-by-semester GPA entry
- - Cumulative GPA calculation
- - GPA trend chart
- - **Transcripts Upload:**
- - PDF upload to Supabase Storage
- - View uploaded transcripts
- - Share transcripts with 4-year colleges
- - **Academic Eligibility Tracking:**
- - NCAA eligibility requirements
- - NAIA eligibility requirements
- - Alert if player falls below eligibility
- - **Course Planning:**
- - Required courses for transfer
- - Course completion tracking
- - **Database:**
- - Create `academic_records` table
- - Create `transcript_files` table
- - **Implementation Needed:**
- - Design and create database schema
- - Build GPA entry form
- - Build transcript upload system
- - Create academic dashboard
- - Add eligibility checker
- - **Priority:** P2
-
-- [ ] **JUCO-003: Transfer Tracking (NOT STARTED)**
- - **Location:** Not created yet
- - **Current Status:** 0% complete
- - **What's Missing:**
- - **Transfer Portal Integration:**
- - Track players entering transfer portal
- - Mark player status: Transferring, Committed, Graduated
- - **4-Year College Tracking:**
- - Which 4-year colleges player is interested in
- - Contact with 4-year coaches
- - Official visit tracking
- - **Transfer Timeline:**
- - Key dates: Portal entry, signing day, enrollment
- - Timeline view of transfer process
- - **Document Management:**
- - Transfer release forms
- - Transcripts
- - Compliance documents
- - **Database:**
- - Create `transfer_tracking` table
- - Add `transfer_status` column to players
- - **Implementation Needed:**
- - Design transfer tracking database
- - Build transfer portal interface
- - Create timeline view
- - Add document upload system
- - **Priority:** P3
-
----
-
-### Showcase Coach Features (0/2) - Priority: P1
-
-- [ ] **SHOW-001: Multi-Team Management (INCOMPLETE)**
- - **Location:** `/dashboard/teams/page.tsx`
- - **Current Status:** 35% complete
- - **What Exists:**
- - Teams listing page with grid of teams
- - Create team modal
- - Edit team basic info
- - Delete team
- - **What's Missing:**
- - **Team Switcher Dropdown:**
- - Dropdown in header/sidebar to switch active team
- - Shows all teams user manages
- - Updates entire dashboard context to selected team
- - **Per-Team Roster Pages:**
- - Route: `/coach/showcase/team/[id]/roster`
- - Isolated roster per team
- - Jersey numbers per team (player can have different # on different teams)
- - **Per-Team Videos:**
- - Route: `/coach/showcase/team/[id]/videos`
- - Videos organized by team
- - Tag videos to specific team
- - **Per-Team Calendar:**
- - Route: `/coach/showcase/team/[id]/calendar`
- - Team-specific events
- - Multi-team calendar view (see all teams' events)
- - **Organization-Level Dashboard:**
- - Overview of all teams
- - Aggregate stats across teams
- - Top performers across organization
- - **Database:**
- - Teams table exists
- - Need: team context in sessions/state
- - **Implementation Needed:**
- - Create team switcher component
- - Build team context provider
- - Create per-team routes
- - Build organization dashboard
- - Add team filtering to all queries
- - **Priority:** P1
-
-- [ ] **SHOW-002: Showcase Events Management (PARTIAL)**
- - **Location:** `/dashboard/events/page.tsx`
- - **Current Status:** 30% complete
- - **What Exists:**
- - Events page with basic event list
- - Create event modal
- - Event types: Tournament, Showcase, Combine
- - **What's Missing:**
- - **Event Registration:**
- - Player registration for events
- - Team registration for tournaments
- - Registration limits and waitlists
- - **Event Analytics:**
- - Attendance tracking
- - Performance stats from event
- - Scout attendance (which colleges attended)
- - **Multi-Team Event Coordination:**
- - Assign teams to events
- - Brackets and scheduling
- - Live scoring/updates
- - **Database:**
- - Create `showcase_events` table
- - Create `event_registrations` table
- - Create `event_participants` table
- - **Implementation Needed:**
- - Build registration system
- - Create event analytics dashboard
- - Add multi-team coordination
- - Build bracket/scheduling system
- - **Priority:** P2
-
----
-
-### Player Features (0/3) - Priority: P0-P1
-
-- [ ] **PLAYER-005: Multi-Team Support (NOT IMPLEMENTED)**
- - **Location:** N/A - Not built
- - **Current Status:** 0% complete
- - **What Exists:**
- - Players can join 1 team via invite link
- - `team_members` table supports multiple memberships (no constraint preventing it)
- - **What's Missing:**
- - **Allow 2 Team Memberships:**
- - HS player can join: 1 HS team + 1 Showcase team
- - Showcase player can join: 1 Showcase team + 1 HS team
- - JUCO player: 1 JUCO team only
- - College player: 1 College team only
- - **Team Switcher Dropdown:**
- - Dropdown in player dashboard/sidebar
- - Switch between teams
- - Shows team name, type, logo
- - **Isolated Team Contexts:**
- - Team dashboard shows only selected team's data
- - Schedule shows selected team's events
- - Videos filtered by selected team
- - Dev plan from selected team's coach
- - Messages to selected team's coaches
- - **Team Type Validation:**
- - Prevent HS player from joining 2 HS teams
- - Prevent HS player from joining JUCO or College team
- - Validate team types on join
- - **Database:**
- - Add `team_type` column to `teams` table (high-school, showcase, juco, college)
- - Add validation logic in server actions
- - **Implementation Needed:**
- - Create team switcher component
- - Build team context provider
- - Add team type validation
- - Update join flow to check team limits
- - Filter all team queries by selected team
- - **Priority:** P0 - CRITICAL (documented feature not implemented)
-
-- [ ] **PLAYER-006: College Discovery (INCOMPLETE)**
- - **Location:** `/dashboard/colleges/page.tsx`
- - **Current Status:** 40% complete
- - **What Exists:**
- - Page exists with basic college grid
- - College cards show name, division, location
- - Click college to view program profile
- - **What's Missing:**
- - **Advanced Filters:**
- - Filter by division (D1, D2, D3, NAIA, JUCO)
- - Filter by conference
- - Filter by location (state, region)
- - Filter by program characteristics (size, public/private, cost)
- - **Save to Dream Schools:**
- - Add college to "dream schools" list
- - Manage dream schools list
- - Share dream schools with coaches
- - **School Comparison:**
- - Compare 2-4 colleges side-by-side
- - Compare: Division, location, size, tuition, baseball program stats
- - **College Match Scoring:**
- - Algorithm to match player to colleges
- - Based on: Academics (GPA, SAT), Athletics (position, stats), Preferences
- - **Database:**
- - `dream_schools` table exists (or use `recruiting_interests`)
- - `organizations` table has all colleges
- - **Implementation Needed:**
- - Build filter panel with all filter options
- - Create dream schools management system
- - Build college comparison tool
- - Develop match scoring algorithm
- - **Priority:** P2
-
-- [ ] **PLAYER-007: Recruiting Activation Flow (BASIC)**
- - **Location:** `/dashboard/activate/page.tsx`
- - **Current Status:** 60% complete
- - **What Exists:**
- - Activation page exists
- - Button to activate recruiting
- - Sets `recruiting_activated = true` and `recruiting_activated_at = NOW()`
- - Redirects to recruiting dashboard
- - **What's Missing:**
- - **Privacy Settings Review Modal:**
- - Before activating, show modal explaining privacy
- - Review current privacy settings
- - Adjust settings before activating
- - Confirm changes
- - **Terms Acceptance:**
- - Show recruiting terms and conditions
- - Checkbox to accept terms
- - Require acceptance before activation
- - **Benefits Explanation:**
- - Better explanation of benefits
- - Video or graphics showing features unlocked
- - Testimonials from other players
- - **Anonymous vs Identified Interest UI:**
- - Currently no differentiation in UI
- - Need to show: "A D1 coach viewed your profile" when not activated
- - vs "Coach John Smith from Texas A&M viewed your profile" when activated
- - Implement in analytics, activity feed, notifications
- - **Database:**
- - Add `recruiting_terms_accepted_at` to players table
- - Privacy settings already exist in `player_settings`
- - **Implementation Needed:**
- - Build privacy review modal
- - Add terms and conditions modal
- - Enhance benefits explanation
- - Implement anonymous vs identified interest logic throughout app
- - **Priority:** P1
-
----
-
-### Video Features (0/1) - Priority: P1
-
-- [ ] **VIDEO-002: Video Clipping Tool (DATABASE READY, NO UI)**
- - **Location:** N/A - Not built
- - **Current Status:** 20% complete
- - **What Exists:**
- - Database schema ready:
- - `videos` table has `is_clip` boolean column
- - `videos` table has `parent_video_id` foreign key column
- - Backend can save clips (just needs clip metadata)
- - **What's Missing:**
- - **Clip Editor UI Component:**
- - Video player with clip controls
- - Click "Create Clip" button on video
- - Modal with video player + timeline
- - **Timeline Scrubber:**
- - Draggable timeline showing video duration
- - Set start time marker (drag or input time)
- - Set end time marker (drag or input time)
- - Preview clip (play only selected portion)
- - Waveform visualization (optional)
- - **Clip Metadata:**
- - Clip title (auto-generate from parent + timestamps)
- - Clip description
- - Clip tags (At-Bat, Pitch, Fielding, etc.)
- - **Save Clips:**
- - Save clip as separate video record in database
- - `is_clip = true`, `parent_video_id = parent.id`
- - Clip URL: Same as parent video + start/end params OR generate separate clip file
- - Display clips in video library with "CLIP" badge
- - **Clip Management:**
- - View all clips from a parent video
- - Delete clips (doesn't delete parent)
- - Share individual clips
- - **Database:** Already ready (`videos` table)
- - **Implementation Needed:**
- - Build clip editor modal component
- - Create timeline scrubber with React (use library like react-player + custom timeline)
- - Add clip creation server action
- - Update video library to show clips
- - Add "CLIP" badge to clip videos
- - **Priority:** P1
- - **Library Suggestions:** `react-player`, `wavesurfer.js`, or custom HTML5 video controls
-
----
-
-### Comparison Features (0/1) - Priority: P1
-
-- [ ] **RECRUIT-006: Advanced Player Comparison (PARTIAL)**
- - **Location:** `/dashboard/compare/page.tsx`
- - **Current Status:** 60% complete
- - **What Exists:**
- - Basic comparison page works (side-by-side 2-4 players)
- - Comparison table with metrics
- - URL-based state (`?players=id1,id2`)
- - **What's Missing:**
- - **Radar Chart Overlay:**
- - Visual radar chart comparing players on multiple dimensions
- - Dimensions: Pitch Velo, Exit Velo, 60-Yard, GPA, etc.
- - Recharts RadarChart component
- - Overlay multiple players on same chart
- - **Save Comparison Feature:**
- - Save comparison with title
- - Store in `player_comparisons` table (table exists in schema)
- - Access saved comparisons from dashboard
- - Share saved comparison link
- - **Comparison History:**
- - List of all saved comparisons
- - Filter by date created
- - Delete old comparisons
- - **Export to PDF:**
- - Export comparison table + radar chart to PDF
- - Include player photos and key stats
- - Downloadable PDF file
- - **Database:**
- - `player_comparisons` table exists in schema but not used
- - Columns: id, coach_id, player_ids (array), title, created_at
- - **Implementation Needed:**
- - Add Recharts RadarChart to comparison page
- - Build save comparison feature (form + server action)
- - Create saved comparisons list page
- - Implement PDF export (use library like `jsPDF` or `react-pdf`)
- - **Priority:** P1
-
----
-
-### Public Profiles (0/2) - Priority: P2
-
-- [ ] **PUB-001: Public Player Profiles (BASIC)**
- - **Location:** `/baseball/(public)/player/[id]/page.tsx`
- - **Current Status:** 50% complete
- - **What Exists:**
- - Public player profile page exists
- - Shows basic player info (name, position, grad year, school)
- - Shows stats
- - Basic layout
- - **What's Missing:**
- - **Privacy Settings Enforcement:**
- - If recruiting NOT activated → Show limited profile (name, position, grad year only)
- - If recruiting activated AND profile privacy = "Public" → Show full profile
- - If recruiting activated AND profile privacy = "Recruiting Only" → Show full profile only to logged-in coaches
- - If profile privacy = "Private" → Show nothing (404 or "Profile not available")
- - **Video Embeds:**
- - Embed primary highlight video
- - Show all public videos in grid
- - Video player modal
- - **Achievement/Honors Display:**
- - Show awards, honors, accolades
- - All-Star selections, championships, etc.
- - **Recruiting Status Visibility:**
- - "Actively Recruiting" badge if recruiting activated
- - "Committed to [School]" badge if committed
- - **Database:**
- - Privacy settings in `player_settings` table
- - Achievements in `player_achievements` table
- - **Implementation Needed:**
- - Add privacy check logic
- - Embed videos on profile
- - Display achievements
- - Add recruiting status badges
- - **Priority:** P2
-
-- [ ] **PUB-002: Public Program Profiles (BASIC)**
- - **Location:** `/baseball/(public)/program/[id]/page.tsx`
- - **Current Status:** 40% complete
- - **What Exists:**
- - Public program profile page exists
- - Shows program name, division, location
- - Basic layout
- - **What's Missing:**
- - **Full Content Display:**
- - About program (full description)
- - Coach staff list with bios
- - Program history and achievements
- - Facilities and resources
- - Contact information
- - **SEO Optimization:**
- - Meta tags for social sharing
- - Structured data (JSON-LD)
- - Open Graph tags
- - Optimized images
- - **Roster Preview:**
- - Show current roster (public players only)
- - Filter by position, grad year
- - Link to player profiles
- - **Database:**
- - Organizations table has all data
- - Need to query roster with privacy filters
- - **Implementation Needed:**
- - Build full program profile page
- - Add SEO meta tags
- - Create roster preview component
- - Add contact form (optional)
- - **Priority:** P2
-
----
-
-### Golf Platform Enhancements (0/2) - Priority: P2
-
-- [ ] **GOLF-004: Golf Round Management (PARTIAL)**
- - **Location:** `/player-golf/rounds/`
- - **Current Status:** 40% complete
- - **What Exists:**
- - Create new round
- - View round list
- - Basic round detail page
- - **What's Missing:**
- - **Round History:**
- - Detailed round history with filters (date range, course, score)
- - Sort by date, score, course
- - Search rounds
- - **Statistics Aggregation:**
- - Total rounds played
- - Average score
- - Best score, worst score
- - Scoring trends over time
- - Par 3/4/5 averages
- - **Performance Analytics:**
- - Fairways hit percentage
- - Greens in regulation
- - Putts per round
- - Up and down percentage
- - Charts and graphs
- - **Database:**
- - Rounds table exists
- - Need: aggregation queries
- - **Implementation Needed:**
- - Build round history page with filters
- - Create statistics dashboard
- - Add analytics charts
- - **Priority:** P2
-
-- [ ] **GOLF-005: Golf Team Management (PARTIAL)**
- - **Location:** `/golf/dashboard/`
- - **Current Status:** 35% complete
- - **What Exists:**
- - Golf team dashboard shows basic stats
- - Can view team roster
- - **What's Missing:**
- - **Multi-Player Tracking:**
- - Track multiple players on team
- - Individual player stats
- - Team leaderboard
- - **Team Statistics:**
- - Team average score
- - Team best round
- - Player comparisons
- - **Tournament Management:**
- - Create tournaments
- - Team brackets
- - Live scoring
- - Tournament results
- - **Database:**
- - Golf teams table exists
- - Need: tournament tables
- - **Implementation Needed:**
- - Build team leaderboard
- - Create tournament system
- - Add live scoring
- - **Priority:** P2
-
----
-
-## PLANNED FEATURES 🚀
-**Status:** 35 new features planned across 5 priority levels
-
-### Critical Priority (P0) - Fix Core Gaps (5 features)
-
-- [ ] **CORE-001: Implement JUCO Mode Toggle**
- - **Description:** Wire up ModeToggle component for JUCO coaches to switch between recruiting and team modes
- - **Why Critical:** JUCO coaches cannot access recruiting features without this
- - **Implementation:**
- - Add ModeToggle to Sidebar when coach type is JUCO
- - Create mode state in Zustand store
- - Build routing logic to change dashboard based on mode
- - Separate recruiting and team dashboards for JUCO
- - Persist mode selection in database
- - **Estimated Effort:** 3-5 days
- - **Blockers:** None
- - **Success Criteria:**
- - JUCO coach sees mode toggle in sidebar
- - Clicking toggle changes navigation items
- - Recruiting mode shows Discover, Watchlist, Pipeline
- - Team mode shows Roster, Videos, Dev Plans
- - Mode preference persists across sessions
- - **Priority:** P0 - CRITICAL
-
-- [ ] **CORE-002: Implement Multi-Team Support for Players**
- - **Description:** Allow players to join 2 teams (HS + Showcase, etc.) with team switcher
- - **Why Critical:** Documented feature, players expect this functionality
- - **Implementation:**
- - Add `team_type` column to `teams` table
- - Create team switcher dropdown component
- - Build team context provider to track active team
- - Add validation: HS player can join 1 HS + 1 Showcase team only
- - Filter all team queries by selected team
- - Update join flow to check team limits
- - **Estimated Effort:** 5-7 days
- - **Blockers:** Database migration needed
- - **Success Criteria:**
- - HS player can join HS team and Showcase team
- - Team switcher appears when player has 2 teams
- - Switching teams updates all team-related data
- - Join flow prevents joining invalid team types
- - **Priority:** P0 - CRITICAL
-
-- [ ] **CORE-003: Complete HS Coach Dashboard**
- - **Description:** Build HS-specific team dashboard with academic tracking and college interest features
- - **Why Critical:** HS coaches currently see generic dashboard, missing key features
- - **Implementation:**
- - Create `/dashboard/team/high-school/page.tsx`
- - Build academic tracking widget (GPA trends, transcripts)
- - Build college interest feed (which colleges viewing players)
- - Add player development overview
- - Create parent portal preparation
- - **Estimated Effort:** 7-10 days
- - **Blockers:** None (all tables exist)
- - **Success Criteria:**
- - HS coach sees custom dashboard instead of generic team dashboard
- - Dashboard shows academic metrics for all players
- - Dashboard shows college interest notifications
- - Player development section functional
- - **Priority:** P0 - CRITICAL
-
-- [ ] **CORE-004: Separate Golf Platform**
- - **Description:** Move golf app to separate directory or repository, establish clear separation
- - **Why Critical:** Golf app mixed with baseball app creates confusion, routing conflicts
- - **Implementation:**
- - Option A: Move to monorepo structure (`/apps/baseball/`, `/apps/golf/`)
- - Option B: Separate repositories
- - Independent routing (`golf.helm.app` vs `baseball.helm.app`)
- - Separate authentication contexts
- - Update documentation to reflect dual-app structure
- - **Estimated Effort:** 3-5 days
- - **Blockers:** Deployment strategy decision needed
- - **Success Criteria:**
- - Golf app completely isolated from baseball app
- - No shared routes or components (except design system)
- - Clear documentation of dual-platform architecture
- - **Priority:** P0 - CRITICAL
-
-- [ ] **CORE-005: Remove Dead Code**
- - **Description:** Clean up unused components, duplicate implementations, test files
- - **Why Critical:** Reduces confusion, improves maintainability, smaller bundle size
- - **Implementation:**
- - Delete unused peek panel components
- - Remove duplicate pipeline components (old vs new)
- - Clean up test/dev files
- - Remove deprecated imports
- - Update imports to reflect deletions
- - **Files to Remove:**
- - `components/panels/PeekPanelRoot.tsx`
- - `components/panels/PlayerPeekPanel.tsx`
- - `components/panels/SchoolPeekPanel.tsx`
- - `components/coach/pipeline/PipelineBoard.tsx` (old version)
- - `components/coach/pipeline/PipelineColumn.tsx` (old version)
- - `components/coach/discover/USAMap.tsx` (duplicate)
- - `src/app/dev/page.tsx`
- - `src/app/test-shot-tracking/page.tsx`
- - **Estimated Effort:** 1-2 days
- - **Blockers:** None
- - **Success Criteria:**
- - All unused files deleted
- - No broken imports
- - Bundle size reduced by at least 10%
- - TypeScript compiles with no errors
- - **Priority:** P0
-
----
-
-### High Priority (P1) - Complete Partially Built Features (5 features)
-
-- [ ] **FEATURE-001: Complete Video Clipping System**
- - **Description:** Build clip editor UI with timeline scrubber to create clips from videos
- - **Implementation:**
- - Create clip editor modal component
- - Build timeline scrubber with React (use react-player)
- - Add start/end time selection (drag markers or input times)
- - Preview clip before saving
- - Save clip metadata to database (`is_clip = true`, `parent_video_id`)
- - Display clips in video library with "CLIP" badge
- - Add clip tagging (At-Bat, Pitch, Fielding, etc.)
- - **Database:** Already ready
- - **Libraries:** `react-player`, custom timeline scrubber
- - **Estimated Effort:** 7-10 days
- - **Success Criteria:**
- - Click "Create Clip" on video opens editor
- - Timeline scrubber functional with draggable markers
- - Preview clip plays only selected portion
- - Saved clips appear in video library
- - **Priority:** P1
-
-- [ ] **FEATURE-002: Implement Notifications System**
- - **Description:** Build full notification system with real-time, email, and push notifications
- - **Implementation:**
- - Integrate notification bell component (NotificationCenter exists)
- - Real-time notifications with Supabase Realtime
- - Email notifications (Supabase Auth emails or SendGrid)
- - Push notifications (PWA + service worker)
- - Notification preferences in settings
- - Mark as read/unread
- - Notification types: Profile views, Watchlist adds, Messages, Calendar events, Dev plans
- - **Database:** `notifications` table exists
- - **Estimated Effort:** 10-14 days
- - **Success Criteria:**
- - Notification bell shows unread count
- - Clicking bell shows notification dropdown
- - Real-time updates when new notification arrives
- - Email sent for important notifications
- - Notification preferences work
- - **Priority:** P1
-
-- [ ] **FEATURE-003: Complete Player Comparison Tool**
- - **Description:** Add radar chart overlay, save comparisons, export to PDF
- - **Implementation:**
- - Add Recharts RadarChart to comparison page
- - Overlay multiple players on same radar chart
- - Build save comparison feature (form + server action)
- - Create saved comparisons list page
- - Implement PDF export (use jsPDF or react-pdf)
- - Include player photos, stats, and charts in PDF
- - **Database:** Use `player_comparisons` table
- - **Estimated Effort:** 5-7 days
- - **Success Criteria:**
- - Radar chart displays on comparison page
- - Can save comparison with title
- - Saved comparisons list accessible
- - PDF export downloads successfully
- - **Priority:** P1
-
-- [ ] **FEATURE-004: Complete Showcase Coach Multi-Team Management**
- - **Description:** Organization dashboard, per-team routing, team switcher
- - **Implementation:**
- - Build organization-level dashboard (overview of all teams)
- - Create team switcher component (dropdown in header)
- - Build per-team routes: `/coach/showcase/team/[id]/roster`, `/coach/showcase/team/[id]/videos`, etc.
- - Add team filtering to all queries
- - Multi-team calendar view (aggregate events from all teams)
- - Cross-team analytics
- - **Database:** Teams table exists
- - **Estimated Effort:** 7-10 days
- - **Success Criteria:**
- - Showcase coach sees team switcher
- - Switching teams updates entire dashboard
- - Per-team routes functional
- - Organization dashboard shows aggregate stats
- - **Priority:** P1
-
-- [ ] **FEATURE-005: Anonymous vs Identified Interest System**
- - **Description:** Implement privacy model for recruiting activation
- - **Implementation:**
- - Update analytics page to show anonymous vs identified interest
- - Anonymous (recruiting not activated): "A D1 coach from Texas viewed your profile"
- - Identified (recruiting activated): "Coach John Smith from Texas A&M viewed your profile"
- - Update activity feed with same logic
- - Update notifications with same logic
- - Add privacy settings review modal to activation flow
- - Explain benefits of activation clearly
- - **Database:** Privacy settings already exist
- - **Estimated Effort:** 5-7 days
- - **Success Criteria:**
- - Non-activated players see anonymous interest
- - Activated players see identified interest (coach names)
- - Activation flow explains privacy model
- - **Priority:** P1
-
----
-
-### Medium Priority (P2) - New Features (15 features)
-
-#### Baseball Platform Features (10 features)
-
-- [ ] **FEATURE-006: Complete Developmental Plans System**
- - **Description:** Drill library, progress tracking, goal setting, coach feedback
- - **Estimated Effort:** 14-21 days
- - **Priority:** P2
-
-- [ ] **FEATURE-007: Academics Tracking System**
- - **Description:** Academic records database, GPA tracking, transcripts upload, eligibility tracking
- - **Estimated Effort:** 10-14 days
- - **Priority:** P2
-
-- [ ] **FEATURE-008: College Interest Analytics**
- - **Description:** Full engagement analytics, notifications, interest timeline, heatmap
- - **Estimated Effort:** 7-10 days
- - **Priority:** P2
-
-- [ ] **FEATURE-009: Advanced Search System**
- - **Description:** Global search with Command Palette, saved searches, search history
- - **Estimated Effort:** 7-10 days
- - **Priority:** P2
-
-- [ ] **FEATURE-010: College Discovery Enhancements**
- - **Description:** Advanced filters, school comparison, dream schools list, match scoring
- - **Estimated Effort:** 10-14 days
- - **Priority:** P2
-
-- [ ] **FEATURE-011: Transfer Tracking (JUCO)**
- - **Description:** Transfer portal integration, 4-year college tracking, transfer timeline
- - **Estimated Effort:** 10-14 days
- - **Priority:** P2
-
-- [ ] **FEATURE-012: Public Player Profiles Enhancement**
- - **Description:** Privacy enforcement, video embeds, achievements display
- - **Estimated Effort:** 5-7 days
- - **Priority:** P2
-
-- [ ] **FEATURE-013: Public Program Profiles Enhancement**
- - **Description:** Full content display, SEO optimization, roster preview
- - **Estimated Effort:** 5-7 days
- - **Priority:** P2
-
-- [ ] **FEATURE-014: Showcase Events Management**
- - **Description:** Event registration, event analytics, multi-team coordination
- - **Estimated Effort:** 10-14 days
- - **Priority:** P2
-
-- [ ] **FEATURE-015: Parent Portal**
- - **Description:** Parent accounts, communication system, academic access, recruiting updates
- - **Estimated Effort:** 14-21 days
- - **Priority:** P2
-
-#### Golf Platform Expansion (5 features)
-
-- [ ] **GOLF-006: Complete Golf Round Management**
- - **Description:** Round history, statistics aggregation, performance analytics, handicap calculation
- - **Estimated Effort:** 7-10 days
- - **Priority:** P2
-
-- [ ] **GOLF-007: Golf Tournament System**
- - **Description:** Tournament creation, leaderboards, live scoring, tournament analytics
- - **Estimated Effort:** 14-21 days
- - **Priority:** P2
-
-- [ ] **GOLF-008: Golf Player Development**
- - **Description:** Swing analysis, practice tracking, goal setting, performance trends
- - **Estimated Effort:** 14-21 days
- - **Priority:** P2
-
-- [ ] **GOLF-009: Golf Team Statistics**
- - **Description:** Team performance metrics, player comparison, season statistics, team rankings
- - **Estimated Effort:** 7-10 days
- - **Priority:** P2
-
-- [ ] **GOLF-010: Golf Course Management**
- - **Description:** Course database, course ratings, hole details, yardage tracking
- - **Estimated Effort:** 7-10 days
- - **Priority:** P2
-
----
-
-### Low Priority (P3) - Future Enhancements (5 features)
-
-- [ ] **FUTURE-001: Mobile App**
- - **Description:** React Native or PWA, push notifications, offline support
- - **Estimated Effort:** 60+ days
- - **Priority:** P3
-
-- [ ] **FUTURE-002: Advanced Analytics**
- - **Description:** Predictive analytics, ML-based player matching, trend analysis, custom reports
- - **Estimated Effort:** 30+ days
- - **Priority:** P3
-
-- [ ] **FUTURE-003: Parent Portal (Full Version)**
- - **Description:** Beyond basic parent portal, full communication system
- - **Estimated Effort:** 21-30 days
- - **Priority:** P3
-
-- [ ] **FUTURE-004: Payment System**
- - **Description:** Subscription management, camp payments, premium features, billing dashboard
- - **Estimated Effort:** 14-21 days
- - **Priority:** P3
-
-- [ ] **FUTURE-005: Social Features**
- - **Description:** Activity feed, achievements sharing, team announcements, social media integration
- - **Estimated Effort:** 14-21 days
- - **Priority:** P3
-
----
-
-## TECHNICAL DEBT 🔧
-**Status:** 5 technical improvements needed
-
-- [ ] **TECH-001: Type System Cleanup**
- - **Description:** Ensure all types from @/lib/types, remove deprecated imports
- - **Implementation:**
- - Audit all files for type imports
- - Replace deprecated imports with `@/lib/types`
- - Add missing type definitions
- - Remove `any` types where possible
- - **Estimated Effort:** 2-3 days
- - **Priority:** P1
-
-- [ ] **TECH-002: Performance Optimization**
- - **Description:** Image optimization, lazy loading, code splitting, bundle size reduction
- - **Implementation:**
- - Use Next.js Image component everywhere
- - Implement lazy loading for modals and large components
- - Code split routes with dynamic imports
- - Analyze bundle with webpack-bundle-analyzer
- - Remove unused dependencies
- - **Estimated Effort:** 5-7 days
- - **Priority:** P2
-
-- [ ] **TECH-003: Testing Infrastructure**
- - **Description:** E2E tests, component tests, API tests, 80% coverage
- - **Implementation:**
- - Set up Playwright for E2E tests
- - Set up Vitest for component tests
- - Write tests for critical paths (auth, recruiting, messaging)
- - Add API route tests
- - Set up CI/CD with test runs
- - **Estimated Effort:** 14-21 days
- - **Priority:** P2
-
-- [ ] **TECH-004: Documentation Updates**
- - **Description:** Update CLAUDE.md with golf platform, document hooks, API docs, component docs
- - **Implementation:**
- - Update CLAUDE.md to reflect dual-platform
- - Document all custom hooks with JSDoc
- - Create API reference documentation
- - Document component props with Storybook or TSDoc
- - Create developer onboarding guide
- - **Estimated Effort:** 5-7 days
- - **Priority:** P2
-
-- [ ] **TECH-005: Accessibility Improvements**
- - **Description:** WCAG 2.1 AA compliance, keyboard navigation, screen reader support, color contrast
- - **Implementation:**
- - Audit with axe DevTools
- - Fix all keyboard navigation issues
- - Add ARIA labels where missing
- - Fix color contrast issues
- - Test with screen readers (NVDA, VoiceOver)
- - **Estimated Effort:** 7-10 days
- - **Priority:** P2
-
----
-
-## QUICK STATS 📊
-
-### Overall Progress
-- **Total Features:** 100+ tracked
-- **Completed:** 55 features (55%)
-- **In Progress:** 17 features (17%)
-- **Planned:** 35 features (35%)
-- **Technical Debt:** 5 items
-
-### By Platform
-- **Baseball Platform:** 65% complete
-- **Golf Platform:** 40% complete
-
-### By User Type
-- **College Coach:** 95% complete
-- **HS Coach:** 40% complete
-- **JUCO Coach:** 30% complete (blocked by mode toggle)
-- **Showcase Coach:** 35% complete
-- **Player (HS/Showcase):** 70% complete
-- **Player (JUCO):** 60% complete
-- **Player (College):** 80% complete
-
-### By Category
-- **Authentication:** 100% complete (3/3)
-- **Recruiting:** 90% complete (5/6)
-- **Team Management:** 70% complete (2/3)
-- **Messaging:** 100% complete (1/1)
-- **Video:** 50% complete (1/2)
-- **Calendar:** 100% complete (1/1)
-- **Settings:** 100% complete (2/2)
-- **Infrastructure:** 100% complete (5/5)
-- **Golf:** 60% complete (3/5)
-
-### Critical Priorities
-- **P0 (Critical):** 5 features - MUST BE DONE FIRST
-- **P1 (High):** 5 features - Complete partial features
-- **P2 (Medium):** 20 features - New features and enhancements
-- **P3 (Future):** 5 features - Long-term vision
-
----
-
-## NEXT STEPS 🚀
-
-### Week 1-2 (P0 Critical Fixes)
-1. ✅ Mark all completed features as DONE
-2. ⚠️ Implement JUCO Mode Toggle (CORE-001)
-3. ⚠️ Complete HS Coach Dashboard (CORE-003)
-4. ⚠️ Separate Golf Platform (CORE-004)
-5. ⚠️ Remove Dead Code (CORE-005)
-
-### Week 3-4 (P0 + P1)
-6. ⚠️ Implement Multi-Team Support (CORE-002)
-7. 🚀 Complete Video Clipping (FEATURE-001)
-8. 🚀 Complete Player Comparison (FEATURE-003)
-9. 🚀 Anonymous vs Identified Interest (FEATURE-005)
-
-### Week 5-8 (P1 + P2)
-10. 🚀 Implement Notifications (FEATURE-002)
-11. 🚀 Complete Showcase Multi-Team (FEATURE-004)
-12. 🚀 Complete Dev Plans (FEATURE-006)
-13. 🚀 Academics Tracking (FEATURE-007)
-
-### Week 9-12 (Golf Expansion)
-14. 🏌️ Complete Golf Round Management (GOLF-006)
-15. 🏌️ Golf Tournament System (GOLF-007)
-16. 🏌️ Golf Player Development (GOLF-008)
-17. 🏌️ Golf Team Statistics (GOLF-009)
-
----
-
-**END OF CHECKLIST**
-
-This checklist will be updated as features are completed. Use this document to track progress and prioritize work.
-
-**Legend:**
-- ✅ = Completed
-- ⚠️ = In Progress
-- 🚀 = Planned (High Priority)
-- 🏌️ = Golf Platform
-- ❌ = Not Started
diff --git a/.taskmaster/docs/prd.txt b/.taskmaster/docs/prd.txt
deleted file mode 100644
index 185ddac1a..000000000
--- a/.taskmaster/docs/prd.txt
+++ /dev/null
@@ -1,676 +0,0 @@
-# Helm Sports Labs - Product Requirements Document
-# Generated: December 22, 2024
-# Version: 3.0
-
-## PRODUCT OVERVIEW
-Helm Sports Labs is a dual-platform sports management system:
-1. Baseball Recruiting Platform - Connect players with college coaches
-2. Golf Team Management Platform - Track rounds, shots, and player development
-
----
-
-## SECTION 1: COMPLETED FEATURES ✅
-
-### Baseball Platform - Authentication & Core Infrastructure
-
-AUTH-001: User Authentication System
-- Email/password authentication via Supabase Auth
-- Role-based signup (Coach vs Player)
-- Protected routes with middleware
-- Session management
-
-AUTH-002: Player Onboarding Flow
-- 5-step wizard: Basic Info → Baseball Info → Physical/School → Metrics → Profile/Goals
-- Avatar upload integration
-- Position selection, graduation year
-- Metrics capture: pitch velocity, exit velocity, 60-yard time, GPA
-- Links to Supabase Auth user
-
-AUTH-003: Coach Onboarding Flow
-- 4-step wizard: Personal Info → Program Info → Program Details → Preferences
-- Creates coach record, organization, and team
-- Logo upload, brand colors
-- Links to Supabase Auth user
-
-### College Coach - Recruiting Suite
-
-RECRUIT-001: Player Discovery System
-- Advanced filtering: grad year, position, state, velocity metrics, GPA
-- Name and school search
-- Pagination (24 players per page)
-- USA Map visualization with state click filters
-- Filter panel with real-time URL params
-- Shows recruiting-activated players only
-- Watchlist integration (add/remove from card)
-
-RECRUIT-002: Recruiting Watchlist Management
-- Full CRUD operations on watchlist
-- Table view with player details
-- Inline status dropdown (5 pipeline stages)
-- Inline notes editing
-- Filter tabs by status
-- Filter by position and grad year
-- Bulk selection and bulk actions
-- Bulk remove with confirmation
-- Player detail modal
-
-RECRUIT-003: Recruiting Pipeline Board
-- Drag-and-drop kanban board with 5 columns
-- Pipeline stages: watchlist, high_priority, offer_extended, committed, uninterested
-- Uses @dnd-kit for smooth interactions
-- Grad year filter
-- Real-time stage updates
-- Empty state with CTA to Discover
-
-RECRUIT-004: Player Comparison Tool
-- Side-by-side comparison of 2-4 players
-- Search and add players dynamically
-- Player removal
-- URL-based state management
-- Metrics comparison table
-- Stats comparison
-
-RECRUIT-005: College Coach Dashboard
-- Bento Grid layout with glass morphism
-- Pipeline stats (watchlist, high_priority, offer_extended, committed counts)
-- Profile views, messages stats
-- Recent players list (last 5)
-- 7-day engagement chart
-- Activity feed (last 8 events)
-- Upcoming events & camps calendar widget
-- USA map showing player distribution by state
-- Quick actions (Discover, Messages, Calendar, Edit Program)
-
-### Player Features
-
-PLAYER-001: Player Dashboard
-- Profile card with avatar, name, position, grad year, school, location
-- Bento grid stats: Profile views, On watchlists count, Messages, Video views
-- Your Stats card (height, weight, velocity, GPA)
-- Quick actions (Complete profile, Browse colleges, Check messages)
-- Recruiting activation banner (if not activated)
-- Profile completion percentage badge
-
-PLAYER-002: Player Profile Management
-- Full profile editing
-- Avatar upload
-- Baseball stats and metrics
-- School information
-- Contact details
-- Privacy settings
-
-PLAYER-003: Recruiting Journey Tracker
-- Track colleges player is interested in
-- Update status per school (interested, researching, contacted, visited, offered, committed)
-- Timeline view of journey events
-- Milestone tracking
-
-PLAYER-004: Player Analytics Dashboard
-- Profile views, watchlist adds, video views, messages sent
-- 7-day engagement chart (Recharts)
-- Top schools viewing profile
-- Engagement metrics
-
-### Messaging System
-
-MSG-001: Real-time Messaging Platform
-- Full real-time messaging between coaches and players
-- Conversation list with unread counts
-- Chat window with message history
-- New conversation modal
-- Mobile-responsive (split view on desktop, single view on mobile)
-- URL-based conversation selection
-- Real-time updates
-
-### Video Management
-
-VIDEO-001: Video Upload and Library
-- Video upload with drag-and-drop
-- Supabase Storage integration
-- Video library grid view
-- Search videos by title or player name
-- Video player modal
-- Delete videos with confirmation
-- Coach view: See all team player videos
-- Player view: Personal video library
-
-### Camps Management
-
-CAMP-001: Camp Management System
-- Coach: Create, edit, delete camps
-- Player: Browse camps, register/unregister
-- Camp cards with date, location, capacity, price
-- Registration tracking
-- Filter by status (upcoming, past)
-- Camp detail view
-
-### Calendar & Events
-
-CAL-001: Team Calendar System
-- Full calendar view of team events
-- Create, edit, delete events
-- Event types: game, practice, tournament, camp, showcase, team_meeting
-- Team-specific events
-- Coach calendar management
-
-### Team Management
-
-TEAM-001: Roster Management System
-- View team members with full details
-- Search by name, position, grad year
-- Generate team invite links
-- Jersey number assignment
-- Player status badges (recruiting active vs team only)
-- Team invitation system
-
-TEAM-002: Team Dashboard
-- Team stats and roster overview
-- Team-specific view for HS/Showcase coaches
-- Quick actions
-
-### Settings & Configuration
-
-SET-001: User Settings
-- Account settings
-- Profile settings
-- Privacy settings
-- Notification preferences
-
-SET-002: Program Profile Management
-- Edit organization details
-- School name, website, division, conference
-- Location (city, state)
-- About program description
-- Brand colors (primary, secondary)
-- Logo upload
-
-### Infrastructure & Shared Systems
-
-SYS-001: Navigation System
-- Dynamic sidebar with role-based navigation
-- Responsive header
-- Mobile menu
-- User dropdown
-
-SYS-002: Authentication Store
-- Zustand store for auth state
-- useAuth hook with user, coach, player, loading
-- Real-time auth state management
-
-SYS-003: Route Protection System
-- Recruiting route protection (college/JUCO coaches only)
-- Team route protection (HS/JUCO/Showcase coaches)
-- Role-based redirects
-
-SYS-004: Database Query Layer
-- Centralized query functions
-- Type-safe Supabase queries
-- Error handling
-
-SYS-005: UI Component Library
-- 40+ reusable components
-- Button, Card, Input, Select, Badge, Avatar, Modal, Toast
-- Design system: Kelly Green (#16A34A) + Cream White (#FAF6F1)
-- Glass morphism effects
-- Subtle animations
-
-### Golf Platform - Core Features
-
-GOLF-001: Golf Dashboard
-- Golf coach dashboard with team overview
-- Round management
-- Player statistics
-
-GOLF-002: Golf Player Features
-- Player golf dashboard
-- Round tracking
-- Shot tracking component
-- Scorecard integration
-
-GOLF-003: Shot Tracking System
-- Real-time shot tracking during rounds
-- Distance calculation
-- Club selection
-- Shot type tracking
-- Scorecard integration with premium dark theme
-
----
-
-## SECTION 2: IN-PROGRESS FEATURES ⚠️
-
-### High School Coach Features
-
-HS-001: HS Coach Team Dashboard (INCOMPLETE)
-- Currently redirects to generic team dashboard
-- NEEDS: HS-specific metrics and features
-- NEEDS: Player development tracking
-- NEEDS: Academic tracking for HS players
-- STATUS: 40% complete
-
-HS-002: College Interest Tracking (PARTIAL)
-- Shows which college coaches are viewing players on roster
-- NEEDS: Full engagement event tracking
-- NEEDS: Detailed analytics per player
-- NEEDS: Notifications when coaches view players
-- STATUS: 50% complete
-
-HS-003: Developmental Plans System (PARTIAL)
-- Create dev plans for players
-- NEEDS: Drill library
-- NEEDS: Progress tracking
-- NEEDS: Player goal setting
-- NEEDS: Player view integration
-- STATUS: 40% complete
-
-### JUCO Coach Features
-
-JUCO-001: JUCO Mode Toggle (NOT INTEGRATED)
-- Component exists but not wired up
-- NEEDS: Routing logic for mode switching
-- NEEDS: Separate recruiting and team dashboards
-- NEEDS: Mode state management
-- STATUS: 20% complete
-
-JUCO-002: Academics Tracking (STUB)
-- Stub page exists
-- NEEDS: Academic records database schema
-- NEEDS: GPA tracking over time
-- NEEDS: Transcripts upload
-- NEEDS: Academic eligibility tracking
-- STATUS: 10% complete
-
-JUCO-003: Transfer Tracking (NOT STARTED)
-- NEEDS: Transfer portal integration
-- NEEDS: 4-year college tracking
-- NEEDS: Transfer timeline
-- STATUS: 0% complete
-
-### Showcase Coach Features
-
-SHOW-001: Multi-Team Management (INCOMPLETE)
-- Teams listing page partially built
-- NEEDS: Team switcher dropdown
-- NEEDS: Per-team roster pages (/team/[id]/roster)
-- NEEDS: Per-team videos, calendar
-- NEEDS: Organization-level dashboard
-- STATUS: 35% complete
-
-SHOW-002: Showcase Events Management (PARTIAL)
-- Basic events page exists
-- NEEDS: Event registration
-- NEEDS: Event analytics
-- NEEDS: Multi-team event coordination
-- STATUS: 30% complete
-
-### Player Features
-
-PLAYER-005: Multi-Team Support (NOT IMPLEMENTED)
-- Currently players can only join 1 team
-- NEEDS: Allow 2 teams (HS + Showcase)
-- NEEDS: Team switcher dropdown
-- NEEDS: Isolated team contexts
-- STATUS: 0% complete
-
-PLAYER-006: College Discovery (INCOMPLETE)
-- Browse colleges page exists
-- NEEDS: Filter by division, conference, location
-- NEEDS: Save to "dream schools"
-- NEEDS: School comparison
-- STATUS: 40% complete
-
-PLAYER-007: Recruiting Activation Flow (BASIC)
-- Basic activation works
-- NEEDS: Privacy settings review modal
-- NEEDS: Terms acceptance
-- NEEDS: Benefits explanation
-- NEEDS: Anonymous vs Identified interest UI
-- STATUS: 60% complete
-
-### Video Features
-
-VIDEO-002: Video Clipping Tool (DATABASE READY, NO UI)
-- Database has is_clip and parent_video_id fields
-- NEEDS: Clip editor UI component
-- NEEDS: Timeline scrubber
-- NEEDS: Set start/end times
-- NEEDS: Save clips as separate video records
-- STATUS: 20% complete
-
-### Comparison Features
-
-RECRUIT-006: Advanced Player Comparison (PARTIAL)
-- Basic comparison works
-- NEEDS: Radar chart overlay
-- NEEDS: Save comparisons feature (table exists)
-- NEEDS: Export comparison to PDF
-- STATUS: 60% complete
-
-### Public Profiles
-
-PUB-001: Public Player Profiles (BASIC)
-- Basic layout exists
-- NEEDS: Privacy settings enforcement
-- NEEDS: Video embeds
-- NEEDS: Achievement/honors display
-- NEEDS: Recruiting status visibility
-- STATUS: 50% complete
-
-PUB-002: Public Program Profiles (BASIC)
-- Basic structure exists
-- NEEDS: Full content display
-- NEEDS: SEO optimization
-- NEEDS: Roster preview
-- STATUS: 40% complete
-
-### Golf Platform Enhancements
-
-GOLF-004: Golf Round Management (PARTIAL)
-- Basic round tracking exists
-- NEEDS: Round history
-- NEEDS: Statistics aggregation
-- NEEDS: Performance analytics
-- STATUS: 40% complete
-
-GOLF-005: Golf Team Management (PARTIAL)
-- Basic team structure exists
-- NEEDS: Multi-player tracking
-- NEEDS: Team statistics
-- NEEDS: Tournament management
-- STATUS: 35% complete
-
----
-
-## SECTION 3: PLANNED FEATURES 🚀
-
-### Critical Priority (P0) - Fix Core Gaps
-
-CORE-001: Implement JUCO Mode Toggle
-- Wire up ModeToggle component
-- Create routing logic for mode switching
-- Separate recruiting and team dashboards for JUCO
-- Mode state persistence
-- PRIORITY: P0 - CRITICAL
-
-CORE-002: Implement Multi-Team Support for Players
-- Database schema update for multiple team memberships
-- Team switcher dropdown component
-- Isolated team contexts
-- Team selection state management
-- Allow HS + Showcase team combinations
-- PRIORITY: P0 - CRITICAL
-
-CORE-003: Complete HS Coach Dashboard
-- HS-specific team metrics
-- Player development overview
-- College interest notifications
-- Academic tracking integration
-- Parent portal preparation
-- PRIORITY: P0 - CRITICAL
-
-CORE-004: Separate Golf Platform
-- Move golf app to separate directory or repository
-- Independent routing
-- Separate authentication context
-- Clear documentation
-- PRIORITY: P0 - CRITICAL
-
-CORE-005: Remove Dead Code
-- Delete unused peek panel components
-- Remove duplicate pipeline implementations
-- Clean up test/dev files
-- Remove deprecated components
-- PRIORITY: P0 - CRITICAL
-
-### High Priority (P1) - Complete Partially Built Features
-
-FEATURE-001: Complete Video Clipping System
-- Build clip editor UI
-- Timeline scrubber component
-- Start/end time selection
-- Preview clip before saving
-- Save clips as separate video records
-- Clip tagging (at-bat, pitch, etc.)
-- PRIORITY: P1
-
-FEATURE-002: Implement Notifications System
-- Notification bell component integration
-- Real-time notifications with Supabase Realtime
-- Email notifications
-- Push notifications (PWA)
-- Notification preferences
-- Mark as read/unread
-- PRIORITY: P1
-
-FEATURE-003: Complete Player Comparison Tool
-- Add radar chart overlay (Recharts)
-- Save comparison feature (use existing table)
-- Comparison history
-- Export comparison to PDF
-- Share comparison link
-- PRIORITY: P1
-
-FEATURE-004: Complete Showcase Coach Multi-Team Management
-- Organization-level dashboard
-- Per-team routing (/coach/showcase/team/[id]/...)
-- Team switcher component
-- Multi-team calendar view
-- Cross-team analytics
-- PRIORITY: P1
-
-FEATURE-005: Anonymous vs Identified Interest System
-- UI to show "A D1 coach viewed" vs "Coach John Smith from Texas A&M viewed"
-- Privacy settings enforcement
-- Recruiting activation benefits
-- Activation flow improvements
-- PRIORITY: P1
-
-### Medium Priority (P2) - New Features
-
-FEATURE-006: Complete Developmental Plans System
-- Drill library with videos
-- Progress tracking dashboard
-- Player goal setting
-- Coach feedback system
-- Milestone achievements
-- PRIORITY: P2
-
-FEATURE-007: Academics Tracking System
-- Academic records database schema
-- GPA tracking over time
-- Transcripts upload
-- Academic eligibility tracking
-- Semester/year breakdown
-- PRIORITY: P2
-
-FEATURE-008: College Interest Analytics
-- Full engagement analytics
-- Notifications when coaches view players
-- Interest timeline
-- School engagement heatmap
-- PRIORITY: P2
-
-FEATURE-009: Advanced Search System
-- Global search with Command Palette
-- Saved searches (use existing UI)
-- Search history
-- Advanced filters
-- Quick keyboard shortcuts
-- PRIORITY: P2
-
-FEATURE-010: College Discovery Enhancements
-- Filter by division, conference, location
-- School comparison tool
-- Dream schools list
-- School match scoring
-- PRIORITY: P2
-
-### Golf Platform Expansion (P2)
-
-GOLF-006: Complete Golf Round Management
-- Detailed round history
-- Statistics aggregation
-- Performance analytics
-- Handicap calculation
-- Round comparison
-- PRIORITY: P2
-
-GOLF-007: Golf Tournament System
-- Tournament creation and management
-- Leaderboards
-- Live scoring
-- Tournament analytics
-- Team tournaments
-- PRIORITY: P2
-
-GOLF-008: Golf Player Development
-- Swing analysis
-- Practice tracking
-- Goal setting
-- Performance trends
-- Coach feedback
-- PRIORITY: P2
-
-GOLF-009: Golf Team Statistics
-- Team performance metrics
-- Player comparison
-- Season statistics
-- Team rankings
-- PRIORITY: P2
-
-GOLF-010: Golf Course Management
-- Course database
-- Course ratings
-- Hole details
-- Yardage tracking
-- PRIORITY: P2
-
-### Low Priority (P3) - Future Enhancements
-
-FUTURE-001: Mobile App
-- React Native or PWA
-- Push notifications
-- Offline support
-- Mobile-optimized UI
-- PRIORITY: P3
-
-FUTURE-002: Advanced Analytics
-- Predictive analytics for recruiting
-- ML-based player matching
-- Trend analysis
-- Custom reports
-- PRIORITY: P3
-
-FUTURE-003: Parent Portal
-- Parent accounts
-- Communication system
-- Academic tracking access
-- Recruiting updates
-- PRIORITY: P3
-
-FUTURE-004: Payment System
-- Subscription management
-- Camp payment processing
-- Premium features
-- Billing dashboard
-- PRIORITY: P3
-
-FUTURE-005: Social Features
-- Activity feed
-- Player achievements sharing
-- Team announcements
-- Social media integration
-- PRIORITY: P3
-
----
-
-## TECHNICAL DEBT & IMPROVEMENTS
-
-TECH-001: Type System Cleanup
-- Ensure all types from @/lib/types
-- Remove deprecated type imports
-- Add missing type definitions
-- PRIORITY: P1
-
-TECH-002: Performance Optimization
-- Image optimization
-- Lazy loading
-- Code splitting
-- Bundle size reduction
-- PRIORITY: P2
-
-TECH-003: Testing Infrastructure
-- E2E tests with Playwright
-- Component tests with Vitest
-- API tests
-- Test coverage > 80%
-- PRIORITY: P2
-
-TECH-004: Documentation Updates
-- Update CLAUDE.md with golf platform
-- Document all hooks
-- API documentation
-- Component documentation
-- PRIORITY: P2
-
-TECH-005: Accessibility Improvements
-- WCAG 2.1 AA compliance
-- Keyboard navigation
-- Screen reader support
-- Color contrast fixes
-- PRIORITY: P2
-
----
-
-## SUCCESS METRICS
-
-### Baseball Platform
-- College Coach adoption: 100+ programs
-- Player registrations: 1000+ players
-- Active recruiting conversations: 500+/month
-- Camp registrations: 50+/month
-- Platform engagement: 70% weekly active users
-
-### Golf Platform
-- Coach adoption: 50+ programs
-- Player registrations: 500+ players
-- Rounds tracked: 1000+/month
-- Shot tracking usage: 80% of rounds
-- Platform engagement: 60% weekly active users
-
----
-
-## RELEASE TIMELINE
-
-### Phase 1: Critical Fixes (Weeks 1-2)
-- CORE-001: JUCO Mode Toggle
-- CORE-003: HS Coach Dashboard
-- CORE-004: Separate Golf Platform
-- CORE-005: Remove Dead Code
-
-### Phase 2: Complete Partial Features (Weeks 3-4)
-- CORE-002: Multi-Team Support
-- FEATURE-001: Video Clipping
-- FEATURE-003: Player Comparison
-- FEATURE-005: Anonymous Interest
-
-### Phase 3: New Features (Weeks 5-8)
-- FEATURE-002: Notifications
-- FEATURE-004: Showcase Multi-Team
-- FEATURE-006: Dev Plans
-- FEATURE-007: Academics
-
-### Phase 4: Golf Expansion (Weeks 9-12)
-- GOLF-006: Round Management
-- GOLF-007: Tournament System
-- GOLF-008: Player Development
-- GOLF-009: Team Statistics
-
-### Phase 5: Future Enhancements (Weeks 13+)
-- FUTURE-001: Mobile App
-- FUTURE-002: Advanced Analytics
-- FUTURE-003: Parent Portal
-- FUTURE-004: Payment System
-
----
-
-END OF PRD
diff --git a/.taskmaster/logs/.gitkeep b/.taskmaster/logs/.gitkeep
deleted file mode 100644
index 4fcd85293..000000000
--- a/.taskmaster/logs/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-# TaskMaster logs directory
diff --git a/.taskmaster/state.json b/.taskmaster/state.json
deleted file mode 100644
index a9206ae26..000000000
--- a/.taskmaster/state.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "currentTag": "master",
- "lastSwitched": "2025-12-28T22:06:23.990Z",
- "branchTagMapping": {},
- "migrationNoticeShown": false
-}
\ No newline at end of file
diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json
deleted file mode 100644
index 6a5d01509..000000000
--- a/.taskmaster/tasks/tasks.json
+++ /dev/null
@@ -1,70 +0,0 @@
-{
- "master": {
- "tasks": [
- {
- "id": "HELM-001",
- "title": "Premium Dark Scorecard",
- "description": "Transform scorecard to premium dark theme with impossible-to-miss current hole",
- "status": "done",
- "priority": "high",
- "category": "feature",
- "assignee": "claude",
- "created": "2025-12-21",
- "updated": "2025-12-21",
- "completed": "2025-12-21",
- "tags": [
- "ui",
- "scorecard",
- "golf"
- ],
- "files": [
- "src/components/golf/ShotTrackingFinal.tsx"
- ],
- "commits": [
- "f6625a5"
- ],
- "notes": [
- "Dark theme with slate-900/800 gradient",
- "Current hole: green gradient, scale-110, pulsing dot",
- "Color-coded scores (Eagle: yellow, Birdie: red, etc.)",
- "Performance badges showing +/- on current hole"
- ]
- },
- {
- "id": "HELM-002",
- "title": "Fix Shot Distance Calculation Bug",
- "description": "Fix yards/feet unit conversion bug causing incorrect distance display",
- "status": "done",
- "priority": "critical",
- "category": "bug",
- "assignee": "claude",
- "created": "2025-12-21",
- "updated": "2025-12-21",
- "completed": "2025-12-21",
- "tags": [
- "bug",
- "calculation",
- "golf",
- "units"
- ],
- "files": [
- "src/components/golf/ShotTrackingFinal.tsx"
- ],
- "commits": [
- "f6625a5"
- ],
- "notes": [
- "Added shotDistanceUnit field to ShotRecord interface",
- "Store unit during calculation instead of guessing",
- "Fixed: Shot 1 now shows '1105 feet' not '1105 yards'",
- "Improved unit detection: Shot 1 always yards OR distance > 100"
- ]
- }
- ],
- "metadata": {
- "totalTasks": 2,
- "completedTasks": 2,
- "lastUpdated": "2025-12-21T22:45:00Z"
- }
- }
-}
\ No newline at end of file
diff --git a/.taskmaster/templates/task-template.json b/.taskmaster/templates/task-template.json
deleted file mode 100644
index f0acdaa98..000000000
--- a/.taskmaster/templates/task-template.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "id": "HELM-XXX",
- "title": "",
- "description": "",
- "status": "todo",
- "priority": "medium",
- "category": "feature",
- "assignee": "",
- "created": "",
- "updated": "",
- "completed": null,
- "tags": [],
- "files": [],
- "commits": [],
- "notes": [],
- "subtasks": [],
- "dependencies": [],
- "blockedBy": [],
- "estimatedHours": null,
- "actualHours": null
-}
diff --git a/age-ratings-snapshot.yml b/age-ratings-snapshot.yml
deleted file mode 100644
index 442f2319d..000000000
--- a/age-ratings-snapshot.yml
+++ /dev/null
@@ -1,209 +0,0 @@
-- generic [ref=e1]:
- - banner "App Store Connect" [ref=e3]:
- - generic [ref=e4]:
- - heading "App Store Connect" [level=1] [ref=e6]:
- - link "App Store Connect" [ref=e7] [cursor=pointer]:
- - /url: /
- - navigation "Global" [ref=e8]:
- - list [ref=e10]:
- - listitem [ref=e11]:
- - link "Apps" [ref=e12] [cursor=pointer]:
- - /url: /apps
- - listitem [ref=e13]:
- - link "Trends" [ref=e14] [cursor=pointer]:
- - /url: /trends
- - listitem [ref=e15]:
- - link "Reports" [ref=e16] [cursor=pointer]:
- - /url: /itc/payments_and_financial_reports
- - listitem [ref=e17]:
- - link "Business" [ref=e18] [cursor=pointer]:
- - /url: /business
- - listitem [ref=e19]:
- - link "Users and Access" [ref=e20] [cursor=pointer]:
- - /url: /access/users
- - button "Rick Nini NICHOLAS JAMES RINI Account name menu" [ref=e22] [cursor=pointer]:
- - generic:
- - generic: Rick Nini
- - generic: NICHOLAS JAMES RINI
- - img [ref=e23]
- - generic [ref=e54]:
- - button "Apps menu, Helm Sports Labs, selected" [ref=e59] [cursor=pointer]:
- - generic [ref=e60]:
- - generic "Helm Sports Labs" [ref=e61]:
- - img "Helm Sports Labs" [ref=e62]
- - generic [ref=e64]: Helm Sports Labs
- - img [ref=e66]
- - navigation "Apps" [ref=e69]:
- - list [ref=e70]:
- - listitem [ref=e71]:
- - link "Distribution" [ref=e72] [cursor=pointer]:
- - /url: /apps/6761740758/distribution
- - listitem [ref=e73]:
- - link "Analytics" [ref=e74] [cursor=pointer]:
- - /url: /apps/6761740758/analytics
- - listitem [ref=e75]:
- - link "TestFlight" [ref=e76] [cursor=pointer]:
- - /url: /teams/7ea6779d-f797-4a55-9604-73fb1b7eccd9/apps/6761740758/testflight
- - listitem [ref=e77]:
- - link "Xcode Cloud" [ref=e78] [cursor=pointer]:
- - /url: /teams/7ea6779d-f797-4a55-9604-73fb1b7eccd9/apps/6761740758/ci
- - generic [ref=e81]:
- - main [ref=e82]:
- - generic [ref=e87]:
- - navigation "Distribution" [ref=e89]:
- - list [ref=e90]:
- - listitem [ref=e91]:
- - generic [ref=e92]:
- - heading "iOS App" [level=2] [ref=e94]
- - list [ref=e95]
- - button "Add Platform" [ref=e101] [cursor=pointer]
- - listitem [ref=e102]:
- - separator [ref=e103]
- - listitem [ref=e104]:
- - heading "General" [level=2] [ref=e106]
- - list [ref=e107]:
- - listitem [ref=e108]
- - listitem [ref=e111]
- - listitem [ref=e114]
- - listitem [ref=e117]:
- - separator [ref=e118]
- - listitem [ref=e119]:
- - heading "App Store" [level=2] [ref=e121]
- - generic [ref=e122]:
- - heading "Trust & Safety" [level=3] [ref=e124]
- - list [ref=e125]
- - generic [ref=e135]:
- - heading "Growth & Marketing" [level=3] [ref=e137]
- - list [ref=e138]
- - generic [ref=e154]:
- - heading "Monetization" [level=3] [ref=e156]
- - list [ref=e157]
- - generic [ref=e167]:
- - heading "Featuring" [level=3] [ref=e169]
- - list [ref=e170]
- - generic [ref=e174]:
- - generic:
- - generic [ref=e175]:
- - generic [ref=e176]:
- - generic [ref=e178]:
- - generic [ref=e180]
- - generic [ref=e185]
- - generic:
- - generic [ref=e189]:
- - generic [ref=e191]
- - generic [ref=e205]
- - generic [ref=e228]:
- - heading "General Information" [level=3] [ref=e231]
- - generic [ref=e232]
- - generic [ref=e304]:
- - separator [ref=e305]
- - heading "Age Ratings" [level=3] [ref=e306]
- - paragraph [ref=e307]:
- - generic [ref=e308]
- - button "Set Up Age Ratings" [ref=e313] [cursor=pointer]
- - generic [ref=e314]:
- - separator [ref=e315]
- - generic [ref=e317]:
- - heading "Learn More About Age Ratings" [level=3] [ref=e318]
- - paragraph [ref=e319]: To learn more about and view examples of the features and content used to determine age ratings, select a category below.
- - generic [ref=e321]:
- - generic [ref=e322] [cursor=pointer]
- - generic [ref=e327] [cursor=pointer]
- - generic [ref=e332] [cursor=pointer]
- - generic [ref=e337] [cursor=pointer]
- - generic [ref=e342] [cursor=pointer]
- - generic [ref=e347] [cursor=pointer]
- - generic [ref=e352] [cursor=pointer]
- - generic [ref=e357]:
- - separator [ref=e358]
- - generic [ref=e359]:
- - heading "App Encryption Documentation" [level=3] [ref=e360]
- - button "Upload" [ref=e361] [cursor=pointer]
- - paragraph [ref=e364]:
- - generic [ref=e365]
- - generic [ref=e368]:
- - paragraph [ref=e369]: "You're required to provide documentation if your app contains any of the following:"
- - list [ref=e370]
- - generic [ref=e374]:
- - paragraph [ref=e375]: You can provide your documentation before you submit a build.
- - button "Upload" [ref=e376] [cursor=pointer]
- - generic [ref=e377]:
- - heading "App Store Regulations & Permits" [level=3] [ref=e378]
- - generic [ref=e379]:
- - generic [ref=e380]
- - generic [ref=e384]
- - generic [ref=e392]
- - separator [ref=e401]
- - generic [ref=e403]:
- - heading "App Store Server Notifications" [level=3] [ref=e404]
- - paragraph [ref=e405]:
- - generic [ref=e406]
- - generic [ref=e408]:
- - generic [ref=e409]
- - generic [ref=e415]
- - separator [ref=e421]
- - generic [ref=e422]:
- - heading "App-Specific Shared Secret" [level=3] [ref=e423]
- - paragraph [ref=e424]: The app-specific shared secret is a unique code to receive receipts for only this app’s auto-renewable subscriptions. You may want to use an app-specific shared secret if you’re transferring this app to another developer, or if you want to keep your primary shared secret private.
- - paragraph [ref=e425]:
- - button "Manage" [ref=e426] [cursor=pointer]
- - separator [ref=e427]
- - generic [ref=e428]:
- - heading "Additional Information" [level=3] [ref=e429]
- - paragraph [ref=e430]:
- - button "View on App Store" [ref=e431] [cursor=pointer]
- - paragraph [ref=e432]:
- - button "Edit User Access" [ref=e433] [cursor=pointer]
- - paragraph [ref=e434]:
- - button "Remove App" [ref=e435] [cursor=pointer]
- - 'dialog "Age Ratings Step 1: Features" [active] [ref=e440]':
- - document [ref=e441]:
- - generic [ref=e442]:
- - 'heading "Age Ratings Step 1: Features" [level=2] [ref=e443]':
- - text: Age Ratings
- - list [ref=e444]:
- - listitem [ref=e445]:
- - generic [ref=e446]: "Step 1: Features"
- - listitem [ref=e448]:
- - generic [ref=e449]: Step 2
- - listitem [ref=e451]:
- - generic [ref=e452]: Step 3
- - listitem [ref=e454]:
- - generic [ref=e455]: Step 4
- - listitem [ref=e457]:
- - generic [ref=e458]: Step 5
- - listitem [ref=e460]:
- - generic [ref=e461]: Step 6
- - listitem [ref=e463]:
- - generic [ref=e464]: Step 7
- - generic [ref=e466]:
- - paragraph [ref=e468]: Select whether your app has certain in-app controls, which can be used to limit the content in your app, and capabilities.
- - generic [ref=e469]:
- - table [ref=e470]:
- - rowgroup [ref=e471]
- - rowgroup [ref=e481]
- - table [ref=e508]:
- - rowgroup [ref=e509]
- - rowgroup [ref=e519]
- - generic [ref=e574]:
- - button "Cancel" [ref=e575] [cursor=pointer]
- - button "Next" [disabled] [ref=e576]
- - contentinfo [ref=e38]:
- - generic [ref=e39]:
- - list [ref=e40]:
- - listitem [ref=e41]:
- - link "App Store Connect" [ref=e42] [cursor=pointer]:
- - /url: /apps
- - list [ref=e43]:
- - listitem [ref=e44]: Copyright © 2026 Apple Inc. All rights reserved. |
- - listitem [ref=e45]:
- - link "Terms of Service" [ref=e46] [cursor=pointer]:
- - /url: /WebObjects/iTunesConnect.woa/wa/termsOfService
- - text: "|"
- - listitem [ref=e47]:
- - link "Privacy Policy" [ref=e48] [cursor=pointer]:
- - /url: https://www.apple.com/legal/privacy
- - text: "|"
- - listitem [ref=e49]:
- - link "Contact Us" [ref=e50] [cursor=pointer]:
- - /url: /contact-us
\ No newline at end of file
diff --git a/app-info-snapshot.yml b/app-info-snapshot.yml
deleted file mode 100644
index 19e6a9905..000000000
--- a/app-info-snapshot.yml
+++ /dev/null
@@ -1,242 +0,0 @@
-- generic [active] [ref=e1]:
- - banner "App Store Connect" [ref=e3]:
- - generic [ref=e4]:
- - heading "App Store Connect" [level=1] [ref=e6]:
- - link "App Store Connect" [ref=e7] [cursor=pointer]:
- - /url: /
- - navigation "Global" [ref=e8]:
- - list [ref=e10]:
- - listitem [ref=e11]:
- - link "Apps" [ref=e12] [cursor=pointer]:
- - /url: /apps
- - listitem [ref=e13]:
- - link "Trends" [ref=e14] [cursor=pointer]:
- - /url: /trends
- - listitem [ref=e15]:
- - link "Reports" [ref=e16] [cursor=pointer]:
- - /url: /itc/payments_and_financial_reports
- - listitem [ref=e17]:
- - link "Business" [ref=e18] [cursor=pointer]:
- - /url: /business
- - listitem [ref=e19]:
- - link "Users and Access" [ref=e20] [cursor=pointer]:
- - /url: /access/users
- - button "Rick Nini NICHOLAS JAMES RINI Account name menu" [ref=e22] [cursor=pointer]:
- - generic:
- - generic: Rick Nini
- - generic: NICHOLAS JAMES RINI
- - img [ref=e23]
- - generic [ref=e54]:
- - button "Apps menu, Helm Sports Labs, selected" [ref=e59] [cursor=pointer]:
- - generic [ref=e60]:
- - generic "Helm Sports Labs" [ref=e61]:
- - img "Helm Sports Labs" [ref=e62]
- - generic [ref=e64]: Helm Sports Labs
- - img [ref=e66]
- - navigation "Apps" [ref=e69]:
- - list [ref=e70]:
- - listitem [ref=e71]:
- - link "Distribution" [ref=e72] [cursor=pointer]:
- - /url: /apps/6761740758/distribution
- - listitem [ref=e73]:
- - link "Analytics" [ref=e74] [cursor=pointer]:
- - /url: /apps/6761740758/analytics
- - listitem [ref=e75]:
- - link "TestFlight" [ref=e76] [cursor=pointer]:
- - /url: /teams/7ea6779d-f797-4a55-9604-73fb1b7eccd9/apps/6761740758/testflight
- - listitem [ref=e77]:
- - link "Xcode Cloud" [ref=e78] [cursor=pointer]:
- - /url: /teams/7ea6779d-f797-4a55-9604-73fb1b7eccd9/apps/6761740758/ci
- - main [ref=e82]:
- - generic [ref=e87]:
- - navigation "Distribution" [ref=e89]:
- - list [ref=e90]:
- - listitem [ref=e91]:
- - generic [ref=e92]:
- - heading "iOS App" [level=2] [ref=e94]
- - list [ref=e95]:
- - listitem [ref=e96]
- - button "Add Platform" [ref=e101] [cursor=pointer]
- - listitem [ref=e102]:
- - separator [ref=e103]
- - listitem [ref=e104]:
- - heading "General" [level=2] [ref=e106]
- - list [ref=e107]:
- - listitem [ref=e108]:
- - link "App Information" [ref=e109] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/info
- - listitem [ref=e111]:
- - link "App Review" [ref=e112] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/reviewsubmissions
- - listitem [ref=e114]:
- - link "History" [ref=e115] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/activity/ios/versions
- - listitem [ref=e117]:
- - separator [ref=e118]
- - listitem [ref=e119]:
- - heading "App Store" [level=2] [ref=e121]
- - generic [ref=e122]:
- - heading "Trust & Safety" [level=3] [ref=e124]
- - list [ref=e125]:
- - listitem [ref=e126]
- - listitem [ref=e129]
- - listitem [ref=e132]
- - generic [ref=e135]:
- - heading "Growth & Marketing" [level=3] [ref=e137]
- - list [ref=e138]:
- - listitem [ref=e139]
- - listitem [ref=e142]
- - listitem [ref=e145]
- - listitem [ref=e148]
- - listitem [ref=e151]
- - generic [ref=e154]:
- - heading "Monetization" [level=3] [ref=e156]
- - list [ref=e157]:
- - listitem [ref=e158]
- - listitem [ref=e161]
- - listitem [ref=e164]
- - generic [ref=e167]:
- - heading "Featuring" [level=3] [ref=e169]
- - list [ref=e170]:
- - listitem [ref=e171]
- - generic [ref=e174]:
- - generic:
- - generic [ref=e175]:
- - generic [ref=e176]:
- - generic [ref=e178]:
- - generic [ref=e180]:
- - heading "App Information" [level=2] [ref=e182]
- - paragraph [ref=e184]: This information is used for all platforms of this app. Any changes will be released with your next app version.
- - generic [ref=e185]:
- - button "Save"
- - generic:
- - generic [ref=e189]:
- - generic [ref=e191]:
- - heading "Localizable Information" [level=3] [ref=e193]
- - generic [ref=e197]
- - generic [ref=e205]:
- - generic [ref=e206]
- - generic [ref=e217]
- - generic [ref=e228]:
- - heading "General Information" [level=3] [ref=e231]
- - generic [ref=e232]:
- - generic [ref=e234]
- - generic [ref=e281]
- - generic [ref=e304]:
- - separator [ref=e305]
- - heading "Age Ratings" [level=3] [ref=e306]
- - paragraph [ref=e307]:
- - generic [ref=e308]:
- - text: Age ratings help users better understand if your app contains any objectionable content. To determine your app's age rating, you'll be asked the availability or frequency of certain features and types of content within your app. Based on your responses, an age rating will be assigned for each country or region based on their age suitability standards. The assigned age rating will appear on each country or region's App Store and be the same across all platforms in that country or region.
- - link "Learn More" [ref=e309] [cursor=pointer]:
- - /url: https://developer.apple.com/help/app-store-connect/reference/age-ratings-values-and-definitions
- - button "Set Up Age Ratings" [ref=e313] [cursor=pointer]
- - generic [ref=e314]:
- - separator [ref=e315]
- - generic [ref=e317]:
- - heading "Learn More About Age Ratings" [level=3] [ref=e318]
- - paragraph [ref=e319]: To learn more about and view examples of the features and content used to determine age ratings, select a category below.
- - generic [ref=e321]:
- - generic [ref=e322] [cursor=pointer]:
- - paragraph [ref=e324]: In-App Controls
- - paragraph [ref=e326]: Parental Controls, Age Assurance
- - generic [ref=e327] [cursor=pointer]:
- - paragraph [ref=e329]: Capabilities
- - paragraph [ref=e331]: Unrestricted Web Access, User-Generated Content, Messaging and Chat, Advertising
- - generic [ref=e332] [cursor=pointer]:
- - paragraph [ref=e334]: Mature Themes
- - paragraph [ref=e336]: Profanity or Crude Humor, Horror/Fear Themes, Alcohol, Tobacco, or Drug Use or References
- - generic [ref=e337] [cursor=pointer]:
- - paragraph [ref=e339]: Medical or Wellness
- - paragraph [ref=e341]: Medical or Treatment Information, Health or Wellness Topics
- - generic [ref=e342] [cursor=pointer]:
- - paragraph [ref=e344]: Sexuality or Nudity
- - paragraph [ref=e346]: Mature or Suggestive Themes, Sexual Content or Nudity, Graphic Sexual Content and Nudity
- - generic [ref=e347] [cursor=pointer]:
- - paragraph [ref=e349]: Violence
- - paragraph [ref=e351]: Cartoon or Fantasy Violence, Realistic Violence, Prolonged Graphic or Sadistic Realistic Violence, Guns or Other Weapons
- - generic [ref=e352] [cursor=pointer]:
- - paragraph [ref=e354]: Chance-Based Activities
- - paragraph [ref=e356]: Gambling, Simulated Gambling, Contests, Loot Boxes
- - generic [ref=e357]:
- - separator [ref=e358]
- - generic [ref=e359]:
- - heading "App Encryption Documentation" [level=3] [ref=e360]
- - button "Upload" [ref=e361] [cursor=pointer]:
- - img [ref=e362]
- - paragraph [ref=e364]:
- - generic [ref=e365]:
- - text: Specify your use of encryption in Xcode by adding the
- - strong [ref=e366]: App Uses Non-Exempt Encryption
- - text: key to your app's Info.plist file with a Boolean value that indicates whether your app uses encryption.
- - link "Learn More" [ref=e367] [cursor=pointer]:
- - /url: https://developer.apple.com/documentation/security/complying_with_encryption_export_regulations
- - generic [ref=e368]:
- - paragraph [ref=e369]: "You're required to provide documentation if your app contains any of the following:"
- - list [ref=e370]:
- - listitem [ref=e371]: Encryption algorithms that are proprietary or not accepted as standard by international standard bodies (IEEE, IETF, ITU, etc.)
- - listitem [ref=e372]: Standard encryption algorithms instead of, or in addition to, using or accessing the encryption within Apple's operating system
- - generic [ref=e374]:
- - paragraph [ref=e375]: You can provide your documentation before you submit a build.
- - button "Upload" [ref=e376] [cursor=pointer]
- - generic [ref=e377]:
- - heading "App Store Regulations & Permits" [level=3] [ref=e378]
- - generic [ref=e379]:
- - generic [ref=e380]:
- - heading "Digital Services Act" [level=6] [ref=e381]
- - paragraph [ref=e382]
- - generic [ref=e384]:
- - heading "Vietnam Game License" [level=3] [ref=e385]
- - generic [ref=e387]
- - generic [ref=e392]:
- - heading "Regulated Medical Devices" [level=6] [ref=e394]
- - generic [ref=e396]
- - separator [ref=e401]
- - generic [ref=e403]:
- - heading "App Store Server Notifications" [level=3] [ref=e404]
- - paragraph [ref=e405]:
- - generic [ref=e406]:
- - text: App Store server notifications provide information about key events related to your in-app purchases. To test notifications before implementing them in production, you can set up a separate sandbox server URL.
- - link "Learn More" [ref=e407] [cursor=pointer]:
- - /url: https://developer.apple.com/help/app-store-connect/configure-in-app-purchase-settings/enter-server-urls-for-app-store-server-notifications
- - generic [ref=e408]:
- - generic [ref=e409]:
- - generic [ref=e411]: Production Server URL
- - button "Set Up URL" [ref=e414] [cursor=pointer]
- - generic [ref=e415]:
- - generic [ref=e417]: Sandbox Server URL
- - button "Set Up URL" [ref=e420] [cursor=pointer]
- - separator [ref=e421]
- - generic [ref=e422]:
- - heading "App-Specific Shared Secret" [level=3] [ref=e423]
- - paragraph [ref=e424]: The app-specific shared secret is a unique code to receive receipts for only this app’s auto-renewable subscriptions. You may want to use an app-specific shared secret if you’re transferring this app to another developer, or if you want to keep your primary shared secret private.
- - paragraph [ref=e425]:
- - button "Manage" [ref=e426] [cursor=pointer]
- - separator [ref=e427]
- - generic [ref=e428]:
- - heading "Additional Information" [level=3] [ref=e429]
- - paragraph [ref=e430]:
- - button "View on App Store" [ref=e431] [cursor=pointer]
- - paragraph [ref=e432]:
- - button "Edit User Access" [ref=e433] [cursor=pointer]
- - paragraph [ref=e434]:
- - button "Remove App" [ref=e435] [cursor=pointer]
- - contentinfo [ref=e38]:
- - generic [ref=e39]:
- - list [ref=e40]:
- - listitem [ref=e41]:
- - link "App Store Connect" [ref=e42] [cursor=pointer]:
- - /url: /apps
- - list [ref=e43]:
- - listitem [ref=e44]: Copyright © 2026 Apple Inc. All rights reserved. |
- - listitem [ref=e45]:
- - link "Terms of Service" [ref=e46] [cursor=pointer]:
- - /url: /WebObjects/iTunesConnect.woa/wa/termsOfService
- - text: "|"
- - listitem [ref=e47]:
- - link "Privacy Policy" [ref=e48] [cursor=pointer]:
- - /url: https://www.apple.com/legal/privacy
- - text: "|"
- - listitem [ref=e49]:
- - link "Contact Us" [ref=e50] [cursor=pointer]:
- - /url: /contact-us
\ No newline at end of file
diff --git a/full-snapshot.yml b/full-snapshot.yml
deleted file mode 100644
index c8702968f..000000000
--- a/full-snapshot.yml
+++ /dev/null
@@ -1,348 +0,0 @@
-- generic [active] [ref=e1]:
- - banner "App Store Connect" [ref=e3]:
- - generic [ref=e4]:
- - heading "App Store Connect" [level=1] [ref=e19]:
- - link "App Store Connect" [ref=e20] [cursor=pointer]:
- - /url: /
- - navigation "Global" [ref=e21]:
- - list [ref=e23]:
- - listitem [ref=e24]:
- - link "Apps" [ref=e25] [cursor=pointer]:
- - /url: /apps
- - listitem [ref=e26]:
- - link "Trends" [ref=e27] [cursor=pointer]:
- - /url: /trends
- - listitem [ref=e28]:
- - link "Reports" [ref=e29] [cursor=pointer]:
- - /url: /itc/payments_and_financial_reports
- - listitem [ref=e30]:
- - link "Business" [ref=e31] [cursor=pointer]:
- - /url: /business
- - listitem [ref=e32]:
- - link "Users and Access" [ref=e33] [cursor=pointer]:
- - /url: /access/users
- - button "Rick Nini NICHOLAS JAMES RINI Account name menu" [ref=e35] [cursor=pointer]:
- - generic:
- - generic: Rick Nini
- - generic: NICHOLAS JAMES RINI
- - img [ref=e36]
- - generic [ref=e41]:
- - button "Apps menu, Helm Sports Labs, selected" [ref=e46] [cursor=pointer]:
- - generic [ref=e47]:
- - generic "Helm Sports Labs" [ref=e48]:
- - img "Helm Sports Labs" [ref=e49]
- - generic [ref=e51]: Helm Sports Labs
- - img [ref=e53]
- - navigation "Apps" [ref=e56]:
- - list [ref=e57]:
- - listitem [ref=e58]:
- - link "Distribution" [ref=e59] [cursor=pointer]:
- - /url: /apps/6761740758/distribution
- - listitem [ref=e60]:
- - link "Analytics" [ref=e61] [cursor=pointer]:
- - /url: /apps/6761740758/analytics
- - listitem [ref=e62]:
- - link "TestFlight" [ref=e63] [cursor=pointer]:
- - /url: /teams/7ea6779d-f797-4a55-9604-73fb1b7eccd9/apps/6761740758/testflight
- - listitem [ref=e64]:
- - link "Xcode Cloud" [ref=e65] [cursor=pointer]:
- - /url: /teams/7ea6779d-f797-4a55-9604-73fb1b7eccd9/apps/6761740758/ci
- - main [ref=e72]:
- - generic [ref=e77]:
- - navigation "Distribution" [ref=e79]:
- - list [ref=e80]:
- - listitem [ref=e81]:
- - generic [ref=e82]:
- - heading "iOS App" [level=2] [ref=e84]
- - list [ref=e85]:
- - listitem [ref=e86]:
- - link "1.0 Prepare for Submission" [ref=e87] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/ios/version/inflight
- - generic [ref=e88]
- - button "Add Platform" [ref=e91] [cursor=pointer]
- - listitem [ref=e92]:
- - separator [ref=e93]
- - listitem [ref=e94]:
- - heading "General" [level=2] [ref=e96]
- - list [ref=e97]:
- - listitem [ref=e98]:
- - link "App Information" [ref=e99] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/info
- - generic [ref=e100]: App Information
- - listitem [ref=e101]:
- - link "App Review" [ref=e102] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/reviewsubmissions
- - generic [ref=e103]: App Review
- - listitem [ref=e104]:
- - link "History" [ref=e105] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/activity/ios/versions
- - generic [ref=e106]: History
- - listitem [ref=e107]:
- - separator [ref=e108]
- - listitem [ref=e109]:
- - heading "App Store" [level=2] [ref=e111]
- - generic [ref=e112]:
- - heading "Trust & Safety" [level=3] [ref=e114]
- - list [ref=e115]:
- - listitem [ref=e116]:
- - link "App Privacy" [ref=e117] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/privacy
- - generic [ref=e118]: App Privacy
- - listitem [ref=e119]:
- - link "App Accessibility" [ref=e120] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/accessibility
- - generic [ref=e121]: App Accessibility
- - listitem [ref=e122]:
- - link "Ratings and Reviews" [ref=e123] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/ratings/ios
- - generic [ref=e124]: Ratings and Reviews
- - generic [ref=e125]:
- - heading "Growth & Marketing" [level=3] [ref=e127]
- - list [ref=e128]:
- - listitem [ref=e129]:
- - link "In-App Events" [ref=e130] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/events
- - generic [ref=e131]: In-App Events
- - listitem [ref=e132]:
- - link "Custom Product Pages" [ref=e133] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/productpages
- - generic [ref=e134]: Custom Product Pages
- - listitem [ref=e135]:
- - link "Product Page Optimization" [ref=e136] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/optimization
- - generic [ref=e137]: Product Page Optimization
- - listitem [ref=e138]:
- - link "Promo Codes" [ref=e139] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/promo_codes/generate
- - generic [ref=e140]: Promo Codes
- - listitem [ref=e141]:
- - link "Game Center" [ref=e142] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/gamecenter
- - generic [ref=e143]: Game Center
- - generic [ref=e144]:
- - heading "Monetization" [level=3] [ref=e146]
- - list [ref=e147]:
- - listitem [ref=e148]:
- - link "Pricing and Availability" [ref=e149] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/pricing
- - generic [ref=e150]: Pricing and Availability
- - listitem [ref=e151]:
- - link "In-App Purchases" [ref=e152] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/iaps
- - generic [ref=e153]: In-App Purchases
- - listitem [ref=e154]:
- - link "Subscriptions" [ref=e155] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/subscriptions
- - generic [ref=e156]: Subscriptions
- - generic [ref=e157]:
- - heading "Featuring" [level=3] [ref=e159]
- - list [ref=e160]:
- - listitem [ref=e161]:
- - link "Nominations" [ref=e162] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/nominations
- - generic [ref=e163]: Nominations
- - generic [ref=e166]:
- - generic [ref=e167]:
- - generic [ref=e168]:
- - heading "iOS App Version 1.0" [level=2] [ref=e171]
- - generic [ref=e172]:
- - button "Save" [disabled] [ref=e173]
- - button "Add for Review" [ref=e174] [cursor=pointer]
- - separator [ref=e175]
- - generic [ref=e176]:
- - generic [ref=e179]:
- - paragraph [ref=e181]: The assets and metadata below appear on your app’s product page, when users install your app, and will be used for web engine search results once you release your app.
- - generic [ref=e184]:
- - button "English (U.S.)" [ref=e185] [cursor=pointer]:
- - text: English (U.S.)
- - img [ref=e186]
- - button "?" [ref=e190] [cursor=pointer]
- - separator [ref=e191]
- - generic [ref=e192]:
- - heading "Previews and Screenshots" [level=3] [ref=e193]
- - button "More information" [ref=e195] [cursor=pointer]: "?"
- - paragraph [ref=e196]: Adding accurate screenshots of your app on the newest devices can help you represent the app's user experience. Keep in mind that we'll use these screenshots for all display sizes and localizations. Screenshots are only required for iOS apps, and only the first 3 will be used on the app installation sheets.
- - generic [ref=e197]:
- - tablist [ref=e198]:
- - tab "iPhone" [selected] [ref=e199] [cursor=pointer]:
- - generic [ref=e200]: iPhone
- - tab "iPad" [ref=e201] [cursor=pointer]:
- - generic [ref=e202]: iPad
- - tab "Apple Watch" [ref=e203] [cursor=pointer]:
- - generic [ref=e204]: Apple Watch
- - paragraph [ref=e205]:
- - link "View All Sizes in Media Manager" [ref=e206] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/ios/version/inflight/media-manager/iphone
- - generic [ref=e208]:
- - generic:
- - alert
- - tabpanel "iPhone" [ref=e209]:
- - generic [ref=e210]:
- - generic [ref=e211]:
- - img [ref=e214]
- - generic [ref=e219]:
- - generic [ref=e220]: iPhone
- - generic [ref=e221]: 6.5" Display
- - region [ref=e222]:
- - tabpanel [ref=e223]:
- - generic [ref=e226]
- - generic [ref=e229]
- - generic [ref=e241]:
- - generic [ref=e242]:
- - generic [ref=e244]:
- - generic [ref=e245]: Promotional Text
- - button "More information" [ref=e247] [cursor=pointer]: "?"
- - textbox "Promotional Text" [ref=e249]
- - status "Characters remaining" [ref=e251]: "170"
- - separator [ref=e253]
- - generic [ref=e254]:
- - generic [ref=e256]:
- - generic [ref=e257]: Description
- - button "More information" [ref=e259] [cursor=pointer]: "?"
- - textbox "Description" [ref=e261]
- - status "Characters remaining" [ref=e263]: 4,000
- - generic [ref=e264]:
- - generic [ref=e266]:
- - generic [ref=e267]: Keywords
- - button "More information" [ref=e269] [cursor=pointer]: "?"
- - textbox "Keywords" [ref=e271]
- - status "Characters remaining" [ref=e273]: "100"
- - generic [ref=e274]:
- - generic [ref=e276]:
- - generic [ref=e277]: Support URL
- - button "More information" [ref=e279] [cursor=pointer]: "?"
- - textbox "Support URL" [ref=e281]
- - generic [ref=e282]:
- - generic [ref=e284]:
- - generic [ref=e285]: Marketing URL
- - button "More information" [ref=e287] [cursor=pointer]: "?"
- - textbox "Marketing URL" [ref=e289]
- - generic [ref=e290]:
- - generic [ref=e292]:
- - generic [ref=e293]: Version
- - button "More information" [ref=e295] [cursor=pointer]: "?"
- - textbox "Version" [ref=e297]: "1.0"
- - generic [ref=e298]:
- - generic [ref=e300]:
- - generic [ref=e301]: Copyright
- - button "More information" [ref=e303] [cursor=pointer]: "?"
- - textbox "Copyright" [ref=e305]
- - status "Characters remaining" [ref=e307]: "200"
- - generic [ref=e308]:
- - generic [ref=e310]:
- - generic [ref=e311]: Routing App Coverage File
- - button "More information" [ref=e313] [cursor=pointer]: "?"
- - generic [ref=e315] [cursor=pointer]:
- - button "Choose File" [ref=e316]
- - generic [ref=e318]: Choose File
- - separator [ref=e319]
- - button "App Clip" [ref=e321] [cursor=pointer]:
- - img [ref=e323]
- - text: App Clip
- - generic [ref=e326]:
- - heading "iMessage App" [level=3] [ref=e327]:
- - button "iMessage App" [ref=e328] [cursor=pointer]:
- - img [ref=e330]
- - text: iMessage App
- - button "More information" [ref=e333] [cursor=pointer]: "?"
- - generic [ref=e334]:
- - heading "Build" [level=3] [ref=e336]
- - generic [ref=e337]:
- - img [ref=e338]
- - paragraph [ref=e340]:
- - generic [ref=e341]:
- - text: If your app uses encryption, you're required to upload export compliance documentation. You can submit this documentation before you submit your app for review in the
- - link "App Encryption Documentation section" [ref=e342] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/info
- - text: ", or by uploading your app below."
- - paragraph [ref=e345]:
- - generic [ref=e346]:
- - text: Upload your builds using one of several tools.
- - link "See Upload Tools" [ref=e347] [cursor=pointer]:
- - /url: https://developer.apple.com/help/app-store-connect/manage-builds/upload-builds
- - generic [ref=e349]:
- - generic [ref=e353] [cursor=pointer]:
- - checkbox "Game Center" [ref=e355]
- - text: Game Center
- - generic [ref=e357]:
- - img [ref=e358]
- - paragraph [ref=e360]:
- - text: Game Center components can now be added for review directly from the Game Center section. You can no longer select them from the app version page.
- - link "Learn More" [ref=e361] [cursor=pointer]:
- - /url: https://developer.apple.com/help/app-store-connect/manage-submissions-to-app-review/submit-game-center-components
- - button [ref=e362] [cursor=pointer]:
- - img [ref=e363]
- - separator [ref=e365]
- - generic [ref=e366]:
- - heading "App Review Information" [level=3] [ref=e367]
- - generic [ref=e368]:
- - generic [ref=e369]:
- - generic [ref=e370]:
- - generic [ref=e372]:
- - generic [ref=e373]: Sign-In Information
- - button "More information" [ref=e375] [cursor=pointer]: "?"
- - paragraph [ref=e376]: Provide a user name and password so we can sign in to your app. We’ll need this to complete your app review.
- - generic [ref=e378]:
- - checkbox "Sign-in required" [checked] [ref=e379]
- - generic [ref=e381]: Sign-in required
- - textbox "User name" [ref=e384]
- - textbox "Password" [ref=e387]
- - generic [ref=e388]:
- - generic [ref=e390]:
- - generic [ref=e391]: Contact Information
- - button "More information" [ref=e393] [cursor=pointer]: "?"
- - textbox "First name" [ref=e396]
- - textbox "Last name" [ref=e399]
- - textbox "Phone number" [ref=e402]
- - textbox "Email" [ref=e405]
- - generic [ref=e406]:
- - generic [ref=e408]:
- - generic [ref=e409]: Notes
- - button "More information" [ref=e411] [cursor=pointer]: "?"
- - textbox "Notes" [ref=e413]
- - status "Characters remaining" [ref=e415]: 4,000
- - generic [ref=e416]:
- - generic [ref=e418]:
- - generic [ref=e419]: Attachment
- - button "More information" [ref=e421] [cursor=pointer]: "?"
- - generic [ref=e423] [cursor=pointer]:
- - button "Choose File (Optional)" [ref=e424]
- - generic [ref=e426]: Choose File (Optional)
- - separator [ref=e427]
- - generic [ref=e428]:
- - heading "App Store Version Release" [level=3] [ref=e429]
- - paragraph [ref=e430]: To make your app available on the App Store, you can automatically release it after it’s been approved by App Review. You can also manually release it on the App Store at a later date.
- - generic [ref=e431]:
- - generic [ref=e433]:
- - generic [ref=e434]:
- - radio "Manually release this version" [ref=e435]
- - generic [ref=e437]: Manually release this version
- - generic [ref=e438]:
- - radio "Automatically release this version" [checked] [ref=e439]
- - generic [ref=e441]: Automatically release this version
- - generic [ref=e442]:
- - radio "Automatically release this version after App Review, no earlier than" [ref=e443]
- - generic [ref=e445]: Automatically release this version after App Review, no earlier than
- - paragraph [ref=e447]: Your local date and time.
- - generic [ref=e450]:
- - textbox "Automatically release this version after App Review, no earlier than" [disabled] [ref=e451]: Apr 9, 2026 7:00 PM
- - text: EDT
- - contentinfo [ref=e9]:
- - generic [ref=e10]:
- - list [ref=e66]:
- - listitem [ref=e67]:
- - link "App Store Connect" [ref=e68] [cursor=pointer]:
- - /url: /apps
- - list [ref=e11]:
- - listitem [ref=e12]: Copyright © 2026 Apple Inc. All rights reserved. |
- - listitem [ref=e13]:
- - link "Terms of Service" [ref=e14] [cursor=pointer]:
- - /url: /WebObjects/iTunesConnect.woa/wa/termsOfService
- - text: "|"
- - listitem [ref=e15]:
- - link "Privacy Policy" [ref=e16] [cursor=pointer]:
- - /url: https://www.apple.com/legal/privacy
- - text: "|"
- - listitem [ref=e17]:
- - link "Contact Us" [ref=e18] [cursor=pointer]:
- - /url: /contact-us
\ No newline at end of file
diff --git a/full-snapshot2.yml b/full-snapshot2.yml
deleted file mode 100644
index 0195e620b..000000000
--- a/full-snapshot2.yml
+++ /dev/null
@@ -1,350 +0,0 @@
-- generic [ref=e1]:
- - banner "App Store Connect" [ref=e3]:
- - generic [ref=e4]:
- - heading "App Store Connect" [level=1] [ref=e19]:
- - link "App Store Connect" [ref=e20] [cursor=pointer]:
- - /url: /
- - navigation "Global" [ref=e21]:
- - list [ref=e23]:
- - listitem [ref=e24]:
- - link "Apps" [ref=e25] [cursor=pointer]:
- - /url: /apps
- - listitem [ref=e26]:
- - link "Trends" [ref=e27] [cursor=pointer]:
- - /url: /trends
- - listitem [ref=e28]:
- - link "Reports" [ref=e29] [cursor=pointer]:
- - /url: /itc/payments_and_financial_reports
- - listitem [ref=e30]:
- - link "Business" [ref=e31] [cursor=pointer]:
- - /url: /business
- - listitem [ref=e32]:
- - link "Users and Access" [ref=e33] [cursor=pointer]:
- - /url: /access/users
- - button "Rick Nini NICHOLAS JAMES RINI Account name menu" [ref=e35] [cursor=pointer]:
- - generic:
- - generic: Rick Nini
- - generic: NICHOLAS JAMES RINI
- - img [ref=e36]
- - generic [ref=e41]:
- - button "Apps menu, Helm Sports Labs, selected" [ref=e46] [cursor=pointer]:
- - generic [ref=e47]:
- - generic "Helm Sports Labs" [ref=e48]:
- - img "Helm Sports Labs" [ref=e49]
- - generic [ref=e51]: Helm Sports Labs
- - img [ref=e53]
- - navigation "Apps" [ref=e56]:
- - list [ref=e57]:
- - listitem [ref=e58]:
- - link "Distribution" [ref=e59] [cursor=pointer]:
- - /url: /apps/6761740758/distribution
- - listitem [ref=e60]:
- - link "Analytics" [ref=e61] [cursor=pointer]:
- - /url: /apps/6761740758/analytics
- - listitem [ref=e62]:
- - link "TestFlight" [ref=e63] [cursor=pointer]:
- - /url: /teams/7ea6779d-f797-4a55-9604-73fb1b7eccd9/apps/6761740758/testflight
- - listitem [ref=e64]:
- - link "Xcode Cloud" [ref=e65] [cursor=pointer]:
- - /url: /teams/7ea6779d-f797-4a55-9604-73fb1b7eccd9/apps/6761740758/ci
- - main [ref=e72]:
- - generic [ref=e77]:
- - navigation "Distribution" [ref=e79]:
- - list [ref=e80]:
- - listitem [ref=e81]:
- - generic [ref=e82]:
- - heading "iOS App" [level=2] [ref=e84]
- - list [ref=e85]:
- - listitem [ref=e86]:
- - link "1.0 Prepare for Submission" [ref=e87] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/ios/version/inflight
- - generic [ref=e88]
- - button "Add Platform" [ref=e91] [cursor=pointer]
- - listitem [ref=e92]:
- - separator [ref=e93]
- - listitem [ref=e94]:
- - heading "General" [level=2] [ref=e96]
- - list [ref=e97]:
- - listitem [ref=e98]:
- - link "App Information" [ref=e99] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/info
- - generic [ref=e100]: App Information
- - listitem [ref=e101]:
- - link "App Review" [ref=e102] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/reviewsubmissions
- - generic [ref=e103]: App Review
- - listitem [ref=e104]:
- - link "History" [ref=e105] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/activity/ios/versions
- - generic [ref=e106]: History
- - listitem [ref=e107]:
- - separator [ref=e108]
- - listitem [ref=e109]:
- - heading "App Store" [level=2] [ref=e111]
- - generic [ref=e112]:
- - heading "Trust & Safety" [level=3] [ref=e114]
- - list [ref=e115]:
- - listitem [ref=e116]:
- - link "App Privacy" [ref=e117] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/privacy
- - generic [ref=e118]: App Privacy
- - listitem [ref=e119]:
- - link "App Accessibility" [ref=e120] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/accessibility
- - generic [ref=e121]: App Accessibility
- - listitem [ref=e122]:
- - link "Ratings and Reviews" [ref=e123] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/ratings/ios
- - generic [ref=e124]: Ratings and Reviews
- - generic [ref=e125]:
- - heading "Growth & Marketing" [level=3] [ref=e127]
- - list [ref=e128]:
- - listitem [ref=e129]:
- - link "In-App Events" [ref=e130] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/events
- - generic [ref=e131]: In-App Events
- - listitem [ref=e132]:
- - link "Custom Product Pages" [ref=e133] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/productpages
- - generic [ref=e134]: Custom Product Pages
- - listitem [ref=e135]:
- - link "Product Page Optimization" [ref=e136] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/optimization
- - generic [ref=e137]: Product Page Optimization
- - listitem [ref=e138]:
- - link "Promo Codes" [ref=e139] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/promo_codes/generate
- - generic [ref=e140]: Promo Codes
- - listitem [ref=e141]:
- - link "Game Center" [ref=e142] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/gamecenter
- - generic [ref=e143]: Game Center
- - generic [ref=e144]:
- - heading "Monetization" [level=3] [ref=e146]
- - list [ref=e147]:
- - listitem [ref=e148]:
- - link "Pricing and Availability" [ref=e149] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/pricing
- - generic [ref=e150]: Pricing and Availability
- - listitem [ref=e151]:
- - link "In-App Purchases" [ref=e152] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/iaps
- - generic [ref=e153]: In-App Purchases
- - listitem [ref=e154]:
- - link "Subscriptions" [ref=e155] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/subscriptions
- - generic [ref=e156]: Subscriptions
- - generic [ref=e157]:
- - heading "Featuring" [level=3] [ref=e159]
- - list [ref=e160]:
- - listitem [ref=e161]:
- - link "Nominations" [ref=e162] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/nominations
- - generic [ref=e163]: Nominations
- - generic [ref=e166]:
- - generic [ref=e167]:
- - generic [ref=e168]:
- - heading "iOS App Version 1.0" [level=2] [ref=e171]
- - generic [ref=e172]:
- - button "Save" [ref=e173] [cursor=pointer]
- - button "Add for Review" [disabled] [ref=e174]
- - separator [ref=e175]
- - generic [ref=e176]:
- - generic [ref=e179]:
- - paragraph [ref=e181]: The assets and metadata below appear on your app’s product page, when users install your app, and will be used for web engine search results once you release your app.
- - generic [ref=e184]:
- - button "English (U.S.)" [ref=e185] [cursor=pointer]:
- - text: English (U.S.)
- - img [ref=e186]
- - button "?" [ref=e190] [cursor=pointer]
- - separator [ref=e191]
- - generic [ref=e192]:
- - heading "Previews and Screenshots" [level=3] [ref=e193]
- - button "More information" [ref=e195] [cursor=pointer]: "?"
- - paragraph [ref=e196]: Adding accurate screenshots of your app on the newest devices can help you represent the app's user experience. Keep in mind that we'll use these screenshots for all display sizes and localizations. Screenshots are only required for iOS apps, and only the first 3 will be used on the app installation sheets.
- - generic [ref=e197]:
- - tablist [ref=e198]:
- - tab "iPhone" [selected] [ref=e199] [cursor=pointer]:
- - generic [ref=e200]: iPhone
- - tab "iPad" [ref=e201] [cursor=pointer]:
- - generic [ref=e202]: iPad
- - tab "Apple Watch" [ref=e203] [cursor=pointer]:
- - generic [ref=e204]: Apple Watch
- - paragraph [ref=e205]:
- - link "View All Sizes in Media Manager" [ref=e206] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/ios/version/inflight/media-manager/iphone
- - generic [ref=e208]:
- - generic:
- - alert
- - tabpanel "iPhone" [ref=e209]:
- - generic [ref=e210]:
- - generic [ref=e211]:
- - img [ref=e214]
- - generic [ref=e219]:
- - generic [ref=e220]: iPhone
- - generic [ref=e221]: 6.5" Display
- - region [ref=e222]:
- - tabpanel [ref=e223]:
- - generic [ref=e453]
- - generic [ref=e229]
- - generic [ref=e241]:
- - generic [ref=e242]:
- - generic [ref=e244]:
- - generic [ref=e245]: Promotional Text
- - button "More information" [ref=e247] [cursor=pointer]: "?"
- - textbox "Promotional Text" [ref=e249]: Introducing GolfHelm — the all-in-one platform for college golf teams. Shot-by-shot round tracking, AI-powered coaching insights, and more.
- - status "Characters remaining" [ref=e251]: "31"
- - separator [ref=e253]
- - generic [ref=e254]:
- - generic [ref=e256]:
- - generic [ref=e257]: Description
- - button "More information" [ref=e259] [cursor=pointer]: "?"
- - textbox "Description" [ref=e261]: "GolfHelm is the premier platform for college golf team management, combining powerful round tracking with AI-driven coaching intelligence. FOR COACHES: • Full team management — roster, calendar, events, tasks, documents, and travel planning • CoachHelm AI — automatically surfaces insights, patterns, and predictions from your team's round data • Development plans — set focus areas for each player and track improvement over time • Team statistics — comprehensive analytics across your entire roster • Qualifier management — create and run team qualifiers with automatic scoring • Smart alerts — get notified when players show emerging trends or areas needing attention • Round reviews — AI-generated analysis of every round your players submit FOR PLAYERS: • Shot-by-shot round tracking — log every shot with lie, distance, club, and result • 50+ statistics calculated automatically from your round data • Personal CoachHelm — AI insights tailored to your game and development areas • Development tracking — see your focus areas and progress over time • Team hub — RSVP to events, complete tasks, view announcements, and stay connected • Class schedule integration — automatic conflict detection with team events • Qualifier participation — enter team qualifiers and track your results COACHHELM AI ENGINE: GolfHelm's CoachHelm AI engine analyzes round data to deliver actionable coaching intelligence. It identifies scoring patterns, predicts performance trends, and generates natural-language insights that help coaches make better decisions and players improve faster. Built for college golf programs that want to elevate their team management and player development with modern technology."
- - status "Characters remaining" [ref=e263]: 2,301
- - generic [ref=e264]:
- - generic [ref=e266]:
- - generic [ref=e267]: Keywords
- - button "More information" [ref=e269] [cursor=pointer]: "?"
- - textbox "Keywords" [ref=e271]: golf,college golf,coaching,team management,round tracking,golf stats,player development,analytics
- - status "Characters remaining" [ref=e273]: "3"
- - generic [ref=e274]:
- - generic [ref=e276]:
- - generic [ref=e277]: Support URL
- - button "More information" [ref=e279] [cursor=pointer]: "?"
- - textbox "Support URL" [ref=e281]: https://helmsportslabs.com/support
- - generic [ref=e282]:
- - generic [ref=e284]:
- - generic [ref=e285]: Marketing URL
- - button "More information" [ref=e287] [cursor=pointer]: "?"
- - textbox "Marketing URL" [ref=e289]: https://helmsportslabs.com
- - generic [ref=e290]:
- - generic [ref=e292]:
- - generic [ref=e293]: Version
- - button "More information" [ref=e295] [cursor=pointer]: "?"
- - textbox "Version" [ref=e297]: "1.0"
- - generic [ref=e298]:
- - generic [ref=e300]:
- - generic [ref=e301]: Copyright
- - button "More information" [ref=e303] [cursor=pointer]: "?"
- - textbox "Copyright" [active] [ref=e305]: 2026 Helm Sports Labs LLC
- - status "Characters remaining" [ref=e307]: "175"
- - generic [ref=e308]:
- - generic [ref=e310]:
- - generic [ref=e311]: Routing App Coverage File
- - button "More information" [ref=e313] [cursor=pointer]: "?"
- - generic [ref=e315] [cursor=pointer]:
- - button "Choose File" [ref=e316]
- - generic [ref=e318]: Choose File
- - separator [ref=e319]
- - button "App Clip" [ref=e321] [cursor=pointer]:
- - img [ref=e323]
- - text: App Clip
- - generic [ref=e326]:
- - heading "iMessage App" [level=3] [ref=e327]:
- - button "iMessage App" [ref=e328] [cursor=pointer]:
- - img [ref=e330]
- - text: iMessage App
- - button "More information" [ref=e333] [cursor=pointer]: "?"
- - generic [ref=e334]:
- - heading "Build" [level=3] [ref=e336]
- - generic [ref=e337]:
- - img [ref=e338]
- - paragraph [ref=e340]:
- - generic [ref=e341]:
- - text: If your app uses encryption, you're required to upload export compliance documentation. You can submit this documentation before you submit your app for review in the
- - link "App Encryption Documentation section" [ref=e342] [cursor=pointer]:
- - /url: /apps/6761740758/distribution/info
- - text: ", or by uploading your app below."
- - paragraph [ref=e345]:
- - generic [ref=e346]:
- - text: Upload your builds using one of several tools.
- - link "See Upload Tools" [ref=e347] [cursor=pointer]:
- - /url: https://developer.apple.com/help/app-store-connect/manage-builds/upload-builds
- - generic [ref=e349]:
- - generic [ref=e353] [cursor=pointer]:
- - checkbox "Game Center" [ref=e355]
- - text: Game Center
- - generic [ref=e357]:
- - img [ref=e358]
- - paragraph [ref=e360]:
- - text: Game Center components can now be added for review directly from the Game Center section. You can no longer select them from the app version page.
- - link "Learn More" [ref=e361] [cursor=pointer]:
- - /url: https://developer.apple.com/help/app-store-connect/manage-submissions-to-app-review/submit-game-center-components
- - button [ref=e362] [cursor=pointer]:
- - img [ref=e363]
- - separator [ref=e365]
- - generic [ref=e366]:
- - heading "App Review Information" [level=3] [ref=e367]
- - generic [ref=e368]:
- - generic [ref=e369]:
- - generic [ref=e370]:
- - generic [ref=e372]:
- - generic [ref=e373]: Sign-In Information
- - button "More information" [ref=e375] [cursor=pointer]: "?"
- - paragraph [ref=e376]: Provide a user name and password so we can sign in to your app. We’ll need this to complete your app review.
- - generic [ref=e378]:
- - checkbox "Sign-in required" [checked] [ref=e379]
- - generic [ref=e381]: Sign-in required
- - textbox "User name" [ref=e384]
- - textbox "Password" [ref=e387]
- - generic [ref=e388]:
- - generic [ref=e390]:
- - generic [ref=e391]: Contact Information
- - button "More information" [ref=e393] [cursor=pointer]: "?"
- - textbox "First name" [ref=e396]
- - textbox "Last name" [ref=e399]
- - textbox "Phone number" [ref=e402]
- - textbox "Email" [ref=e405]
- - generic [ref=e406]:
- - generic [ref=e408]:
- - generic [ref=e409]: Notes
- - button "More information" [ref=e411] [cursor=pointer]: "?"
- - textbox "Notes" [ref=e413]
- - status "Characters remaining" [ref=e415]: 4,000
- - generic [ref=e416]:
- - generic [ref=e418]:
- - generic [ref=e419]: Attachment
- - button "More information" [ref=e421] [cursor=pointer]: "?"
- - generic [ref=e423] [cursor=pointer]:
- - button "Choose File (Optional)" [ref=e424]
- - generic [ref=e426]: Choose File (Optional)
- - separator [ref=e427]
- - generic [ref=e428]:
- - heading "App Store Version Release" [level=3] [ref=e429]
- - paragraph [ref=e430]: To make your app available on the App Store, you can automatically release it after it’s been approved by App Review. You can also manually release it on the App Store at a later date.
- - generic [ref=e431]:
- - generic [ref=e433]:
- - generic [ref=e434]:
- - radio "Manually release this version" [ref=e435]
- - generic [ref=e437]: Manually release this version
- - generic [ref=e438]:
- - radio "Automatically release this version" [checked] [ref=e439]
- - generic [ref=e441]: Automatically release this version
- - generic [ref=e442]:
- - radio "Automatically release this version after App Review, no earlier than" [ref=e443]
- - generic [ref=e445]: Automatically release this version after App Review, no earlier than
- - paragraph [ref=e447]: Your local date and time.
- - generic [ref=e450]:
- - textbox "Automatically release this version after App Review, no earlier than" [disabled] [ref=e451]: Apr 9, 2026 7:00 PM
- - text: EDT
- - contentinfo [ref=e9]:
- - generic [ref=e10]:
- - list [ref=e66]:
- - listitem [ref=e67]:
- - link "App Store Connect" [ref=e68] [cursor=pointer]:
- - /url: /apps
- - list [ref=e11]:
- - listitem [ref=e12]: Copyright © 2026 Apple Inc. All rights reserved. |
- - listitem [ref=e13]:
- - link "Terms of Service" [ref=e14] [cursor=pointer]:
- - /url: /WebObjects/iTunesConnect.woa/wa/termsOfService
- - text: "|"
- - listitem [ref=e15]:
- - link "Privacy Policy" [ref=e16] [cursor=pointer]:
- - /url: https://www.apple.com/legal/privacy
- - text: "|"
- - listitem [ref=e17]:
- - link "Contact Us" [ref=e18] [cursor=pointer]:
- - /url: /contact-us
- - log [ref=e547]
- - log [ref=e548]
\ No newline at end of file
From c4ec7769546de0efa1aa0ff3a5fe894fe646eb4f Mon Sep 17 00:00:00 2001
From: njrini99-code
Date: Wed, 15 Jul 2026 18:10:13 -0400
Subject: [PATCH 04/18] =?UTF-8?q?devibe:=20console=20triage=20=E2=80=94=20?=
=?UTF-8?q?remove=20debug-leftover=20console.log=20in=20use-service-worker?=
=?UTF-8?q?=20(#861)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Audited the 77 console.log/debug/warn call sites in prod src (excluding
tests). Two mechanisms make almost all of them deliberate, not vibe-coded
leftovers, and this PR documents why nearly everything was kept:
- next.config.mjs compiler.removeConsole strips console.log AND
console.debug from production builds, excluding only 'error'/'warn'.
So every console.log/.debug call is already dev-only/no-op in prod.
- src/instrumentation.ts + src/instrumentation-client.ts both configure
Sentry.consoleLoggingIntegration({ levels: ['log','warn','error'] }) —
console.warn is the established, load-bearing structured-logging idiom
in this codebase (forwarded to Sentry Explore → Logs), which is exactly
why admin-tracer-data.ts has an explicit comment: "console.warn used
(not console.log) because production build strips console.log."
Reviewed every one of the 48 console.warn and 8 console.debug call sites
individually: every single one has either an explicit comment justifying
the log level (e.g. insight-delivery.ts's transient-fetch debug downgrade,
useAdminPresence.ts's `if (process.env.NODE_ENV !== 'production')`-gated
join/leave debug logs, pattern-miner.ts's documented severity policy,
admin-logger.ts's PGRST205 once-only warn) or is a genuine production
security/error signal (auth rate-limiting, unauthorized message/team
actions, fetch-failure fallbacks). None were genuine leftovers — all kept
as-is, no logger-idiom conversion performed (see below).
**Deleted** (1 file, 8 statements): src/hooks/golf/use-service-worker.ts
— 8 console.log calls tracing every SW lifecycle branch (register
no-op, already-registered, registered, unregistered, update complete,
sync unsupported, sync registered, no active worker to message, message
received). Unlike every kept call site above, these had (a) no
explanatory comment, (b) no dev-only guard, (c) duplicate state already
exposed via the hook's own return value (`status`/`isRegistered`/
`hasUpdate`), and (d) trace literally every branch including plain early
returns — the classic "log every branch while debugging a tricky SW bug"
pattern (see memory: dev-SW false-offline investigation) never cleaned
up. The 5 console.error calls in this same file's catch blocks are
untouched (KEEP per the task rule).
**Logger-idiom conversion**: grepped for a logger util first
(src/lib/admin-logger.ts, server-error-logger.ts, error-logging.ts exist)
— none is a general-purpose console.warn replacement; they're
purpose-built for the admin_events audit trail / Sentry error
classification, and console.warn already IS the repo's structured-log
idiom for this class of signal (per the Sentry consoleLoggingIntegration
wiring above). Converting would be redundant double-logging and risk
semantic changes (async logger calls dropped into sync catch blocks) for
no observability gain, so no conversions were made — warns left as-is,
per the "if none, leave warns" instruction.
Gates: typecheck clean, eslint --max-warnings 0 on the touched file clean,
check-cycles clean (33 known cycles, none new). No test file covers this
hook (grepped for use-service-worker in *.test.*/*.spec.* — zero hits).
Co-authored-by: Fable Integrator
Co-authored-by: Claude Fable 5
---
src/hooks/golf/use-service-worker.ts | 13 -------------
1 file changed, 13 deletions(-)
diff --git a/src/hooks/golf/use-service-worker.ts b/src/hooks/golf/use-service-worker.ts
index 53dd52406..9595bd10c 100644
--- a/src/hooks/golf/use-service-worker.ts
+++ b/src/hooks/golf/use-service-worker.ts
@@ -115,12 +115,10 @@ export function useServiceWorker(options: UseServiceWorkerOptions = {}): Service
*/
const register = useCallback(async () => {
if (!state.isSupported) {
- console.log('[SW Hook] Service workers not supported');
return;
}
if (state.isRegistered) {
- console.log('[SW Hook] Already registered');
return;
}
@@ -139,8 +137,6 @@ export function useServiceWorker(options: UseServiceWorkerOptions = {}): Service
if (!mountedRef.current) return;
- console.log('[SW Hook] Service worker registered:', registration.scope);
-
// Check for sync support
const syncSupported = typeof registration === 'object' && 'sync' in registration;
@@ -239,8 +235,6 @@ export function useServiceWorker(options: UseServiceWorkerOptions = {}): Service
registration: null,
hasUpdate: false,
}));
-
- console.log('[SW Hook] Service worker unregistered');
} catch (error) {
console.error('[SW Hook] Unregistration failed:', error);
}
@@ -262,8 +256,6 @@ export function useServiceWorker(options: UseServiceWorkerOptions = {}): Service
if (!mountedRef.current) return;
setState(prev => ({ ...prev, status: 'registered' }));
-
- console.log('[SW Hook] Update check complete');
} catch (error) {
console.error('[SW Hook] Update check failed:', error);
@@ -312,14 +304,12 @@ export function useServiceWorker(options: UseServiceWorkerOptions = {}): Service
*/
const registerBackgroundSync = useCallback(async (tag: string): Promise => {
if (!registrationRef.current || !state.syncSupported) {
- console.log('[SW Hook] Background sync not supported');
return false;
}
try {
// @ts-expect-error - sync API is not in TypeScript types
await registrationRef.current.sync.register(tag);
- console.log('[SW Hook] Background sync registered:', tag);
return true;
} catch (error) {
console.error('[SW Hook] Background sync registration failed:', error);
@@ -332,7 +322,6 @@ export function useServiceWorker(options: UseServiceWorkerOptions = {}): Service
*/
const postMessage = useCallback((message: unknown) => {
if (!navigator.serviceWorker.controller) {
- console.log('[SW Hook] No active service worker to message');
return;
}
@@ -351,8 +340,6 @@ export function useServiceWorker(options: UseServiceWorkerOptions = {}): Service
if (!state.isSupported) return;
const handleMessage = (event: MessageEvent) => {
- console.log('[SW Hook] Message from service worker:', event.data);
-
if (event.data?.type === 'SYNC_REQUESTED') {
// Dispatch custom event that the app can listen for
window.dispatchEvent(new CustomEvent('sw-sync-requested', {
From 9c3a2f68a3581abfeb6b87effbb9d5200649e3b9 Mon Sep 17 00:00:00 2001
From: njrini99-code
Date: Wed, 15 Jul 2026 18:10:16 -0400
Subject: [PATCH 05/18] Build the /baseball public marketing page (was a bare
redirect) (#865)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-out visitors used to get bounced straight to /baseball/login with
zero context; they now see a real front door — hero, four editorial
feature sections (roster/team-ops, stats center, recruiting pipeline,
player passport) composed from the Living Annual kit in ghost/placeholder
state (no fabricated screenshots or invented player data), and an honest
CTA row (Sign in / Create a program / Join with a code). Signed-in
visitors keep the exact prior redirect-to-dashboard behavior.
- src/app/baseball/page.tsx: rewritten from a bare redirect into the full
marketing page; auth check now only fires the redirect when a session
exists.
- src/components/baseball/marketing/BaseballMarketingMotionScope.tsx: new
tiny 'use client' LazyMotion wrapper — the Living Annual atoms used here
(RuledStatLine/Masthead/HairlineRule/GradeStamp) never transition off
their hidden variant without a loaded feature bundle, and the page
itself stays a Server Component (async session check + redirect), so
this is the one client boundary.
- src/app/baseball/join/page.tsx: new — the "Join with a code" CTA needed
a real destination; only the dynamic /baseball/join/[code] existed.
Mirrors GolfHelm's /golf/join code-entry page, themed in the Living
Annual paper/ink system instead of golf's glass-orb auth chrome.
- src/components/landing/Footer.tsx: generalized the shared cross-product
footer's tagline off golf-only wording ("college golf") since it now
also renders under a BaseballHelm hero.
- src/app/baseball/__tests__/page.test.tsx: pins the redirect/no-redirect
branching (coach session, player session, signed-out).
Co-authored-by: Fable Integrator
Co-authored-by: Claude Fable 5
---
src/app/baseball/__tests__/page.test.tsx | 66 ++++
src/app/baseball/join/page.tsx | 114 +++++++
src/app/baseball/page.tsx | 305 +++++++++++++++++-
.../BaseballMarketingMotionScope.tsx | 36 +++
src/components/landing/Footer.tsx | 2 +-
5 files changed, 515 insertions(+), 8 deletions(-)
create mode 100644 src/app/baseball/__tests__/page.test.tsx
create mode 100644 src/app/baseball/join/page.tsx
create mode 100644 src/components/baseball/marketing/BaseballMarketingMotionScope.tsx
diff --git a/src/app/baseball/__tests__/page.test.tsx b/src/app/baseball/__tests__/page.test.tsx
new file mode 100644
index 000000000..f2cfaa038
--- /dev/null
+++ b/src/app/baseball/__tests__/page.test.tsx
@@ -0,0 +1,66 @@
+// =============================================================================
+// BaseballLandingPage — signed-in redirect preserved, signed-out renders the
+// marketing page.
+//
+// The bare `/baseball` route used to unconditionally redirect (even
+// signed-out visitors got bounced to /baseball/login with zero context).
+// This pins the fix: a signed-in session still redirects straight to the
+// right dashboard (coach vs. player), while no session renders the public
+// landing content instead of redirecting.
+// =============================================================================
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ getSessionProfile: vi.fn(),
+ redirect: vi.fn((path: string) => {
+ throw new Error(`REDIRECT:${path}`);
+ }),
+}));
+
+vi.mock('next/navigation', () => ({ redirect: mocks.redirect }));
+
+vi.mock('@/lib/auth/session', () => ({
+ getSessionProfile: mocks.getSessionProfile,
+}));
+
+import BaseballLandingPage from '../page';
+
+describe('BaseballLandingPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('redirects a coach session straight to the command center', async () => {
+ mocks.getSessionProfile.mockResolvedValue({
+ userId: 'coach-1',
+ role: 'coach',
+ coach: { id: 'coach-1' },
+ player: null,
+ });
+
+ await expect(BaseballLandingPage()).rejects.toThrow(
+ 'REDIRECT:/baseball/dashboard/command-center',
+ );
+ });
+
+ it('redirects a player session straight to their daily hub', async () => {
+ mocks.getSessionProfile.mockResolvedValue({
+ userId: 'player-1',
+ role: 'player',
+ coach: null,
+ player: { id: 'player-1' },
+ });
+
+ await expect(BaseballLandingPage()).rejects.toThrow('REDIRECT:/baseball/player/today');
+ });
+
+ it('renders the public marketing page for a signed-out visitor instead of redirecting', async () => {
+ mocks.getSessionProfile.mockResolvedValue(null);
+
+ const element = await BaseballLandingPage();
+
+ expect(element).toBeTruthy();
+ expect(mocks.redirect).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/baseball/join/page.tsx b/src/app/baseball/join/page.tsx
new file mode 100644
index 000000000..aeb5a8f76
--- /dev/null
+++ b/src/app/baseball/join/page.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+/**
+ * BaseballHelm "join with a code" landing page.
+ *
+ * Bare `/baseball/join` had no page — only the dynamic `/baseball/join/[code]`
+ * existed, which requires the code already be in the URL. This is the code
+ * ENTRY step the public marketing page's "Join with a code" CTA links to,
+ * mirroring GolfHelm's `/golf/join/page.tsx` (same code-length validation,
+ * same `router.push` handoff), themed in the Living Annual paper/ink system
+ * instead of golf's glass-orb auth chrome — `/baseball/join/[code]/page.tsx`
+ * already uses `PaperCard` from this kit for its own success/error states.
+ *
+ * Reduced motion: the one entrance animation is the plain CSS
+ * `animate-fade-up` utility, neutralized app-wide by the global
+ * `@media (prefers-reduced-motion: reduce)` reset in globals.css — no
+ * framer-motion / LazyMotion needed for a page this small.
+ */
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { HelmMark } from '@/components/brand/HelmMark';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { PaperCard, HairlineRule } from '@/components/baseball/living-annual';
+
+export default function BaseballJoinPage() {
+ const [code, setCode] = useState('');
+ const [error, setError] = useState(null);
+ const router = useRouter();
+
+ const trimmed = code.trim();
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+
+ if (!trimmed) {
+ setError('Please enter an invite code.');
+ return;
+ }
+
+ if (trimmed.length < 4) {
+ setError('Invite code must be at least 4 characters.');
+ return;
+ }
+
+ router.push(`/baseball/join/${trimmed}`);
+ };
+
+ const handleChange = (e: React.ChangeEvent) => {
+ setCode(e.target.value.toUpperCase());
+ if (error) setError(null);
+ };
+
+ return (
+
+
+
+
+
+
+
Join a team
+
Enter the invite code your coach sent you.
+
+
+
+
+
+
+ {!error && (
+
+ {trimmed.length > 0 ? `${trimmed.length} / 10 characters` : '4–10 characters, letters and numbers'}
+
+ )}
+
+
+ Join team
+
+
+
+
+
+
+ Don't have a code? Ask your coach, or{' '}
+
+ create a program
+
+ .
+
+
+
+
+
+ ← Back to BaseballHelm
+
+
+
+
+ );
+}
diff --git a/src/app/baseball/page.tsx b/src/app/baseball/page.tsx
index b158ac3a4..7f8b8d9a5 100644
--- a/src/app/baseball/page.tsx
+++ b/src/app/baseball/page.tsx
@@ -1,19 +1,310 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
import { redirect } from 'next/navigation';
+import Link from 'next/link';
import { getSessionProfile } from '@/lib/auth/session';
+import { cn } from '@/lib/utils';
+import { HelmMark } from '@/components/brand/HelmMark';
+import { Button } from '@/components/ui/button';
+import { IconArrowRight } from '@/components/icons';
+import { Footer } from '@/components/landing/Footer';
+import {
+ Eyebrow,
+ HairlineRule,
+ PaperCard,
+ PositionChip,
+ RuledStatLine,
+ Masthead,
+ GradeStamp,
+} from '@/components/baseball/living-annual';
+import { BaseballMarketingMotionScope } from '@/components/baseball/marketing/BaseballMarketingMotionScope';
+
+const HERO_DESCRIPTION =
+ "BaseballHelm brings the roster, the stat sheet, and the prospect pipeline into one system — built for college, JUCO, high school, and showcase programs, not adapted from someone else's spreadsheet.";
+
+export const metadata: Metadata = {
+ title: 'BaseballHelm — Recruiting & Team Management for College Baseball',
+ description: HERO_DESCRIPTION,
+ openGraph: {
+ title: 'BaseballHelm — Recruiting & Team Management for College Baseball',
+ description: HERO_DESCRIPTION,
+ type: 'website',
+ url: '/baseball',
+ images: [
+ {
+ url: '/baseball-aerial.webp',
+ width: 1920,
+ height: 1080,
+ alt: 'A college baseball field seen from above',
+ },
+ ],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: 'BaseballHelm — Recruiting & Team Management for College Baseball',
+ description: HERO_DESCRIPTION,
+ images: ['/baseball-aerial.webp'],
+ },
+};
/**
- * BaseballHelm landing route.
+ * BaseballHelm public landing route — the front door for coaches, players,
+ * and parents arriving from a shared link.
*
- * The bare `/baseball` path had no page (returned 404). Mirror the GolfHelm
- * landing pattern: unauthenticated visitors go to the login, authenticated
- * users go to the dashboard (which role-routes coach vs player internally).
+ * Signed-in visitors keep the original redirect-straight-to-dashboard
+ * behavior (mirrors GolfHelm's `/golf/page.tsx`): coaches land on the
+ * command center, players on their daily hub — nobody who's already signed
+ * in sees marketing copy. Signed-OUT visitors — the ones this route is
+ * actually for — used to be bounced straight to `/baseball/login` with zero
+ * context about what BaseballHelm even is; they now see the real page.
*/
export default async function BaseballLandingPage() {
const session = await getSessionProfile();
- if (!session) {
- redirect('/baseball/login');
+ if (session) {
+ redirect(session.coach ? '/baseball/dashboard/command-center' : '/baseball/player/today');
}
- redirect(session.coach ? '/baseball/dashboard/command-center' : '/baseball/player/today');
+ return (
+
+
+ Skip to main content
+
+
+
+
+ {/* The Living Annual atoms below (RuledStatLine, Masthead, HairlineRule,
+ GradeStamp) are framer-motion `m` components — they only ever
+ transition from their `hidden` variant to `visible` when a
+ `
` ancestor has loaded a feature bundle. This scope is
+ the one client boundary on an otherwise server-rendered page. */}
+
+
+
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+
+
+
+
+
+
+ );
+}
+
+function MarketingHeader() {
+ return (
+
+ );
+}
+
+function Hero() {
+ return (
+
+
+
+
+ Recruiting and team operations for college baseball.
+
+
+
+
+
{HERO_DESCRIPTION}
+
+
+
+ );
+}
+
+interface FeatureSectionProps {
+ ink: 'team' | 'pursuit';
+ eyebrowItems: string[];
+ heading: string;
+ body: string;
+ visual: ReactNode;
+ reverse?: boolean;
+}
+
+function FeatureSection({ ink, eyebrowItems, heading, body, visual, reverse = false }: FeatureSectionProps) {
+ return (
+
+
+
+
+
+ {heading}
+
+
+
{body}
+
+
{visual}
+
+
+ );
+}
+
+const ROSTER_POSITIONS = ['C', '1B', '2B', '3B', 'SS', 'OF', 'RHP', 'LHP'];
+
+function RosterVisual() {
+ return (
+
+
+ {ROSTER_POSITIONS.map((pos) => (
+
+ ))}
+
+
+
+ Every position, every class year — one active roster the whole staff can see, not a shared spreadsheet.
+
+
+ );
+}
+
+function StatsVisual() {
+ return (
+
+
+
+
+
+
+
+ Every logged game rolls into the record book automatically.
+
+
+ );
+}
+
+const PIPELINE_STAGES = ['Watchlist', 'High Priority', 'Offer Extended', 'Committed'];
+
+function PipelineVisual() {
+ return (
+
+
+ {PIPELINE_STAGES.map((stage) => (
+
+ {stage}
+
+
+ ))}
+
+
+
+
+
+
+
+ Standard 20-80 scouting grades travel with every prospect file.
+
+
+
+ );
+}
+
+function PassportVisual() {
+ return (
+
+ }
+ />
+
+
+
+
+
+
+ );
+}
+
+function ClosingCta() {
+ return (
+
+
+
+ Built for coaches who are done juggling spreadsheets.
+
+
+ Sign in to an existing program, start a new one, or join with the code your coach sent you.
+
+
+
+
+ );
+}
+
+function CtaRow({ className }: { className?: string }) {
+ return (
+
+
+
+ Sign in
+
+
+
+
+
+ Create a program
+
+
+
+
+ Join with a code
+
+
+
+ );
}
diff --git a/src/components/baseball/marketing/BaseballMarketingMotionScope.tsx b/src/components/baseball/marketing/BaseballMarketingMotionScope.tsx
new file mode 100644
index 000000000..422f28f2b
--- /dev/null
+++ b/src/components/baseball/marketing/BaseballMarketingMotionScope.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+/**
+ * BaseballMarketingMotionScope — the LazyMotion provider for the public
+ * `/baseball` marketing page.
+ *
+ * The page itself (`src/app/baseball/page.tsx`) is a Server Component (it
+ * awaits `getSessionProfile()` and redirects signed-in visitors before ever
+ * rendering markup), so it cannot itself carry `'use client'`. But the
+ * Living Annual kit's animated atoms (`RuledStatLine`, `Masthead`,
+ * `HairlineRule`, `Reveal`, …) are `m`-based framer-motion components that
+ * only mount their AnimationFeature — and therefore only ever transition
+ * from their `initial` variant to `animate` — when a `` ancestor
+ * has loaded a feature bundle (verified against framer-motion's
+ * `VisualElement.updateFeatures`: a feature only instantiates when
+ * `featureDefinitions[key].Feature` is populated, which only `loadFeatures`/
+ * `` does). Without it every one of those atoms is
+ * frozen at `inkSettles`'s hidden state — `opacity: 0` — forever, not a
+ * static-but-visible fallback.
+ *
+ * This is a deliberately tiny client boundary (the same "self-contained
+ * motion provider" shape as `(dashboard)/dashboard/template.tsx` and
+ * `BaseballAuthShell`) so the page above it keeps doing its data/redirect
+ * work as a genuine Server Component; only this leaf wrapper — and the
+ * Living Annual atoms it wraps — hydrate on the client. Server-rendered
+ * children are passed in as the `children` prop from the page, exactly as
+ * Next.js's "Server Components as children of a Client Component" pattern
+ * expects.
+ */
+import type { ReactNode } from 'react';
+import { LazyMotion } from 'framer-motion';
+import { loadFeatures } from '@/lib/motion/load-features';
+
+export function BaseballMarketingMotionScope({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/src/components/landing/Footer.tsx b/src/components/landing/Footer.tsx
index 11d0b411d..23bb8ffce 100644
--- a/src/components/landing/Footer.tsx
+++ b/src/components/landing/Footer.tsx
@@ -44,7 +44,7 @@ export function Footer() {
Helm Sports Labs
- The coaching intelligence layer for college golf — strokes-gained, qualifiers, and the conversations that matter.
+ The coaching and recruiting intelligence layer for college athletics — rosters, stats, and the conversations that matter.
From 08251a3a3f98bc136bc8d7c2fb464ca65ac88eb1 Mon Sep 17 00:00:00 2001
From: njrini99-code
Date: Wed, 15 Jul 2026 18:14:47 -0400
Subject: [PATCH 06/18] Fix invisible names/numerals on public baseball profile
pages (no LazyMotion ancestor) (#866)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
team/[id], player/[id] (via PlayerProfileClient), program/[id], and
packet/[token] sit in the (public) route group, whose layout was a bare
`<>{children}>` — no LazyMotion anywhere upstream. team/[id] and
PlayerProfileClient render Living Annual `m`-based atoms (Masthead,
RuledStatLine, HairlineRule) directly; their `inkSettles`/`rulesDraw`
entrance variants start at `hidden` (opacity: 0 / scaleX: 0) and only
animate to `visible` once framer-motion's feature bundle is loaded via a
`LazyMotion` ancestor. Without one, an `m.*` component's AnimationFeature
never mounts, so the hidden variant is terminal for any visitor without
`prefers-reduced-motion` on — player/team names and stat numerals stayed
invisible on these live public recruiting pages.
Adds PublicMotionScope (mirrors the existing AdminMotionProvider /
`(dashboard)/dashboard/template.tsx` pattern already used elsewhere in the
repo) and mounts it from `(public)/layout.tsx`, which stays a Server
Component — the LazyMotion boundary lives in the client child.
Verified via a real (unmocked) framer-motion render test: Masthead's
surname text is measurably opacity: 0 forever with no wrapper, and
measurably transitions off 0 once PublicMotionScope loads its feature
bundle — the same computed-opacity check `toBeVisible()` uses, so it
reproduces the actual bug and the actual fix rather than a mocked stand-in.
program/[id] and packet/[token] don't currently render any Living Annual
`m` atoms directly (packet's ScoutPacketView already carries its own
LazyMotion) — the shared layout-level provider covers them defensively
against regression as those pages grow.
PR #865 (open, targets this same base) adds a near-identical
BaseballMarketingMotionScope for the separate /baseball marketing root and
explicitly flagged this (public) route group gap out of its own scope;
this PR is the fix for that flagged gap. Not touching #865's files — noted
in the PR body that the two wrappers could be consolidated into one shared
component later.
Co-authored-by: Fable Integrator
Co-authored-by: Claude Fable 5
---
.../(public)/PublicMotionScope.test.tsx | 54 +++++++++++++++++++
.../baseball/(public)/PublicMotionScope.tsx | 41 ++++++++++++++
src/app/baseball/(public)/layout.tsx | 9 +++-
3 files changed, 103 insertions(+), 1 deletion(-)
create mode 100644 src/app/baseball/(public)/PublicMotionScope.test.tsx
create mode 100644 src/app/baseball/(public)/PublicMotionScope.tsx
diff --git a/src/app/baseball/(public)/PublicMotionScope.test.tsx b/src/app/baseball/(public)/PublicMotionScope.test.tsx
new file mode 100644
index 000000000..86be75e5f
--- /dev/null
+++ b/src/app/baseball/(public)/PublicMotionScope.test.tsx
@@ -0,0 +1,54 @@
+/**
+ * PublicMotionScope.tsx tests — deliberately NOT mocking framer-motion (unlike
+ * Reveal.test.tsx / HoverReveal.test.tsx), because the bug this file guards
+ * against IS framer-motion's real `m`-without-`LazyMotion` behavior, and a
+ * `vi.mock('framer-motion', ...)` Proxy would render everything visible
+ * regardless, masking the exact regression a mock would hide.
+ *
+ * Both assertions exercise the REAL Masthead atom + REAL framer-motion:
+ * 1. Without any LazyMotion ancestor, its `inkSettles` hidden variant
+ * (`opacity: 0`) never transitions — the name stays invisible forever.
+ * This reproduces the bug that shipped on `team/[id]`, `player/[id]`
+ * (via PlayerProfileClient) before this fix.
+ * 2. Wrapped in ``, the same atom's opacity measurably
+ * moves off `0` once the async `loadFeatures` bundle resolves and the
+ * entrance transition starts — proving the fix actually re-enables the
+ * animation feature, not just that the component renders text into the
+ * DOM (which `opacity: 0` never removes, so a plain `getByText` /
+ * `toBeInTheDocument` assertion would pass in both the broken and fixed
+ * cases and catch nothing).
+ */
+import { describe, it, expect } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import { Masthead } from '@/components/baseball/living-annual/Masthead';
+import { PublicMotionScope } from './PublicMotionScope';
+import PublicLayout from './layout';
+
+describe('PublicMotionScope', () => {
+ it('regression: an `m`-based Living Annual atom with no LazyMotion ancestor stays frozen invisible', () => {
+ render( );
+ const surname = screen.getByText('Player');
+ // Real (unmocked) framer-motion: `initial="hidden"` is applied as an
+ // inline style synchronously on mount even with no LazyMotion ancestor —
+ // it just never animates to `visible` without one. `toBeVisible()` reads
+ // computed `opacity`, so this is the same check a real screen reader /
+ // visual regression tool would fail on.
+ expect(surname).not.toBeVisible();
+ });
+
+ it('fix: the same atom becomes visible once wrapped in PublicMotionScope', async () => {
+ render(
+
+
+ ,
+ );
+ const surname = screen.getByText('Recruit');
+ await waitFor(() => expect(surname).toBeVisible());
+ });
+
+ it('the (public) route layout mounts PublicMotionScope around its children', async () => {
+ render({ } );
+ const surname = screen.getByText('Check');
+ await waitFor(() => expect(surname).toBeVisible());
+ });
+});
diff --git a/src/app/baseball/(public)/PublicMotionScope.tsx b/src/app/baseball/(public)/PublicMotionScope.tsx
new file mode 100644
index 000000000..3e58910af
--- /dev/null
+++ b/src/app/baseball/(public)/PublicMotionScope.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { LazyMotion } from 'framer-motion';
+import { loadFeatures } from '@/lib/motion/load-features';
+import type { ReactNode } from 'react';
+
+/**
+ * Motion provider for the `(public)` baseball route group — `team/[id]`,
+ * `player/[id]`, `program/[id]`, `packet/[token]` — public recruiting pages
+ * visited by signed-out browsers (coaches sharing a link, families checking a
+ * player profile) with no app shell of their own to supply one.
+ *
+ * `team/[id]` and `player/[id]` (via `PlayerProfileClient`) render Living
+ * Annual `m`-based atoms directly — `Masthead`, `RuledStatLine`,
+ * `HairlineRule` (default `animate=true`) — whose entrance variant
+ * (`inkSettles`/`rulesDraw` in `living-annual/motion.ts`) starts at `hidden`
+ * (`opacity: 0` for text, `scaleX: 0` for rules) and only transitions to
+ * `visible` once framer-motion's animation feature bundle is loaded. An
+ * `m.*` component never mounts that feature — and therefore never leaves
+ * `hidden` — without a `LazyMotion` ancestor (see `motion-dom`'s
+ * `VisualElement.updateFeatures`, and the identical note on
+ * `AdminMotionProvider` / `(dashboard)/dashboard/template.tsx`). Before this
+ * wrapper, the `(public)` layout was `<>{children}>` with nothing upstream
+ * providing one, so any visitor without `prefers-reduced-motion` on saw
+ * invisible player/team names and stat numerals on these live public pages.
+ *
+ * `strict`: verified no descendant in this route's component tree renders a
+ * full `motion.*` component (only `m.*`), so `strict` is safe here and turns
+ * a future accidental `motion` import into a loud dev-time error instead of
+ * a silent tree-shaking regression.
+ *
+ * Kept as its own file (not merged into `layout.tsx`) so the layout stays a
+ * Server Component — `LazyMotion` requires a client boundary.
+ */
+export function PublicMotionScope({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/baseball/(public)/layout.tsx b/src/app/baseball/(public)/layout.tsx
index 9ef5c896e..bc1250397 100644
--- a/src/app/baseball/(public)/layout.tsx
+++ b/src/app/baseball/(public)/layout.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
+import { PublicMotionScope } from './PublicMotionScope';
// Server Component — public-facing baseball profile/team/program pages
// (team/[id], player/[id], program/[id], packet). No override previously
@@ -14,5 +15,11 @@ export default function PublicLayout({
}: {
children: React.ReactNode;
}) {
- return <>{children}>;
+ // PublicMotionScope mounts for the whole route group — without
+ // it, the Living Annual `m`-based atoms these pages render (Masthead,
+ // RuledStatLine, HairlineRule) never leave their `hidden` (opacity: 0)
+ // entrance variant for visitors without `prefers-reduced-motion` on. See
+ // PublicMotionScope.tsx for the full trace. This layout itself stays a
+ // Server Component — the LazyMotion boundary lives in that client child.
+ return {children} ;
}
From 760d5bbe63628b4a916c79a0cec10671b701183f Mon Sep 17 00:00:00 2001
From: njrini99-code
Date: Wed, 15 Jul 2026 18:26:24 -0400
Subject: [PATCH 07/18] Add production visual-audit screenshot crawl (GHA,
manual-only) (#867)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New e2e/visual-audit.spec.ts mirrors baseball-route-crawler.spec.ts's proven
live-DOM nav discovery (FairwaySidebar + hub-sub-nav links) and
best-effort public-sample-link discovery, but captures full-page screenshots
at phone (390x844) and desktop (1440x900) viewports for every discovered
coach/player route plus signed-out publics, instead of asserting route
health. Screenshots are data capture, not assertions — the spec only fails
on a login failure or a total navigation failure. Gated behind
VISUAL_AUDIT=1 (test.skip otherwise); playwright.config.ts's chromium
project now ignores it and baseball-coach/baseball-player now match it, so
it never runs in the ordinary e2e lane and playwright.yml/ci.yml (which
name their spec files explicitly) never pick it up.
New .github/workflows/visual-audit.yml runs it via workflow_dispatch against
a chosen base_url (default prod), --project=baseball-coach
--project=baseball-player only — verified against the installed Playwright
runner source that this also runs the `setup` project's full baseball auth
(both roles) as a dependency, without needing an explicit --project=setup,
and without ever touching Golf's auth.setup.ts. Uploads
test-results/visual-audit as visual-audit-, if: always().
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
Co-authored-by: Fable Integrator
Co-authored-by: Claude Fable 5
---
.github/workflows/visual-audit.yml | 108 ++++++++
e2e/visual-audit.spec.ts | 385 +++++++++++++++++++++++++++++
playwright.config.ts | 14 +-
3 files changed, 504 insertions(+), 3 deletions(-)
create mode 100644 .github/workflows/visual-audit.yml
create mode 100644 e2e/visual-audit.spec.ts
diff --git a/.github/workflows/visual-audit.yml b/.github/workflows/visual-audit.yml
new file mode 100644
index 000000000..099f03aac
--- /dev/null
+++ b/.github/workflows/visual-audit.yml
@@ -0,0 +1,108 @@
+name: Visual Audit
+
+# Manual-only production screenshot crawl (e2e/visual-audit.spec.ts). NOT part
+# of any PR gate or push trigger — this hits a real, deployed base_url (prod
+# by default) as both the baseball coach and player roles, discovers every
+# visible nav route from the LIVE DOM, and captures full-page screenshots at
+# phone (390x844) and desktop (1440x900) viewports for a human/follow-up
+# review pass to look at. Screenshots are data capture, not assertions — the
+# spec only fails this workflow on a login failure or a total navigation
+# failure (see e2e/visual-audit.spec.ts's module doc for the exact contract).
+#
+# Runs ONLY the baseball auth setup + this spec — no build, no dev server, no
+# seeding: PLAYWRIGHT_BASE_URL points playwright.config.ts at the deployed
+# base_url, which makes its `webServer` block a no-op (only defined when
+# PLAYWRIGHT_BASE_URL is unset), so there is nothing to boot locally.
+#
+# `--project=baseball-coach --project=baseball-player` alone is sufficient to
+# also run the `setup` project (playwright/baseball-auth.setup.ts): both
+# projects declare `dependencies: ['setup']` in playwright.config.ts, and
+# Playwright always runs a project's dependencies with their own FULL,
+# unfiltered test file set — the `e2e/visual-audit.spec.ts` file argument on
+# the CLI only restricts the explicitly-requested top-level projects
+# (baseball-coach/baseball-player), never a project pulled in purely as a
+# dependency. Verified against the installed playwright package's runner
+# source (collectProjectsAndTestFiles in lib/runner/index.js) before relying
+# on it here. The `setup` project's testMatch is scoped to
+# `baseball-auth.setup.ts` specifically, so Golf's `playwright/auth.setup.ts`
+# is never pulled in.
+#
+# SECURITY: consumes ZERO untrusted user input — the only `${{ }}` expressions
+# are a trusted workflow_dispatch string input, secrets, and github.run_number.
+
+on:
+ workflow_dispatch:
+ inputs:
+ base_url:
+ description: Base URL to crawl (playwright.config.ts baseURL)
+ required: false
+ default: "https://helmsportslabs.com"
+ type: string
+
+concurrency:
+ group: visual-audit-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ visual-audit:
+ name: Visual audit (coach + player)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ PLAYWRIGHT_BASE_URL: ${{ inputs.base_url }}
+ VISUAL_AUDIT: "1"
+ # Fail loud (not a graceful skip) on missing/bad creds — this run is
+ # pointless without a real authenticated crawl.
+ PLAYWRIGHT_BASEBALL_REQUIRED: "1"
+ E2E_BASEBALL_COACH_EMAIL: ${{ secrets.E2E_BASEBALL_COACH_EMAIL }}
+ E2E_BASEBALL_COACH_PASSWORD: ${{ secrets.E2E_BASEBALL_COACH_PASSWORD }}
+ E2E_BASEBALL_PLAYER_EMAIL: ${{ secrets.E2E_BASEBALL_PLAYER_EMAIL }}
+ E2E_BASEBALL_PLAYER_PASSWORD: ${{ secrets.E2E_BASEBALL_PLAYER_PASSWORD }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Setup Node
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+ restore-keys: |
+ playwright-${{ runner.os }}-
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps chromium
+
+ # Runs the `setup` project (baseball coach + player auth, persisting
+ # storageState) as a dependency, then this spec under baseball-coach
+ # and baseball-player — see the header comment above for why no
+ # explicit --project=setup is needed.
+ - name: Run visual-audit screenshot crawl
+ run: |
+ npx playwright test \
+ --project=baseball-coach \
+ --project=baseball-player \
+ e2e/visual-audit.spec.ts
+
+ - name: Upload visual-audit screenshots + manifests
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
+ with:
+ name: visual-audit-${{ github.run_number }}
+ path: test-results/visual-audit
+ retention-days: 7
+ if-no-files-found: warn
diff --git a/e2e/visual-audit.spec.ts b/e2e/visual-audit.spec.ts
new file mode 100644
index 000000000..240a5d696
--- /dev/null
+++ b/e2e/visual-audit.spec.ts
@@ -0,0 +1,385 @@
+/**
+ * e2e/visual-audit.spec.ts — production visual-audit screenshot crawl.
+ *
+ * Runs under the same `baseball-coach` / `baseball-player` Playwright
+ * projects (see playwright.config.ts) that e2e/baseball-route-crawler.spec.ts
+ * uses — this is NOT a new auth mechanism. It reuses that spec's proven
+ * discovery pattern (visible ` a[href]` links from the LIVE RENDERED
+ * DOM — covers both FairwaySidebar's `aria-label="Main navigation"` rail,
+ * whose link list itself renders inside a nested ``,
+ * and any hub-sub-nav strip, e.g.
+ * src/app/baseball/(dashboard)/_components/hub-sub-nav.tsx, since both are
+ * real ` `s inside a `` element — never nav-registry source) and
+ * its best-effort public-sample-link discovery, but instead of asserting
+ * route health it captures full-page screenshots of every discovered route
+ * at two viewports (390x844 phone, 1440x900 desktop) plus a manifest of
+ * per-route capture metadata.
+ *
+ * THIS IS DATA CAPTURE, NOT ASSERTIONS. A route that renders an error
+ * boundary, a stuck spinner, or a near-blank page still gets a screenshot
+ * and a manifest entry — that is the whole point of a visual audit, a human
+ * (or a follow-up review pass) looks at the images. The spec only fails the
+ * build on:
+ * 1. LOGIN FAILURE — the authenticated storageState didn't actually hold
+ * (entry route bounces to /login), or the session dies mid-crawl
+ * (any subsequent route bounces to /login).
+ * 2. TOTAL NAVIGATION FAILURE — the entry route itself couldn't be
+ * reached at all, no nav links were discoverable at all (the shell
+ * never rendered), or every single discovered route failed to
+ * navigate (0 screenshots produced out of N attempts).
+ * Anything short of that — a single broken route, console errors, a slow
+ * load — is recorded in the manifest and the crawl keeps going.
+ *
+ * GATED behind `VISUAL_AUDIT=1` (test.skip otherwise) so this never runs in
+ * the normal e2e lane — see playwright.config.ts's `chromium` project
+ * testIgnore and the `baseball-coach`/`baseball-player` projects' testMatch,
+ * and .github/workflows/visual-audit.yml (the only place this is invoked).
+ *
+ * Output layout (see writeManifest / captureRouteAllViewports below):
+ * test-results/visual-audit///-.png
+ * test-results/visual-audit//manifest.json
+ * `` is 'coach' or 'player'; the signed-out public/publics capture
+ * (/baseball, /baseball/login, + up to 3 best-effort public sample links
+ * discovered from that role's authenticated crawl) is appended to the SAME
+ * role's file set and manifest, continuing the index sequence, matching
+ * baseball-route-crawler.spec.ts's per-role anonymous-verify structure.
+ */
+import { test, type Page, type Response, type ConsoleMessage } from '@playwright/test';
+import fs from 'node:fs';
+import path from 'node:path';
+
+const REPORT_DIR = path.join(process.cwd(), 'test-results', 'visual-audit');
+
+/** Cap on how many best-effort public sample routes to capture per role —
+ * mirrors baseball-route-crawler.spec.ts's MAX_PUBLIC_SAMPLES; this is a
+ * representative sample, not an exhaustive crawl of every public profile. */
+const MAX_PUBLIC_SAMPLES = 3;
+
+const PUBLIC_SAMPLE_RE = /^\/baseball\/(player|team|program)\/[^/?#]+$|^\/baseball\/packet\/[^/?#]+$/;
+
+/** Always captured signed-out, regardless of what a given crawl discovers. */
+const ALWAYS_PUBLIC_ROUTES = ['/baseball', '/baseball/login'] as const;
+
+const VIEWPORTS = [
+ { name: 'phone', size: { width: 390, height: 844 } },
+ { name: 'desktop', size: { width: 1440, height: 900 } },
+] as const;
+type ViewportName = (typeof VIEWPORTS)[number]['name'];
+
+/** Settle window after navigation before the fullPage shot — long enough for
+ * entrance transitions (this codebase's cinematic glide/settle motion, see
+ * design-system-living-annual.md) to finish, short enough to keep a
+ * many-route crawl tractable in CI. */
+const ENTRANCE_SETTLE_MS = 600;
+/** Settle window after each scroll step (bottom, then back to top) — gives
+ * IntersectionObserver-gated lazy content a chance to mount before the shot. */
+const SCROLL_SETTLE_MS = 400;
+const NAV_TIMEOUT_MS = 30000;
+
+interface RouteCapture {
+ index: number;
+ route: string;
+ authRequired: boolean;
+ files: Partial>;
+ status: Partial>;
+ loadMs: Partial>;
+ navError: Partial>;
+ consoleErrorCount: number;
+}
+
+/** Total-navigation-failure signal for the whole crawl (not a single route). */
+class VisualAuditFailure extends Error {}
+
+function slugifyRoute(route: string): string {
+ const cleaned = route.replace(/[#?].*$/, '').replace(/^\//, '');
+ const slug = cleaned
+ .toLowerCase()
+ .replace(/\//g, '-')
+ .replace(/[^a-z0-9-]+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ return slug || 'root';
+}
+
+/** Visible `` links, deduped, same-origin, baseball-scoped. Excludes
+ * hash-only fragments, query strings, and sign-out/logout controls (which
+ * would end the session mid-crawl). Identical discovery contract to
+ * baseball-route-crawler.spec.ts's discoverVisibleNavLinks. */
+async function discoverVisibleNavLinks(page: Page): Promise {
+ const hrefs = await page.evaluate(() => {
+ const anchors = Array.from(document.querySelectorAll('nav a[href]')) as HTMLAnchorElement[];
+ return anchors
+ .filter((a) => {
+ const style = getComputedStyle(a);
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
+ const r = a.getBoundingClientRect();
+ return r.width > 0 && r.height > 0;
+ })
+ .map((a) => a.getAttribute('href') || '');
+ });
+
+ const seen = new Set();
+ const routes: string[] = [];
+ for (const href of hrefs) {
+ if (!href.startsWith('/baseball/')) continue;
+ if (/sign-?out|logout/i.test(href)) continue;
+ const clean = href.replace(/[#?].*$/, '');
+ if (!clean || seen.has(clean)) continue;
+ seen.add(clean);
+ routes.push(clean);
+ }
+ return routes;
+}
+
+/** Any anchor anywhere on the page (not just ) matching a public
+ * player/team/program/packet route shape. Identical contract to
+ * baseball-route-crawler.spec.ts's discoverPublicSampleLinks. */
+async function discoverPublicSampleLinks(page: Page): Promise {
+ const hrefs = await page.evaluate(() =>
+ Array.from(document.querySelectorAll('a[href]')).map(
+ (a) => (a as HTMLAnchorElement).getAttribute('href') || '',
+ ),
+ );
+ return [...new Set(hrefs.filter((h) => PUBLIC_SAMPLE_RE.test(h)))];
+}
+
+/** Wait for network settle + web fonts + a short entrance-animation settle
+ * delay, then scroll to the bottom and back to the top to trigger any
+ * lazy/IntersectionObserver-gated content before the full-page shot. Never
+ * throws — a slow/odd page still gets the best screenshot we can take. */
+async function settleForCapture(page: Page): Promise {
+ await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
+ await page
+ .evaluate(() => (document.fonts ? document.fonts.ready.then(() => undefined) : undefined))
+ .catch(() => {});
+ await page.waitForTimeout(ENTRANCE_SETTLE_MS);
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)).catch(() => {});
+ await page.waitForTimeout(SCROLL_SETTLE_MS);
+ await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
+ await page.waitForTimeout(SCROLL_SETTLE_MS);
+}
+
+/**
+ * Navigate to `route` at each viewport in turn (setViewportSize BEFORE
+ * navigating, so entrance transitions animate straight to the correct
+ * layout instead of replaying on a post-mount resize), settle, and save a
+ * full-page screenshot. Returns capture metadata; never throws — a failed
+ * navigation for ONE route is recorded (navError) and the crawl continues,
+ * per this spec's "data capture, not assertions" contract.
+ */
+async function captureRouteAllViewports(
+ page: Page,
+ route: string,
+ index: number,
+ role: 'coach' | 'player',
+ authRequired: boolean,
+): Promise {
+ const slug = slugifyRoute(route);
+ const nnn = String(index).padStart(3, '0');
+ const capture: RouteCapture = {
+ index,
+ route,
+ authRequired,
+ files: {},
+ status: {},
+ loadMs: {},
+ navError: {},
+ consoleErrorCount: 0,
+ };
+
+ let errorCount = 0;
+ const onConsole = (msg: ConsoleMessage) => {
+ if (msg.type() === 'error') errorCount++;
+ };
+ const onPageError = () => {
+ errorCount++;
+ };
+ page.on('console', onConsole);
+ page.on('pageerror', onPageError);
+
+ try {
+ for (const vp of VIEWPORTS) {
+ await page.setViewportSize(vp.size);
+
+ const started = Date.now();
+ let response: Response | null = null;
+ try {
+ response = await page.goto(route, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
+ } catch (err) {
+ capture.loadMs[vp.name] = Date.now() - started;
+ capture.navError[vp.name] = err instanceof Error ? err.message : String(err);
+ continue; // can't screenshot a page that failed to navigate
+ }
+ capture.loadMs[vp.name] = Date.now() - started;
+ capture.status[vp.name] = response?.status() ?? null;
+
+ await settleForCapture(page);
+
+ const dir = path.join(REPORT_DIR, role, vp.name);
+ fs.mkdirSync(dir, { recursive: true });
+ const filePath = path.join(dir, `${nnn}-${slug}.png`);
+ try {
+ await page.screenshot({ path: filePath, fullPage: true });
+ capture.files[vp.name] = path.relative(REPORT_DIR, filePath);
+ } catch (err) {
+ capture.navError[vp.name] = `screenshot failed: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ }
+ } finally {
+ page.off('console', onConsole);
+ page.off('pageerror', onPageError);
+ }
+
+ capture.consoleErrorCount = errorCount;
+ return capture;
+}
+
+/**
+ * Discover every visible nav route from `entryRoute` (BFS, growing the
+ * frontier as newly-visited hubs reveal their own hub-subnav links — not
+ * all mounted from the entry route alone) and capture both viewports for
+ * each. Pushes into the caller-owned `captures` array so partial progress
+ * survives a thrown VisualAuditFailure (the caller writes whatever landed
+ * in `captures` to the manifest either way).
+ *
+ * Throws VisualAuditFailure for the two conditions this spec is allowed to
+ * fail on: a login bounce (entry or mid-crawl), or a total navigation
+ * failure (entry route unreachable, zero nav links discovered, or every
+ * discovered route failed to produce a single screenshot).
+ */
+async function crawlAuthenticatedRole(
+ page: Page,
+ role: 'coach' | 'player',
+ entryRoute: string,
+ captures: RouteCapture[],
+): Promise<{ publicSamples: string[] }> {
+ try {
+ await page.goto(entryRoute, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
+ } catch (err) {
+ throw new VisualAuditFailure(
+ `visual-audit(${role}): total navigation failure — could not load entry route ${entryRoute}: ` +
+ `${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
+
+ if (page.url().includes('/login')) {
+ throw new VisualAuditFailure(
+ `visual-audit(${role}): login failure — entry route ${entryRoute} bounced to ${page.url()} ` +
+ '(authenticated storageState did not hold)',
+ );
+ }
+
+ const visited = new Set();
+ const publicSamples = new Set();
+ const frontier = await discoverVisibleNavLinks(page);
+ for (const link of await discoverPublicSampleLinks(page)) publicSamples.add(link);
+
+ if (frontier.length === 0) {
+ throw new VisualAuditFailure(
+ `visual-audit(${role}): total navigation failure — no visible nav links discovered from ` +
+ `${entryRoute}; the authenticated shell may not have rendered`,
+ );
+ }
+
+ for (let i = 0; i < frontier.length; i++) {
+ const route = frontier[i];
+ if (!route || visited.has(route)) continue;
+ visited.add(route);
+
+ const capture = await captureRouteAllViewports(page, route, captures.length + 1, role, true);
+ captures.push(capture);
+
+ const navigatedOk = VIEWPORTS.some((vp) => capture.files[vp.name]);
+ if (!navigatedOk) continue; // this one route failed — data-capture only, keep crawling.
+
+ for (const nested of await discoverVisibleNavLinks(page)) {
+ if (!visited.has(nested) && !frontier.includes(nested)) frontier.push(nested);
+ }
+ for (const link of await discoverPublicSampleLinks(page)) publicSamples.add(link);
+
+ if (page.url().includes('/login')) {
+ throw new VisualAuditFailure(
+ `visual-audit(${role}): login failure — session bounced to ${page.url()} while crawling ${route}`,
+ );
+ }
+ }
+
+ const anySucceeded = captures.some((c) => VIEWPORTS.some((vp) => c.files[vp.name]));
+ if (!anySucceeded) {
+ throw new VisualAuditFailure(
+ `visual-audit(${role}): total navigation failure — 0/${captures.length} discovered routes ` +
+ 'produced a screenshot',
+ );
+ }
+
+ return { publicSamples: [...publicSamples].slice(0, MAX_PUBLIC_SAMPLES) };
+}
+
+/**
+ * Capture /baseball, /baseball/login, and up to MAX_PUBLIC_SAMPLES
+ * best-effort public sample routes discovered during the authenticated
+ * crawl, in a FRESH unauthenticated browser context — a public profile
+ * route must render for an anonymous visitor, not just for the
+ * already-authenticated crawl page. Mirrors
+ * baseball-route-crawler.spec.ts's verifyPublicSamplesAnonymously, but
+ * captures screenshots instead of asserting health. Pushes into the
+ * caller-owned `captures` array, continuing its index sequence.
+ */
+async function captureSignedOutRoutes(
+ page: Page,
+ role: 'coach' | 'player',
+ publicSamples: string[],
+ captures: RouteCapture[],
+): Promise {
+ const browser = page.context().browser();
+ if (!browser) return;
+
+ const routes = [...new Set([...ALWAYS_PUBLIC_ROUTES, ...publicSamples])];
+ const anonContext = await browser.newContext();
+ try {
+ const anonPage = await anonContext.newPage();
+ for (const route of routes) {
+ const capture = await captureRouteAllViewports(anonPage, route, captures.length + 1, role, false);
+ captures.push(capture);
+ }
+ } finally {
+ await anonContext.close();
+ }
+}
+
+function writeManifest(role: 'coach' | 'player', captures: RouteCapture[]): void {
+ const dir = path.join(REPORT_DIR, role);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(captures, null, 2));
+}
+
+async function runRoleVisualAudit(
+ page: Page,
+ role: 'coach' | 'player',
+ entryRoute: string,
+): Promise {
+ const captures: RouteCapture[] = [];
+ try {
+ const { publicSamples } = await crawlAuthenticatedRole(page, role, entryRoute, captures);
+ await captureSignedOutRoutes(page, role, publicSamples, captures);
+ } finally {
+ // Written even on a thrown VisualAuditFailure — whatever screenshots
+ // DID land before the failure are still useful CI artifact data.
+ writeManifest(role, captures);
+ }
+}
+
+test.describe('Visual audit — coach', { tag: '@coach' }, () => {
+ test('screenshot every discovered coach route at phone + desktop viewports', async ({ page }) => {
+ test.skip(process.env.VISUAL_AUDIT !== '1', 'Gated behind VISUAL_AUDIT=1 — see visual-audit.yml');
+ await runRoleVisualAudit(page, 'coach', '/baseball/dashboard/command-center');
+ });
+});
+
+test.describe('Visual audit — player', { tag: '@player' }, () => {
+ test('screenshot every discovered player route at phone + desktop viewports', async ({ page }) => {
+ test.skip(process.env.VISUAL_AUDIT !== '1', 'Gated behind VISUAL_AUDIT=1 — see visual-audit.yml');
+ await runRoleVisualAudit(page, 'player', '/baseball/player/today');
+ });
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index 0b1667493..2dac2a5a0 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -50,8 +50,12 @@ export default defineConfig({
// authenticated storageState from the `setup` project below — they
// must not also run anonymously here.
// mobile-viewports.spec.ts runs only under the mobile-* projects.
+ // visual-audit.spec.ts is gated behind VISUAL_AUDIT=1 (test.skip
+ // otherwise) and only ever invoked by visual-audit.yml against
+ // baseball-coach/baseball-player — excluded here too so it never
+ // shows up (even as a no-op skip) in the ordinary e2e lane.
testIgnore:
- /baseball-(smoke|route-crawler)\.spec\.ts|mobile-viewports\.spec\.ts/,
+ /baseball-(smoke|route-crawler)\.spec\.ts|mobile-viewports\.spec\.ts|visual-audit\.spec\.ts/,
},
// BaseballHelm mandatory smoke (#372) — durable per-role auth. `setup`
@@ -66,7 +70,11 @@ export default defineConfig({
},
{
name: 'baseball-coach',
- testMatch: /baseball-(smoke|route-crawler)\.spec\.ts/,
+ // visual-audit.spec.ts (#visual-audit) rides these same projects —
+ // it is a separate, gated (VISUAL_AUDIT=1) screenshot crawl, not a
+ // new auth mechanism, so it belongs on the existing role-scoped
+ // projects rather than growing a third set.
+ testMatch: /baseball-(smoke|route-crawler)\.spec\.ts|visual-audit\.spec\.ts/,
grep: /@coach/,
dependencies: ['setup'],
use: {
@@ -76,7 +84,7 @@ export default defineConfig({
},
{
name: 'baseball-player',
- testMatch: /baseball-(smoke|route-crawler)\.spec\.ts/,
+ testMatch: /baseball-(smoke|route-crawler)\.spec\.ts|visual-audit\.spec\.ts/,
grep: /@player/,
dependencies: ['setup'],
use: {
From cafa74649dfe623f1b49de2f3f4d8df9d97c0b9b Mon Sep 17 00:00:00 2001
From: njrini99-code
Date: Wed, 15 Jul 2026 18:43:10 -0400
Subject: [PATCH 08/18] db(baseball): write #379 legacy stats backfill
migration (pending Nick's go) (#862)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* db(baseball): write #379 legacy stats backfill migration (pending Nick's go)
One-time, NOT-APPLIED migration that copies legacy baseball_player_stats
'game' rows into baseball_box_score_batting/_pitching + synthesizes shared
baseball_games rows, scoped to teams with ZERO existing box-score data (teams
already on the box-score adapter path are never touched). Deterministic ids
(SHA-1, RFC4122-v5-shaped, own namespace) mirror #827's
scripts/seed-baseball-stats.mjs detId() pattern so re-applying is a no-op and
rollback can recompute — not just look up — exactly which rows are ours.
Copy-only: legacy rows are never mutated. Deliberately skips
recalculate_baseball_season_stats() to avoid clobbering any pre-existing
season_totals-imported baseline on baseball_player_season_stats — documented
as an opt-in follow-up instead.
Exercised end-to-end against a disposable local Postgres 16 instance (schema
mirrored from the real migrations, never any shared project) covering a
two-way partial-innings player, a duplicate-row collision, an
already-box-score team (excluded), and a pre-existing-scheduled-game
collision (date skipped) — verified idempotent re-run and a dry-run rollback
recompute+delete. See docs/baseball/legacy-backfill-runbook.md for the
check-first queries, apply steps, and rollback recipe.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
* fix(baseball): make #379 backfill's season-stats safety story true, not just written
Adversarial review on PR #862 found the migration's core safety claim false:
recalculate_baseball_season_stats() is described as a deliberate, manual,
opt-in, per-team step, but the already-shipped save_baseball_full_box_score
RPC calls it automatically on every ordinary box-score save. Since the
backfilled games carry their real historical game_date (plausibly within the
current season year for teams whose whole history predates #827), the very
next normal game entry for an overlapping player would silently overwrite
baseball_player_season_stats -- including any pre-existing season_totals
baseline -- with no opt-in and no signoff.
Fix, verified against a disposable local Postgres 16 instance (never any
shared Supabase project):
- Migration: add Step 4, seeding baseball_player_season_stats for exactly the
(player_id, team_id, season_year) triples the migration's own box-score
rows touch, using the identical aggregation/rate formulas
recalculate_baseball_season_stats() uses -- guarded by
ON CONFLICT ... DO NOTHING so a pre-existing row (e.g. a season_totals
baseline) is never touched, preserving copy-only/additive-only/idempotent.
Where no row existed, the eventual live recalc now lands on the same
numbers already seeded (a no-op, not a surprise).
- Runbook: replace the "deliberately out of scope" framing with the true
story, add a pre-flight query that surfaces exactly which triples still
carry pre-existing-baseline risk (Nick must review before applying), and
add a diff-based season-stats rollback procedure since DO NOTHING rows
have no deterministic id to recompute against.
Locally reproduced the exact scenario the review described (a fresh ordinary
game save via the real, unmodified RPC): the seeded player's row extended
cleanly with correct math; the pre-existing baseline player's row was
silently overwritten by the (unmodified) live RPC, exactly as newly
documented -- confirming the fix and the doc are both now accurate.
File remains WRITE-ONLY / NOT APPLIED pending Nick's go-ahead.
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Fable Integrator
Co-authored-by: Claude Fable 5
---
docs/baseball/legacy-backfill-runbook.md | 476 ++++++++++++
...5141727_baseball_legacy_stats_backfill.sql | 716 ++++++++++++++++++
2 files changed, 1192 insertions(+)
create mode 100644 docs/baseball/legacy-backfill-runbook.md
create mode 100644 supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql
diff --git a/docs/baseball/legacy-backfill-runbook.md b/docs/baseball/legacy-backfill-runbook.md
new file mode 100644
index 000000000..8998a4427
--- /dev/null
+++ b/docs/baseball/legacy-backfill-runbook.md
@@ -0,0 +1,476 @@
+# #379 Legacy Stats Backfill — Runbook
+
+> Companion to `supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql`
+> and `docs/baseball/stats-migration-plan.md` / `stats-architecture.md`.
+> Last updated: 2026-07-15.
+>
+> **Status: WRITTEN, NOT APPLIED.** This migration is a one-time, pending-approval
+> catch-up — do not run it against any shared project until Nick has explicitly
+> signed off. It is not wired into any cron, CI gate, or app code path; nothing
+> executes it automatically.
+
+## What it does
+
+`#827` ("#379 Phase 0") fixed `scripts/seed-baseball-stats.mjs` and the live
+import/box-score-save paths (`src/app/baseball/actions/imports.ts`,
+`src/app/baseball/actions/games.ts`) to write **both** stat layers going
+forward — the legacy flat table (`baseball_player_stats`) AND the canonical
+box-score tables (`baseball_box_score_batting` / `_pitching` + synthesized
+`baseball_games` rows). It did not do anything for teams whose entire game
+history predates that fix and lives **only** in the legacy table. Those teams
+still show real numbers on Command Center / Roster / Player Today / Passport
+(the grandfathered legacy-layer consumers — see
+`src/lib/baseball/stat-layer-manifest.ts`) but an honestly-empty Stats Center,
+because `src/lib/baseball/read-models/stats-center.ts` reads **only** the
+box-score/season layer.
+
+The migration is the one-time catch-up for exactly those teams:
+
+1. Finds every team with a `stat_type = 'game'` row in `baseball_player_stats`
+ and **zero** rows in both `baseball_box_score_batting` and
+ `baseball_box_score_pitching` ("zero box-score data" — see below).
+2. For those teams only, groups their legacy `'game'` rows by
+ `(team_id, session_date)` into one shared, synthesized `baseball_games` row
+ per team-date (never one row per player — mirrors #827's
+ `buildBoxScoreRowsForSessions` fix).
+3. Copies each attending player's legacy row into a
+ `baseball_box_score_batting` row (always) and a `baseball_box_score_pitching`
+ row (only when `innings_pitched > 0`), computing avg/obp/slg/ops and
+ era/whip/k9/bb9 with the same formulas
+ `src/app/baseball/actions/games.ts`'s `computeBattingRates` /
+ `computePitchingRates` use, including outs-based innings-pitched conversion
+ (`src/lib/baseball/innings.ts` — the `X.1`/`X.2` notation is thirds of an
+ inning, not a decimal fraction).
+
+**Practice rows are never touched.** Only `stat_type = 'game'` rows are read;
+`'practice'` and `'other'` rows have no box-score equivalent
+(`baseball_games.game_type` only allows `'game'`/`'scrimmage'`).
+
+**Copy-only.** The migration only ever `INSERT`s into `baseball_games`,
+`baseball_box_score_batting`, `baseball_box_score_pitching`. It never
+`UPDATE`s or `DELETE`s a row of `baseball_player_stats`, or anything else —
+the legacy table is read-only input.
+
+**Never mixes into teams already using box-score.** A team with even one
+existing box-score row is completely excluded — that team's box-score data is
+maintained live by the adapter precedence in `applyGameBoxScoreImport`
+(`imports.ts`) and `save_baseball_full_box_score` (`games.ts`), and this
+migration never races with or duplicates that path.
+
+### Season-stats interaction: seeded where safe, explicitly flagged where not
+
+**Recalc is not an opt-in, manual, per-team step — it already runs
+automatically on every ordinary box-score save.** The already-shipped,
+unrelated RPC `save_baseball_full_box_score`
+(`supabase/migrations/20260630000000_baseball_save_full_box_score_rpc.sql`) —
+called by every normal in-app "save box score" action — unconditionally
+calls `recalculate_baseball_season_stats(player_id, team_id, EXTRACT(YEAR
+FROM now()))` for every player in whatever game a coach just saved. That
+function fully aggregates all of that player's completed-game box-score rows
+for the current calendar year and does an `ON CONFLICT ... DO UPDATE` — a
+full **overwrite**, not a merge — of `baseball_player_season_stats`.
+
+This migration inserts `baseball_games` rows carrying the legacy rows' real
+historical `game_date`. For a team whose entire history "predates #827"
+(applied the same day as this migration), those dates can plausibly fall
+within the current season year. So the first time any coach enters one
+ordinary new game this season for a player who overlaps with a backfilled
+team, that live recalc sweeps up these backfilled box-score rows and
+overwrites `baseball_player_season_stats` for that (player, team, year) —
+with no code change, no extra step, and no opt-in required. If that team
+already had a `season_totals`-imported baseline in
+`baseball_player_season_stats` for that player/year, it gets silently
+replaced at that moment.
+
+**What the migration does about it (Step 4):** it seeds
+`baseball_player_season_stats` now, for exactly the `(player_id, team_id,
+season_year)` triples its own box-score inserts touch, using the identical
+aggregation and rate formulas `recalculate_baseball_season_stats()` uses
+(same SUMs, same `w`/`l`/`sv`/`holds`/`blown_saves` derivation from `result`,
+same era/whip/k9/bb9 division by raw `ip`). It never invokes the live RPC —
+the formulas are mirrored inline, read-only against the rows this migration
+just wrote, so the migration never depends on (or risks a future edit to)
+that shared function. It is guarded by `ON CONFLICT (player_id, team_id,
+season_year) DO NOTHING`, so:
+
+- **No pre-existing season row for that triple** (the common case, since the
+ team had zero box-score data): the seed populates it now with numbers that
+ exactly match what the inevitable future recalc would produce anyway — so
+ that eventual overwrite becomes a substantive no-op, not a surprise.
+- **A pre-existing season row already there** (a `season_totals`-imported
+ baseline): Step 4 does **not** touch it — copy-only/additive-only is
+ preserved. But that row remains exposed to the same already-shipped
+ recalc-on-save behavior described above once this migration's box-score
+ rows exist. This is not a new risk this migration invents — any team
+ mixing legacy and `season_totals` data already had it — but backfilling box
+ scores makes it far more likely to actually fire. **Run the pre-flight
+ query below before applying** to see exactly which triples this affects,
+ and decide with Nick (skip those teams for now, accept the eventual
+ overwrite, or snapshot those specific rows externally) before proceeding.
+
+Stats Center's game-log views (the batting/pitching splits, which read
+straight off box-score rows) show real numbers immediately after this
+migration runs regardless. See "Season-stats rollback" below for how the
+Step 4 seed interacts with rollback.
+
+#### Pre-flight query — season rows at risk (run BEFORE applying)
+
+```sql
+SELECT bpss.*
+FROM public.baseball_player_season_stats bpss
+WHERE (bpss.player_id, bpss.team_id, bpss.season_year) IN (
+ SELECT DISTINCT ps.player_id, ps.team_id, EXTRACT(YEAR FROM ps.session_date)::integer
+ FROM public.baseball_player_stats ps
+ WHERE ps.stat_type = 'game'
+ AND NOT EXISTS (SELECT 1 FROM public.baseball_box_score_batting bsb WHERE bsb.team_id = ps.team_id)
+ AND NOT EXISTS (SELECT 1 FROM public.baseball_box_score_pitching bsp WHERE bsp.team_id = ps.team_id)
+);
+```
+
+Any row this returns is one Step 4 will deliberately leave alone (`DO
+NOTHING`) — and one that stays exposed to the live recalc-on-save behavior
+above. **Non-empty result: stop and review with Nick before applying**,
+per-team if needed (e.g. hold off on just the affected team's legacy rows
+until its `season_totals` baseline is reconciled or intentionally retired).
+
+## Eligibility, precisely
+
+A team qualifies iff, at the moment the migration runs:
+
+```sql
+SELECT DISTINCT ps.team_id
+FROM baseball_player_stats ps
+WHERE ps.stat_type = 'game'
+ AND NOT EXISTS (SELECT 1 FROM baseball_box_score_batting bsb WHERE bsb.team_id = ps.team_id)
+ AND NOT EXISTS (SELECT 1 FROM baseball_box_score_pitching bsp WHERE bsp.team_id = ps.team_id);
+```
+
+This set is snapshotted once into a session-local `TEMP TABLE` (`ON COMMIT
+DROP` — never persisted) before any writes happen, so a team's eligibility
+can't be affected by rows the migration itself inserts mid-run.
+
+A second, defensive check applies per `(team, date)`: even for an eligible
+team, a date is skipped if a `baseball_games` row already exists for that
+exact team + date (e.g. a scheduled-but-not-yet-played game created via the
+Games UI). The migration never risks minting a second, duplicate game row
+next to one that already exists — that date is left for manual/live
+handling.
+
+## Known limitations (by design — one-time script, not a product feature)
+
+- **Grouping key is `(team_id, session_date)` only** (no opponent in the key),
+ matching the `#827`/`scripts/seed-baseball-stats.mjs` precedent this
+ migration mirrors. A genuine double-header (two different-opponent games,
+ same team, same date) can't be told apart and collapses onto one game.
+- **Duplicate legacy rows for the same player+team+date** (a data-entry dupe)
+ can only produce one box-score line — enforced by the table's own
+ `UNIQUE (game_id, player_id)` constraint, which has no concept of a player
+ appearing twice in "the same game." The winner is the legacy row with the
+ lexicographically-smallest `id` (via `ROW_NUMBER()`), so re-running the
+ migration always picks the same winner.
+- **Pitching `hr` (home runs allowed)** has no legacy column and is always
+ `0` — a true, documented gap, not a fabricated stat.
+- **`our_score` / `opponent_score` / lineup / `lob` / `batting_order`** have
+ no legacy source and are left `NULL` / `0` — honest empty state, not
+ invented data.
+
+## Idempotency
+
+Every synthesized id is **deterministic**: a SHA-1 hash of a namespaced key
+(`baseball-legacy-backfill-379::<...>`), shaped into RFC4122-v5-style
+bytes (version nibble forced to `0x5`, variant bits forced to `10xx`) — the
+exact pattern `scripts/seed-baseball-stats.mjs`'s `detId()` uses (see its
+header comment and `#827`). The namespace is deliberately different from the
+seed script's own `baseball-stats-seed` namespace, so these ids can never
+collide with the demo seeder's (or anything else's) ids, and so this exact id
+formula can be **recomputed** later — not merely looked up from a log — which
+is what makes the rollback below possible without any extra bookkeeping
+table.
+
+Every `INSERT` is `ON CONFLICT (...) DO NOTHING` keyed on that deterministic
+id (games) or the table's natural unique key (`(game_id, player_id)` for
+batting/pitching). Re-running the file is always a no-op the second time —
+verified empirically (see "Verified" below): a second run against the same
+database inserted zero new rows in any of the three tables.
+
+## How the orchestrator applies it
+
+This file stays **written, not applied** until Nick says go. When he does:
+
+1. Run the **pre-check queries** below (also present as SQL comments at the
+ bottom of the migration file) via `mcp__supabase__execute_sql` and eyeball
+ the team list / row counts — confirm it's the expected set of dormant
+ legacy-only teams, not something surprising.
+2. Run the **season-stats pre-flight query** above. If it returns any rows,
+ stop and get Nick's explicit call on those specific teams/players before
+ proceeding (see "Season-stats interaction" above) — do not treat an
+ empty migration diff as proof this step is unnecessary.
+3. Apply the migration file verbatim via `mcp__supabase__apply_migration`
+ (file content unchanged from what's committed — this is a WRITE-ONLY repo
+ file until that point).
+4. Run the **post-check queries** below to confirm row-count parity per team.
+5. Spot-check one backfilled team's Stats Center page in the app to confirm
+ real numbers now render (previously empty).
+
+No code changes accompany this migration — nothing needs deploying alongside
+it. It's pure data.
+
+### Pre-check query (preview affected teams)
+
+```sql
+SELECT
+ ps.team_id,
+ COUNT(*) FILTER (WHERE ps.stat_type = 'game') AS legacy_game_rows,
+ COUNT(DISTINCT ps.session_date) FILTER (WHERE ps.stat_type = 'game') AS legacy_game_dates
+FROM public.baseball_player_stats ps
+WHERE ps.stat_type = 'game'
+ AND NOT EXISTS (SELECT 1 FROM public.baseball_box_score_batting bsb WHERE bsb.team_id = ps.team_id)
+ AND NOT EXISTS (SELECT 1 FROM public.baseball_box_score_pitching bsp WHERE bsp.team_id = ps.team_id)
+GROUP BY ps.team_id
+ORDER BY legacy_game_rows DESC;
+```
+
+### Post-check queries (row-count parity)
+
+```sql
+-- Distinct (team, date) game-slots: legacy dates vs synthesized baseball_games
+-- (identifiable via the notes tag). Counts should match unless the
+-- "skip if a game already exists that date" guard fired for some dates.
+WITH legacy_dates AS (
+ SELECT team_id, COUNT(DISTINCT session_date) AS n
+ FROM public.baseball_player_stats
+ WHERE stat_type = 'game'
+ GROUP BY team_id
+),
+backfilled_games AS (
+ SELECT team_id, COUNT(*) AS n
+ FROM public.baseball_games
+ WHERE notes LIKE 'Backfilled by #379 one-time legacy stats backfill%'
+ GROUP BY team_id
+)
+SELECT ld.team_id, ld.n AS legacy_game_dates, COALESCE(bg.n, 0) AS backfilled_games
+FROM legacy_dates ld
+LEFT JOIN backfilled_games bg ON bg.team_id = ld.team_id
+ORDER BY ld.team_id;
+```
+
+```sql
+-- Per-team row parity (swap in a real team id):
+SELECT
+ (SELECT COUNT(*) FROM public.baseball_player_stats
+ WHERE team_id = '' AND stat_type = 'game') AS legacy_game_rows,
+ (SELECT COUNT(*) FROM public.baseball_box_score_batting bsb
+ JOIN public.baseball_games g ON g.id = bsb.game_id
+ WHERE bsb.team_id = ''
+ AND g.notes LIKE 'Backfilled by #379 one-time legacy stats backfill%') AS backfilled_batting_rows,
+ (SELECT COUNT(*) FROM public.baseball_box_score_pitching bsp
+ JOIN public.baseball_games g ON g.id = bsp.game_id
+ WHERE bsp.team_id = ''
+ AND g.notes LIKE 'Backfilled by #379 one-time legacy stats backfill%') AS backfilled_pitching_rows;
+```
+
+`backfilled_batting_rows` should equal `legacy_game_rows` unless duplicate
+team+date+player legacy rows existed (see Known Limitations).
+`backfilled_pitching_rows` will be `<= legacy_game_rows`: only rows with
+`innings_pitched > 0` get a pitching line.
+
+## Rollback story
+
+Copy-only means rollback is a pure delete, and because every id is
+deterministic (not random), rollback does not depend on any log or snapshot
+from the original run — it **recomputes** the exact same ids from whatever
+`baseball_player_stats` currently contains, then deletes any row whose id
+matches. Rows this migration never created simply won't match anything (a
+pre-existing team's real box-score id was assigned by `gen_random_uuid()`,
+not derived from this hash, so it cannot collide), so this is precise and
+safe to run at any time after the migration, without needing to already know
+which teams were touched.
+
+Run this as one transaction:
+
+```sql
+BEGIN;
+
+-- Recompute candidate game ids from CURRENT baseball_player_stats — no
+-- eligibility gate needed here; safety comes from exact id match, not from
+-- re-deriving "which teams were eligible" (which would self-exclude every
+-- team this migration touched, since they now have box-score rows).
+WITH game_groups AS (
+ SELECT ps.team_id, ps.session_date
+ FROM public.baseball_player_stats ps
+ WHERE ps.stat_type = 'game'
+ GROUP BY ps.team_id, ps.session_date
+),
+hashed AS (
+ SELECT g.team_id, g.session_date,
+ substring(
+ public.digest('baseball-legacy-backfill-379:box-game:' || g.team_id::text || ':' || g.session_date::text, 'sha1')
+ FROM 1 FOR 16
+ ) AS raw16
+ FROM game_groups g
+),
+versioned AS (
+ SELECT team_id, session_date, set_byte(raw16, 6, (get_byte(raw16, 6) & 15) | 80) AS b1 FROM hashed
+),
+varianted AS (
+ SELECT team_id, session_date,
+ set_byte(b1, 8, ((((get_byte(b1, 8) >> 4) & 3) | 8) << 4) | (get_byte(b1, 8) & 15)) AS b2
+ FROM versioned
+),
+hexed AS (SELECT team_id, session_date, encode(b2, 'hex') AS hx FROM varianted),
+game_ids AS (
+ SELECT team_id, session_date,
+ (substring(hx FROM 1 FOR 8) || '-' || substring(hx FROM 9 FOR 4) || '-' ||
+ substring(hx FROM 13 FOR 4) || '-' || substring(hx FROM 17 FOR 4) || '-' ||
+ substring(hx FROM 21 FOR 12))::uuid AS game_id
+ FROM hexed
+)
+SELECT game_id INTO TEMP _rollback_379_game_ids FROM game_ids;
+
+-- Eyeball this before deleting: should equal the number of games the
+-- post-check query above reported as backfilled.
+SELECT count(*) AS games_to_delete
+FROM public.baseball_games g
+JOIN _rollback_379_game_ids c ON c.game_id = g.id;
+
+DELETE FROM public.baseball_box_score_batting
+WHERE game_id IN (SELECT game_id FROM _rollback_379_game_ids);
+
+DELETE FROM public.baseball_box_score_pitching
+WHERE game_id IN (SELECT game_id FROM _rollback_379_game_ids);
+
+DELETE FROM public.baseball_games
+WHERE id IN (SELECT game_id FROM _rollback_379_game_ids);
+
+-- Review the row counts printed by the DELETEs above, THEN:
+COMMIT;
+-- (or ROLLBACK; instead, to abort without changing anything)
+```
+
+`baseball_games`'s `game_id` foreign key on `baseball_box_score_batting` /
+`_pitching` is `ON DELETE CASCADE`, so deleting only the `baseball_games` rows
+would technically also remove the box-score rows — the explicit 3-statement
+form above is preferred for an auditable, step-by-step rollback where each
+`DELETE`'s row count is visible before committing.
+
+`baseball_player_stats` (the legacy source) is never touched by the forward
+migration, so there is nothing to restore there on rollback.
+
+### Season-stats rollback
+
+Step 4's seed is guarded by `ON CONFLICT DO NOTHING`, so — unlike the
+deterministic-id games/box-score rollback above — there is no id to
+recompute-and-match for `baseball_player_season_stats` rows: a row this
+migration seeded and a row that pre-existed both look like ordinary rows
+once written, keyed only on `(player_id, team_id, season_year)`.
+
+This is exactly why the **pre-flight query** ("Season-stats interaction"
+above) must be run and its output saved (a screenshot, a CSV export, a copy
+of the JSON result) **before** applying the migration:
+
+1. **Before applying**, run the pre-flight query and save its output — that
+ is your "already existed" list for every triple this migration is about
+ to touch.
+2. **If you need to roll back**, run the same pre-flight-shaped query again
+ (against the same touched-triple set the games rollback above
+ recomputes) and diff against the saved "before" list:
+ - Any `(player_id, team_id, season_year)` present **now** but **absent**
+ from the saved "before" list was seeded by Step 4 — safe to `DELETE FROM
+ baseball_player_season_stats WHERE (player_id, team_id, season_year) =
+ (...)` for those rows specifically.
+ - Any triple present in **both** is the pre-existing baseline Step 4 never
+ touched — leave it alone.
+3. If the "before" snapshot was never taken (e.g. this section is read after
+ the fact), do **not** guess — treat every season-stats row for a
+ backfilled team as unknown provenance and reconcile it manually against
+ `season_totals` import records or Nick's own knowledge of that team,
+ rather than deleting rows that might be real, independent data.
+
+## Season-stats reconcile (only relevant for pre-existing baselines Step 4 left alone)
+
+For the rarer case flagged by the pre-flight query — a team where
+`baseball_player_season_stats` already had a `season_totals`-imported row for
+a touched player/year — Nick can force that row in sync with the
+now-complete box-score data (this **is** the one action that overwrites
+existing data, since it calls the live, already-shipped RPC directly):
+
+```sql
+SELECT public.recalculate_baseball_season_stats(
+ ''::uuid, ''::uuid, ::int
+);
+```
+
+Do this deliberately and per-team, only after confirming with Nick that the
+box-score-derived total should win over whatever `season_totals` baseline is
+there — remembering that, per "Season-stats interaction" above, an ordinary
+game save for that player this season year will trigger the exact same
+overwrite anyway, whether or not anyone runs this by hand.
+
+## Verified
+
+Before writing this runbook, the migration was exercised against a disposable
+local Postgres 16 instance (schema mirrored from
+`supabase/migrations/20260527000000_prod_public_baseline.sql` +
+`20260624001000_baseball_official_stat_breadth.sql` +
+`20260708011000`/`20260708022000`'s drift columns — never against any shared
+Supabase project) with fixture data covering:
+
+- a normal single-sport batter,
+- a two-way player (bats and pitches with a partial-innings `6.2` /
+ `4.1` IP notation),
+- a duplicate same-player-same-date legacy row (dedupe correctness),
+- a team that already has box-score data (must be fully excluded),
+- a team with zero box-score data but a pre-existing scheduled
+ `baseball_games` row on the same date as a legacy row (that date must be
+ skipped).
+
+Results: avg/obp/slg/ops and era/whip/k9/bb9 matched hand-calculated values
+(and the outs-based IP conversion) exactly; the already-box-score team was
+untouched; the colliding date was correctly skipped; a second run of the
+same file inserted zero additional rows anywhere; a rollback recompute+delete
+(run inside a `ROLLBACK`ed transaction as a dry run) matched exactly the rows
+the migration had created, and nothing else.
+
+### Step 4 (season-stats seed) — re-verified after the post-review fix
+
+Re-exercised against a fresh disposable local Postgres 16 instance (schema
+reconstructed directly from the real column/constraint lists in
+`20260527000000_prod_public_baseline.sql` and
+`20260624001000_baseball_official_stat_breadth.sql`, plus the real,
+unmodified `recalculate_baseball_season_stats()` and
+`save_baseball_full_box_score()` function bodies from this repo — never
+against any shared Supabase project) with fixtures covering exactly the
+scenario the review flagged:
+
+- a two-way player on an eligible team with **no** pre-existing
+ `baseball_player_season_stats` row for the touched season year,
+- a second player on the **same** eligible team **with** a pre-existing
+ `season_totals`-imported baseline row for that year,
+- a team that already has box-score data (must be fully excluded from
+ Step 4 too, not just Steps 1-3).
+
+Confirmed:
+- Step 4 seeded the first player's season row with `g`/`ab`/`h`/`hr`/`avg`/
+ `obp`/`slg`/`ops` and `ip`/`era`/`whip`/`k9`/`bb9` matching hand-calculated
+ values exactly, aggregated across both backfilled games.
+- Step 4 left the second player's pre-existing baseline **completely
+ unchanged** (`DO NOTHING` fired; row was excluded from the `INSERT ...
+ RETURNING` count).
+- The already-box-score team got zero season-stats rows from Step 4.
+- Re-running the whole migration file a second time was still a no-op
+ everywhere, including Step 4.
+- The pre-flight query above, run against the fixtures **before** applying,
+ correctly returned exactly the second player's at-risk row and nothing
+ else.
+- **Reproduced the exact risk this section documents:** after applying,
+ calling the real, unmodified `save_baseball_full_box_score()` RPC for a
+ brand-new ordinary game dated in the same season year, with both players
+ in its box score, behaved exactly as written above — the first player's
+ seeded row extended cleanly (2 games → 3, numbers correct) with no
+ surprise, while the second player's pre-existing `season_totals` baseline
+ was silently overwritten by that already-shipped RPC, exactly as warned.
+ This was not a hypothetical for this test — it happened on the very next
+ ordinary save.
+- The rollback story (games/box-score delete + the season-stats diff-based
+ delete described in "Season-stats rollback" above) correctly removed only
+ the seeded row and left the pre-existing baseline intact.
diff --git a/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql b/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql
new file mode 100644
index 000000000..34c5c2ef8
--- /dev/null
+++ b/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql
@@ -0,0 +1,716 @@
+-- =============================================================================
+-- #379 — ONE-TIME legacy stats backfill: baseball_player_stats → box-score.
+-- Migration: 20260715141727_baseball_legacy_stats_backfill.sql
+--
+-- STATUS: WRITTEN, NOT APPLIED. This file is committed pending Nick's explicit
+-- go-ahead. Do not `apply_migration` this without that sign-off — see
+-- docs/baseball/legacy-backfill-runbook.md for the check-first queries, how
+-- the orchestrator applies it, and the rollback story.
+--
+-- WHAT THIS DOES
+-- ---------------------------------------------------------------------------
+-- #827 (20260715's "#379 Phase 0" reconciliation) fixed *new* seeding/import
+-- paths to write BOTH stat layers going forward. It did not do anything for
+-- teams whose entire game history already lives ONLY in the legacy flat table
+-- (`baseball_player_stats`, stat_type = 'game') from before that fix — those
+-- teams still show real numbers on Command Center/Roster/Passport (legacy
+-- layer) but an honestly-empty Stats Center (`src/lib/baseball/read-models/
+-- stats-center.ts` reads ONLY the box-score/season layer — see its own
+-- module docstring). This migration is the one-time catch-up for exactly
+-- those teams:
+--
+-- 1. Identifies teams with ZERO existing `baseball_box_score_batting` /
+-- `baseball_box_score_pitching` rows (see "ELIGIBILITY" below). Teams
+-- that have ANY box-score row already — even one — are left completely
+-- alone; their box-score data is maintained live by the adapter
+-- precedence in `src/app/baseball/actions/imports.ts`
+-- (`applyGameBoxScoreImport`) and `src/app/baseball/actions/games.ts`
+-- (`save_baseball_full_box_score`), which this migration does not touch
+-- or race with (it never re-derives games for a team that already has
+-- box-score rows, full stop).
+-- 2. For those teams only, groups their `stat_type = 'game'` legacy rows by
+-- (team_id, session_date) into ONE shared, synthesized `baseball_games`
+-- row per team-date — mirroring the shared-game-schedule fix in
+-- scripts/seed-baseball-stats.mjs's `buildBoxScoreRowsForSessions`
+-- (#827): a game is a TEAM-level event every attending player's line
+-- references, never a private per-player row.
+-- 3. Copies each attending player's legacy row into one
+-- `baseball_box_score_batting` row (always) and one
+-- `baseball_box_score_pitching` row (only when `innings_pitched > 0`),
+-- computing avg/obp/slg/ops and era/whip/k9/bb9 with the SAME formulas
+-- `src/app/baseball/actions/games.ts`'s `computeBattingRates` /
+-- `computePitchingRates` use (including innings-pitched OUTS-based
+-- conversion — see `src/lib/baseball/innings.ts` — NOT naive decimal
+-- division of the X.1/X.2 notation).
+--
+-- practice / other rows (`stat_type IN ('practice','other')`) are never
+-- touched — box scores are a game-only concept (`baseball_games.game_type`
+-- CHECK only allows 'game'/'scrimmage'; legacy 'practice' sessions still have
+-- no box-score equivalent — same open gap #827's header notes).
+--
+-- COPY-ONLY: this migration only ever INSERTs — into `baseball_games`,
+-- `baseball_box_score_batting`, `baseball_box_score_pitching`, and (Step 4,
+-- below) `baseball_player_season_stats` guarded by `ON CONFLICT DO NOTHING`
+-- so an EXISTING season row is never touched. It never UPDATEs or DELETEs a
+-- single row of `baseball_player_stats` (or anything else) — the legacy
+-- table is read-only input here.
+--
+-- SEASON-STATS SAFETY (post-review fix — read this before assuming
+-- `baseball_player_season_stats` is inert until someone deliberately recalcs)
+-- ---------------------------------------------------------------------------
+-- `recalculate_baseball_season_stats()` is NOT an opt-in, manual, per-team
+-- step in practice: the ALREADY-SHIPPED, unrelated RPC
+-- `save_baseball_full_box_score` (`20260630000000_baseball_save_full_box_score_rpc.sql`)
+-- — called by every normal in-app box-score save — unconditionally calls it
+-- for every player in whatever game a coach just saved, for the CURRENT
+-- calendar year, and it does a full `ON CONFLICT ... DO UPDATE` OVERWRITE
+-- (not a merge) of that player's `baseball_player_season_stats` row. This
+-- migration inserts `baseball_games` rows carrying the legacy rows' REAL
+-- historical `game_date` — for a team whose whole history "predates #827"
+-- (applied earlier the same day as this migration), those dates can very
+-- plausibly fall in the current season year. So the instant any overlapping
+-- player has ONE new ordinary game entered via the Games UI in that same
+-- year, the live recalc sweeps up these backfilled box-score rows and
+-- overwrites `baseball_player_season_stats` for that (player, team, year) —
+-- with or without anyone touching this migration again.
+--
+-- Given that, Step 4 below SEEDS `baseball_player_season_stats` now, for
+-- exactly the (player_id, team_id, season_year) triples this migration's own
+-- box-score rows touch, using the IDENTICAL aggregation + rate formulas
+-- `recalculate_baseball_season_stats()` uses (see
+-- `20260624001000_baseball_official_stat_breadth.sql:145-265`) — so that the
+-- inevitable future recalc lands on the SAME numbers we just seeded (a
+-- substantive no-op, not a silent surprise). It is guarded by
+-- `ON CONFLICT (player_id, team_id, season_year) DO NOTHING`: a
+-- season_totals-imported baseline that already exists for one of these
+-- triples is NEVER overwritten by this migration — that preserves the
+-- copy-only/additive-only contract. That pre-existing baseline remains
+-- exposed to the SAME already-shipped recalc-on-save behavior once this
+-- migration's box-score rows exist (not a new risk this migration invents,
+-- but one it makes far more likely to actually fire) — see the runbook's
+-- "Season-stats interaction" section for the pre-flight snapshot query that
+-- identifies exactly which triples are at risk, and the sign-off/rollback
+-- guidance for them.
+--
+-- ELIGIBILITY ("zero box-score data")
+-- ---------------------------------------------------------------------------
+-- A team qualifies iff it has a `stat_type = 'game'` row in
+-- `baseball_player_stats` AND zero rows in BOTH `baseball_box_score_batting`
+-- and `baseball_box_score_pitching`. This set is snapshotted ONCE into a
+-- session-local TEMP TABLE (dropped at COMMIT, never persisted) before any
+-- writes below happen, so a team's eligibility can never be affected by rows
+-- THIS migration itself inserts mid-run (no Halloween-problem self-exclusion,
+-- no accidental partial-team backfill either).
+--
+-- Defensive extra: even for an eligible team, a (team, date) pair is skipped
+-- if a `baseball_games` row ALREADY exists for that exact team+date (e.g. a
+-- scheduled game created via the Games UI with no stats entered yet) — this
+-- migration never risks minting a second, duplicate game row alongside one
+-- that already exists. Any such date is left for manual/live handling.
+--
+-- KNOWN LIMITATIONS (documented, not fixed here — one-time script, not a
+-- product feature):
+-- * Grouping key is (team_id, session_date) only, matching the
+-- #827/scripts/seed-baseball-stats.mjs precedent this migration was asked
+-- to mirror — a real double-header (two DIFFERENT-opponent games, same
+-- team, same date) cannot be told apart and collapses onto one game.
+-- * If a player somehow has more than one `stat_type = 'game'` legacy row
+-- for the same team+date (duplicate manual entry), only one wins per
+-- (game, player) — enforced by `baseball_box_score_batting`/`_pitching`'s
+-- own `UNIQUE (game_id, player_id)` constraint, which has no concept of a
+-- player appearing twice in "the same game". The winner is the legacy row
+-- with the lexicographically-smallest `id`, chosen via `ROW_NUMBER()` so
+-- re-running this migration is fully idempotent (same winner every time).
+-- * Pitching `hr` (home runs allowed) has no legacy column and is always 0
+-- — same true gap as `opponent_score`/`our_score` (legacy never captured
+-- these; left NULL, not fabricated).
+--
+-- IDEMPOTENCY
+-- ---------------------------------------------------------------------------
+-- Every id below is DETERMINISTIC — a SHA-1 hash of a namespaced key, shaped
+-- into RFC4122-v5-style bytes (version nibble forced to 0x5, variant bits
+-- forced to 10xx), the exact pattern `scripts/seed-baseball-stats.mjs`'s
+-- `detId()` uses (see its header comment + #827). The namespace here is
+-- `baseball-legacy-backfill-379` — DELIBERATELY DIFFERENT from the seed
+-- script's `baseball-stats-seed` namespace, so these ids can never collide
+-- with anything the demo seeder (or anything else) has ever produced, and so
+-- a rollback can RECOMPUTE (not merely record) exactly which rows are this
+-- migration's. Every INSERT below is `ON CONFLICT (...) DO NOTHING` keyed on
+-- that deterministic id (or the table's own natural unique key), so re-running
+-- this file is always a no-op the second time. Step 4's season-stats seed has
+-- no id of its own to derive — it's keyed on the table's existing
+-- `(player_id, team_id, season_year)` unique constraint with `DO NOTHING`,
+-- which is equally idempotent: a second run recomputes the identical
+-- aggregate and finds the conflict already satisfied (either from its own
+-- first run or a pre-existing row it never touched either time).
+--
+-- No new database function is created. The id derivation is inlined as plain
+-- SQL (bytea `get_byte`/`set_byte` + `pgcrypto.digest`) inside CTEs, computed
+-- fresh in this transaction and never persisted as a callable object — so
+-- there is nothing new here to REVOKE from anon or pin a search_path on. This
+-- migration calls no functions at all beyond core Postgres builtins and
+-- pgcrypto's `digest()` (already installed — see 20260527000000's
+-- `CREATE EXTENSION IF NOT EXISTS pgcrypto`). It still deliberately does NOT
+-- call `public.recalculate_baseball_season_stats` itself — Step 4 mirrors
+-- its aggregation logic inline (read-only against the rows this migration
+-- just wrote) rather than invoking the live RPC, so this migration never
+-- depends on — or risks a future edit to — that shared function's behavior.
+-- See SEASON-STATS SAFETY above.
+--
+-- Wrapped in an explicit BEGIN/COMMIT (precedented in this repo — see
+-- 20260528041553_fix_coachhelm_settings_preferences_and_insight_types.sql) so
+-- the two TEMP TABLE snapshots and all four INSERTs commit atomically as one
+-- unit regardless of how the migration runner batches statements.
+-- =============================================================================
+
+BEGIN;
+
+-- ----------------------------------------------------------------------------
+-- Step 0 — snapshot eligible ("zero box-score data") teams BEFORE any writes.
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS pg_temp._bb_legacy_backfill_379_teams;
+CREATE TEMP TABLE _bb_legacy_backfill_379_teams (
+ team_id uuid PRIMARY KEY
+) ON COMMIT DROP;
+
+INSERT INTO _bb_legacy_backfill_379_teams (team_id)
+SELECT DISTINCT ps.team_id
+FROM public.baseball_player_stats ps
+WHERE ps.stat_type = 'game'
+ AND NOT EXISTS (
+ SELECT 1 FROM public.baseball_box_score_batting bsb WHERE bsb.team_id = ps.team_id
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM public.baseball_box_score_pitching bsp WHERE bsp.team_id = ps.team_id
+ );
+
+-- ----------------------------------------------------------------------------
+-- Step 1 — one shared team-date game per eligible team, id derived from
+-- (team_id, session_date) only (mirrors #827's buildBoxScoreRowsForSessions:
+-- "Scoped to TEAM + DATE only (never player_id)").
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS pg_temp._bb_legacy_backfill_379_games;
+CREATE TEMP TABLE _bb_legacy_backfill_379_games (
+ team_id uuid NOT NULL,
+ session_date date NOT NULL,
+ opponent_name text,
+ game_id uuid NOT NULL,
+ PRIMARY KEY (team_id, session_date)
+) ON COMMIT DROP;
+
+INSERT INTO _bb_legacy_backfill_379_games (team_id, session_date, opponent_name, game_id)
+WITH game_groups AS (
+ SELECT
+ ps.team_id,
+ ps.session_date,
+ -- Deterministic, stable pick among possibly-differing session_name values
+ -- for the same team+date (alphabetically-smallest non-blank value wins).
+ -- Mirrors the live import path's own convention
+ -- (`findOrCreateImportGame` in src/app/baseball/actions/imports.ts:
+ -- `opponentName: sessionName?.trim() || null`) — session_name IS the
+ -- opponent name for real legacy rows, not a "Game vs X" prefix (that
+ -- prefix is scripts/seed-baseball-stats.mjs's OWN synthetic-data
+ -- convention, not a real-data one).
+ MIN(NULLIF(TRIM(ps.session_name), '')) AS opponent_name
+ FROM public.baseball_player_stats ps
+ JOIN _bb_legacy_backfill_379_teams t ON t.team_id = ps.team_id
+ WHERE ps.stat_type = 'game'
+ GROUP BY ps.team_id, ps.session_date
+ HAVING NOT EXISTS (
+ -- Defensive: never mint a second game row for a team+date that already
+ -- has one (e.g. a scheduled-but-not-yet-played game from the Games UI).
+ SELECT 1 FROM public.baseball_games bg
+ WHERE bg.team_id = ps.team_id AND bg.game_date = ps.session_date
+ )
+),
+hashed AS (
+ SELECT
+ g.team_id, g.session_date, g.opponent_name,
+ substring(
+ public.digest(
+ 'baseball-legacy-backfill-379:box-game:' || g.team_id::text || ':' || g.session_date::text,
+ 'sha1'
+ )
+ FROM 1 FOR 16
+ ) AS raw16
+ FROM game_groups g
+),
+versioned AS (
+ SELECT team_id, session_date, opponent_name,
+ set_byte(raw16, 6, (get_byte(raw16, 6) & 15) | 80) AS b1 -- version nibble -> 0x5
+ FROM hashed
+),
+varianted AS (
+ SELECT team_id, session_date, opponent_name,
+ set_byte(
+ b1, 8,
+ ((((get_byte(b1, 8) >> 4) & 3) | 8) << 4) | (get_byte(b1, 8) & 15) -- variant bits -> 10xx
+ ) AS b2
+ FROM versioned
+),
+hexed AS (
+ SELECT team_id, session_date, opponent_name, encode(b2, 'hex') AS hx
+ FROM varianted
+)
+SELECT
+ team_id, session_date, opponent_name,
+ (
+ substring(hx FROM 1 FOR 8) || '-' || substring(hx FROM 9 FOR 4) || '-' ||
+ substring(hx FROM 13 FOR 4) || '-' || substring(hx FROM 17 FOR 4) || '-' ||
+ substring(hx FROM 21 FOR 12)
+ )::uuid AS game_id
+FROM hexed;
+
+INSERT INTO public.baseball_games (
+ id, team_id, game_date, game_type, opponent_name, status, notes
+)
+SELECT
+ g.game_id,
+ g.team_id,
+ g.session_date,
+ 'game',
+ g.opponent_name,
+ 'completed',
+ 'Backfilled by #379 one-time legacy stats backfill from baseball_player_stats '
+ || '(copy-only; legacy rows untouched). Deterministic id — see '
+ || 'docs/baseball/legacy-backfill-runbook.md for rollback.'
+FROM _bb_legacy_backfill_379_games g
+ON CONFLICT (id) DO NOTHING;
+
+-- ----------------------------------------------------------------------------
+-- Step 2 — batting lines. One per (game, player); dedupe via ROW_NUMBER when
+-- the legacy table somehow has >1 'game' row for the same player+team+date.
+-- ----------------------------------------------------------------------------
+WITH candidates AS (
+ SELECT ps.*, g.game_id
+ FROM public.baseball_player_stats ps
+ JOIN _bb_legacy_backfill_379_games g
+ ON g.team_id = ps.team_id AND g.session_date = ps.session_date
+ WHERE ps.stat_type = 'game'
+),
+ranked AS (
+ SELECT c.*, ROW_NUMBER() OVER (PARTITION BY c.game_id, c.player_id ORDER BY c.id) AS rn
+ FROM candidates c
+),
+one_per_player AS (
+ SELECT * FROM ranked WHERE rn = 1
+),
+norm AS (
+ SELECT
+ o.game_id, o.player_id, o.team_id,
+ COALESCE(o.at_bats, 0)::int AS ab,
+ COALESCE(o.hits, 0)::int AS h,
+ COALESCE(o.doubles, 0)::int AS doubles,
+ COALESCE(o.triples, 0)::int AS triples,
+ COALESCE(o.home_runs, 0)::int AS hr,
+ COALESCE(o.rbis, 0)::int AS rbi,
+ COALESCE(o.walks, 0)::int AS bb,
+ COALESCE(o.strikeouts, 0)::int AS k,
+ COALESCE(o.stolen_bases, 0)::int AS sb,
+ COALESCE(o.caught_stealing, 0)::int AS cs,
+ COALESCE(o.hit_by_pitch, 0)::int AS hbp,
+ COALESCE(o.sacrifice_bunts, 0)::int AS sac,
+ COALESCE(o.sacrifice_flies, 0)::int AS sf
+ FROM one_per_player o
+),
+hashed AS (
+ SELECT n.*,
+ substring(
+ public.digest(
+ 'baseball-legacy-backfill-379:box-bat:' || n.game_id::text || ':' || n.player_id::text,
+ 'sha1'
+ )
+ FROM 1 FOR 16
+ ) AS raw16
+ FROM norm n
+),
+versioned AS (
+ SELECT *, set_byte(raw16, 6, (get_byte(raw16, 6) & 15) | 80) AS b1 FROM hashed
+),
+varianted AS (
+ SELECT *,
+ set_byte(b1, 8, ((((get_byte(b1, 8) >> 4) & 3) | 8) << 4) | (get_byte(b1, 8) & 15)) AS b2
+ FROM versioned
+),
+ided AS (
+ SELECT *,
+ (
+ substring(hx FROM 1 FOR 8) || '-' || substring(hx FROM 9 FOR 4) || '-' ||
+ substring(hx FROM 13 FOR 4) || '-' || substring(hx FROM 17 FOR 4) || '-' ||
+ substring(hx FROM 21 FOR 12)
+ )::uuid AS bat_id
+ FROM (SELECT *, encode(b2, 'hex') AS hx FROM varianted) e
+),
+rated AS (
+ -- Rate formulas mirror src/app/baseball/actions/games.ts's computeBattingRates()
+ -- exactly (same ab==0 short-circuit to NULL, same singles/slg/obp/ops math).
+ SELECT
+ bat_id, game_id, player_id, team_id, ab, h, doubles, triples, hr, rbi, bb, k, sb, cs, hbp, sac, sf,
+ CASE WHEN ab > 0 THEN ROUND(h::numeric / ab, 3) END AS avg,
+ CASE WHEN (ab + bb + hbp + sf) > 0
+ THEN ROUND((h + bb + hbp)::numeric / (ab + bb + hbp + sf), 3)
+ END AS obp,
+ CASE WHEN ab > 0
+ THEN ROUND(((h - doubles - triples - hr) + 2 * doubles + 3 * triples + 4 * hr)::numeric / ab, 3)
+ END AS slg
+ FROM ided
+),
+final AS (
+ SELECT r.*, CASE WHEN r.obp IS NOT NULL AND r.slg IS NOT NULL THEN ROUND(r.obp + r.slg, 3) END AS ops
+ FROM rated r
+)
+INSERT INTO public.baseball_box_score_batting (
+ id, game_id, player_id, team_id,
+ ab, r, h, doubles, triples, hr, rbi, bb, k, sb, cs, hbp, sac, sf, lob, batting_order,
+ avg, obp, slg, ops
+)
+SELECT
+ bat_id, game_id, player_id, team_id,
+ ab,
+ 0, -- r (runs scored): no legacy column — honestly 0, not fabricated
+ h, doubles, triples, hr, rbi, bb, k, sb, cs, hbp, sac, sf,
+ 0, -- lob: no legacy column (CSV-import-only ephemeral field, never persisted)
+ NULL, -- batting_order: no legacy column
+ avg, obp, slg, ops
+FROM final
+ON CONFLICT (game_id, player_id) DO NOTHING;
+
+-- ----------------------------------------------------------------------------
+-- Step 3 — pitching lines. Only legacy rows that actually recorded innings
+-- pitched (session.innings_pitched > 0), same dedupe pattern as batting.
+-- ----------------------------------------------------------------------------
+WITH candidates AS (
+ SELECT ps.*, g.game_id
+ FROM public.baseball_player_stats ps
+ JOIN _bb_legacy_backfill_379_games g
+ ON g.team_id = ps.team_id AND g.session_date = ps.session_date
+ WHERE ps.stat_type = 'game'
+ AND ps.innings_pitched IS NOT NULL
+ AND ps.innings_pitched > 0
+),
+ranked AS (
+ SELECT c.*, ROW_NUMBER() OVER (PARTITION BY c.game_id, c.player_id ORDER BY c.id) AS rn
+ FROM candidates c
+),
+one_per_player AS (
+ SELECT * FROM ranked WHERE rn = 1
+),
+norm AS (
+ SELECT
+ o.game_id, o.player_id, o.team_id,
+ o.innings_pitched AS ip,
+ COALESCE(o.hits_allowed, 0)::int AS h,
+ COALESCE(o.runs_allowed, 0)::int AS r,
+ COALESCE(o.earned_runs, 0)::int AS er,
+ COALESCE(o.walks_allowed, 0)::int AS bb,
+ COALESCE(o.strikeouts_thrown, 0)::int AS k,
+ o.pitches_thrown AS pitch_count,
+ o.strikes_thrown AS strikes
+ FROM one_per_player o
+),
+hashed AS (
+ SELECT n.*,
+ substring(
+ public.digest(
+ 'baseball-legacy-backfill-379:box-pit:' || n.game_id::text || ':' || n.player_id::text,
+ 'sha1'
+ )
+ FROM 1 FOR 16
+ ) AS raw16
+ FROM norm n
+),
+versioned AS (
+ SELECT *, set_byte(raw16, 6, (get_byte(raw16, 6) & 15) | 80) AS b1 FROM hashed
+),
+varianted AS (
+ SELECT *,
+ set_byte(b1, 8, ((((get_byte(b1, 8) >> 4) & 3) | 8) << 4) | (get_byte(b1, 8) & 15)) AS b2
+ FROM versioned
+),
+ided AS (
+ SELECT *,
+ (
+ substring(hx FROM 1 FOR 8) || '-' || substring(hx FROM 9 FOR 4) || '-' ||
+ substring(hx FROM 13 FOR 4) || '-' || substring(hx FROM 17 FOR 4) || '-' ||
+ substring(hx FROM 21 FOR 12)
+ )::uuid AS pit_id
+ FROM (SELECT *, encode(b2, 'hex') AS hx FROM varianted) e
+),
+outsed AS (
+ -- OUTS-based conversion of the X.1/X.2 innings-pitched notation — mirrors
+ -- src/lib/baseball/innings.ts's ipToOuts()/ipToInnings() EXACTLY (tenths
+ -- digit = outs, not a base-10 fraction). Naive `ip / 1.0` division here
+ -- would silently corrupt every rate stat for any partial-inning row.
+ SELECT *,
+ (trunc(ip)::int * 3 + round((ip - trunc(ip)) * 10)::int) AS outs
+ FROM ided
+),
+rated AS (
+ -- era/whip/k9/bb9 formulas mirror computePitchingRates() exactly.
+ SELECT
+ pit_id, game_id, player_id, team_id, ip, h, r, er, bb, k, pitch_count, strikes, outs,
+ CASE WHEN outs > 0 THEN ROUND(9.0 * er / (outs / 3.0), 2) END AS era,
+ CASE WHEN outs > 0 THEN ROUND((bb + h)::numeric / (outs / 3.0), 3) END AS whip,
+ CASE WHEN outs > 0 THEN ROUND(9.0 * k / (outs / 3.0), 2) END AS k9,
+ CASE WHEN outs > 0 THEN ROUND(9.0 * bb / (outs / 3.0), 2) END AS bb9
+ FROM outsed
+)
+INSERT INTO public.baseball_box_score_pitching (
+ id, game_id, player_id, team_id, ip, h, r, er, bb, k, hr, pitch_count, strikes, result,
+ era, whip, k9, bb9
+)
+SELECT
+ pit_id, game_id, player_id, team_id, ip, h, r, er, bb, k,
+ 0, -- hr (home runs allowed): no legacy column — honestly 0, not fabricated
+ pitch_count, strikes,
+ NULL, -- result (W/L/S/H/BS/ND): no legacy column, decision unknown
+ era, whip, k9, bb9
+FROM rated
+ON CONFLICT (game_id, player_id) DO NOTHING;
+
+-- ----------------------------------------------------------------------------
+-- Step 4 — season-stat seed for exactly the (player_id, team_id, season_year)
+-- triples this migration's own box-score rows touch. See "SEASON-STATS
+-- SAFETY" in the header for why this exists. Aggregation + rate formulas
+-- below are a byte-for-byte mirror of
+-- `public.recalculate_baseball_season_stats()`
+-- (20260624001000_baseball_official_stat_breadth.sql:145-265) — same SUMs,
+-- same `g_p`/`w`/`l`/`sv` derivation from `result`, same era/whip/k9/bb9
+-- division by raw `ip` (not outs-converted — matching that function's own
+-- math exactly, not Step 3's per-game outs-based conversion above). Reading
+-- the mirror inline (rather than calling the live RPC) means this migration
+-- never depends on, or risks a future edit to, that shared function.
+--
+-- `ON CONFLICT (player_id, team_id, season_year) DO NOTHING`: a row that
+-- already exists for one of these triples (e.g. a season_totals-imported
+-- baseline) is NEVER touched here — copy-only/additive-only preserved. Any
+-- such pre-existing row is surfaced by the runbook's pre-flight snapshot
+-- query for Nick's review, not silently left for the live recalc-on-save
+-- path to overwrite unannounced.
+-- ----------------------------------------------------------------------------
+WITH bb379_touched AS (
+ SELECT DISTINCT bsb.player_id, bsb.team_id, EXTRACT(YEAR FROM bg.game_date)::integer AS season_year
+ FROM public.baseball_box_score_batting bsb
+ JOIN _bb_legacy_backfill_379_games tg ON tg.game_id = bsb.game_id
+ JOIN public.baseball_games bg ON bg.id = bsb.game_id
+ UNION
+ SELECT DISTINCT bsp.player_id, bsp.team_id, EXTRACT(YEAR FROM bg.game_date)::integer AS season_year
+ FROM public.baseball_box_score_pitching bsp
+ JOIN _bb_legacy_backfill_379_games tg ON tg.game_id = bsp.game_id
+ JOIN public.baseball_games bg ON bg.id = bsp.game_id
+),
+bb379_bat_agg AS (
+ -- Mirrors recalc's batting SELECT (breadth migration lines 146-176) exactly.
+ SELECT
+ t.player_id, t.team_id, t.season_year,
+ COUNT(DISTINCT bsb.game_id)::integer AS g,
+ COALESCE(SUM(bsb.ab), 0)::integer AS ab,
+ COALESCE(SUM(bsb.r), 0)::integer AS r,
+ COALESCE(SUM(bsb.h), 0)::integer AS h,
+ COALESCE(SUM(bsb.doubles), 0)::integer AS doubles,
+ COALESCE(SUM(bsb.triples), 0)::integer AS triples,
+ COALESCE(SUM(bsb.hr), 0)::integer AS hr,
+ COALESCE(SUM(bsb.rbi), 0)::integer AS rbi,
+ COALESCE(SUM(bsb.bb), 0)::integer AS bb,
+ COALESCE(SUM(bsb.k), 0)::integer AS k,
+ COALESCE(SUM(bsb.sb), 0)::integer AS sb,
+ COALESCE(SUM(bsb.cs), 0)::integer AS cs,
+ COALESCE(SUM(bsb.hbp), 0)::integer AS hbp,
+ COALESCE(SUM(bsb.sac), 0)::integer AS sac,
+ COALESCE(SUM(bsb.sf), 0)::integer AS sf,
+ COALESCE(SUM(bsb.ibb), 0)::integer AS ibb,
+ COALESCE(SUM(bsb.gidp), 0)::integer AS gidp,
+ COALESCE(SUM(bsb.roe), 0)::integer AS roe,
+ COALESCE(SUM(bsb.two_out_rbi), 0)::integer AS two_out_rbi,
+ COALESCE(SUM(bsb.lob), 0)::integer AS lob
+ FROM bb379_touched t
+ JOIN public.baseball_box_score_batting bsb
+ ON bsb.player_id = t.player_id AND bsb.team_id = t.team_id
+ JOIN public.baseball_games bg
+ ON bg.id = bsb.game_id AND bg.status = 'completed'
+ AND EXTRACT(YEAR FROM bg.game_date)::integer = t.season_year
+ GROUP BY t.player_id, t.team_id, t.season_year
+),
+bb379_bat_rated AS (
+ -- Rate formulas mirror recalc's batting rates (breadth migration lines 178-187) exactly.
+ SELECT
+ a.*,
+ CASE WHEN a.ab > 0 THEN ROUND(a.h::numeric / a.ab, 3) END AS avg,
+ CASE WHEN (a.ab + a.bb + a.hbp + a.sf) > 0
+ THEN ROUND((a.h + a.bb + a.hbp)::numeric / (a.ab + a.bb + a.hbp + a.sf), 3)
+ END AS obp,
+ CASE WHEN a.ab > 0
+ THEN ROUND(((a.h - a.doubles - a.triples - a.hr) + 2 * a.doubles + 3 * a.triples + 4 * a.hr)::numeric / a.ab, 3)
+ END AS slg
+ FROM bb379_bat_agg a
+),
+bb379_bat_final AS (
+ -- ops mirrors recalc's `IF v_obp IS NOT NULL AND v_slg IS NOT NULL` (breadth
+ -- migration lines 188-190) exactly.
+ SELECT
+ r.*,
+ CASE WHEN r.obp IS NOT NULL AND r.slg IS NOT NULL THEN ROUND(r.obp + r.slg, 3) END AS ops
+ FROM bb379_bat_rated r
+),
+bb379_pit_agg AS (
+ -- Mirrors recalc's pitching SELECT (breadth migration lines 193-220) exactly,
+ -- including deriving w/l/sv/holds/blown_saves from `result` (always NULL on
+ -- our backfilled rows — no legacy source — so these are always 0 here).
+ SELECT
+ t.player_id, t.team_id, t.season_year,
+ COUNT(DISTINCT bsp.game_id)::integer AS g_p,
+ COUNT(CASE WHEN bsp.result = 'W' THEN 1 END)::integer AS w,
+ COUNT(CASE WHEN bsp.result = 'L' THEN 1 END)::integer AS l,
+ COUNT(CASE WHEN bsp.result = 'S' THEN 1 END)::integer AS sv,
+ COALESCE(SUM(bsp.ip), 0) AS ip,
+ COALESCE(SUM(bsp.h), 0)::integer AS h_allowed,
+ COALESCE(SUM(bsp.r), 0)::integer AS r_allowed,
+ COALESCE(SUM(bsp.er), 0)::integer AS er,
+ COALESCE(SUM(bsp.bb), 0)::integer AS bb_allowed,
+ COALESCE(SUM(bsp.k), 0)::integer AS k_thrown,
+ COALESCE(SUM(bsp.hr), 0)::integer AS hr_allowed,
+ COALESCE(SUM(bsp.gf), 0)::integer AS gf,
+ (COUNT(CASE WHEN bsp.result = 'H' THEN 1 END)::integer + COALESCE(SUM(bsp.holds), 0)::integer) AS holds,
+ (COUNT(CASE WHEN bsp.result = 'BS' THEN 1 END)::integer + COALESCE(SUM(bsp.blown_saves), 0)::integer) AS blown_saves,
+ COALESCE(SUM(bsp.bf), 0)::integer AS bf,
+ COALESCE(SUM(bsp.hbp), 0)::integer AS p_hbp,
+ COALESCE(SUM(bsp.wp), 0)::integer AS wp
+ FROM bb379_touched t
+ JOIN public.baseball_box_score_pitching bsp
+ ON bsp.player_id = t.player_id AND bsp.team_id = t.team_id
+ JOIN public.baseball_games bg
+ ON bg.id = bsp.game_id AND bg.status = 'completed'
+ AND EXTRACT(YEAR FROM bg.game_date)::integer = t.season_year
+ GROUP BY t.player_id, t.team_id, t.season_year
+),
+bb379_pit_final AS (
+ -- era/whip/k9/bb9 mirror recalc's pitching rates (breadth migration lines
+ -- 222-227) exactly — division by raw `ip`, not outs-converted.
+ SELECT
+ p.*,
+ CASE WHEN p.ip > 0 THEN ROUND(9.0 * p.er / p.ip, 2) END AS era,
+ CASE WHEN p.ip > 0 THEN ROUND((p.bb_allowed + p.h_allowed)::numeric / p.ip, 3) END AS whip,
+ CASE WHEN p.ip > 0 THEN ROUND(9.0 * p.k_thrown / p.ip, 2) END AS k9,
+ CASE WHEN p.ip > 0 THEN ROUND(9.0 * p.bb_allowed / p.ip, 2) END AS bb9
+ FROM bb379_pit_agg p
+)
+INSERT INTO public.baseball_player_season_stats (
+ player_id, team_id, season_year,
+ g, ab, r, h, doubles, triples, hr, rbi, bb, k, sb, cs, hbp, sac, sf,
+ ibb, gidp, roe, two_out_rbi, lob,
+ avg, obp, slg, ops,
+ g_p, gs, w, l, sv, ip, h_allowed, r_allowed, er, bb_allowed, k_thrown, hr_allowed,
+ gf, holds, blown_saves, bf, p_hbp, wp,
+ era, whip, k9, bb9,
+ last_updated
+)
+SELECT
+ t.player_id, t.team_id, t.season_year,
+ COALESCE(bat.g, 0), COALESCE(bat.ab, 0), COALESCE(bat.r, 0), COALESCE(bat.h, 0),
+ COALESCE(bat.doubles, 0), COALESCE(bat.triples, 0), COALESCE(bat.hr, 0), COALESCE(bat.rbi, 0),
+ COALESCE(bat.bb, 0), COALESCE(bat.k, 0), COALESCE(bat.sb, 0), COALESCE(bat.cs, 0),
+ COALESCE(bat.hbp, 0), COALESCE(bat.sac, 0), COALESCE(bat.sf, 0),
+ COALESCE(bat.ibb, 0), COALESCE(bat.gidp, 0), COALESCE(bat.roe, 0), COALESCE(bat.two_out_rbi, 0), COALESCE(bat.lob, 0),
+ bat.avg, bat.obp, bat.slg, bat.ops,
+ COALESCE(pit.g_p, 0), 0, COALESCE(pit.w, 0), COALESCE(pit.l, 0), COALESCE(pit.sv, 0),
+ COALESCE(pit.ip, 0), COALESCE(pit.h_allowed, 0), COALESCE(pit.r_allowed, 0), COALESCE(pit.er, 0),
+ COALESCE(pit.bb_allowed, 0), COALESCE(pit.k_thrown, 0), COALESCE(pit.hr_allowed, 0),
+ COALESCE(pit.gf, 0), COALESCE(pit.holds, 0), COALESCE(pit.blown_saves, 0), COALESCE(pit.bf, 0),
+ COALESCE(pit.p_hbp, 0), COALESCE(pit.wp, 0),
+ pit.era, pit.whip, pit.k9, pit.bb9,
+ now()
+FROM bb379_touched t
+LEFT JOIN bb379_bat_final bat ON bat.player_id = t.player_id AND bat.team_id = t.team_id AND bat.season_year = t.season_year
+LEFT JOIN bb379_pit_final pit ON pit.player_id = t.player_id AND pit.team_id = t.team_id AND pit.season_year = t.season_year
+ON CONFLICT (player_id, team_id, season_year) DO NOTHING;
+
+COMMIT;
+
+-- =============================================================================
+-- VERIFICATION — run these by hand (or via mcp__supabase__execute_sql) BEFORE
+-- and AFTER applying. None of this runs as part of the migration itself.
+-- =============================================================================
+
+-- ---- BEFORE: preview which teams/rows this migration will touch ----
+-- SELECT
+-- ps.team_id,
+-- COUNT(*) FILTER (WHERE ps.stat_type = 'game') AS legacy_game_rows,
+-- COUNT(DISTINCT ps.session_date) FILTER (WHERE ps.stat_type = 'game') AS legacy_game_dates
+-- FROM public.baseball_player_stats ps
+-- WHERE ps.stat_type = 'game'
+-- AND NOT EXISTS (SELECT 1 FROM public.baseball_box_score_batting bsb WHERE bsb.team_id = ps.team_id)
+-- AND NOT EXISTS (SELECT 1 FROM public.baseball_box_score_pitching bsp WHERE bsp.team_id = ps.team_id)
+-- GROUP BY ps.team_id
+-- ORDER BY legacy_game_rows DESC;
+
+-- ---- AFTER: row-count parity per team, legacy vs box-score ----
+-- Distinct (team, date) game-slots: legacy vs synthesized baseball_games.
+-- (Counts should match UNLESS the "skip if a game already exists that date"
+-- defensive guard fired for some dates — check baseball_games.notes for the
+-- '#379 one-time legacy stats backfill' tag to see which games are ours.)
+-- WITH legacy_dates AS (
+-- SELECT team_id, COUNT(DISTINCT session_date) AS n
+-- FROM public.baseball_player_stats
+-- WHERE stat_type = 'game'
+-- GROUP BY team_id
+-- ),
+-- backfilled_games AS (
+-- SELECT team_id, COUNT(*) AS n
+-- FROM public.baseball_games
+-- WHERE notes LIKE 'Backfilled by #379 one-time legacy stats backfill%'
+-- GROUP BY team_id
+-- )
+-- SELECT ld.team_id, ld.n AS legacy_game_dates, COALESCE(bg.n, 0) AS backfilled_games
+-- FROM legacy_dates ld
+-- LEFT JOIN backfilled_games bg ON bg.team_id = ld.team_id
+-- ORDER BY ld.team_id;
+
+-- Per-player batting/pitching row parity for a specific team (swap in a real id):
+-- SELECT
+-- (SELECT COUNT(*) FROM public.baseball_player_stats
+-- WHERE team_id = '00000000-0000-0000-0000-000000000000' AND stat_type = 'game') AS legacy_game_rows,
+-- (SELECT COUNT(*) FROM public.baseball_box_score_batting bsb
+-- JOIN public.baseball_games g ON g.id = bsb.game_id
+-- WHERE bsb.team_id = '00000000-0000-0000-0000-000000000000'
+-- AND g.notes LIKE 'Backfilled by #379 one-time legacy stats backfill%') AS backfilled_batting_rows,
+-- (SELECT COUNT(*) FROM public.baseball_box_score_pitching bsp
+-- JOIN public.baseball_games g ON g.id = bsp.game_id
+-- WHERE bsp.team_id = '00000000-0000-0000-0000-000000000000'
+-- AND g.notes LIKE 'Backfilled by #379 one-time legacy stats backfill%') AS backfilled_pitching_rows;
+-- (backfilled_batting_rows should equal legacy_game_rows unless duplicate
+-- team+date+player legacy rows existed — see KNOWN LIMITATIONS above.
+-- backfilled_pitching_rows will be <= legacy_game_rows: only rows with
+-- innings_pitched > 0 get a pitching line.)
+
+-- ---- BEFORE: season-stats pre-flight — rows AT RISK of a future silent
+-- recalc-on-save overwrite because they already exist for a triple this
+-- migration is about to touch (see SEASON-STATS SAFETY above and the
+-- runbook's "Season-stats interaction" section). Run this BEFORE applying —
+-- any row returned here is one Step 4's `DO NOTHING` will deliberately leave
+-- alone. Non-empty result = review/back these up with Nick before proceeding.
+-- SELECT bpss.*
+-- FROM public.baseball_player_season_stats bpss
+-- WHERE (bpss.player_id, bpss.team_id, bpss.season_year) IN (
+-- SELECT DISTINCT ps.player_id, ps.team_id, EXTRACT(YEAR FROM ps.session_date)::integer
+-- FROM public.baseball_player_stats ps
+-- WHERE ps.stat_type = 'game'
+-- AND NOT EXISTS (SELECT 1 FROM public.baseball_box_score_batting bsb WHERE bsb.team_id = ps.team_id)
+-- AND NOT EXISTS (SELECT 1 FROM public.baseball_box_score_pitching bsp WHERE bsp.team_id = ps.team_id)
+-- );
+
+-- ---- AFTER: season-stats rows for a backfilled team (swap in a real team id) ----
+-- SELECT DISTINCT bpss.*
+-- FROM public.baseball_player_season_stats bpss
+-- WHERE bpss.team_id = '00000000-0000-0000-0000-000000000000'
+-- AND EXISTS (
+-- SELECT 1 FROM public.baseball_games g
+-- WHERE g.team_id = bpss.team_id
+-- AND EXTRACT(YEAR FROM g.game_date)::integer = bpss.season_year
+-- AND g.notes LIKE 'Backfilled by #379 one-time legacy stats backfill%'
+-- )
+-- ORDER BY bpss.player_id, bpss.season_year;
+-- (Compare against the BEFORE pre-flight query above: any row here that was
+-- NOT in the BEFORE result was seeded by Step 4 and is safe to remove on
+-- rollback; any row that WAS in the BEFORE result is the pre-existing
+-- baseline Step 4 deliberately left untouched.)
+-- =============================================================================
From d2bb0149b99349bfdc97f369a905f184800ee2a3 Mon Sep 17 00:00:00 2001
From: njrini99-code
Date: Wed, 15 Jul 2026 18:43:13 -0400
Subject: [PATCH 09/18] baseball(engine): wire event-derived velocity into
engine-run/outcome-sweep/action-baseline (#852 residual) (#864)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* baseball(engine): wire event-derived velocity into engine-run/outcome-sweep/action-baseline (#852 residual)
Box-score-migrated players had NO velocity metrics: their legacy
exit_velocity/pitch_velocity scalar is dropped alongside superseded legacy
GAME rows (engine-stat-rows.ts rule 1), and the canonical box-score tables
carry no velocity columns at all. loaders.ts's eventDerived hook (#851)
already threaded a per-field event-layer override into loadPlayerMetrics,
but nothing called it.
Adds src/lib/baseball/coachhelm/engine-event-derived.ts: a team-scoped,
paginated read of baseball_pitch_events/baseball_batted_ball_events (#813
superseded-row filter) plus a pure per-player reducer that reuses
elite-stat-events.ts's real buildHitterMetrics/buildPitcherMetrics +
loaders.ts's eventDerivedVelocityFromMetrics -- never a second, drifting
"average exit velocity" implementation. All-or-nothing degrade on read
failure, mirroring engine-stat-rows.ts's own honesty rule.
Wires it into all three engine callers:
- engine-run.ts: full-history event pool -> loadAllPlayerMetrics.
- outcome-sweep.ts: event rows filtered to the SAME per-action after-window
as the box-score read, so a pre-action event never counts toward
did-it-move measurement.
- action-baseline.ts: full-history event pool -> the baseline capture.
Tests: pure aggregation (mixed hitter/pitcher, zero-event absence,
supersede filter, all-or-nothing degrade) plus per-caller wiring tests
(event wins over legacy scalar for the same player; a zero-event player
keeps their legacy velocity; event-read failure degrades every player to
legacy). Extends stat-layer-manifest.ts's grandfathered-consumer allowlist
for the new fixture files (legacy baseball_player_stats rows are the
fallback pin, not staleness).
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
* fix(baseball): bound velocity event read to player scope + fix sampleSize honesty (PR #864 fix-first)
Two adversarial-review criticals on #864:
1. buildActionOutcomeSeed (action-baseline.ts) fired a TEAM-WIDE, unbounded,
player-unscoped read of the entire pitch/batted-ball event history on
every coach "convert to action" click, just to resolve ONE player's
velocity scalar. loadEngineEventRows now takes an optional `playerIds`
scope (`.in('pitcher_id'|'batter_id', playerIds)`, mirroring
loadEngineStatRows's own `.in('player_id', playerIds)` idiom) — the
single-player caller passes `[playerId]`; engine-run/outcome-sweep now
pass their own already-computed roster/todo player-id lists instead of
reading the whole team's history.
2. avg_exit_velocity's sampleSize was `bbCount` (every batted ball) instead
of the count of rows that actually carried a non-null exit_velocity
reading — inflating the honesty gate for any team whose batted-ball
capture doesn't always log a radar reading. Fixed to
`battedBalls.filter(b => b.exit_velocity != null).length`, and applied
the same fix to the sibling avg_launch_angle metric (identical bug,
same line shape). Pitcher avg_velocity was already correct.
Tests: pin the DB-level player scoping (loadEngineEventRows + a
buildActionOutcomeSeed integration check), and pin the sampleSize fix (10
batted balls / 4 readings -> sampleSize 4; independent launch_angle gating;
hard_hit_rate's bbCount-based denominator unaffected).
Co-Authored-By: Claude Fable 5
---------
Co-authored-by: Fable Integrator
Co-authored-by: Claude Fable 5
---
.../action-baseline-event-velocity.test.ts | 132 +++++++
.../__tests__/action-baseline.test.ts | 9 +
.../__tests__/engine-event-derived.test.ts | 330 ++++++++++++++++++
.../engine-run-event-velocity.test.ts | 164 +++++++++
.../outcome-sweep-event-velocity.test.ts | 153 ++++++++
.../outcome-sweep-insight-resolve.test.ts | 6 +
src/lib/baseball/coachhelm/action-baseline.ts | 21 +-
.../coachhelm/engine-event-derived.ts | 237 +++++++++++++
src/lib/baseball/coachhelm/engine-run.ts | 21 ++
src/lib/baseball/coachhelm/outcome-sweep.ts | 37 +-
.../__tests__/elite-stat-events.test.ts | 40 +++
.../baseball/read-models/elite-stat-events.ts | 16 +-
src/lib/baseball/stat-layer-manifest.ts | 21 ++
13 files changed, 1183 insertions(+), 4 deletions(-)
create mode 100644 src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts
create mode 100644 src/lib/baseball/__tests__/engine-event-derived.test.ts
create mode 100644 src/lib/baseball/__tests__/engine-run-event-velocity.test.ts
create mode 100644 src/lib/baseball/__tests__/outcome-sweep-event-velocity.test.ts
create mode 100644 src/lib/baseball/coachhelm/engine-event-derived.ts
diff --git a/src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts b/src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts
new file mode 100644
index 000000000..d68643192
--- /dev/null
+++ b/src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts
@@ -0,0 +1,132 @@
+// =============================================================================
+// #852 residual: buildActionOutcomeSeed must thread event-derived velocity
+// into its baseline capture -- a box-score-migrated player's baseline must
+// read the elite event layer (never a legacy scalar) per #379 design rule 4,
+// while a zero-event player's legacy scalar keeps seeding the ledger exactly
+// as before.
+// =============================================================================
+
+import { describe, it, expect } from 'vitest';
+import { buildActionOutcomeSeed, type BaselineClient } from '@/lib/baseball/coachhelm/action-baseline';
+
+const TEAM = 'team-1';
+
+/**
+ * Minimal in-memory Supabase-shaped stub with REAL filtering (eq/in/is mutate
+ * the row set), matching action-baseline.test.ts's established style.
+ */
+function makeClient(
+ tables: {
+ baseball_player_stats?: Array>;
+ baseball_games?: Array>;
+ baseball_box_score_batting?: Array>;
+ baseball_box_score_pitching?: Array>;
+ baseball_pitch_events?: Array>;
+ baseball_batted_ball_events?: Array>;
+ },
+ inCalls?: Array<{ table: string; col: string; vals: unknown[] }>,
+): BaselineClient {
+ return {
+ from(table: string) {
+ let rows: Array> =
+ (tables as Record>>)[table] ?? [];
+ const api = {
+ select() {
+ return api;
+ },
+ eq(col: string, val: unknown) {
+ rows = rows.filter((r) => r[col] === val);
+ return api;
+ },
+ in(col: string, vals: unknown[]) {
+ inCalls?.push({ table, col, vals });
+ rows = rows.filter((r) => vals.includes(r[col]));
+ return api;
+ },
+ is(col: string, val: unknown) {
+ rows = rows.filter((r) => r[col] === val);
+ return api;
+ },
+ order() {
+ return api;
+ },
+ limit() {
+ return Promise.resolve({ data: rows, error: null });
+ },
+ range() {
+ return Promise.resolve({ data: rows, error: null });
+ },
+ maybeSingle() {
+ return Promise.resolve({ data: rows[0] ?? null, error: null });
+ },
+ };
+ return api;
+ },
+ };
+}
+
+describe('buildActionOutcomeSeed — #852 event-derived velocity wiring', () => {
+ it('captures the baseline from event-derived avg exit velocity, WINNING over the legacy scalar for the same player', async () => {
+ const client = makeClient({
+ baseball_player_stats: [
+ {
+ id: 's1', team_id: TEAM, player_id: 'p1', stat_type: 'game', session_date: '2026-04-01',
+ at_bats: 4, hits: 1, walks: 0, strikeouts: 1, exit_velocity: 80,
+ },
+ ],
+ baseball_batted_ball_events: [
+ { id: 'bb1', team_id: TEAM, batter_id: 'p1', exit_velocity: 100, measured_at: '2026-04-01T00:00:00.000Z', superseded_by_run_id: null },
+ { id: 'bb2', team_id: TEAM, batter_id: 'p1', exit_velocity: 104, measured_at: '2026-04-02T00:00:00.000Z', superseded_by_run_id: null },
+ ],
+ });
+
+ const seed = await buildActionOutcomeSeed(client, TEAM, 'p1', 'avg_exit_velocity');
+ expect(seed.outcome_metric).toBe('avg_exit_velocity');
+ // (100 + 104) / 2 = 102, NOT the legacy scalar of 80.
+ expect(seed.outcome_baseline_value).toBe(102);
+ expect(seed.outcome_verdict).toBeNull();
+ });
+
+ it('falls back to the legacy exit-velocity scalar for a player with zero event rows', async () => {
+ const client = makeClient({
+ baseball_player_stats: [
+ {
+ id: 's2', team_id: TEAM, player_id: 'p2', stat_type: 'game', session_date: '2026-04-01',
+ at_bats: 4, hits: 1, walks: 0, strikeouts: 1, exit_velocity: 90,
+ },
+ ],
+ baseball_batted_ball_events: [],
+ });
+
+ const seed = await buildActionOutcomeSeed(client, TEAM, 'p2', 'avg_exit_velocity');
+ expect(seed.outcome_metric).toBe('avg_exit_velocity');
+ expect(seed.outcome_baseline_value).toBe(90);
+ expect(seed.outcome_verdict).toBeNull();
+ });
+
+ it('scopes the event read to ONLY the subject player — never a team-wide unbounded scan on a single "convert to action" click', async () => {
+ const inCalls: Array<{ table: string; col: string; vals: unknown[] }> = [];
+ const client = makeClient(
+ {
+ baseball_player_stats: [
+ {
+ id: 's3', team_id: TEAM, player_id: 'p3', stat_type: 'game', session_date: '2026-04-01',
+ at_bats: 4, hits: 1, walks: 0, strikeouts: 1, exit_velocity: 75,
+ },
+ ],
+ baseball_batted_ball_events: [
+ { id: 'bb-p3', team_id: TEAM, batter_id: 'p3', exit_velocity: 100, measured_at: '2026-04-01T00:00:00.000Z', superseded_by_run_id: null },
+ // A teammate's batted ball -- must never enter p3's baseline read.
+ { id: 'bb-other', team_id: TEAM, batter_id: 'p-other', exit_velocity: 50, measured_at: '2026-04-01T00:00:00.000Z', superseded_by_run_id: null },
+ ],
+ },
+ inCalls,
+ );
+
+ const seed = await buildActionOutcomeSeed(client, TEAM, 'p3', 'avg_exit_velocity');
+ expect(seed.outcome_baseline_value).toBe(100); // NOT (100+50)/2 -- p-other never enters the pool.
+
+ const bbeScope = inCalls.find((c) => c.table === 'baseball_batted_ball_events' && c.col === 'batter_id');
+ expect(bbeScope?.vals).toEqual(['p3']);
+ });
+});
diff --git a/src/lib/baseball/__tests__/action-baseline.test.ts b/src/lib/baseball/__tests__/action-baseline.test.ts
index 1b07d9d5b..145aa7288 100644
--- a/src/lib/baseball/__tests__/action-baseline.test.ts
+++ b/src/lib/baseball/__tests__/action-baseline.test.ts
@@ -53,6 +53,15 @@ function makeClient(tables: {
rows = rows.filter((r) => vals.includes(r[col]));
return api;
},
+ // #852 residual: buildActionOutcomeSeed now also reads
+ // baseball_pitch_events/baseball_batted_ball_events (event-derived
+ // velocity) via the #813 superseded-row filter. Mirrors .eq()'s exact
+ // equality semantics so a fixture row can opt into (or out of) the
+ // supersede filter by setting `superseded_by_run_id` explicitly.
+ is(col: string, val: unknown) {
+ rows = rows.filter((r) => r[col] === val);
+ return api;
+ },
order() {
return api;
},
diff --git a/src/lib/baseball/__tests__/engine-event-derived.test.ts b/src/lib/baseball/__tests__/engine-event-derived.test.ts
new file mode 100644
index 000000000..838fddac4
--- /dev/null
+++ b/src/lib/baseball/__tests__/engine-event-derived.test.ts
@@ -0,0 +1,330 @@
+// =============================================================================
+// Unit tests for the #852 residual velocity-coverage fix.
+//
+// loaders.ts's `eventDerived` hook (#851) already threaded exit/pitch velocity
+// overrides into loadPlayerMetrics/loadAllPlayerMetrics, but nothing called it
+// -- box-score-migrated players (whose legacy exit_velocity/pitch_velocity
+// scalar is dropped alongside their superseded legacy GAME rows) had NO
+// velocity metric at all. engine-event-derived.ts is the missing wire. These
+// pin:
+// 1. eventDerivedVelocityForPlayer resolves a hitter's avg exit velocity from
+// their OWN batted-ball events and a pitcher's avg pitch velocity from
+// their OWN pitch events (independently -- a two-way player gets both).
+// 2. buildEventDerivedByPlayer only populates players with at least one
+// event row (honest absence for a zero-event player, never a fabricated
+// zero) -- the caller's per-player legacy fallback is what serves them.
+// 3. loadEngineEventRows respects the #813 superseded-row filter and
+// degrades ALL-OR-NOTHING (data: null) when either table's read fails.
+// =============================================================================
+
+import { describe, it, expect } from 'vitest';
+import {
+ loadEngineEventRows,
+ eventDerivedVelocityForPlayer,
+ buildEventDerivedByPlayer,
+} from '@/lib/baseball/coachhelm/engine-event-derived';
+import type {
+ BaseballPitchEvent,
+ BaseballBattedBallEvent,
+} from '@/lib/types/baseball-stat-events';
+
+const TEAM = 'team-1';
+
+function pitchEvent(overrides: Partial & { id: string }): BaseballPitchEvent {
+ return {
+ team_id: TEAM,
+ game_id: null,
+ practice_id: null,
+ plate_appearance_id: null,
+ pitcher_id: null,
+ batter_id: null,
+ catcher_id: null,
+ data_context: 'official_game',
+ pitch_number: null,
+ pitch_type: null,
+ pitch_type_classified: null,
+ pitch_call: null,
+ pitch_result: null,
+ velocity: null,
+ spin_rate: null,
+ spin_axis: null,
+ spin_efficiency: null,
+ seam_orientation: null,
+ induced_vertical_break: null,
+ horizontal_break: null,
+ release_height: null,
+ release_side: null,
+ extension: null,
+ plate_height: null,
+ plate_side: null,
+ zone: null,
+ intended_location: null,
+ miss_distance: null,
+ is_swing: null,
+ is_whiff: null,
+ is_chase: null,
+ is_called_strike: null,
+ is_in_zone: null,
+ count_state: null,
+ batter_handedness: null,
+ video_id: null,
+ external_pitch_id: null,
+ import_run_id: null,
+ source_id: null,
+ trust_tier: 'official',
+ visibility: 'staff_only',
+ measured_at: '2026-04-01T00:00:00.000Z',
+ created_at: '2026-04-01T00:00:00.000Z',
+ ...overrides,
+ };
+}
+
+function battedBall(
+ overrides: Partial & { id: string },
+): BaseballBattedBallEvent {
+ return {
+ team_id: TEAM,
+ game_id: null,
+ practice_id: null,
+ plate_appearance_id: null,
+ pitch_event_id: null,
+ batter_id: null,
+ pitcher_id: null,
+ data_context: 'official_game',
+ exit_velocity: null,
+ launch_angle: null,
+ spray_angle: null,
+ distance: null,
+ hang_time: null,
+ batted_ball_type: null,
+ field_zone: null,
+ is_hard_hit: null,
+ is_barrel: null,
+ is_sweet_spot: null,
+ result: null,
+ pitch_type: null,
+ video_id: null,
+ external_event_id: null,
+ import_run_id: null,
+ source_id: null,
+ trust_tier: 'official',
+ visibility: 'staff_only',
+ measured_at: '2026-04-01T00:00:00.000Z',
+ created_at: '2026-04-01T00:00:00.000Z',
+ ...overrides,
+ };
+}
+
+describe('eventDerivedVelocityForPlayer — per-player hitter/pitcher aggregation', () => {
+ it("resolves a hitter's avg exit velocity from their OWN batted-ball events only", () => {
+ const battedBalls = [
+ battedBall({ id: 'bb1', batter_id: 'p1', exit_velocity: 90 }),
+ battedBall({ id: 'bb2', batter_id: 'p1', exit_velocity: 94 }),
+ // A different player's batted ball must not leak into p1's average.
+ battedBall({ id: 'bb3', batter_id: 'p2', exit_velocity: 60 }),
+ ];
+ const out = eventDerivedVelocityForPlayer('p1', [], battedBalls);
+ expect(out.avgExitVelocity).toEqual({ value: 92, sampleSize: 2 });
+ // No max-velocity event metric exists yet (honest gap, matches loaders.ts).
+ expect(out.maxExitVelocity).toBeNull();
+ expect(out.avgPitchVelocity).toBeNull();
+ expect(out.maxPitchVelocity).toBeNull();
+ });
+
+ it("resolves a pitcher's avg pitch velocity from their OWN pitches only", () => {
+ const pitches = [
+ pitchEvent({ id: 'p1a', pitcher_id: 'p9', velocity: 88 }),
+ pitchEvent({ id: 'p1b', pitcher_id: 'p9', velocity: 92 }),
+ // A different pitcher's pitch must not leak into p9's average.
+ pitchEvent({ id: 'p2a', pitcher_id: 'p8', velocity: 70 }),
+ ];
+ const out = eventDerivedVelocityForPlayer('p9', pitches, []);
+ expect(out.avgPitchVelocity).toEqual({ value: 90, sampleSize: 2 });
+ expect(out.maxPitchVelocity).toBeNull();
+ expect(out.avgExitVelocity).toBeNull();
+ });
+
+ it('a two-way player gets BOTH sides independently from their own rows on each side', () => {
+ const pitches = [pitchEvent({ id: 'pt1', pitcher_id: 'p1', velocity: 91 })];
+ const battedBalls = [battedBall({ id: 'bb1', batter_id: 'p1', exit_velocity: 95 })];
+ const out = eventDerivedVelocityForPlayer('p1', pitches, battedBalls);
+ expect(out.avgExitVelocity).toEqual({ value: 95, sampleSize: 1 });
+ expect(out.avgPitchVelocity).toEqual({ value: 91, sampleSize: 1 });
+ });
+
+ it('a player with zero matching rows on either side returns all-null (honest absence)', () => {
+ const out = eventDerivedVelocityForPlayer('ghost', [], []);
+ expect(out).toEqual({
+ avgExitVelocity: null,
+ maxExitVelocity: null,
+ avgPitchVelocity: null,
+ maxPitchVelocity: null,
+ });
+ });
+});
+
+describe('buildEventDerivedByPlayer — team-wide map', () => {
+ it('populates only players with at least one event row; a zero-event player is absent (legacy fallback keeps serving them)', () => {
+ const pitches = [pitchEvent({ id: 'pt1', pitcher_id: 'p2', velocity: 89 })];
+ const battedBalls = [battedBall({ id: 'bb1', batter_id: 'p1', exit_velocity: 93 })];
+
+ const map = buildEventDerivedByPlayer(['p1', 'p2', 'p3'], pitches, battedBalls);
+
+ expect(map.p1?.avgExitVelocity).toEqual({ value: 93, sampleSize: 1 });
+ expect(map.p2?.avgPitchVelocity).toEqual({ value: 89, sampleSize: 1 });
+ // p3 has no event rows at all -- absent from the map entirely.
+ expect(map.p3).toBeUndefined();
+ });
+
+ it('returns an empty map when no player has any event rows', () => {
+ const map = buildEventDerivedByPlayer(['p1', 'p2'], [], []);
+ expect(map).toEqual({});
+ });
+});
+
+// -----------------------------------------------------------------------------
+// loadEngineEventRows — the DB-fetch layer (pagination + #813 supersede filter
+// + all-or-nothing degrade).
+// -----------------------------------------------------------------------------
+
+type Row = Record;
+
+/**
+ * Same minimal chainable fake shape as engine-stat-rows.test.ts's, plus a REAL
+ * (not no-op) `.is()` so the #813 supersede-filter test below is an honest
+ * assertion rather than a smoke test, and a REAL `.in()` so the player-id
+ * scoping test below actually exercises the DB-side filter, not just the
+ * pure aggregation layer.
+ */
+function makeClient(tables: Record, errorTables: Set = new Set()) {
+ return {
+ from(table: string) {
+ let rows = tables[table] ?? [];
+ const fail = errorTables.has(table);
+ const builder: Record = {
+ select: () => builder,
+ eq: () => builder,
+ is: (col: string, val: unknown) => {
+ rows = rows.filter((r) => r[col] === val);
+ return builder;
+ },
+ in: (col: string, vals: unknown[]) => {
+ rows = rows.filter((r) => vals.includes(r[col]));
+ return builder;
+ },
+ order: () => builder,
+ range: () =>
+ Promise.resolve(
+ fail ? { data: null, error: { message: `${table} read failed` } } : { data: rows, error: null },
+ ),
+ };
+ return builder;
+ },
+ };
+}
+
+describe('loadEngineEventRows', () => {
+ it('respects the #813 superseded-row filter (only the current row powers the engine)', async () => {
+ // Plain rows (not the strict BaseballPitchEvent factory) -- the fake
+ // client's tables are untyped Row[], and `superseded_by_run_id` isn't on
+ // the hand-written type (a real DB column the query filters on but the
+ // engine never reads back), so a loose row literal is the honest fixture
+ // shape here.
+ const client = makeClient({
+ baseball_pitch_events: [
+ { id: 'pt-old', team_id: TEAM, pitcher_id: 'p1', velocity: 70, superseded_by_run_id: 'run-1' },
+ { id: 'pt-current', team_id: TEAM, pitcher_id: 'p1', velocity: 92, superseded_by_run_id: null },
+ ],
+ baseball_batted_ball_events: [],
+ });
+
+ const { data, error } = await loadEngineEventRows(client, TEAM);
+ expect(error).toBeNull();
+ expect(data).not.toBeNull();
+ expect(data!.pitches.map((p) => p.id)).toEqual(['pt-current']);
+ });
+
+ it('degrades ALL-OR-NOTHING (data: null) when either table read fails', async () => {
+ const client = makeClient(
+ {
+ baseball_pitch_events: [{ id: 'pt1', team_id: TEAM, superseded_by_run_id: null }],
+ baseball_batted_ball_events: [],
+ },
+ new Set(['baseball_batted_ball_events']),
+ );
+
+ const { data, error } = await loadEngineEventRows(client, TEAM);
+ expect(data).toBeNull();
+ expect(error).not.toBeNull();
+ });
+
+ it('returns an empty pool without querying when no team id is given', async () => {
+ let queried = false;
+ const client = {
+ from() {
+ queried = true;
+ throw new Error('should not query');
+ },
+ };
+ const { data, error } = await loadEngineEventRows(client, '');
+ expect(data).toEqual({ pitches: [], battedBalls: [] });
+ expect(error).toBeNull();
+ expect(queried).toBe(false);
+ });
+});
+
+// -----------------------------------------------------------------------------
+// loadEngineEventRows — player-id scoping (unbounded-read fix).
+//
+// buildActionOutcomeSeed (action-baseline.ts) resolves ONE player's velocity
+// scalar on every coach "convert to action" click; it must never fire a
+// team-wide, unbounded scan of the whole pitch/batted-ball history to do so.
+// These pin that the optional `playerIds` param actually bounds the DB read
+// (not just the pure aggregation downstream), mirroring loadEngineStatRows's
+// own `.in('player_id', playerIds)` scoping.
+// -----------------------------------------------------------------------------
+describe('loadEngineEventRows — player-id scoping (unbounded-read fix)', () => {
+ it('scopes the pitch read to pitcher_id IN playerIds and the batted-ball read to batter_id IN playerIds — other players never enter the pool', async () => {
+ const client = makeClient({
+ baseball_pitch_events: [
+ { id: 'pt-p1', team_id: TEAM, pitcher_id: 'p1', velocity: 90, superseded_by_run_id: null },
+ { id: 'pt-p2', team_id: TEAM, pitcher_id: 'p2', velocity: 70, superseded_by_run_id: null },
+ ],
+ baseball_batted_ball_events: [
+ { id: 'bb-p1', team_id: TEAM, batter_id: 'p1', exit_velocity: 100, superseded_by_run_id: null },
+ { id: 'bb-p2', team_id: TEAM, batter_id: 'p2', exit_velocity: 60, superseded_by_run_id: null },
+ ],
+ });
+
+ const { data, error } = await loadEngineEventRows(client, TEAM, ['p1']);
+ expect(error).toBeNull();
+ expect(data!.pitches.map((p) => p.id)).toEqual(['pt-p1']);
+ expect(data!.battedBalls.map((b) => b.id)).toEqual(['bb-p1']);
+ });
+
+ it('returns an empty pool WITHOUT querying when playerIds is an explicit empty array', async () => {
+ let queried = false;
+ const client = {
+ from() {
+ queried = true;
+ throw new Error('should not query');
+ },
+ };
+ const { data, error } = await loadEngineEventRows(client, TEAM, []);
+ expect(data).toEqual({ pitches: [], battedBalls: [] });
+ expect(error).toBeNull();
+ expect(queried).toBe(false);
+ });
+
+ it('omitting playerIds keeps the team-wide read (explicit opt-in only — no behavior change for a caller that truly needs every player)', async () => {
+ const client = makeClient({
+ baseball_pitch_events: [
+ { id: 'pt-p1', team_id: TEAM, pitcher_id: 'p1', velocity: 90, superseded_by_run_id: null },
+ { id: 'pt-p2', team_id: TEAM, pitcher_id: 'p2', velocity: 70, superseded_by_run_id: null },
+ ],
+ baseball_batted_ball_events: [],
+ });
+ const { data } = await loadEngineEventRows(client, TEAM);
+ expect(data!.pitches.map((p) => p.id).sort()).toEqual(['pt-p1', 'pt-p2']);
+ });
+});
diff --git a/src/lib/baseball/__tests__/engine-run-event-velocity.test.ts b/src/lib/baseball/__tests__/engine-run-event-velocity.test.ts
new file mode 100644
index 000000000..3f612c9eb
--- /dev/null
+++ b/src/lib/baseball/__tests__/engine-run-event-velocity.test.ts
@@ -0,0 +1,164 @@
+// =============================================================================
+// #852 residual: runBaseballEngineCore must thread event-derived velocity into
+// loadAllPlayerMetrics so a box-score-migrated player isn't left with NO
+// velocity metric (the elite event layer wins over a legacy scalar per #379
+// design rule 4; a zero-event player keeps their legacy scalar unchanged).
+// =============================================================================
+
+import { describe, it, expect, vi } from 'vitest';
+import { createFakeSupabase, type FakeSupabase } from '@/test/fixtures/fake-supabase';
+import { DEFAULT_AI_POLICY } from '@/lib/baseball/ai-policy';
+import type { BaseballInsightCandidate } from '@/lib/coachhelm/baseball/generators';
+import type { BaseballV10EngineInputs } from '@/lib/coachhelm/baseball/engine';
+
+const NOW = '2026-06-30T12:00:00.000Z';
+const TEAM_ID = 'team-1';
+const ORG_ID = 'org-1';
+const MIXED_PLAYER = 'player-mixed'; // has legacy exit_velocity AND event batted-balls
+const LEGACY_ONLY_PLAYER = 'player-legacy-only'; // legacy exit_velocity, zero events
+
+let capturedInputs: BaseballV10EngineInputs | null = null;
+
+vi.mock('@/lib/coachhelm/baseball/engine', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ generateAllBaseballCandidates: vi.fn((inputs: BaseballV10EngineInputs) => {
+ capturedInputs = inputs;
+ return [] as BaseballInsightCandidate[];
+ }),
+ };
+});
+
+import { runBaseballEngineCore, type EngineRunClient } from '@/lib/baseball/coachhelm/engine-run';
+
+function baseTables() {
+ return {
+ baseball_team_members: [
+ { team_id: TEAM_ID, player_id: MIXED_PLAYER },
+ { team_id: TEAM_ID, player_id: LEGACY_ONLY_PLAYER },
+ ],
+ // Legacy box-score rows: BOTH players have a legacy exit_velocity scalar.
+ baseball_player_stats: [
+ {
+ id: 'lg-mixed-1', team_id: TEAM_ID, player_id: MIXED_PLAYER, stat_type: 'game',
+ session_date: '2026-04-01', at_bats: 4, hits: 1, walks: 0, strikeouts: 1,
+ exit_velocity: 80, // legacy scalar the event source should OUTRANK
+ },
+ {
+ id: 'lg-legacy-1', team_id: TEAM_ID, player_id: LEGACY_ONLY_PLAYER, stat_type: 'game',
+ session_date: '2026-04-01', at_bats: 4, hits: 1, walks: 0, strikeouts: 1,
+ exit_velocity: 85, // the ONLY source for this player -- must survive
+ },
+ ],
+ baseball_events: [],
+ baseball_teams: [{ id: TEAM_ID, organization_id: ORG_ID }],
+ helm_lifting_athletes: [] as Array>,
+ helm_lifting_readiness_checkins: [] as Array>,
+ baseball_lift_sessions: [],
+ baseball_lift_set_results: [],
+ baseball_import_runs: [],
+ // Event layer: ONLY the mixed player has batted-ball events.
+ baseball_pitch_events: [] as Array>,
+ baseball_batted_ball_events: [
+ {
+ id: 'bb-1', team_id: TEAM_ID, batter_id: MIXED_PLAYER, pitcher_id: null,
+ data_context: 'official_game', exit_velocity: 100, superseded_by_run_id: null,
+ measured_at: '2026-04-05T00:00:00.000Z', trust_tier: 'official', visibility: 'staff_only',
+ },
+ {
+ id: 'bb-2', team_id: TEAM_ID, batter_id: MIXED_PLAYER, pitcher_id: null,
+ data_context: 'official_game', exit_velocity: 104, superseded_by_run_id: null,
+ measured_at: '2026-04-06T00:00:00.000Z', trust_tier: 'official', visibility: 'staff_only',
+ },
+ ],
+ baseball_catching_events: [],
+ baseball_fielding_events: [],
+ baseball_baserunning_events: [],
+ baseball_video_events: [],
+ baseball_coach_insights: [] as Array>,
+ baseball_signals: [] as Array>,
+ baseball_ai_audit: [] as Array>,
+ };
+}
+
+async function runEngine(fake: FakeSupabase) {
+ return runBaseballEngineCore(fake as unknown as EngineRunClient, {
+ teamId: TEAM_ID,
+ coachId: 'coach-1',
+ createdByUserId: 'user-1',
+ policy: DEFAULT_AI_POLICY,
+ nowIso: NOW,
+ });
+}
+
+describe('runBaseballEngineCore — #852 event-derived velocity wiring', () => {
+ it('event-derived avg exit velocity WINS over the legacy scalar for a box-score-migrated player', async () => {
+ capturedInputs = null;
+ const fake = createFakeSupabase({ user: { id: 'user-1' }, tables: baseTables() });
+
+ const result = await runEngine(fake);
+ expect(result.success).toBe(true);
+ expect(capturedInputs).not.toBeNull();
+
+ const mixed = capturedInputs!.players.find((p) => p.playerId === MIXED_PLAYER);
+ expect(mixed).toBeDefined();
+ // (100 + 104) / 2 = 102, NOT the legacy scalar of 80.
+ expect(mixed!.metrics.avg_exit_velocity?.value).toBe(102);
+ expect(mixed!.metrics.avg_exit_velocity?.source_refs[0]?.table).toBe('baseball_batted_ball_events');
+ });
+
+ it('a zero-event player keeps their legacy exit-velocity scalar unchanged (honest fallback)', async () => {
+ capturedInputs = null;
+ const fake = createFakeSupabase({ user: { id: 'user-1' }, tables: baseTables() });
+
+ const result = await runEngine(fake);
+ expect(result.success).toBe(true);
+ expect(capturedInputs).not.toBeNull();
+
+ const legacyOnly = capturedInputs!.players.find((p) => p.playerId === LEGACY_ONLY_PLAYER);
+ expect(legacyOnly).toBeDefined();
+ expect(legacyOnly!.metrics.avg_exit_velocity?.value).toBe(85);
+ expect(legacyOnly!.metrics.avg_exit_velocity?.source_refs[0]?.table).toBe('baseball_player_stats');
+ });
+
+ it('degrades ALL-OR-NOTHING to legacy scalars for every player when the event read fails', async () => {
+ capturedInputs = null;
+ const tables = baseTables();
+ const fake = createFakeSupabase({ user: { id: 'user-1' }, tables });
+ // Force the batted-ball read to error by deleting the table key entirely
+ // is not enough (the fixture defaults missing tables to [] with no error),
+ // so we monkey-patch the fake's `from` to inject an error for this one
+ // table -- the smallest surface that exercises the degrade path without
+ // hand-rolling a whole second fake client. `baseball_batted_ball_events`
+ // is ALSO read by the pre-existing "deepened event catalog" fetch further
+ // down runBaseballEngineCore (a `.gte('measured_at', ...)`-shaped query,
+ // no `.is()`), so the stub must satisfy BOTH call shapes. `.in()` is the
+ // player-id scope loadEngineEventRows now applies (velocity-read
+ // unbounded-scan fix) -- must be stubbed too.
+ const realFrom = fake.from.bind(fake);
+ const erroringBuilder: Record = {
+ select: () => erroringBuilder,
+ eq: () => erroringBuilder,
+ in: () => erroringBuilder,
+ is: () => erroringBuilder,
+ gte: () => erroringBuilder,
+ order: () => erroringBuilder,
+ range: () => Promise.resolve({ data: null, error: { message: 'boom' } }),
+ };
+ (fake as unknown as { from: typeof fake.from }).from = (table: string) => {
+ if (table === 'baseball_batted_ball_events') return erroringBuilder as never;
+ return realFrom(table);
+ };
+
+ const result = await runEngine(fake);
+ expect(result.success).toBe(true);
+ expect(capturedInputs).not.toBeNull();
+
+ // The mixed player, who WOULD have event data, falls all the way back to
+ // their legacy scalar -- never a partial/blended result.
+ const mixed = capturedInputs!.players.find((p) => p.playerId === MIXED_PLAYER);
+ expect(mixed!.metrics.avg_exit_velocity?.value).toBe(80);
+ expect(mixed!.metrics.avg_exit_velocity?.source_refs[0]?.table).toBe('baseball_player_stats');
+ });
+});
diff --git a/src/lib/baseball/__tests__/outcome-sweep-event-velocity.test.ts b/src/lib/baseball/__tests__/outcome-sweep-event-velocity.test.ts
new file mode 100644
index 000000000..97ee9ebc3
--- /dev/null
+++ b/src/lib/baseball/__tests__/outcome-sweep-event-velocity.test.ts
@@ -0,0 +1,153 @@
+// =============================================================================
+// #852 residual: sweepActionOutcomes must thread event-derived velocity into
+// its per-action `loadPlayerMetrics` call, scoped to the SAME after-window
+// honesty rule as the box-score read (measured strictly after the action's
+// created_at) -- a pre-action batted-ball event must never count toward
+// "did it move" measurement.
+// =============================================================================
+
+import { describe, it, expect } from 'vitest';
+import { sweepActionOutcomes } from '@/lib/baseball/coachhelm/outcome-sweep';
+
+const TEAM = 'team-1';
+
+interface UpdateCall {
+ table: string;
+ payload: Record;
+}
+
+/**
+ * Minimal chainable Supabase fake with REAL filtering (eq/in/is mutate the
+ * in-scope row set) so the after-window date filter inside sweepActionOutcomes
+ * is exercised honestly, not just smoke-tested.
+ */
+function makeClient(opts: {
+ actions: Array>;
+ stats: Array>;
+ battedBallEvents?: Array>;
+ pitchEvents?: Array>;
+ updates: UpdateCall[];
+}) {
+ function from(table: string) {
+ let rows: Array> =
+ table === 'baseball_actions'
+ ? opts.actions
+ : table === 'baseball_player_stats'
+ ? opts.stats
+ : table === 'baseball_batted_ball_events'
+ ? (opts.battedBallEvents ?? [])
+ : table === 'baseball_pitch_events'
+ ? (opts.pitchEvents ?? [])
+ : [];
+ const state: { isUpdate: boolean; payload: Record | null } = {
+ isUpdate: false,
+ payload: null,
+ };
+ const builder: Record = {
+ select: () => builder,
+ eq: (col: string, val: unknown) => {
+ rows = rows.filter((r) => r[col] === val);
+ return builder;
+ },
+ in: (col: string, vals: unknown[]) => {
+ rows = rows.filter((r) => vals.includes(r[col]));
+ return builder;
+ },
+ is: (col: string, val: unknown) => {
+ rows = rows.filter((r) => r[col] === val);
+ return builder;
+ },
+ order: () => builder,
+ limit: () => builder,
+ range: () => builder,
+ update(payload: Record) {
+ state.isUpdate = true;
+ state.payload = payload;
+ return builder;
+ },
+ then(resolve: (v: { data: unknown; error: null }) => unknown) {
+ if (state.isUpdate && state.payload) {
+ opts.updates.push({ table, payload: state.payload });
+ return resolve({ data: null, error: null });
+ }
+ return resolve({ data: rows, error: null });
+ },
+ };
+ return builder;
+ }
+ return { from } as unknown as Parameters[0];
+}
+
+describe('sweepActionOutcomes — #852 event-derived velocity wiring', () => {
+ it('measures avg_exit_velocity from event-derived data, WINNING over the after-window legacy scalar for the same player', async () => {
+ const actions = [
+ {
+ id: 'act-1',
+ team_id: TEAM,
+ player_id: 'p1',
+ created_at: '2026-01-01T00:00:00.000Z',
+ outcome_metric: 'avg_exit_velocity',
+ outcome_baseline_value: 80,
+ outcome_observed_value: null,
+ signal_id: null,
+ status: 'open',
+ },
+ ];
+ // Legacy after-window box-score row would give avg_exit_velocity = 80.
+ const stats = [
+ {
+ id: 's1', team_id: TEAM, player_id: 'p1', stat_type: 'game', session_date: '2026-03-01',
+ at_bats: 4, hits: 1, walks: 0, strikeouts: 1, exit_velocity: 80,
+ },
+ ];
+ const battedBallEvents = [
+ { id: 'bb1', team_id: TEAM, batter_id: 'p1', exit_velocity: 100, measured_at: '2026-03-01T00:00:00.000Z', superseded_by_run_id: null },
+ { id: 'bb2', team_id: TEAM, batter_id: 'p1', exit_velocity: 104, measured_at: '2026-03-05T00:00:00.000Z', superseded_by_run_id: null },
+ // BEFORE the action's created_at -- must be EXCLUDED by the after-window
+ // filter. If wrongly included, the average would shift far from 102.
+ { id: 'bb-before', team_id: TEAM, batter_id: 'p1', exit_velocity: 20, measured_at: '2025-12-01T00:00:00.000Z', superseded_by_run_id: null },
+ ];
+ const updates: UpdateCall[] = [];
+ const client = makeClient({ actions, stats, battedBallEvents, updates });
+
+ const res = await sweepActionOutcomes(client, TEAM);
+ expect(res.measured).toBe(1);
+
+ const actionUpdate = updates.find((u) => u.table === 'baseball_actions');
+ // (100 + 104) / 2 = 102 -- the event-derived value, NOT the legacy 80, and
+ // NOT skewed by the pre-action bb-before row.
+ expect(actionUpdate?.payload.outcome_observed_value).toBe(102);
+ expect(actionUpdate?.payload.outcome_sample_n).toBe(2);
+ });
+
+ it('falls back to the legacy exit-velocity scalar for a player with zero event rows', async () => {
+ const actions = [
+ {
+ id: 'act-2',
+ team_id: TEAM,
+ player_id: 'p2',
+ created_at: '2026-01-01T00:00:00.000Z',
+ outcome_metric: 'avg_exit_velocity',
+ outcome_baseline_value: 70,
+ outcome_observed_value: null,
+ signal_id: null,
+ status: 'open',
+ },
+ ];
+ const stats = [
+ {
+ id: 's2', team_id: TEAM, player_id: 'p2', stat_type: 'game', session_date: '2026-03-01',
+ at_bats: 4, hits: 1, walks: 0, strikeouts: 1, exit_velocity: 90,
+ },
+ ];
+ const updates: UpdateCall[] = [];
+ // No batted-ball events at all for p2 -- honest legacy fallback.
+ const client = makeClient({ actions, stats, battedBallEvents: [], updates });
+
+ const res = await sweepActionOutcomes(client, TEAM);
+ expect(res.measured).toBe(1);
+
+ const actionUpdate = updates.find((u) => u.table === 'baseball_actions');
+ expect(actionUpdate?.payload.outcome_observed_value).toBe(90);
+ });
+});
diff --git a/src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts b/src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts
index ae4bb152a..ddc44eef3 100644
--- a/src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts
+++ b/src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts
@@ -58,6 +58,12 @@ function makeClient(opts: {
select: () => builder,
eq: () => builder,
in: () => builder,
+ // #852 residual: the outcome sweep now also reads
+ // baseball_pitch_events/baseball_batted_ball_events (event-derived
+ // velocity) via the #813 superseded-row filter. Neither table is seeded
+ // by these fixtures (falls to `[]` in the `data` lookup above), so this
+ // is a pass-through -- it only needs to exist so the call doesn't throw.
+ is: () => builder,
order: () => builder,
limit: () => builder,
// The stats read now paginates via fetchAllRowsResult (ends on .range).
diff --git a/src/lib/baseball/coachhelm/action-baseline.ts b/src/lib/baseball/coachhelm/action-baseline.ts
index ab87c3e0f..f6ce08fb7 100644
--- a/src/lib/baseball/coachhelm/action-baseline.ts
+++ b/src/lib/baseball/coachhelm/action-baseline.ts
@@ -58,6 +58,10 @@ import {
type BaseballMetricId,
} from '@/lib/coachhelm/baseball/metrics/registry';
import { loadEngineStatRows } from '@/lib/baseball/coachhelm/engine-stat-rows';
+import {
+ loadEngineEventRows,
+ eventDerivedVelocityForPlayer,
+} from '@/lib/baseball/coachhelm/engine-event-derived';
import { parseSignalSourceRefs } from '@/lib/types/baseball-signals';
import type { BaseballActionOutcomeVerdict } from '@/lib/types/baseball-coachhelm-v10';
@@ -213,7 +217,22 @@ export async function buildActionOutcomeSeed(
// single-page `.limit(1000)` read.
const { data: statRows } = await loadEngineStatRows(supabase, teamId, [playerId]);
- const loaded = loadPlayerMetrics(playerId, statRows ?? []);
+ // #852 residual: event-derived velocity over the SAME full-history pool the
+ // box-score baseline above reads (no after-window here -- a baseline is
+ // "the player's current value at conversion time", matching the box-score
+ // read's own no-date-filter semantics). ALL-OR-NOTHING: an event-read
+ // failure (eventRows null) falls back to the legacy scalar for every
+ // velocity field, never a partial blend (mirrors loadEngineStatRows).
+ // Scoped to THIS ONE player -- a single "convert to action" click must
+ // never fire a team-wide, unbounded scan of the whole pitch/batted-ball
+ // history just to resolve one player's velocity scalar (mirrors the
+ // box-score read's own `[playerId]` scope immediately above).
+ const { data: eventRows } = await loadEngineEventRows(supabase, teamId, [playerId]);
+ const eventDerived = eventRows
+ ? eventDerivedVelocityForPlayer(playerId, eventRows.pitches, eventRows.battedBalls)
+ : null;
+
+ const loaded = loadPlayerMetrics(playerId, statRows ?? [], undefined, eventDerived);
const baselineValue = loaded.metrics[targetMetric]?.value ?? null;
return {
diff --git a/src/lib/baseball/coachhelm/engine-event-derived.ts b/src/lib/baseball/coachhelm/engine-event-derived.ts
new file mode 100644
index 000000000..074989694
--- /dev/null
+++ b/src/lib/baseball/coachhelm/engine-event-derived.ts
@@ -0,0 +1,237 @@
+import 'server-only';
+
+// =============================================================================
+// src/lib/baseball/coachhelm/engine-event-derived.ts
+//
+// #852 residual — closes the velocity coverage gap engine-stat-rows.ts (#379
+// Phase 4b) opened: a box-score-migrated player's legacy GAME rows (the only
+// place `exit_velocity` / `pitch_velocity` scalars ever lived) are dropped for
+// any date the canonical box-score layer now covers, and the canonical
+// box-score tables carry no velocity columns at all. Per #379 design rule 4,
+// the canonical velocity source is the elite EVENT layer, never a legacy
+// scalar — loaders.ts's `eventDerived` hook (#851) already threads that
+// per-field override into `loadPlayerMetrics` / `loadAllPlayerMetrics`, but
+// nothing called it, so every migrated player's velocity metrics silently
+// went dark. This module is that missing wire: it reads the event-grain
+// tables and reduces them to the `EventDerivedVelocityInput` the loaders hook
+// expects, reusing `elite-stat-events.ts`'s REAL aggregators (buildHitterMetrics
+// / buildPitcherMetrics) so the honesty-gated average here is byte-identical
+// to what the Stats Center already shows a coach -- never a second, drifting
+// "average exit velocity" implementation.
+//
+// TWO LAYERS, mirroring loaders.ts's own split:
+// 1. loadEngineEventRows (impure) -- team-scoped read of
+// baseball_pitch_events / baseball_batted_ball_events, paginated past the
+// PostgREST 1000-row cap (fetchAllRowsResult) with the #813 superseded-row
+// filter (`superseded_by_run_id IS NULL` -- only the CURRENT value powers
+// the engine, matching engine-run.ts's existing deepened-catalog read and
+// elite-stat-events.ts's own getEliteStatEvents). Also bounded by an
+// OPTIONAL `playerIds` scope (`.in('pitcher_id'|'batter_id', playerIds)`)
+// -- a single "convert to action" click only ever needs ONE player's
+// rows, so action-baseline.ts passes `[playerId]` rather than forcing a
+// team-wide scan; engine-run/outcome-sweep pass their own
+// roster/todo-derived id lists. No date window here -- callers apply
+// their OWN honesty window (e.g. outcome-sweep's after-window) by
+// filtering the returned rows before aggregating, the same way
+// loadEngineStatRows returns the full box-score history (for its own
+// player scope) and lets each caller decide how much of it to use.
+// 2. buildEventDerivedByPlayer / eventDerivedVelocityForPlayer (pure) --
+// groups rows by player and calls buildHitterMetrics / buildPitcherMetrics
+// + eventDerivedVelocityFromMetrics to produce the per-player velocity
+// input. Fully unit-testable without a DB (fixed-clock friendly -- none
+// of this depends on the wall clock).
+//
+// ALL-OR-NOTHING (mirrors loadEngineStatRows's own honesty rule): if EITHER
+// event table fails to read, `loadEngineEventRows` returns `data: null` and a
+// caller must treat that as "no event-derived data for anyone this run" --
+// never apply it to some players and not others depending on which table
+// happened to fail. Falling back to `{}` (an empty eventDerivedByPlayer map)
+// degrades every player to their legacy scalar for every velocity field,
+// exactly the pre-#852-fix behavior -- never a partial event/legacy blend.
+// =============================================================================
+
+import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows';
+import {
+ buildHitterMetrics,
+ buildPitcherMetrics,
+} from '@/lib/baseball/read-models/elite-stat-events';
+import {
+ eventDerivedVelocityFromMetrics,
+ type EventDerivedVelocityInput,
+} from '@/lib/coachhelm/baseball/loaders';
+import type {
+ BaseballPitchEvent,
+ BaseballBattedBallEvent,
+ BaseballDataContext,
+} from '@/lib/types/baseball-stat-events';
+
+// A minimally-typed client so this runs against the RLS server client or the
+// service-role admin client (both expose `.from`) -- the same loose-client
+// pattern as loadEngineStatRows / the three engine callers.
+export type EngineEventRowsClient = {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ from: (table: string) => any;
+};
+
+// The event layer doesn't carry a "this is the competitive record" default the
+// way elite-stat-events.ts's team/player Stats Center reads do; the engine
+// wants every context's velocity signal (a bullpen session's pitch velocity is
+// still an honest measurement), so this is only ever used as the metric
+// factory's provenance fallback when a row's own `data_context` is missing.
+const FALLBACK_CONTEXT: BaseballDataContext = 'official_game';
+
+export interface EngineEventRows {
+ pitches: BaseballPitchEvent[];
+ battedBalls: BaseballBattedBallEvent[];
+}
+
+/**
+ * Load the team's pitch + batted-ball event rows for velocity aggregation.
+ *
+ * Paginated past the PostgREST 1000-row cap (fetchAllRowsResult) with a stable
+ * `id` order, and scoped to the #813 CURRENT rows only
+ * (`superseded_by_run_id IS NULL`) -- a corrected import must never let a
+ * stale, superseded pitch/batted-ball row into the engine's velocity average.
+ *
+ * `playerIds`, when passed, bounds the read to exactly the players the caller
+ * needs -- mirrors loadEngineStatRows's own `.in('player_id', playerIds)`
+ * scoping (this read's box-score sibling). `eventDerivedVelocityForPlayer` /
+ * `buildEventDerivedByPlayer` only ever read a pitch row via its
+ * `pitcher_id` and a batted-ball row via its `batter_id`, so that is exactly
+ * what each table is filtered on -- a single coach "convert to action" click
+ * (ONE player) must never fire a team-wide, unbounded scan of the entire
+ * pitch/batted-ball history just to resolve that one player's velocity
+ * scalar. Omit `playerIds` (or pass `undefined`) for a genuinely team-wide
+ * read (engine-run / outcome-sweep already compute their own roster/todo
+ * player-id list and now pass it through here too).
+ *
+ * ALL-OR-NOTHING: a failure on EITHER table returns `data: null` so a caller
+ * degrades every player to their legacy scalar this run, never a partial
+ * blend (see module docblock).
+ */
+export async function loadEngineEventRows(
+ db: EngineEventRowsClient,
+ teamId: string,
+ playerIds?: string[],
+): Promise<{ data: EngineEventRows | null; error: { message: string; code?: string | null } | null }> {
+ if (!teamId) return { data: { pitches: [], battedBalls: [] }, error: null };
+ // An explicitly empty scope list means "no players to resolve" -- honestly
+ // return nothing rather than querying (mirrors loadEngineStatRows's own
+ // `playerIds.length === 0` short-circuit).
+ if (playerIds && playerIds.length === 0) {
+ return { data: { pitches: [], battedBalls: [] }, error: null };
+ }
+
+ const [pitchRes, bbRes] = await Promise.all([
+ fetchAllRowsResult((from, to) => {
+ let q = db
+ .from('baseball_pitch_events')
+ .select('*')
+ .eq('team_id', teamId)
+ .is('superseded_by_run_id', null);
+ if (playerIds) q = q.in('pitcher_id', playerIds);
+ return q.order('id', { ascending: true }).range(from, to);
+ }),
+ fetchAllRowsResult((from, to) => {
+ let q = db
+ .from('baseball_batted_ball_events')
+ .select('*')
+ .eq('team_id', teamId)
+ .is('superseded_by_run_id', null);
+ if (playerIds) q = q.in('batter_id', playerIds);
+ return q.order('id', { ascending: true }).range(from, to);
+ }),
+ ]);
+
+ if (pitchRes.error || bbRes.error) {
+ return { data: null, error: pitchRes.error ?? bbRes.error };
+ }
+
+ return {
+ data: {
+ pitches: (pitchRes.data ?? []) as BaseballPitchEvent[],
+ battedBalls: (bbRes.data ?? []) as BaseballBattedBallEvent[],
+ },
+ error: null,
+ };
+}
+
+const EMPTY_VELOCITY: EventDerivedVelocityInput = {
+ avgExitVelocity: null,
+ maxExitVelocity: null,
+ avgPitchVelocity: null,
+ maxPitchVelocity: null,
+};
+
+/**
+ * Pure aggregation: given ALREADY-SCOPED pitch/batted-ball rows (the caller
+ * decides the window -- full history, or an after-window subset), build the
+ * EventDerivedVelocityInput for ONE player.
+ *
+ * Reuses elite-stat-events.ts's real aggregators:
+ * - a player's batted balls AS A BATTER -> buildHitterMetrics's
+ * 'avg_exit_velocity' metric (pitches array is irrelevant to that metric,
+ * so an empty array is passed -- we only read this one metricKey out).
+ * - a player's pitches AS A PITCHER -> buildPitcherMetrics's 'avg_velocity'
+ * metric (battedBalls array is likewise irrelevant to that metric).
+ * The two metric arrays are concatenated and handed to
+ * `eventDerivedVelocityFromMetrics`, which independently resolves each of the
+ * four velocity fields (max_* stay null -- there is no max-velocity event
+ * metric yet, matching loaders.ts's own honest gap).
+ */
+export function eventDerivedVelocityForPlayer(
+ playerId: string,
+ pitches: BaseballPitchEvent[],
+ battedBalls: BaseballBattedBallEvent[],
+): EventDerivedVelocityInput {
+ const battedBallsAsBatter = battedBalls.filter((b) => b.batter_id === playerId);
+ const pitchesAsPitcher = pitches.filter((p) => p.pitcher_id === playerId);
+ if (battedBallsAsBatter.length === 0 && pitchesAsPitcher.length === 0) {
+ return EMPTY_VELOCITY;
+ }
+ const hitter = buildHitterMetrics(playerId, [], battedBallsAsBatter, FALLBACK_CONTEXT);
+ const pitcher = buildPitcherMetrics(playerId, pitchesAsPitcher, [], FALLBACK_CONTEXT);
+ return eventDerivedVelocityFromMetrics([...hitter.metrics, ...pitcher.metrics]);
+}
+
+/**
+ * Build the `eventDerivedByPlayer` map `loadAllPlayerMetrics` consumes, for
+ * every id in `playerIds` that has at least one pitch/batted-ball row in the
+ * (already-scoped) pool. A player with zero event rows is simply absent from
+ * the map -- `loadPlayerMetrics` falls back to their legacy scalar for every
+ * velocity field, unchanged (honest absence, never a fabricated zero).
+ */
+export function buildEventDerivedByPlayer(
+ playerIds: string[],
+ pitches: BaseballPitchEvent[],
+ battedBalls: BaseballBattedBallEvent[],
+): Record {
+ const battedByBatter = new Map();
+ for (const bb of battedBalls) {
+ if (!bb.batter_id) continue;
+ const list = battedByBatter.get(bb.batter_id);
+ if (list) list.push(bb);
+ else battedByBatter.set(bb.batter_id, [bb]);
+ }
+ const pitchesByPitcher = new Map();
+ for (const p of pitches) {
+ if (!p.pitcher_id) continue;
+ const list = pitchesByPitcher.get(p.pitcher_id);
+ if (list) list.push(p);
+ else pitchesByPitcher.set(p.pitcher_id, [p]);
+ }
+
+ const out: Record = {};
+ for (const pid of playerIds) {
+ const bbRows = battedByBatter.get(pid) ?? [];
+ const pRows = pitchesByPitcher.get(pid) ?? [];
+ if (bbRows.length === 0 && pRows.length === 0) continue;
+ const hitter = buildHitterMetrics(pid, [], bbRows, FALLBACK_CONTEXT);
+ const pitcher = buildPitcherMetrics(pid, pRows, [], FALLBACK_CONTEXT);
+ const v = eventDerivedVelocityFromMetrics([...hitter.metrics, ...pitcher.metrics]);
+ if (v.avgExitVelocity || v.avgPitchVelocity || v.maxExitVelocity || v.maxPitchVelocity) {
+ out[pid] = v;
+ }
+ }
+ return out;
+}
diff --git a/src/lib/baseball/coachhelm/engine-run.ts b/src/lib/baseball/coachhelm/engine-run.ts
index e45e25a55..0c26ef1af 100644
--- a/src/lib/baseball/coachhelm/engine-run.ts
+++ b/src/lib/baseball/coachhelm/engine-run.ts
@@ -61,9 +61,14 @@ import type { VideoCoverageInput } from '@/lib/coachhelm/baseball/generators/v10
import {
loadAllPlayerMetrics,
type BoxScoreRow,
+ type EventDerivedVelocityInput,
type ScheduleEventRow,
} from '@/lib/coachhelm/baseball/loaders';
import { loadEngineStatRows } from '@/lib/baseball/coachhelm/engine-stat-rows';
+import {
+ loadEngineEventRows,
+ buildEventDerivedByPlayer,
+} from '@/lib/baseball/coachhelm/engine-event-derived';
import {
mergeV10PlayerMetrics,
type ReadinessRow,
@@ -313,6 +318,21 @@ export async function runBaseballEngineCore(
const { data: statRows, error: statsErr } = await loadEngineStatRows(db, teamId, playerIds);
if (statsErr) return emptyResult({ error: 'Could not load box-score stats.' });
+ // #852 residual: event-derived avg/max exit + pitch velocity, per player.
+ // A box-score-migrated player's legacy exit_velocity/pitch_velocity scalar
+ // is dropped alongside their superseded legacy GAME rows (loadEngineStatRows
+ // rule 1) and the canonical box-score tables carry no velocity columns at
+ // all -- without this, those players had NO velocity metric whatsoever. Per
+ // #379 design rule 4, the elite event layer (never a legacy scalar) is the
+ // canonical velocity source. ALL-OR-NOTHING: an event-read failure leaves
+ // eventDerivedByPlayer EMPTY, so every player degrades to their legacy
+ // scalar this run -- never a partial event/legacy blend.
+ const { data: engineEventRows, error: eventRowsErr } = await loadEngineEventRows(db, teamId, playerIds);
+ const eventDerivedByPlayer: Record =
+ !eventRowsErr && engineEventRows
+ ? buildEventDerivedByPlayer(playerIds, engineEventRows.pitches, engineEventRows.battedBalls)
+ : {};
+
const horizonIso = new Date(Date.parse(nowIso) + EVENT_LOOKAHEAD_DAYS * 86400_000).toISOString();
const { data: eventRows } = await db
.from('baseball_events')
@@ -544,6 +564,7 @@ export async function runBaseballEngineCore(
playerIds,
(statRows ?? []) as BoxScoreRow[],
nowIso,
+ eventDerivedByPlayer,
);
const players = boxScorePlayers.map((p) =>
mergeEventPlayerMetrics(
diff --git a/src/lib/baseball/coachhelm/outcome-sweep.ts b/src/lib/baseball/coachhelm/outcome-sweep.ts
index 884094bde..676295c59 100644
--- a/src/lib/baseball/coachhelm/outcome-sweep.ts
+++ b/src/lib/baseball/coachhelm/outcome-sweep.ts
@@ -43,6 +43,11 @@ import {
type BaseballMetricId,
} from '@/lib/coachhelm/baseball/metrics/registry';
import { loadEngineStatRows } from '@/lib/baseball/coachhelm/engine-stat-rows';
+import {
+ loadEngineEventRows,
+ eventDerivedVelocityForPlayer,
+ type EngineEventRows,
+} from '@/lib/baseball/coachhelm/engine-event-derived';
import type { BaseballActionOutcomeVerdict } from '@/lib/types/baseball-coachhelm-v10';
// A minimally-typed client so the sweep runs against either the RLS server
@@ -162,6 +167,18 @@ export async function sweepActionOutcomes(
else byPlayer.set(r.player_id, [r]);
}
+ // #852 residual: event-derived velocity, scoped to the SAME after-window
+ // honesty rule as the box-score pool below (measured strictly AFTER the
+ // action's created_at). ALL-OR-NOTHING: an event-read failure leaves
+ // eventRows null, so every action's `eventDerivedForPlayer` below resolves
+ // to no event data (legacy scalar fallback for every player this pass) --
+ // never a partial event/legacy blend (mirrors loadEngineStatRows's own rule).
+ const { data: eventRows }: { data: EngineEventRows | null } = await loadEngineEventRows(
+ supabase,
+ teamId,
+ playerIds,
+ );
+
const nowIso = new Date().toISOString();
let measured = 0;
// Signal ids whose linked action's target metric IMPROVED this pass — used to
@@ -185,7 +202,25 @@ export async function sweepActionOutcomes(
// still gates on the resulting sample (honest, just less precise).
playerRows;
- const loaded = loadPlayerMetrics(a.player_id!, afterRows);
+ // Event rows get the SAME after-window filter (measured_at strictly after
+ // created_at) so an event-derived velocity metric is apples-to-apples with
+ // the box-score after-window above -- a pre-action pitch/batted-ball must
+ // never count toward "did it move" measurement.
+ const afterPitches = eventRows
+ ? createdAt
+ ? eventRows.pitches.filter((p) => !!p.measured_at && p.measured_at > createdAt)
+ : eventRows.pitches
+ : [];
+ const afterBattedBalls = eventRows
+ ? createdAt
+ ? eventRows.battedBalls.filter((b) => !!b.measured_at && b.measured_at > createdAt)
+ : eventRows.battedBalls
+ : [];
+ const eventDerivedForPlayer = eventRows
+ ? eventDerivedVelocityForPlayer(a.player_id!, afterPitches, afterBattedBalls)
+ : null;
+
+ const loaded = loadPlayerMetrics(a.player_id!, afterRows, nowIso, eventDerivedForPlayer);
const lm = loaded.metrics[metric];
const observed = lm?.value ?? null;
const afterSampleN = lm?.sample_n ?? 0;
diff --git a/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts b/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts
index 9c9d178ee..09db9f330 100644
--- a/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts
+++ b/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts
@@ -204,6 +204,46 @@ describe('buildHitterMetrics — batted-ball quality', () => {
});
});
+describe('buildHitterMetrics — avg_exit_velocity / avg_launch_angle sampleSize honesty', () => {
+ it('a player with 10 batted balls but only 4 exit-velo readings reports sampleSize 4, NOT 10 (a hand-charted at-bat with no radar gun must never inflate the gate)', () => {
+ const battedBalls = [
+ ...Array.from({ length: 4 }, () => bbe({ exit_velocity: 95 })),
+ // 6 hand-charted batted balls with no radar reading at all.
+ ...Array.from({ length: 6 }, () => bbe({ exit_velocity: null })),
+ ];
+ const model = buildHitterMetrics('p1', [], battedBalls, 'official_game');
+ const m = metric(model, 'avg_exit_velocity')!;
+ expect(m.value).toBe(95);
+ expect(m.sampleSize).toBe(4);
+ });
+
+ it('avg_launch_angle independently counts only rows with a non-null launch_angle', () => {
+ const battedBalls = [
+ bbe({ exit_velocity: 90, launch_angle: 12 }),
+ bbe({ exit_velocity: 92, launch_angle: 18 }),
+ // Exit velocity logged, launch angle not -- the two fields gate independently.
+ bbe({ exit_velocity: 88, launch_angle: null }),
+ ];
+ const model = buildHitterMetrics('p1', [], battedBalls, 'official_game');
+ expect(metric(model, 'avg_exit_velocity')!.sampleSize).toBe(3);
+ expect(metric(model, 'avg_launch_angle')!.sampleSize).toBe(2);
+ expect(metric(model, 'avg_launch_angle')!.value).toBeCloseTo(15);
+ });
+
+ it('a hard-hit-rate/barrel-rate/gb-rate denominator still uses the FULL batted-ball count (bbCount), unaffected by this sampleSize fix', () => {
+ const battedBalls = [
+ bbe({ is_hard_hit: true, exit_velocity: 95 }),
+ bbe({ is_hard_hit: false, exit_velocity: null }),
+ bbe({ is_hard_hit: false, exit_velocity: null }),
+ ];
+ const model = buildHitterMetrics('p1', [], battedBalls, 'official_game');
+ // hard_hit_rate's denominator is every batted ball, radar-read or not.
+ expect(metric(model, 'hard_hit_rate')!.sampleSize).toBe(3);
+ // but avg_exit_velocity only counts the ones with an actual reading.
+ expect(metric(model, 'avg_exit_velocity')!.sampleSize).toBe(1);
+ });
+});
+
describe('honest confidence + provenance', () => {
it('never returns high on a thin sample', () => {
const thin = buildHitterMetrics('p1', Array.from({ length: 5 }, () => pitch({ is_in_zone: false, is_swing: true })), [], 'official_game');
diff --git a/src/lib/baseball/read-models/elite-stat-events.ts b/src/lib/baseball/read-models/elite-stat-events.ts
index 2044d9e36..339521fec 100644
--- a/src/lib/baseball/read-models/elite-stat-events.ts
+++ b/src/lib/baseball/read-models/elite-stat-events.ts
@@ -744,11 +744,23 @@ export function buildHitterMetrics(
),
scalarMetric(
{ metricKey: 'avg_exit_velocity', metricGroup: 'hitting', label: 'Avg Exit Velo', unit: 'mph', higherIsBetter: true, threshold: SCALAR_THRESHOLD, fallbackContext },
- avgOf(battedBalls.map((b) => b.exit_velocity)), bbCount, bbProv,
+ // sampleSize is the count of rows that ACTUALLY carried a velocity
+ // reading (exit_velocity is nullable — a hand-charted at-bat with no
+ // radar gun logs the batted ball with no exit_velocity), never bbCount
+ // (every batted ball, radar-read or not). A player with 10 batted balls
+ // but only 4 exit-velo readings must report sampleSize 4, not 10 —
+ // gateSample() honesty depends on it.
+ avgOf(battedBalls.map((b) => b.exit_velocity)),
+ battedBalls.filter((b) => b.exit_velocity != null).length,
+ bbProv,
),
scalarMetric(
{ metricKey: 'avg_launch_angle', metricGroup: 'hitting', label: 'Avg Launch Angle', unit: 'deg', higherIsBetter: true, threshold: SCALAR_THRESHOLD, fallbackContext },
- avgOf(battedBalls.map((b) => b.launch_angle)), bbCount, bbProv,
+ // Same non-null-reading rule as avg_exit_velocity above — launch_angle
+ // is independently nullable per row.
+ avgOf(battedBalls.map((b) => b.launch_angle)),
+ battedBalls.filter((b) => b.launch_angle != null).length,
+ bbProv,
),
];
diff --git a/src/lib/baseball/stat-layer-manifest.ts b/src/lib/baseball/stat-layer-manifest.ts
index 2800810f2..638b4c692 100644
--- a/src/lib/baseball/stat-layer-manifest.ts
+++ b/src/lib/baseball/stat-layer-manifest.ts
@@ -317,6 +317,27 @@ export const GRANDFATHERED_CONSUMERS: GrandfatheredStatLayerConsumer[] = [
note:
'#379 Phase 4a: pins loaders.ts\'s legacy-fallback behavior (an unmigrated caller keeps citing baseball_player_stats verbatim in source_refs when no eventDerived/source-table input is supplied) alongside the NEW event-derived-override and box-score-normalization tests — so this legitimately still references the deprecated table by design, not staleness. Retires once loaders.ts drops the legacy-table fallback entirely (tracked on loaders.ts\'s own manifest entry above).',
},
+ {
+ path: 'src/lib/baseball/__tests__/engine-run-event-velocity.test.ts',
+ group: 'test',
+ status: 'pending migration',
+ note:
+ '#852 residual: pins runBaseballEngineCore threading event-derived velocity (engine-event-derived.ts) into loadAllPlayerMetrics — asserts event-derived avg exit velocity WINS over a legacy baseball_player_stats exit_velocity scalar for the same player, and that a zero-event player keeps their legacy scalar (plus the all-or-nothing degrade on an event-read failure). The legacy fixture rows are the fallback pin, not staleness; retires with loaders.ts\'s legacy-scalar fallback.',
+ },
+ {
+ path: 'src/lib/baseball/__tests__/outcome-sweep-event-velocity.test.ts',
+ group: 'test',
+ status: 'pending migration',
+ note:
+ '#852 residual: pins sweepActionOutcomes threading event-derived velocity into its per-action after-window loadPlayerMetrics call — event-derived avg exit velocity wins over the legacy baseball_player_stats scalar for the same player, and a pre-action event is excluded by the after-window filter. The legacy fixture row is the fallback pin, not staleness; retires with loaders.ts\'s legacy-scalar fallback.',
+ },
+ {
+ path: 'src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts',
+ group: 'test',
+ status: 'pending migration',
+ note:
+ '#852 residual: pins buildActionOutcomeSeed threading event-derived velocity into its baseline capture — event-derived avg exit velocity wins over the legacy baseball_player_stats scalar for the same player; a zero-event player\'s legacy scalar still seeds the baseline. The legacy fixture row is the fallback pin, not staleness; retires with loaders.ts\'s legacy-scalar fallback.',
+ },
{
path: 'src/app/baseball/actions/__tests__/imports-registry.test.ts',
group: 'test',
From fe26e7c5a6ee14f88f08da63d4f67801dfd05e99 Mon Sep 17 00:00:00 2001
From: njrini99-code
Date: Wed, 15 Jul 2026 19:38:06 -0400
Subject: [PATCH 10/18] Consolidate stats-upload wizard into Import Center
(canonical) (#863)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Consolidate stats-upload wizard into Import Center (canonical)
Audited both wizards end-to-end (§3.11 decision: Import Center is
canonical). Ported the two real capability gaps before retiring the
legacy path — everything else (atomic save_baseball_full_box_score RPC,
player-match corrections, dedup/provenance/rollback) was already covered
by Import Center's commitImport pipeline, so nothing else needed porting:
- ImportWizardClient: added a "Quick box score" entry point on the choose
step (preselects game_box_score + jumps straight to Upload) plus
drag-and-drop onto the dropzone and a sample-values data-preview table
on the detect step — the legacy wizard's two capabilities Import Center
didn't have. No server-action signatures changed.
- /dashboard/stats/upload is now a pure redirect into /dashboard/import,
mirroring the stats -> stats-center legacy-redirect shim idiom. Sibling
error.tsx/loading.tsx removed (that idiom has neither).
- Retired the now-fully-orphaned StatsUploadClient/UploadHistory
components (only ever imported by the old page).
- Repointed the two in-app links that still pointed at the legacy route
(Command Center's "Upload stats", Stats Center's header) straight at
Import Center, and dropped Stats Center's redundant "Upload" button
(Import Center already sat right next to it, same destination).
- Test migration: extended settings-aliases-and-legacy-redirects.test.ts
with the new shim, added ImportWizardClient.quick-box-score.test.tsx for
the two ported capabilities, and updated the e2e assertion that pinned
the retired wizard's UI strings to assert the redirect instead.
nav-registry.ts (frozen) still lists /baseball/dashboard/stats/upload in
stats-center's matchPrefixes and STAFF_CAPABILITY_ROUTES/GUARD_ALLOWLIST
still gate it at can_manage_stats — both harmless now (a plain redirect
page, still resolves on disk, destination re-enforces can_manage_imports
itself) but flagging for the orchestrator in case a follow-up wants them
tidied.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
* Fix wizard-consolidation capability lockout + restore upload history (PR #863)
Adversarial review (FIX_FIRST) flagged two criticals in the stats-upload ->
Import Center consolidation:
1. CAPABILITY LOCKOUT — the /stats/upload redirect shim + the two repointed
CTAs sent every viewer straight at Import Center's can_manage_imports gate,
locking out every default staff role that holds can_manage_stats but not
can_manage_imports (assistant/pitching/hitting/catching/defensive/strength
coach — 6 of 11 canonical BASEBALL_STAFF_ROLE_PRESETS). Those roles could
reach and interact with the old wizard before this consolidation.
Fix: /stats/upload now branches on capability instead of redirecting
unconditionally. can_manage_imports staff still forward to the full Import
Center; can_manage_stats-only staff get the SAME ImportWizardClient
rendered inline, restricted to the "Quick box score" entry point
(new quickEntryOnly prop — skips the choose step and hides the "change
data shape" affordance, no way to reach the full shape picker/event-level
mode/source registry/rollback reserved for can_manage_imports staff).
Middleware's STAFF_CAPABILITY_ROUTES already allowlists this exact route
at can_manage_stats, so no middleware/nav-registry contract change was
needed. Command Center's "Upload stats" and Stats Center's two CTAs are
repointed from /dashboard/import back to /dashboard/stats/upload so every
entry point resolves through the capability-aware router.
2. UPLOAD HISTORY DELETED — UploadHistory.tsx was the only surface reading
baseball_stat_uploads (filename/status/processed counts); its deletion
left every pre-consolidation upload record permanently unviewable.
Fix: ported a read-only "Legacy uploads" section into ImportWizardClient
(Living Annual idiom: Eyebrow/HairlineRule/EditorsLetter honest empty
state, matching the existing "Recent imports" section), backed by
getRecentUploads — an existing, already-demoSafe, already-team-scoped
server action with zero prior callers. No server-action signature
changes. Wired into both the full Import Center page and the new
capability-aware /stats/upload entry point.
Also extracted the roster-for-matching query (previously inlined in
import/page.tsx) into a shared src/lib/baseball/import-roster.ts helper so
both pages load player-matching data identically instead of drifting.
Gates: typecheck clean, eslint --max-warnings 0 clean on all touched files,
targeted + broader baseball vitest suites green (1178 tests), check-cycles
clean (33 known cycles, none new).
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
* fix(stats-center): route import entry points by viewer capability
The two Import Center entry points (header action + empty-state CTA) sent
everyone through the /stats/upload shim, whose middleware gate is
can_manage_stats — bouncing import-capable-but-not-stats staff (e.g. the
director_ops preset) off middleware before the shim's own capability branch
could forward them. The page now computes can_manage_imports server-side
(same helper the shim branches on) and import-capable viewers go straight to
/dashboard/import; everyone else keeps the shim path.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
* fix(baseball-import): authorize stats-only staff for box-score import commit/preview (PR #863 round-4)
previewImport/commitImport were hard-gated to can_manage_imports
unconditionally, so the quickEntryOnly inline wizard at /stats/upload
(rendered for the 6 can_manage_stats-only staff presets) let a
stats-only coach fill out the whole form and then fail server-side on
submit. Pre-consolidation, stats-only staff could upload box scores via
the legacy wizard, so restore that: a 'game_box_score' request may now
be authorized by can_manage_imports OR can_manage_stats; every other
shape (season_totals, event_log, or omitted) keeps the original
can_manage_imports-only gate.
- with-baseball-action.ts: requiredCapability now also accepts a
readonly array (ANY-of) or a resolver function of the action's own
args, resolved once before AUTH so tags/metadata and enforcement can
never disagree. Single-capability call sites (~60 existing) resolve
to a one-element list and behave byte-identically to before.
- imports.ts: previewImport gained an optional dataShape field
(mirroring CommitImportArgs.dataShape) so the same shape-conditional
gate applies at preview time too; both actions resolve the OR-gate
from the exact field applyImportPlan uses for canonical-table
routing, so the auth decision and the write decision can never
diverge.
- ImportWizardClient.tsx: pass dataShape through to previewImport, and
hide the Upload step's "Back to choose" button for quickEntryOnly
viewers (it routed to the full shape picker Import Center reserves
for can_manage_imports staff).
- New suite (imports-capability-shape-gate.test.ts) exercises the real
withBaseballAction/capabilities wiring (not a passthrough mock) to
prove: stats-only + game_box_score authorizes and actually writes;
stats-only + season_totals still throws BaseballCapabilityError with
zero side effects; no-capability staff still denied; imports-only
staff unchanged across every shape.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
---------
Co-authored-by: Fable Integrator
Co-authored-by: Claude Fable 5
---
e2e/baseball-stats-smoke.spec.ts | 15 +-
.../(dashboard)/dashboard/import/page.tsx | 47 +-
.../dashboard/stats-center/page.tsx | 9 +-
.../dashboard/stats/upload/error.tsx | 23 -
.../dashboard/stats/upload/loading.tsx | 54 -
.../dashboard/stats/upload/page.tsx | 159 +--
.../imports-capability-shape-gate.test.ts | 307 ++++
src/app/baseball/actions/imports.ts | 91 +-
.../command-center/CommandCenterFairway.tsx | 7 +
.../import-center/ImportCenterShell.tsx | 10 +
.../import-center/ImportWizardClient.tsx | 295 +++-
...mportWizardClient.quick-box-score.test.tsx | 162 +++
.../stats-center/StatsCenterClient.tsx | 60 +-
.../baseball/stats/StatsUploadClient.tsx | 1263 -----------------
.../baseball/stats/UploadHistory.tsx | 268 ----
src/components/baseball/stats/index.ts | 2 -
...tings-aliases-and-legacy-redirects.test.ts | 17 +
src/lib/baseball/import-roster.ts | 54 +
src/lib/baseball/stat-layer-manifest.ts | 7 +
src/lib/baseball/with-baseball-action.ts | 94 +-
20 files changed, 1137 insertions(+), 1807 deletions(-)
delete mode 100644 src/app/baseball/(dashboard)/dashboard/stats/upload/error.tsx
delete mode 100644 src/app/baseball/(dashboard)/dashboard/stats/upload/loading.tsx
create mode 100644 src/app/baseball/actions/__tests__/imports-capability-shape-gate.test.ts
create mode 100644 src/components/baseball/import-center/__tests__/ImportWizardClient.quick-box-score.test.tsx
delete mode 100644 src/components/baseball/stats/StatsUploadClient.tsx
delete mode 100644 src/components/baseball/stats/UploadHistory.tsx
delete mode 100644 src/components/baseball/stats/index.ts
create mode 100644 src/lib/baseball/import-roster.ts
diff --git a/e2e/baseball-stats-smoke.spec.ts b/e2e/baseball-stats-smoke.spec.ts
index d1908ff01..0b1d3562f 100644
--- a/e2e/baseball-stats-smoke.spec.ts
+++ b/e2e/baseball-stats-smoke.spec.ts
@@ -188,16 +188,23 @@ test.describe('BaseballHelm seeded smoke — coach surfaces', () => {
await expect(page.getByText('FINAL')).toBeVisible();
});
- test('Upload route renders the CSV upload surface (college-coach guard passed)', async ({ page }) => {
+ // Wizard consolidation: the legacy /stats/upload wizard is now a redirect
+ // shim INTO Import Center (the canonical wizard) — see
+ // src/app/baseball/(dashboard)/dashboard/stats/upload/page.tsx. This test
+ // used to assert the retired StatsUploadClient surface directly; it now
+ // asserts the redirect lands the coach on Import Center's "Quick box score"
+ // entry point instead, so the college-coach guard + destination stay
+ // covered without pinning a UI that no longer exists.
+ test('Upload route redirects into Import Center (college-coach guard passed)', async ({ page }) => {
const ok = await tryLogin(page, TEST_USERS.coach);
test.skip(!ok, 'coach login fixture unavailable in this environment');
await page.goto('/baseball/dashboard/stats/upload');
await waitForPageLoad(page);
- await expect(page.getByRole('heading', { name: 'Upload Stats' })).toBeVisible({ timeout: 10000 });
- await expect(page.getByRole('heading', { name: 'Upload CSV File' })).toBeVisible();
- await expect(page.getByText('Choose File')).toBeVisible();
+ await expect(page).toHaveURL(/\/baseball\/dashboard\/import$/);
+ await expect(page.getByRole('heading', { name: 'Import Center' })).toBeVisible({ timeout: 10000 });
+ await expect(page.getByText('Quick box score')).toBeVisible();
});
});
diff --git a/src/app/baseball/(dashboard)/dashboard/import/page.tsx b/src/app/baseball/(dashboard)/dashboard/import/page.tsx
index 6269e97e8..6bb1933b1 100644
--- a/src/app/baseball/(dashboard)/dashboard/import/page.tsx
+++ b/src/app/baseball/(dashboard)/dashboard/import/page.tsx
@@ -16,16 +16,19 @@ import { createClient } from '@/lib/supabase/server';
import { getActiveBaseballContext } from '@/lib/baseball/active-context';
import { hasBaseballCapability } from '@/lib/baseball/capabilities';
import { getImportRuns } from '@/app/baseball/actions/imports';
+import { getRecentUploads } from '@/app/baseball/actions/stats';
import { listImportSources } from '@/app/baseball/actions/program-settings';
import { ImportCenterShell } from '@/components/baseball/import-center/ImportCenterShell';
import { CONCRETE_EVENT_ADAPTERS } from '@/lib/baseball/adapters';
import { getSourceRegistryEntry } from '@/lib/baseball/stat-import-adapters';
import { BASEBALL_IMPORT_SOURCES } from '@/lib/baseball/import-matching';
+import { getRosterForImportMatching } from '@/lib/baseball/import-roster';
import { isImportSourceEnabled } from '@/lib/baseball/import-source-enabled';
import { fairwayScope } from '@/lib/redesign/flag';
import type { BaseballImportRunRow } from '@/lib/types/baseball-imports';
import type { BaseballImportSourceConfig } from '@/lib/types/baseball-settings';
import type { BaseballSourceKey } from '@/lib/types/baseball-stat-events';
+import type { BaseballStatUpload } from '@/lib/types';
import type { RegisteredSourceOption } from '@/components/baseball/import-center/ImportCenterShell';
/**
@@ -92,33 +95,7 @@ export default async function ImportCenterPage() {
// (player_matching_v2.md): jersey_number (per-team, on the membership) plus
// grad_year + primary_position (on the player), so the matcher can break
// same-name ties and the manual-match dropdown can show jersey/class/position.
- const { data: members } = await supabase
- .from('baseball_team_members')
- .select(
- `player_id,
- jersey_number,
- baseball_players!inner ( id, first_name, last_name, grad_year, primary_position )`
- )
- .eq('team_id', teamId);
-
- const players = (members ?? []).map((m) => {
- const p = m.baseball_players as unknown as {
- id: string;
- first_name: string | null;
- last_name: string | null;
- grad_year: number | null;
- primary_position: string | null;
- };
- const member = m as unknown as { jersey_number: number | null };
- return {
- id: p.id,
- first_name: p.first_name,
- last_name: p.last_name,
- jersey_number: member.jersey_number,
- grad_year: p.grad_year,
- primary_position: p.primary_position,
- };
- });
+ const players = await getRosterForImportMatching(supabase, teamId);
// Recent runs (capability-checked again inside the action).
let recentRuns: BaseballImportRunRow[] = [];
@@ -128,6 +105,21 @@ export default async function ImportCenterPage() {
recentRuns = [];
}
+ // UPLOAD-HISTORY RESTORE — the pre-consolidation flat-upload path
+ // (baseball_stat_uploads) is no longer written to by any in-app UI, but
+ // historical rows from before this wizard consolidation still exist and
+ // were left with NO viewing surface once UploadHistory.tsx was retired.
+ // getRecentUploads is a read-only, demoSafe action gated only on team
+ // access (no can_manage_imports requirement), so it's safe to surface here
+ // for every viewer who reaches this page.
+ let legacyUploads: BaseballStatUpload[] = [];
+ try {
+ const legacy = await getRecentUploads(teamId, 20);
+ legacyUploads = legacy.data ?? [];
+ } catch {
+ legacyUploads = [];
+ }
+
// Registered sources from the team's import-source registry. These MERGE OVER
// the hardcoded adapter defaults so the wizard offers (and governs by) what the
// coach actually registered — the fix that makes the registry load-bearing in
@@ -147,6 +139,7 @@ export default async function ImportCenterPage() {
teamName={team?.name ?? 'Your Team'}
players={players}
recentRuns={recentRuns}
+ legacyUploads={legacyUploads}
eventSources={EVENT_SOURCES}
registeredSources={registeredSources}
/>
diff --git a/src/app/baseball/(dashboard)/dashboard/stats-center/page.tsx b/src/app/baseball/(dashboard)/dashboard/stats-center/page.tsx
index 576914a72..a972d4b59 100644
--- a/src/app/baseball/(dashboard)/dashboard/stats-center/page.tsx
+++ b/src/app/baseball/(dashboard)/dashboard/stats-center/page.tsx
@@ -23,6 +23,7 @@
import { redirect } from 'next/navigation';
import { getActiveBaseballContext } from '@/lib/baseball/active-context';
+import { hasBaseballCapability } from '@/lib/baseball/capabilities';
import {
getStatsCenter,
type StatSide,
@@ -102,12 +103,17 @@ export default async function StatsCenterPage({
// read model that feeds the V10 chart gallery (chase/whiff/EV-LA/spray/
// pitch-shape/velo-decay). The event read model is itself can_manage_stats-
// gated and returns empty visuals when no granular events are captured.
- const [model, visualsPayload] = await Promise.all([
+ const [model, visualsPayload, canManageImports] = await Promise.all([
getStatsCenter(context.activeTeamId, options),
getStatVisualsPayload(context.activeTeamId, {
fromDate: options.fromDate ?? null,
toDate: options.toDate ?? null,
}),
+ // Decides where the client's import entry points route: straight to the
+ // full Import Center for import-capable staff, or through the
+ // capability-aware /stats/upload shim for everyone else. Same helper the
+ // shim itself branches on, so the two stay in agreement.
+ hasBaseballCapability(context.activeTeamId, 'can_manage_imports'),
]);
// The full V10 chart payload (every visual family). Undefined ONLY when the
@@ -117,6 +123,7 @@ export default async function StatsCenterPage({
return (
void;
-}) {
- return (
-
- );
-}
diff --git a/src/app/baseball/(dashboard)/dashboard/stats/upload/loading.tsx b/src/app/baseball/(dashboard)/dashboard/stats/upload/loading.tsx
deleted file mode 100644
index 462a3fe8f..000000000
--- a/src/app/baseball/(dashboard)/dashboard/stats/upload/loading.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { PaperCard } from '@/components/baseball/living-annual';
-
-export default function StatsUploadLoading() {
- return (
-
-
- {/* Header skeleton */}
-
-
- {/* Progress steps skeleton */}
-
- {[1, 2, 3, 4].map((i) => (
-
- ))}
-
-
- {/* Upload area skeleton */}
-
-
-
-
- {/* History skeleton */}
-
-
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
-
-
-
- );
-}
diff --git a/src/app/baseball/(dashboard)/dashboard/stats/upload/page.tsx b/src/app/baseball/(dashboard)/dashboard/stats/upload/page.tsx
index c2dc77aae..8de74532d 100644
--- a/src/app/baseball/(dashboard)/dashboard/stats/upload/page.tsx
+++ b/src/app/baseball/(dashboard)/dashboard/stats/upload/page.tsx
@@ -1,103 +1,96 @@
-'use server';
+// =============================================================================
+// src/app/baseball/(dashboard)/dashboard/stats/upload/page.tsx
+//
+// WIZARD CONSOLIDATION — this route used to render the standalone
+// StatsUploadClient wizard (drag-a-CSV, map columns, match players, upload).
+// Every capability it had that Import Center lacked has been ported there
+// as the "Quick box score" entry point on the choose step (drag-and-drop
+// upload + a data-preview table), and Import Center already covers — with a
+// strictly larger, audited, rollback-able pipeline — everything else this
+// page used to do (atomic save_baseball_full_box_score RPC, player matching,
+// column mapping).
+//
+// CAPABILITY-AWARE ROUTING (fix-first, wizard-consolidation review) — Import
+// Center's own page (and middleware's STAFF_CAPABILITY_ROUTES map) gate on
+// can_manage_imports. A plain redirect straight there — as this page used to
+// be — locks out every default staff role that holds can_manage_stats but
+// NOT can_manage_imports (assistant/pitching/hitting/catching/defensive/
+// strength coach; see BASEBALL_STAFF_ROLE_PRESETS in
+// src/lib/types/baseball-staff-roles.ts). Those roles could reach and
+// interact with this wizard before the consolidation, so this route now
+// branches on capability instead of redirecting unconditionally:
+// - can_manage_imports staff -> redirected on to the full, canonical
+// Import Center (source registry, event-level mode, rollback).
+// - can_manage_stats-only staff -> the SAME ImportWizardClient renders
+// INLINE, right here, restricted to the "Quick box score" entry point
+// (quickEntryOnly) — middleware's STAFF_CAPABILITY_ROUTES already
+// allowlists this exact route at can_manage_stats, so no middleware
+// change is needed to restore their reachability.
+// - neither capability -> redirected to Command Center, same fallback
+// Import Center's own page uses.
+// =============================================================================
-import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
-import { StatsUploadClient, UploadHistory } from '@/components/baseball/stats';
-import { EditorsLetter } from '@/components/baseball/living-annual';
-import { resolveCoachTeamIdWithCookie } from '@/lib/baseball/resolve-team-server';
+
+import { createClient } from '@/lib/supabase/server';
+import { getActiveBaseballContext } from '@/lib/baseball/active-context';
+import { hasBaseballCapability } from '@/lib/baseball/capabilities';
+import { getRecentUploads } from '@/app/baseball/actions/stats';
+import { getRosterForImportMatching } from '@/lib/baseball/import-roster';
+import { ImportWizardClient } from '@/components/baseball/import-center/ImportWizardClient';
+import { fairwayScope } from '@/lib/redesign/flag';
+import type { BaseballStatUpload } from '@/lib/types';
export default async function StatsUploadPage() {
const supabase = await createClient();
- // Get authenticated user
- const { data: { user }, error: userError } = await supabase.auth.getUser();
- if (userError || !user) {
- redirect('/baseball/login');
- }
-
- // Get coach profile
- const { data: coach, error: coachError } = await supabase
- .from('baseball_coaches')
- .select('id, coach_type, organization_id, full_name')
- .eq('user_id', user.id)
- .single();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) redirect('/baseball/login');
- if (coachError || !coach) {
- redirect('/baseball/dashboard/command-center');
- }
+ const context = await getActiveBaseballContext();
+ if (!context) redirect('/baseball/dashboard/command-center');
- // Only college and JUCO coaches have access
- if (coach.coach_type !== 'college' && coach.coach_type !== 'juco') {
- redirect('/baseball/dashboard/command-center');
- }
+ const teamId = context.activeTeamId;
- if (!coach.organization_id) {
- redirect('/baseball/dashboard/program');
- }
+ // Import-capable staff get the full, canonical Import Center — the SAME
+ // destination this shim has always pointed to for them.
+ const canImport = await hasBaseballCapability(teamId, 'can_manage_imports');
+ if (canImport) redirect('/baseball/dashboard/import');
- // Get team for this organization (cookie-aware, multi-row-safe — matches
- // Command Center).
- type TeamInfo = { id: string; name: string; team_type: string };
- const teamId = await resolveCoachTeamIdWithCookie(supabase, coach.organization_id, coach.id);
- const { data: team, error: teamError } = teamId
- ? ((await supabase
- .from('baseball_teams')
- .select('id, name, team_type')
- .eq('id', teamId)
- .maybeSingle()) as { data: TeamInfo | null; error: unknown })
- : { data: null, error: null };
+ const canManageStats = await hasBaseballCapability(teamId, 'can_manage_stats');
+ if (!canManageStats) redirect('/baseball/dashboard/command-center');
- if (teamError || !team) {
- // LA ghost/EditorsLetter state (spec doctrine: no amber warning boxes
- // anywhere) — this replaces the bespoke glass + amber-icon empty tile.
- return (
-
-
- Go to Command Center
-
- }
- />
-
- );
- }
+ // STATS-ONLY STAFF — render the quick-box-score wizard directly at this
+ // (already can_manage_stats-gated) route instead of bouncing them at
+ // Import Center's can_manage_imports gate.
+ const { data: team } = await supabase
+ .from('baseball_teams')
+ .select('id, name')
+ .eq('id', teamId)
+ .maybeSingle();
- // Get team members for preview
- const { data: teamMembers } = await supabase
- .from('baseball_team_members')
- .select(`
- player_id,
- baseball_players!inner (
- id,
- first_name,
- last_name
- )
- `)
- .eq('team_id', team.id);
+ const players = await getRosterForImportMatching(supabase, teamId);
- const players = (teamMembers || []).map(tm => ({
- id: (tm.baseball_players as { id: string }).id,
- firstName: (tm.baseball_players as { first_name: string | null }).first_name || '',
- lastName: (tm.baseball_players as { last_name: string | null }).last_name || '',
- }));
+ let legacyUploads: BaseballStatUpload[] = [];
+ try {
+ const legacy = await getRecentUploads(teamId, 20);
+ legacyUploads = legacy.data ?? [];
+ } catch {
+ legacyUploads = [];
+ }
return (
-
);
}
diff --git a/src/app/baseball/actions/__tests__/imports-capability-shape-gate.test.ts b/src/app/baseball/actions/__tests__/imports-capability-shape-gate.test.ts
new file mode 100644
index 000000000..3e1a3df5e
--- /dev/null
+++ b/src/app/baseball/actions/__tests__/imports-capability-shape-gate.test.ts
@@ -0,0 +1,307 @@
+// =============================================================================
+// src/app/baseball/actions/__tests__/imports-capability-shape-gate.test.ts
+//
+// ROUND-4 FIX (#863) — previewImport/commitImport were hard-gated on
+// can_manage_imports UNCONDITIONALLY, so the quickEntryOnly inline wizard at
+// /stats/upload (rendered for the 6 can_manage_stats-only staff presets) let a
+// stats-only coach fill out the ENTIRE form and then fail server-side on
+// submit — the two real actions behind it never actually authorized them.
+// Pre-consolidation, stats-only staff COULD upload box scores via the legacy
+// /stats/upload wizard, so the faithful model is: a 'game_box_score' request
+// may be authorized by can_manage_imports OR can_manage_stats; every other
+// shape (season_totals, event_log, undefined) keeps the ORIGINAL
+// can_manage_imports-only gate.
+//
+// UNLIKE imports-registry.test.ts (which mocks withBaseballAction to a
+// passthrough and so proves nothing about authorization), THIS suite leaves
+// the real withBaseballAction/capabilities wiring in place and mocks only the
+// capability/staff-resolution seam (hasBaseballCapability/
+// requireBaseballCapability) plus the auth/context/observability plumbing —
+// the SAME pattern academics-coach-gating.test.ts uses. This is what actually
+// exercises the new shape-conditional `requiredCapability` resolver in
+// with-baseball-action.ts end-to-end, through the real action bodies.
+// =============================================================================
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ---- capability/staff-resolution seam (the thing under test) ---------------
+
+let grantedCapabilities: Set;
+
+vi.mock('@/lib/baseball/capabilities', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ hasBaseballCapability: vi.fn(async (_teamId: string, cap: string) =>
+ grantedCapabilities.has(cap),
+ ),
+ requireBaseballCapability: vi.fn(async (teamId: string, cap: string) => {
+ if (!grantedCapabilities.has(cap)) {
+ throw new actual.BaseballCapabilityError(cap as never, teamId);
+ }
+ return {} as never;
+ }),
+ };
+});
+
+// ---- auth/context/observability plumbing (real withBaseballAction runs) ----
+
+vi.mock('@/lib/baseball/active-context', () => ({
+ getActiveBaseballContext: vi.fn(async () => ({
+ userId: 'user-1',
+ activeTeamId: 'team-1',
+ activeRole: 'coach' as const,
+ activeCoachId: 'coach-1',
+ activePlayerId: null,
+ fellBackFromStale: false,
+ })),
+}));
+
+vi.mock('@/lib/demo/baseball-config.server', () => ({
+ isCurrentSessionBaseballDemo: vi.fn(async () => false),
+}));
+
+vi.mock('@/lib/server-error-logger', () => ({
+ logServerException: vi.fn(async () => undefined),
+ logServerError: vi.fn(async () => undefined),
+ logServerEvent: vi.fn(async () => undefined),
+}));
+
+vi.mock('@sentry/nextjs', () => ({
+ withScope: (fn: (scope: unknown) => unknown) =>
+ fn({ setTag: vi.fn(), addBreadcrumb: vi.fn(), setUser: vi.fn() }),
+}));
+
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
+vi.mock('@/app/baseball/actions/coachhelm', () => ({ runBaseballEngine: vi.fn(async () => {}) }));
+vi.mock('@/lib/baseball/timeline-writer', () => ({
+ appendImportTimelineEvent: vi.fn(async () => {}),
+}));
+
+// ---- table-aware Supabase recorder (same pattern as imports-registry.test.ts) --
+
+type Row = Record;
+const inserted: Record = {};
+
+/** What loadSourcePolicy returns — null (unregistered, adapter defaults) for
+ * every test here since the registry policy is not what's under test. */
+let existingStatRow: Row | null = null;
+
+function tableHandle(table: string) {
+ const result = (data: unknown) => ({ data, error: null });
+ const chain: Record = {};
+ const ret = () => chain;
+ for (const m of ['select', 'eq', 'in', 'or', 'order', 'limit', 'is']) chain[m] = vi.fn(ret);
+
+ chain.maybeSingle = vi.fn(async () => {
+ if (table === 'baseball_player_stats') return result(existingStatRow);
+ return result(null);
+ });
+ chain.single = vi.fn(async () => {
+ if (table === 'baseball_import_runs') return result({ id: 'run-1' });
+ if (table === 'baseball_player_stats') return result({ id: 'stat-new-1' });
+ return result({ id: `${table}-1` });
+ });
+
+ // baseball_import_sources resolves via .eq(...).or(...).limit(1) awaited
+ // directly (no .single()) — always unregistered (adapter defaults) here.
+ chain.then = (resolve: (v: unknown) => unknown) => resolve(result([]));
+
+ chain.insert = vi.fn((rows: Row | Row[]) => {
+ (inserted[table] ??= []).push(...(Array.isArray(rows) ? rows : [rows]));
+ return {
+ select: vi.fn(() => ({
+ single: vi.fn(async () =>
+ table === 'baseball_import_runs' ? result({ id: 'run-1' }) : result({ id: 'stat-new-1' }),
+ ),
+ })),
+ then: (r: (v: unknown) => unknown) => r(result(null)),
+ };
+ });
+ chain.update = vi.fn(() => ({
+ eq: vi.fn(() => ({ then: (r: (v: unknown) => unknown) => r(result(null)) })),
+ }));
+ chain.upsert = vi.fn((rows: Row | Row[]) => {
+ (inserted[table] ??= []).push(...(Array.isArray(rows) ? rows : [rows]));
+ return { then: (r: (v: unknown) => unknown) => r(result(null)) };
+ });
+
+ return chain;
+}
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(async () => ({
+ from: (table: string) => tableHandle(table),
+ auth: {
+ getUser: vi.fn(async () => ({
+ data: { user: { id: 'user-1', email: 'coach@example.edu' } },
+ error: null,
+ })),
+ },
+ })),
+}));
+
+// ---- import AFTER every mock -------------------------------------------------
+
+import { previewImport, commitImport } from '@/app/baseball/actions/imports';
+import { BaseballCapabilityError } from '@/lib/baseball/capabilities';
+
+const TEAM_ID = 'team-1';
+
+function commitArgs(overrides: Record = {}) {
+ return {
+ teamId: TEAM_ID,
+ sourceId: 'generic_csv',
+ // No at_bats/hits mapped — a 'game_box_score' dataShape's canonical
+ // box-score routing (applyGameBoxScoreImport) then finds no batting/
+ // pitching-shaped values and no-ops WITHOUT touching games.ts, keeping
+ // this suite scoped to the authorization gate rather than box-score
+ // write mechanics (already covered by games.ts's own suites).
+ fileName: 'box.csv',
+ statType: 'game' as const,
+ sessionDate: '2026-03-01',
+ headers: ['player'],
+ mapping: { player_name: 'player' },
+ rows: [{ player: 'Jane Doe' }],
+ matches: [
+ {
+ rowIndex: 0,
+ sourceName: 'Jane Doe',
+ playerId: 'p1',
+ playerName: 'Jane Doe',
+ confidence: 1,
+ matchTier: 'exact_roster' as const,
+ isManualMatch: false,
+ action: 'create' as const,
+ },
+ ],
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ for (const k of Object.keys(inserted)) delete inserted[k];
+ existingStatRow = null;
+ grantedCapabilities = new Set();
+});
+
+// -----------------------------------------------------------------------------
+// previewImport
+// -----------------------------------------------------------------------------
+
+describe('previewImport — shape-conditional capability gate', () => {
+ it('(a) stats-only staff + game_box_score preview: authorized, runs (parses the real CSV)', async () => {
+ grantedCapabilities = new Set(['can_manage_stats']);
+
+ const result = await previewImport({
+ teamId: TEAM_ID,
+ sourceId: 'generic_csv',
+ csvContent: 'player,ab,h\nJane Doe,4,2\n',
+ statType: 'game',
+ sessionDate: '2026-03-01',
+ dataShape: 'game_box_score',
+ });
+
+ // Proves the REAL body ran (parsed the CSV), not just that the gate
+ // didn't throw.
+ expect(result.totalRows).toBe(1);
+ expect(result.headers).toEqual(['player', 'ab', 'h']);
+ });
+
+ it('restrictive default: stats-only staff previewing WITHOUT a dataShape still requires can_manage_imports', async () => {
+ grantedCapabilities = new Set(['can_manage_stats']);
+
+ await expect(
+ previewImport({
+ teamId: TEAM_ID,
+ sourceId: 'generic_csv',
+ csvContent: 'player,ab,h\nJane Doe,4,2\n',
+ statType: 'game',
+ sessionDate: '2026-03-01',
+ // dataShape omitted — must NOT be treated as 'game_box_score'.
+ }),
+ ).rejects.toBeInstanceOf(BaseballCapabilityError);
+ });
+
+ it('stats-only staff + season_totals preview: still requires can_manage_imports', async () => {
+ grantedCapabilities = new Set(['can_manage_stats']);
+
+ await expect(
+ previewImport({
+ teamId: TEAM_ID,
+ sourceId: 'generic_csv',
+ csvContent: 'player,ab,h\nJane Doe,4,2\n',
+ statType: 'other',
+ sessionDate: '2026',
+ dataShape: 'season_totals',
+ }),
+ ).rejects.toBeInstanceOf(BaseballCapabilityError);
+ });
+});
+
+// -----------------------------------------------------------------------------
+// commitImport
+// -----------------------------------------------------------------------------
+
+describe('commitImport — shape-conditional capability gate', () => {
+ it('(b) stats-only staff + season_totals commit: BaseballCapabilityError, writes NOTHING', async () => {
+ grantedCapabilities = new Set(['can_manage_stats']);
+
+ await expect(
+ commitImport(commitArgs({ dataShape: 'season_totals', sessionDate: '2026' })),
+ ).rejects.toBeInstanceOf(BaseballCapabilityError);
+
+ // The gate ran BEFORE any side effect — no run header, no stat row.
+ expect(inserted['baseball_import_runs'] ?? []).toHaveLength(0);
+ expect(inserted['baseball_player_stats'] ?? []).toHaveLength(0);
+ });
+
+ it('(c) stats-only staff + game_box_score commit: authorized, runs (writes the stat row for real)', async () => {
+ grantedCapabilities = new Set(['can_manage_stats']);
+
+ const res = await commitImport(commitArgs({ dataShape: 'game_box_score' }));
+
+ expect(res.heldForReview).toBe(false);
+ expect(res.created).toBe(1);
+ expect(inserted['baseball_player_stats'] ?? []).toHaveLength(1);
+ expect(inserted['baseball_import_runs'] ?? []).toHaveLength(1);
+ });
+
+ it('(d) no-capability staff + game_box_score commit: BaseballCapabilityError (the OR is not "anyone")', async () => {
+ grantedCapabilities = new Set(); // neither can_manage_imports nor can_manage_stats
+
+ await expect(
+ commitImport(commitArgs({ dataShape: 'game_box_score' })),
+ ).rejects.toBeInstanceOf(BaseballCapabilityError);
+
+ expect(inserted['baseball_import_runs'] ?? []).toHaveLength(0);
+ expect(inserted['baseball_player_stats'] ?? []).toHaveLength(0);
+ });
+
+ it('(e) imports-only staff + game_box_score commit: unchanged — authorized exactly as before this fix', async () => {
+ grantedCapabilities = new Set(['can_manage_imports']);
+
+ const res = await commitImport(commitArgs({ dataShape: 'game_box_score' }));
+
+ expect(res.heldForReview).toBe(false);
+ expect(res.created).toBe(1);
+ });
+
+ it('(e) imports-only staff + season_totals commit: unchanged — authorized exactly as before this fix', async () => {
+ grantedCapabilities = new Set(['can_manage_imports']);
+
+ const res = await commitImport(commitArgs({ dataShape: 'season_totals', sessionDate: '2026' }));
+
+ expect(res.heldForReview).toBe(false);
+ expect(res.created).toBe(1);
+ });
+
+ it('imports-only staff + NO dataShape commit: unchanged — the legacy (pre-#379) caller path still works', async () => {
+ grantedCapabilities = new Set(['can_manage_imports']);
+
+ const res = await commitImport(commitArgs({ dataShape: undefined }));
+
+ expect(res.heldForReview).toBe(false);
+ expect(res.created).toBe(1);
+ });
+});
diff --git a/src/app/baseball/actions/imports.ts b/src/app/baseball/actions/imports.ts
index 135da122f..48a8204ee 100644
--- a/src/app/baseball/actions/imports.ts
+++ b/src/app/baseball/actions/imports.ts
@@ -629,32 +629,56 @@ function findClassColumn(headers: string[]): string | null {
// previewImport — parse + match, NO writes
// -----------------------------------------------------------------------------
+/**
+ * previewImport's args. `dataShape` mirrors CommitImportArgs.dataShape (the
+ * wizard's Step 1 "What are you uploading?" choice) so the capability gate
+ * below can relax identically at preview time and at commit time — a
+ * stats-only coach whose ONLY unlocked path is 'game_box_score' must not get
+ * blocked at the FIRST wizard step the two-shape capability model allows them
+ * to reach. Optional so a legacy/other caller that omits it (see
+ * EventImportWizard.tsx, which never sets it) keeps the pre-existing
+ * can_manage_imports-only gate — the restrictive default.
+ */
+interface PreviewImportArgs {
+ teamId: string;
+ sourceId: string;
+ csvContent: string;
+ // The targeted grain — supplied so the preview can detect EXISTING rows and
+ // show the per-row create/update/skip verdict BEFORE commit. Optional so a
+ // caller that only wants the parse/match preview still works (verdicts empty).
+ statType?: 'practice' | 'game' | 'other';
+ sessionDate?: string;
+ dataShape?: 'season_totals' | 'game_box_score' | 'event_log';
+}
+
+/**
+ * ROUND-4 FIX (#863) — the SAME shape-conditional capability gate commitImport
+ * uses (see its own comment above the `requiredCapability` option): a
+ * 'game_box_score' request may be authorized by can_manage_imports OR
+ * can_manage_stats; every other shape (including omitted/undefined, which
+ * covers every existing non-wizard caller) keeps the original can_manage_imports-
+ * only gate. `dataShape` here is the exact field the wizard sends and the coach
+ * chose in the UI — there is no other, more "server-trusted" source for it at
+ * preview time (no write has happened yet to derive it from); resolving the
+ * SAME field the commit-time gate resolves is what keeps the two gates from
+ * ever disagreeing about what a given request is allowed to do.
+ */
+function importCapabilityForShape(
+ a: Pick,
+): 'can_manage_imports' | readonly ['can_manage_stats', 'can_manage_imports'] {
+ return a.dataShape === 'game_box_score'
+ ? (['can_manage_stats', 'can_manage_imports'] as const)
+ : 'can_manage_imports';
+}
+
export const previewImport = withBaseballAction(
'previewImport',
{
featureArea: 'baseball-import',
- requiredCapability: 'can_manage_imports',
- teamFrom: (a: {
- teamId: string;
- sourceId: string;
- csvContent: string;
- statType?: 'practice' | 'game' | 'other';
- sessionDate?: string;
- }) => a.teamId,
+ requiredCapability: (a: PreviewImportArgs) => importCapabilityForShape(a),
+ teamFrom: (a: PreviewImportArgs) => a.teamId,
},
- async (
- _ctx,
- args: {
- teamId: string;
- sourceId: string;
- csvContent: string;
- // The targeted grain — supplied so the preview can detect EXISTING rows and
- // show the per-row create/update/skip verdict BEFORE commit. Optional so a
- // caller that only wants the parse/match preview still works (verdicts empty).
- statType?: 'practice' | 'game' | 'other';
- sessionDate?: string;
- }
- ): Promise => {
+ async (_ctx, args: PreviewImportArgs): Promise => {
const supabase = await createClient();
const { teamId, sourceId } = args;
const db = supabase as unknown as LooseClient;
@@ -815,7 +839,30 @@ export const commitImport = withBaseballAction(
'commitImport',
{
featureArea: 'baseball-import',
- requiredCapability: 'can_manage_imports',
+ // ROUND-4 FIX (#863) — pre-consolidation, stats-only staff (can_manage_stats,
+ // no can_manage_imports) could upload box scores via the legacy /stats/upload
+ // wizard; the consolidated Import Center regressed that by hard-gating BOTH
+ // previewImport and commitImport to can_manage_imports unconditionally,
+ // silently locking out every stats-only role even though the quickEntryOnly
+ // inline wizard (see StatsUploadPage) still renders for them. Restore the
+ // faithful capability model: a 'game_box_score' commit — the ONLY shape
+ // quickEntryOnly's UI can ever produce (see ImportWizardClient's `!quickEntryOnly`-
+ // gated shape picker / back-to-choose affordances) — may be authorized by
+ // can_manage_imports OR can_manage_stats. Every other shape (season_totals,
+ // event_log, or omitted/undefined) keeps the ORIGINAL can_manage_imports-only
+ // gate, so stats-only staff gain NOTHING beyond the historical box-score path.
+ // `args.dataShape` (CommitImportArgs.dataShape) is gated on here via the SAME
+ // field applyImportPlan below uses to decide canonical-table routing — there
+ // is no separate, more "server-trusted" value to derive it from at commit
+ // time (no staged/server-persisted preview precedes a direct commit; see
+ // reviewImportRun for the ONE flow that does persist it, which stays fully
+ // can_manage_imports-gated below, unchanged). Using the identical binding for
+ // both the auth decision and the write decision means they can never diverge:
+ // a caller cannot claim 'game_box_score' to unlock the OR-gate while having
+ // the season_totals/event_log canonical write actually apply — that write
+ // branch only runs when this SAME field says 'season_totals'/'event_log',
+ // which requires the (unrelaxed) can_manage_imports gate to have passed.
+ requiredCapability: (a: CommitImportArgs) => importCapabilityForShape(a),
teamFrom: (a: CommitImportArgs) => a.teamId,
},
async (ctx, args: CommitImportArgs): Promise => {
diff --git a/src/components/baseball/command-center/CommandCenterFairway.tsx b/src/components/baseball/command-center/CommandCenterFairway.tsx
index ad8b54bb2..8d8140406 100644
--- a/src/components/baseball/command-center/CommandCenterFairway.tsx
+++ b/src/components/baseball/command-center/CommandCenterFairway.tsx
@@ -244,6 +244,13 @@ export function CommandCenterFairway({
/>
) : null}
}>
+ {/* Wizard consolidation: /stats/upload now branches on
+ capability — can_manage_imports staff land in the full
+ Import Center, can_manage_stats-only staff (assistant/
+ pitching/hitting/catching/defensive/strength coach) get the
+ same wizard's quick-box-score entry point inline. Linking
+ straight to Import Center here would lock the latter group
+ out entirely (it gates on can_manage_imports). */}
Upload stats
>
diff --git a/src/components/baseball/import-center/ImportCenterShell.tsx b/src/components/baseball/import-center/ImportCenterShell.tsx
index 86d194c80..d19126cf5 100644
--- a/src/components/baseball/import-center/ImportCenterShell.tsx
+++ b/src/components/baseball/import-center/ImportCenterShell.tsx
@@ -28,6 +28,7 @@ import { EventImportWizard } from '@/components/baseball/import-center/EventImpo
import type { BaseballImportRunRow } from '@/lib/types/baseball-imports';
import type { BaseballSourceKey } from '@/lib/types/baseball-stat-events';
import type { BaseballSourceTrustLevel } from '@/lib/types/baseball-settings';
+import type { BaseballStatUpload } from '@/lib/types';
import type { MatchablePlayer } from '@/lib/baseball/import-matching';
// The shell carries the full matcher shape (id/name + jersey/grad_year/position)
@@ -54,6 +55,13 @@ interface Props {
teamName: string;
players: RosterPlayer[];
recentRuns: BaseballImportRunRow[];
+ /**
+ * Historical rows from the pre-consolidation flat-upload path
+ * (baseball_stat_uploads) — no longer written to, but still the record of
+ * every upload made before this wizard consolidation. Rendered as a
+ * read-only "Legacy uploads" section inside the box-score wizard.
+ */
+ legacyUploads?: BaseballStatUpload[];
eventSources: Array<{ key: BaseballSourceKey; label: string; accept: string }>;
registeredSources: RegisteredSourceOption[];
}
@@ -70,6 +78,7 @@ export function ImportCenterShell({
teamName,
players,
recentRuns,
+ legacyUploads,
eventSources,
registeredSources,
}: Props) {
@@ -122,6 +131,7 @@ export function ImportCenterShell({
teamName={teamName}
players={players}
recentRuns={recentRuns}
+ legacyUploads={legacyUploads}
registeredSources={registeredSources}
showHeader={false}
onRequestEventLevel={() => setMode('event_level')}
diff --git a/src/components/baseball/import-center/ImportWizardClient.tsx b/src/components/baseball/import-center/ImportWizardClient.tsx
index e3ae46367..04e196401 100644
--- a/src/components/baseball/import-center/ImportWizardClient.tsx
+++ b/src/components/baseball/import-center/ImportWizardClient.tsx
@@ -81,6 +81,7 @@ import type {
BaseballImportRowDuplicate,
BaseballImportDuplicateVerdict,
} from '@/lib/types/baseball-imports';
+import type { BaseballStatUpload } from '@/lib/types';
import { isBinaryFileName } from '@/lib/baseball/adapters/import-file-body';
import { xlsxToCsv } from '@/lib/baseball/adapters/xlsx-reader';
@@ -177,6 +178,14 @@ interface Props {
teamName: string;
players: RosterPlayer[];
recentRuns: BaseballImportRunRow[];
+ /**
+ * Historical rows from the pre-consolidation flat-upload path
+ * (baseball_stat_uploads) — the table the retired UploadHistory component
+ * used to read. No longer written to by any in-app UI, but still the only
+ * record of every upload made before this wizard consolidation. Rendered
+ * read-only, below "Recent imports". Defaults to an empty list.
+ */
+ legacyUploads?: BaseballStatUpload[];
/**
* Box-score source options = hardcoded adapter defaults MERGED with the team's
* registered import-source policy. Falls back to the adapter defaults when the
@@ -195,6 +204,20 @@ interface Props {
* The shell intercepts this to switch to the "Event level" wizard tab.
*/
onRequestEventLevel?: () => void;
+ /**
+ * CAPABILITY LOCKOUT FIX — when true, skip the "choose" data-shape step
+ * entirely and land the coach straight on the "Quick box score" upload step
+ * (game_box_score preselected), with no way back to the full shape picker.
+ * Used ONLY for staff who hold can_manage_stats but not can_manage_imports
+ * (assistant/pitching/hitting/catching/defensive/strength coach — every
+ * default staff role preset that manages stats without also managing
+ * imports): they reach this SAME audited wizard/commit pipeline through the
+ * quick-box-score entry point at /dashboard/stats/upload, while the full
+ * Import Center (event-level mode, source registry, other data shapes,
+ * rollback) stays reserved for can_manage_imports staff. Defaults to false
+ * so every existing call site (the full Import Center) is unaffected.
+ */
+ quickEntryOnly?: boolean;
}
const TRUST_LABEL: Record = {
@@ -250,9 +273,11 @@ export function ImportWizardClient({
teamName,
players,
recentRuns,
+ legacyUploads = [],
registeredSources,
showHeader = true,
onRequestEventLevel,
+ quickEntryOnly = false,
}: Props) {
const { addToast } = useToast();
@@ -273,10 +298,21 @@ export function ImportWizardClient({
[registeredSources]
);
- const [step, setStep] = useState('choose');
+ // quickEntryOnly staff have no "choose" step to land on — they arrive
+ // pre-committed to the box-score shape via the quick-box-score entry point.
+ const [step, setStep] = useState(quickEntryOnly ? 'upload' : 'choose');
const [dataShape, setDataShape] = useState('game_box_score');
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
+ /**
+ * QUICK BOX SCORE — the legacy /dashboard/stats/upload wizard's headline
+ * capability was "drag a CSV in and go" with zero setup. Ported here as a
+ * dropzone-drag-over affordance on the SAME upload step every path uses, so
+ * the fast path still runs through the canonical, audited commit pipeline
+ * (dedup, provenance, rollback) instead of a parallel shortcut that could
+ * drift from it.
+ */
+ const [isDragging, setIsDragging] = useState(false);
const [sourceId, setSourceId] = useState('generic_csv');
const [fileName, setFileName] = useState('');
@@ -346,6 +382,22 @@ export function ImportWizardClient({
reader.readAsText(file);
}, []);
+ // ---- quick box score: drag-and-drop onto the dropzone ----------------------
+ const onDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(true);
+ }, []);
+ const onDragLeave = useCallback(() => setIsDragging(false), []);
+ const onDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ const file = e.dataTransfer.files[0];
+ if (file) onFile(file);
+ },
+ [onFile]
+ );
+
// ---- run preview (detect + map + match) ------------------------------------
const runPreview = useCallback(async () => {
if (!csvContent.trim()) {
@@ -357,12 +409,18 @@ export function ImportWizardClient({
try {
// Pass the targeted grain so the preview can detect EXISTING rows and return
// the per-row create/update/skip verdict BEFORE commit (duplicate_resolution_v2.md).
+ // ROUND-4 FIX (#863) — also pass `dataShape`: the server's capability gate
+ // relaxes to can_manage_imports OR can_manage_stats ONLY for a
+ // 'game_box_score' preview (quickEntryOnly's one reachable shape); a
+ // stats-only coach previewing without this field would still be blocked
+ // here even though commit would have authorized them.
const result = await previewImport({
teamId,
sourceId,
csvContent,
statType,
sessionDate,
+ dataShape,
});
if (result.totalRows === 0) {
setError('No data rows found in that file.');
@@ -377,7 +435,7 @@ export function ImportWizardClient({
} finally {
setBusy(false);
}
- }, [csvContent, sourceId, teamId, statType, sessionDate]);
+ }, [csvContent, sourceId, teamId, statType, sessionDate, dataShape]);
// ---- per-row manual match override -----------------------------------------
const setRowPlayer = useCallback(
@@ -697,7 +755,7 @@ export function ImportWizardClient({
);
const resetWizard = useCallback(() => {
- setStep('choose');
+ setStep(quickEntryOnly ? 'upload' : 'choose');
setDataShape('game_box_score');
setPreview(null);
setMatches([]);
@@ -706,7 +764,7 @@ export function ImportWizardClient({
setFileName('');
setError(null);
setWarningsAcknowledged(false);
- }, []);
+ }, [quickEntryOnly]);
// ---- render ----------------------------------------------------------------
// 'choose' and 'committing' are not in the visible stepper.
@@ -741,30 +799,37 @@ export function ImportWizardClient({
control keeps its compact ~19x19px footprint beside the small
InkBadge stamp on desktop/tablet — swapping in IconButton
unconditionally grew it to 36px there too, an unintended
- visual change next to an ~18px badge. */}
- {/* eslint-disable-next-line helm/no-raw-button */}
- { setStep('choose'); setError(null); }}
- className={cn(
- 'inline-flex items-center justify-center rounded-full p-1',
- '[@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11',
- pressableClass({ ink: 'team' }),
- )}
- >
- { setStep('choose'); setError(null); }}
+ className={cn(
+ 'inline-flex items-center justify-center rounded-full p-1',
+ '[@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11',
+ pressableClass({ ink: 'team' }),
+ )}
>
-
-
-
+
+
+
+
+ )}
{VISIBLE_STEP_ORDER.map((s) => {
@@ -806,6 +871,40 @@ export function ImportWizardClient({
table and dedup model.
+
+ {/* QUICK BOX SCORE — the fast, zero-config entry point that ports the
+ legacy stats/upload wizard's headline capability (drag a CSV in and
+ go) onto the SAME canonical pipeline as every other path here
+ (atomic RPC write, provenance, dedup, rollback — nothing skipped). */}
+ {/* eslint-disable-next-line helm/no-raw-button */}
+ {
+ setDataShape('game_box_score');
+ setStatType('game');
+ setStep('upload');
+ }}
+ className={cn(
+ 'flex w-full items-center justify-between gap-3 rounded-card border px-5 py-4 text-left',
+ pressableClass({ ink: 'team', lift: true }),
+ 'border-grade-plus/40 bg-grade-plus/[0.05]',
+ )}
+ >
+
+
+ Quick box score
+
+
+ One game, right now — drag a CSV in and go. Same audited pipeline, fewer
+ questions.
+
+
+
+
+
+
+ Or pick the exact data shape:
+
{(Object.values(DATA_SHAPE_META) as DataShapeMeta[]).map((meta) => (
// A bespoke Living Annual paper tile, not a CTA — pressableClass
@@ -965,7 +1064,17 @@ export function ImportWizardClient({
/>
-
+
{/* Hidden native file input — the primitive does not
support type=file; this is a visually-hidden field driving
the styled dropzone label. */}
@@ -980,10 +1089,10 @@ export function ImportWizardClient({
}}
/>
- {fileName ? fileName : 'Choose a CSV or Excel file'}
+ {fileName ? fileName : isDragging ? 'Drop it in' : 'Choose a CSV or Excel file'}
- CSV or .xlsx — a header row + one row per player.
+ {fileName ? 'Drag a different file in, or click to browse.' : 'Drag and drop, or click to browse — a header row + one row per player.'}
@@ -1008,10 +1117,18 @@ export function ImportWizardClient({
-
-
setStep('choose')}>
- Back
-
+ {/* ROUND-4 FIX (#863) — quickEntryOnly staff have no "choose" step to
+ return to (same reasoning as the pencil affordance above): the
+ 'choose' step renders the FULL shape picker Import Center
+ reserves for can_manage_imports staff, so a Back button that
+ routes there must not exist for a can_manage_stats-only viewer
+ even though this Upload step itself is reachable for them. */}
+
+ {!quickEntryOnly && (
+ setStep('choose')}>
+ Back
+
+ )}
Analyze file
@@ -1041,6 +1158,45 @@ export function ImportWizardClient({
))}
+
+ {/* DATA PREVIEW — ports the legacy stats/upload wizard's "see your
+ actual rows before mapping" capability so a coach can confirm the
+ file parsed correctly (right columns, no garbled values) before
+ committing to a column mapping. */}
+ {preview.rows.length > 0 && (
+
+
+
+
+
+ {preview.headers.slice(0, 6).map((h) => (
+
+ {h}
+
+ ))}
+
+
+
+ {preview.rows.slice(0, 3).map((row, i) => (
+
+ {preview.headers.slice(0, 6).map((h) => (
+
+ {row[h] || '—'}
+
+ ))}
+
+ ))}
+
+
+
+ {preview.rows.length > 3 && (
+
+ +{preview.rows.length - 3} more row{preview.rows.length - 3 === 1 ? '' : 's'}
+
+ )}
+
+ )}
+
setStep('upload')}
onNext={() => setStep('map')}
@@ -1540,6 +1696,61 @@ export function ImportWizardClient({
)}
+
+ {/* LEGACY UPLOAD HISTORY --------------------------------------------
+ baseball_stat_uploads — the pre-consolidation flat-upload path's
+ record. Nothing writes to this table anymore (uploadStatsCSV lost
+ its only caller when the standalone stats-upload wizard was
+ retired), but rows from before this consolidation still exist and
+ had NO viewing surface once UploadHistory.tsx was retired with it.
+ Read-only: rollback/review are Import Center concepts that don't
+ apply to this table's rows. */}
+
+ Legacy uploads
+
+
+ {legacyUploads.length === 0 ? (
+
+ ) : (
+
+
+
+
+
+ File
+
+ Rows
+ Status
+ Uploaded
+
+
+
+ {legacyUploads.map((u) => (
+
+
+ {u.filename}
+
+
+ {u.matched_rows}/{u.total_rows}
+
+
+
+
+
+ {u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'}
+
+
+ ))}
+
+
+
+ )}
+
+
);
}
@@ -1966,3 +2177,19 @@ function RunStatus({ status }: { status: string }) {
const meta = RUN_STATUS_META[status] ?? { tone: 'neutral' as const, variant: 'soft' as const };
return ;
}
+
+// baseball_stat_uploads.status is a distinct enum from baseball_import_runs'
+// (BaseballUploadStatus: pending | processing | completed | failed |
+// needs_review) — its own meta map rather than overloading RUN_STATUS_META.
+const LEGACY_UPLOAD_STATUS_META: Record = {
+ completed: { tone: 'team', variant: 'soft' },
+ failed: { tone: 'sodium', variant: 'solid' },
+ needs_review: { tone: 'sodium', variant: 'soft' },
+ pending: { tone: 'neutral', variant: 'soft' },
+ processing: { tone: 'neutral', variant: 'soft' },
+};
+
+function LegacyUploadStatus({ status }: { status: string }) {
+ const meta = LEGACY_UPLOAD_STATUS_META[status] ?? { tone: 'neutral' as const, variant: 'soft' as const };
+ return ;
+}
diff --git a/src/components/baseball/import-center/__tests__/ImportWizardClient.quick-box-score.test.tsx b/src/components/baseball/import-center/__tests__/ImportWizardClient.quick-box-score.test.tsx
new file mode 100644
index 000000000..cce9fc17f
--- /dev/null
+++ b/src/components/baseball/import-center/__tests__/ImportWizardClient.quick-box-score.test.tsx
@@ -0,0 +1,162 @@
+// =============================================================================
+// ImportWizardClient — "Quick box score" + drag-and-drop + data preview.
+//
+// WIZARD CONSOLIDATION (stats/upload -> Import Center): the legacy
+// StatsUploadClient wizard offered two things Import Center's flow did not —
+// (1) a drag-and-drop dropzone (click-to-browse only, before this change) and
+// (2) a visible preview of the CSV's actual sample values before the coach
+// commits to a column mapping. Both are ported here as first-class parts of
+// the SAME canonical pipeline (no parallel write path, no capability lost).
+// This spec pins that port so it can't silently regress.
+// =============================================================================
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+
+import type { ImportPreview } from '@/app/baseball/actions/imports';
+import type { BaseballImportRowMatch } from '@/lib/types/baseball-imports';
+
+const mocks = vi.hoisted(() => ({
+ previewImport: vi.fn(),
+ commitImport: vi.fn(),
+ rollbackImport: vi.fn(),
+ reviewImportRun: vi.fn(),
+ getImportRunFileUrl: vi.fn(),
+}));
+
+vi.mock('@/app/baseball/actions/imports', () => ({
+ previewImport: mocks.previewImport,
+ commitImport: mocks.commitImport,
+ rollbackImport: mocks.rollbackImport,
+ reviewImportRun: mocks.reviewImportRun,
+ getImportRunFileUrl: mocks.getImportRunFileUrl,
+}));
+
+import { ImportWizardClient } from '../ImportWizardClient';
+
+const PLAYERS = [
+ { id: 'p1', first_name: 'Jane', last_name: 'Doe', jersey_number: 12, grad_year: 2027, primary_position: 'SS' },
+];
+
+function makePreview(overrides: Partial = {}): ImportPreview {
+ const matches: BaseballImportRowMatch[] = [
+ {
+ rowIndex: 0,
+ sourceName: 'Jane Doe',
+ playerId: 'p1',
+ playerName: 'Jane Doe',
+ confidence: 1,
+ matchTier: 'exact_roster',
+ isManualMatch: false,
+ action: 'update',
+ },
+ ];
+ return {
+ sourceId: 'generic_csv',
+ sourceLabel: 'Generic CSV',
+ detectedSourceId: 'generic_csv',
+ headers: ['player', 'ab', 'h'],
+ mapping: { player_name: 'player', at_bats: 'ab', hits: 'h' },
+ matches,
+ totalRows: 1,
+ matchedRows: 1,
+ unmatchedRows: 0,
+ rows: [{ player: 'Jane Doe', ab: '4', h: '2' }],
+ validation: {
+ issues: [],
+ blockingCount: 0,
+ warningCount: 0,
+ infoCount: 0,
+ blockingRowIndices: [],
+ hasBlockers: false,
+ hasWarnings: false,
+ },
+ policy: {
+ registered: false,
+ trustLevel: 'unreviewed',
+ defaultVisibility: 'staff_only',
+ requiredReview: false,
+ dedupeStrictness: 'standard',
+ playerMatchStrategy: 'name_then_external_id',
+ externalIdNamespace: null,
+ },
+ duplicates: [],
+ ...overrides,
+ };
+}
+
+function makeCsvFile(name = 'sample.csv'): File {
+ const csv = 'player,ab,h\nJane Doe,4,2\n';
+ return new File([csv], name, { type: 'text/csv' });
+}
+
+describe('ImportWizardClient — Quick box score entry point', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('jumps straight to the Upload step with the box-score shape preselected, skipping the choose ceremony', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Quick box score')).toBeInTheDocument();
+ fireEvent.click(screen.getByText('Quick box score'));
+
+ // The upload step's context strip stamps the active data shape (it renders
+ // twice — the shape badge plus the data-shape summary further down — so
+ // assert at least one is present rather than pinning an exact count).
+ expect(screen.getAllByText('Game box score').length).toBeGreaterThan(0);
+ expect(screen.getByText(/Choose a CSV or Excel file|Drop it in/)).toBeInTheDocument();
+ });
+
+ it('accepts a dropped file on the dropzone (not just click-to-browse)', async () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Quick box score'));
+
+ const dropzone = screen.getByText(/Choose a CSV or Excel file/).closest('label');
+ expect(dropzone).not.toBeNull();
+
+ const file = makeCsvFile();
+ fireEvent.drop(dropzone!, { dataTransfer: { files: [file] } });
+
+ await waitFor(() => {
+ expect(screen.getByText('sample.csv')).toBeInTheDocument();
+ });
+
+ // Dropping a file enables the same "Analyze file" action the click-to-
+ // browse path uses — one canonical pipeline, not a shortcut around it.
+ const analyzeButton = screen.getByRole('button', { name: 'Analyze file' });
+ expect(analyzeButton).not.toBeDisabled();
+ });
+
+ it('shows a sample-values data preview before the coach commits to a column mapping', async () => {
+ mocks.previewImport.mockResolvedValue(makePreview());
+
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Quick box score'));
+
+ const dropzone = screen.getByText(/Choose a CSV or Excel file/).closest('label');
+ fireEvent.drop(dropzone!, { dataTransfer: { files: [makeCsvFile()] } });
+ await waitFor(() => screen.getByText('sample.csv'));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Analyze file' }));
+
+ await waitFor(() => {
+ expect(mocks.previewImport).toHaveBeenCalled();
+ });
+
+ // The actual row values from the parsed file are visible, not just the
+ // header names — this is what the legacy wizard's "Data Preview" table
+ // gave a coach that Import Center's detect step didn't show before.
+ await waitFor(() => {
+ expect(screen.getByText('Jane Doe')).toBeInTheDocument();
+ });
+ expect(screen.getByText('4')).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/baseball/stats-center/StatsCenterClient.tsx b/src/components/baseball/stats-center/StatsCenterClient.tsx
index 39787509a..9266f27e8 100644
--- a/src/components/baseball/stats-center/StatsCenterClient.tsx
+++ b/src/components/baseball/stats-center/StatsCenterClient.tsx
@@ -29,7 +29,7 @@ import { LazyMotion, m, useReducedMotion } from 'framer-motion';
import { loadFeatures } from '@/lib/motion/load-features';
import { Button } from '@/components/ui/button';
-import { IconDownload, IconFilter, IconFolder, IconUpload, IconX } from '@/components/icons';
+import { IconDownload, IconFilter, IconFolder, IconX } from '@/components/icons';
import { cn } from '@/lib/utils';
import { loadStatsCenter } from '@/app/baseball/actions/games';
// V10 stat-visual chart gallery (stat-visuals packet). Mounted at team scope; it
@@ -80,6 +80,17 @@ interface StatsCenterClientProps {
* renders its truthful "no captured events" frames.
*/
statVisualsData?: StatVisualsData;
+ /**
+ * Whether the viewer holds can_manage_imports (computed server-side by the
+ * page). Decides where the two import entry points route: import-capable
+ * staff go straight to the full Import Center (whose middleware gate is
+ * can_manage_imports), everyone else through the capability-aware
+ * /stats/upload shim (middleware gate: can_manage_stats). Sending
+ * import-capable-but-not-stats staff (e.g. the director_ops preset) through
+ * the shim would bounce them off its can_manage_stats middleware gate
+ * before the shim's own capability branch ever ran.
+ */
+ canManageImports?: boolean;
}
/** Which game-set the wall currently shows. */
@@ -455,7 +466,12 @@ function SegmentedControl({
// Main client
// -----------------------------------------------------------------------------
-export function StatsCenterClient({ model: initialModel, initialFilters, statVisualsData }: StatsCenterClientProps) {
+export function StatsCenterClient({
+ model: initialModel,
+ initialFilters,
+ statVisualsData,
+ canManageImports = false,
+}: StatsCenterClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const reducedMotion = useReducedMotion() ?? false;
@@ -632,27 +648,37 @@ export function StatsCenterClient({ model: initialModel, initialFilters, statVis
.filter(Boolean)
.join(' · ');
+ // Both import entry points (header action + empty-state CTA) share one
+ // capability-resolved destination — see the canManageImports prop doc.
+ const importEntryHref = canManageImports
+ ? '/baseball/dashboard/import'
+ : '/baseball/dashboard/stats/upload';
+
const mastheadActions = (
- {/* Ruling 2 (item 2): Upload + Import Center folded off the hub sub-nav
- strip into persistent header-level actions here — Stats Center is
- now the ONLY place they're reachable from the Stats & Performance
- hub (the empty-state "Import a box score" CTA below stays too, for
- the exact moment it's most useful). */}
-
}
- onClick={() => router.push('/baseball/dashboard/stats/upload')}
- >
- Upload
-
+ {/* Ruling 2 (item 2): Import Center folded off the hub sub-nav strip into
+ a persistent header-level action here — Stats Center is now the
+ ONLY place it's reachable from the Stats & Performance hub (the
+ empty-state "Import a box score" CTA below stays too, for the exact
+ moment it's most useful).
+ Wizard consolidation: the standalone "Upload" button that used to
+ sit beside this one (routing to the retired /stats/upload wizard)
+ is gone — that wizard is now the SAME entry point ("Quick box
+ score"). The destination branches on the viewer's capability
+ (importEntryHref): can_manage_imports staff go STRAIGHT to the full
+ Import Center — routing them through the /stats/upload shim would
+ bounce anyone without can_manage_stats (e.g. director_ops) off that
+ route's own middleware gate — while everyone else goes through the
+ shim, which renders the quick-box-score wizard inline for
+ can_manage_stats-only staff (assistant/pitching/hitting/catching/
+ defensive/strength coach) instead of locking them out on Import
+ Center's can_manage_imports gate. */}
}
- onClick={() => router.push('/baseball/dashboard/import')}
+ onClick={() => router.push(importEntryHref)}
>
Import Center
@@ -835,7 +861,7 @@ export function StatsCenterClient({ model: initialModel, initialFilters, statVis
router.push('/baseball/dashboard/import')}>
+ router.push(importEntryHref)}>
Import a box score
}
diff --git a/src/components/baseball/stats/StatsUploadClient.tsx b/src/components/baseball/stats/StatsUploadClient.tsx
deleted file mode 100644
index c7e4bb346..000000000
--- a/src/components/baseball/stats/StatsUploadClient.tsx
+++ /dev/null
@@ -1,1263 +0,0 @@
-'use client';
-
-import { useState, useCallback, useEffect, useMemo } from 'react';
-import { useRouter } from 'next/navigation';
-import Link from 'next/link';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Select } from '@/components/ui/select';
-import { useToast } from '@/components/ui/sonner';
-import {
- IconUpload,
- IconFile,
- IconCheck,
- IconX,
- IconArrowLeft,
- IconAlertCircle,
- IconChevronRight,
- IconChevronDown,
- IconSearch,
- IconClock,
- IconSettings,
- IconRefresh,
-} from '@/components/icons';
-import { uploadStatsCSV } from '@/app/baseball/actions/stats';
-import { PaperCard, EditorsLetter, InkNotice } from '@/components/baseball/living-annual';
-import {
- parseCSV,
- findBestPlayerMatch,
- HEADER_MAPPINGS,
- type PlayerMatch,
-} from '@/lib/baseball/csv-utils';
-import { UploadHistory } from './UploadHistory';
-
-// ============================================================================
-// TYPES
-// ============================================================================
-
-interface StatsUploadClientProps {
- teamId: string;
- teamName: string;
- players: Array<{ id: string; firstName: string; lastName: string }>;
-}
-
-type Step = 'upload' | 'preview' | 'columns' | 'match' | 'configure' | 'processing' | 'complete';
-
-interface ColumnMapping {
- csvColumn: string;
- mappedTo: string | null;
- sampleValues: string[];
-}
-
-// Stat fields that can be mapped
-const STAT_FIELDS = [
- { key: 'player_name', label: 'Player Name', required: true },
- { key: 'at_bats', label: 'At Bats (AB)', required: false },
- { key: 'hits', label: 'Hits (H)', required: false },
- { key: 'doubles', label: 'Doubles (2B)', required: false },
- { key: 'triples', label: 'Triples (3B)', required: false },
- { key: 'home_runs', label: 'Home Runs (HR)', required: false },
- { key: 'rbis', label: 'RBIs', required: false },
- { key: 'walks', label: 'Walks (BB)', required: false },
- { key: 'strikeouts', label: 'Strikeouts (K)', required: false },
- { key: 'stolen_bases', label: 'Stolen Bases (SB)', required: false },
- { key: 'exit_velocity', label: 'Exit Velocity', required: false },
- { key: 'launch_angle', label: 'Launch Angle', required: false },
-] as const;
-
-// ============================================================================
-// HELPER FUNCTIONS
-// ============================================================================
-
-/**
- * Auto-detect column mappings based on header names
- */
-function autoDetectMappings(headers: string[]): Record {
- const mappings: Record = {};
-
- for (const header of headers) {
- const normalized = header.toLowerCase().replace(/[^a-z0-9]/g, '');
-
- for (const [fieldKey, aliases] of Object.entries(HEADER_MAPPINGS)) {
- for (const alias of aliases) {
- if (normalized.includes(alias) || alias.includes(normalized)) {
- mappings[header] = fieldKey;
- break;
- }
- }
- if (mappings[header]) break;
- }
- }
-
- return mappings;
-}
-
-/**
- * Get fuzzy suggestions for an unmatched player name
- */
-function getPlayerSuggestions(
- csvName: string,
- players: Array<{ id: string; firstName: string; lastName: string }>,
- limit = 3
-): Array<{ player: { id: string; firstName: string; lastName: string }; confidence: number }> {
- const suggestions: Array<{
- player: { id: string; firstName: string; lastName: string };
- confidence: number;
- }> = [];
-
- for (const player of players) {
- const match = findBestPlayerMatch(csvName, [
- { id: player.id, first_name: player.firstName, last_name: player.lastName },
- ]);
- suggestions.push({
- player,
- confidence: match.confidence,
- });
- }
-
- return suggestions
- .sort((a, b) => b.confidence - a.confidence)
- .slice(0, limit);
-}
-
-// ============================================================================
-// COMPONENT
-// ============================================================================
-
-export function StatsUploadClient({
- teamId,
- teamName,
- players,
-}: StatsUploadClientProps) {
- const router = useRouter();
- const { addToast } = useToast();
-
- // Core state
- const [step, setStep] = useState('upload');
- const [csvContent, setCsvContent] = useState('');
- const [fileName, setFileName] = useState('');
- const [statType, setStatType] = useState<'practice' | 'game' | 'other'>('practice');
- const [sessionDate, setSessionDate] = useState(
- new Date().toISOString().split('T')[0]!
- );
- const [sessionName, setSessionName] = useState('');
- const [parsedRows, setParsedRows] = useState>>([]);
- const [playerMatches, setPlayerMatches] = useState([]);
- const [uploadResult, setUploadResult] = useState<{
- success: boolean;
- matchedRows?: number;
- unmatchedRows?: number;
- unmatchedNames?: string[];
- error?: string;
- } | null>(null);
-
- // UI state
- const [isDragging, setIsDragging] = useState(false);
- const [showHistory, setShowHistory] = useState(false);
- const [isUploading, setIsUploading] = useState(false);
- const [playerSearchQuery, setPlayerSearchQuery] = useState('');
- const [expandedUnmatchedPlayer, setExpandedUnmatchedPlayer] = useState(null);
-
- // Column mapping state
- const [columnMappings, setColumnMappings] = useState([]);
-
- // ============================================================================
- // FILE HANDLING
- // ============================================================================
-
- const handleFileSelect = useCallback(
- (file: File) => {
- const reader = new FileReader();
- reader.onload = (e) => {
- const content = e.target?.result as string;
- setCsvContent(content);
- setFileName(file.name);
-
- // Parse and preview
- const rows = parseCSV(content);
- setParsedRows(rows);
-
- if (rows.length === 0) {
- addToast({
- type: 'error',
- title: 'Invalid CSV',
- description: 'No valid data found in the file',
- });
- return;
- }
-
- // Build column mappings with sample values
- const headers = Object.keys(rows[0]!);
- const autoMappings = autoDetectMappings(headers);
-
- const mappings: ColumnMapping[] = headers.map((header) => ({
- csvColumn: header,
- mappedTo: autoMappings[header] || null,
- sampleValues: rows.slice(0, 3).map((r) => r[header] || '-'),
- }));
-
- setColumnMappings(mappings);
-
- setStep('preview');
- };
- reader.readAsText(file);
- },
- [addToast]
- );
-
- const handleDrop = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault();
- setIsDragging(false);
-
- const file = e.dataTransfer.files[0];
- if (file && file.name.endsWith('.csv')) {
- handleFileSelect(file);
- } else {
- addToast({
- type: 'error',
- title: 'Invalid file',
- description: 'Please upload a CSV file',
- });
- }
- },
- [handleFileSelect, addToast]
- );
-
- const handleFileInput = useCallback(
- (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (file) {
- handleFileSelect(file);
- }
- },
- [handleFileSelect]
- );
-
- // ============================================================================
- // COLUMN MAPPING
- // ============================================================================
-
- const handleColumnMappingChange = useCallback(
- (csvColumn: string, mappedTo: string | null) => {
- setColumnMappings((prev) =>
- prev.map((m) => (m.csvColumn === csvColumn ? { ...m, mappedTo } : m))
- );
- },
- []
- );
-
- const playerNameColumn = useMemo(() => {
- const mapping = columnMappings.find((m) => m.mappedTo === 'player_name');
- return mapping?.csvColumn || null;
- }, [columnMappings]);
-
- const mappedStatColumns = useMemo(() => {
- return columnMappings.filter(
- (m) => m.mappedTo && m.mappedTo !== 'player_name'
- );
- }, [columnMappings]);
-
- // ============================================================================
- // PLAYER MATCHING
- // ============================================================================
-
- const runPlayerMatching = useCallback(() => {
- if (!playerNameColumn || parsedRows.length === 0) return;
-
- const dbPlayers = players.map((p) => ({
- id: p.id,
- first_name: p.firstName,
- last_name: p.lastName,
- }));
-
- const uniqueNames = [
- ...new Set(parsedRows.map((r) => r[playerNameColumn]).filter(Boolean)),
- ];
-
- const matches = uniqueNames.map((name) =>
- findBestPlayerMatch(name as string, dbPlayers)
- );
-
- setPlayerMatches(matches);
- }, [playerNameColumn, parsedRows, players]);
-
- // Run player matching when moving to match step
- useEffect(() => {
- if (step === 'match' || step === 'preview') {
- runPlayerMatching();
- }
- }, [step, runPlayerMatching]);
-
- const handleManualAssignment = useCallback(
- (csvName: string, playerId: string | null) => {
- if (!playerId) {
- // Skip this player - mark with isManualMatch but no playerId
- setPlayerMatches((prev) =>
- prev.map((match) =>
- match.csvName === csvName
- ? { ...match, playerId: null, playerName: null, confidence: 0, isManualMatch: true }
- : match
- )
- );
- setExpandedUnmatchedPlayer(null);
- return;
- }
-
- const player = players.find((p) => p.id === playerId);
- setPlayerMatches((prev) =>
- prev.map((match) => {
- if (match.csvName === csvName) {
- return {
- ...match,
- playerId,
- playerName: player ? `${player.firstName} ${player.lastName}` : '',
- confidence: 1.0, // Manual match = 100%
- isManualMatch: true,
- };
- }
- return match;
- })
- );
-
- // Collapse the expanded section
- setExpandedUnmatchedPlayer(null);
- },
- [players]
- );
-
- // Filter players based on search
- const filteredPlayers = useMemo(() => {
- if (!playerSearchQuery.trim()) return players;
- const query = playerSearchQuery.toLowerCase();
- return players.filter(
- (p) =>
- p.firstName.toLowerCase().includes(query) ||
- p.lastName.toLowerCase().includes(query) ||
- `${p.firstName} ${p.lastName}`.toLowerCase().includes(query)
- );
- }, [players, playerSearchQuery]);
-
- const goodMatches = playerMatches.filter((m) => m.confidence >= 0.7);
- const poorMatches = playerMatches.filter(
- (m) => m.confidence < 0.7 && !m.isManualMatch
- );
-
- // ============================================================================
- // UPLOAD
- // ============================================================================
-
- const handleUpload = async () => {
- setStep('processing');
- setIsUploading(true);
-
- try {
- // Carry the coach's Map-Columns + Match-Players edits into the server
- // action so overrides made in those steps are actually honored instead
- // of being silently re-derived (and discarded) server-side.
- const columnMappingsPayload: Record = Object.fromEntries(
- columnMappings.map((m) => [m.csvColumn, m.mappedTo])
- );
-
- const result = await uploadStatsCSV(
- teamId,
- csvContent,
- statType,
- sessionDate,
- sessionName || undefined,
- columnMappingsPayload,
- playerMatches
- );
-
- setUploadResult(result);
- setStep('complete');
-
- if (result.success) {
- addToast({
- type: 'success',
- title: 'Stats uploaded',
- description: `${result.matchedRows} players matched successfully`,
- });
- } else {
- addToast({
- type: 'error',
- title: 'Upload failed',
- description: result.error || 'An error occurred',
- });
- }
- } catch {
- setUploadResult({
- success: false,
- error: 'An unexpected error occurred',
- });
- setStep('complete');
- addToast({
- type: 'error',
- title: 'Upload failed',
- description: 'An unexpected error occurred',
- });
- } finally {
- setIsUploading(false);
- }
- };
-
- const resetUpload = useCallback(() => {
- setStep('upload');
- setCsvContent('');
- setFileName('');
- setParsedRows([]);
- setPlayerMatches([]);
- setColumnMappings([]);
- setUploadResult(null);
- setExpandedUnmatchedPlayer(null);
- setPlayerSearchQuery('');
- }, []);
-
- // ============================================================================
- // RENDER
- // ============================================================================
-
- return (
-
-
- {/* Header */}
-
-
-
-
-
-
-
- Upload Stats
-
-
{teamName}
-
-
-
setShowHistory(!showHistory)}
- className="gap-2"
- >
-
- History
-
-
-
-
- {/* Upload History (collapsible) */}
- {showHistory && (
-
-
-
- )}
-
- {/* Progress Steps */}
-
- {(
- [
- { key: 'upload', label: 'Upload' },
- { key: 'preview', label: 'Preview' },
- { key: 'columns', label: 'Map Columns' },
- { key: 'match', label: 'Match Players' },
- { key: 'configure', label: 'Configure' },
- { key: 'complete', label: 'Complete' },
- ] as const
- ).map((s, i, arr) => {
- const stepOrder = ['upload', 'preview', 'columns', 'match', 'configure', 'processing', 'complete'];
- const currentIndex = stepOrder.indexOf(step);
- const thisIndex = stepOrder.indexOf(s.key);
- const isActive = step === s.key || (step === 'processing' && s.key === 'configure');
- const isPast = thisIndex < currentIndex;
-
- return (
-
-
- {isPast ? : i + 1}
-
-
- {s.label}
-
- {i < arr.length - 1 && (
-
- )}
-
- );
- })}
-
-
- {/* Step: Upload */}
- {step === 'upload' && (
-
{
- e.preventDefault();
- setIsDragging(true);
- }}
- onDragLeave={() => setIsDragging(false)}
- onDrop={handleDrop}
- >
-
-
-
-
- Upload CSV File
-
-
- Drag and drop your stats CSV file here, or click to browse. We'll
- help you map columns and match player names.
-
-
-
-
- Choose File
-
-
-
-
- Supported Columns
-
-
- {[
- 'Player Name',
- 'AB',
- 'H',
- '2B',
- '3B',
- 'HR',
- 'RBI',
- 'BB',
- 'SO',
- 'SB',
- 'Exit Velo',
- ].map((col) => (
-
- {col}
-
- ))}
-
-
-
- )}
-
- {/* Step: Preview */}
- {step === 'preview' && (
-
- {/* File Info */}
-
-
-
-
-
-
-
{fileName}
-
- {parsedRows.length} rows • {columnMappings.length} columns
-
-
-
- Change File
-
-
-
-
- {/* Auto-detected Mappings Summary */}
-
-
-
-
- Auto-Detected Columns
-
-
-
- {playerNameColumn ? (
-
-
-
- Player name column: "{playerNameColumn}"
-
-
- ) : (
- // Advisory (not blocking): icon-less polite strip, visually
- // lighter than the Required alert in the validation block.
-
-
- Could not detect player name column. You'll need to map it
- manually.
-
-
- )}
-
- {mappedStatColumns.length > 0 && (
-
- {mappedStatColumns.map((col) => (
-
- {col.csvColumn} →{' '}
- {STAT_FIELDS.find((f) => f.key === col.mappedTo)?.label ||
- col.mappedTo}
-
- ))}
-
- )}
-
-
- {/* Data Preview */}
-
- Data Preview
-
-
-
-
- {parsedRows[0] &&
- Object.keys(parsedRows[0])
- .slice(0, 6)
- .map((header) => (
-
- {header.replace(/_/g, ' ')}
-
- ))}
-
-
-
- {parsedRows.slice(0, 5).map((row, i) => (
-
- {Object.values(row)
- .slice(0, 6)
- .map((val, j) => (
-
- {val || '-'}
-
- ))}
-
- ))}
-
-
- {parsedRows.length > 5 && (
-
- +{parsedRows.length - 5} more rows
-
- )}
-
-
-
-
-
- Back
-
- setStep('columns')} className="gap-1">
- Map Columns
-
-
-
-
- )}
-
- {/* Step: Column Mapping */}
- {step === 'columns' && (
-
-
-
- Map CSV Columns
-
-
- We auto-detected some columns. Review and adjust the mappings as
- needed.
-
-
-
- {columnMappings.map((mapping) => (
-
-
- {/* CSV Column Info */}
-
-
- {mapping.csvColumn}
-
-
- Sample: {mapping.sampleValues.join(', ')}
-
-
-
- {/* Arrow */}
-
-
-
-
- {/* Mapping Dropdown */}
-
-
- handleColumnMappingChange(
- mapping.csvColumn,
- value || null
- )
- }
- className={
- mapping.mappedTo === 'player_name'
- ? 'border-primary-300'
- : undefined
- }
- options={[
- { value: '', label: 'Skip this column' },
- ...STAT_FIELDS.map((field) => {
- const isUsed = columnMappings.some(
- (m) =>
- m.mappedTo === field.key &&
- m.csvColumn !== mapping.csvColumn
- );
- return {
- value: field.key,
- label: `${field.label}${field.required ? ' *' : ''}${isUsed ? ' (already mapped)' : ''}`,
- disabled: isUsed,
- };
- }),
- ]}
- />
-
-
-
- ))}
-
-
- {/* Validation */}
- {!playerNameColumn && (
-
-
- Required: Please map a column to "Player
- Name"
-
-
- )}
-
-
-
- setStep('preview')}>
- Back
-
- setStep('match')}
- disabled={!playerNameColumn}
- className="gap-1"
- >
- Match Players
-
-
-
-
- )}
-
- {/* Step: Match Players */}
- {step === 'match' && (
-
- {/* Good Matches Summary */}
- {goodMatches.length > 0 && (
-
-
-
-
-
-
-
- {goodMatches.length} Players Matched
-
-
- Automatically matched with high confidence
-
-
-
-
-
- {goodMatches.slice(0, 10).map((match) => (
-
- {match.csvName} → {match.playerName}
-
- ))}
- {goodMatches.length > 10 && (
-
- +{goodMatches.length - 10} more
-
- )}
-
-
-
- )}
-
- {/* Poor Matches - Need Review */}
- {poorMatches.length > 0 && (
-
-
-
-
-
-
-
- {poorMatches.length} Players Need Review
-
-
- Select the correct player or skip
-
-
-
-
-
- {poorMatches.map((match) => {
- const suggestions = getPlayerSuggestions(
- match.csvName,
- players,
- 5
- );
- const isExpanded = expandedUnmatchedPlayer === match.csvName;
-
- return (
-
-
- setExpandedUnmatchedPlayer(
- isExpanded ? null : match.csvName
- )
- }
- className="w-full flex items-center justify-between p-4 text-left"
- >
-
-
- {match.csvName}
-
-
- No automatic match found
-
-
-
-
-
- {isExpanded && (
-
- {/* Suggestions */}
- {suggestions.length > 0 && (
-
-
- Suggestions
-
-
- {suggestions.map((sug) => (
-
- handleManualAssignment(
- match.csvName,
- sug.player.id
- )
- }
- className="w-full flex items-center justify-between p-2 rounded-lg border border-warm-200 hover:border-primary-300 hover:bg-primary-50 transition-colors text-left"
- >
-
- {sug.player.firstName} {sug.player.lastName}
-
-
- {Math.round(sug.confidence * 100)}% match
-
-
- ))}
-
-
- )}
-
- {/* Search All Players */}
-
-
- Or search roster
-
-
-
- setPlayerSearchQuery(e.target.value)
- }
- leftIcon={ }
- />
-
- {playerSearchQuery && (
-
- {filteredPlayers.slice(0, 8).map((player) => (
-
- handleManualAssignment(
- match.csvName,
- player.id
- )
- }
- className="w-full flex items-center p-2 rounded-lg hover:bg-warm-50 transition-colors text-left text-sm"
- >
- {player.firstName} {player.lastName}
-
- ))}
- {filteredPlayers.length === 0 && (
-
- No players found
-
- )}
-
- )}
-
-
- {/* Skip Button */}
-
-
- handleManualAssignment(match.csvName, null)
- }
- className="text-sm text-warm-500 hover:text-warm-700 transition-colors"
- >
- Skip this player
-
-
-
- )}
-
- );
- })}
-
-
- )}
-
- {/* All Matched Summary */}
- {poorMatches.length === 0 && goodMatches.length > 0 && (
-
-
-
-
-
- All Players Matched!
-
-
- {goodMatches.length} players matched automatically
-
-
- )}
-
- {/* No Players Found */}
- {playerMatches.length === 0 && (
-
- )}
-
-
- setStep('columns')}>
- Back
-
- setStep('configure')}
- disabled={goodMatches.length === 0}
- className="gap-1"
- >
- Continue
-
-
-
-
- )}
-
- {/* Step: Configure */}
- {step === 'configure' && (
-
-
-
- Session Details
-
-
-
- {/* Stat Type */}
-
-
- Session Type
-
-
- {(['practice', 'game', 'other'] as const).map((type) => (
- setStatType(type)}
- className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
- statType === type
- ? 'bg-primary-600 text-white'
- : 'bg-warm-100 text-warm-600 hover:bg-warm-200 active:bg-warm-300'
- }`}
- >
- {type.charAt(0).toUpperCase() + type.slice(1)}
-
- ))}
-
-
- This helps track practice vs game performance separately.
-
-
-
- {/* Date */}
-
-
- Session Date
-
- setSessionDate(e.target.value)}
- />
-
-
- {/* Session Name */}
-
-
- Session Name (Optional)
-
- setSessionName(e.target.value)}
- placeholder="e.g., Fall Scrimmage vs State"
- />
-
-
-
-
- {/* Summary */}
-
-
- Ready to Upload
-
-
-
-
- {parsedRows.length} rows from {fileName}
-
-
-
- {goodMatches.length} players will be matched
-
- {poorMatches.length > 0 && (
-
-
- {poorMatches.length} players will be skipped
-
- )}
-
-
- Type: {statType.charAt(0).toUpperCase() + statType.slice(1)}
-
-
-
- Date: {new Date(sessionDate).toLocaleDateString()}
-
- {mappedStatColumns.length > 0 && (
-
-
- {mappedStatColumns.length} stat columns mapped
-
- )}
-
-
-
-
- setStep('match')}>
- Back
-
-
- {isUploading ? (
- <>
-
- Uploading...
- >
- ) : (
- <>
- Upload Stats
-
- >
- )}
-
-
-
- )}
-
- {/* Step: Processing */}
- {step === 'processing' && (
-
-
-
- Processing Upload
-
-
- Matching players and calculating statistics…
-
-
- )}
-
- {/* Step: Complete */}
- {step === 'complete' && uploadResult && (
-
-
-
- {uploadResult.success ? (
-
- ) : (
-
- )}
-
-
- {uploadResult.success ? 'Upload Complete!' : 'Upload Failed'}
-
-
- {uploadResult.success && (
-
-
- {uploadResult.matchedRows} players matched
- and stats recorded
-
- {(uploadResult.unmatchedRows ?? 0) > 0 && (
-
- {uploadResult.unmatchedRows} players
- could not be matched
-
- )}
-
- )}
-
- {uploadResult.error && (
-
{uploadResult.error}
- )}
-
- {uploadResult.unmatchedNames &&
- uploadResult.unmatchedNames.length > 0 && (
-
-
- Unmatched Names
-
-
- {uploadResult.unmatchedNames.map((name) => (
-
- {name}
-
- ))}
-
-
- )}
-
-
-
-
- Upload Another
-
- router.push('/baseball/dashboard/command-center')}
- >
- Go to Command Center
-
-
-
- )}
-
-
- );
-}
diff --git a/src/components/baseball/stats/UploadHistory.tsx b/src/components/baseball/stats/UploadHistory.tsx
deleted file mode 100644
index bffaa44c3..000000000
--- a/src/components/baseball/stats/UploadHistory.tsx
+++ /dev/null
@@ -1,268 +0,0 @@
-'use client';
-
-import { useEffect, useState, useCallback } from 'react';
-import { createClient } from '@/lib/supabase/client';
-import { formatDistanceToNow } from 'date-fns';
-import { Button, IconButton } from '@/components/ui/button';
-import {
- IconFile,
- IconCheck,
- IconX,
- IconRefresh,
- IconUpload,
- IconAlertCircle,
-} from '@/components/icons';
-import { PaperCard, InkBadge } from '@/components/baseball/living-annual';
-
-// ============================================================================
-// TYPES
-// ============================================================================
-
-// Interface matching the database schema for baseball_stat_uploads
-interface Upload {
- id: string;
- filename: string;
- coach_id: string;
- team_id: string;
- status: string | null;
- row_count: number | null;
- processed_count: number | null;
- error_message: string | null;
- file_url: string | null;
- completed_at: string | null;
- created_at: string | null;
-}
-
-interface UploadHistoryProps {
- teamId: string;
- limit?: number;
- showViewAll?: boolean;
- onViewAll?: () => void;
-}
-
-// ============================================================================
-// LOADING SKELETON
-// ============================================================================
-
-function UploadSkeleton() {
- return (
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
- );
-}
-
-// ============================================================================
-// COMPONENT
-// ============================================================================
-
-export function UploadHistory({
- teamId,
- limit = 5,
- showViewAll = false,
- onViewAll,
-}: UploadHistoryProps) {
- const [uploads, setUploads] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- const fetchHistory = useCallback(async () => {
- setLoading(true);
- setError(null);
-
- try {
- const supabase = createClient();
- const { data, error: fetchError } = await supabase
- .from('baseball_stat_uploads')
- .select('*')
- .eq('team_id', teamId)
- .order('created_at', { ascending: false })
- .limit(limit);
-
- if (fetchError) {
- setError('Failed to load upload history');
- console.error('Error fetching upload history:', fetchError);
- } else {
- setUploads((data as Upload[]) || []);
- }
- } catch (err) {
- setError('An unexpected error occurred');
- console.error('Error:', err);
- } finally {
- setLoading(false);
- }
- }, [teamId, limit]);
-
- useEffect(() => {
- fetchHistory();
- }, [fetchHistory]);
-
- // Loading state with skeleton
- if (loading) {
- return (
-
-
-
Recent Uploads
-
-
-
-
- );
- }
-
- // Error state
- if (error) {
- return (
-
-
-
Recent Uploads
-
-
-
-
-
- {/* Real fetch failure — reads --notice-error-ink (not --pursuit-ink)
- so this stays clay/oxide, never sage, on any scope (see
- InkNotice.tsx header for why the two vars stay independent). */}
-
-
-
-
- Unable to load history
-
-
{error}
-
- Try again
-
-
-
- );
- }
-
- // Empty state
- if (uploads.length === 0) {
- return (
-
- Recent Uploads
-
-
-
-
-
No uploads yet
-
- Upload a CSV file to start tracking stats
-
-
-
- );
- }
-
- // Normal state with data
- return (
-
-
-
Recent Uploads
-
-
-
-
-
- {uploads.map((upload) => {
- const isComplete = upload.status === 'completed';
- const isProcessing = upload.status === 'processing';
- const hasFailed = upload.status === 'failed' || !!upload.error_message;
-
- return (
-
-
-
-
-
-
- {upload.filename}
-
-
- {/* success→team, in-progress→pursuit soft (warning),
- failed→pursuit solid (error), else→neutral (info). */}
-
- {upload.created_at && (
- <>
- •
-
- {formatDistanceToNow(new Date(upload.created_at), {
- addSuffix: true,
- })}
-
- >
- )}
-
-
-
- {upload.processed_count != null && upload.processed_count > 0 && (
-
-
- {upload.processed_count}
-
- )}
- {upload.row_count != null && upload.row_count > 0 && upload.processed_count != null && upload.row_count > upload.processed_count && (
-
-
- {upload.row_count - upload.processed_count}
-
- )}
-
-
- );
- })}
-
-
- {showViewAll && onViewAll && uploads.length >= limit && (
-
- View all uploads
-
- )}
-
- );
-}
diff --git a/src/components/baseball/stats/index.ts b/src/components/baseball/stats/index.ts
deleted file mode 100644
index 5d6865af8..000000000
--- a/src/components/baseball/stats/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { StatsUploadClient } from './StatsUploadClient';
-export { UploadHistory } from './UploadHistory';
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 dba424693..ccfff3a1e 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
@@ -160,6 +160,23 @@ const LEGACY_REDIRECT_PAGES: ReadonlyArray<{
relPath: ['baseball', '(dashboard)', 'dashboard', 'stats', 'page.tsx'],
expectedTargets: ['/baseball/dashboard/stats-center'],
},
+ {
+ // Wizard consolidation — the legacy stats-upload wizard now branches on
+ // capability instead of redirecting unconditionally: can_manage_imports
+ // staff are forwarded to Import Center (the canonical import wizard);
+ // can_manage_stats-only staff (assistant/pitching/hitting/catching/
+ // defensive/strength coach) render the SAME wizard inline here instead of
+ // being locked out by Import Center's can_manage_imports gate; everyone
+ // else (unauthenticated / no active team / neither capability) falls
+ // back to login or Command Center. See
+ // src/app/baseball/(dashboard)/dashboard/stats/upload/page.tsx.
+ relPath: ['baseball', '(dashboard)', 'dashboard', 'stats', 'upload', 'page.tsx'],
+ expectedTargets: [
+ '/baseball/dashboard/import',
+ '/baseball/dashboard/command-center',
+ '/baseball/login',
+ ],
+ },
{
// Dynamic: branches coach -> command-center, player -> player/today, plus
// the unauthenticated -> login guard.
diff --git a/src/lib/baseball/import-roster.ts b/src/lib/baseball/import-roster.ts
new file mode 100644
index 000000000..1653c896f
--- /dev/null
+++ b/src/lib/baseball/import-roster.ts
@@ -0,0 +1,54 @@
+// =============================================================================
+// src/lib/baseball/import-roster.ts
+//
+// Shared roster fetch for the box-score import wizard's player matcher.
+// Extracted so the FULL Import Center page (/dashboard/import) and the
+// capability-aware /dashboard/stats/upload entry point (rendered inline for
+// staff who hold can_manage_stats but not can_manage_imports) load the SAME
+// tier-3 disambiguation signals (jersey_number, grad_year, primary_position)
+// instead of two independently-drifting copies of this query.
+// =============================================================================
+
+import 'server-only';
+
+import type { createClient } from '@/lib/supabase/server';
+import type { MatchablePlayer } from '@/lib/baseball/import-matching';
+
+/**
+ * Loads the team's roster in the shape the import wizard's player matcher
+ * expects: id/name plus jersey_number (per-team, on the membership) and
+ * grad_year + primary_position (on the player row), so the matcher can break
+ * same-name ties and the manual-match dropdown can show jersey/class/position.
+ */
+export async function getRosterForImportMatching(
+ supabase: Awaited>,
+ teamId: string,
+): Promise {
+ const { data: members } = await supabase
+ .from('baseball_team_members')
+ .select(
+ `player_id,
+ jersey_number,
+ baseball_players!inner ( id, first_name, last_name, grad_year, primary_position )`
+ )
+ .eq('team_id', teamId);
+
+ return (members ?? []).map((m) => {
+ const p = m.baseball_players as unknown as {
+ id: string;
+ first_name: string | null;
+ last_name: string | null;
+ grad_year: number | null;
+ primary_position: string | null;
+ };
+ const member = m as unknown as { jersey_number: number | null };
+ return {
+ id: p.id,
+ first_name: p.first_name,
+ last_name: p.last_name,
+ jersey_number: member.jersey_number,
+ grad_year: p.grad_year,
+ primary_position: p.primary_position,
+ };
+ });
+}
diff --git a/src/lib/baseball/stat-layer-manifest.ts b/src/lib/baseball/stat-layer-manifest.ts
index 638b4c692..60946f057 100644
--- a/src/lib/baseball/stat-layer-manifest.ts
+++ b/src/lib/baseball/stat-layer-manifest.ts
@@ -344,6 +344,13 @@ export const GRANDFATHERED_CONSUMERS: GrandfatheredStatLayerConsumer[] = [
status: 'pending migration',
note: 'Exercises imports.ts commitImport() against a fake baseball_player_stats table.',
},
+ {
+ path: 'src/app/baseball/actions/__tests__/imports-capability-shape-gate.test.ts',
+ group: 'test',
+ status: 'pending migration',
+ note:
+ '#863 round-4 regression coverage for the previewImport/commitImport shape-conditional capability gate (can_manage_imports OR can_manage_stats for game_box_score only) — mirrors imports-registry.test.ts above; exercises the SAME commitImport() legacy write against a fake baseball_player_stats table to prove an authorized stats-only-staff commit actually writes, not just that the gate doesn\'t throw. Production reference is the legacy-import-writer entry, not a new one.',
+ },
{
path: 'src/app/baseball/actions/__tests__/upload-stats-csv.test.ts',
group: 'test',
diff --git a/src/lib/baseball/with-baseball-action.ts b/src/lib/baseball/with-baseball-action.ts
index 869668658..ff9389a55 100644
--- a/src/lib/baseball/with-baseball-action.ts
+++ b/src/lib/baseball/with-baseball-action.ts
@@ -14,7 +14,13 @@
// 3. CAPABILITY — when opts.requiredCapability is set, enforce it
// SERVER-SIDE via requireBaseballCapability() against the
// resolved team. Head/primary coach implicitly hold all caps;
-// non-staff / suspended hold none.
+// non-staff / suspended hold none. requiredCapability may
+// also be a readonly array (ANY-of / OR) or a resolver
+// function of the action's own args (evaluated once, before
+// AUTH) that RETURNS a single capability or an ANY-of array
+// — e.g. relaxing the gate only for one client-declared
+// shape/kind while every other shape keeps the original,
+// single, stricter capability.
// 4. OBSERVABILITY — run the action inside a Sentry scope tagged
// { sport:'baseball', feature:, action: }
// with user + breadcrumbs, and on throw route the error
@@ -68,6 +74,7 @@ import { getActiveBaseballContext } from '@/lib/baseball/active-context';
import type { ActiveBaseballContext } from '@/lib/baseball/active-context-shared';
import {
requireBaseballCapability,
+ hasBaseballCapability,
BaseballCapabilityError,
type BaseballCapability,
} from '@/lib/baseball/capabilities';
@@ -157,8 +164,30 @@ export interface WithBaseballActionOptions
* When set, the wrapper enforces this capability SERVER-SIDE (via
* requireBaseballCapability) against the resolved team before the body runs.
* Head/primary coach implicitly satisfy every capability.
+ *
+ * Three forms:
+ * - a single BaseballCapability : the existing, most common form.
+ * - a readonly array of BaseballCapability : ANY-of (OR) — the staff member
+ * need only hold ONE of the listed
+ * capabilities. On a miss, the
+ * thrown BaseballCapabilityError
+ * names the LAST array element
+ * (put the action's "primary" /
+ * historical capability last so
+ * a fully-unauthorized caller sees
+ * the same error identity as a
+ * single-capability gate would).
+ * - (...args) => single | array : resolve the requirement from the
+ * action's OWN arguments (e.g. gate
+ * on a client-supplied shape/kind
+ * field) — evaluated once per call,
+ * before AUTH, from the SAME `args`
+ * the action body receives.
*/
- requiredCapability?: BaseballCapability;
+ requiredCapability?:
+ | BaseballCapability
+ | readonly BaseballCapability[]
+ | ((...args: TArgs) => BaseballCapability | readonly BaseballCapability[]);
/**
* When set, the wrapper enforces a PLAYER/GUARDIAN-access toggle SERVER-SIDE
* (via requirePlayerAccess) against the resolved team before the body runs.
@@ -262,6 +291,24 @@ export function withBaseballAction(
} = opts;
return async (...args: TArgs): Promise => {
+ // Resolve the (possibly args-conditional) capability requirement ONCE, up
+ // front, from the SAME args the action body receives below — so the
+ // observability tags/metadata and the actual enforcement in step 3 can
+ // never disagree about what was required. A plain single-capability opt
+ // (the ~60 existing call sites) resolves to a one-element list here, so
+ // every branch below behaves byte-identically to the pre-existing
+ // single-capability path.
+ const resolvedCapability =
+ typeof requiredCapability === 'function'
+ ? requiredCapability(...args)
+ : requiredCapability;
+ const resolvedCapabilityList: readonly BaseballCapability[] | null = resolvedCapability
+ ? Array.isArray(resolvedCapability)
+ ? resolvedCapability
+ : [resolvedCapability as BaseballCapability]
+ : null;
+ const resolvedCapabilityTag = resolvedCapabilityList?.join('|') ?? null;
+
return Sentry.withScope(async (scope) => {
// Stable scope identity for every trace emitted from this action.
scope.setTag('sport', 'baseball');
@@ -275,7 +322,7 @@ export function withBaseballAction(
data: {
feature,
featureArea,
- requiredCapability: requiredCapability ?? null,
+ requiredCapability: resolvedCapabilityTag,
requiredPlayerAccess: requiredPlayerAccess ?? null,
},
});
@@ -305,7 +352,7 @@ export function withBaseballAction(
...(observedRole ? { baseball_role: observedRole } : {}),
...(observedTeamId ? { baseball_team: observedTeamId } : {}),
...(observedTargetTeamId ? { baseball_target_team: observedTargetTeamId } : {}),
- ...(requiredCapability ? { baseball_capability: requiredCapability } : {}),
+ ...(resolvedCapabilityTag ? { baseball_capability: resolvedCapabilityTag } : {}),
...(requiredPlayerAccess ? { baseball_player_access: requiredPlayerAccess } : {}),
},
metadata: {
@@ -314,7 +361,7 @@ export function withBaseballAction(
activeRole: observedRole,
activeCoachId: observedCoachId,
activePlayerId: observedPlayerId,
- requiredCapability: requiredCapability ?? null,
+ requiredCapability: resolvedCapabilityTag,
requiredPlayerAccess: requiredPlayerAccess ?? null,
requireActiveContext,
demoSafe,
@@ -395,18 +442,47 @@ export function withBaseballAction(
// -------------------------------------------------------------------
// 3. CAPABILITY — enforce server-side when required.
+ //
+ // A single-element resolvedCapabilityList (the ~60 existing static
+ // single-capability call sites, plus any function-form resolver
+ // that returns a single capability) enforces via
+ // requireBaseballCapability EXACTLY as before this list-normalized
+ // form existed.
+ //
+ // A multi-element list is ANY-of (OR): the staff member need only
+ // hold ONE of the listed capabilities. Every candidate but the
+ // last is probed with the non-throwing hasBaseballCapability; the
+ // LAST candidate is enforced via requireBaseballCapability so a
+ // genuine miss still throws the real BaseballCapabilityError (not
+ // a bespoke error type) — reported against that last capability.
// -------------------------------------------------------------------
- if (requiredCapability) {
+ if (resolvedCapabilityList) {
if (!targetTeamId) {
throw new BaseballNoActiveTeamError(
'Could not resolve a team for capability enforcement.',
);
}
- scope.setTag('baseball_capability', requiredCapability);
- await requireBaseballCapability(targetTeamId, requiredCapability);
+ scope.setTag('baseball_capability', resolvedCapabilityTag!);
+ if (resolvedCapabilityList.length === 1) {
+ await requireBaseballCapability(targetTeamId, resolvedCapabilityList[0]!);
+ } else {
+ let granted = false;
+ for (const cap of resolvedCapabilityList.slice(0, -1)) {
+ if (await hasBaseballCapability(targetTeamId, cap)) {
+ granted = true;
+ break;
+ }
+ }
+ if (!granted) {
+ await requireBaseballCapability(
+ targetTeamId,
+ resolvedCapabilityList[resolvedCapabilityList.length - 1]!,
+ );
+ }
+ }
scope.addBreadcrumb({
category: 'baseball.action',
- message: `capability ${requiredCapability} granted`,
+ message: `capability ${resolvedCapabilityTag} granted`,
level: 'info',
data: { teamId: targetTeamId },
});
From 3759cbecd604c8f913f359585f0829b2d768c8c7 Mon Sep 17 00:00:00 2001
From: Fable Integrator
Date: Wed, 15 Jul 2026 19:40:54 -0400
Subject: [PATCH 11/18] ci(visual-audit): two spaces before inline version
comments (yamllint strict)
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
---
.github/workflows/visual-audit.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/visual-audit.yml b/.github/workflows/visual-audit.yml
index 099f03aac..a3e9be672 100644
--- a/.github/workflows/visual-audit.yml
+++ b/.github/workflows/visual-audit.yml
@@ -63,12 +63,12 @@ jobs:
E2E_BASEBALL_PLAYER_PASSWORD: ${{ secrets.E2E_BASEBALL_PLAYER_PASSWORD }}
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
@@ -77,7 +77,7 @@ jobs:
run: npm ci
- name: Cache Playwright browsers
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
@@ -100,7 +100,7 @@ jobs:
- name: Upload visual-audit screenshots + manifests
if: always()
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
with:
name: visual-audit-${{ github.run_number }}
path: test-results/visual-audit
From e88f866bc784b2c512cba9d8bd8453dfbeb1d879 Mon Sep 17 00:00:00 2001
From: Fable Integrator
Date: Wed, 15 Jul 2026 20:32:36 -0400
Subject: [PATCH 12/18] =?UTF-8?q?fix(migration):=20qualify=20digest()=20as?=
=?UTF-8?q?=20extensions.digest=20=E2=80=94=20pgcrypto=20is=20not=20in=20p?=
=?UTF-8?q?ublic?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 42883 failure reproduced on the CI fresh-stack replay and would have
occurred identically on prod at apply time: pgcrypto lives in the
extensions schema in both environments.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
---
...20260715141727_baseball_legacy_stats_backfill.sql | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql b/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql
index 34c5c2ef8..fc8361196 100644
--- a/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql
+++ b/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql
@@ -149,8 +149,10 @@
-- fresh in this transaction and never persisted as a callable object — so
-- there is nothing new here to REVOKE from anon or pin a search_path on. This
-- migration calls no functions at all beyond core Postgres builtins and
--- pgcrypto's `digest()` (already installed — see 20260527000000's
--- `CREATE EXTENSION IF NOT EXISTS pgcrypto`). It still deliberately does NOT
+-- pgcrypto's `digest()`, schema-qualified as `extensions.digest` — pgcrypto
+-- lives in the `extensions` schema on BOTH prod (verified via pg_extension)
+-- and fresh `supabase start` stacks; an unqualified or `public.`-qualified
+-- call fails 42883 in either environment. It still deliberately does NOT
-- call `public.recalculate_baseball_season_stats` itself — Step 4 mirrors
-- its aggregation logic inline (read-only against the rows this migration
-- just wrote) rather than invoking the live RPC, so this migration never
@@ -227,7 +229,7 @@ hashed AS (
SELECT
g.team_id, g.session_date, g.opponent_name,
substring(
- public.digest(
+ extensions.digest(
'baseball-legacy-backfill-379:box-game:' || g.team_id::text || ':' || g.session_date::text,
'sha1'
)
@@ -316,7 +318,7 @@ norm AS (
hashed AS (
SELECT n.*,
substring(
- public.digest(
+ extensions.digest(
'baseball-legacy-backfill-379:box-bat:' || n.game_id::text || ':' || n.player_id::text,
'sha1'
)
@@ -411,7 +413,7 @@ norm AS (
hashed AS (
SELECT n.*,
substring(
- public.digest(
+ extensions.digest(
'baseball-legacy-backfill-379:box-pit:' || n.game_id::text || ':' || n.player_id::text,
'sha1'
)
From 510cb3a2068a65b630ac6d12cb4a33ad21be7531 Mon Sep 17 00:00:00 2001
From: Fable Integrator
Date: Wed, 15 Jul 2026 21:29:29 -0400
Subject: [PATCH 13/18] db(baseball): manifest-based rollback + concurrency
lock for #379 backfill (CodeRabbit #868)
- Copy-only summary now lists Step 4's baseball_player_season_stats write (finding 1).
- Add permanent, service-role-only baseball_legacy_backfill_manifest ledger
(RLS enabled, anon/authenticated revoked); every Step 1-4 INSERT records its
own RETURNING rows into it, same transaction, tagged with a run_tag. Rollback
now joins against the manifest instead of recomputing deterministic ids from
current (possibly-changed) baseball_player_stats, and the runbook's rollback
+ season-stats-rollback sections are rewritten around manifest-join DELETEs.
Verified recalculate_baseball_season_stats() does a full from-scratch
rebuild (not an incremental merge) before writing the "safe to delete"
rollback caveat (finding 2).
- Take an explicit LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE on all 5
read/written tables before the eligibility snapshot; runbook gains an apply-
window note. Confirmed SHARE ROW EXCLUSIVE cannot self-conflict with this
migration's own later INSERTs (finding 9).
- Rename the two TEMP TABLEs to the required baseball_ prefix, all references
(finding 10).
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
---
docs/baseball/legacy-backfill-runbook.md | 279 +++++++++-------
...5141727_baseball_legacy_stats_backfill.sql | 301 ++++++++++++------
2 files changed, 377 insertions(+), 203 deletions(-)
diff --git a/docs/baseball/legacy-backfill-runbook.md b/docs/baseball/legacy-backfill-runbook.md
index 8998a4427..3c0e8e263 100644
--- a/docs/baseball/legacy-backfill-runbook.md
+++ b/docs/baseball/legacy-backfill-runbook.md
@@ -47,7 +47,8 @@ The migration is the one-time catch-up for exactly those teams:
(`baseball_games.game_type` only allows `'game'`/`'scrimmage'`).
**Copy-only.** The migration only ever `INSERT`s into `baseball_games`,
-`baseball_box_score_batting`, `baseball_box_score_pitching`. It never
+`baseball_box_score_batting`, `baseball_box_score_pitching`, and
+`baseball_player_season_stats`. It never
`UPDATE`s or `DELETE`s a row of `baseball_player_stats`, or anything else —
the legacy table is read-only input.
@@ -182,16 +183,22 @@ bytes (version nibble forced to `0x5`, variant bits forced to `10xx`) — the
exact pattern `scripts/seed-baseball-stats.mjs`'s `detId()` uses (see its
header comment and `#827`). The namespace is deliberately different from the
seed script's own `baseball-stats-seed` namespace, so these ids can never
-collide with the demo seeder's (or anything else's) ids, and so this exact id
-formula can be **recomputed** later — not merely looked up from a log — which
-is what makes the rollback below possible without any extra bookkeeping
-table.
+collide with the demo seeder's (or anything else's) ids.
Every `INSERT` is `ON CONFLICT (...) DO NOTHING` keyed on that deterministic
id (games) or the table's natural unique key (`(game_id, player_id)` for
-batting/pitching). Re-running the file is always a no-op the second time —
-verified empirically (see "Verified" below): a second run against the same
-database inserted zero new rows in any of the three tables.
+batting/pitching, `(player_id, team_id, season_year)` for the season seed).
+Re-running the file is always a no-op the second time — verified empirically
+(see "Verified" below): a second run against the same database inserted zero
+new rows in any of the four tables.
+
+**Rollback no longer depends on recomputing these ids** — see "Rollback
+story" below. The migration also writes a permanent, append-only manifest
+table, `baseball_legacy_backfill_manifest`, recording every row it inserts
+(games, batting rows, pitching rows, and season-stat triples) tagged with
+`run_tag = 'baseball-legacy-backfill-379'`, in the SAME transaction as each
+insert. That manifest — not the deterministic ids — is the authoritative
+record of exactly what this run touched, and is what rollback joins against.
## How the orchestrator applies it
@@ -205,11 +212,19 @@ This file stays **written, not applied** until Nick says go. When he does:
stop and get Nick's explicit call on those specific teams/players before
proceeding (see "Season-stats interaction" above) — do not treat an
empty migration diff as proof this step is unnecessary.
-3. Apply the migration file verbatim via `mcp__supabase__apply_migration`
+3. **Apply during a low-traffic window.** The migration takes an explicit
+ `LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE` on `baseball_player_stats`,
+ `baseball_games`, `baseball_box_score_batting`, `baseball_box_score_pitching`,
+ and `baseball_player_season_stats` before it snapshots eligibility, so that
+ a concurrent box-score/game save cannot land mid-run and invalidate the
+ snapshot. Ordinary reads (Stats Center, Roster, etc.) are unaffected —
+ only writes to those five tables briefly queue behind this migration's
+ transaction until it commits.
+4. Apply the migration file verbatim via `mcp__supabase__apply_migration`
(file content unchanged from what's committed — this is a WRITE-ONLY repo
file until that point).
-4. Run the **post-check queries** below to confirm row-count parity per team.
-5. Spot-check one backfilled team's Stats Center page in the app to confirm
+5. Run the **post-check queries** below to confirm row-count parity per team.
+6. Spot-check one backfilled team's Stats Center page in the app to confirm
real numbers now render (previously empty).
No code changes accompany this migration — nothing needs deploying alongside
@@ -276,73 +291,56 @@ team+date+player legacy rows existed (see Known Limitations).
## Rollback story
-Copy-only means rollback is a pure delete, and because every id is
-deterministic (not random), rollback does not depend on any log or snapshot
-from the original run — it **recomputes** the exact same ids from whatever
-`baseball_player_stats` currently contains, then deletes any row whose id
-matches. Rows this migration never created simply won't match anything (a
-pre-existing team's real box-score id was assigned by `gen_random_uuid()`,
-not derived from this hash, so it cannot collide), so this is precise and
-safe to run at any time after the migration, without needing to already know
-which teams were touched.
+Rollback is a pure delete, joined against the permanent
+`baseball_legacy_backfill_manifest` table this migration writes to (see
+"Idempotency" above) — it does **not** recompute anything. The migration
+used to recompute the deterministic ids from whatever `baseball_player_stats`
+happened to contain at rollback time; that is not safe, because legacy rows
+can be edited or deleted between apply and rollback, which would recompute a
+DIFFERENT candidate set than what this run actually wrote. The manifest is
+an immutable, apply-time record — written in the SAME transaction as each
+insert — of exactly which rows this run created, so rollback reads it
+instead of re-deriving anything from current, possibly-changed state.
Run this as one transaction:
```sql
BEGIN;
--- Recompute candidate game ids from CURRENT baseball_player_stats — no
--- eligibility gate needed here; safety comes from exact id match, not from
--- re-deriving "which teams were eligible" (which would self-exclude every
--- team this migration touched, since they now have box-score rows).
-WITH game_groups AS (
- SELECT ps.team_id, ps.session_date
- FROM public.baseball_player_stats ps
- WHERE ps.stat_type = 'game'
- GROUP BY ps.team_id, ps.session_date
-),
-hashed AS (
- SELECT g.team_id, g.session_date,
- substring(
- public.digest('baseball-legacy-backfill-379:box-game:' || g.team_id::text || ':' || g.session_date::text, 'sha1')
- FROM 1 FOR 16
- ) AS raw16
- FROM game_groups g
-),
-versioned AS (
- SELECT team_id, session_date, set_byte(raw16, 6, (get_byte(raw16, 6) & 15) | 80) AS b1 FROM hashed
-),
-varianted AS (
- SELECT team_id, session_date,
- set_byte(b1, 8, ((((get_byte(b1, 8) >> 4) & 3) | 8) << 4) | (get_byte(b1, 8) & 15)) AS b2
- FROM versioned
-),
-hexed AS (SELECT team_id, session_date, encode(b2, 'hex') AS hx FROM varianted),
-game_ids AS (
- SELECT team_id, session_date,
- (substring(hx FROM 1 FOR 8) || '-' || substring(hx FROM 9 FOR 4) || '-' ||
- substring(hx FROM 13 FOR 4) || '-' || substring(hx FROM 17 FOR 4) || '-' ||
- substring(hx FROM 21 FOR 12))::uuid AS game_id
- FROM hexed
-)
-SELECT game_id INTO TEMP _rollback_379_game_ids FROM game_ids;
-
--- Eyeball this before deleting: should equal the number of games the
--- post-check query above reported as backfilled.
-SELECT count(*) AS games_to_delete
-FROM public.baseball_games g
-JOIN _rollback_379_game_ids c ON c.game_id = g.id;
-
-DELETE FROM public.baseball_box_score_batting
-WHERE game_id IN (SELECT game_id FROM _rollback_379_game_ids);
-
-DELETE FROM public.baseball_box_score_pitching
-WHERE game_id IN (SELECT game_id FROM _rollback_379_game_ids);
-
-DELETE FROM public.baseball_games
-WHERE id IN (SELECT game_id FROM _rollback_379_game_ids);
-
--- Review the row counts printed by the DELETEs above, THEN:
+-- Eyeball this before deleting: counts by kind, should match the manifest
+-- audit query at the bottom of the migration file.
+SELECT row_kind, count(*) AS n
+FROM public.baseball_legacy_backfill_manifest
+WHERE run_tag = 'baseball-legacy-backfill-379'
+GROUP BY row_kind
+ORDER BY row_kind;
+
+-- Batting rows this run inserted — join by (game_id, player_id), the
+-- manifest's recorded key for row_kind = 'batting_row'.
+DELETE FROM public.baseball_box_score_batting bsb
+USING public.baseball_legacy_backfill_manifest m
+WHERE m.run_tag = 'baseball-legacy-backfill-379'
+ AND m.row_kind = 'batting_row'
+ AND bsb.game_id = m.game_id
+ AND bsb.player_id = m.player_id;
+
+-- Pitching rows this run inserted.
+DELETE FROM public.baseball_box_score_pitching bsp
+USING public.baseball_legacy_backfill_manifest m
+WHERE m.run_tag = 'baseball-legacy-backfill-379'
+ AND m.row_kind = 'pitching_row'
+ AND bsp.game_id = m.game_id
+ AND bsp.player_id = m.player_id;
+
+-- Games this run synthesized.
+DELETE FROM public.baseball_games g
+USING public.baseball_legacy_backfill_manifest m
+WHERE m.run_tag = 'baseball-legacy-backfill-379'
+ AND m.row_kind = 'game'
+ AND g.id = m.game_id;
+
+-- Review the row counts printed by the DELETEs above (should match the
+-- audit query's counts for 'batting_row' / 'pitching_row' / 'game'), THEN:
COMMIT;
-- (or ROLLBACK; instead, to abort without changing anything)
```
@@ -351,40 +349,61 @@ COMMIT;
`_pitching` is `ON DELETE CASCADE`, so deleting only the `baseball_games` rows
would technically also remove the box-score rows — the explicit 3-statement
form above is preferred for an auditable, step-by-step rollback where each
-`DELETE`'s row count is visible before committing.
+`DELETE`'s row count is visible before committing, and where each `DELETE`
+is independently scoped to its own manifest `row_kind` rather than relying on
+cascade to clean up rows a bug might have mis-tagged.
`baseball_player_stats` (the legacy source) is never touched by the forward
-migration, so there is nothing to restore there on rollback.
+migration, so there is nothing to restore there on rollback. The manifest
+table itself is **never deleted** by this rollback (or by the migration) —
+it is permanent, append-only audit history, not a single-use ticket.
### Season-stats rollback
-Step 4's seed is guarded by `ON CONFLICT DO NOTHING`, so — unlike the
-deterministic-id games/box-score rollback above — there is no id to
-recompute-and-match for `baseball_player_season_stats` rows: a row this
-migration seeded and a row that pre-existed both look like ordinary rows
-once written, keyed only on `(player_id, team_id, season_year)`.
-
-This is exactly why the **pre-flight query** ("Season-stats interaction"
-above) must be run and its output saved (a screenshot, a CSV export, a copy
-of the JSON result) **before** applying the migration:
-
-1. **Before applying**, run the pre-flight query and save its output — that
- is your "already existed" list for every triple this migration is about
- to touch.
-2. **If you need to roll back**, run the same pre-flight-shaped query again
- (against the same touched-triple set the games rollback above
- recomputes) and diff against the saved "before" list:
- - Any `(player_id, team_id, season_year)` present **now** but **absent**
- from the saved "before" list was seeded by Step 4 — safe to `DELETE FROM
- baseball_player_season_stats WHERE (player_id, team_id, season_year) =
- (...)` for those rows specifically.
- - Any triple present in **both** is the pre-existing baseline Step 4 never
- touched — leave it alone.
-3. If the "before" snapshot was never taken (e.g. this section is read after
- the fact), do **not** guess — treat every season-stats row for a
- backfilled team as unknown provenance and reconcile it manually against
- `season_totals` import records or Nick's own knowledge of that team,
- rather than deleting rows that might be real, independent data.
+Step 4's seed is recorded in the manifest too (`row_kind = 'season_stat_triple'`),
+so — unlike the old pre-flight-diff approach this replaces — there is no
+"before" snapshot to have saved and no diff to compute: the manifest already
+distinguishes a triple Step 4 inserted (present in the manifest) from a
+pre-existing baseline Step 4's `ON CONFLICT DO NOTHING` left untouched (never
+recorded, because nothing was actually inserted for it).
+
+```sql
+BEGIN;
+
+DELETE FROM public.baseball_player_season_stats bpss
+USING public.baseball_legacy_backfill_manifest m
+WHERE m.run_tag = 'baseball-legacy-backfill-379'
+ AND m.row_kind = 'season_stat_triple'
+ AND bpss.player_id = m.player_id
+ AND bpss.team_id = m.team_id
+ AND bpss.season_year = m.season_year;
+
+COMMIT;
+```
+
+**Caveat the manifest does not erase — read before assuming this DELETE is a
+clean undo:** by the time you run this, a season row Step 4 seeded may
+already have been folded into by an ordinary, already-shipped box-score save
+(see "Season-stats interaction" above). This DELETE is safe to run anyway,
+specifically **because** of how `recalculate_baseball_season_stats()` is
+written — verified directly against its body
+(`supabase/migrations/20260624001000_baseball_official_stat_breadth.sql:109-265`):
+every call does a fresh `SELECT SUM(...)` aggregation over **all** of that
+player/team/year's currently-completed box-score games, then an unconditional
+`INSERT ... ON CONFLICT (player_id, team_id, season_year) DO UPDATE SET
+col = EXCLUDED.col` for every column. It is a from-scratch REBUILD every
+time, never an incremental merge on top of whatever the row already held. So:
+
+- Deleting the row here does not "lose" anything the next ordinary save
+ can't reconstruct: once this rollback's box-score DELETEs above have
+ already removed this migration's synthesized rows, the next box-score save
+ for that player/team/year calls `recalculate_baseball_season_stats()` again,
+ which re-aggregates from whatever box-score rows remain (this migration's
+ are now gone) and does a full, correct overwrite — not a correction applied
+ on top of stale data.
+- Until that next save happens, the row is simply absent (an honest empty
+ state), not silently wrong — which is why deleting it outright, rather than
+ trying to hand-patch it, is the conservative choice here.
## Season-stats reconcile (only relevant for pre-existing baselines Step 4 left alone)
@@ -427,9 +446,11 @@ Supabase project) with fixture data covering:
Results: avg/obp/slg/ops and era/whip/k9/bb9 matched hand-calculated values
(and the outs-based IP conversion) exactly; the already-box-score team was
untouched; the colliding date was correctly skipped; a second run of the
-same file inserted zero additional rows anywhere; a rollback recompute+delete
-(run inside a `ROLLBACK`ed transaction as a dry run) matched exactly the rows
-the migration had created, and nothing else.
+same file inserted zero additional rows anywhere. (This pass predates the
+manifest-based rollback below — at the time, rollback was a deterministic-id
+recompute-and-delete, which matched exactly the rows the migration had
+created in a `ROLLBACK`ed dry run. That recompute strategy has since been
+replaced; see "Rollback manifest, lock, and temp-table rename" below.)
### Step 4 (season-stats seed) — re-verified after the post-review fix
@@ -471,6 +492,50 @@ Confirmed:
was silently overwritten by that already-shipped RPC, exactly as warned.
This was not a hypothetical for this test — it happened on the very next
ordinary save.
-- The rollback story (games/box-score delete + the season-stats diff-based
- delete described in "Season-stats rollback" above) correctly removed only
- the seeded row and left the pre-existing baseline intact.
+- The rollback story tested at the time (games/box-score delete + the
+ since-superseded season-stats diff-based delete) correctly removed only
+ the seeded row and left the pre-existing baseline intact. See below for
+ what changed and how the current manifest-based rollback was verified.
+
+### Rollback manifest, lock, and temp-table rename — verified by design review (post-review fix)
+
+This round replaced the deterministic-id-recompute rollback above with the
+manifest-join rollback in "Rollback story," added the `LOCK TABLE` at the top
+of the transaction, and renamed the two TEMP TABLEs to the required
+`baseball_` prefix. These were verified as follows (design/code review, not a
+fresh disposable-Postgres re-run — the underlying INSERT/aggregation logic
+this touches is unchanged from the passes above):
+
+- **Manifest wiring**: each of the four `INSERT ... RETURNING` statements
+ (games, batting, pitching, season-stats) was checked column-by-column
+ against the manifest table's schema — the `RETURNING` list supplies exactly
+ the columns each row's `row_kind` needs (`game_id`+`team_id` for `'game'`;
+ `game_id`+`player_id`+`team_id` for `'batting_row'`/`'pitching_row'`;
+ `player_id`+`team_id`+`season_year` for `'season_stat_triple'`), and because
+ the manifest INSERT reads from the data INSERT's own `RETURNING` (not a
+ separate re-query), a conflict that makes the data INSERT a no-op also
+ makes the manifest INSERT a no-op — the idempotency guarantee is structural,
+ not a separate thing to keep in sync.
+- **Lock self-conflict**: `SHARE ROW EXCLUSIVE MODE` conflicts with `ROW
+ EXCLUSIVE` (what a plain `INSERT` takes) from **other** sessions, but
+ Postgres never blocks a transaction on a lock it already holds itself —
+ a single transaction's lock requests against its own previously-acquired
+ locks always succeed immediately, regardless of nominal conflict mode. So
+ taking the stronger lock first, then running this migration's own
+ `INSERT`s in the same transaction, cannot self-deadlock or self-block.
+- **"Recalc fully rebuilds it" claim**: verified directly against
+ `recalculate_baseball_season_stats()`'s actual body
+ (`supabase/migrations/20260624001000_baseball_official_stat_breadth.sql:109-265`)
+ — it declares fresh local variables, `SELECT SUM(...) INTO` them from a
+ from-scratch aggregation query scoped to `(player_id, team_id,
+ season_year)` over `baseball_box_score_batting`/`_pitching` joined to
+ `baseball_games`, then does one `INSERT ... ON CONFLICT (player_id,
+ team_id, season_year) DO UPDATE SET = EXCLUDED.`.
+ There is no read-modify-write against the row's own prior value anywhere in
+ it — every call is a full, from-scratch overwrite, confirming the
+ "Season-stats rollback" caveat above.
+- **Temp-table rename**: verified by grepping the full migration file for the
+ old `_bb_legacy_backfill_379_teams`/`_bb_legacy_backfill_379_games` names
+ after the rename — zero remaining references; all 8 occurrences (2×`DROP
+ TABLE IF EXISTS`, 2×`CREATE TEMP TABLE`, 2×`INSERT INTO`, plus the `JOIN`
+ references in Steps 1-4) now use the `baseball_` prefix.
diff --git a/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql b/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql
index fc8361196..ce5415ae5 100644
--- a/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql
+++ b/supabase/migrations/20260715141727_baseball_legacy_stats_backfill.sql
@@ -51,9 +51,11 @@
-- COPY-ONLY: this migration only ever INSERTs — into `baseball_games`,
-- `baseball_box_score_batting`, `baseball_box_score_pitching`, and (Step 4,
-- below) `baseball_player_season_stats` guarded by `ON CONFLICT DO NOTHING`
--- so an EXISTING season row is never touched. It never UPDATEs or DELETEs a
--- single row of `baseball_player_stats` (or anything else) — the legacy
--- table is read-only input here.
+-- so an EXISTING season row is never touched, plus its own append-only
+-- `baseball_legacy_backfill_manifest` rollback ledger (see "ROLLBACK
+-- MANIFEST" below). It never UPDATEs or DELETEs a single row of
+-- `baseball_player_stats` (or anything else) — the legacy table is
+-- read-only input here.
--
-- SEASON-STATS SAFETY (post-review fix — read this before assuming
-- `baseball_player_season_stats` is inert until someone deliberately recalcs)
@@ -133,16 +135,15 @@
-- `detId()` uses (see its header comment + #827). The namespace here is
-- `baseball-legacy-backfill-379` — DELIBERATELY DIFFERENT from the seed
-- script's `baseball-stats-seed` namespace, so these ids can never collide
--- with anything the demo seeder (or anything else) has ever produced, and so
--- a rollback can RECOMPUTE (not merely record) exactly which rows are this
--- migration's. Every INSERT below is `ON CONFLICT (...) DO NOTHING` keyed on
--- that deterministic id (or the table's own natural unique key), so re-running
--- this file is always a no-op the second time. Step 4's season-stats seed has
--- no id of its own to derive — it's keyed on the table's existing
--- `(player_id, team_id, season_year)` unique constraint with `DO NOTHING`,
--- which is equally idempotent: a second run recomputes the identical
--- aggregate and finds the conflict already satisfied (either from its own
--- first run or a pre-existing row it never touched either time).
+-- with anything the demo seeder (or anything else) has ever produced. Every
+-- INSERT below is `ON CONFLICT (...) DO NOTHING` keyed on that deterministic
+-- id (or the table's own natural unique key), so re-running this file is
+-- always a no-op the second time. Step 4's season-stats seed has no id of its
+-- own to derive — it's keyed on the table's existing `(player_id, team_id,
+-- season_year)` unique constraint with `DO NOTHING`, which is equally
+-- idempotent: a second run recomputes the identical aggregate and finds the
+-- conflict already satisfied (either from its own first run or a
+-- pre-existing row it never touched either time).
--
-- No new database function is created. The id derivation is inlined as plain
-- SQL (bytea `get_byte`/`set_byte` + `pgcrypto.digest`) inside CTEs, computed
@@ -159,23 +160,93 @@
-- depends on — or risks a future edit to — that shared function's behavior.
-- See SEASON-STATS SAFETY above.
--
+-- ROLLBACK MANIFEST (post-review fix — replaces id-recomputation rollback)
+-- ---------------------------------------------------------------------------
+-- Recomputing ids from whatever `baseball_player_stats` happens to contain AT
+-- ROLLBACK TIME is not safe: legacy rows can be edited or deleted between
+-- apply and rollback, which would recompute a DIFFERENT candidate set than
+-- what was actually written, and a Step-4-seeded season row that a later live
+-- `recalculate_baseball_season_stats()` call has already folded new, real
+-- games into is no longer safe to delete purely from a pre-flight diff. So
+-- Step 0 below first creates (if missing) a permanent, additive,
+-- service-role-only ledger table, `baseball_legacy_backfill_manifest`, and
+-- every subsequent INSERT in this file (Steps 1-4) immediately re-inserts its
+-- own `RETURNING` rows into that manifest, tagged with this run's
+-- `run_tag` (`'baseball-legacy-backfill-379'`), in the SAME transaction as the
+-- data write itself. Rollback (see the runbook's "Rollback story") then joins
+-- against this manifest instead of recomputing anything — it deletes EXACTLY
+-- the rows this run created, nothing more, nothing else, regardless of what
+-- `baseball_player_stats` looks like by the time rollback runs. The manifest
+-- itself is never deleted by rollback (or by this migration) — it is
+-- permanent, append-only audit history.
+--
+-- CONCURRENCY
+-- ---------------------------------------------------------------------------
+-- Step 0 below takes an explicit `LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE`
+-- on all five tables this migration reads or writes, BEFORE the eligibility
+-- snapshot, so a concurrent box-score/game save (which takes the default
+-- ROW EXCLUSIVE mode for its own INSERT/UPDATE) cannot land between the
+-- eligibility snapshot and this migration's own writes and invalidate it —
+-- see docs/baseball/legacy-backfill-runbook.md's "Apply window" note. This
+-- does not self-conflict with this migration's own later INSERTs: Postgres
+-- never blocks a transaction on a lock it already holds itself, and
+-- SHARE ROW EXCLUSIVE is a superset of what ROW EXCLUSIVE (a plain INSERT)
+-- needs. It DOES block other sessions' writes to these tables for the
+-- duration of this transaction — apply only during a low-traffic window.
+--
-- Wrapped in an explicit BEGIN/COMMIT (precedented in this repo — see
-- 20260528041553_fix_coachhelm_settings_preferences_and_insight_types.sql) so
--- the two TEMP TABLE snapshots and all four INSERTs commit atomically as one
--- unit regardless of how the migration runner batches statements.
+-- the manifest table, the two TEMP TABLE snapshots, and all four INSERTs (plus
+-- their manifest records) commit atomically as one unit regardless of how the
+-- migration runner batches statements.
-- =============================================================================
BEGIN;
+-- ----------------------------------------------------------------------------
+-- Step -1 — permanent rollback manifest (service-role only). Created once;
+-- IF NOT EXISTS makes a second run of this file a no-op here too. RLS is
+-- enabled with no policies attached (deny-by-default for authenticated/anon);
+-- the explicit REVOKE below is the actual enforcement, RLS is defense in
+-- depth. service_role (the only writer/reader — the migration runner and any
+-- future rollback script) bypasses RLS entirely, as usual.
+-- ----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS public.baseball_legacy_backfill_manifest (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ run_tag text NOT NULL,
+ row_kind text NOT NULL CHECK (row_kind IN ('game', 'batting_row', 'pitching_row', 'season_stat_triple')),
+ game_id uuid,
+ player_id uuid,
+ team_id uuid,
+ season_year integer,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+ALTER TABLE public.baseball_legacy_backfill_manifest ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON TABLE public.baseball_legacy_backfill_manifest FROM PUBLIC, anon, authenticated;
+GRANT ALL ON TABLE public.baseball_legacy_backfill_manifest TO service_role;
+
+-- ----------------------------------------------------------------------------
+-- Step 0a — lock every table this migration reads or writes BEFORE taking the
+-- eligibility snapshot below, so a concurrent save cannot invalidate it. See
+-- "CONCURRENCY" above.
+-- ----------------------------------------------------------------------------
+LOCK TABLE public.baseball_player_stats,
+ public.baseball_games,
+ public.baseball_box_score_batting,
+ public.baseball_box_score_pitching,
+ public.baseball_player_season_stats
+ IN SHARE ROW EXCLUSIVE MODE;
+
-- ----------------------------------------------------------------------------
-- Step 0 — snapshot eligible ("zero box-score data") teams BEFORE any writes.
-- ----------------------------------------------------------------------------
-DROP TABLE IF EXISTS pg_temp._bb_legacy_backfill_379_teams;
-CREATE TEMP TABLE _bb_legacy_backfill_379_teams (
+DROP TABLE IF EXISTS pg_temp.baseball_legacy_backfill_379_teams;
+CREATE TEMP TABLE baseball_legacy_backfill_379_teams (
team_id uuid PRIMARY KEY
) ON COMMIT DROP;
-INSERT INTO _bb_legacy_backfill_379_teams (team_id)
+INSERT INTO baseball_legacy_backfill_379_teams (team_id)
SELECT DISTINCT ps.team_id
FROM public.baseball_player_stats ps
WHERE ps.stat_type = 'game'
@@ -191,8 +262,8 @@ WHERE ps.stat_type = 'game'
-- (team_id, session_date) only (mirrors #827's buildBoxScoreRowsForSessions:
-- "Scoped to TEAM + DATE only (never player_id)").
-- ----------------------------------------------------------------------------
-DROP TABLE IF EXISTS pg_temp._bb_legacy_backfill_379_games;
-CREATE TEMP TABLE _bb_legacy_backfill_379_games (
+DROP TABLE IF EXISTS pg_temp.baseball_legacy_backfill_379_games;
+CREATE TEMP TABLE baseball_legacy_backfill_379_games (
team_id uuid NOT NULL,
session_date date NOT NULL,
opponent_name text,
@@ -200,7 +271,7 @@ CREATE TEMP TABLE _bb_legacy_backfill_379_games (
PRIMARY KEY (team_id, session_date)
) ON COMMIT DROP;
-INSERT INTO _bb_legacy_backfill_379_games (team_id, session_date, opponent_name, game_id)
+INSERT INTO baseball_legacy_backfill_379_games (team_id, session_date, opponent_name, game_id)
WITH game_groups AS (
SELECT
ps.team_id,
@@ -215,7 +286,7 @@ WITH game_groups AS (
-- convention, not a real-data one).
MIN(NULLIF(TRIM(ps.session_name), '')) AS opponent_name
FROM public.baseball_player_stats ps
- JOIN _bb_legacy_backfill_379_teams t ON t.team_id = ps.team_id
+ JOIN baseball_legacy_backfill_379_teams t ON t.team_id = ps.team_id
WHERE ps.stat_type = 'game'
GROUP BY ps.team_id, ps.session_date
HAVING NOT EXISTS (
@@ -263,21 +334,31 @@ SELECT
)::uuid AS game_id
FROM hexed;
-INSERT INTO public.baseball_games (
- id, team_id, game_date, game_type, opponent_name, status, notes
+WITH inserted_games AS (
+ INSERT INTO public.baseball_games (
+ id, team_id, game_date, game_type, opponent_name, status, notes
+ )
+ SELECT
+ g.game_id,
+ g.team_id,
+ g.session_date,
+ 'game',
+ g.opponent_name,
+ 'completed',
+ 'Backfilled by #379 one-time legacy stats backfill from baseball_player_stats '
+ || '(copy-only; legacy rows untouched). Deterministic id — see '
+ || 'docs/baseball/legacy-backfill-runbook.md for rollback.'
+ FROM baseball_legacy_backfill_379_games g
+ ON CONFLICT (id) DO NOTHING
+ RETURNING id AS game_id, team_id
)
-SELECT
- g.game_id,
- g.team_id,
- g.session_date,
- 'game',
- g.opponent_name,
- 'completed',
- 'Backfilled by #379 one-time legacy stats backfill from baseball_player_stats '
- || '(copy-only; legacy rows untouched). Deterministic id — see '
- || 'docs/baseball/legacy-backfill-runbook.md for rollback.'
-FROM _bb_legacy_backfill_379_games g
-ON CONFLICT (id) DO NOTHING;
+-- Manifest record for exactly the games this run actually inserted (an
+-- ON CONFLICT DO NOTHING that hits an existing row returns nothing here, so
+-- a second run of this file adds zero manifest rows too — same idempotency
+-- guarantee as the data write itself).
+INSERT INTO public.baseball_legacy_backfill_manifest (run_tag, row_kind, game_id, team_id)
+SELECT 'baseball-legacy-backfill-379', 'game', game_id, team_id
+FROM inserted_games;
-- ----------------------------------------------------------------------------
-- Step 2 — batting lines. One per (game, player); dedupe via ROW_NUMBER when
@@ -286,7 +367,7 @@ ON CONFLICT (id) DO NOTHING;
WITH candidates AS (
SELECT ps.*, g.game_id
FROM public.baseball_player_stats ps
- JOIN _bb_legacy_backfill_379_games g
+ JOIN baseball_legacy_backfill_379_games g
ON g.team_id = ps.team_id AND g.session_date = ps.session_date
WHERE ps.stat_type = 'game'
),
@@ -360,22 +441,28 @@ rated AS (
final AS (
SELECT r.*, CASE WHEN r.obp IS NOT NULL AND r.slg IS NOT NULL THEN ROUND(r.obp + r.slg, 3) END AS ops
FROM rated r
+),
+inserted_batting AS (
+ INSERT INTO public.baseball_box_score_batting (
+ id, game_id, player_id, team_id,
+ ab, r, h, doubles, triples, hr, rbi, bb, k, sb, cs, hbp, sac, sf, lob, batting_order,
+ avg, obp, slg, ops
+ )
+ SELECT
+ bat_id, game_id, player_id, team_id,
+ ab,
+ 0, -- r (runs scored): no legacy column — honestly 0, not fabricated
+ h, doubles, triples, hr, rbi, bb, k, sb, cs, hbp, sac, sf,
+ 0, -- lob: no legacy column (CSV-import-only ephemeral field, never persisted)
+ NULL, -- batting_order: no legacy column
+ avg, obp, slg, ops
+ FROM final
+ ON CONFLICT (game_id, player_id) DO NOTHING
+ RETURNING game_id, player_id, team_id
)
-INSERT INTO public.baseball_box_score_batting (
- id, game_id, player_id, team_id,
- ab, r, h, doubles, triples, hr, rbi, bb, k, sb, cs, hbp, sac, sf, lob, batting_order,
- avg, obp, slg, ops
-)
-SELECT
- bat_id, game_id, player_id, team_id,
- ab,
- 0, -- r (runs scored): no legacy column — honestly 0, not fabricated
- h, doubles, triples, hr, rbi, bb, k, sb, cs, hbp, sac, sf,
- 0, -- lob: no legacy column (CSV-import-only ephemeral field, never persisted)
- NULL, -- batting_order: no legacy column
- avg, obp, slg, ops
-FROM final
-ON CONFLICT (game_id, player_id) DO NOTHING;
+INSERT INTO public.baseball_legacy_backfill_manifest (run_tag, row_kind, game_id, player_id, team_id)
+SELECT 'baseball-legacy-backfill-379', 'batting_row', game_id, player_id, team_id
+FROM inserted_batting;
-- ----------------------------------------------------------------------------
-- Step 3 — pitching lines. Only legacy rows that actually recorded innings
@@ -384,7 +471,7 @@ ON CONFLICT (game_id, player_id) DO NOTHING;
WITH candidates AS (
SELECT ps.*, g.game_id
FROM public.baseball_player_stats ps
- JOIN _bb_legacy_backfill_379_games g
+ JOIN baseball_legacy_backfill_379_games g
ON g.team_id = ps.team_id AND g.session_date = ps.session_date
WHERE ps.stat_type = 'game'
AND ps.innings_pitched IS NOT NULL
@@ -456,19 +543,25 @@ rated AS (
CASE WHEN outs > 0 THEN ROUND(9.0 * k / (outs / 3.0), 2) END AS k9,
CASE WHEN outs > 0 THEN ROUND(9.0 * bb / (outs / 3.0), 2) END AS bb9
FROM outsed
+),
+inserted_pitching AS (
+ INSERT INTO public.baseball_box_score_pitching (
+ id, game_id, player_id, team_id, ip, h, r, er, bb, k, hr, pitch_count, strikes, result,
+ era, whip, k9, bb9
+ )
+ SELECT
+ pit_id, game_id, player_id, team_id, ip, h, r, er, bb, k,
+ 0, -- hr (home runs allowed): no legacy column — honestly 0, not fabricated
+ pitch_count, strikes,
+ NULL, -- result (W/L/S/H/BS/ND): no legacy column, decision unknown
+ era, whip, k9, bb9
+ FROM rated
+ ON CONFLICT (game_id, player_id) DO NOTHING
+ RETURNING game_id, player_id, team_id
)
-INSERT INTO public.baseball_box_score_pitching (
- id, game_id, player_id, team_id, ip, h, r, er, bb, k, hr, pitch_count, strikes, result,
- era, whip, k9, bb9
-)
-SELECT
- pit_id, game_id, player_id, team_id, ip, h, r, er, bb, k,
- 0, -- hr (home runs allowed): no legacy column — honestly 0, not fabricated
- pitch_count, strikes,
- NULL, -- result (W/L/S/H/BS/ND): no legacy column, decision unknown
- era, whip, k9, bb9
-FROM rated
-ON CONFLICT (game_id, player_id) DO NOTHING;
+INSERT INTO public.baseball_legacy_backfill_manifest (run_tag, row_kind, game_id, player_id, team_id)
+SELECT 'baseball-legacy-backfill-379', 'pitching_row', game_id, player_id, team_id
+FROM inserted_pitching;
-- ----------------------------------------------------------------------------
-- Step 4 — season-stat seed for exactly the (player_id, team_id, season_year)
@@ -493,12 +586,12 @@ ON CONFLICT (game_id, player_id) DO NOTHING;
WITH bb379_touched AS (
SELECT DISTINCT bsb.player_id, bsb.team_id, EXTRACT(YEAR FROM bg.game_date)::integer AS season_year
FROM public.baseball_box_score_batting bsb
- JOIN _bb_legacy_backfill_379_games tg ON tg.game_id = bsb.game_id
+ JOIN baseball_legacy_backfill_379_games tg ON tg.game_id = bsb.game_id
JOIN public.baseball_games bg ON bg.id = bsb.game_id
UNION
SELECT DISTINCT bsp.player_id, bsp.team_id, EXTRACT(YEAR FROM bg.game_date)::integer AS season_year
FROM public.baseball_box_score_pitching bsp
- JOIN _bb_legacy_backfill_379_games tg ON tg.game_id = bsp.game_id
+ JOIN baseball_legacy_backfill_379_games tg ON tg.game_id = bsp.game_id
JOIN public.baseball_games bg ON bg.id = bsp.game_id
),
bb379_bat_agg AS (
@@ -595,36 +688,42 @@ bb379_pit_final AS (
CASE WHEN p.ip > 0 THEN ROUND(9.0 * p.k_thrown / p.ip, 2) END AS k9,
CASE WHEN p.ip > 0 THEN ROUND(9.0 * p.bb_allowed / p.ip, 2) END AS bb9
FROM bb379_pit_agg p
+),
+inserted_season AS (
+ INSERT INTO public.baseball_player_season_stats (
+ player_id, team_id, season_year,
+ g, ab, r, h, doubles, triples, hr, rbi, bb, k, sb, cs, hbp, sac, sf,
+ ibb, gidp, roe, two_out_rbi, lob,
+ avg, obp, slg, ops,
+ g_p, gs, w, l, sv, ip, h_allowed, r_allowed, er, bb_allowed, k_thrown, hr_allowed,
+ gf, holds, blown_saves, bf, p_hbp, wp,
+ era, whip, k9, bb9,
+ last_updated
+ )
+ SELECT
+ t.player_id, t.team_id, t.season_year,
+ COALESCE(bat.g, 0), COALESCE(bat.ab, 0), COALESCE(bat.r, 0), COALESCE(bat.h, 0),
+ COALESCE(bat.doubles, 0), COALESCE(bat.triples, 0), COALESCE(bat.hr, 0), COALESCE(bat.rbi, 0),
+ COALESCE(bat.bb, 0), COALESCE(bat.k, 0), COALESCE(bat.sb, 0), COALESCE(bat.cs, 0),
+ COALESCE(bat.hbp, 0), COALESCE(bat.sac, 0), COALESCE(bat.sf, 0),
+ COALESCE(bat.ibb, 0), COALESCE(bat.gidp, 0), COALESCE(bat.roe, 0), COALESCE(bat.two_out_rbi, 0), COALESCE(bat.lob, 0),
+ bat.avg, bat.obp, bat.slg, bat.ops,
+ COALESCE(pit.g_p, 0), 0, COALESCE(pit.w, 0), COALESCE(pit.l, 0), COALESCE(pit.sv, 0),
+ COALESCE(pit.ip, 0), COALESCE(pit.h_allowed, 0), COALESCE(pit.r_allowed, 0), COALESCE(pit.er, 0),
+ COALESCE(pit.bb_allowed, 0), COALESCE(pit.k_thrown, 0), COALESCE(pit.hr_allowed, 0),
+ COALESCE(pit.gf, 0), COALESCE(pit.holds, 0), COALESCE(pit.blown_saves, 0), COALESCE(pit.bf, 0),
+ COALESCE(pit.p_hbp, 0), COALESCE(pit.wp, 0),
+ pit.era, pit.whip, pit.k9, pit.bb9,
+ now()
+ FROM bb379_touched t
+ LEFT JOIN bb379_bat_final bat ON bat.player_id = t.player_id AND bat.team_id = t.team_id AND bat.season_year = t.season_year
+ LEFT JOIN bb379_pit_final pit ON pit.player_id = t.player_id AND pit.team_id = t.team_id AND pit.season_year = t.season_year
+ ON CONFLICT (player_id, team_id, season_year) DO NOTHING
+ RETURNING player_id, team_id, season_year
)
-INSERT INTO public.baseball_player_season_stats (
- player_id, team_id, season_year,
- g, ab, r, h, doubles, triples, hr, rbi, bb, k, sb, cs, hbp, sac, sf,
- ibb, gidp, roe, two_out_rbi, lob,
- avg, obp, slg, ops,
- g_p, gs, w, l, sv, ip, h_allowed, r_allowed, er, bb_allowed, k_thrown, hr_allowed,
- gf, holds, blown_saves, bf, p_hbp, wp,
- era, whip, k9, bb9,
- last_updated
-)
-SELECT
- t.player_id, t.team_id, t.season_year,
- COALESCE(bat.g, 0), COALESCE(bat.ab, 0), COALESCE(bat.r, 0), COALESCE(bat.h, 0),
- COALESCE(bat.doubles, 0), COALESCE(bat.triples, 0), COALESCE(bat.hr, 0), COALESCE(bat.rbi, 0),
- COALESCE(bat.bb, 0), COALESCE(bat.k, 0), COALESCE(bat.sb, 0), COALESCE(bat.cs, 0),
- COALESCE(bat.hbp, 0), COALESCE(bat.sac, 0), COALESCE(bat.sf, 0),
- COALESCE(bat.ibb, 0), COALESCE(bat.gidp, 0), COALESCE(bat.roe, 0), COALESCE(bat.two_out_rbi, 0), COALESCE(bat.lob, 0),
- bat.avg, bat.obp, bat.slg, bat.ops,
- COALESCE(pit.g_p, 0), 0, COALESCE(pit.w, 0), COALESCE(pit.l, 0), COALESCE(pit.sv, 0),
- COALESCE(pit.ip, 0), COALESCE(pit.h_allowed, 0), COALESCE(pit.r_allowed, 0), COALESCE(pit.er, 0),
- COALESCE(pit.bb_allowed, 0), COALESCE(pit.k_thrown, 0), COALESCE(pit.hr_allowed, 0),
- COALESCE(pit.gf, 0), COALESCE(pit.holds, 0), COALESCE(pit.blown_saves, 0), COALESCE(pit.bf, 0),
- COALESCE(pit.p_hbp, 0), COALESCE(pit.wp, 0),
- pit.era, pit.whip, pit.k9, pit.bb9,
- now()
-FROM bb379_touched t
-LEFT JOIN bb379_bat_final bat ON bat.player_id = t.player_id AND bat.team_id = t.team_id AND bat.season_year = t.season_year
-LEFT JOIN bb379_pit_final pit ON pit.player_id = t.player_id AND pit.team_id = t.team_id AND pit.season_year = t.season_year
-ON CONFLICT (player_id, team_id, season_year) DO NOTHING;
+INSERT INTO public.baseball_legacy_backfill_manifest (run_tag, row_kind, player_id, team_id, season_year)
+SELECT 'baseball-legacy-backfill-379', 'season_stat_triple', player_id, team_id, season_year
+FROM inserted_season;
COMMIT;
@@ -715,4 +814,14 @@ COMMIT;
-- NOT in the BEFORE result was seeded by Step 4 and is safe to remove on
-- rollback; any row that WAS in the BEFORE result is the pre-existing
-- baseline Step 4 deliberately left untouched.)
+
+-- ---- AFTER: manifest audit — every row this run recorded, by kind ----
+-- SELECT row_kind, count(*) AS n
+-- FROM public.baseball_legacy_backfill_manifest
+-- WHERE run_tag = 'baseball-legacy-backfill-379'
+-- GROUP BY row_kind
+-- ORDER BY row_kind;
+-- (This is the authoritative rollback source — see the runbook's "Rollback
+-- story" for the manifest-join DELETE statements. It is never itself deleted
+-- by rollback; it is permanent, append-only history.)
-- =============================================================================
From a3b6f2957fa3735fd28e5001323d5d71b2dae35a Mon Sep 17 00:00:00 2001
From: Fable Integrator
Date: Wed, 15 Jul 2026 21:29:40 -0400
Subject: [PATCH 14/18] fix(baseball): validate invite code is alphanumeric
before router.push (CodeRabbit #868)
The hint text promises "letters and numbers" but only length was checked,
letting URI-breaking characters (?, #, /) reach router.push(`/baseball/join/${trimmed}`).
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
---
src/app/baseball/join/page.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/app/baseball/join/page.tsx b/src/app/baseball/join/page.tsx
index aeb5a8f76..593875432 100644
--- a/src/app/baseball/join/page.tsx
+++ b/src/app/baseball/join/page.tsx
@@ -45,6 +45,11 @@ export default function BaseballJoinPage() {
return;
}
+ if (!/^[A-Z0-9]+$/.test(trimmed)) {
+ setError('Invite code must contain only letters and numbers.');
+ return;
+ }
+
router.push(`/baseball/join/${trimmed}`);
};
From da60296b61ee9cb2a93fc992b3b9b387147d8dc4 Mon Sep 17 00:00:00 2001
From: Fable Integrator
Date: Wed, 15 Jul 2026 21:29:52 -0400
Subject: [PATCH 15/18] fix(baseball): suppressHydrationWarning on
legacy-upload created_at cell (CodeRabbit #868)
toLocaleDateString() formats with the server's locale/timezone during SSR
but the browser's on hydration, risking a mismatch warning. Matches this
repo's existing suppressHydrationWarning-on-the-enclosing-element precedent
(LocalTime.tsx, RelativeTime.tsx, Fairway calendar/announcements components).
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
---
src/components/baseball/import-center/ImportWizardClient.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/components/baseball/import-center/ImportWizardClient.tsx b/src/components/baseball/import-center/ImportWizardClient.tsx
index 04e196401..4e095f5f8 100644
--- a/src/components/baseball/import-center/ImportWizardClient.tsx
+++ b/src/components/baseball/import-center/ImportWizardClient.tsx
@@ -1740,7 +1740,7 @@ export function ImportWizardClient({
-
+
{u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'}
From 9f5e7b7140e2eb9f4b6fc6fbc1a60488ccec5527 Mon Sep 17 00:00:00 2001
From: Fable Integrator
Date: Wed, 15 Jul 2026 21:30:03 -0400
Subject: [PATCH 16/18] fix(baseball): hide Stats Center import actions for
staff with neither capability (CodeRabbit #868)
canManageImports=false conflated stats-capable staff (routed through the
/stats/upload shim) with staff holding NEITHER can_manage_imports nor
can_manage_stats, whom both routes would just bounce off their own
middleware gate. page.tsx now Promise.all's a second hasBaseballCapability
call for can_manage_stats and passes both down; StatsCenterClient renders
the header "Import Center" action and the empty-state "Import a box score"
CTA only when canManageImports || canManageStats holds, keeping the existing
importEntryHref branch for the visible cases.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
---
.../dashboard/stats-center/page.tsx | 9 +++-
.../stats-center/StatsCenterClient.tsx | 50 ++++++++++++++-----
2 files changed, 46 insertions(+), 13 deletions(-)
diff --git a/src/app/baseball/(dashboard)/dashboard/stats-center/page.tsx b/src/app/baseball/(dashboard)/dashboard/stats-center/page.tsx
index a972d4b59..19f802fa9 100644
--- a/src/app/baseball/(dashboard)/dashboard/stats-center/page.tsx
+++ b/src/app/baseball/(dashboard)/dashboard/stats-center/page.tsx
@@ -103,7 +103,7 @@ export default async function StatsCenterPage({
// read model that feeds the V10 chart gallery (chase/whiff/EV-LA/spray/
// pitch-shape/velo-decay). The event read model is itself can_manage_stats-
// gated and returns empty visuals when no granular events are captured.
- const [model, visualsPayload, canManageImports] = await Promise.all([
+ const [model, visualsPayload, canManageImports, canManageStats] = await Promise.all([
getStatsCenter(context.activeTeamId, options),
getStatVisualsPayload(context.activeTeamId, {
fromDate: options.fromDate ?? null,
@@ -114,6 +114,12 @@ export default async function StatsCenterPage({
// capability-aware /stats/upload shim for everyone else. Same helper the
// shim itself branches on, so the two stay in agreement.
hasBaseballCapability(context.activeTeamId, 'can_manage_imports'),
+ // Whether the shim destination (/stats/upload, middleware-gated on
+ // can_manage_stats) is even reachable for this viewer. Staff holding
+ // NEITHER capability get no import entry point at all — routing them to
+ // either destination would just bounce them off that destination's own
+ // middleware gate.
+ hasBaseballCapability(context.activeTeamId, 'can_manage_stats'),
]);
// The full V10 chart payload (every visual family). Undefined ONLY when the
@@ -124,6 +130,7 @@ export default async function StatsCenterPage({
}
- onClick={() => router.push(importEntryHref)}
- >
- Import Center
-
+ Center's can_manage_imports gate. Staff holding NEITHER capability
+ (canShowImportEntry false) get no import action at all — every
+ destination this button could route to gates on one of these two
+ capabilities, so showing it would only produce a dead button. */}
+ {canShowImportEntry && (
+ }
+ onClick={() => router.push(importEntryHref)}
+ >
+ Import Center
+
+ )}
router.push(importEntryHref)}>
- Import a box score
-
+ canShowImportEntry ? (
+ router.push(importEntryHref)}>
+ Import a box score
+
+ ) : undefined
}
/>
) : (
From e51a3c0dce3ee16b04e2f93c301eaf6de9187f4d Mon Sep 17 00:00:00 2001
From: Fable Integrator
Date: Wed, 15 Jul 2026 21:30:17 -0400
Subject: [PATCH 17/18] fix(baseball): filter provenance to the reading-bearing
rows sampleSize counts (CodeRabbit #868)
avg_exit_velocity/avg_launch_angle (hitting) and avg_velocity (pitching) each
correctly narrow sampleSize to rows with an actual non-null reading, but
still passed the FULL bbProv/pProv array (every batted ball / pitch,
hand-charted or radar-read) into dominantTrust/dominantContext. A majority
of hand-charted, no-reading rows could drag trustTier down to 'unverified'
even when every row that fed the average was 'official' radar data. Pass the
same `.filter(reading != null)` array as provenance in all three call sites.
Extends the #864 sampleSize-honesty suite with mixed-trust regression tests
(few official radar rows + many unverified hand-charted rows -> trustTier
must reflect only the radar rows) for the batting and pitching paths.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
---
.../__tests__/elite-stat-events.test.ts | 54 +++++++++++++++++++
.../baseball/read-models/elite-stat-events.ts | 20 +++++--
2 files changed, 71 insertions(+), 3 deletions(-)
diff --git a/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts b/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts
index 09db9f330..3ddb8892e 100644
--- a/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts
+++ b/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts
@@ -244,6 +244,60 @@ describe('buildHitterMetrics — avg_exit_velocity / avg_launch_angle sampleSize
});
});
+// =============================================================================
+// Provenance (trustTier/dataContext) must reflect the SAME reading-bearing
+// rows sampleSize counts, not the full battedBalls/pitches array. Regression
+// coverage for the post-#864 review fix: dominantTrust/dominantContext used
+// to receive bbProv/pProv (every row) even for the velocity-gated scalar
+// metrics, so a majority of hand-charted (no-reading, unverified) rows could
+// drag trustTier down to 'unverified' even when every row that actually fed
+// the average was 'official' radar data.
+// =============================================================================
+describe('buildHitterMetrics / buildPitcherMetrics — provenance matches the reading-bearing rows, not the full row set', () => {
+ it('avg_exit_velocity reports the trust tier of the radar rows that fed it, unaffected by MORE-numerous hand-charted rows with no reading', () => {
+ const battedBalls = [
+ // 2 radar-tracked batted balls: the only rows with an actual reading.
+ bbe({ exit_velocity: 95, trust_tier: 'official' }),
+ bbe({ exit_velocity: 98, trust_tier: 'official' }),
+ // 5 hand-charted batted balls: no velocity reading, weaker trust tier.
+ // If provenance were still the full bbProv array, these would
+ // outnumber the radar rows 5-to-2 and drag trustTier down to
+ // 'unverified' even though they contributed NOTHING to the average.
+ ...Array.from({ length: 5 }, () => bbe({ exit_velocity: null, trust_tier: 'unverified' })),
+ ];
+ const model = buildHitterMetrics('p1', [], battedBalls, 'official_game');
+ const m = metric(model, 'avg_exit_velocity')!;
+ expect(m.sampleSize).toBe(2);
+ expect(m.value).toBeCloseTo((95 + 98) / 2);
+ expect(m.trustTier).toBe('official');
+ });
+
+ it('avg_launch_angle reports the trust tier of the reading-bearing rows only', () => {
+ const battedBalls = [
+ bbe({ launch_angle: 12, trust_tier: 'official' }),
+ bbe({ launch_angle: 18, trust_tier: 'official' }),
+ ...Array.from({ length: 5 }, () => bbe({ launch_angle: null, trust_tier: 'unverified' })),
+ ];
+ const model = buildHitterMetrics('p1', [], battedBalls, 'official_game');
+ const m = metric(model, 'avg_launch_angle')!;
+ expect(m.sampleSize).toBe(2);
+ expect(m.trustTier).toBe('official');
+ });
+
+ it('avg_velocity (pitching) reports the trust tier of the radar-tracked pitches, unaffected by MORE-numerous hand-charted pitches with no velocity reading', () => {
+ const pitches = [
+ pitch({ velocity: 92, trust_tier: 'official' }),
+ pitch({ velocity: 94, trust_tier: 'official' }),
+ ...Array.from({ length: 5 }, () => pitch({ velocity: null, trust_tier: 'unverified' })),
+ ];
+ const model = buildPitcherMetrics('p1', pitches, [], 'official_game');
+ const m = metric(model, 'avg_velocity')!;
+ expect(m.sampleSize).toBe(2);
+ expect(m.value).toBeCloseTo((92 + 94) / 2);
+ expect(m.trustTier).toBe('official');
+ });
+});
+
describe('honest confidence + provenance', () => {
it('never returns high on a thin sample', () => {
const thin = buildHitterMetrics('p1', Array.from({ length: 5 }, () => pitch({ is_in_zone: false, is_swing: true })), [], 'official_game');
diff --git a/src/lib/baseball/read-models/elite-stat-events.ts b/src/lib/baseball/read-models/elite-stat-events.ts
index 339521fec..7e4283112 100644
--- a/src/lib/baseball/read-models/elite-stat-events.ts
+++ b/src/lib/baseball/read-models/elite-stat-events.ts
@@ -752,7 +752,12 @@ export function buildHitterMetrics(
// gateSample() honesty depends on it.
avgOf(battedBalls.map((b) => b.exit_velocity)),
battedBalls.filter((b) => b.exit_velocity != null).length,
- bbProv,
+ // Provenance must be the SAME reading-bearing rows sampleSize counts —
+ // NOT bbProv (every batted ball, hand-charted or radar-read). Passing
+ // the unfiltered set lets hand-charted rows with no reading (typically
+ // `unverified`) drag trustTier/dataContext down even when every row
+ // that actually fed the average was `official` radar data.
+ battedBalls.filter((b) => b.exit_velocity != null),
),
scalarMetric(
{ metricKey: 'avg_launch_angle', metricGroup: 'hitting', label: 'Avg Launch Angle', unit: 'deg', higherIsBetter: true, threshold: SCALAR_THRESHOLD, fallbackContext },
@@ -760,7 +765,8 @@ export function buildHitterMetrics(
// is independently nullable per row.
avgOf(battedBalls.map((b) => b.launch_angle)),
battedBalls.filter((b) => b.launch_angle != null).length,
- bbProv,
+ // Same provenance-must-match-sampleSize fix as avg_exit_velocity above.
+ battedBalls.filter((b) => b.launch_angle != null),
),
];
@@ -1000,7 +1006,15 @@ export function buildPitcherMetrics(
),
scalarMetric(
{ metricKey: 'avg_velocity', metricGroup: 'pitching', label: 'Avg Velocity', unit: 'mph', higherIsBetter: true, threshold: SCALAR_THRESHOLD, fallbackContext },
- avgOf(pitches.map((p) => p.velocity)), pitches.filter((p) => p.velocity != null).length, pProv,
+ // Same provenance-must-match-sampleSize rule as the batting-side
+ // avg_exit_velocity/avg_launch_angle above — velocity is nullable per
+ // pitch (a hand-charted pitch with no radar gun logs no velocity), so
+ // pProv (every pitch, radar-read or not) would let non-reading rows
+ // drag trustTier/dataContext down below what the reading-bearing rows
+ // actually warrant.
+ avgOf(pitches.map((p) => p.velocity)),
+ pitches.filter((p) => p.velocity != null).length,
+ pitches.filter((p) => p.velocity != null),
),
];
From 528ef83d5dd220cec9d3728b3bc3fc7b0394168f Mon Sep 17 00:00:00 2001
From: Fable Integrator
Date: Wed, 15 Jul 2026 21:30:30 -0400
Subject: [PATCH 18/18] fix(baseball): resolve capability requirement inside
guarded flow + reject empty results (CodeRabbit #868)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two related fixes to withBaseballAction:
- The (possibly args-conditional) requiredCapability resolver ran BEFORE
Sentry.withScope/the wrapper's own try/catch even started, so a throwing
resolver (e.g. a malformed/omitted argument) threw raw and unsanitized,
skipping AUTH, Sentry, and logServerException entirely. Resolution now
happens inside the guarded try/catch, right after AUTH resolves and before
capability enforcement — a throwing resolver now produces the same
sanitized BaseballActionError + Sentry-logged path as any other action
failure. Still resolved exactly once, from the same args reference; tags/
breadcrumbs are set from the resolved value immediately afterward.
- requiredCapability's array forms are now typed as non-empty tuples
(readonly [BaseballCapability, ...BaseballCapability[]]) so `[]` is a
compile-time error, and a resolver that manufactures an empty array at
runtime anyway is rejected with a thrown BaseballCapabilityError (fail
closed) instead of falling through to `resolvedCapabilityList[-1]` ===
undefined being passed to requireBaseballCapability.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa
---
src/lib/baseball/with-baseball-action.ts | 146 ++++++++++++++++++-----
1 file changed, 114 insertions(+), 32 deletions(-)
diff --git a/src/lib/baseball/with-baseball-action.ts b/src/lib/baseball/with-baseball-action.ts
index ff9389a55..934c44d64 100644
--- a/src/lib/baseball/with-baseball-action.ts
+++ b/src/lib/baseball/with-baseball-action.ts
@@ -15,12 +15,20 @@
// SERVER-SIDE via requireBaseballCapability() against the
// resolved team. Head/primary coach implicitly hold all caps;
// non-staff / suspended hold none. requiredCapability may
-// also be a readonly array (ANY-of / OR) or a resolver
-// function of the action's own args (evaluated once, before
-// AUTH) that RETURNS a single capability or an ANY-of array
-// — e.g. relaxing the gate only for one client-declared
-// shape/kind while every other shape keeps the original,
-// single, stricter capability.
+// also be a non-empty readonly array (ANY-of / OR) or a
+// resolver function of the action's own args that RETURNS a
+// single capability or a non-empty ANY-of array — e.g.
+// relaxing the gate only for one client-declared shape/kind
+// while every other shape keeps the original, single,
+// stricter capability. Resolved exactly ONCE per call,
+// INSIDE this wrapper's own guarded try/catch (after AUTH,
+// before enforcement) — a throwing resolver (e.g. a
+// malformed/omitted argument) is therefore sanitized and
+// logged through the same BaseballActionError + Sentry path
+// as any other action-body failure, never thrown raw. A
+// resolver that returns an empty array at runtime is
+// rejected (fail closed) rather than silently skipping
+// enforcement.
// 4. OBSERVABILITY — run the action inside a Sentry scope tagged
// { sport:'baseball', feature:, action: }
// with user + breadcrumbs, and on throw route the error
@@ -167,7 +175,8 @@ export interface WithBaseballActionOptions
*
* Three forms:
* - a single BaseballCapability : the existing, most common form.
- * - a readonly array of BaseballCapability : ANY-of (OR) — the staff member
+ * - a NON-EMPTY readonly array of
+ * BaseballCapability : ANY-of (OR) — the staff member
* need only hold ONE of the listed
* capabilities. On a miss, the
* thrown BaseballCapabilityError
@@ -177,17 +186,36 @@ export interface WithBaseballActionOptions
* a fully-unauthorized caller sees
* the same error identity as a
* single-capability gate would).
- * - (...args) => single | array : resolve the requirement from the
+ * - (...args) => single | non-empty array : resolve the requirement from the
* action's OWN arguments (e.g. gate
* on a client-supplied shape/kind
- * field) — evaluated once per call,
- * before AUTH, from the SAME `args`
- * the action body receives.
+ * field) — evaluated exactly ONCE
+ * per call, from the SAME `args`
+ * the action body receives, INSIDE
+ * this wrapper's own guarded
+ * try/catch (after AUTH resolves,
+ * before capability enforcement
+ * runs) — a throwing resolver is
+ * sanitized + logged exactly like
+ * any other action failure, never
+ * thrown raw. A resolver that
+ * returns an empty array at
+ * runtime (despite the non-empty
+ * tuple type below) is rejected —
+ * fail closed, enforcement is
+ * never silently skipped.
+ *
+ * The array forms are typed as non-empty tuples
+ * (`readonly [BaseballCapability, ...BaseballCapability[]]`) so `[]` is a
+ * compile-time error; the runtime check above catches a resolver that
+ * manufactures an empty array anyway (e.g. from an empty input list).
*/
requiredCapability?:
| BaseballCapability
- | readonly BaseballCapability[]
- | ((...args: TArgs) => BaseballCapability | readonly BaseballCapability[]);
+ | readonly [BaseballCapability, ...BaseballCapability[]]
+ | ((...args: TArgs) =>
+ | BaseballCapability
+ | readonly [BaseballCapability, ...BaseballCapability[]]);
/**
* When set, the wrapper enforces a PLAYER/GUARDIAN-access toggle SERVER-SIDE
* (via requirePlayerAccess) against the resolved team before the body runs.
@@ -291,23 +319,19 @@ export function withBaseballAction(
} = opts;
return async (...args: TArgs): Promise => {
- // Resolve the (possibly args-conditional) capability requirement ONCE, up
- // front, from the SAME args the action body receives below — so the
- // observability tags/metadata and the actual enforcement in step 3 can
- // never disagree about what was required. A plain single-capability opt
- // (the ~60 existing call sites) resolves to a one-element list here, so
- // every branch below behaves byte-identically to the pre-existing
- // single-capability path.
- const resolvedCapability =
- typeof requiredCapability === 'function'
- ? requiredCapability(...args)
- : requiredCapability;
- const resolvedCapabilityList: readonly BaseballCapability[] | null = resolvedCapability
- ? Array.isArray(resolvedCapability)
- ? resolvedCapability
- : [resolvedCapability as BaseballCapability]
- : null;
- const resolvedCapabilityTag = resolvedCapabilityList?.join('|') ?? null;
+ // The (possibly args-conditional) capability requirement is resolved
+ // exactly ONCE per call, from the SAME `args` the action body receives
+ // below — but NOT here. It is resolved INSIDE the guarded try/catch below
+ // (see step 1c, after AUTH), not before Sentry.withScope even starts, so a
+ // throwing resolver (e.g. a malformed/omitted argument) is caught by this
+ // wrapper's own error handling and produces the same sanitized
+ // BaseballActionError + Sentry-logged path as any other action-body
+ // failure — never a raw, unsanitized throw straight out of this function
+ // before any observability wiring has run. These are declared here (not
+ // `const` inside the try) only so buildTraceContext's closure below can
+ // read whatever they resolve to, from either the success or catch path.
+ let resolvedCapabilityList: readonly BaseballCapability[] | null = null;
+ let resolvedCapabilityTag: string | null = null;
return Sentry.withScope(async (scope) => {
// Stable scope identity for every trace emitted from this action.
@@ -322,7 +346,10 @@ export function withBaseballAction(
data: {
feature,
featureArea,
- requiredCapability: resolvedCapabilityTag,
+ // requiredCapability is intentionally omitted here: it isn't
+ // resolved yet at this point (see step 1c below, inside the try) —
+ // logging it now would always show `null`. The "capability
+ // requirement resolved" breadcrumb added in step 1c carries it.
requiredPlayerAccess: requiredPlayerAccess ?? null,
},
});
@@ -396,6 +423,60 @@ export function withBaseballAction(
throw new BaseballDemoReadOnlyError();
}
+ // -------------------------------------------------------------------
+ // 1c. Resolve the (possibly args-conditional) capability requirement.
+ // Moved here (post-review fix) from BEFORE Sentry.withScope even
+ // started to HERE — inside this try/catch, after AUTH — so a
+ // throwing resolver (e.g. a malformed/omitted argument) is caught
+ // by this wrapper's own error handling below and produces the
+ // same sanitized BaseballActionError + Sentry-logged path as any
+ // other action-body failure, instead of a raw, unsanitized throw
+ // that skipped every bit of this wrapper's observability. Still
+ // resolved exactly ONCE, from the SAME `args` reference the
+ // action body receives (unchanged from before this move) — the
+ // auth/write coupling (capability always checked against the
+ // same args the body runs with) is preserved.
+ // -------------------------------------------------------------------
+ const resolvedCapability =
+ typeof requiredCapability === 'function'
+ ? requiredCapability(...args)
+ : requiredCapability;
+ resolvedCapabilityList = resolvedCapability
+ ? Array.isArray(resolvedCapability)
+ ? resolvedCapability
+ : [resolvedCapability as BaseballCapability]
+ : null;
+ // Fail closed: a resolver typed to return a non-empty tuple can still
+ // manufacture an empty array at runtime (e.g. an empty input list).
+ // Treating `[]` as "no capability required" would silently SKIP
+ // enforcement entirely — instead, reject it the same way any other
+ // capability miss is rejected (a thrown BaseballCapabilityError),
+ // same fail-closed posture as this codebase's other capability
+ // defaults (e.g. imports.ts's can_manage_imports fallback for an
+ // unrecognized shape).
+ if (resolvedCapabilityList && resolvedCapabilityList.length === 0) {
+ // Team id isn't resolved yet at this point in the flow (CONTEXT
+ // runs next) — an empty string here is fine, the thrown error is
+ // caught as a recognized control-flow error below regardless of
+ // teamId, and the resolver bug it flags has nothing to do with
+ // which team was targeted.
+ throw new BaseballCapabilityError(
+ 'can_manage_imports',
+ '',
+ 'Capability requirement resolved to an empty set — denying by default.',
+ );
+ }
+ resolvedCapabilityTag = resolvedCapabilityList?.join('|') ?? null;
+ if (resolvedCapabilityTag) {
+ scope.setTag('baseball_capability', resolvedCapabilityTag);
+ }
+ scope.addBreadcrumb({
+ category: 'baseball.action',
+ message: `capability requirement resolved for ${name}`,
+ level: 'info',
+ data: { requiredCapability: resolvedCapabilityTag },
+ });
+
// -------------------------------------------------------------------
// 2. CONTEXT — resolve the server-validated active baseball context.
// -------------------------------------------------------------------
@@ -462,7 +543,8 @@ export function withBaseballAction(
'Could not resolve a team for capability enforcement.',
);
}
- scope.setTag('baseball_capability', resolvedCapabilityTag!);
+ // baseball_capability tag already set in step 1c, right after
+ // resolution — not repeated here.
if (resolvedCapabilityList.length === 1) {
await requireBaseballCapability(targetTeamId, resolvedCapabilityList[0]!);
} else {