Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
push:
branches:
- main
pull_request:

jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: yarn

- name: Enable Corepack
run: corepack enable

- name: Install dependencies
run: yarn install --immutable

- name: Test
run: yarn test:ci

- name: Lint
run: yarn lint

- name: Build
run: yarn build
238 changes: 196 additions & 42 deletions pages/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useRouter } from "next/router";
import { Fragment, useEffect, useRef, useState } from "react";
import { AddUser, Delete, People, User, Document } from "react-iconly";
import { Court } from "../src/Court";
import { PairLinkIcon } from "../src/PlayerBadge";
import {
newGame,
useShufflerDispatch,
Expand All @@ -19,6 +20,49 @@ import {
} from "../src/useShuffler";
import { ResetPlayersModal } from "../src/ResetPlayersModal";

type NamePair = [string, string];

function getPartner(name: string, pairs: NamePair[]): string | null {
for (const [a, b] of pairs) {
if (a === name) return b;
if (b === name) return a;
}
return null;
}

function isPaired(name: string, pairs: NamePair[]): boolean {
return getPartner(name, pairs) !== null;
}

function removePairForPlayer(pairs: NamePair[], name: string): NamePair[] {
return pairs.filter(([a, b]) => a !== name && b !== name);
}

function addPair(pairs: NamePair[], a: string, b: string): NamePair[] {
const sorted: NamePair = a.localeCompare(b) <= 0 ? [a, b] : [b, a];
return [
...removePairForPlayer(pairs, a),
...removePairForPlayer(pairs, b),
sorted,
];
}

function renameInPairs(
pairs: NamePair[],
oldName: string,
newName: string
): NamePair[] {
return pairs.map(([a, b]) => {
const next: NamePair = [
a === oldName ? newName : a,
b === oldName ? newName : b,
];
return next[0].localeCompare(next[1]) <= 0
? next
: [next[1], next[0]];
});
}

function NewGame() {
const router = useRouter();
const state = useShufflerState();
Expand All @@ -36,6 +80,8 @@ function NewGame() {
const [courts, setCourts] = useState(state.courts.toString());
const [customizeCourtNames, setCustomizeCourtNames] = useState(false);
const [courtNames, setCourtNames] = useState<string[]>([]);
const [fixedPairs, setFixedPairs] = useState<NamePair[]>([]);
const [linkingPlayer, setLinkingPlayer] = useState<string | null>(null);

const handleAddPlayers = () => {
if (!playerInput) return;
Expand Down Expand Up @@ -64,7 +110,19 @@ function NewGame() {
setCustomizeCourtNames(true);
setCourtNames(state.courtNames);
}
}, [state.players, state.courts, state.courtNames]);

if (state.fixedPairs?.length) {
const namePairs = state.fixedPairs
.map(([a, b]) => {
const nameA = playersById[a]?.name;
const nameB = playersById[b]?.name;
if (nameA && nameB) return [nameA, nameB] as NamePair;
return null;
})
.filter((pair): pair is NamePair => pair !== null);
setFixedPairs(namePairs);
}
}, [state.players, state.courts, state.courtNames, state.fixedPairs, playersById]);

const handleNewGame = async () => {
const names = players;
Expand All @@ -89,6 +147,7 @@ function NewGame() {
names,
courts: courtCount,
courtNames: customizeCourtNames ? courtNames : [],
fixedPairs,
});
router.push("/rounds");
};
Expand All @@ -97,6 +156,33 @@ function NewGame() {
setModal("reset-players");
};

const handleLinkClick = (name: string) => {
if (isPaired(name, fixedPairs)) return;

if (linkingPlayer === null) {
setLinkingPlayer(name);
return;
}

if (linkingPlayer === name) {
setLinkingPlayer(null);
return;
}

if (isPaired(linkingPlayer, fixedPairs)) {
setLinkingPlayer(name);
return;
}

setFixedPairs(addPair(fixedPairs, linkingPlayer, name));
setLinkingPlayer(null);
};

const handleUnlink = (name: string) => {
setFixedPairs(removePairForPlayer(fixedPairs, name));
if (linkingPlayer === name) setLinkingPlayer(null);
};

