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
32 changes: 28 additions & 4 deletions components/FlagPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { useCallback, useEffect, useId, useMemo, useReducer, useRef } from "react";
import { Fragment, useCallback, useEffect, useId, useMemo, useReducer, useRef } from "react";
import { Pencil, Plus, Search, X } from "lucide-react";
import { searchCountries } from "@/lib/countries";
import { flagPickerList } from "@/lib/wc26";
import { comboboxReducer, initialComboboxState } from "@/lib/comboboxNav";
import { useClickOutside } from "@/hooks/useClickOutside";

Expand Down Expand Up @@ -41,7 +41,11 @@
const listId = `${baseId}-list`;
const optionId = (i: number) => `${baseId}-opt-${i}`;

const results = useMemo(() => searchCountries(query), [query]);
// Browsing shows the 48 World-Cup teams first, so a national team isn't buried
// 200 countries down; searching is left exactly as searchCountries ranked it
// (see flagPickerList). The list stays FLAT either way — the combobox addresses
// its options by index — so the group headers below are rendered as non-options.
const { list: results, qualifiedCount } = useMemo(() => flagPickerList(query), [query]);

const close = useCallback(() => dispatch({ type: "close" }), []);
useClickOutside(rootRef, open, close);
Expand Down Expand Up @@ -203,9 +207,28 @@
{results.map((c, i) => {
const selected = c.code === value;
const highlighted = i === active;
// Headers sit at the two group boundaries and are NOT options: they
// carry role="presentation" so they stay out of the listbox's index
// space, which is what arrow keys and aria-activedescendant address.
const header =
qualifiedCount === 0
? null
: i === 0
? "World Cup 26"
: i === qualifiedCount
? "All countries"
: null;
return (
<Fragment key={c.code}>
{header && (
<li
role="presentation"
className="px-[10px] pb-[4px] pt-[10px] text-[10px] font-semibold uppercase tracking-[.12em] text-ink-mute"
>
{header}
</li>
)}
<li
key={c.code}
id={optionId(i)}
role="option"
aria-selected={selected}
Expand All @@ -215,7 +238,7 @@
highlighted ? "bg-brand/15 text-white" : "text-ink-dim"
}`}
>
<img

Check warning on line 241 in components/FlagPicker.tsx

View workflow job for this annotation

GitHub Actions / Lint & build

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={FLAG(c.code)}
alt=""
width={24}
Expand All @@ -229,6 +252,7 @@
</span>
{selected && <span className="h-[6px] w-[6px] flex-none rounded-full bg-brand" />}
</li>
</Fragment>
);
})}
</ul>
Expand Down
125 changes: 125 additions & 0 deletions lib/wc26.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { type Country, searchCountries } from "./countries";

// The 48 teams qualified for the 2026 World Cup, keyed by the flag asset code we
// already ship (public/badges/flags/<code>.png). This is the whole point of the
// picker change: gitfut is a World-Cup-26 app, but the flag list is a flat ISO
// dump of ~250 countries, so finding your national team means scrolling past 200
// that aren't in the tournament.
//
// Codes are OUR flag keys, not ISO: England and Scotland are "eng"/"sct" (see
// UK_NATIONS in countries.ts), which is exactly the kind of thing a hand-written
// list gets wrong — so a test asserts every code below resolves to a real country
// AND a real flag asset.
//
// Includes the three hosts (Canada, Mexico, USA) and the two inter-confederation
// play-off winners (Iraq, DR Congo).

export type Confederation = "AFC" | "CAF" | "CONCACAF" | "CONMEBOL" | "OFC" | "UEFA";

export interface QualifiedTeam {
code: string; // flag asset key, lowercase
confederation: Confederation;
}

export const WC26_TEAMS: readonly QualifiedTeam[] = [
// AFC (9)
{ code: "au", confederation: "AFC" },
{ code: "ir", confederation: "AFC" },
{ code: "iq", confederation: "AFC" }, // inter-confederation play-off winner
{ code: "jp", confederation: "AFC" },
{ code: "jo", confederation: "AFC" },
{ code: "qa", confederation: "AFC" },
{ code: "sa", confederation: "AFC" },
{ code: "kr", confederation: "AFC" },
{ code: "uz", confederation: "AFC" },
// CAF (10)
{ code: "dz", confederation: "CAF" },
{ code: "cv", confederation: "CAF" },
{ code: "cd", confederation: "CAF" }, // inter-confederation play-off winner
{ code: "eg", confederation: "CAF" },
{ code: "gh", confederation: "CAF" },
{ code: "ci", confederation: "CAF" },
{ code: "ma", confederation: "CAF" },
{ code: "sn", confederation: "CAF" },
{ code: "za", confederation: "CAF" },
{ code: "tn", confederation: "CAF" },
// CONCACAF (6)
{ code: "ca", confederation: "CONCACAF" }, // host
{ code: "cw", confederation: "CONCACAF" },
{ code: "ht", confederation: "CONCACAF" },
{ code: "mx", confederation: "CONCACAF" }, // host
{ code: "pa", confederation: "CONCACAF" },
{ code: "us", confederation: "CONCACAF" }, // host
// CONMEBOL (6)
{ code: "ar", confederation: "CONMEBOL" },
{ code: "br", confederation: "CONMEBOL" },
{ code: "co", confederation: "CONMEBOL" },
{ code: "ec", confederation: "CONMEBOL" },
{ code: "py", confederation: "CONMEBOL" },
{ code: "uy", confederation: "CONMEBOL" },
// OFC (1)
{ code: "nz", confederation: "OFC" },
// UEFA (16)
{ code: "at", confederation: "UEFA" },
{ code: "be", confederation: "UEFA" },
{ code: "ba", confederation: "UEFA" },
{ code: "hr", confederation: "UEFA" },
{ code: "cz", confederation: "UEFA" },
{ code: "eng", confederation: "UEFA" },
{ code: "fr", confederation: "UEFA" },
{ code: "de", confederation: "UEFA" },
{ code: "nl", confederation: "UEFA" },
{ code: "no", confederation: "UEFA" },
{ code: "pt", confederation: "UEFA" },
{ code: "sct", confederation: "UEFA" },
{ code: "es", confederation: "UEFA" },
{ code: "se", confederation: "UEFA" },
{ code: "ch", confederation: "UEFA" },
{ code: "tr", confederation: "UEFA" },
];

const QUALIFIED = new Set(WC26_TEAMS.map((t) => t.code));

/** True when `code` is one of the 48 teams at the 2026 World Cup. Case-insensitive. */
export function isQualifiedWC26(code: string | null | undefined): boolean {
return !!code && QUALIFIED.has(code.toLowerCase());
}

/**
* Floats the World-Cup-26 teams to the top of a country list, keeping each group
* in the order it came in — so the caller's own ranking (searchCountries already
* ranks prefix matches above substring ones) still holds WITHIN each group.
*
* Returns a FLAT list plus the size of the qualified block, deliberately: the
* picker's keyboard navigation and aria-activedescendant address options by
* index, so grouping has to stay a rendering concern, not a data-shape one.
*/
export function qualifiedFirst(countries: readonly Country[]): {
list: readonly Country[];
qualifiedCount: number;
} {
const qualified: Country[] = [];
const rest: Country[] = [];
for (const c of countries) (isQualifiedWC26(c.code) ? qualified : rest).push(c);
return { list: [...qualified, ...rest], qualifiedCount: qualified.length };
}

/**
* The flag picker's list, and the size of its leading World-Cup block (0 = show no
* group headers).
*
* The regrouping applies to the BROWSE case only. While the user is searching,
* searchCountries' prefix-over-substring ranking is what they're steering, and
* floating the qualified teams through it would fight that: "ma" would surface
* Germany and Panama (substring, qualified) above Madagascar (prefix), which is
* plainly the wrong answer. So a search is returned exactly as ranked, and only
* the full list gets the World-Cup block — which is where the win actually is:
* your national team is at the top instead of 200 countries down.
*/
export function flagPickerList(query: string): {
list: readonly Country[];
qualifiedCount: number;
} {
const matches = searchCountries(query);
return query.trim() ? { list: matches, qualifiedCount: 0 } : qualifiedFirst(matches);
}
154 changes: 154 additions & 0 deletions tests/wc26.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { countryName, normalizeCountry, searchCountries } from "@/lib/countries";
import { WC26_TEAMS, flagPickerList, isQualifiedWC26, qualifiedFirst } from "@/lib/wc26";
import type { Confederation } from "@/lib/wc26";

// The team list is hand-written data, so the tests treat it as data: they check it
// against the two things that can silently break the picker — a code that isn't a
// real country, and a code with no flag PNG behind it (a card would render a
// broken image). Everything else here pins the ordering contract the picker relies on.

describe("WC26_TEAMS — the data", () => {
it("has the 48 teams of the tournament", () => {
expect(WC26_TEAMS).toHaveLength(48);
});

it("lists no team twice", () => {
const codes = WC26_TEAMS.map((t) => t.code);
expect(new Set(codes).size).toBe(codes.length);
});

it("splits into the real confederation quotas", () => {
const count = (c: Confederation) => WC26_TEAMS.filter((t) => t.confederation === c).length;
expect({
AFC: count("AFC"),
CAF: count("CAF"),
CONCACAF: count("CONCACAF"),
CONMEBOL: count("CONMEBOL"),
OFC: count("OFC"),
UEFA: count("UEFA"),
}).toEqual({ AFC: 9, CAF: 10, CONCACAF: 6, CONMEBOL: 6, OFC: 1, UEFA: 16 });
});

// The list is keyed by OUR flag asset codes, not ISO — England and Scotland are
// "eng"/"sct". A typo here is invisible until someone's flag silently vanishes.
it("gives every team a code that resolves to a real country", () => {
for (const { code } of WC26_TEAMS) {
expect(normalizeCountry(code), code).toBe(code);
expect(countryName(code), code).toBeTruthy();
}
});

it("gives every team a flag asset that actually exists on disk", () => {
for (const { code } of WC26_TEAMS) {
const png = join(process.cwd(), "public", "badges", "flags", `${code}.png`);
expect(existsSync(png), `missing flag asset for ${code}`).toBe(true);
}
});

it("includes the three hosts and both play-off winners", () => {
for (const code of ["ca", "mx", "us", "iq", "cd"]) {
expect(isQualifiedWC26(code), code).toBe(true);
}
});

it("carries the UK home nations under their non-ISO codes", () => {
expect(countryName("eng")).toBe("England");
expect(countryName("sct")).toBe("Scotland");
expect(isQualifiedWC26("eng")).toBe(true);
expect(isQualifiedWC26("sct")).toBe(true);
});
});

describe("isQualifiedWC26", () => {
it("is false for a country that didn't qualify", () => {
expect(isQualifiedWC26("it")).toBe(false); // Italy missed out again
expect(isQualifiedWC26("dk")).toBe(false);
});

it("matches case-insensitively", () => {
expect(isQualifiedWC26("FR")).toBe(true);
expect(isQualifiedWC26("Ma")).toBe(true);
});

it("is safe on empty / missing input", () => {
expect(isQualifiedWC26("")).toBe(false);
expect(isQualifiedWC26(null)).toBe(false);
expect(isQualifiedWC26(undefined)).toBe(false);
});
});

describe("qualifiedFirst", () => {
const of = (...codes: string[]) => codes.map((code) => ({ code, name: code.toUpperCase() }));

it("floats qualified teams above everyone else", () => {
const { list } = qualifiedFirst(of("it", "fr", "dk", "br"));
expect(list.map((c) => c.code)).toEqual(["fr", "br", "it", "dk"]);
});

it("reports the size of the qualified block", () => {
expect(qualifiedFirst(of("it", "fr", "dk", "br")).qualifiedCount).toBe(2);
expect(qualifiedFirst(of("it", "dk")).qualifiedCount).toBe(0);
});

// searchCountries already ranks prefix matches above substring ones; floating the
// qualified block must not scramble that ranking WITHIN either group.
it("preserves the incoming order inside each group", () => {
const { list } = qualifiedFirst(of("dk", "br", "it", "fr"));
expect(list.map((c) => c.code)).toEqual(["br", "fr", "dk", "it"]);
});

it("keeps the full country list intact, just reordered", () => {
const all = searchCountries("");
const { list, qualifiedCount } = qualifiedFirst(all);
expect(list).toHaveLength(all.length);
expect(qualifiedCount).toBe(48);
expect(new Set(list.map((c) => c.code))).toEqual(new Set(all.map((c) => c.code)));
});

it("puts all 48 — and only those — in the leading block", () => {
const { list, qualifiedCount } = qualifiedFirst(searchCountries(""));
const lead = list.slice(0, qualifiedCount);
expect(lead.every((c) => isQualifiedWC26(c.code))).toBe(true);
expect(list.slice(qualifiedCount).some((c) => isQualifiedWC26(c.code))).toBe(false);
});

it("returns an empty result untouched", () => {
expect(qualifiedFirst([])).toEqual({ list: [], qualifiedCount: 0 });
});
});

describe("flagPickerList — what the picker actually renders", () => {
it("leads the browse list with the 48, and flags the block size", () => {
const { list, qualifiedCount } = flagPickerList("");
expect(qualifiedCount).toBe(48);
expect(list.slice(0, 48).every((c) => isQualifiedWC26(c.code))).toBe(true);
// …and nothing is lost off the bottom.
expect(list).toHaveLength(searchCountries("").length);
});

it("puts your national team first instead of 200 countries down", () => {
const { list } = flagPickerList("");
// Plain alphabetical would open on Afghanistan; now it opens on the tournament.
expect(list[0].code).not.toBe("af");
expect(isQualifiedWC26(list[0].code)).toBe(true);
});

// The regression this design exists to avoid: searchCountries deliberately ranks
// prefix matches above substring ones. Floating the qualified teams THROUGH a
// search would put Germany and Panama (substring, qualified) above Madagascar
// (prefix) for "ma" — plainly wrong. A search is returned exactly as ranked.
it("does not reorder a search, so prefix still beats substring", () => {
const { list, qualifiedCount } = flagPickerList("ma");
expect(list).toEqual(searchCountries("ma"));
expect(list[0].name).toBe("Macao"); // not Morocco, not Germany
expect(qualifiedCount).toBe(0); // → the picker draws no group headers
});

it("still ranks a searched-for qualified team on its own merits", () => {
expect(flagPickerList("morocco").list[0].code).toBe("ma");
expect(flagPickerList("fr").list[0].code).toBe("fr"); // exact code match
});
});
Loading