# P1-market-journey — screen inventory

| | |
| --- | --- |
| Title (ar) | رحلة المستخدم في سوق الانتقالات |
| Title (en) | Market journey |
| Owning epic | A0.8 |
| Reviewer | Zayed |
| Timebox | 3 days |
| Opened | 2026-09-12 |
| Status | locked |

## The question

A club opens the transfer market, narrows a large pool down to a handful of
names it trusts, reads one of them properly, and keeps it where its colleagues
can see it. That spine already exists as a shipped Livewire screen, but it
exists in one shape only: a left filter panel, a card of fixed density, a table
of four columns, and no profile detail at all. Before the Next.js front end
copies that shape by default, this prototype re-expresses the same journey with
the alternatives built side by side, so the reviewer can choose the shape rather
than inherit it. When this is locked, three things are true: we know whether a
profile is a page or a sheet, we know where the filters and their counts live,
and we know how much a card carries — and therefore what the API's
`ProfileCard` payload has to return.

The shipped screen resolves that whole spine per **sport** as well as per role,
and the platform ships two. Both are live here: football and basketball each
bring their own registry groups, their own catalogues, their own facet counts,
their own season metrics and their own sortable orders, and the header select
re-resolves all of it. A screen that only ever renders one sport would settle
the three decisions for a market that does not exist.

## Decisions to settle

| Id | Decision | Options | Settled in |
| --- | --- | --- | --- |
| D1 | Is a profile detail a route of its own or a side sheet over the results? | a) full page — `detail.html`  b) side sheet over search — `index.html?state=sheet` | findings.md |
| D2 | Where does the filter panel live, and where do the facet counts and the three callouts live with it? | a) sidebar, today's shape — `index.html?panel=sidebar`  b) top bar for the most-used filters, long tail in a sheet — `index.html?panel=topbar` | findings.md |
| D3 | How much does a result carry — in the card and in the table? | a) card `?density=regular` (4 summary fields + 4 metrics) vs `?density=compact` (2 + 2)  b) table `?table=today` (name, details, availability, tier) vs `?table=groups` (one summary column per registry group) | findings.md |
| D4 | Where does the sport switch live? | a) a control in the market header beside the sort — today's shape, `index.html?sport=basketball`  b) a choice made above the market, which the market inherits and never offers | findings.md |

### What the reviewer must answer

- **D1** — Does reading a profile interrupt the search or replace it? Watch what
  happens to the filter set and the scroll position in each. The sheet keeps the
  result list alive behind it; the page gives the private-tier block, the career
  history and the per-season metrics the room they visibly lack in the sheet.
  Answer: one of them, both with a stated rule for when, or the sheet as a
  preview with the page as the destination.
- **D2** — In the sidebar the counts sit against every option and the three
  callouts sit above the results column. In the top bar only six filters are
  visible, their counts move into the option labels, and the long tail moves
  into a sheet that the counts follow. Answer: which shape, and if the top bar
  wins, which six filters are the top bar and whether a count inside an option
  label is still a count anyone reads.
- **D3** — Compact fits more names on one screen; regular answers more questions
  per name. The group table is the only view that shows a registry group the
  card omits. Answer: the default density, whether the density is a user setting
  or a fixed choice, and which columns the table ships with — this decides the
  `ProfileCard` payload (see the table at the end of this file).
- **D4** — Switching sport is not like switching a sort. It re-resolves the
  filter panel, re-counts every facet, re-numbers the role tabs, replaces the
  metric orders, re-shapes the card and closes the open profile, because a
  profile id belongs to one sport. Today that sits in a select beside the sort,
  which looks like a control of the same weight and is not. Answer: keep it in
  the market header, or lift it above the market as a choice the club makes
  once and the screen inherits — and if it stays, what the screen says out loud
  when a filter or an order does not survive the move.

## Screens

| Screen | File | Purpose | Actor |
| --- | --- | --- | --- |
| Market search | `index.html` | Narrow the pool: role tabs with counts, name search, availability presets, the filter panel with facet counts, active-filter chips, the three callouts, grid or table, pagination | Club member |
| Profile detail (page) | `detail.html` | Read one professional in full: every registry group, metrics per season, availability, current club, injury status, career history, the private-tier block, send a request | Club member |
| Profile detail (sheet) | `index.html?state=sheet` | The same profile read without leaving the result list | Club member |
| Club watchlist | `watchlist.html` | The club's shared list, rendered with the same market card and the same watch toggle | Club member |

### Switches carried in the URL

Toggles, not states: they multiply the screenshots, not the markup.

| Parameter | Values | Screens | What it changes |
| --- | --- | --- | --- |
| `locale` | `ar` (default), `en` | all | handled by `shared/proto.js` |
| `theme` | `light` (default), `dark` | all | handled by `shared/proto.js` |
| `state` | see below | all | handled by `shared/proto.js` |
| `sport` | `football` (default), `basketball` | `index`, `detail`, `watchlist`, sheet | D4: which sport the market is. Re-resolves the panel groups and their fields, the facet counts, the role-tab counts, the presets, the metric sorts, the seasons, the card's summary fields and metric pills, and the group columns of the table |
| `role` | `player` (default), `head_coach`, `referee` | `index`, `watchlist` | which role tab is selected, and therefore which registry groups the panel shows |
| `view` | `grid` (default), `table` | `index`, `watchlist` | results as cards or as a table |
| `density` | `regular` (default), `compact` | `index`, `watchlist`, sheet | D3: 4 summary fields + 4 metrics, or 2 + 2 |
| `panel` | `sidebar` (default), `topbar` | `index` | D2: filter panel shape |
| `expand` | empty (default), or filter keys separated by commas — `person.nationalities` | `index` | which option lists show their whole catalogue instead of the first eight, in the sidebar and in the sheet alike. The shipped screen keeps this on the component and nowhere else; it is carried in the URL here so an expanded panel can be reopened and shot exactly as it was left, and a key that arrives this way opens the group that holds its field |
| `table` | `today` (default), `groups` | `index`, `watchlist` | D3: table columns |
| `tier` | `basic` (default), `private` | `detail`, sheet | A1.5: whether the private block renders or is a locked placeholder |
| `p` | football `p1`…`p6`, `c1`…`c6`; basketball `b1`…`b6`, `bc1`…`bc3` | `detail`, sheet | which profile is open. An id belongs to one sport: asking for a football id under `?sport=basketball` resolves to that sport's first profile rather than leaking a person across the boundary, and switching sport drops `?p=` from the URL |

## States per screen

| Screen | default | loading | empty | error | dropped | partial | stranded | sheet |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `index.html` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| `detail.html` | ✓ | ✓ | | ✓ | | | | |
| `watchlist.html` | ✓ | ✓ | ✓ | ✓ | | | | |

The three extra names are the three warning callouts the shipped screen already
raises, split apart so each can be judged on its own:

| State | It means | Copy it carries |
| --- | --- | --- |
| `dropped` | Values in the request could not be applied and were ignored. | `أُهملت 3 قيم تصفية في هذا الطلب لتعذّر تطبيقها.` |
| `partial` | The filter ran, but on less than was asked for. | `طُبّق عامل التصفية بالقيم المتاحة، إذ لا تتوفر هنا 4 من القيم المطلوبة.` |
| `stranded` | Switching role stranded a filter, which was cleared. | `أُزيل عامل تصفية واحد لأنه لا ينطبق على المدرب الرئيسي.` |

`stranded` has two readings, and the shipped screen has a string for each: a
filter can be stranded by the **role** or by the **sport**. On football the
state renders the role sentence; on `?sport=basketball` it renders the sport
one — `أُزيل عامل تصفية واحد لأنه لا ينطبق على كرة السلة.` — because a football metric
filter is exactly what a switch to basketball has to throw away.

`sheet` is a state rather than a toggle because it changes what the screen *is*
— the results are still there but they are no longer what you are reading.

## Screens actually built and shot

One row per screen × state × locale × viewport that exists in `screenshots/`.

| Screen | State | Locale | Viewport | Theme | File |
| --- | --- | --- | --- | --- | --- |
| index | default | ar | 1280×800 | light | `index--default--ar.png` |
| index | default | en | 1280×800 | light | `index--default--en.png` |
| index | loading | ar | 1280×800 | light | `index--loading--ar.png` |
| index | loading | en | 1280×800 | light | `index--loading--en.png` |
| index | empty (`role=referee`) | ar | 1280×800 | light | `index--empty--ar.png` |
| index | empty (`role=referee`) | en | 1280×800 | light | `index--empty--en.png` |
| index | error | ar | 1280×800 | light | `index--error--ar.png` |
| index | error | en | 1280×800 | light | `index--error--en.png` |
| index | dropped | ar | 1280×800 | light | `index--dropped--ar.png` |
| index | dropped | en | 1280×800 | light | `index--dropped--en.png` |
| index | partial | ar | 1280×800 | light | `index--partial--ar.png` |
| index | partial | en | 1280×800 | light | `index--partial--en.png` |
| index | stranded (`role=head_coach`) | ar | 1280×800 | light | `index--stranded--ar.png` |
| index | stranded (`role=head_coach`) | en | 1280×800 | light | `index--stranded--en.png` |
| index | sheet (`p=p1&tier=basic`) | ar | 1280×800 | light | `index--sheet--ar.png` |
| index | sheet (`p=p1&tier=basic`) | en | 1280×800 | light | `index--sheet--en.png` |
| index | default | ar | 390×844 | light | `index--default--ar--mobile.png` |
| index | default | en | 390×844 | light | `index--default--en--mobile.png` |
| index | default (`panel=topbar`) | ar | 1280×800 | light | `index--default--ar--topbar.png` |
| index | default | ar | 1280×800 | dark | `index--default--ar--dark.png` |
| index | default (`view=table`) | ar | 1280×800 | light | `index--table--ar.png` |
| index | default (`view=table&table=groups`) | ar | 1280×800 | light | `index--table-groups--ar.png` |
| index | default (`density=compact`) | ar | 1280×800 | light | `index--compact--ar.png` |
| index | default (`role=head_coach`) | ar | 1280×800 | light | `index--coach--ar.png` |
| index | default (`expand=person.nationalities`), scrolled to the filter panel | ar | 1280×800 | light | `index--options-expanded--ar.png` |
| detail | default (`tier=basic`) | ar | 1280×800 | light | `detail--basic--ar.png` |
| detail | default (`tier=basic`) | en | 1280×800 | light | `detail--basic--en.png` |
| detail | default (`tier=private`) | ar | 1280×800 | light | `detail--private--ar.png` |
| detail | default (`tier=private`) | en | 1280×800 | light | `detail--private--en.png` |
| watchlist | default | ar | 1280×800 | light | `watchlist--default--ar.png` |
| index | default (`sport=basketball`) | ar | 1280×800 | light | `index--basketball--ar.png` |
| index | default (`sport=basketball`) | en | 1280×800 | light | `index--basketball--en.png` |
| index | default (`sport=basketball`) | ar | 390×844 | light | `index--basketball--ar--mobile.png` |
| index | default (`sport=basketball&role=head_coach`) | ar | 1280×800 | light | `index--basketball-coach--ar.png` |
| index | default (`sport=basketball&view=table`) | ar | 1280×800 | light | `index--basketball-table--ar.png` |
| detail | default (`sport=basketball&p=b1&tier=private`) | ar | 1280×800 | light | `detail--basketball-private--ar.png` |
| watchlist | default (`sport=basketball`) | ar | 1280×800 | light | `watchlist--basketball--ar.png` |

## Roles and data used

Every person and every club below is a fictional fixture modelled on the code
repository's demo seeders; nothing here names a real person or a real club.

| Sport | Role | Tab count | Cards built | Registry groups the panel shows |
| --- | --- | --- | --- | --- |
| football | `player` (لاعب) | 128 | 6 | identity, sport role, physical, experience, availability, trust, season metrics |
| football | `head_coach` (مدرب رئيسي) | 34 | 6 | identity, credentials, experience, availability, trust — **no metrics group** |
| football | `referee` (حكم) | 0 | 0 | identity, credentials, availability, trust |
| basketball | `player` (لاعب) | 76 | 6 | identity, sport role, physical, experience, availability, trust, season metrics |
| basketball | `head_coach` (مدرب رئيسي) | 19 | 3 | identity, credentials, experience, availability, trust — **no metrics group** |
| basketball | `referee` (حكم) | 0 | 0 | identity, credentials, availability, trust |

The referee tab exists to prove the rule that a role stays visible at zero; it
lands on the `empty` results rather than disappearing from the tab strip. It
stays at zero in both sports.

### What the two sports share, and what they do not

The group *names* are the same in both sports because most of them are
universal and inherited untouched. Three of them are not.

| Group | Football | Basketball |
| --- | --- | --- |
| identity | shared, verbatim | shared, verbatim — only the facet counts move (128 vs 76) |
| experience | shared | shared |
| availability | shared | shared |
| trust | shared | shared |
| credentials (coach, referee) | shared fields; AFC/UEFA licences and FIFA grades | shared fields; WABC levels and FIBA licences |
| **sport role** | preferred foot (`feet`), positions, primary position, position group, squad number, **training status**, work permit | dominant hand (`hands`), positions, primary position, position group, jersey number, work permit — **no training status**, because basketball's quota is nationality-based with no training-history rule |
| **physical** | height, weight, injury status — three fields | height, weight, injury status **plus wingspan, standing reach and vertical leap** — six fields, and height and weight are widened (a FIBA centre clears football's 220 cm ceiling) |
| **season metrics** | appearances, minutes, goals, assists, pass accuracy %, duels won %, cards, clean sheets, save % — **three** seasons | games played, minutes, points, rebounds, assists, steals, blocks, turnovers, FG %, 3P %, FT %, efficiency — **two** seasons, because basketball arrived a season later |

Everything downstream follows from that table: the card's summary fields (a
football card leads with position and competition tier, a basketball card with
position and height), the metric pills, the `table=groups` columns, the detail
page's per-season rows, and the metric orders in the sort select — six for
basketball's sortable metrics, four for football's, and never both at once.

