Score Dashboard
Summary
Score review and analysis tab within the event detail page. Sortable tables, filtering, CSV export, and void actions for event scores. Scores are submitted from mobile/PWA — the dashboard is read + manage only.
Shared contracts
This spec consumes the following from dashboard-shared:
- Data table: score table with filters and pagination
- Status badges: voided scores (
line-through text-red-500) - Confirm dialog: void score
- Role-gating: admin/instructor can void; all org members can view event scores; admin/instructor/RO can export CSV
- Route ownership: Scores tab within
/events/:id(no own routes)
Requirements
- Score table with shooter name, drill, zones, time, points, hit factor, and date
- Filter scores by shooter, drill, and voided status
- Void scores with confirmation dialog (admin/instructor only)
- CSV export of score data
- Pagination via cursor-based “Load More”
Context
The scores tab lives inside EventDetail (owned by event-builder). It receives event data via signals from the parent component but fetches its own score data via ListScores(event_id).
Important: Scores are not created from the dashboard. They are submitted by shooters via mobile apps or PWA. The dashboard provides view, filter, export, and void capabilities for organizers.
API-to-UI mapping
ScoreService RPCs used
| UI action | RPC | Key request fields | Notes |
|---|---|---|---|
| List event scores | ListScores | event_id, shooter_id (filter), drill_id (filter), final_only, pagination | Cursor-based, timestamp DESC |
| Void score | VoidScore | score_id | Admin/instructor only. Confirmation required. |
| Get single score | GetScore | score_id | For detail expansion (optional) |
Score fields
| Field | Type | Source | Editable? |
|---|---|---|---|
| id | UUID | Server | No |
| shooter display_name | string | Resolved from shooter_id | No |
| drill_name | string | Denormalized in score | No |
| drill_type | DrillType | Denormalized | No |
| calculation | CalculationMethod | Denormalized | No |
| zone_breakdown | [{zone_name, hits}] | Submitted | No |
| time | double? | Submitted | No |
| points | int32? | Server-computed | No |
| hit_factor | double? | Server-computed | No |
| penalties | int32? | Submitted | No |
| pass | bool? | Server-computed (PassFail) | No |
| voided | bool | Server | Via VoidScore |
| notes | string? | Submitted | No |
| timestamp | Timestamp | Submitted (when shot) | No |
| created_at | Timestamp | Server | No |
All score fields are read-only from the dashboard. The only mutation is voiding.
Filtering
| Filter | Input type | API field | Notes |
|---|---|---|---|
| Shooter | Dropdown (from roster) | shooter_id | ”All Shooters” default |
| Drill | Dropdown (from event drills) | drill_id | ”All Drills” default |
| Show voided | Checkbox | final_only | Unchecked = final_only: true (hide voided). Checked = final_only: false (show all). |
Filters are dropdowns populated from the event’s roster and drill assignments (already loaded by parent). No server round-trip to populate filter options.
ListScores authorization
Per server rules:
- Org members can view all event scores (event_id scoped)
- Non-members get PermissionDenied
- Dashboard users are always org members (enforced by session)
Component
ScoresTab
Rendered inside EventDetail when the Scores tab is active.
Props (received from parent):
event: Signal<Option<EventDto>>— event metadatadrills: Signal<Vec<EventDrillDto>>— assigned drills (for filter dropdown)roster: Signal<Vec<RosterDto>>— roster entries (for shooter filter dropdown)
Layout:
- Header: “Scores” + score count + “Export CSV” button (admin/instructor/RO)
- Filter row: Shooter dropdown, Drill dropdown, “Show voided” checkbox
- Score table
- “Load More” button (cursor pagination)
Score table columns:
| Column | Content | Sort? |
|---|---|---|
| Shooter | Display name (resolved) | No (server doesn’t support) |
| Drill | drill_name | No |
| Type | Calculation method badge | No |
| Zones | Abbreviated zone breakdown (e.g., “A:5 C:3 D:1”) | No |
| Time | Formatted seconds (e.g., “4.32s”) or ”-“ | No |
| Points | Integer or ”-“ | No |
| Hit Factor | 2 decimal places (e.g., “6.48”) or ”-“ | No |
| Pass | Checkmark / X / ”-” (PassFail only) | No |
| Date | Formatted timestamp | No |
| Actions | Void button | No |
Row styling:
- Voided scores: entire row uses
line-through text-red-500 bg-red-50(per shared badge contract) - Voided scores show “(Voided)” label in place of Void button
Shooter name resolution: Uses enriched roster data from GetEvent (see dashboard-shared server API gaps — roster entries must include display_name). The roster signal (shared from parent) provides the user_id → display_name mapping. Scores also contain drill_name denormalized, so no drill lookup is needed. ListOrgMembers is NOT used here (restricted to admin/instructor, but all org members can view scores).
Void flow:
- Click “Void” button on score row (admin/instructor only)
- Confirm dialog: “Void this score? This cannot be undone.”
VoidScore(score_id)called- On success: update score in local state (set voided=true), show toast
- On error: show error alert
- Voided scores remain visible with strikethrough styling (unless “Show voided” is unchecked)
CSV export:
- Click “Export CSV” button
- Client-side: iterate all loaded scores, generate CSV string
- Trigger browser download as
{event_name}_scores_{date}.csv - CSV columns: Shooter, Drill, Type, Zones, Time, Points, Hit Factor, Pass, Penalties, Notes, Date, Voided
- Note: exports only currently loaded scores. If pagination has more, show info: “Showing {loaded} of {total} scores. Load all before exporting.”
Empty state: “No scores recorded yet. Scores will appear here as shooters submit them from the mobile app.”
Live scores (deferred): WatchScores RPC exists but returns Unimplemented on server. Live streaming is deferred to a future item. Note in UI is not needed — scores refresh on tab focus or manual refresh.
New API module
apps/dashboard/src/api/score.rs:
DTOs:
ScoreDto— all score fields (see field table above)ZoneCountDto— zone_name, hitsListScoresRequest— event_id, shooter_id, drill_id, final_only, paginationListScoresResponse— scores, paginationVoidScoreRequest— score_id
Methods:
list_scores(token, req),void_score(token, score_id)
Test plan
- List scores: Navigate to event with scores → scores tab shows table with all score data
- Filter by shooter: Select shooter from dropdown → table shows only that shooter’s scores
- Filter by drill: Select drill → table shows only scores for that drill
- Combined filters: Shooter + drill → intersection shown
- Show/hide voided: Check “Show voided” → voided scores appear with strikethrough. Uncheck → voided hidden.
- Void score: Click Void → confirm → score shows strikethrough + “(Voided)” label
- CSV export: Click export → CSV file downloads with correct data
- Authorization: Member cannot see Void button. RO can view and export but not void.
- Empty state: New event with no scores → shows empty message
- Pagination: Event with many scores → “Load More” fetches next page
Acceptance criteria
- Scores tab within event detail shows all event scores in a table with shooter name, drill, zones, time, points, hit factor, and date.
- Shooter and drill dropdown filters narrow the displayed scores; dropdowns are populated from event roster and drill assignments.
- “Show voided” checkbox toggles voided score visibility; voided scores display with strikethrough styling.
- Void button (admin/instructor only) triggers confirmation dialog; voiding updates the score row immediately without full reload.
- CSV export downloads all loaded scores as a CSV file with all columns.
- Shooter names are resolved from enriched roster data in
GetEventresponse (see dashboard-shared server API gaps). - Pagination via “Load More” fetches additional score pages.
- Empty state shows helpful message when no scores exist.
- RO users can view and export but cannot void. Members can view only. Server enforces authorization as fallback.
- End-to-end: Create event (event-builder) → add drills → add roster (roster-builder) → scores submitted via mobile → scores tab shows submitted scores → admin voids one → voided score shows strikethrough → CSV export includes voided column.