Catalogue Browse (V2) — /catalogue¶
The stringer-facing catalogue surface — browse, search, filter, edit, and submit-for-moderation. Owned by Mira. Cross-cuts: V2 scope M6 + M7 + M17, data-model § Racket / String / CatalogueSubmission, add-stringjob § racket + string pickers, admin-catalogue-moderation (the M17 admin side this surface feeds), client-management-v2 (per-client racket history lives there, NOT here), settings-v2 (stringer-default settings, not catalogue), v3-vision § auto-update.
Source requirement¶
- V2 stringer catalogue browse epic — racket-book#147 — V2 today: catalogue is reachable only via the M9 add-stringjob picker. No way to browse / curate / submit-for-moderation outside an active job. Mira authors the spec; Pax + Juno implement in a later round.
- M6 + M7 — Single Racket entity + Strings catalogue. The schema (
app/db/models/catalogue.py) has shipped; the stringer-side browse surface is the missing piece. - M17 — Admin-moderated promotion via request-queue. The producer side (stringer submits a
CatalogueSubmission) lives on this page; the consumer side (admin-catalogue-moderation) shipped Round 5. app/db/models/catalogue.py— three-tier visibility (catalogue_private,catalogue_shared,one_off);Racket.owner_client_profile_id,Racket.created_by_stringer_id,String.created_by_stringer_id. The visibility predicate in the chokepoint composer (app.db.tenancy._racket_predicate/_string_predicate) is the cross-tenant read gate.- add-stringjob § new-racket / new-string mini-flows + issue #144 — Pax-A is wiring the inline-from-add-stringjob create endpoints (
/orders/new/_new-racket/_new-string). This page reuses the same field shapes; the create modal here is the same form. - Sibling V2 schema-hook — racket-book#145 — adds
Racket.source+String.sourceenum (manual|imported) in V2 with no UI; this spec reserves a future-filter slot for it. - V3 epic — racket-book#146 — auto-update from external feeds. The
importedsource rows would land via that future surface; this spec must not preclude an "auto-update status" / "last imported at" UI extension.
Goal¶
Three commitments, in tension-resolution order:
- The browse surface is the curation home. The add-stringjob picker is transactional (find one item to attach to one order); the browse surface is curatorial (skim my inventory, edit a typo, submit a private string for promotion). Stefan asked for both — this page is the curatorial half. It is not a replacement for the picker.
- Visibility is the load-bearing axis. The three-tier model (
catalogue_private/catalogue_shared/one_off) is invisible to a stringer using add-stringjob — they just see "rackets I can pick." On the browse surface, visibility is first-class: the filter chip controls the view, the per-row chip declares the tier, and the submit-for-moderation flow is the explicit transition from private → shared. Hiding visibility here would make the moderation flow incoherent. - Forward-compat without forward-clutter.
source(#145) and auto-update (#146) get reserved structural slots — a filter-chip-row that admits a future "Source" segment, a row-card that admits a future "imported" badge — but no V2 UI surfaces them. The spec calls out exactly where they land in V3 so Pax + Juno don't re-architect.
Surface decision — tabs vs split routes vs unified¶
The epic explicitly defers this to Mira. Three options were on the table:
| Option | What it looks like | Pros | Cons |
|---|---|---|---|
| A. Unified single list | /catalogue — one mixed list with rackets + strings interleaved; type chip per row. |
One URL; one search box. | Mixed-shape rows (racket has head_size + pattern; string has gauge) make per-row layout messy; sort axes diverge ("head size" doesn't apply to strings); search relevance gets weird ("Pure Aero" should rank above "RPM Pure" only if we know we're searching rackets). |
| B. Tabs at one route | /catalogue?kind=rackets / ?kind=strings — single page, two tab-pills, JS-toggled list. |
One landing surface (/catalogue keeps semantic primacy); cheap to build. |
Tab state lives in the URL anyway (deep-linking + back-button correctness); without JS, tabs are just two links — same as option C. |
| C. Split routes | /catalogue/rackets + /catalogue/strings; /catalogue 302s to one of them (or shows a chooser landing). |
Clean URLs; clean per-route templates; clean per-route routing logic; mirrors the admin-catalogue-moderation ?kind=racket\|string filter chip pattern. |
Two URLs; the /catalogue root needs a decision (chooser or redirect). |
Decision: Option C — split routes with a /catalogue chooser landing.
Rationale¶
- Racket and String are structurally different shapes. Racket has
head_size,string_pattern,version,owner_client_profile_id; String hasgaugeonly. A single list can't surface both without per-row type-branching that defeats the whole point of a list. The admin moderation queue (admin-catalogue-moderation § Stage 2) already separates rackets from strings; we mirror that. - The picker pattern (
add-stringjob) already separates them — the M9 picker for rackets is_rackets, for strings is_strings, two different modal endpoints. The browse surface is downstream of that mental model. /catalogue(root) is a meaningful URL. Stefan navigating to/cataloguedeserves a deliberate landing — a small chooser ("Rackets · 47 entries · Strings · 23 entries") that gives him a one-tap path into either side. Cheaper than a 302 (a chooser surfaces the counts, which is useful at-a-glance), more deliberate than tabs.- Visibility filter is per-kind. "Show me my private rackets" and "show me my private strings" are independent decisions; sharing one filter chip across a mixed list creates a "no items match — but you have private rackets only, just not private strings" empty-state-confusion that's avoided when the kind is structural.
- V3 auto-update (#146) will likely diverge per kind — racket models update by year (Wilson Pro Staff X 2024 → 2025); string models update by spec (RPM Soft 1.30 → 1.25 variant). Splitting routes today leaves room for divergent V3 surfaces tomorrow.
TODO(stefan): confirm split-routes-with-chooser. Alternative: tabs at
/catalogue(option B). Mira leans split because the per-shape divergence is real; Stefan flips trivially if he prefers the single-URL surface.
Surfaces¶
1. /catalogue — chooser landing (counts + 2 CTAs)
2. /catalogue/rackets — racket list (search, filter, sort)
3. /catalogue/rackets/{id} — racket detail (read-only OR editable)
4. /catalogue/rackets/{id}/edit — racket edit (own only)
5. /catalogue/rackets/{id}/submit — submit-for-moderation modal (own private only)
6. /catalogue/strings — string list (mirror of #2)
7. /catalogue/strings/{id} — string detail (mirror of #3)
8. /catalogue/strings/{id}/edit — string edit (mirror of #4)
9. /catalogue/strings/{id}/submit — submit-for-moderation modal (mirror of #5)
A new catalogue entry is NOT created from /catalogue — that affordance lives in the add-stringjob new-racket / new-string mini-flows per #144 (the M9 picker creates new catalogue rows inline when "the racket I want isn't here" / "the string I want isn't here"). The browse surface has a "+ New" button on each list page for completeness, but it routes to the same modal Pax-A is building for #144 — same shape, just opened from a different entry point.
TODO(stefan): confirm the "no orphan-catalogue creation outside add-stringjob" stance. Alternative: a standalone create form on
/catalogue/rackets/newthat creates acatalogue_privateRacket without an order. Mira leans on the add-stringjob mini-flow path: orphan catalogue rows that never get used are dead inventory; the rare "I want to enter my new racket before I string it" case is acceptable extra friction (open add-stringjob, draft a job, save the racket via the inline create — even if the order is later abandoned, the catalogue row persists).
Surface 1 — /catalogue (chooser landing)¶
A small two-card landing. The point is to give Stefan a one-tap-path to either side with the counts visible at-a-glance. Mobile-first; same shape on lg — the cards just sit side-by-side on wide screens.
Viewport sm 375 px¶
┌───────────────────────────────────────┐
│ ← Catalogue │ Header: back to dashboard
├───────────────────────────────────────┤
│ │
│ Browse your inventory of rackets and │ text-body slate-600 — one-line
│ strings. │ context line
│ │
│ ┌───────────────────────────────────┐ │
│ │ 🎾 Rackets │ │ H2 + icon
│ │ │ │
│ │ 47 visible to you │ │ text-small slate-700
│ │ · 12 private │ │ text-tiny slate-500
│ │ · 32 shared │ │
│ │ · 3 one-off │ │
│ │ │ │
│ │ Browse → │ │ CTA
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ ⫻ Strings │ │
│ │ │ │
│ │ 23 visible to you │ │
│ │ · 4 private │ │
│ │ · 19 shared │ │
│ │ │ │
│ │ Browse → │ │
│ └───────────────────────────────────┘ │
│ │
└───────────────────────────────────────┘
Viewport lg 1280 px¶
┌───────────────────────────────────────────────────────────────────┐
│ ← Catalogue │
├───────────────────────────────────────────────────────────────────┤
│ │
│ Browse your inventory of rackets and strings. │
│ │
│ ┌──────────────────────────────┐ ┌──────────────────────────────┐│
│ │ 🎾 Rackets │ │ ⫻ Strings ││
│ │ 47 visible to you │ │ 23 visible to you ││
│ │ · 12 private · 32 shared · │ │ · 4 private · 19 shared ││
│ │ 3 one-off │ │ ││
│ │ Browse → │ │ Browse → ││
│ └──────────────────────────────┘ └──────────────────────────────┘│
│ │
└───────────────────────────────────────────────────────────────────┘
Component breakdown¶
| Component | Notes |
|---|---|
| Header back-arrow | Returns to dashboard. |
| Context line | "Browse your inventory of rackets and strings." text-body slate-600. One line. |
| Kind card | bg-white border border-slate-200 rounded-xl p-5. Whole card is the tap target. H2 with icon (🎾 / ⫻ — same icons as admin-catalogue-moderation § list view). Visible-count + per-tier breakdown. CTA "Browse →" right-aligned. |
| Visible-count line | text-small slate-700. Counts the rows the chokepoint visibility predicate would return for the current stringer. |
| Per-tier breakdown | text-tiny slate-500, three rows on sm (private / shared / one-off for rackets; private / shared for strings — strings have no one_off path because there is no Order.string_id ownership column for an "instance"; the V2 schema admits one-off strings via the order-attached predicate, but for stringers without orders that lift the catalogue row, the count is typically zero). |
Why no + New from the chooser? Adding a catalogue row needs context (which kind? owned by which client for racket?). The add-stringjob mini-flow has that context naturally. From the chooser, we'd render a "which kind?" picker → "which client?" picker → form — three taps to where the user could've been with one tap into the list page. The list page's "+ New" button is the right entry point.
Surface 2 — /catalogue/rackets (racket list)¶
The XLSX-parity racket-inventory surface. Searchable, filterable by visibility + last-strung, sortable.
Viewport sm 375 px (mobile-first)¶
┌───────────────────────────────────────┐
│ ← Rackets [+ New] │ Header: back + new-racket CTA
├───────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Search rackets │ │ Search input
│ └───────────────────────────────────┘ │
│ │
│ Visibility │ Filter chip group label
│ [ All 47 ] [ Private 12 ] │
│ [ Shared 32 ] [ One-off 3 ] │ Multi-row chip flow
│ │
│ Last strung │ Filter chip group label
│ [ Any ] [ Last 30 days ] │
│ [ This year ] [ Never ] │
│ │
│ Sort: ◉ Recent activity │ Sort radio
│ ◯ Make / model (A→Z) │
│ │
│ 47 rackets │ H3 + count
│ │
│ ┌───────────────────────────────────┐ │
│ │ 🎾 Babolat Pure Aero 98 │ │ Make + model
│ │ V8 · 100 sq in · 16×19 │ │ Spec line
│ │ owned by Lukas Müller │ │ Owner line
│ │ [Shared] last strung 2026-04-30│ │ Visibility chip + last-strung
│ │ Open → │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 🎾 Wilson Blade 98 │ │
│ │ V8 · 98 sq in · 16×19 │ │
│ │ owned by Anna Bauer │ │
│ │ [Private] last strung 2026-04-28│
│ │ Open → │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 🎾 Yonex EZONE 98 │ │
│ │ 2024 · 98 sq in · 16×19 │ │
│ │ owned by Stefan (myself) │ │
│ │ [Private] last strung 2026-04-30│
│ │ Open → │ │
│ └───────────────────────────────────┘ │
│ ⋯ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ Show more (10 of 47) │ │ Pagination
│ └───────────────────────────────────┘ │
│ │
└───────────────────────────────────────┘
Viewport lg 1280 px¶
┌───────────────────────────────────────────────────────────────────┐
│ ← Rackets [+ New] │
├───────────────────────────────────────────────────────────────────┤
│ │
│ [🔍 Search rackets ] │
│ │
│ Visibility: [All 47] [Private 12] [Shared 32] [One-off 3] │
│ Last strung: [Any] [Last 30 days] [This year] [Never] │
│ Sort: [Recent activity ▾] │
│ │
│ 47 rackets │
│ │
│ ┌─────────────────┬─────────┬──────────┬────────┬──────────────┐ │
│ │ Make / model │ Spec │ Owner │ Visib. │ Last strung │ │
│ ├─────────────────┼─────────┼──────────┼────────┼──────────────┤ │
│ │ Babolat Pure A. │ 100·16×19│ Lukas M.│ Shared │ 2026-04-30 │ │
│ │ Wilson Blade 98 │ 98·16×19│ Anna B. │ Private│ 2026-04-28 │ │
│ │ Yonex EZONE 98 │ 98·16×19│ Stefan │ Private│ 2026-04-30 │ │
│ │ ⋯ │ │ │ │ │ │
│ └─────────────────┴─────────┴──────────┴────────┴──────────────┘ │
│ │
│ [ Show more (10/47) ] │
└───────────────────────────────────────────────────────────────────┘
The columns "Spec / Owner / Visibility / Last strung" are mobile-stacked-into-the-card on sm; on lg they're a real table with sortable column headers.
Component breakdown¶
| Component | Notes |
|---|---|
| Header back-arrow | Returns to /catalogue chooser. |
+ New CTA |
Opens the same new-racket modal Pax-A is building for #144 (/orders/new/_new-racket) — but rendered in a "no order context" mode (the client_profile_id field becomes a picker for the new-racket-owner ClientProfile rather than being passed through from the order being drafted). On save, the new Racket lands as catalogue_private; the user is returned to the list with a "Saved." toast. No order is created. |
| Search input | Placeholder "Search rackets". Matches against Racket.make + model + version (case-insensitive substring, ILIKE). HTMX live-filter, 200 ms debounce — same shape as client list search. |
| Visibility filter chips | [All N] [Private N] [Shared N] [One-off N] — single-select; default "All". Filters by visibility = .... The "All" chip is the default URL state (/catalogue/rackets with no query string). |
| Last-strung filter chips | [Any] [Last 30 days] [This year] [Never] — single-select; default "Any". Computed via the same MAX(orders.strung_at) subquery the M9 picker uses (orders.py § list_clients_for_picker) — but joining on Order.racket_id instead of Order.client_profile_id. "Never" maps to MAX(strung_at) IS NULL. |
| Sort selector | Two options on sm (radio pills): "Recent activity" (default — MAX(orders.strung_at) DESC NULLS LAST) and "Make / model (A→Z)" (ORDER BY make, model). On lg, every column header is sort-clickable. |
| Racket row | Avatar — racket icon (🎾) on slate-100 background. H3 with make + model. Spec line version · head_size sq in · string_pattern (skips NULL fields). Owner line owned by {ClientProfile name}. Visibility chip + last-strung line. Whole row is the tap target. |
| Visibility chip | Per-row pill: [Private] (slate-100 / slate-700), [Shared] (indigo-50 / indigo-700), [One-off] (amber-50 / amber-700). Tier-coloured for at-a-glance recognition. |
| Last-strung line | "last strung YYYY-MM-DD" (Babel format_date) or "never strung" (slate-500 italic). Computed per-row via the same MAX subquery as the filter. |
+ New CTA |
(Top of header.) See above. |
| Pagination | "Show more (N of M)" — same shape as client list pagination. Default page size 50 (matches the client list); HTMX hx-swap="beforeend". |
| Empty state — no rackets | "You don't have any rackets in your catalogue yet. Add one through your next stringjob." + CTA "+ Add stringjob" routing to /orders/new. |
| Empty state — search/filter no match | "No rackets match your filters." + "Reset filters" link. |
Search behaviour¶
- Match scope:
Racket.make,Racket.model,Racket.version(concatenated for the match) — same as the M9 picker per issue #144. - Match type: case-insensitive substring (
ILIKE %q%). No fuzzy matching. - Debounce: 200 ms (matches client list + add-stringjob picker).
- Search interacts with filters: filters AND search compose (
visibility = 'private' AND make+model+version ILIKE %q%). Resetting filters doesn't reset the search; resetting search doesn't reset filters.
Sort decision¶
Default sort: "Recent activity" — most-recently-strung first. Rationale matches client list sort — Stefan's most-frequent catalogue use is "find the racket I just strung." Make/model alphabetical is the secondary sort.
TODO(stefan): confirm default sort. Alternative: alphabetical first. Mira leans recent-first for the same reason as the client list.
Surface 3 — /catalogue/rackets/{id} (racket detail)¶
The full-context view of one Racket. Renders read-only when created_by_stringer_id != self; renders editable affordances (Edit / Submit-for-moderation) when created_by_stringer_id == self.
Viewport sm 375 px¶
┌───────────────────────────────────────┐
│ ← Babolat Pure Aero 98 │ Header — back + page title
├───────────────────────────────────────┤
│ │
│ 🎾 Babolat Pure Aero 98 │ H1 + icon
│ V8 · 100 sq in · 16×19 │ Spec summary
│ │
│ ─── Specs ─── │ Group label
│ │
│ Make: Babolat │ Spec list — text-small
│ Model: Pure Aero 98 │
│ Version: V8 │
│ Head size: 100 sq in │
│ String pattern: 16 × 19 │
│ Year: 2023 │
│ Serial: PA98_2023_25 │ (only if set)
│ Instance label: PA98 #1 │ (only if set)
│ Currently in rotation: Yes │
│ │
│ ─── Origin ─── │
│ │
│ Visibility: [Private] │ Chip
│ Owner: Lukas Müller → │ Tap → /clients/{id}
│ Created by: you │
│ Created at: 2024-08-15 │
│ Last strung: 2026-04-30 │ Or "never strung" │
│ Times strung: 12 │
│ │
│ ─── Actions ─── │ Owner only
│ │
│ ┌──────────────────────────────┐ │
│ │ Edit │ │ Primary CTA
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ Submit for shared catalogue │ │ Visible iff Private + own
│ └──────────────────────────────┘ │
│ Promotes this entry so other │ text-tiny slate-500
│ stringers can pick it from their │
│ catalogue. │
│ │
│ ─── Order history ─── │ Section
│ │
│ 12 stringjobs on this racket │ Summary
│ │
│ ┌───────────────────────────────────┐ │
│ │ ✅ #2026-0042 · 2026-04-30 │ │ Order card
│ │ 24 kg · CHF 43 │ │
│ │ Open → │ │
│ └───────────────────────────────────┘ │
│ ⋯ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ Show more (10 of 12) │ │
│ └───────────────────────────────────┘ │
│ │
└───────────────────────────────────────┘
Viewport lg 1280 px¶
Two-column with specs + origin + actions on the left (~40%, sticky), order history on the right (~60%) — same shape as client-detail § lg viewport.
Component breakdown¶
Specs panel¶
<dl> with <dt> (slate-500) + <dd> (slate-700). Skips NULL fields entirely (no "Version: —" row when version IS NULL).
| Field | Source | Notes |
|---|---|---|
| Make | Racket.make |
Required column. |
| Model | Racket.model |
Required column. |
| Version | Racket.version |
Optional. Skipped when NULL. |
| Head size | Racket.head_size (sq in) |
Optional. "100 sq in" via Babel format_unit. |
| String pattern | Racket.string_pattern |
Optional. "16 × 19" — the column stores "16x19" text; the UI inserts the ×. |
| Serial number | Racket.serial_number |
Optional. Monospace. |
| Instance label | Racket.instance_id |
Optional. Free text — "PA98 #1". |
| Year | Racket.year |
Optional. |
| Currently in rotation | Racket.current |
Bool — "Yes" / "No". |
Origin panel¶
| Field | Notes |
|---|---|
| Visibility | Same per-row chip as the list page ([Private] / [Shared] / [One-off]). |
| Owner | Racket.owner_client_profile_id → ClientProfile name. Tap navigates to /clients/{owner_client_profile_id}. |
| Created by | "you" if created_by_stringer_id == self, else "(another stringer)" — we don't surface a name across tenants per the privacy invariant. The line still tells Stefan "this isn't mine to edit." |
| Created at | Racket.created_at ISO date. |
| Last strung | MAX(orders.strung_at) for Order.racket_id == this.id AND Order.stringer_id == self. ISO date or "never strung." |
| Times strung | COUNT(orders) for Order.racket_id == this.id AND Order.stringer_id == self AND strung_at IS NOT NULL. |
Same-tenant restriction on order-derived metrics: "last strung" and "times strung" are computed only over the current stringer's orders, not cross-tenant. A catalogue_shared racket strung 100 times by other stringers shows "times strung: 0" if the current stringer has never used it. This is consistent with the M9 picker's last_strung_at shape (per app/services/orders.py § list_clients_for_picker — comment: "if a Rule-3-shared CP has Rule-3 invisible orders strung by ANOTHER stringer, we render 'no previous jobs'").
Actions panel — owner-only¶
Renders only when created_by_stringer_id == self. For non-owner views (e.g. a catalogue_shared racket created by another stringer, or a one_off racket reachable through an inbound Rule #1 share's order), the section is omitted entirely; the page renders specs + origin + order history without the action buttons.
| Button | Visibility | Notes |
|---|---|---|
| Edit | Always (when owner) | Routes to /catalogue/rackets/{id}/edit. Modal form. |
| Submit for shared catalogue | When visibility == 'catalogue_private' AND no PENDING CatalogueSubmission exists for this row |
Opens the submit-for-moderation modal. |
| Submission pending | When a PENDING CatalogueSubmission exists for this row |
Read-only chip "Submission pending review · {N days ago}" — replaces the Submit button. Tap opens a small read-only sheet showing the submission's submitted_at and a link "View moderation status" (which routes to the V2.x stringer-side view of CatalogueSubmission — out of this spec). |
| Submitted (decided) | When the most recent CatalogueSubmission is promoted or rejected |
The Submit button stays available IF the row is still catalogue_private (i.e. rejected — re-submission is allowed per the schema's uq_catalogue_submissions_one_pending_per_target partial unique). On promoted, the row's visibility is now catalogue_shared, so the submit button is moot and is omitted. |
TODO(stefan): confirm the "no V2 stringer-side view of decided submissions" punt. The schema (
CatalogueSubmission) supports it; the UI is queued for a future round (mentioned in design § Future rounds as the producer side of the moderation queue). The compromise this spec adopts: the pending-state chip exists; the decided-state full view is deferred. Stefan to flag if he wants the decided-state view surfaced now.
Order history¶
Per-racket order list, paginated. Shape matches client-detail § order history but keyed off Order.racket_id instead of Order.client_profile_id. Default page size 20.
The order history is stringer-scoped: only the current stringer's orders against this racket are visible (chokepoint applies). On a catalogue_shared racket, the section reads "0 stringjobs on this racket" if the current stringer has never used it; on a same-tenant racket with 12 of the stringer's stringjobs, all 12 render.
Surface 4 — /catalogue/rackets/{id}/edit (racket edit modal)¶
Modal form. Renders only when created_by_stringer_id == self; non-owners hitting the URL get a 403 with a "this catalogue entry is read-only" page.
╔═══════════════════════════════╗
║ Edit racket ║
║ ║
║ Make * ║
║ [Babolat ] ║
║ ║
║ Model * ║
║ [Pure Aero 98 ] ║
║ ║
║ Version ║
║ [V8 ] ║
║ ║
║ Head size (sq in) ║
║ [100 ] ║
║ ║
║ String pattern ║
║ [16x19 ] ║
║ ║
║ Year ║
║ [2023 ] ║
║ ║
║ Serial number ║
║ [PA98_2023_25 ] ║
║ ║
║ Instance label ║
║ [PA98 #1 ] ║
║ ║
║ Owner * ║
║ [Lukas Müller ▾] ║ Client picker (per add-stringjob shape)
║ ║
║ ☑ Currently in rotation ║
║ ║
║ ⓘ Visibility: [Private] ║
║ Use "Submit for shared ║ text-tiny slate-500
║ catalogue" on the detail ║
║ page to promote this entry. ║
║ ║
║ ┌───────────┐ ┌────────────┐ ║
║ │ Cancel │ │ Save │ ║
║ └───────────┘ └────────────┘ ║
╚═══════════════════════════════╝
Component breakdown¶
| Field | Type | Notes |
|---|---|---|
| Make | <input maxlength="80" required> |
Per Racket.make SAString(80). |
| Model | <input maxlength="120" required> |
Per Racket.model SAString(120). |
| Version | <input maxlength="40"> |
Optional. |
| Head size | <input type="number" inputmode="numeric" min="50" max="200"> |
Sq in. Optional. |
| String pattern | <input maxlength="20"> |
Free text — "16x19" / "18x20" / "16x18-tied". |
| Year | <input type="number" inputmode="numeric" min="1980" max="2099"> |
Optional. |
| Serial number | <input maxlength="80"> |
Optional. Monospace. |
| Instance label | <input maxlength="80"> |
Optional. |
| Owner | Picker — same shape as the add-stringjob client picker | Required. Default-filled with the current owner; the user can re-assign to a different ClientProfile in their tenant. No re-assignment to a ClientProfile from another tenant (chokepoint enforces). |
| Currently in rotation | <input type="checkbox"> |
Maps to Racket.current. |
| Visibility (read-only display) | Chip + hint | Visibility is not editable on this form — the only path from catalogue_private → catalogue_shared is the moderation queue. The chip + hint communicates that. |
Visibility editability — explicit non-affordance¶
Visibility is the load-bearing axis (per Goal #2) and is governed by the moderation queue. Therefore:
- The edit form does NOT expose visibility as an editable field.
- A stringer cannot self-promote
catalogue_private→catalogue_shared(M17's whole point — "no unilateral flag-flip by stringers"). The path is "Submit for shared catalogue" → admin promotes. - A stringer CAN flip
catalogue_private↔one_offis NOT an offered transition —one_offis set only at order-creation time per the schema's intent ("visibility = one_off (order-attached only)" percatalogue.pyline 245). The UI doesn't surface acatalogue_private→one_offtransition; once created, the row stays in its tier until moderated. - A stringer cannot demote a
catalogue_sharedrow back tocatalogue_private— that's an admin-only operation (V2 doesn't expose it; it would require a UI on the admin-catalogue-moderation surface).
TODO(stefan): confirm the "no demote / no shared→private revert" stance. Edge case: a stringer mistakenly submits a typo'd Racket ("Babolat Pyre Aero 98") that gets promoted; the only V2 path is "edit the typo on the shared row" (per the edit form, since make/model are editable on own rows). If Stefan wants a "request demote" flow, that's a V2.x admin polish — not in this spec.
Validation¶
| Rule | Inline message |
|---|---|
| Make empty | "Make is required." |
| Make > 80 chars | "Make is too long (max 80)." |
| Model empty | "Model is required." |
| Model > 120 chars | "Model is too long (max 120)." |
| Version > 40 chars | "Version is too long (max 40)." |
| Head size out of range (50–200) | "Head size must be between 50 and 200 square inches." |
| Year out of range (1980–2099) | "Year must be between 1980 and 2099." |
| Serial > 80 chars | "Serial number is too long (max 80)." |
| Instance label > 80 chars | "Instance label is too long (max 80)." |
| Owner empty | "Owner is required." |
Submit-for-moderation flow¶
Triggered from the racket-detail page's "Submit for shared catalogue" button. Renders only when visibility == 'catalogue_private' AND the row's created_by_stringer_id == self. Creates a CatalogueSubmission row with target_type = 'racket', target_id = this.id, status = 'pending'.
Submit modal¶
╔═══════════════════════════════╗
║ ║
║ Submit for shared catalogue? ║ H2
║ ║
║ Babolat Pure Aero 98 will be ║ text-body
║ reviewed by an admin and, if ║
║ promoted, become visible to ║
║ every stringer. ║
║ ║
║ Note for the admin (optional)║ text-small slate-700
║ ┌─────────────────────────┐ ║
║ │ │ ║ Textarea
║ │ │ ║
║ └─────────────────────────┘ ║
║ e.g. "I've used this with ║ text-tiny slate-500 — placeholder
║ several clients this season" ║
║ ║
║ ⓘ Until the admin decides, ║ text-tiny slate-500
║ this racket stays visible to ║
║ you only. ║
║ ║
║ ┌───────────┐ ┌────────────┐ ║
║ │ Cancel │ │ Submit │ ║ Primary
║ └───────────┘ └────────────┘ ║
╚═══════════════════════════════╝
Behaviour¶
- Note is optional (consistent with admin-catalogue-moderation Stage 3 which renders the note when present). Maps to
CatalogueSubmission.notes. (Note: the Pax-A schema names this field — the column doesn't currently exist; see decision-log below.) - Submit creates the
CatalogueSubmissionrow inpendingstate, fires the admin notification (catalogue_submission_pending), closes the modal, and re-renders the detail page with the "Submission pending review" chip replacing the Submit button. Toast: "Submitted for review." - Cancel dismisses the modal; nothing changes.
- Already-pending guard (server-side, defensive): if a PENDING submission already exists for this
target_id(per the partial uniqueuq_catalogue_submissions_one_pending_per_target), the server returns a 409 + the modal renders an inline error "A submission for this racket is already pending review." (This shouldn't happen via the UI — the Submit button is hidden when pending exists — but defends against double-submit / replay.) - Re-submit after reject is allowed per the schema; the Submit button re-appears on the detail page when the most recent submission is
rejectedAND visibility is stillcatalogue_private. Pre-fills the note textarea with the prior submission's note (if any) — small kindness for the "fix the typo and resubmit" path.
Note schema gap¶
The admin-catalogue-moderation § Stage 3 spec renders CatalogueSubmission.notes as a free-text block ("Stringer's note"). The current app/db/models/catalogue.py does not declare a notes column on CatalogueSubmission — only reject_reason (admin-side) and reviewed_by_admin_id / reviewed_at exist.
TODO(stefan): the
CatalogueSubmission.notescolumn needs to land before this submit modal can ship. Mira flagged this as a schema gap on review of admin-catalogue-moderation.md vs the model file. Pax to add a nullablenotestext column onCatalogueSubmission(no migration of existing rows needed — they're all NULL post-add). Out of this spec's scope but a blocking sibling for #147 implementation. Mira's recommendation: file a sibling issue or include in #147's implementation MR.
Surface 5 — /catalogue/strings (string list)¶
Symmetric to the racket list. The structural difference: String has fewer columns (manufacturer, model, gauge), no owner_client_profile_id, no current rotation flag.
Viewport sm 375 px¶
┌───────────────────────────────────────┐
│ ← Strings [+ New] │
├───────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Search strings │ │
│ └───────────────────────────────────┘ │
│ │
│ Visibility │
│ [ All 23 ] [ Private 4 ] │
│ [ Shared 19 ] │ Note: no One-off chip — see below
│ │
│ Last used │
│ [ Any ] [ Last 30 days ] │
│ [ This year ] [ Never ] │
│ │
│ Sort: ◉ Recent activity │
│ ◯ Manufacturer / model (A→Z) │
│ │
│ 23 strings │
│ │
│ ┌───────────────────────────────────┐ │
│ │ ⫻ Luxilon ALU Power │ │
│ │ 1.25 mm │ │ Spec line — gauge only
│ │ [Shared] last used 2026-04-30 │ │ Visibility chip + last-used
│ │ Open → │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ ⫻ Solinco Hyper-G │ │
│ │ 1.20 mm │ │
│ │ [Private] last used 2026-04-25│ │
│ │ Open → │ │
│ └───────────────────────────────────┘ │
│ ⋯ │
│ │
└───────────────────────────────────────┘
Component breakdown¶
The list page's components mirror Surface 2 with adjustments:
| Component | Difference from racket list |
|---|---|
| Search match scope | String.manufacturer + model + gauge (concatenated). Mirrors the M9 string-picker per issue #144. |
| Visibility filter chips | [All N] [Private N] [Shared N] — no [One-off N] chip rendered by default. Strings can technically have visibility = 'one_off' per the schema (a string spec attached to one Order, never reused), but the count is typically zero for a stringer with normal usage; the chip would be visual noise. Server-side rule: if count(visibility = 'one_off') > 0, render the chip; otherwise omit. Same convention as the racket list page (the racket list always renders one-off because rackets-as-instances have a more legitimate one_off shape). |
| Last-used filter | Replaces "Last strung" — naming follows the verb that fits a string (you "use" a string in a stringjob; you "string" a racket with a string). Same query: MAX(orders.strung_at) joining Order.main_string_id OR Order.cross_string_id against String.id. |
| Owner column | Removed. Strings have no owner_client_profile_id. The list page card has 4 lines instead of 5 (no "owned by ..." line). |
Currently in rotation field |
Removed. No analogue on String. |
lg-fallback table columns¶
Owner column omitted; one fewer column than the racket list.
Surface 6 — /catalogue/strings/{id} (string detail)¶
Mirror of Surface 3 with the same shape adjustments as the list:
- Specs panel has: Manufacturer, Model, Gauge. No head size, string pattern, year, serial, instance label, current.
- Origin panel omits the Owner row.
- Actions panel is identical to Surface 3 (Edit + Submit-for-moderation conditional logic).
- Order history — joins on
Order.main_string_id == this.idOROrder.cross_string_id == this.id. The order card adds a small "main" / "cross" label so the user sees which side of the racket the string was used on. (V2 reality: ~89% of cases use the same string on both sides per UC-2; the per-side label still distinguishes the rare BYO-cross / mixed setups.)
Edit modal (Surface 7)¶
Same modal shape as racket edit, with three fields instead of nine:
| Field | Type | Notes |
|---|---|---|
| Manufacturer | <input maxlength="80" required> |
Per String.manufacturer SAString(80). |
| Model | <input maxlength="120" required> |
Per String.model SAString(120). |
| Gauge | <input maxlength="20"> |
Optional. Free text — "1.25" / "17g". |
Validation rules mirror the racket edit form for the matching fields; no head-size / year / pattern / etc.
V3 forward-compat hooks¶
Per epic #147 + the sibling-issue cross-cuts: surfaces where V3 features will land on the catalogue browse without re-architecting.
Hook 1 — source filter (#145 column lands V2; UI is V3)¶
Per issue #145, the V2 schema-hook adds Racket.source and String.source enum columns (manual | imported), default manual. No V2 UI surfaces them. The browse list pages reserve a structural slot:
- The filter-chip area on
/catalogue/racketsand/catalogue/stringsadmits a third filter row below "Last strung" / "Last used":
Visibility: [All 47] [Private 12] [Shared 32] [One-off 3]
Last strung: [Any] [Last 30 days] [This year] [Never]
Source: [Any] [Manual] [Imported] ← V3 only
- In V2 the "Source" row is NOT rendered. The
_filter_chipstemplate carries an explicit<!-- v3-source-filter -->HTML comment where Pax + Juno render the row conditionally on a feature flag once V3 ships. - The detail-page Origin panel admits a future "Source" row:
Same posture: comment-marker reserved; not rendered in V2.
Hook 2 — V3 auto-update (#146 — imported rows + last-imported-at + conflict resolution)¶
Per issue #146, V3 adds periodic ingestion from manufacturer feeds. The browse surface becomes the natural display point for:
- Per-row "imported" badge on the list rows:
[Imported]chip next to[Shared](same chip style; teal-50 / teal-700 to differentiate). Indicates the row originated from a feed, not from a manual stringer entry. - Per-row "stale" indicator when an imported row's source feed has been updated more recently than the local copy (V3 conflict-resolution surface; out of V2 scope).
- A per-row "merge with newer" CTA on shared+imported rows where the manufacturer feed has a newer version (V3-only).
V2 posture: all three are unimplemented; the row card has explicit space reserved. The right-edge of the visibility-chip area on the list-page row card admits one additional chip without breaking the layout (verified at sm 375 px — three chips fit on one line).
Hook 3 — Bulk operations (V3, not in #147 scope)¶
Bulk-edit / bulk-submit-for-moderation / bulk-archive operations are V3 territory (out of V2 per epic #147 non-goals). The list page's per-row tap target is the only interactive affordance; no checkboxes for multi-select. V3 will need to retrofit checkboxes — the current row layout reserves left-edge space (16 px) where a checkbox would land.
Non-goals¶
Per epic #147 + Mira's sweep:
- Admin moderation UI — shipped Round 5; lives at
/admin/catalogue/queue. The submit-for-moderation flow on this page feeds that queue; the queue itself is admin-only. - Auto-update from external feeds — V3 (#146). See Hook 2 above.
- Per-client racket history. Lives on
/clients/{id}§ order history (per-client, all rackets). The/catalogue/rackets/{id}detail page's order history is per-racket (cross-client). The two surfaces are distinct projections of the sameOrdertable. - Catalogue-row deletion. Per
Racket.owner_client_profile_idondelete=RESTRICT and the FK chain to Order, deleting a catalogue row that has orders would orphan the order. V2 doesn't offer a "delete this racket" affordance; the soft equivalent isRacket.current = false(sets the in-rotation flag — preserves the row + order history). Strings have nocurrentflag; for V2, a stringer who created a typo'dcatalogue_privateString simply leaves the row in place (it doesn't appear in the M9 picker prominently if never used). A "remove this string from my catalogue" affordance is V2.x polish, not in this spec. - Cross-stringer catalogue search — the chokepoint already shows shared rows from all stringers; there is no "show me all stringers' private rackets" view (privacy invariant; ADR-0004).
- Catalogue-row commenting / discussion. Out of V2; the
CatalogueSubmission.notesfield is a one-shot stringer→admin message, not a thread. - Tag / category metadata on catalogue rows ("power racket" / "control racket" / "polyester" / "multifilament"). Out of V2; no schema, no UI. V3 candidate.
- Stringer-side full view of decided submissions (the producer-side mirror of admin-catalogue-moderation § archive). Mentioned in design § Future rounds as the producer-side complement; out of #147 scope.
Interaction states¶
| State | What renders |
|---|---|
| Chooser landing — initial | Two cards with counts. |
| Chooser landing — empty (no catalogue rows) | Zero counts on both cards; the cards still render with "0 visible to you" + a hint "Your first racket / string lands here when you save your first stringjob." |
| List — initial load | First 50 rows + filter chips + sort selector + search. |
| List — searching / filtering | HTMX swap of the row region as the user types or taps a chip. 200 ms debounce on search; immediate on chip. |
| List — show more | New 50 rows append; "Show more" CTA refreshes with new "(N of M)". |
| List — empty (no rows for filter) | "No {rackets / strings} match your filters." + "Reset filters" link. |
| List — empty (no rows at all) | "You don't have any {rackets / strings} in your catalogue yet." + CTA "+ Add stringjob". |
| Detail — initial load (own row) | Specs + Origin + Actions panel + Order history (first 20 orders). |
| Detail — initial load (shared row, not own) | Same shape MINUS the Actions panel. The page surfaces the spec + the order history (this stringer's only). |
| Detail — no order history | "No stringjobs on this {racket / string} yet." + CTA "+ Add stringjob" routing to /orders/new with the catalogue row pre-selected. |
| Detail — submission pending | Actions panel shows "Submission pending review · {N days ago}" chip in place of the Submit button. |
| Detail — submission rejected, re-submit available | Submit button re-appears. The "Decisions" history (a small expandable below the Actions panel — "Show prior submissions") lists the rejected submission with its reject_reason. For V2: the prior-submissions history is rendered as a small <details> element; the inline content is server-rendered HTML (no extra HTMX). |
| Edit modal — open | Modal pre-filled with current row values. |
| Edit modal — save success | Modal closes; detail page re-renders with the new values; toast: "Saved." |
| Edit modal — validation error | Inline red-700 per affected field; modal stays open. |
| Submit modal — open | Modal renders with empty notes textarea (or pre-filled with prior rejected submission's notes). |
| Submit modal — submit success | Modal closes; detail page re-renders with the "Submission pending review" chip; toast: "Submitted for review." |
| Submit modal — already-pending 409 | Inline red-700 "A submission for this {racket / string} is already pending review."; modal stays open. |
| 403 — non-owner hits edit URL | Plain page: "This catalogue entry is read-only — only the stringer who created it can edit." + back link to detail page. |
| 404 — catalogue id doesn't exist or chokepoint hides it | Plain "Not found" page; identical to the chokepoint's tenant-isolation 404 elsewhere. |
Validation rules (UI surface; canonical server-side)¶
| Rule | Inline message |
|---|---|
| Make / Manufacturer empty (edit) | "{Make / Manufacturer} is required." |
| Make / Manufacturer > 80 chars | "Too long (max 80)." |
| Model empty | "Model is required." |
| Model > 120 chars | "Too long (max 120)." |
| Version > 40 chars | "Version is too long (max 40)." |
| Head size out of range (50–200) | "Head size must be between 50 and 200 sq in." |
| Year out of range (1980–2099) | "Year must be between 1980 and 2099." |
| Serial > 80 chars | "Serial number is too long (max 80)." |
| Instance label > 80 chars | "Instance label is too long (max 80)." |
| Owner empty (racket only) | "Owner is required." |
| Gauge > 20 chars | "Gauge is too long (max 20)." |
| Submit modal — already pending (409) | "A submission for this {racket / string} is already pending review." |
Accessibility¶
- Heading order:
<h1>per page (chooser: "Catalogue"; list: "Rackets" / "Strings"; detail: catalogue row's display name) →<h2>per section ("Specs", "Origin", "Actions", "Order history"). The list-page H1 is visually rendered in the back-arrow header; the chooser-page H1 is similarly rendered. - Search input:
<input type="search">,aria-label="Search rackets"/"Search strings". - Filter chips:
<fieldset role="group" aria-label="Filter by visibility">+<button aria-pressed="true|false">per chip. Single-select (radio-like behaviour); clicking a chip flips itsaria-pressedand clears the others. - Sort selector: same pattern as client list sort —
<fieldset>with two radios onsm; column-header<button>inside<th>onlgtable view;aria-sortflips between ascending / descending / none. - Catalogue row tap targets: whole row is a single focusable region;
aria-labeldescribes the row, e.g. "Open Babolat Pure Aero 98 — V8, 100 sq in, 16 by 19, owned by Lukas Müller, Private, last strung 2026-04-30." - Visibility chip:
aria-labelon the chip describes the tier semantically: "Private to you" / "Shared catalogue" / "One-off entry, attached to one order." The decorative chip text is for sighted users; the aria-label disambiguates for screen readers. - Modals:
role="dialog" aria-modal="true" aria-labelledby="modal-title". Focus moves to the first input on open; ESC dismisses (Cancel-equivalent); focus trap inside the modal until close. - Non-owner detail view: the omission of the Actions panel is signaled by the page structure — there's no "you can't edit this" warning, just the absence of the buttons. Screen-reader users navigating the page hear "Specs", "Origin", "Order history" — no "Actions" — which is correct.
- Submit-for-moderation modal: the destructive-ish nature (it's a one-way request — admin reviews, can't be cancelled by the stringer mid-review) is communicated via copy ("will be reviewed by an admin and, if promoted, become visible to every stringer"), not via destructive-styling colours. The button is primary indigo, not red.
- Hit targets: every interactive element ≥ 44 × 44 px; row hit targets are full-row.
- Color contrast: every text/background pair meets WCAG 2.1 AA. The visibility chips: slate-700 on slate-100 (10.5:1), indigo-700 on indigo-50 (8.59:1), amber-700 on amber-50 (4.92:1). All pass.
prefers-reduced-motion: disables HTMX swap fades; "Show more" appends instantly; modal transitions become instant.- Without JS: every link still works; modals become full pages (
/catalogue/rackets/{id}/editis a real page, not just a modal); search reverts to a<form method="get">that re-renders the list with the filter applied.
HTMX / progressive-enhancement seams¶
- List search:
hx-get="/catalogue/rackets?q=..." hx-trigger="keyup changed delay:200ms" hx-target="#catalogue-list" hx-swap="innerHTML". Without JS: regular<form method="get">submit. - Filter chip tap:
hx-get="/catalogue/rackets?visibility=private" hx-target="#catalogue-list". Without JS: each chip is a regular<a href="?visibility=private">link. - List sort: column-header click on
lg→hx-get="/catalogue/rackets?sort=..." hx-target="#catalogue-list". Without JS:<a href="?sort=...">link. - List pagination:
hx-get="/catalogue/rackets?page=N" hx-target="#catalogue-list" hx-swap="beforeend". Without JS:<a href="?page=N#catalogue-list">link. - Detail page initial load: server-rendered
GET /catalogue/rackets/{id}. - Detail order pagination:
hx-get="/catalogue/rackets/{id}?page=N" hx-target="#order-list" hx-swap="beforeend". Without JS: anchor link. - Edit modal — open:
hx-get="/catalogue/rackets/{id}/edit" hx-target="#modal-portal" hx-swap="innerHTML". Without JS: separate/catalogue/rackets/{id}/editpage. - Edit modal — save: regular
<form method="post" action="/catalogue/rackets/{id}/edit">. HTMX upgrade returns a 303 redirect; without JS the browser follows. - Submit-for-moderation modal — open:
hx-get="/catalogue/rackets/{id}/submit" hx-target="#modal-portal". Without JS: separate/catalogue/rackets/{id}/submitpage. - Submit modal — submit: regular
<form method="post" action="/catalogue/rackets/{id}/submit">. HTMX upgrade returns a 303; without JS the browser follows back to/catalogue/rackets/{id}. + New(list page):hx-get="/catalogue/rackets/new" hx-target="#modal-portal"(re-uses the same Pax-A endpoint/orders/new/_new-racketmounted at this path with the no-order context flag). Without JS: full-page form at/catalogue/rackets/new.
The page's fundamental contract is "regular form POSTs". HTMX swaps are surgical optimisations.
i18n affordance¶
| String | Type | Catalogue key |
|---|---|---|
| "Catalogue" (chooser H1) | {% trans %} |
catalogue.chooser.title |
| "Browse your inventory of rackets and strings." | {% trans %} |
catalogue.chooser.subtitle |
| "Rackets" / "Strings" (chooser cards + list H1s) | {% trans %} |
catalogue.kind.{rackets,strings} |
| "{N} visible to you" / per-tier breakdown | {% trans %} (Babel format_decimal) |
catalogue.chooser.{visible,private,shared,one_off} |
| "Browse →" CTA | {% trans %} |
catalogue.chooser.cta |
| "Search rackets" / "Search strings" placeholder | {% trans %} |
catalogue.list.search.placeholder.{rackets,strings} |
| "Visibility" / "Last strung" / "Last used" / "Sort:" filter labels | {% trans %} |
catalogue.list.filter.{visibility,last_strung,last_used,sort} |
| "All" / "Private" / "Shared" / "One-off" chip labels | {% trans %} |
catalogue.visibility.{all,private,shared,one_off} |
| "Any" / "Last 30 days" / "This year" / "Never" chip labels | {% trans %} |
catalogue.last_strung.{any,last_30,this_year,never} |
| "Recent activity" / "Make / model (A→Z)" / "Manufacturer / model (A→Z)" sort labels | {% trans %} |
catalogue.list.sort.{recent,name_racket,name_string} |
| "{N} rackets" / "{N} strings" count line | {% trans %} (Babel) |
catalogue.list.count.{rackets,strings} |
| "Show more ({N} of {M})" | {% trans %} (Babel) |
catalogue.list.show_more |
| "+ New" CTA | {% trans %} |
catalogue.list.cta.new |
| Row spec line "{version} · {head_size} sq in · {pattern}" | mixed ({% trans %} for "sq in" via Babel format_unit; rest is data) |
catalogue.row.head_size_unit (just "sq in") |
| "owned by {name}" | {% trans %} |
catalogue.row.owned_by |
| "last strung {date}" / "never strung" / "last used {date}" / "never used" | {% trans %} (Babel format_date) |
catalogue.row.last_{strung,used}.{value,never} |
| "Specs" / "Origin" / "Actions" / "Order history" detail H2s | {% trans %} |
catalogue.detail.section.{specs,origin,actions,history} |
| Spec field labels ("Make", "Model", "Version", "Head size", "String pattern", "Year", "Serial", "Instance label", "Currently in rotation", "Manufacturer", "Gauge") | {% trans %} |
catalogue.detail.field.{make,model,version,head_size,string_pattern,year,serial,instance_label,current,manufacturer,gauge} |
| Origin field labels ("Visibility", "Owner", "Created by", "Created at", "Last strung", "Times strung") | {% trans %} |
catalogue.detail.origin.{visibility,owner,created_by,created_at,last_strung,times_strung} |
| "you" (created_by self) / "(another stringer)" | {% trans %} |
catalogue.detail.origin.created_by.{self,other} |
| "Yes" / "No" (Currently in rotation) | {% trans %} |
common.{yes,no} |
| "Edit" CTA | {% trans %} |
catalogue.detail.cta.edit |
| "Submit for shared catalogue" CTA + hint | {% trans %} |
catalogue.detail.cta.submit / catalogue.detail.cta.submit_hint |
| "Submission pending review · {N days ago}" chip | {% trans %} (Babel) |
catalogue.detail.submission.pending |
| "{N} stringjobs on this racket" / "this string" | {% trans %} (Babel) |
catalogue.detail.history.summary.{racket,string} |
| "main" / "cross" string-side label (string detail order card) | {% trans %} |
catalogue.detail.history.side.{main,cross} |
| Edit modal title "Edit racket" / "Edit string" | {% trans %} |
catalogue.edit.title.{racket,string} |
| Edit modal field labels (re-use detail-field keys) | {% trans %} |
(re-use) |
| Edit modal "Visibility:" hint | {% trans %} |
catalogue.edit.visibility_hint |
| Edit modal "Cancel" / "Save" | {% trans %} |
common.{cancel,save} |
| Submit modal title "Submit for shared catalogue?" | {% trans %} |
catalogue.submit.title |
| Submit modal body "{name} will be reviewed…" | {% trans %} |
catalogue.submit.body |
| Submit modal "Note for the admin (optional)" | {% trans %} |
catalogue.submit.note_label |
| Submit modal placeholder hint "e.g. 'I've used this with…'" | {% trans %} |
catalogue.submit.note_placeholder |
| Submit modal hint "Until the admin decides, this racket stays visible to you only." | {% trans %} |
catalogue.submit.privacy_hint |
| Submit modal "Cancel" / "Submit" | {% trans %} |
common.cancel / catalogue.submit.cta |
| Toasts ("Saved.", "Submitted for review.") | {% trans %} |
catalogue.toast.{saved,submitted} |
| Empty states ("You don't have any rackets…", "No rackets match your filters.") | {% trans %} |
catalogue.list.empty.{none,no_match}.{rackets,strings} |
| 403 page "This catalogue entry is read-only…" | {% trans %} |
catalogue.detail.readonly_403 |
| Validation messages | {% trans %} |
catalogue.validation.{...} |
| Make / Model / Manufacturer values (Stefan's actual data) | Data | n/a |
| Date "2026-04-30" | Format ISO YYYY-MM-DD (Babel format_date) |
n/a |
| Tension "24 kg" (in order card) | Format (Babel format_unit) |
n/a |
| Currency "CHF 43" (in order card) | Format (Babel format_currency) |
n/a |
DE strings are Iris's later pass.
DE width budget (designer note)¶
DE labels here are typically 20–30% longer than EN. Specific watch-outs:
- "Catalogue" → "Katalog" (~0.8×) — actually shorter.
- "Rackets" → "Schläger" (~1.2×) — fits chooser card + list H1.
- "Strings" → "Saiten" (~0.8×) — fits.
- "Search rackets" → "Schläger suchen" (~1.1×) — fits.
- "Visibility" → "Sichtbarkeit" (~1.3×) — fits filter row label.
- "Private" → "Privat" (~0.8×); "Shared" → "Geteilt" (~1.2×); "One-off" → "Einmalig" (~1.4×) — chip widths admit each.
- "Last strung {date}" → "Zuletzt bespannt {date}" (~1.5×) — wraps to two lines on
smfor some rows; the row card reserves the height (already verified for the client list). - "Submit for shared catalogue" → "Für geteilten Katalog einreichen" (~1.6×) — the button wraps to two lines on
sm; OK at 48 px hit target. - "Submission pending review · {N days ago}" → "Einreichung in Prüfung · vor {N} Tagen" (~1.5×) — wraps; OK.
- "owned by {name}" → "gehört {name}" (~0.8×) — actually shorter.
- "Currently in rotation" → "Aktuell im Einsatz" (~1.0×) — fits the modal label.
- "{N} stringjobs on this racket" → "{N} Bespannungen mit diesem Schläger" (~1.5×) — wraps to two lines; OK.
The single-column scroll naturally handles wrapping; no DE-specific layout tweaks needed.
Decisions Mira made on Stefan's behalf¶
Listed for the MR description so Stefan can reverse any of them on review:
- Split routes —
/catalogue/rackets+/catalogue/stringswith a/cataloguechooser landing — vs. tabs at one route or a unified mixed list. Five-point rationale above; Stefan flips trivially if he prefers tabs. + Newfrom the list page reuses the add-stringjob inline-create modal (Pax-A's #144 endpoints) in a "no order context" mode. Avoids forking the create form; no orphan-catalogue-row standalone create path.- Visibility is NOT editable on the racket / string edit form — the only
catalogue_private→catalogue_sharedtransition is the moderation queue. M17's "no unilateral flag-flip by stringers" explicitly drives this. one_offrows are not user-promotable —one_offis set at order-creation time only, and the edit form does not offer acatalogue_private→one_offtransition. Stefan to flag if he wants the demote-to-one-off path.catalogue_sharedrows cannot be reverted tocatalogue_privatefrom the stringer side — admin-only operation. V2 doesn't expose it.- Visibility filter shows
[One-off]only when count > 0 on each list (rackets and strings). Avoids visual noise for stringers without one-off rows. - Last-strung / last-used / times-strung metrics are stringer-scoped — only the current stringer's orders count. A
catalogue_sharedracket strung 100 times by other stringers shows "0 stringjobs" if the current stringer has never used it. Matches the M9 picker's posture. - Catalogue rows are NOT deletable in V2 — soft-equivalent for rackets is
Racket.current = false; for strings, no soft-equivalent exists (a typo'd private string just sits unused). FK chain to Order makes hard-delete unsafe. - Submit-for-moderation note is optional. Re-submission after reject is allowed and pre-fills the prior note. No V2 stringer-side full view of decided submissions (queued for a future round).
- The detail page omits the Actions panel entirely for non-owner views — vs. rendering disabled buttons + a "you can't edit this" warning. Cleaner; the absence is the message.
sourcefilter (#145) is structurally reserved but not rendered in V2. A comment marker in the template tells Pax + Juno where to wire the V3 chip row.- Auto-update /
importedUI (#146) is structurally reserved on the row card — one extra chip slot fits atsmwidth — but not rendered in V2. - Per-row checkboxes for bulk operations are not in V2 — but the row layout reserves left-edge space (16 px) for V3 retrofit.
Open questions for Stefan (with proposed defaults)¶
- Split routes vs. tabs at
/catalogue? Proposed default: split routes with chooser landing. See surface-decision section above. Stefan flips trivially if he prefers tabs at one URL. - List page size — 50? Proposed default: 50. Matches the client list page size; Stefan's V1 inventory is small enough that 50 covers most stringers in 1–3 pages.
- Order-history page size on detail (20)? Proposed default: 20. Matches client-detail's order history. Bumps trivially if Stefan flags it.
- Visibility filter chip default — "All" or "Private"? Proposed default: "All". Stefan's curatorial use case ("submit my private string for moderation") is a sub-task; the primary use case is "what's in my catalogue?" — answered by All.
- Show one-off chip when count is 0 — render with "0" or omit? Proposed default: omit. Per decision #6 above. Stefan to flag if he wants it always-rendered for consistency.
- Search match scope — also match
Racket.serial_number,Racket.instance_id? Proposed default: no — search matches make + model + version only. Serial / instance are ID-shaped fields; matching them from a free-text search produces hits that look like noise. Stefan can filter via the (future) advanced-filter row if needed. - Last-strung filter buckets — "Last 30 days" / "This year" / "Never" is a 3-bucket coarse filter. Should Mira add "Last 7 days" or "Last 90 days" buckets? Proposed default: ship the 3 buckets. More buckets = more chip-row real estate; the 3 cover the "recent / this year / dormant" dimensions Stefan asked for in the epic.
- Submit-for-moderation note pre-fill on re-submit after reject — pre-fill or empty? Proposed default: pre-fill with the prior note. Small kindness for the typo-fix-and-resubmit path. Stefan flips if he wants empty-by-default.
CatalogueSubmission.notesschema gap — Mira flagged that the model file lacks thenotescolumn the admin moderation spec renders. Proposed default: file a sibling implementation issue (or include in #147's implementation MR) for Pax to add the column. Out of design-spec scope; flagged here for visibility.- Detail page "Order history" stringer-scoped vs. global? Proposed default: stringer-scoped — only the current stringer's orders. Matches the M9 picker's last-strung shape; respects tenant isolation. A
catalogue_sharedracket's "global stringings" view (across all stringers) would leak cross-tenant order metadata. Stefan to flag if he disagrees. + Newbutton placement — top-right header (current proposal) or bottom-of-list? Proposed default: top-right header. Mirrors the client list § header+ Newbutton. Stefan to flag if he wants the bottom-of-list pattern (which is more thumb-friendly onsmbut pushes the affordance below the fold).- Visibility chip colours — slate / indigo / amber for private / shared / one-off? Proposed default: that triplet. Slate = neutral / private; indigo = primary / shared (matches the platform primary); amber = warning-ish / transient (one-off is rare and order-attached). Stefan flips if he wants different semantics.
- Detail-page "Origin" panel — show the
created_by_stringer_id == self ? 'you' : '(another stringer)'line, or omit cross-tenant rows entirely? Proposed default: show "(another stringer)". Tells the user "this isn't yours to edit" without revealing identity. Removing the row entirely would leave the user wondering why Edit isn't there.
Cross-references¶
- Source requirement: V2 stringer catalogue browse epic — racket-book#147, V2 scope M6 + M7 + M17, use-cases UC-7.
- Schema:
app/db/models/catalogue.py(Racket,String,CatalogueSubmission), data-model § Racket / String / Catalogue submission. - Sibling V2 schema-hook: racket-book#145 —
Racket.source+String.sourcecolumns. - V3 epic: racket-book#146 — auto-update from external feeds.
- Sibling V2 implementation: racket-book#144 — M9 catalogue picker backend endpoints (Pax-A's
/orders/new/_new-racket+_new-string; this spec reuses those endpoints from the list-page+ New). - Linked design surfaces: admin-catalogue-moderation (the M17 admin side this surface feeds), add-stringjob (the M9 picker that reads the same catalogue), client-management-v2 (per-client racket history; complementary projection).
- V3 hooks not surfaced in V2:
Racket.source/String.sourcefilter (#145), auto-update + imported badge (#146), bulk operations (V3), stringer-side decided-submissions full view (design § Future rounds). - Privacy + tenancy: client-identity-and-sharing § privacy invariant, ADR-0004, auth-and-tenancy § chokepoint catalogue predicate.
- Notifications: ADR-0005 § notification kinds —
catalogue_submission_pending,catalogue_submission_decided. - i18n strategy: i18n architecture.
- Issue tracking: racket-book#147 (closes on merge).