Skip to content
3 changes: 2 additions & 1 deletion e2e/tests/stats-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,10 @@ test.describe('Stats Page', () => {
await expect(page.locator('.pairs-graph').first()).toBeVisible();
});

test('rapid re-generate without winners is ignored - stats page shows only 1 round of data', async ({ page }) => {
test('completed round followed by regeneration commits new assignments', async ({ page }) => {
await mainPage.addPlayers(['Alice', 'Bob', 'Charlie', 'Diana', 'Eve']);
await mainPage.generateAssignments(1);
await mainPage.court(1).selectWinner();
await mainPage.regenerate();

await page.locator('a[href*="stats"]').click();
Expand Down
4 changes: 3 additions & 1 deletion src/components/common/NumberField.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React from 'react';

import { clamp } from '../../utils/numberUtils';

interface NumberFieldProps {
value: number;
min: number;
Expand All @@ -15,7 +17,7 @@ export const NumberField: React.FC<NumberFieldProps> = ({ value, min, onChange,
min={min}
max={max}
value={value}
onChange={e => onChange(Math.min(max ?? Infinity, Math.max(min, parseInt(e.target.value, 10) || min)))}
onChange={e => onChange(clamp(parseInt(e.target.value, 10) || min, min, max ?? Infinity))}
className="court-count-input"
data-testid={testId}
/>
Expand Down
50 changes: 50 additions & 0 deletions src/components/court/AssignmentsEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';

interface AssignmentsEmptyStateProps {
hasAssignments: boolean;
hasPlayers: boolean;
isButtonShaking: boolean;
onGenerate: () => void;
}

const AssignmentsEmptyState: React.FC<AssignmentsEmptyStateProps> = ({
hasAssignments,
hasPlayers,
isButtonShaking,
onGenerate,
}) => {
if (hasPlayers) {
if (hasAssignments) return null;
return (
<div className="no-assignments-hint">
<p>
<strong>How it works:</strong> Players will be randomly assigned to courts.
Doubles (4 players) is preferred, but singles (2 players) will be used for odd numbers.
Extra players will be benched.
</p>
<button
onClick={onGenerate}
className={`generate-button ${isButtonShaking ? 'button-shake' : ''}`}
data-testid="generate-assignments-button"
>
🎲 Generate Assignments
</button>
</div>
);
}

return (
<div className="no-players-hint">
<p>Add some players above to start generating court assignments.</p>
<button
disabled
className="generate-button"
data-testid="generate-assignments-button"
>
🎲 Generate Assignments
</button>
</div>
);
};

export default AssignmentsEmptyState;
44 changes: 44 additions & 0 deletions src/components/court/BenchSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';

import type { Player } from '../../types';

import type { SlotBinding } from './edit/slotBinding';
import { TeamPlayerList } from './team';

interface BenchSectionProps {
benchedPlayers: Player[];
isAnimating: boolean;
slotBinding?: SlotBinding;
onViewBenchCounts?: () => void;
}

const BenchSection: React.FC<BenchSectionProps> = ({
benchedPlayers,
isAnimating,
slotBinding,
onViewBenchCounts,
}) => (
<div className={`bench-section ${isAnimating ? 'animating-blur' : ''}`}>
<div className="bench-header">
🪑 Bench ({benchedPlayers.length} player{benchedPlayers.length !== 1 ? 's' : ''})
</div>
<div className="bench-players">
<TeamPlayerList
players={benchedPlayers}
className="bench-player"
slotBinding={slotBinding}
/>
</div>
{onViewBenchCounts && (
<button
onClick={onViewBenchCounts}
className="view-bench-counts-button"
data-testid="view-bench-counts-button"
>
View bench counts &amp; manage
</button>
)}
</div>
);

export default BenchSection;
118 changes: 29 additions & 89 deletions src/components/court/CourtAssignments.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import React, { useEffect, useState } from 'react';
import { ArrowsLeftRight } from '@phosphor-icons/react';

import type { Court, Player, SetScore, WinnerSelection } from '../../types';
import { useAnalytics } from '../../hooks/useAnalytics';
import { useSlotSwap } from '../../hooks/useSlotSwap';
import type { SlotAddr } from '../../utils/slotSwap';
import { benchSlot, courtSlot } from '../../utils/courtSwap';

import AssignmentsEmptyState from './AssignmentsEmptyState';
import BenchSection from './BenchSection';
import { CourtCard } from './card';
import { TeamPlayerList } from './team';
import { clampCourtCount, isValidCourtCount } from './courtCountUtils';
import CourtSettingsBar from './CourtSettingsBar';

const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;

Expand Down Expand Up @@ -91,19 +93,17 @@ const CourtAssignments: React.FC<CourtAssignmentsProps> = ({
setCourtInputValue(inputValue);

const value = parseInt(inputValue, 10);
if (!isNaN(value) && value > 0 && value <= 20) {
if (isValidCourtCount(value)) {
onNumberOfCourtsChange(value);
}
};

const handleCourtsBlur = () => {
const value = parseInt(courtInputValue, 10);
if (isNaN(value) || value < 1) {
setCourtInputValue('1');
onNumberOfCourtsChange(1);
} else if (value > 20) {
setCourtInputValue('20');
onNumberOfCourtsChange(20);
if (!isValidCourtCount(value)) {
const clamped = clampCourtCount(isNaN(value) ? 1 : value);
setCourtInputValue(String(clamped));
onNumberOfCourtsChange(clamped);
}
};

Expand Down Expand Up @@ -136,35 +136,14 @@ const CourtAssignments: React.FC<CourtAssignmentsProps> = ({
return (
<div className={`court-assignments-container${isEditMode ? ' edit-mode' : ''}`}>
{swap.dragGhost}
<div className="court-settings-inline">
<div className="court-input-group">
<label htmlFor="courts">Courts:</label>
<input
id="courts"
type="number"
min="1"
max="20"
value={courtInputValue}
onChange={handleCourtsChange}
onBlur={handleCourtsBlur}
className="court-input"
data-testid="court-count-input"
/>
</div>

{canRearrange && (
<button
onClick={() => (isEditMode ? exitEditMode() : enterEditMode())}
className={`rearrange-button ${isEditMode ? 'active' : ''}`}
data-testid="rearrange-button"
aria-pressed={isEditMode}
data-tooltip="Drag a player onto another to swap them between teams, courts and the bench — or tap two players."
>
<ArrowsLeftRight size={16} weight="bold" />
Rearrange players
</button>
)}
</div>
<CourtSettingsBar
courtInputValue={courtInputValue}
onCourtsChange={handleCourtsChange}
onCourtsBlur={handleCourtsBlur}
canRearrange={canRearrange}
isEditMode={isEditMode}
onToggleEditMode={() => (isEditMode ? exitEditMode() : enterEditMode())}
/>

{isEditMode && (
<div className="edit-mode-banner" data-testid="edit-mode-banner">
Expand Down Expand Up @@ -203,27 +182,12 @@ const CourtAssignments: React.FC<CourtAssignmentsProps> = ({
</div>

{benchedPlayers.length > 0 && (
<div className={`bench-section ${isAnimating ? 'animating-blur' : ''}`}>
<div className="bench-header">
🪑 Bench ({benchedPlayers.length} player{benchedPlayers.length !== 1 ? 's' : ''})
</div>
<div className="bench-players">
<TeamPlayerList
players={benchedPlayers}
className="bench-player"
slotBinding={onSwapPlayers ? swap.binding(i => benchSlot(assignments.length, i)) : undefined}
/>
</div>
{onViewBenchCounts && (
<button
onClick={onViewBenchCounts}
className="view-bench-counts-button"
data-testid="view-bench-counts-button"
>
View bench counts &amp; manage
</button>
)}
</div>
<BenchSection
benchedPlayers={benchedPlayers}
isAnimating={isAnimating}
slotBinding={onSwapPlayers ? swap.binding(i => benchSlot(assignments.length, i)) : undefined}
onViewBenchCounts={onViewBenchCounts}
/>
)}

{onWinnerChange && !hasHistoricalWinners && !assignments.some(c => c.winner !== undefined) && (
Expand Down Expand Up @@ -252,36 +216,12 @@ const CourtAssignments: React.FC<CourtAssignmentsProps> = ({
</>
)}

{!hasAssignments && hasPlayers && (
<div className="no-assignments-hint">
<p>
<strong>How it works:</strong> Players will be randomly assigned to courts.
Doubles (4 players) is preferred, but singles (2 players) will be used for odd numbers.
Extra players will be benched.
</p>
<button
onClick={handleGenerateAssignments}
disabled={!hasPlayers}
className={`generate-button ${isButtonShaking ? 'button-shake' : ''}`}
data-testid="generate-assignments-button"
>
🎲 Generate Assignments
</button>
</div>
)}

{!hasPlayers && (
<div className="no-players-hint">
<p>Add some players above to start generating court assignments.</p>
<button
disabled
className="generate-button"
data-testid="generate-assignments-button"
>
🎲 Generate Assignments
</button>
</div>
)}
<AssignmentsEmptyState
hasAssignments={hasAssignments}
hasPlayers={hasPlayers}
isButtonShaking={isButtonShaking}
onGenerate={handleGenerateAssignments}
/>
</div>
);
};
Expand Down
54 changes: 54 additions & 0 deletions src/components/court/CourtSettingsBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import { ArrowsLeftRight } from '@phosphor-icons/react';

import { MAX_COURTS, MIN_COURTS } from './courtCountUtils';

interface CourtSettingsBarProps {
courtInputValue: string;
onCourtsChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onCourtsBlur: () => void;
canRearrange: boolean;
isEditMode: boolean;
onToggleEditMode: () => void;
}

const CourtSettingsBar: React.FC<CourtSettingsBarProps> = ({
courtInputValue,
onCourtsChange,
onCourtsBlur,
canRearrange,
isEditMode,
onToggleEditMode,
}) => (
<div className="court-settings-inline">
<div className="court-input-group">
<label htmlFor="courts">Courts:</label>
<input
id="courts"
type="number"
min={MIN_COURTS}
max={MAX_COURTS}
value={courtInputValue}
onChange={onCourtsChange}
onBlur={onCourtsBlur}
className="court-input"
data-testid="court-count-input"
/>
</div>

{canRearrange && (
<button
onClick={onToggleEditMode}
className={`rearrange-button ${isEditMode ? 'active' : ''}`}
data-testid="rearrange-button"
aria-pressed={isEditMode}
data-tooltip="Drag a player onto another to swap them between teams, courts and the bench — or tap two players."
>
<ArrowsLeftRight size={16} weight="bold" />
Rearrange players
</button>
)}
</div>
);

export default CourtSettingsBar;
11 changes: 11 additions & 0 deletions src/components/court/courtCountUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { clamp } from '../../utils/numberUtils';

export const MIN_COURTS = 1;
export const MAX_COURTS = 20;

/** True when the parsed court count is a whole number within the allowed range. */
export const isValidCourtCount = (value: number): boolean =>
!isNaN(value) && value >= MIN_COURTS && value <= MAX_COURTS;

/** Clamps a court count into the allowed [MIN_COURTS, MAX_COURTS] range. */
export const clampCourtCount = (value: number): number => clamp(value, MIN_COURTS, MAX_COURTS);
3 changes: 2 additions & 1 deletion src/components/graphs/TeammateGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useMemo } from 'react';

import { getColorForCount, GRAPH_COLORS } from '../../constants/graphColors';
import { splitPairKey } from '../../utils/playerUtils';

type GraphVariant = 'teammate' | 'opponent';

Expand Down Expand Up @@ -90,7 +91,7 @@ export function TeammateGraph({

Object.entries(teammateData).forEach(([pair, count]) => {
if (count < 1) return;
const [id1, id2] = pair.split('|');
const [id1, id2] = splitPairKey(pair);
playerIds.add(id1);
playerIds.add(id2);
edgeList.push({ source: id1, target: id2, count });
Expand Down
4 changes: 2 additions & 2 deletions src/constants/graphColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ export const GRAPH_GLOW_COLORS = {
/** Legend labels used in graph components */
export const GRAPH_LEGEND_LABELS = ['1×', '2×', '3×', '4×+'] as const;

type CountTier = 'count1' | 'count2' | 'count3' | 'count4Plus';
export type CountTier = 'count1' | 'count2' | 'count3' | 'count4Plus';

/** Bucket a repetition count into its colour tier. */
function countTier(count: number): CountTier {
export function countTier(count: number): CountTier {
if (count >= 4) return 'count4Plus';
if (count === 3) return 'count3';
if (count === 2) return 'count2';
Expand Down
Loading
Loading