A graph-database app for exploring footballers, clubs and managers β and for answering the question every football argument eventually needs: how many teammates apart are two players?
Built for the Wexa AI take-home assignment, on CognoDB (openCypher over Bolt) as the data layer.
- π Home β search, and a few pre-picked "famous connections" to try immediately
- π€ Player profile β full career timeline, "players you might know" (2-hop teammate suggestions), and any manager reunions
- π Club profile β squad history and manager history
- π Connection Finder β the flagship feature: shortest path of shared-club teammates between any two players
Football careers are naturally a graph: players move between clubs, clubs employ managers, managers move between clubs too, and every overlap in time at the same club creates a relationship between two people. The questions worth asking about this data are almost all about paths and connections, not aggregates:
- "How is Cristiano Ronaldo connected to Lionel Messi through shared teammates?"
- "Which players followed a manager from one club to the next?"
- "Who's one step removed from this player's teammate circle, but never played with them directly?"
In a relational schema, the first two questions require a recursive CTE with manual cycle detection, and the query gets more expensive with every hop you add β there's no way to stop as soon as a shortest path is found, and "up to 6 hops in either direction" turns into a genuinely painful self-join chain. In Cypher, it's:
MATCH path = shortestPath((a:Player {id:$fromId})-[:TEAMMATE_OF*1..6]-(b:Player {id:$toId}))One line, and the database's traversal engine β not application code β does the pathfinding, stopping the instant it finds the shortest route. That's the core argument for a graph database here: the interesting operations are traversals, and a graph database makes traversal the native, indexable, boundedly-fast operation instead of an emulated one.
flowchart LR
Country((Country))
Competition((Competition))
Club((Club))
Manager((Manager))
Player((Player))
Player -- NATIONALITY --> Country
Manager -- NATIONALITY --> Country
Club -- BASED_IN --> Country
Club -- COMPETES_IN --> Competition
Competition -- HELD_IN --> Country
Manager -- "MANAGED {from, to}" --> Club
Player -- "PLAYED_FOR {from, to, appearances, goals}" --> Club
Player -- "TEAMMATE_OF {clubId, from, to}" --> Player
| Node | Key properties |
|---|---|
Player |
id, name, dob, position |
Club |
id, name, founded |
Manager |
id, name |
Competition |
id, name, tier |
Country |
id, name |
| Relationship | From β To | Properties | Notes |
|---|---|---|---|
PLAYED_FOR |
Player β Club | from, to, appearances, goals |
One per career spell; to is null if ongoing |
MANAGED |
Manager β Club | from, to |
One per managerial spell |
TEAMMATE_OF |
Player β Player | clubId, from, to |
Derived, not raw input β see below |
COMPETES_IN |
Club β Competition | β | |
BASED_IN |
Club β Country | β | |
HELD_IN |
Competition β Country | β | |
NATIONALITY |
Player/Manager β Country | β |
TEAMMATE_OF is a derived relationship. The seed script (server/src/seed/computeTeammates.js)
scans every pair of PLAYED_FOR spells at the same club and, wherever two players' date ranges
overlap, writes a TEAMMATE_OF edge carrying the club and the overlapping window. This is a
one-time bulk derivation done in JS at load time β once written, it turns "find the shortest chain
of shared dressing rooms" from a runtime date-overlap join into a plain, indexable graph
traversal. Precomputing it is itself a graph-modeling decision: it trades a bit of load-time
computation for queries that stay fast and simple no matter how deep the traversal goes.
All queries are parameterised through the official neo4j-driver β no string-concatenated Cypher
anywhere in the codebase (see server/src/queries/*.js).
MATCH (a:Player {id: $fromId}), (b:Player {id: $toId})
OPTIONAL MATCH path = shortestPath((a)-[:TEAMMATE_OF*1..6]-(b))
RETURN a, b, pathVariable-length shortest-path search between two arbitrary players, up to 6 hops. This is the query a relational database would find genuinely awkward β no recursive-CTE contortion, no cycle-detection bookkeeping, and CognoDB's planner stops as soon as the shortest path is located instead of exploring every path up to the hop limit.
Example: Cristiano Ronaldo β Lionel Messi resolves in 2 hops, via Sergio Ramos (Real Madrid 2009β2018, then PSG 2021β2023 alongside Messi):
MATCH (p:Player {id: $id})-[s1:PLAYED_FOR]->(c1:Club)<-[m1:MANAGED]-(mgr:Manager)
-[m2:MANAGED]->(c2:Club)<-[s2:PLAYED_FOR]-(p)
WHERE c1 <> c2
AND m1.from < m2.from
AND s1.from <= coalesce(m1.to, date()) AND coalesce(s1.to, date()) >= m1.from
AND s2.from <= coalesce(m2.to, date()) AND coalesce(s2.to, date()) >= m2.from
RETURN DISTINCT mgr, c1, c2, m1.from AS firstFrom, m2.from AS secondFromFinds players who played under the same manager at two different clubs, with each spell's dates actually overlapping the manager's tenure there. In SQL this is a self-join across two relationship tables with four independent date-range conditions β here it's one graph pattern. The dataset's real example: Ricardo Carvalho played under JosΓ© Mourinho at Porto, then followed him to Chelsea, then again to Real Madrid.
MATCH (a:Player {id: $id})-[:TEAMMATE_OF]-(direct:Player)
WITH a, collect(DISTINCT direct.id) AS directIds
MATCH (a)-[:TEAMMATE_OF]-(mutual:Player)-[:TEAMMATE_OF]-(fof:Player)
WHERE fof.id <> a.id AND NOT fof.id IN directIds
RETURN fof, count(DISTINCT mutual) AS mutualTeammates
ORDER BY mutualTeammates DESCA classic "friend of a friend, but not already a friend" traversal: players who share a teammate
with the given player but never played alongside them directly, ranked by how many mutual
teammates they share. Equivalent to a self-join plus a NOT IN subquery over a many-to-many table
in SQL.
An honest engineering note: the more idiomatic Cypher form of this anti-join β
WHERE NOT (a)-[:TEAMMATE_OF]-(fof)as a pattern predicate β mis-resolvesfofon this CognoDB instance when both endpoints of the negated pattern are already bound from an earlier multi-hopMATCH(confirmed by isolating it with the driver directly: it silently collapsesfofback toa's direct neighbour instead of checking each bound row). The list-containment rewrite above sidesteps it and returns correct results. Worth knowing if you extend this further.
Player/club search (CONTAINS, case-insensitive), full career history, squad and manager rosters
for a club β all parameterised, all in server/src/queries/.
congodb/
βββ server/ Express API + CognoDB access
β βββ src/
β β βββ config.js Env var loading & validation
β β βββ db/driver.js Shared neo4j-driver instance, connectivity check
β β βββ queries/ All Cypher, as parameterised query strings
β β βββ routes/ Express routes calling queries, shaping JSON
β β βββ seed/ Seed data (JSON) + load script + teammate-derivation logic
β β βββ server.js App wiring, CORS, central error handler
β βββ .env.example
βββ client/ React (Vite) frontend
β βββ src/
β βββ api/client.js Fetch wrapper with error normalisation
β βββ components/ SearchBox, PlayerPicker, PathView, state views, etc.
β βββ pages/ Home, PlayerPage, ClubPage, ConnectionFinder
βββ docs/screenshots/
- Sign up at console.cognodb.com/signup (no credit card).
- Create a free c0 instance and pick a region β provisions in under a minute.
- Copy the connection URI (
bolt+s://<instance-id>.databases.cognodb.cloud) and the generated password for usercognodb. The password is shown once β save it immediately.
cd server
cp .env.example .env # then fill in COGNODB_URI / COGNODB_USER / COGNODB_PASSWORD
npm install
npm run seed # wipes and loads the graph (constraints, indexes, nodes, relationships)
npm start # API on http://localhost:4000cd client
cp .env.example .env # VITE_API_URL, defaults to http://localhost:4000
npm install
npm run dev # app on http://localhost:5173Visit http://localhost:5173. Try the featured pairs on the home page, or search for any of the
~36 seeded players.
- Secrets: the CognoDB URI and password are read from
server/.env, which is git-ignored; only.env.example(placeholders) is committed. - Error handling:
GET /api/healthverifies live DB connectivity; the frontend shows a banner if the API/DB is unreachable, and every page has a distinct loading, empty, and error state rather than a blank screen. - Parameterised queries only: every Cypher string in
server/src/queries/takes its inputs as$namedParametersbound by the driver β never interpolated into the query text. - Seed data: ~36 real players, 20 real clubs, 7 real managers, with genuine (approximated) transfer histories β dense enough at Real Madrid, Chelsea and PSG to produce non-trivial, verifiable multi-hop paths. Appearance/goal counts are illustrative, not official statistics.
| Player profile | Club profile |
|---|---|
![]() |
![]() |
| No connection found (empty state) |
|---|
![]() |
The app has no hard dependency on any specific host β both halves are configured entirely through env vars, so any free tier works (Render/Railway/Fly for the API, Vercel/Netlify for the frontend).
- Backend: deploy
server/as a Node web service.- Start command:
npm start(runsnode src/server.js) - Required env vars:
COGNODB_URI,COGNODB_USER,COGNODB_PASSWORD - Set
CLIENT_ORIGINto your deployed frontend's URL (for CORS) - Run
npm run seedonce (locally, or as a one-off job) against the same CognoDB instance before going live
- Start command:
- Frontend: deploy
client/as a static site.- Build command:
npm run buildβ output directorydist/ - Required env var (set at build time):
VITE_API_URL= your deployed backend's URL
- Build command:
Live links:




