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
3 changes: 2 additions & 1 deletion pages/rounds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ export default function Rounds() {
<PlayersModal
open={playersModal}
onClose={() => setPlayersModal(false)}
onSubmit={async (newPlayers, regenerate) => {
onSubmit={async (newPlayers, fixedPairs, regenerate) => {
await editPlayers(dispatch, state, worker, {
newPlayers,
fixedPairs,
regenerate,
});
if (!regenerate && roundIndex) setRoundIndex((index) => index + 1);
Expand Down
53 changes: 53 additions & 0 deletions src/PlayerPairSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Select, SelectItem } from "@nextui-org/react";
import { PlayerId } from "./matching/heuristics";
import { getPartnerId } from "./fixedPairs";

type PlayerOption = { id: PlayerId; name: string };

export function PlayerPairSelect({
playerId,
playerName,
players,
fixedPairs,
onPairChange,
disabled,
}: {
playerId: PlayerId;
playerName: string;
players: PlayerOption[];
fixedPairs: [PlayerId, PlayerId][];
onPairChange: (playerId: PlayerId, partnerId: PlayerId | null) => void;
disabled?: boolean;
}) {
const partnerId = getPartnerId(playerId, fixedPairs);
const partnerOptions = players.filter((p) => p.id !== playerId);

return (
<Select
aria-label={`Fixed pair for ${playerName}`}
size="sm"
variant="flat"
className="max-w-[11rem]"
selectedKeys={partnerId ? [partnerId] : ["__none__"]}
isDisabled={disabled}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as string | undefined;
onPairChange(
playerId,
!selected || selected === "__none__" ? null : selected
);
}}
>
{[
<SelectItem key="__none__" value="__none__">
No fixed pair
</SelectItem>,
...partnerOptions.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
)),
]}
</Select>
);
}
158 changes: 103 additions & 55 deletions src/PlayersModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@ import {
} from "@nextui-org/react";
import { v4 as uuidv4 } from "uuid";
import { useEffect, useRef, useState } from "react";
import { AddUser, Delete } from "react-iconly";
import { Player } from "./matching/heuristics";
import { AddUser, Delete, Link } from "react-iconly";
import { Player, Team } from "./matching/heuristics";
import { useShufflerState } from "./useShuffler";
import clsx from "clsx";
import {
getPartnerId,
pairsEqual,
sanitizeFixedPairs,
setPlayerPair,
} from "./fixedPairs";
import { PlayerPairSelect } from "./PlayerPairSelect";

export function PlayersModal({
open,
Expand All @@ -22,25 +29,36 @@ export function PlayersModal({
}: {
open: boolean;
onClose: () => void;
onSubmit: (newPlayers: Player[], regenerate: boolean) => void;
onSubmit: (
newPlayers: Player[],
fixedPairs: Team[],
regenerate: boolean
) => void;
}) {
const state = useShufflerState();
const [newPlayer, setNewPlayer] = useState("");
const newPlayerRef = useRef<HTMLInputElement>(null);
const [players, setPlayers] = useState<
Array<Player & { delete: boolean; new: boolean }>
>([]);
const [fixedPairs, setFixedPairs] = useState<Team[]>([]);

const activePlayers = players.filter((x) => !x.delete);
const activePlayerIds = activePlayers.map(({ id }) => id);

const handleSubmit =
(regenerate: boolean = false) =>
() => {
const newPlayers = players
.filter((x) => !x.delete)
const newPlayers = activePlayers
.map(({ id, name }) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name));
// TODO: error handling for too few players.
if (newPlayers.length < 4) return;
onSubmit(newPlayers, regenerate);

const sanitizedPairs = sanitizeFixedPairs(fixedPairs, activePlayerIds);
const pairsChanged = !pairsEqual(sanitizedPairs, state.fixedPairs);
onSubmit(newPlayers, sanitizedPairs, pairsChanged ? true : regenerate);
};

useEffect(() => {
if (open) {
const allPlayers = Object.values(state.playersById);
Expand All @@ -55,8 +73,24 @@ export function PlayersModal({
new: false,
}))
);
setFixedPairs(state.fixedPairs);
}
}, [open]);
}, [open, state.players, state.playersById, state.fixedPairs]);

const handlePairChange = (playerId: string, partnerId: string | null) => {
setFixedPairs((pairs) => setPlayerPair(playerId, partnerId, pairs));
};

const handleToggleDelete = (playerId: string) => {
setPlayers((current) => {
const updated = current.map((x) =>
x.id === playerId ? { ...x, delete: !x.delete } : x
);
const remainingIds = updated.filter((x) => !x.delete).map((x) => x.id);
setFixedPairs((pairs) => sanitizeFixedPairs(pairs, remainingIds));
return updated;
});
};

