From f6262f6009478d1672d6033800524619214d379a Mon Sep 17 00:00:00 2001 From: Amayyas Date: Tue, 14 Jul 2026 17:52:02 +0200 Subject: [PATCH 1/2] feat(wc26): add the 48 teams qualified for the 2026 World Cup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitfut is a World-Cup-26 app, but it has no idea which countries are actually in the tournament: the flag list is a flat ISO dump of ~250. This adds the 48, each with its confederation, plus the ordering helpers the picker needs. Codes are OUR flag asset keys, not ISO — England and Scotland are "eng" and "sct" (countries.ts UK_NATIONS). That's exactly the kind of thing a hand-written list gets wrong and nobody notices until a flag silently fails to render, so the tests treat the list as data: every code must resolve to a real country AND to a flag PNG that exists on disk. Confederation quotas are pinned too (AFC 9, CAF 10, CONCACAF 6, CONMEBOL 6, OFC 1, UEFA 16 = 48). The list is cross-checked against two sources and includes the three hosts and both inter-confederation play-off winners (Iraq, DR Congo). All 48 flags already ship, so no new assets. qualifiedFirst() is a stable partition — it floats the qualified teams up without scrambling the order they arrived in, which matters because searchCountries has its own ranking to preserve. --- lib/wc26.ts | 125 ++++++++++++++++++++++++++++++++++++ tests/wc26.test.ts | 154 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 lib/wc26.ts create mode 100644 tests/wc26.test.ts diff --git a/lib/wc26.ts b/lib/wc26.ts new file mode 100644 index 0000000..831d292 --- /dev/null +++ b/lib/wc26.ts @@ -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/.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); +} diff --git a/tests/wc26.test.ts b/tests/wc26.test.ts new file mode 100644 index 0000000..fe2fffa --- /dev/null +++ b/tests/wc26.test.ts @@ -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 + }); +}); From 5e95e4726f072d70cf751b8b5e6977102e4c72cd Mon Sep 17 00:00:00 2001 From: Amayyas Date: Tue, 14 Jul 2026 17:52:13 +0200 Subject: [PATCH 2/2] feat(picker): lead the flag list with the World Cup 26 teams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the flag picker landed you on Afghanistan, with your national team somewhere in the next 250 entries. Browsing now leads with a "World Cup 26" block of the 48, then "All countries" — so on a World-Cup app, the teams in the World Cup are the ones you see first. Two things this deliberately does NOT do: - It doesn't reorder a SEARCH. searchCountries ranks prefix matches above substring ones on purpose, and floating the qualified teams through that would surface Germany and Panama above Madagascar for "ma". Typing is the user steering; only the browse list is regrouped (see flagPickerList). - It doesn't nest the options. The combobox addresses its options by index for arrow keys and aria-activedescendant, so the list stays FLAT and the two group headers are role="presentation" — they sit outside the listbox's index space and can't be landed on. Verified in a browser: headers render, ArrowDown still walks option 0 → 1. --- components/FlagPicker.tsx | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/components/FlagPicker.tsx b/components/FlagPicker.tsx index 63a2fa9..5d5cdf6 100644 --- a/components/FlagPicker.tsx +++ b/components/FlagPicker.tsx @@ -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"; @@ -41,7 +41,11 @@ export default function FlagPicker({ value, onChange }: Props) { 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); @@ -203,9 +207,28 @@ export default function FlagPicker({ value, onChange }: Props) { {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 ( + + {header && ( +
  • + {header} +
  • + )}
  • {selected && }
  • +
    ); })}