From 987f5172d9c075b058cfc457285c8c1b93a844a5 Mon Sep 17 00:00:00 2001 From: Christopher Date: Mon, 5 Oct 2026 20:20:40 +0200 Subject: [PATCH] Make the country page the overview of its indie scene #258: a country page is for newcomers and peers, and for search: what the scene is like there, and where to go next. /places stays the tool for exploring on a map. Builds on YoruNoHikage's #73. - Headline numbers: studios, associations, games made there, upcoming events, each linking to the list filtered to the country. - Cities: the top ten, each with its region and a bar split between studios and associations (topCities, counted in the app rather than raw SQL, which also eases the D1 port). - Associations, grouped by city with the most first, linking to their pages; beyond twelve cities the rest fold behind a toggle but stay in the HTML for search. - Games made in the country: the eight most recent, and a link to all. - Upcoming events: the next three, not canceled, with an empty state that invites members to add one. - Title and description for search: "Indie game studios and associations in France", with the counts. - /games?country=XX lists games made by the country's studios, shown as a removable "Made in France" filter. Also gives the /countries cards their missing React key. Co-Authored-By: Claude Opus 5.5 --- app/routes/countries.jsx | 1 + app/routes/country.$code.jsx | 451 +++++++++++++++++++++++++++--- app/routes/games.jsx | 58 +++- app/utils/countryOverview.js | 61 ++++ app/utils/countryOverview.test.js | 92 ++++++ e2e/places.spec.js | 66 ++++- 6 files changed, 669 insertions(+), 60 deletions(-) create mode 100644 app/utils/countryOverview.js create mode 100644 app/utils/countryOverview.test.js diff --git a/app/routes/countries.jsx b/app/routes/countries.jsx index e6a05b8..dde6d1f 100644 --- a/app/routes/countries.jsx +++ b/app/routes/countries.jsx @@ -37,6 +37,7 @@ const CountriesPage = () => { > {countries.map(({ country_code, count }) => ( { +// A country page is the overview of its indie scene, for newcomers and +// peers, and for search (#258). /places is for exploring on a map. +export const loader = async ({ request, params }) => { if (!params.code) { return redirect('/countries'); } const countryCode = params.code.toUpperCase(); - // using remix-i18n ? - // let locale = await i18next.getLocale(request); + // A country exists here once something is placed in it. + const places = countryNames[countryCode] + ? await db.location.count({ where: { country_code: countryCode } }) + : 0; + if (places === 0) { + throw new Response('Not Found', { status: 404 }); + } - // fetch country data and use right locale + const inCountry = { location: { country_code: countryCode } }; + const gamesMadeHere = { + deleted: false, + game_entity: { some: { entity: inCountry } }, + }; + const upcoming = { + ...inCountry, + status: { not: 'canceled' }, + ends_at: { gte: new Date() }, + }; - const cities = await db.$queryRaw`select city as name, count(e.id)::int from location l left join entity e on l.id = e.location_id where country_code = ${countryCode} and city is not null group by city order by count desc limit 10`; + const [ + orgs, + gameCount, + recentGames, + eventCount, + upcomingEvents, + currentUser, + ] = await Promise.all([ + db.entity.findMany({ + where: inCountry, + select: { + id: true, + name: true, + type: true, + location: { select: { city: true, region: true } }, + }, + }), + db.game.count({ where: gamesMadeHere }), + db.game.findMany({ + where: gamesMadeHere, + orderBy: [{ created_at: 'desc' }, { id: 'asc' }], + take: 8, + include: { + game_image: { include: { image: true } }, + game_tag: { include: { tag: true } }, + game_entity: { include: { entity: true } }, + }, + }), + db.event.count({ where: upcoming }), + db.event.findMany({ + where: upcoming, + include: { + event_participant: true, + game_event: { where: { game: { deleted: false } } }, + location: true, + cover: true, + }, + orderBy: { starts_at: 'asc' }, + take: 3, + }), + isAuthenticated(request), + ]); - // country does not exist, or has no places yet - if (cities.length === 0 || !countryNames[countryCode]) { - throw new Response('Not Found', { status: 404 }); - } + const associations = orgs.filter((org) => org.type === 'association'); return { country: { code: countryCode, name: countryNames[countryCode], - cities, + counts: { + studios: orgs.filter((org) => org.type === 'studio').length, + associations: associations.length, + games: gameCount, + events: eventCount, + cities: new Set( + orgs.map((org) => org.location?.city?.trim()).filter(Boolean) + ).size, + }, + cities: topCities(orgs), + associations: associationsByCity(associations).map( + ({ city, associations: list }) => ({ + city, + associations: list.map(({ id, name }) => ({ id, name })), + }) + ), + recentGames: await Promise.all(recentGames.map(computeGame)), + upcomingEvents: upcomingEvents.map((event) => ({ + ...event, + cover: event.cover ? getImageLinks(event.cover) : null, + })), }, + currentUser, }; }; -export const meta = ({ data, matches, location }) => - data?.country - ? pageMeta(matches, { - title: `${data.country.name} | Indie Collective - Community powered video game data`, - description: `${data.country.name}'s game industry: its most active cities for indie game studios and associations.`, - path: location.pathname, - }) - : [{ title: 'Country not found' }]; +const plural = (n, one, many = `${one}s`) => + `${n.toLocaleString('en')} ${n === 1 ? one : many}`; + +export const meta = ({ data, matches, location }) => { + if (!data?.country) return [{ title: 'Country not found' }]; + const { name, counts } = data.country; + return pageMeta(matches, { + title: `Indie game studios and associations in ${name}`, + description: `${name}'s indie game scene: ${plural(counts.studios, 'studio')} and ${plural( + counts.associations, + 'association' + )} in ${plural(counts.cities, 'city', 'cities')}, ${plural(counts.games, 'game')} made there, and its upcoming events.`, + path: location.pathname, + }); +}; + +const STUDIO = 'yellow.solid'; +const ASSOCIATION = 'green.solid'; + +const Section = ({ title, action, children, ...rest }) => ( + + + + {title} + + {action} + + {children} + +); + +const MoreLink = ({ to, children }) => ( + + {children} + +); + +// Associations are listed for the cities with the most first; the rest fold. +const FIRST_CITIES = 12; + +const AssociationGroups = ({ groups }) => ( + + {groups.map(({ city, associations: list }) => ( + + + {city ?? 'Elsewhere'} + + + {list.map((association) => ( +
  • + + {association.name} + +
  • + ))} +
    +
    + ))} +
    +); -const CountriesPage = () => { - const { country } = useLoaderData(); +const splitText = ({ studios, associations }) => + [ + studios && plural(studios, 'studio'), + associations && plural(associations, 'association'), + ] + .filter(Boolean) + .join(', '); + +const CountryPage = () => { + const { country, currentUser } = useLoaderData(); + const { + code, + name, + counts, + cities, + associations, + recentGames, + upcomingEvents, + } = country; + const most = cities[0]?.count ?? 0; + + const stats = [ + [counts.studios, 'studio', 'studios', `/studios?country=${code}`], + [ + counts.associations, + 'association', + 'associations', + `/associations?country=${code}`, + ], + [counts.games, 'game', 'games', `/games?country=${code}`], + [ + counts.events, + 'upcoming event', + 'upcoming events', + `/events?country=${code}`, + ], + ]; return ( - - {country.name}'s Game Industry - - - Most vibrant cities + + {name}'s indie game scene + - {/* No per-city page exists yet, so cities aren't links. */} - {country.cities.map(({ name, count }) => ( - - - {name} - {count} - {count === 1 ? 'structure' : 'structures'} - + {stats.map(([value, one, many, to]) => ( + + + + + {value.toLocaleString('en')} + + + {value === 1 ? one : many} + + + ))} + +
    + + Studios + + + {' '} + Associations + + + } + > + {cities.length > 0 ? ( + // No per-city page exists yet, so cities aren't links. + + {cities.map((city) => ( + + + + {city.name} + {city.region && city.region !== city.name && ( + + , {city.region} + + )} + + + {splitText(city)} + + + + {city.studios > 0 && ( + + )} + {city.associations > 0 && ( + + )} + + + ))} + + ) : ( + No studios or associations here yet. + )} +
    + +
    0 && ( + + All associations in {name} + + ) + } + > + {associations.length > 0 ? ( + <> + + {associations.length > FIRST_CITIES && ( + // Still in the HTML, for search; only folded away on screen. + + + Show associations in{' '} + {plural( + associations.length - FIRST_CITIES, + 'more city', + 'more cities' + )} + + + + + + )} + + ) : ( + + No associations in {name} yet. + {currentUser && ( + + Know one?{' '} + + Add an association + + . + + )} + + )} +
    + + {recentGames.length > 0 && ( +
    + All {plural(counts.games, 'game')} + + } + > + + {recentGames.map((game) => ( + + + + ))} + +
    + )} + +
    + All events in {name} + + } + > + {upcomingEvents.length > 0 ? ( + + {upcomingEvents.map((event) => ( + + + + ))} + + ) : ( + + + No upcoming events in {name}. + {currentUser && ( + + Know one? Add an event. + + )} + + )} +
    ); }; -export default CountriesPage; +export default CountryPage; diff --git a/app/routes/games.jsx b/app/routes/games.jsx index 8a106f6..9612229 100644 --- a/app/routes/games.jsx +++ b/app/routes/games.jsx @@ -18,12 +18,7 @@ import { NativeSelect, } from '@chakra-ui/react'; import { useCallback, useEffect, useState } from 'react'; -import { - Link, - useFetcher, - useLoaderData, - useSearchParams, -} from 'react-router'; +import { Link, useFetcher, useLoaderData, useSearchParams } from 'react-router'; import { LuPlus, LuSearch } from 'react-icons/lu'; import { db } from '../utils/db.server'; @@ -31,6 +26,7 @@ import computeGame from '../models/game'; import GameCard from '../components/GameCard'; import useDebounce from '../hooks/useDebounce'; import { pageMeta } from '../utils/meta'; +import countryNames from '../assets/countries.json'; // The sort options: user input picks one by name, never reaches Prisma. const SORTS = { @@ -45,10 +41,20 @@ export const loader = async ({ request }) => { const page = Number(searchParams.get('page') || '1'); const selectedTags = searchParams.getAll('tags'); const q = searchParams.get('q')?.trim() || null; - const sort = SORTS[searchParams.get('sort')] ? searchParams.get('sort') : DEFAULT_SORT; + const sort = SORTS[searchParams.get('sort')] + ? searchParams.get('sort') + : DEFAULT_SORT; + // Games made in a country: by one of its studios (#258). + const countryCode = searchParams.get('country')?.toUpperCase(); + const country = countryNames[countryCode] ? countryCode : null; const where = { deleted: false, + ...(country && { + game_entity: { + some: { entity: { location: { country_code: country } } }, + }, + }), ...(q && { OR: [ { name: { contains: q, mode: 'insensitive' } }, @@ -126,6 +132,7 @@ export const loader = async ({ request }) => { games: await Promise.all(games.map(computeGame)), q: q ?? '', sort, + country: country && { code: country, name: countryNames[country] }, }; return data; @@ -134,12 +141,13 @@ export const loader = async ({ request }) => { export const meta = ({ matches, location }) => pageMeta(matches, { title: 'Games', - description: 'Indie games from the community: browse them by tag, see who made them and where they were shown.', + description: + 'Indie games from the community: browse them by tag, see who made them and where they were shown.', path: location.pathname, }); const Games = () => { - const { games: initialGames, tags, q, sort } = useLoaderData(); + const { games: initialGames, tags, q, sort, country } = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); const selectedTags = searchParams.getAll('tags'); @@ -152,7 +160,8 @@ const Games = () => { next.delete('page'); for (const [key, value] of Object.entries(changes)) { next.delete(key); - for (const v of [].concat(value ?? [])) if (v !== '') next.append(key, v); + for (const v of [].concat(value ?? [])) + if (v !== '') next.append(key, v); } return next; }, options), @@ -164,7 +173,8 @@ const Games = () => { useEffect(() => setQuery(q), [q]); const debouncedQuery = useDebounce(query, 300); useEffect(() => { - if (debouncedQuery.trim() !== q) updateParams({ q: debouncedQuery.trim() }, { replace: true }); + if (debouncedQuery.trim() !== q) + updateParams({ q: debouncedQuery.trim() }, { replace: true }); }, [debouncedQuery]); const [games, setGames] = useState(initialGames); @@ -255,7 +265,12 @@ const Games = () => { updateParams({ sort: event.target.value === 'updated' ? '' : event.target.value })} + onChange={(event) => + updateParams({ + sort: + event.target.value === 'updated' ? '' : event.target.value, + }) + } > {Object.entries(SORTS).map(([value, { label }]) => ( + {country && ( + + Made in {country.name} + + updateParams({ country: '' })} + /> + + + )} - + {tags.slice(0, 30).map((tag) => { const selected = selectedTags.includes(tag.name); return ( diff --git a/app/utils/countryOverview.js b/app/utils/countryOverview.js new file mode 100644 index 0000000..6beaff3 --- /dev/null +++ b/app/utils/countryOverview.js @@ -0,0 +1,61 @@ +/** + * The cities with the most studios and associations, from the + * organisations' types and locations. Each city keeps the region most of + * its organisations give, since older places name regions inconsistently + * (#73), and counts its studios and associations apart (#258). + */ +export function topCities(orgs, limit = 10) { + const byCity = new Map(); + for (const org of orgs) { + const city = org?.location?.city?.trim(); + if (!city) continue; + const entry = byCity.get(city) ?? { + name: city, + count: 0, + studios: 0, + associations: 0, + regions: new Map(), + }; + entry.count += 1; + if (org.type === 'studio') entry.studios += 1; + if (org.type === 'association') entry.associations += 1; + const region = org.location.region?.trim(); + if (region) entry.regions.set(region, (entry.regions.get(region) ?? 0) + 1); + byCity.set(city, entry); + } + + return [...byCity.values()] + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) + .slice(0, limit) + .map(({ regions, ...city }) => ({ + ...city, + region: + [...regions].sort( + (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]) + )[0]?.[0] ?? null, + })); +} + +/** + * Associations grouped by city, the cities with the most first (#258): + * newcomers look for the one near them. Associations without a city come + * last, under `null`. + */ +export function associationsByCity(associations) { + const byCity = new Map(); + for (const association of associations) { + const city = association.location?.city?.trim() || null; + byCity.set(city, [...(byCity.get(city) ?? []), association]); + } + return [...byCity] + .map(([city, list]) => ({ + city, + associations: list.sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort( + (a, b) => + (a.city === null) - (b.city === null) || + b.associations.length - a.associations.length || + (a.city ?? '').localeCompare(b.city ?? '') + ); +} diff --git a/app/utils/countryOverview.test.js b/app/utils/countryOverview.test.js new file mode 100644 index 0000000..92e11df --- /dev/null +++ b/app/utils/countryOverview.test.js @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { associationsByCity, topCities } from './countryOverview'; + +const org = (type, city, region) => ({ type, location: { city, region } }); +const studio = (city, region) => org('studio', city, region); +const assoc = (city, region) => org('association', city, region); + +describe('topCities', () => { + it('counts organisations per city, most first, ties by name, with the split', () => { + expect( + topCities([ + studio('Lyon', 'Rhône'), + studio('Paris', 'Île-de-France'), + assoc('Lyon', 'Rhône'), + studio('Lille', 'Nord'), + ]) + ).toEqual([ + { name: 'Lyon', count: 2, studios: 1, associations: 1, region: 'Rhône' }, + { name: 'Lille', count: 1, studios: 1, associations: 0, region: 'Nord' }, + { + name: 'Paris', + count: 1, + studios: 1, + associations: 0, + region: 'Île-de-France', + }, + ]); + }); + + it("keeps a city's most common region", () => { + expect( + topCities([ + studio('Rennes', 'Brittany'), + studio('Rennes', 'Ille-et-Vilaine'), + assoc('Rennes', 'Brittany'), + ])[0].region + ).toBe('Brittany'); + }); + + it('skips organisations without a city, and allows a missing region', () => { + expect( + topCities([ + studio(null, 'Bavaria'), + studio(' ', null), + studio('Berlin', null), + null, + { type: 'studio' }, + ]) + ).toEqual([ + { name: 'Berlin', count: 1, studios: 1, associations: 0, region: null }, + ]); + }); + + it('keeps the top ten', () => { + const many = Array.from({ length: 12 }, (_, i) => + studio(`City ${String(i).padStart(2, '0')}`, null) + ); + expect(topCities(many)).toHaveLength(10); + expect(topCities(many, 3).map((c) => c.name)).toEqual([ + 'City 00', + 'City 01', + 'City 02', + ]); + }); +}); + +describe('associationsByCity', () => { + const a = (name, city) => ({ + name, + location: city === undefined ? null : { city }, + }); + + it('groups by city, biggest first, names sorted, no city last', () => { + expect( + associationsByCity([ + a('Pixel', 'Rennes'), + a('Nowhere', undefined), + a('Bit', 'Lyon'), + a('Arcade', 'Rennes'), + a('Blank', ''), + ]) + ).toEqual([ + { + city: 'Rennes', + associations: [a('Arcade', 'Rennes'), a('Pixel', 'Rennes')], + }, + { city: 'Lyon', associations: [a('Bit', 'Lyon')] }, + { city: null, associations: [a('Blank', ''), a('Nowhere', undefined)] }, + ]); + }); +}); diff --git a/e2e/places.spec.js b/e2e/places.spec.js index 4ca79cd..e2dee4e 100644 --- a/e2e/places.spec.js +++ b/e2e/places.spec.js @@ -8,8 +8,8 @@ test('country pages render their cities, and unknown countries are a 404', async const response = await page.goto(href, { waitUntil: 'networkidle' }); expect(response.status()).toBe(200); - await expect(page.getByRole('heading', { name: 'Most vibrant cities' })).toBeVisible(); - await expect(page.getByText(/structures?$/).first()).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Cities', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Upcoming events' })).toBeVisible(); expect(errors).toEqual([]); const unknown = await page.goto('/country/zz'); @@ -17,6 +17,68 @@ test('country pages render their cities, and unknown countries are a 404', async await expect(page.getByText('Not Found')).toBeVisible(); }); +// #258: a country page is the overview of its scene: numbers, cities with +// the studio/association split, associations by city, games made there, +// and its next events. +test('a country page gives the overview of its scene', async ({ page }) => { + const { PrismaClient } = await import('@prisma/client'); + const db = new PrismaClient(); + const tag = Date.now() % 100000; + const day = 24 * 60 * 60 * 1000; + const place = await db.location.create({ + data: { country_code: 'AQ', city: `Frostville ${tag}`, region: 'Ross Dependency' }, + }); + const org = (name, type) => db.entity.create({ data: { name: `${name} ${tag}`, type, location_id: place.id } }); + const orgs = await Promise.all([org('Ice Studio 1', 'studio'), org('Ice Studio 2', 'studio'), org('Penguin Club', 'association')]); + const game = await db.game.create({ + data: { name: `Glacier Run ${tag}`, game_entity: { create: { entity_id: orgs[0].id } } }, + }); + const event = (name, data) => + db.event.create({ data: { name: `${name} ${tag}`, location_id: place.id, starts_at: new Date('2000-01-01'), ends_at: new Date(Date.now() + day), ...data } }); + const events = await Promise.all([ + event('Polar Jam', {}), + event('Called-off Jam', { status: 'canceled' }), + event('Old Jam', { ends_at: new Date(Date.now() - day) }), + ]); + try { + await page.goto('/country/aq'); + await expect(page.getByRole('heading', { name: "Antarctica's indie game scene" })).toBeVisible(); + await expect(page).toHaveTitle('Indie game studios and associations in Antarctica'); + for (const [label, list] of [['studios', 'studios'], ['associations', 'associations'], ['games', 'games'], ['upcoming events', 'events']]) { + await expect(page.getByRole('link', { name: new RegExp(`^[\\d,]+ ${label.replace(/s$/, '')}s?$`) })).toHaveAttribute('href', `/${list}?country=AQ`); + } + + const city = page.getByRole('listitem').filter({ hasText: `Frostville ${tag}` }); + await expect(city).toContainText('Ross Dependency'); + await expect(city).toContainText('2 studios, 1 association'); + + await expect(page.getByRole('link', { name: `Penguin Club ${tag}` })).toHaveAttribute('href', `/org/${orgs[2].id}`); + await expect(page.getByRole('heading', { name: 'Games made in Antarctica' })).toBeVisible(); + await expect(page.getByText(`Glacier Run ${tag}`)).toBeVisible(); + + await expect(page.getByText(`Polar Jam ${tag}`)).toBeVisible(); + await expect(page.getByText(`Called-off Jam ${tag}`)).toHaveCount(0); + await expect(page.getByText(`Old Jam ${tag}`)).toHaveCount(0); + await expect(page.getByRole('link', { name: 'All events in Antarctica' })).toHaveAttribute('href', '/events?country=AQ'); + + // The games list follows: only games made in Antarctica, until the filter is removed. + await page.getByRole('link', { name: /^All [\d,]+ games?$/ }).click(); + await expect(page).toHaveURL('/games?country=AQ'); + await expect(page.getByText('Made in Antarctica')).toBeVisible(); + await expect(page.getByText(`Glacier Run ${tag}`)).toBeVisible(); + await page.getByRole('button', { name: 'Show games from every country' }).click(); + await expect(page).toHaveURL('/games'); + await expect(page.getByText('Made in Antarctica')).toHaveCount(0); + } finally { + await db.event.deleteMany({ where: { id: { in: events.map((e) => e.id) } } }); + await db.game_entity.deleteMany({ where: { game_id: game.id } }); + await db.game.delete({ where: { id: game.id } }); + await db.entity.deleteMany({ where: { id: { in: orgs.map((o) => o.id) } } }); + await db.location.delete({ where: { id: place.id } }); + await db.$disconnect(); + } +}); + test('places allows page zoom', async ({ page }) => { await page.goto('/places'); const viewports = await page.locator('meta[name="viewport"]').evaluateAll(