Skip to content
19 changes: 16 additions & 3 deletions e2e/baseball-box-score.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ const SEEDED =
const SCHEDULED_OPPONENT = 'Riverside University';
const COMPLETED_OPPONENT = 'Eastview College';

/**
* Matches a game detail redirect, e.g. `/stats/games/<uuid>`.
*
* `baseball_games.id` is a Postgres `uuid` (`DEFAULT gen_random_uuid()`), so
* matching the UUID shape — instead of the previous loose
* `[a-zA-Z0-9-]+$` — structurally excludes `/stats/games/create` (issue
* #952): the create-form route itself is 6 letters, never a UUID, so a
* submit that silently hangs on the create page (no redirect at all) can no
* longer false-pass this assertion.
*/
const GAME_DETAIL_URL_RE =
/\/stats\/games\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/**
* Service-role Supabase client for teardown-only writes (deleting rows this
* spec itself created) — provided by scripts/e2e-supabase-admin.ts so this
Expand Down Expand Up @@ -185,7 +198,7 @@ test.describe('Coach - Create New Game', () => {
await page.locator('#new-game-venue').fill('E2E Created Field');
await page.getByRole('button', { name: /Create Game/i }).click();

await expect(page).toHaveURL(/\/stats\/games\/[a-zA-Z0-9-]+$/, { timeout: 8000 });
await expect(page).toHaveURL(GAME_DETAIL_URL_RE, { timeout: 8000 });
await waitForPageLoad(page);

// Newly created, uncompleted game lands directly on the manual entry form.
Expand Down Expand Up @@ -269,7 +282,7 @@ test.describe('Coach - Manual Box Score Entry', () => {

await page.getByRole('button', { name: /Save Box Score/i }).click();

await expect(page).toHaveURL(/\/stats\/games\/[a-zA-Z0-9-]+$/, { timeout: 8000 });
await expect(page).toHaveURL(GAME_DETAIL_URL_RE, { timeout: 8000 });
await waitForPageLoad(page);

// BoxScoreView now renders the just-completed game (read-only display —
Expand Down Expand Up @@ -349,7 +362,7 @@ test.describe('Coach - Games List and Box Score View', () => {
const gameCard = page.locator('[data-testid="game-card"]', { hasText: COMPLETED_OPPONENT });
await gameCard.getByRole('link').click();
await waitForPageLoad(page);
await expect(page).toHaveURL(/\/stats\/games\/[a-zA-Z0-9-]+$/);
await expect(page).toHaveURL(GAME_DETAIL_URL_RE);
});

test('should display the box score view with the FINAL score and result badge', async ({ page }) => {
Expand Down
36 changes: 36 additions & 0 deletions src/app/admin/golf/__tests__/honest-rounds-delta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { honestRoundsDelta } from '../honest-rounds-delta';

/**
* ============================================================================
* honestRoundsDelta (bug #949 #6 — "Rounds this week" stray arrow, no sparkline)
* ----------------------------------------------------------------------------
* The KpiTile's `delta` used to be unconditional (`roundsThisWeek -
* roundsLastWeek`), decoupled from the `trendData` sparkline's own 2-point
* floor. A team with <2 weeks of `roundsByWeek` history rendered the
* TrendChip arrow with no sparkline beneath it. This locks the fix: the
* delta is only ever honest (a real number) when the trend series that will
* accompany it actually has enough points to draw.
* ========================================================================== */
describe('honestRoundsDelta', () => {
it('is undefined with fewer than 2 weeks of history (no arrow without its sparkline)', () => {
expect(honestRoundsDelta([], 5, 0)).toBeUndefined();
expect(honestRoundsDelta([{ week: '2026-07-13', count: 5 }], 5, 0)).toBeUndefined();
});

it('is the real week-over-week delta once 2+ weeks of history exist', () => {
const roundsByWeek = [
{ week: '2026-07-06', count: 3 },
{ week: '2026-07-13', count: 5 },
];
expect(honestRoundsDelta(roundsByWeek, 5, 3)).toBe(2);
});

it('a real zero-round last week still yields a real (not fabricated) delta once history exists', () => {
const roundsByWeek = [
{ week: '2026-07-06', count: 0 },
{ week: '2026-07-13', count: 4 },
];
expect(honestRoundsDelta(roundsByWeek, 4, 0)).toBe(4);
});
});
22 changes: 22 additions & 0 deletions src/app/admin/golf/honest-rounds-delta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Bug #949 #6 — the "Rounds this week" KpiTile used to pass an unconditional
* `delta={roundsThisWeek - roundsLastWeek}` alongside a `trendData` series
* that can genuinely be shorter than 2 points (a new team's first week live,
* or any week `roundsByWeek`'s 12-week window hasn't filled yet). StatTile's
* own sparkline only renders once its trend series has 2+ finite points, but
* `delta` had no matching gate — so the TrendChip arrow rendered ALONE, with
* no sparkline beneath it, whenever the week-history was thin. Gating the
* delta on the SAME 2-point floor keeps the arrow and its sparkline paired:
* neither renders without the other.
*
* Lives outside page.tsx: the admin-gate coverage tripwire requires every
* export of a page/layout/actions file to reach the gate, and this is a pure
* presentation helper.
*/
export function honestRoundsDelta(
roundsByWeek: ReadonlyArray<unknown>,
roundsThisWeek: number,
roundsLastWeek: number,
): number | undefined {
return roundsByWeek.length >= 2 ? roundsThisWeek - roundsLastWeek : undefined;
}
3 changes: 2 additions & 1 deletion src/app/admin/golf/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { PlayerWatchlist } from '../_components/PlayerWatchlist';
import { LocalTime } from '../_components/LocalTime';
import { AutoRefresh } from '../_components/AutoRefresh';
import { FeatureHealthRollup } from '../_components/FeatureHealthRollup';
import { honestRoundsDelta } from './honest-rounds-delta';