return (
<Modal
Expand All @@ -74,22 +108,20 @@ export function PlayersModal({
</ModalHeader>
<ModalBody>
<p className="text-lg">
Add or remove players. You can either{" "}
Add or remove players, or link fixed pairs. You can either{" "}
<span className="font-bold">redo the current round</span> (because
you haven&apos;t played yet) or{" "}
you haven't played yet) or{" "}
<span className="font-bold">start a new round</span> with the
updated roster.
updated roster. Changing fixed pairs always redoes the current
round.
</p>
<form
name="new-player"
onSubmit={(e) => {
e.preventDefault();
const playerName = newPlayer.trim();
// No empty input.
if (!playerName) return;
// No duplicate names.
if (players.some((player) => player.name === playerName)) return;
// Update list and clear form.
setPlayers((players) => [
{ name: playerName, id: uuidv4(), delete: false, new: true },
...players,
Expand Down Expand Up @@ -120,48 +152,64 @@ export function PlayersModal({
</Button>
</div>
</form>
{players.map((player) => (
<div className="flex items-center border-b-1 pb-3" key={player.id}>
<span
className={clsx("text-large flex-1", {
"line-through": player.delete,
"text-neutral-400": player.delete,
})}
>
{player.new ? "🆕 " : ""}
{player.delete ? "❌ " : ""}
{player.name}
</span>
<Spacer x={0.5} />
<Button
variant="flat"
size="sm"
color={player.delete ? "success" : "default"}
aria-label={
player.delete
? `Restore player named ${player.name}`
: `Remove player named ${player.name}`
}
endContent={player.delete ? <AddUser /> : <Delete />}
title={player.delete ? "Restore player" : "Remove player"}
onPress={() => {
// Toggle delete for this player
setPlayers((players) =>
players.map((x) =>
x.id === player.id
? {
...x,
delete: !x.delete,
}
: x
)
);
}}
{players.map((player) => {
const partnerId = getPartnerId(player.id, fixedPairs);
const partnerName = partnerId
? players.find((p) => p.id === partnerId)?.name
: null;

return (
<div
className="flex items-center border-b-1 pb-3 gap-2"
key={player.id}
>
{player.delete ? "Re-add" : "Remove"}
</Button>
</div>
))}
<span
className={clsx("text-large flex-1 min-w-0", {
"line-through": player.delete,
"text-neutral-400": player.delete,
})}
>
{player.new ? "🆕 " : ""}
{player.delete ? "❌ " : ""}
{player.name}
{partnerName && !player.delete ? (
<span className="text-primary text-medium font-normal ml-2 inline-flex items-center gap-1">
<Link size="small" />
{partnerName}
</span>
) : null}
</span>
{!player.delete ? (
<PlayerPairSelect
playerId={player.id}
playerName={player.name}
players={activePlayers.map(({ id, name }) => ({
id,
name,
}))}
fixedPairs={fixedPairs}
onPairChange={handlePairChange}
/>
) : null}
<Spacer x={0.5} />
<Button
variant="flat"
size="sm"
color={player.delete ? "success" : "default"}
aria-label={
player.delete
? `Restore player named ${player.name}`
: `Remove player named ${player.name}`
}
endContent={player.delete ? <AddUser /> : <Delete />}
title={player.delete ? "Restore player" : "Remove player"}
onPress={() => handleToggleDelete(player.id)}
>
{player.delete ? "Re-add" : "Remove"}
</Button>
</div>
);
})}
</ModalBody>
<ModalFooter>
<Button variant="flat" onPress={onClose}>
Expand Down
46 changes: 46 additions & 0 deletions src/fixedPairs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,49 @@ export function getPlayerIdByName(
): PlayerId | undefined {
return Object.values(playersById).find((player) => player.name === name)?.id;
}

export function sanitizeFixedPairs(
fixedPairs: Team[],
activePlayerIds: Iterable<PlayerId>
): Team[] {
const active = new Set(activePlayerIds);
const seen = new Set<string>();
const result: Team[] = [];

for (const [a, b] of fixedPairs) {
if (a === b || !active.has(a) || !active.has(b)) continue;
const key = [a, b].sort().join(":");
if (seen.has(key)) continue;
seen.add(key);
result.push([a, b]);
}

return result;
}

export function setPlayerPair(
playerId: PlayerId,
partnerId: PlayerId | null,
fixedPairs: Team[]
): Team[] {
if (partnerId !== null && playerId === partnerId) return fixedPairs;

const withoutPlayers = fixedPairs.filter(
([a, b]) =>
a !== playerId && b !== playerId && a !== partnerId && b !== partnerId
);

if (partnerId === null) return withoutPlayers;

return [...withoutPlayers, [playerId, partnerId]];
}

export function pairsEqual(a: Team[], b: Team[]): boolean {
if (a.length !== b.length) return false;
const normalize = (pairs: Team[]) =>
pairs
.map(([x, y]) => [x, y].sort().join(":"))
.sort()
.join("|");
return normalize(a) === normalize(b);
}
Loading
Loading