Coaches carry no metric in either sport, which is what makes the `stranded`
callout real twice over: a metric filter set on the player tab survives neither
the switch to a coach nor the switch to the other sport.

Football clubs used: نادي النهضة, نادي الصحراء, اتحاد الوسام, نادي محاربي نجد,
نادي صقور الحجاز, نادي نسور تبوك, نادي أسود القصيم, نادي بورتو فيرميليو,
نادي ريسينغ بروفانس, نادي ساو باولو ميريديان, نادي الأناضول الرياضي,
نادي أطلس الدار البيضاء.

Basketball clubs used, from the sport's own club seeder:
نادي صقور الرياض لكرة السلة, نادي أمواج جدة لكرة السلة, نادي لؤلؤ الدمام لكرة السلة,
نادي كثبان القصيم لكرة السلة, نادي مرتفعات الطائف لكرة السلة, نادي رحالة حائل لكرة السلة,
نادي قمم نجران لكرة السلة, نادي مارينا فالنسيا, نادي بوسفور إستانبول,
نادي بامباس بوينس آيرس, نادي رون ليون.

## What each card variant implies for the `ProfileCard` payload

This is the part of D3 that outlives the prototype.

| Card variant | Fields the payload must carry per result | Cost |
| --- | --- | --- |
| `compact` | identity (name, role, nationality, age), verification tier, availability status, contract summary, **2** resolved summary fields, **2** resolved metrics for the selected season, watch state | 2 attribute reads + 2 metric reads per row |
| `regular` | the same identity block, plus **4** resolved summary fields and **4** resolved metrics | 4 + 4 per row |
| `table=today` | identity, one pre-joined `details` string (3 summary values), availability status, tier | 3 attribute reads, no metrics |
| `table=groups` | identity plus one resolved summary value **per registry group in scope** — 6 for a player, 4 for a coach — availability, tier | as many reads as there are groups, and the set changes with the role |

Two consequences the reviewer should weigh, because they are not reversible
cheaply:

1. The summary and metric counts are a **server** concern: the resolver picks
   which attributes are "summary" and in what order. A density toggle the user
   flips means the payload always carries the regular set and the client hides
   half of it; a fixed density means the server sends exactly what renders.
2. `table=groups` makes the column set depend on the role, so the table header
   is data, not markup, and a role switch re-shapes the table rather than
   re-filling it.
3. The resolver is keyed on **(sport, role)**, not on role alone. The number of
   columns happens to match across the two sports, but the codes behind them do
   not: the sport-role column is `football.primary_position` in one and
   `basketball.primary_position` in the other, and the summary field order
   differs too. A `ProfileCard` that carries resolved *labels and values* rather
   than a fixed field list is the only version that survives a second sport —
   which is the cheapest thing to get right now and the dearest to change later.

## Out of scope

- The request flow itself. `Send request` opens a stub dialog and nothing is
  sent; the flow belongs to A3.1.
- A third sport. Football and basketball are both live; a sport beyond the two
  the platform ships is not modelled.
- Saved searches, alerts, comparing two profiles, and export.
- The offer thread, eligibility refusals and club approval.
- Sign-in, entitlement purchase, and how a club actually gets the private tier —
  `?tier=` is a switch, not a flow.

## Open questions

- The watch toggle carries no "who added it" on the card; the watchlist screen
  shows it only in the list header. If a club of fifteen people shares one list,
  is that enough attribution?
- The top-bar variant has no home for a filter the user set from the sheet and
  then scrolled past. The active-filter chips carry it, but the chips are the
  only trace — worth testing with someone who has ten filters set.
- Facet counts on the sidebar are shown for open groups only, exactly as the
  shipped screen does. Nothing on the screen says that a collapsed group's
  numbers are simply not computed yet, and a reviewer may read a missing number
  as a zero.
- The preset row is gated the way the shipped screen gates it — a quick pick is
  offered only when the schema resolved for (sport, role) carries every filter
  key it sets. All five presets are built from universal attributes, so today
  it is the **role** that subtracts one (`Free agents` and `Under 21` are absent
  on the coach tab) and the **sport** that subtracts none. The gate is real; the
  sport axis simply has nothing to catch yet. Worth asking whether a second
  sport should bring a quick pick of its own — `لاعبو الارتكاز` for the basketball
  bigs is the obvious candidate — or whether the row stays universal on
  principle.
- Switching sport closes the open profile, because a profile id belongs to one
  sport. In the sheet that is visible and reads fine; from the detail page the
  reader would land on a different person's page, which the prototype does not
  exercise because the sport select is only on the market screen.