const playerError = formStatus === "validating" && players.length < 4;
const courtsError =
formStatus === "validating" &&
Expand All @@ -120,6 +206,8 @@ function NewGame() {
onClose={() => setModal("none")}
onSubmit={() => {
setPlayers([]);
setFixedPairs([]);
setLinkingPlayer(null);
setModal("none");
}}
/>
Expand Down Expand Up @@ -179,48 +267,114 @@ function NewGame() {
</Button>
</div>
<Spacer y={2} />
{players.map((name, index) => (
<Fragment key={index}>
<div className="flex items-center gap-1">
<User primaryColor="#888" size="medium" />
<span className="text-sm text-gray-500 w-4">
{index + 1}
</span>
<Input
className="flex-1"
aria-label="Player"
value={name}
size="sm"
type="text"
variant="underlined"
onChange={(e) => {
const newName = e.currentTarget.value;
setPlayers([
...players.slice(0, index),
newName,
...players.slice(index + 1),
]);
}}
fullWidth
/>
<Button
variant="flat"
color="default"
aria-label={`Remove player named ${name}`}
isIconOnly
onPress={() => {
// Delete this player
setPlayers((players) => [
...players.slice(0, index),
...players.slice(index + 1),
]);
}}
{players.map((name, index) => {
const partner = getPartner(name, fixedPairs);
const paired = partner !== null;
const linking = linkingPlayer === name;
return (
<Fragment key={index}>
<div
className={`flex items-center gap-1 rounded-lg px-1 ${
linking
? "ring-2 ring-primary bg-primary-50"
: paired
? "bg-secondary-50"
: ""
}`}
>
<Delete />
</Button>
</div>
</Fragment>
))}
<User
primaryColor={paired ? "#7828c8" : "#888"}
size="medium"
/>
<span className="text-sm text-gray-500 w-4">
{index + 1}
</span>
<Input
className="flex-1"
aria-label="Player"
value={name}
size="sm"
type="text"
variant="underlined"
onChange={(e) => {
const newName = e.currentTarget.value;
setFixedPairs(
renameInPairs(fixedPairs, name, newName)
);
if (linkingPlayer === name) setLinkingPlayer(newName);
setPlayers([
...players.slice(0, index),
newName,
...players.slice(index + 1),
]);
}}
fullWidth
/>
{paired ? (
<>
<span
className="text-xs text-secondary whitespace-nowrap"
title={`Paired with ${partner}`}
>
↔ {partner}
</span>
<Button
variant="flat"
color="secondary"
aria-label={`Unlink ${name} from ${partner}`}
isIconOnly
onPress={() => handleUnlink(name)}
>
<Delete primaryColor="#7828c8" size="small" />
</Button>
</>
) : (
<Button
variant={linking ? "solid" : "flat"}
color={linking ? "primary" : "default"}
aria-label={
linkingPlayer && linkingPlayer !== name
? `Pair ${linkingPlayer} with ${name}`
: `Link ${name} as a fixed pair`
}
isIconOnly
onPress={() => handleLinkClick(name)}
>
<PairLinkIcon
color={linking ? "#fff" : "#888"}
size={16}
/>
</Button>
)}
<Button
variant="flat"
color="default"
aria-label={`Remove player named ${name}`}
isIconOnly
onPress={() => {
setFixedPairs(removePairForPlayer(fixedPairs, name));
if (linkingPlayer === name) setLinkingPlayer(null);
setPlayers((players) => [
...players.slice(0, index),
...players.slice(index + 1),
]);
}}
>
<Delete />
</Button>
</div>
</Fragment>
);
})}
{linkingPlayer && !isPaired(linkingPlayer, fixedPairs) && (
<>
<Spacer y={1} />
<p className="text-sm text-primary">
Tap another player to pair with {linkingPlayer}, or tap{" "}
{linkingPlayer}&apos;s link button again to cancel.
</p>
</>
)}
</div>
<Spacer y={3} />
<label>
Expand Down
6 changes: 5 additions & 1 deletion pages/rounds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,11 @@ export default function Rounds() {
<Spacer y={0.5} />
<BadgeGroup>
{sitOuts.map((playerId) => (
<PlayerBadge key={playerId} color="default">
<PlayerBadge
key={playerId}
color="default"
playerId={playerId}
>
{playerName(playerId)}
{volunteers.includes(playerId) ? (
<span className="text-neutral-500 font-semibold text-medium">
Expand Down
Loading
Loading