export const dynamic = 'force-dynamic';

Expand Down Expand Up @@ -142,7 +143,7 @@ async function GolfBody() {
<KpiTile
label="Rounds this week"
value={r.roundsThisWeek}
delta={r.roundsThisWeek - r.roundsLastWeek}
delta={honestRoundsDelta(r.roundsByWeek, r.roundsThisWeek, r.roundsLastWeek)}
href="/admin/golf"
trendData={r.roundsByWeek.slice(-8).map((w) => w.count)}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { PaperCard } from '@/components/baseball/living-annual';
import { InlineNotice } from '@/components/fairway';
import { toast } from '@/components/ui/sonner';

interface NewGameClientProps {
teamId: string;
Expand Down Expand Up @@ -38,21 +39,39 @@ export function NewGameClient({ teamId, teamName }: NewGameClientProps) {
setSaving(true);
setError(null);

const result = await createGame(teamId, {
game_date: gameDate,
game_type: gameType,
opponent_name: opponentName || undefined,
location: location || undefined,
home_away: homeAway,
event_time: eventTime || undefined,
create_calendar_event: createCalendarEvent,
});

if (result.success && result.data) {
router.push(`/baseball/dashboard/stats/games/${result.data.id}`);
} else {
try {
const result = await createGame(teamId, {
game_date: gameDate,
game_type: gameType,
opponent_name: opponentName || undefined,
location: location || undefined,
home_away: homeAway,
event_time: eventTime || undefined,
create_calendar_event: createCalendarEvent,
});

if (result.success && result.data) {
router.push(`/baseball/dashboard/stats/games/${result.data.id}`);
return;
}

setError(result.error ?? 'Failed to create game');
setSaving(false);
} catch (err) {
// createGame is server-side wrapped (withBaseballAction has its own
// top-level try/catch) and should always RESOLVE to a
// CreateGameResult — but the client-side call is a network round trip
// to invoke the server action, which CAN reject outright (dropped
// connection, server restart mid-request, aborted navigation). Before
// this fix nothing caught that rejection: `saving` stayed true
// forever, the submit button stayed disabled at "Creating…", and the
// coach got no feedback at all (issue #952). Form state (all the typed
// fields above) is untouched either way, so the coach can just retry.
const message =
err instanceof Error ? err.message : 'Failed to create game. Please try again.';
setError(message);
toast.error('Could not create game', { description: message });
setSaving(false);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// =============================================================================
// NewGameClient.test.tsx — issue #952
//
// Before this fix, `handleSubmit` awaited `createGame()` with no try/catch.
// `createGame` is server-side wrapped and should always RESOLVE to a
// `CreateGameResult`, but the client-side call is a network round trip to
// invoke the server action, which CAN reject outright (dropped connection,
// server restart mid-request). A rejection stranded `saving=true` forever:
// the submit button stayed disabled at "Creating…" with no error shown at
// all. This guards the fix: a rejected `createGame()` call resets `saving`
// (the submit button becomes clickable again), surfaces the error inline AND
// via the repo's shared toast, and leaves the form fields untouched.
// =============================================================================

import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';

const createGameMock = vi.fn();
vi.mock('@/app/baseball/actions/games', () => ({
createGame: (...args: unknown[]) => createGameMock(...args),
}));

const routerPushMock = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: routerPushMock, back: vi.fn() }),
}));

const toastErrorMock = vi.fn();
vi.mock('@/components/ui/sonner', () => ({
toast: {
error: (...args: unknown[]) => toastErrorMock(...args),
success: vi.fn(),
warning: vi.fn(),
},
}));

