import * as React from "react"; import { SendouButton } from "~/components/elements/Button"; import { SendouChipRadio, SendouChipRadioGroup, } from "~/components/elements/ChipRadio"; import { SendouDialog } from "~/components/elements/Dialog"; import { useTournament } from "~/features/tournament/routes/to.$id"; import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; import * as CSV from "~/modules/csv"; import { databaseTimestampToDate } from "~/utils/dates"; import { teamPage, userPage } from "~/utils/urls"; import styles from "./ExportDialog.module.css"; const BASE_URL = "https://sendou.ink"; const EXPORT_FORMATS = ["list", "csv"] as const; const EXPORT_STATUSES = ["all", "checkedIn", "notCheckedIn"] as const; const EXPORT_SORTS = ["name", "seed", "registration"] as const; const EXPORT_ROSTERS = ["full", "captains"] as const; type ExportFormat = (typeof EXPORT_FORMATS)[number]; type ExportStatus = (typeof EXPORT_STATUSES)[number]; type ExportSort = (typeof EXPORT_SORTS)[number]; type ExportRoster = (typeof EXPORT_ROSTERS)[number]; const FORMAT_LABELS: Record = { list: "List", csv: "CSV", }; const ROSTER_LABELS: Record = { full: "Full roster", captains: "Captains only", }; const STATUS_LABELS: Record = { all: "All teams", checkedIn: "Checked in only", notCheckedIn: "Not checked in", }; const SORT_LABELS: Record = { name: "Name", seed: "Seed", registration: "Registration time", }; const TEAM_FIELDS = [ "teamName", "seed", "registeredAt", "checkInStatus", "teamPageUrl", ] as const; const MEMBER_FIELDS = [ "memberUsername", "memberInGameName", "memberDiscord", "memberProfileUrl", ] as const; type ExportField = | (typeof TEAM_FIELDS)[number] | (typeof MEMBER_FIELDS)[number]; const FIELD_LABELS: Record = { teamName: "Team", seed: "Seed", registeredAt: "Registration time", checkInStatus: "Check-in", teamPageUrl: "Team page URL", memberUsername: "Username", memberInGameName: "In-game name", memberDiscord: "Discord mention", memberProfileUrl: "Profile URL", }; const DEFAULT_FIELDS: ExportField[] = [ "teamName", "seed", "checkInStatus", "memberUsername", ]; export function ExportDialog({ close }: { close: () => void }) { const tournament = useTournament(); const [format, setFormat] = React.useState("list"); const [status, setStatus] = React.useState("all"); const [bracketIdx, setBracketIdx] = React.useState(null); const [sort, setSort] = React.useState("seed"); const [roster, setRoster] = React.useState("full"); const [fields, setFields] = React.useState>( new Set(DEFAULT_FIELDS), ); const toggleField = (field: ExportField) => setFields((prev) => { const next = new Set(prev); if (next.has(field)) { next.delete(field); } else { next.add(field); } return next; }); const onDownload = () => { const selectedBracket = bracketIdx !== null ? tournament.brackets[bracketIdx] : null; const bracketRequiresOwnCheckIn = Boolean(selectedBracket?.requiresCheckIn); const teams = scopedAndSortedTeams({ teams: tournament.ctx.teams, status, sort, bracketIdx, bracketRequiresOwnCheckIn, // A team belongs to a bracket whether or not it has checked in: the // checked-in teams populate the matches, the rest are pending check-in. // Both are scoped out of the export when they don't belong to the bracket. bracketParticipantIds: selectedBracket ? new Set([ ...selectedBracket.participantTournamentTeamIds, ...(selectedBracket.teamsPendingCheckIn ?? []), ]) : null, }); const content = buildContent({ teams, format, fields, captainsOnly: roster === "captains", bracketIdx, bracketRequiresOwnCheckIn, checkedInLabel: "Checked in", notCheckedInLabel: "Not checked in", }); handleDownload({ filename: `participants.${format === "csv" ? "csv" : "txt"}`, content, format, }); close(); }; return (
({ value, label: FORMAT_LABELS[value], }))} />
Fields
{[...TEAM_FIELDS, ...MEMBER_FIELDS].map((field) => ( ))}
({ value, label: ROSTER_LABELS[value], }))} /> {tournament.brackets.length > 1 ? ( setBracketIdx(value === "all" ? null : Number(value)) } options={[ { value: "all", label: "All brackets" }, ...tournament.brackets.map((bracket, idx) => ({ value: String(idx), label: bracket.name || `#${idx}`, })), ]} /> ) : null} ({ value, label: STATUS_LABELS[value], }))} /> ({ value, label: SORT_LABELS[value], }))} /> Download
); } function RadioRow({ label, value, onChange, options, }: { label: string; value: T; onChange: (value: T) => void; options: ReadonlyArray<{ value: T; label: string }>; }) { const groupName = React.useId(); return (
{label}
{options.map((option) => ( onChange(value as T)} > {option.label} ))}
); } function hasActiveCheckIn( team: TournamentDataTeam, bracketIdx: number | null, bracketRequiresOwnCheckIn: boolean, ) { if (bracketIdx !== null && bracketRequiresOwnCheckIn) { return team.checkIns.some( (checkIn) => checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut, ); } const eventLevel = team.checkIns.filter( (checkIn) => checkIn.bracketIdx === null, ); return ( eventLevel.some((checkIn) => !checkIn.isCheckOut) && !eventLevel.some((checkIn) => checkIn.isCheckOut) ); } export function scopedAndSortedTeams({ teams, status, sort, bracketIdx, bracketRequiresOwnCheckIn, bracketParticipantIds, }: { teams: TournamentDataTeam[]; status: ExportStatus; sort: ExportSort; bracketIdx: number | null; bracketRequiresOwnCheckIn: boolean; bracketParticipantIds: Set | null; }) { const filtered = teams.filter((team) => { if (bracketParticipantIds && !bracketParticipantIds.has(team.id)) { return false; } switch (status) { case "checkedIn": return hasActiveCheckIn(team, bracketIdx, bracketRequiresOwnCheckIn); case "notCheckedIn": return !hasActiveCheckIn(team, bracketIdx, bracketRequiresOwnCheckIn); default: return true; } }); return [...filtered].sort((a, b) => { switch (sort) { case "name": return a.name.localeCompare(b.name); case "registration": return a.createdAt - b.createdAt; default: { const aSeed = a.seed ?? Number.POSITIVE_INFINITY; const bSeed = b.seed ?? Number.POSITIVE_INFINITY; if (aSeed !== bSeed) return aSeed - bSeed; return a.createdAt - b.createdAt; } } }); } function teamFieldValue( team: TournamentDataTeam, field: (typeof TEAM_FIELDS)[number], opts: { checkedInLabel: string; notCheckedInLabel: string; bracketIdx: number | null; bracketRequiresOwnCheckIn: boolean; }, ) { switch (field) { case "teamName": return team.name; case "seed": return team.seed != null ? String(team.seed) : ""; case "registeredAt": return databaseTimestampToDate(team.createdAt).toISOString(); case "checkInStatus": return hasActiveCheckIn( team, opts.bracketIdx, opts.bracketRequiresOwnCheckIn, ) ? opts.checkedInLabel : opts.notCheckedInLabel; case "teamPageUrl": return team.team?.customUrl ? `${BASE_URL}${teamPage(team.team.customUrl)}` : ""; } } function memberFieldValue( member: TournamentDataTeam["members"][number], field: (typeof MEMBER_FIELDS)[number], ) { switch (field) { case "memberUsername": return member.username; case "memberInGameName": return member.inGameName ?? ""; case "memberDiscord": return member.discordId ? `<@${member.discordId}>` : ""; case "memberProfileUrl": return `${BASE_URL}${userPage(member)}`; } } function buildContent({ teams, format, fields, captainsOnly, bracketIdx, bracketRequiresOwnCheckIn, checkedInLabel, notCheckedInLabel, }: { teams: TournamentDataTeam[]; format: ExportFormat; fields: Set; captainsOnly: boolean; bracketIdx: number | null; bracketRequiresOwnCheckIn: boolean; checkedInLabel: string; notCheckedInLabel: string; }) { const teamFields = TEAM_FIELDS.filter((field) => fields.has(field)); const memberFields = MEMBER_FIELDS.filter((field) => fields.has(field)); const membersOf = (team: TournamentDataTeam) => captainsOnly ? team.members.filter((member) => member.role === "OWNER") : team.members; const labelOpts = { checkedInLabel, notCheckedInLabel, bracketIdx, bracketRequiresOwnCheckIn, }; if (format === "csv") { const maxRoster = Math.max( 0, ...teams.map((team) => membersOf(team).length), ); const header = [ ...teamFields.map((field) => FIELD_LABELS[field]), ...Array.from({ length: maxRoster }).flatMap((_, i) => memberFields.map((field) => `Player ${i + 1} ${FIELD_LABELS[field]}`), ), ]; const rows = teams.map((team) => { const teamValues = teamFields.map((field) => teamFieldValue(team, field, labelOpts), ); const teamMembers = membersOf(team); const memberValues = Array.from({ length: maxRoster }).flatMap((_, i) => { const member = teamMembers[i]; return memberFields.map((field) => member ? memberFieldValue(member, field) : "", ); }); return [...teamValues, ...memberValues]; }); return CSV.serialize([header, ...rows]); } // list: members grouped under each team (omitted when no member fields chosen, // so a team-name-only export is just a plain list of names). Members are only // indented under a team header when team fields are present — without them the // export is a tight flat list (e.g. captains-only + Discord mention). const hasMemberFields = memberFields.length > 0; const groupByTeam = teamFields.length > 0; const entries = teams.map((team) => { const teamLine = teamFields .map((field) => teamFieldValue(team, field, labelOpts)) .filter(Boolean) .join(" - "); if (!hasMemberFields) return teamLine; const memberLines = membersOf(team).map((member) => { const memberLine = memberFields .map((field) => memberFieldValue(member, field)) .filter(Boolean) .join(" - "); return groupByTeam ? ` ${memberLine}` : memberLine; }); return [teamLine, ...memberLines].filter(Boolean).join("\n"); }); return entries .filter(Boolean) .join(groupByTeam && hasMemberFields ? "\n\n" : "\n"); } function handleDownload({ content, filename, format, }: { content: string; filename: string; format: ExportFormat; }) { const isCsv = format === "csv"; const element = document.createElement("a"); const file = new Blob(isCsv ? [CSV.BOM, content] : [content], { type: isCsv ? "text/csv;charset=utf-8" : "text/plain;charset=utf-8", }); element.href = URL.createObjectURL(file); element.download = filename; document.body.appendChild(element); element.click(); document.body.removeChild(element); }