import { NewGameClient } from '../NewGameClient';

function submitButton() {
return screen.getByRole('button', { name: /Create Game|Creating/i });
}

describe('NewGameClient — createGame() rejection is caught (#952)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('resets saving, surfaces the error inline + via toast, and keeps form state when createGame() rejects', async () => {
createGameMock.mockRejectedValue(new Error('fetch failed'));

render(<NewGameClient teamId="team-1" teamName="Test Team" />);

// Fill a field so we can assert it survives the failed submit.
fireEvent.change(screen.getByLabelText(/Opponent Name/), {
target: { value: 'State University' },
});

fireEvent.click(submitButton());

// Immediately disabled + relabeled while the (doomed) request is in flight.
expect(screen.getByRole('button', { name: 'Creating…' })).toBeDisabled();

// The rejection is caught: the button recovers instead of hanging forever.
await waitFor(() =>
expect(screen.getByRole('button', { name: /Create Game/i })).toBeEnabled(),
);

expect(screen.getByText('fetch failed')).toBeInTheDocument();
expect(toastErrorMock).toHaveBeenCalledWith('Could not create game', {
description: 'fetch failed',
});
expect(routerPushMock).not.toHaveBeenCalled();

// Form state is preserved — nothing was cleared on failure.
expect(screen.getByLabelText(/Opponent Name/)).toHaveValue('State University');
});

it('falls back to a generic message when createGame() rejects with a non-Error value', async () => {
createGameMock.mockRejectedValue('boom');

render(<NewGameClient teamId="team-1" teamName="Test Team" />);
fireEvent.click(submitButton());

await waitFor(() =>
expect(screen.getByText('Failed to create game. Please try again.')).toBeInTheDocument(),
);
expect(screen.getByRole('button', { name: /Create Game/i })).toBeEnabled();
expect(toastErrorMock).toHaveBeenCalledWith('Could not create game', {
description: 'Failed to create game. Please try again.',
});
});

it('resets saving and shows the inline error (no toast) when createGame() resolves with success: false', async () => {
createGameMock.mockResolvedValue({ success: false, error: 'Team not found' });

render(<NewGameClient teamId="team-1" teamName="Test Team" />);
fireEvent.click(submitButton());

await waitFor(() => expect(screen.getByText('Team not found')).toBeInTheDocument());
expect(screen.getByRole('button', { name: /Create Game/i })).toBeEnabled();
// The logical `success: false` path is unchanged by this fix — it never
// toasted before, and still shouldn't (only the newly-caught rejection
// path gets the extra toast signal).
expect(toastErrorMock).not.toHaveBeenCalled();
});

it('navigates to the new game and does not reset saving on success', async () => {
createGameMock.mockResolvedValue({ success: true, data: { id: 'game-123' } });

render(<NewGameClient teamId="team-1" teamName="Test Team" />);
fireEvent.click(submitButton());

await waitFor(() =>
expect(routerPushMock).toHaveBeenCalledWith(
'/baseball/dashboard/stats/games/game-123',
),
);
// Deliberately left disabled through navigation — see NewGameClient.tsx.
expect(screen.getByRole('button', { name: 'Creating…' })).toBeDisabled();
});
});
13 changes: 9 additions & 4 deletions src/app/golf/(auth)/demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,19 @@ function DemoGateContent() {
}
}

// #950 — the full brand→headline→card→footer entrance used to take ~1.05s
// (0.5s/0.65s durations + up to a 0.55s stagger delay); a conversion page
// should finish composing well under 1s. Same stagger SHAPE (brand, then
// headline, then the card, then the footer), just tightened durations/
// delays so the whole sequence settles by ~0.5s.
const motionCard = prefersReducedMotion
? { duration: 0 }
: { duration: 0.65, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] };
: { duration: 0.35, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] };

const motionStagger = (delay: number) =>
prefersReducedMotion
? { duration: 0 }
: { duration: 0.5, delay, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] };
: { duration: 0.25, delay, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] };

return (
<LazyMotion features={loadFeatures}>
Expand Down Expand Up @@ -203,7 +208,7 @@ function DemoGateContent() {
<m.div
initial={prefersReducedMotion ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={motionStagger(0.1)}
transition={motionStagger(0.05)}
className="text-center mb-6 sm:mb-8 max-w-[380px]"
>
<h1
Expand Down Expand Up @@ -421,7 +426,7 @@ function DemoGateContent() {
<m.div
initial={prefersReducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={motionStagger(0.55)}
transition={motionStagger(0.25)}
className="mt-6 flex flex-col items-center gap-3"
>
<p className="text-warm-600 text-sm">
Expand Down
Loading
Loading