feat: standings, schedules, seasonal leagues, team-stat bars; rename to sportsball
ca9cb80b93dcff3e9684532eb62310344d14f2c3
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-17 20:25
parent 92d4162d
feat: standings, schedules, seasonal leagues, team-stat bars; rename to sportsball Major feature work since the initial scaffold, plus public-release prep. Features: - League standings view (per-group ranked tables) + full team schedules, both via ESPN endpoints; reachable with s and g/G. - User-configurable leagues: show/hide/reorder with persistence, and seasonal auto-hide (off-season leagues drop out, return when in range) with per-league force-show/force-hide overrides. - Soccer team-stat comparison bars and a state filter (live/recent/upcoming). - Grid-aware dashboard cursor navigation. - Added WNBA. Fixes: - Standings sort by ESPN rank stat (non-MLB came unsorted). - Contrast-aware team colors (white teams visible on light themes). - Favorites box jitter; date/league grouping in filter views. Distribution + public prep: - Makefile, .goreleaser.yaml, GitHub Actions, LICENSE, README. - Renamed project to sportsball (config dir ~/.config/sportsball). - Scrubbed private local paths; gitignored CLAUDE.md and dev-only files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
70 files changed
.github/workflows/ci.yml +25 −0
@@ -0,0 +1,25 @@
+name: ci
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.26"
+
+ - name: Vet
+ run: go vet ./...
+
+ - name: Test
+ run: go test ./...
.github/workflows/release.yml +34 −0
@@ -0,0 +1,34 @@
+name: release
+
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+jobs:
+ goreleaser:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.26"
+
+ - name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v6
+ with:
+ version: "~> v2"
+ args: release --clean
+ env:
+ # Built-in token: creates the GitHub Release on this repo.
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # PAT with write access to humdrum-tiv/homebrew-tap: pushes the formula.
+ HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
.gitignore +9 −1
@@ -1,5 +1,13 @@
-# Built binary
+# Built binaries
/pts
+/sportsball
+# GoReleaser output
+/dist/
# Go workspace / test artifacts
*.out
*.test
+
+# Local dev / agent files — kept on disk, never published
+CLAUDE.md
+.claude/
+_private/
.goreleaser.yaml +65 −0
@@ -0,0 +1,65 @@
+# GoReleaser config — cross-compiles sportsball and publishes a Homebrew formula.
+# Docs: https://goreleaser.com • run locally with `goreleaser release --snapshot --clean`
+version: 2
+
+project_name: sportsball
+
+before:
+ hooks:
+ - go mod tidy
+
+builds:
+ - id: sportsball
+ main: .
+ binary: sportsball
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - darwin
+ - linux
+ goarch:
+ - amd64
+ - arm64
+ ldflags:
+ - -s -w -X main.version={{ .Version }}
+
+archives:
+ - id: sportsball
+ formats:
+ - tar.gz
+ name_template: >-
+ {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
+ files:
+ - README.md
+ - LICENSE
+
+checksum:
+ name_template: checksums.txt
+
+snapshot:
+ version_template: "{{ incpatch .Version }}-dev"
+
+changelog:
+ sort: asc
+ filters:
+ exclude:
+ - "^docs:"
+ - "^test:"
+ - "^chore:"
+
+# Generates Formula/sportsball.rb and pushes it to the tap repo on release.
+brews:
+ - name: sportsball
+ repository:
+ owner: humdrum-tiv
+ name: homebrew-tap
+ branch: main
+ token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
+ directory: Formula
+ homepage: "https://github.com/humdrum-tiv/sportsball"
+ description: "Terminal dashboard for live sports — World Cup, MLB, NBA, NHL, NFL"
+ license: "MIT"
+ install: |
+ bin.install "sportsball"
+ test: |
+ system "#{bin}/sportsball", "--version"
CLAUDE.md +0 −818
@@ -1,833 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## What this is
-
-`pts` is a terminal dashboard for live sports — Plain Text Sports meets Golazo, as a Bubble Tea TUI. It shows on-going / upcoming / past games across the World Cup, MLB, NBA, NHL, and NFL, lets you filter by league, and opens a single game into a live detail view. World Cup and MLB are the current in-season priorities (they lead `model.Leagues`).
-
-## Commands
-
-```bash
-go run . # run the TUI
-go build -o pts . && ./pts # build + run binary
-go test ./... # all tests (offline)
-go test ./internal/espn/ -run TestMapEvent -v # single test
-go vet ./... # vet
-go mod tidy # after changing imports
-```
-
-There is no network test in the suite by design — `internal/espn` tests run against in-memory fixtures so `go test` stays offline and deterministic. To eyeball real data, write a throwaway `*_test.go` **inside** `internal/espn` (internal packages can't be imported from outside the module) that calls `Client.Scoreboard`, then delete it.
-
-## Architecture
-
-Three layers, one direction of dependency: `ui → espn → model`. `model` imports nothing internal.
-
-- **`internal/model`** — league-agnostic domain types (`Game`, `Team`, `State`, `League`). `State` (Pre/Live/Final) is the normalized lifecycle every view branches on. `Leagues` is the canonical ordered slice; **its order is the display + cycle order** in the dashboard, and each entry carries the ESPN `Sport`/`Path` segments plus an accent color and glyph. Add a league here and it flows through fetch, grouping, and tabs automatically.
-
-- **`internal/espn`** — thin client over ESPN's public (unofficial) `site.api.espn.com` scoreboard API. No key required. `types.go` decodes only the handful of JSON fields we use (the real payload is huge); `client.go` maps that into `model.Game` and does the league-agnostic normalization: ESPN scores arrive as **strings**, status lives under `status.type.state` (`pre`/`in`/`post`), and games are sorted **live → upcoming → final**. One league per `Scoreboard` call.
-
-- **`internal/ui`** — the Bubble Tea front end. A single root `App` model (`app.go`) owns *all* game data (`games map[LeagueID][]Game`) and a `viewMode` that switches between the dashboard grid and the detail view. There are no child Bubble Tea models — view functions render off the one App. Files: `app.go` (model + Init/Update/View dispatch), `update.go` (key handling), `views.go` (dashboard), `detail.go` (single-game view), `components.go` (the game **card**), `theme.go` (Lipgloss palette + styles), `commands.go` (async msgs/cmds), `keys.go` (bindings).
-
-### Data + animation loop (the important part)
-
-The App runs **two independent tea.Tick loops**, started in `Init` and self-rescheduling in `Update`:
-
-1. **Poll loop** (`pollInterval`, 15s) → `pollMsg` → `fetchAll` fans out one `fetchLeague` command **per league concurrently** via `tea.Batch`. Each `fetchLeague` pulls a **date window** (`ScoreboardRange`) so Past finals and Upcoming fixtures are available, not just today's slate. The window is **per-league**, tuned to game cadence via `model.League.Window()` (MLB 3/5d since it plays daily; NFL 10/10d for its weekly cadence; soccer/default 5/10d) — keeps dense daily slates small while still surfacing weekly sports' last/next. `ScoreboardRange` sends `&limit=300`: **required**, since ESPN otherwise caps results (~50) and silently truncates wide dense windows. Each league resolves into its own `gamesMsg` as it lands, so the dashboard fills in progressively and one league failing (stored in `errs[league]`) never blocks the others.
-
- When the detail view is open, `pollMsg` also re-fetches that game's summary (`fetchDetail`) so it refreshes in step with the poll loop without a second ticker.
-
-2. **Animation loop** (`animInterval`, 80ms ≈ 12fps) → `animMsg` advances `App.phase`. `App.pulse()` is `(sin(phase)+1)/2` in [0,1] and drives the **breathing LIVE indicator** brightness everywhere (`liveDot`, header, badges). This is the app's signature flair — keep animation state in the App and read `pulse()` at render time; never block the Update loop.
-
-Network I/O **only** happens inside `tea.Cmd` closures (`commands.go`) — `Update` and `View` must stay pure and non-blocking, the Bubble Tea contract.
-
-### Sections & selection model
-
-`sections.go` is the single source of truth for what the dashboard shows. `App.sections()` returns titled `section` groups for the active filter: **all-leagues** → one section per league of *today's* games; **single league** → `Today` / `Upcoming` / `Past` buckets across the fetched window (`bucket()`). When the user has favorites, a **`★ Favorites` section is pinned on top**: all-leagues shows today's favorite games (pulled out of their league section, no dupes); single-league shows each favorite's live + next + last from the window (`favoriteHighlights()`). `App.visible()` is just the flattened concatenation of those sections.
-
-Selection is a single flat `cursor` int into `visible()`. `views.go` (`bodyLines`) re-walks `sections()` in the same order tracking a running global index so the highlighted card matches the cursor — **`sections()` and `bodyLines()` must iterate identically or selection desyncs.** `bodyLines` also reports the selected card's line so `scrollWindow` can keep it on screen: the dashboard renders a fixed header + tabs, a height-clamped scrolling body, and a fixed footer (this is what keeps the header from scrolling off).
-
-### Detail view
-
-`detailView` (`detail.go`) re-reads the freshest game by ID via `currentDetail()` every frame, so an open game updates as polls land. Soccer-aware status (match clock/stage). The **score box is fixed on top**; below it a height-clamped, scrollable body (Up/Down, offset `detailScroll`) shows the deeper summary data fetched via `Client.Summary` → `model.GameDetail` (`internal/espn/summary.go`): a soccer **key-event timeline** or per-team **box-score tables** for the stick-and-ball sports. Summary is keyed by event ID in `App.detailData`, fetched on open / manual `r` / each poll while open. Mark favorites here: `f` = away, `F` = home (★ on cards + score box).
-
-Presentation (Golazo-inspired, TASK-010): the scoreline is **oversized block digits** (`blockfont.go`, all sports), team-colored — live pops goal-orange, winner green, pre-game shows start time. Soccer events are **mirrored by team** (home-left / away-right around a center gutter, neutral events centered) so the scoring side reads at a glance. Section headers use `ruleHeader`/`teamRuleHeader` — a colored title + `////` rule + `▓▒░` fade. Keep the palette loud here: team colors, purple `colAccent2` group labels, goal-orange minutes.
-
-## Conventions & gotchas
-
-- **Colors come from data.** Team accent colors are ESPN hex strings (no `#`); run them through `teamColor()` (`theme.go`), which falls back to default text on empty/short values. League accent colors live on `model.League`.
-- **Lipgloss width, not `len`.** Card/row layout pads with `lipgloss.Width()` (handles wide glyphs/emoji and ANSI), never `len()`. Card width is the `cardWidth` const; the grid column count adapts to terminal width in `App.columns()` (capped 1–4).
-- **Adding a league:** append to `model.Leagues` with the right ESPN `Sport`/`Path` (e.g. `soccer`/`fifa.world`, `baseball`/`mlb`) and an accent color, glyph, and per-cadence fetch window (`WindowBack`/`WindowForward` days; 0 → defaults). Nothing else needs touching.
-- **Preferences persist** via `internal/config` (XDG `~/.config/pts/config.json`): favorites now, leagues/theme reserved. `config.Load()` swallows missing/corrupt → defaults; `config.Save()` is atomic. Favorites are keyed by `favKey` (league + ESPN team id, abbr fallback) in `App.favs`.
-- **Box scores / play-by-play** live in the detail view via the ESPN `summary` endpoint (`Client.Summary`). The JSON is huge; `summary.go` decodes only `keyEvents` + `boxscore.players` and the box-score `statistics[]` shape is uniform across MLB/NBA/NHL/NFL (`name`||`type` group label, `labels[]`, `athletes[].stats[]`).
-
-## Roadmap & known rough edges
-
-Tracked in **Backlog.md** (`backlog task list --plain`). Done: rich box-score/scorer detail (TASK-006), config/persistence layer (TASK-004), favorite teams (TASK-003), Golazo-inspired detail polish (TASK-010). Open headline items: distribution/packaging (TASK-001), user-selectable leagues (TASK-002), full theme system (TASK-005), more leagues incl. domestic soccer (TASK-008), favorite last/next beyond the fetch window via team-schedule endpoint (TASK-009).
-
-Open rough edges (TASK-007): pre-game cards still show ESPN's verbose `status.type.detail` overlapping the start time; Up/Down move the cursor linearly through the flat list rather than column-aware.
-
-## Theming
-
-`theme.go` is **Flexoki**, and the structural/text tokens (`colText`/`colMuted`/`colFaint`/`colBorder`) are `lipgloss.AdaptiveColor` — Flexoki *light* values on a light terminal, the original *dark* values on a dark one (lipgloss auto-detects the background). So don't reintroduce a single hardcoded grey for text — keep both ramps, or contrast breaks on whichever background isn't matched. Accents (`colLive`/`colAccent`/`colGoal`/`colWin`/`colWarn`) are saturated and read on both, so they stay plain `lipgloss.Color`. Color helpers (`teamColor`, `eventColor`, `blockScore` params) return/take `lipgloss.TerminalColor` so adaptive and concrete colors interoperate. The full theme system (TASK-005) will port six palettes from `/Users/kortum/Developer/Home/_shared-app-kit/tokens.css` (`flexoki(-dark)`, `uchu(-dark)`, `humdrum(-dark)`) into a swappable `Theme` struct.
-
-<!-- BACKLOG.MD GUIDELINES START -->
-# Instructions for the usage of Backlog.md CLI Tool
-
-## Backlog.md: Comprehensive Project Management Tool via CLI
-
-### Assistant Objective
-
-Efficiently manage all project tasks, status, and documentation using the Backlog.md CLI, ensuring all project metadata
-remains fully synchronized and up-to-date.
-
-### Core Capabilities
-
-- ✅ **Task Management**: Create, edit, assign, prioritize, and track tasks with full metadata
-- ✅ **Search**: Fuzzy search across tasks, documents, and decisions with `backlog search`
-- ✅ **Acceptance Criteria**: Granular control with add/remove/check/uncheck by index
-- ✅ **Definition of Done checklists**: Per-task DoD items with add/remove/check/uncheck
-- ✅ **Board Visualization**: Terminal-based Kanban board (`backlog board`) and web UI (`backlog browser`)
-- ✅ **Git Integration**: Automatic tracking of task states across branches
-- ✅ **Dependencies**: Task relationships and subtask hierarchies
-- ✅ **Documentation & Decisions**: Structured docs and architectural decision records
-- ✅ **Export & Reporting**: Generate markdown reports and board snapshots
-- ✅ **AI-Optimized**: `--plain` flag provides clean text output for AI processing
-
-### Why This Matters to You (AI Agent)
-
-1. **Comprehensive system** - Full project management capabilities through CLI
-2. **The CLI is the interface** - All operations go through `backlog` commands
-3. **Unified interaction model** - You can use CLI for both reading (`backlog task 1 --plain`) and writing (
- `backlog task edit 1`)
-4. **Metadata stays synchronized** - The CLI handles all the complex relationships
-
-### Key Understanding
-
-- **Tasks** live in `backlog/tasks/` as `task-<id> - <title>.md` files
-- **You interact via CLI only**: `backlog task create`, `backlog task edit`, etc.
-- **Use `--plain` flag** for AI-friendly output when viewing/listing
-- **Never bypass the CLI** - It handles Git, metadata, file naming, and relationships
-
----
-
-# ⚠️ CRITICAL: NEVER EDIT TASK FILES DIRECTLY. Edit Only via CLI
-
-**ALL task operations MUST use the Backlog.md CLI commands**
-
-- ✅ **DO**: Use `backlog task edit` and other CLI commands
-- ✅ **DO**: Use `backlog task create` to create new tasks
-- ✅ **DO**: Use `backlog task edit <id> --check-ac <index>` to mark acceptance criteria
-- ❌ **DON'T**: Edit markdown files directly
-- ❌ **DON'T**: Manually change checkboxes in files
-- ❌ **DON'T**: Add or modify text in task files without using CLI
-
-**Why?** Direct file editing breaks metadata synchronization, Git tracking, and task relationships.
-
----
-
-## 1. Source of Truth & File Structure
-
-### 📖 **UNDERSTANDING** (What you'll see when reading)
-
-- Markdown task files live under **`backlog/tasks/`** (drafts under **`backlog/drafts/`**)
-- Files are named: `task-<id> - <title>.md` (e.g., `task-42 - Add GraphQL resolver.md`)
-- Project documentation is in **`backlog/docs/`**
-- Project decisions are in **`backlog/decisions/`**
-
-### 🔧 **ACTING** (How to change things)
-
-- **All task operations MUST use the Backlog.md CLI tool**
-- This ensures metadata is correctly updated and the project stays in sync
-- **Always use `--plain` flag** when listing or viewing tasks for AI-friendly text output
-- Create and update project docs through Backlog.md APIs so frontmatter and paths stay valid. For CLI users, run `backlog doc create "Title" -p guides/setup` or `backlog doc update doc-1 --content "Updated markdown"`; MCP users should use `document_create` / `document_update`.
-- Document paths are relative to `backlog/docs/`; absolute paths and `..` traversal are rejected.
-
----
-
-## 2. Common Mistakes to Avoid
-
-### ❌ **WRONG: Direct File Editing**
-
-```markdown
-# DON'T DO THIS:
-
-1. Open backlog/tasks/task-7 - Feature.md in editor
-2. Change "- [ ]" to "- [x]" manually
-3. Add notes, comments, or final summary directly to the file
-4. Save the file
-```
-
-### ✅ **CORRECT: Using CLI Commands**
-
-```bash
-# DO THIS INSTEAD:
-backlog task edit 7 --check-ac 1 # Mark AC #1 as complete
-backlog task edit 7 --notes "Implementation complete" # Add notes
-backlog task edit 7 --comment "Review question" --comment-author @agent-k # Add comment
-backlog task edit 7 --final-summary "PR-style summary" # Add final summary
-backlog task edit 7 -s "In Progress" -a @agent-k # Multiple commands: change status and assign the task when you start working on the task
-```
-
----
-
-## 3. Understanding Task Format (Read-Only Reference)
-
-⚠️ **FORMAT REFERENCE ONLY** - The following sections show what you'll SEE in task files.
-**Never edit these directly! Use CLI commands to make changes.**
-
-### Task Structure You'll See
-
-```markdown
----
-id: task-42
-title: Add GraphQL resolver
-status: To Do
-assignee: [@sara]
-labels: [backend, api]
-modified_files:
- - src/server/api.ts
- - src/web/components/TaskList.tsx
----
-
-## Description
-
-Brief explanation of the task purpose.
-
-## Acceptance Criteria
-
-<!-- AC:BEGIN -->
-
-- [ ] #1 First criterion
-- [x] #2 Second criterion (completed)
-- [ ] #3 Third criterion
-
-<!-- AC:END -->
-
-## Definition of Done
-
-<!-- DOD:BEGIN -->
-
-- [ ] #1 Tests pass
-- [ ] #2 Docs updated
-
-<!-- DOD:END -->
-
-## Implementation Plan
-
-1. Research approach
-2. Implement solution
-
-## Implementation Notes
-
-Progress notes captured during implementation.
-
-## Comments
-
-Task discussion, review questions, and collaboration notes.
-
-## Final Summary
-
-PR-style summary of what was implemented.
-```
-
-### How to Modify Each Section
-
-| What You Want to Change | CLI Command to Use |
-|-------------------------|----------------------------------------------------------|
-| Title | `backlog task edit 42 -t "New Title"` |
-| Status | `backlog task edit 42 -s "In Progress"` |
-| Assignee | `backlog task edit 42 -a @sara` |
-| Labels | `backlog task edit 42 -l backend,api` |
-| Description | `backlog task edit 42 -d "New description"` |
-| Add AC | `backlog task edit 42 --ac "New criterion"` |
-| Add DoD | `backlog task edit 42 --dod "Ship notes"` |
-| Check AC #1 | `backlog task edit 42 --check-ac 1` |
-| Check DoD #1 | `backlog task edit 42 --check-dod 1` |
-| Uncheck AC #2 | `backlog task edit 42 --uncheck-ac 2` |
-| Uncheck DoD #2 | `backlog task edit 42 --uncheck-dod 2` |
-| Remove AC #3 | `backlog task edit 42 --remove-ac 3` |
-| Remove DoD #3 | `backlog task edit 42 --remove-dod 3` |
-| Add Plan | `backlog task edit 42 --plan "1. Step one\n2. Step two"` |
-| Add Notes (replace) | `backlog task edit 42 --notes "What I did"` |
-| Append Notes | `backlog task edit 42 --append-notes "Another note"` |
-| Add Comment | `backlog task edit 42 --comment "Review question" --comment-author @agent` |
-| Add Final Summary | `backlog task edit 42 --final-summary "PR-style summary"` |
-| Append Final Summary | `backlog task edit 42 --append-final-summary "Another detail"` |
-| Clear Final Summary | `backlog task edit 42 --clear-final-summary` |
-
----
-
-## 4. Defining Tasks
-
-### Creating New Tasks
-
-**Always use CLI to create tasks:**
-
-```bash
-# Example
-backlog task create "Task title" -d "Description" --ac "First criterion" --ac "Second criterion"
-```
-
-### Title (one liner)
-
-Use a clear brief title that summarizes the task.
-
-### Description (The "why")
-
-Provide a concise summary of the task purpose and its goal. Explains the context without implementation details.
-
-### Acceptance Criteria (The "what")
-
-**Understanding the Format:**
-
-- Acceptance criteria appear as numbered checkboxes in the markdown files
-- Format: `- [ ] #1 Criterion text` (unchecked) or `- [x] #1 Criterion text` (checked)
-
-**Managing Acceptance Criteria via CLI:**
-
-⚠️ **IMPORTANT: How AC Commands Work**
-
-- **Adding criteria (`--ac`)** accepts multiple flags: `--ac "First" --ac "Second"` ✅
-- **Checking/unchecking/removing** accept multiple flags too: `--check-ac 1 --check-ac 2` ✅
-- **Mixed operations** work in a single command: `--check-ac 1 --uncheck-ac 2 --remove-ac 3` ✅
-
-```bash
-# Examples
-
-# Add new criteria (MULTIPLE values allowed)
-backlog task edit 42 --ac "User can login" --ac "Session persists"
-
-# Check specific criteria by index (MULTIPLE values supported)
-backlog task edit 42 --check-ac 1 --check-ac 2 --check-ac 3 # Check multiple ACs
-# Or check them individually if you prefer:
-backlog task edit 42 --check-ac 1 # Mark #1 as complete
-backlog task edit 42 --check-ac 2 # Mark #2 as complete
-
-# Mixed operations in single command
-backlog task edit 42 --check-ac 1 --uncheck-ac 2 --remove-ac 3
-
-# ❌ STILL WRONG - These formats don't work:
-# backlog task edit 42 --check-ac 1,2,3 # No comma-separated values
-# backlog task edit 42 --check-ac 1-3 # No ranges
-# backlog task edit 42 --check 1 # Wrong flag name
-
-# Multiple operations of same type
-backlog task edit 42 --uncheck-ac 1 --uncheck-ac 2 # Uncheck multiple ACs
-backlog task edit 42 --remove-ac 2 --remove-ac 4 # Remove multiple ACs (processed high-to-low)
-```
-
-### Definition of Done checklist (per-task)
-
-Definition of Done items are a second checklist in each task. Defaults come from `definition_of_done` in the project config file (`backlog/config.yml`, `.backlog/config.yml`, or `backlog.config.yml`) or from Web UI Settings, and can be disabled per task.
-
-**Managing Definition of Done via CLI:**
-
-```bash
-# Add DoD items (MULTIPLE values allowed)
-backlog task edit 42 --dod "Run tests" --dod "Update docs"
-
-# Check/uncheck DoD items by index (MULTIPLE values supported)
-backlog task edit 42 --check-dod 1 --check-dod 2
-backlog task edit 42 --uncheck-dod 1
-
-# Remove DoD items by index
-backlog task edit 42 --remove-dod 2
-
-# Create without defaults
-backlog task create "Feature" --no-dod-defaults
-```
-
-**Key Principles for Good ACs:**
-
-- **Outcome-Oriented:** Focus on the result, not the method.
-- **Testable/Verifiable:** Each criterion should be objectively testable
-- **Clear and Concise:** Unambiguous language
-- **Complete:** Collectively cover the task scope
-- **User-Focused:** Frame from end-user or system behavior perspective
-
-Good Examples:
-
-- "User can successfully log in with valid credentials"
-- "System processes 1000 requests per second without errors"
-- "CLI preserves literal newlines in description/plan/notes/comments/final summary; `\\n` sequences are not auto-converted"
-
-Bad Example (Implementation Step):
-
-- "Add a new function handleLogin() in auth.ts"
-- "Define expected behavior and document supported input patterns"
-
-### Task Breakdown Strategy
-
-1. Identify foundational components first
-2. Create tasks in dependency order (foundations before features)
-3. Ensure each task delivers value independently
-4. Avoid creating tasks that block each other
-
-### Task Requirements
-
-- Tasks must be **atomic** and **testable** or **verifiable**
-- Each task should represent a single unit of work for one PR
-- **Never** reference future tasks (only tasks with id < current task id)
-- Ensure tasks are **independent** and don't depend on future work
-
----
-
-## 5. Implementing Tasks
-
-### 5.1. First step when implementing a task
-
-The very first things you must do when you take over a task are:
-
-* set the task in progress
-* assign it to yourself
-
-```bash
-# Example
-backlog task edit 42 -s "In Progress" -a @{myself}
-```
-
-### 5.2. Review Task References and Documentation
-
-Before planning, check if the task has any attached `references` or `documentation`:
-- **References**: Related code files, GitHub issues, or URLs relevant to the implementation
-- **Documentation**: Design docs, API specs, or other materials for understanding context
-
-These are visible in the task view output. Review them to understand the full context before drafting your plan.
-
-### 5.3. Create an Implementation Plan (The "how")
-
-Previously created tasks contain the why and the what. Once you are familiar with that part you should think about a
-plan on **HOW** to tackle the task and all its acceptance criteria. This is your **Implementation Plan**.
-First do a quick check to see if all the tools that you are planning to use are available in the environment you are
-working in.
-When you are ready, write it down in the task so that you can refer to it later.
-
-```bash
-# Example
-backlog task edit 42 --plan "1. Research codebase for references\n2Research on internet for similar cases\n3. Implement\n4. Test"
-```
-
-## 5.4. Implementation
-
-Once you have a plan, you can start implementing the task. This is where you write code, run tests, and make sure
-everything works as expected. Follow the acceptance criteria one by one and MARK THEM AS COMPLETE as soon as you
-finish them.
-
-### 5.5 Implementation Notes (Progress log)
-
-Use Implementation Notes to log progress, decisions, and blockers as you work.
-Append notes progressively during implementation using `--append-notes`:
-
-```
-backlog task edit 42 --append-notes "Investigated root cause" --append-notes "Added tests for edge case"
-```
-
-```bash
-# Example
-backlog task edit 42 --notes "Initial implementation done; pending integration tests"
-```
-
-### 5.6 Final Summary (PR description)
-
-When you are done implementing a task you need to prepare a PR description for it.
-Because you cannot create PRs directly, write the PR as a clean summary in the Final Summary field.
-
-**Quality bar:** Write it like a reviewer will see it. A one‑liner is rarely enough unless the change is truly trivial.
-Include the key scope so someone can understand the impact without reading the whole diff.
-
-```bash
-# Example
-backlog task edit 42 --final-summary "Implemented pattern X because Reason Y; updated files Z and W; added tests"
-```
-
-**IMPORTANT**: Do NOT include an Implementation Plan when creating a task. The plan is added only after you start the
-implementation.
-
-- Creation phase: provide Title, Description, Acceptance Criteria, and optionally labels/priority/assignee.
-- When you begin work, switch to edit, set the task in progress and assign to yourself
- `backlog task edit <id> -s "In Progress" -a "..."`.
-- Think about how you would solve the task and add the plan: `backlog task edit <id> --plan "..."`.
-- After updating the plan, share it with the user and ask for confirmation. Do not begin coding until the user approves the plan or explicitly tells you to skip the review.
-- Append Implementation Notes during implementation using `--append-notes` as progress is made.
-- Add Final Summary only after completing the work: `backlog task edit <id> --final-summary "..."` (replace) or append using `--append-final-summary`.
-
-## Phase discipline: What goes where
-
-- Creation: Title, Description, Acceptance Criteria, labels/priority/assignee.
-- Implementation: Implementation Plan (after moving to In Progress and assigning to yourself) + Implementation Notes (progress log, appended as you work).
-- Wrap-up: Final Summary (PR description), verify AC and Definition of Done checks.
-
-**IMPORTANT**: Only implement what's in the Acceptance Criteria. If you need to do more, either:
-
-1. Update the AC first: `backlog task edit 42 --ac "New requirement"`
-2. Or create a new follow up task: `backlog task create "Additional feature"`
-
----
-
-## 6. Typical Workflow
-
-```bash
-# 1. Identify work
-backlog task list -s "To Do" --plain
-
-# 2. Read task details
-backlog task 42 --plain
-
-# 3. Start work: assign yourself & change status
-backlog task edit 42 -s "In Progress" -a @myself
-
-# 4. Add implementation plan
-backlog task edit 42 --plan "1. Analyze\n2. Refactor\n3. Test"
-
-# 5. Share the plan with the user and wait for approval (do not write code yet)
-
-# 6. Work on the task (write code, test, etc.)
-
-# 7. Mark acceptance criteria as complete (supports multiple in one command)
-backlog task edit 42 --check-ac 1 --check-ac 2 --check-ac 3 # Check all at once
-# Or check them individually if preferred:
-# backlog task edit 42 --check-ac 1
-# backlog task edit 42 --check-ac 2
-# backlog task edit 42 --check-ac 3
-
-# 8. Add Final Summary (PR Description)
-backlog task edit 42 --final-summary "Refactored using strategy pattern, updated tests"
-
-# 9. Mark task as done
-backlog task edit 42 -s Done
-```
-
----
-
-## 7. Definition of Done (DoD)
-
-A task is **Done** only when **ALL** of the following are complete:
-
-### ✅ Via CLI Commands:
-
-1. **All acceptance criteria checked**: Use `backlog task edit <id> --check-ac <index>` for each
-2. **All Definition of Done items checked**: Use `backlog task edit <id> --check-dod <index>` for each
-3. **Final Summary added**: Use `backlog task edit <id> --final-summary "..."`
-4. **Status set to Done**: Use `backlog task edit <id> -s Done`
-
-### ✅ Via Code/Testing:
-
-5. **Tests pass**: Run test suite and linting
-6. **Documentation updated**: Update relevant docs if needed
-7. **Code reviewed**: Self-review your changes
-8. **No regressions**: Performance, security checks pass
-
-⚠️ **NEVER mark a task as Done without completing ALL items above**
-
----
-
-## 8. Finding Tasks and Content with Search
-
-When users ask you to find tasks related to a topic, use the `backlog search` command with `--plain` flag:
-
-```bash
-# Search for tasks about authentication
-backlog search "auth" --plain
-
-# Search only in tasks (not docs/decisions)
-backlog search "login" --type task --plain
-
-# Search with filters
-backlog search "api" --status "In Progress" --plain
-backlog search "bug" --priority high --plain
-
-# Find tasks that modified a project file path
-backlog search --modified-file src/server/api.ts --plain
-```
-
-**Key points:**
-- Uses fuzzy matching - finds "authentication" when searching "auth"
-- Searches task titles, descriptions, and content
-- Also searches `modified_files`; `--modified-file` applies a case-insensitive path substring filter
-- Also searches documents and decisions unless filtered with `--type task`
-- Always use `--plain` flag for AI-readable output
-
----
-
-## 9. Quick Reference: DO vs DON'T
-
-### Viewing and Finding Tasks
-
-| Task | ✅ DO | ❌ DON'T |
-|--------------|-----------------------------|---------------------------------|
-| View task | `backlog task 42 --plain` | Open and read .md file directly |
-| List tasks | `backlog task list --plain` | Browse backlog/tasks folder |
-| Check status | `backlog task 42 --plain` | Look at file content |
-| Find by topic| `backlog search "auth" --plain` | Manually grep through files |
-
-### Modifying Tasks
-
-| Task | ✅ DO | ❌ DON'T |
-|---------------|--------------------------------------|-----------------------------------|
-| Check AC | `backlog task edit 42 --check-ac 1` | Change `- [ ]` to `- [x]` in file |
-| Add notes | `backlog task edit 42 --notes "..."` | Type notes into .md file |
-| Add comment | `backlog task edit 42 --comment "..." --comment-author @agent` | Type comment into .md file |
-| Add final summary | `backlog task edit 42 --final-summary "..."` | Type summary into .md file |
-| Change status | `backlog task edit 42 -s Done` | Edit status in frontmatter |
-| Add AC | `backlog task edit 42 --ac "New"` | Add `- [ ] New` to file |
-
----
-
-## 10. Complete CLI Command Reference
-
-### Task Creation
-
-| Action | Command |
-|------------------|-------------------------------------------------------------------------------------|
-| Create task | `backlog task create "Title"` |
-| With description | `backlog task create "Title" -d "Description"` |
-| With AC | `backlog task create "Title" --ac "Criterion 1" --ac "Criterion 2"` |
-| With final summary | `backlog task create "Title" --final-summary "PR-style summary"` |
-| With references | `backlog task create "Title" --ref src/api.ts --ref https://github.com/issue/123` |
-| With documentation | `backlog task create "Title" --doc https://design-docs.example.com` |
-| With modified files | `backlog task create "Title" --modified-file src/api.ts --modified-file src/ui.ts` |
-| With all options | `backlog task create "Title" -d "Desc" -a @sara -s "To Do" -l auth --priority high --ref src/api.ts --doc docs/spec.md --modified-file src/api.ts` |
-| Create draft | `backlog task create "Title" --draft` |
-| Create subtask | `backlog task create "Title" -p 42` |
-
-### Task Modification
-
-| Action | Command |
-|------------------|---------------------------------------------|
-| Edit title | `backlog task edit 42 -t "New Title"` |
-| Edit description | `backlog task edit 42 -d "New description"` |
-| Change status | `backlog task edit 42 -s "In Progress"` |
-| Assign | `backlog task edit 42 -a @sara` |
-| Add labels | `backlog task edit 42 -l backend,api` |
-| Set priority | `backlog task edit 42 --priority high` |
-
-### Acceptance Criteria Management
-
-| Action | Command |
-|---------------------|-----------------------------------------------------------------------------|
-| Add AC | `backlog task edit 42 --ac "New criterion" --ac "Another"` |
-| Remove AC #2 | `backlog task edit 42 --remove-ac 2` |
-| Remove multiple ACs | `backlog task edit 42 --remove-ac 2 --remove-ac 4` |
-| Check AC #1 | `backlog task edit 42 --check-ac 1` |
-| Check multiple ACs | `backlog task edit 42 --check-ac 1 --check-ac 3` |
-| Uncheck AC #3 | `backlog task edit 42 --uncheck-ac 3` |
-| Mixed operations | `backlog task edit 42 --check-ac 1 --uncheck-ac 2 --remove-ac 3 --ac "New"` |
-
-### Task Content
-
-| Action | Command |
-|------------------|----------------------------------------------------------|
-| Add plan | `backlog task edit 42 --plan "1. Step one\n2. Step two"` |
-| Add notes | `backlog task edit 42 --notes "Implementation details"` |
-| Add comment | `backlog task edit 42 --comment "Review question" --comment-author @agent` |
-| Add final summary | `backlog task edit 42 --final-summary "PR-style summary"` |
-| Append final summary | `backlog task edit 42 --append-final-summary "More details"` |
-| Clear final summary | `backlog task edit 42 --clear-final-summary` |
-| Add dependencies | `backlog task edit 42 --dep task-1 --dep task-2` |
-| Add references | `backlog task edit 42 --ref src/api.ts --ref https://github.com/issue/123` |
-| Add documentation | `backlog task edit 42 --doc https://design-docs.example.com --doc docs/spec.md` |
-| Set modified files | `backlog task edit 42 --modified-file src/api.ts --modified-file src/ui.ts` |
-
-### Multi‑line Input (Description/Plan/Notes/Comments/Final Summary)
-
-The CLI preserves input literally — shells do not convert `\n` inside normal quotes. Use one of the following forms, listed in order of preference for AI agents:
-
-**1. Repeat `--append-*` for each line (works in every shell, including sandboxes that block other forms):**
-
-```bash
-backlog task edit 42 --notes "First line"
-backlog task edit 42 --append-notes "Second line"
-backlog task edit 42 --append-notes "Third line"
-```
-
-**2. Real newlines inside double quotes (single command — pass an actual line break inside the string):**
-
-```bash
-backlog task edit 42 --notes "First line
-Second line
-
-Final paragraph"
-```
-
-The same shape works for `--desc`, `--plan`, `--comment`, `--final-summary`, and the `--append-*` variants.
-
-**3. Shell-specific shorthand (interactive shells only — some AI agent sandboxes reject these):**
-
-- Bash/Zsh (ANSI‑C quoting):
-
- ```bash
- backlog task edit 42 --notes $'Line1\nLine2'
- ```
-
-- POSIX sh (command substitution + printf):
-
- ```bash
- backlog task edit 42 --notes "$(printf 'Line1\nLine2')"
- ```
-
-- PowerShell (backtick‑n):
-
- ```powershell
- backlog task edit 42 --notes "Line1`nLine2"
- ```
-
-Prefer forms **1** and **2** when running under Claude Code, Codex, or any agent harness that screens commands through a tree‑sitter AST walker — those harnesses reject ANSI‑C strings, command substitutions, and heredoc forms (see issue [#595](https://github.com/MrLesk/Backlog.md/issues/595)).
-
-Do not expect the literal sequence `\n` inside double quotes to become a newline. The CLI stores the backslash and `n` as written.
-
-### Implementation Notes Formatting
-
-- Keep implementation notes concise and time-ordered; focus on progress, decisions, and blockers.
-- Use short paragraphs or bullet lists instead of a single long line.
-- Use Markdown bullets (`-` for unordered, `1.` for ordered) for readability.
-- When using CLI flags like `--append-notes`, remember to include explicit
- newlines. Either repeat the flag once per line:
-
- ```bash
- backlog task edit 42 --append-notes "- Added new API endpoint" \
- --append-notes "- Updated tests" \
- --append-notes "- TODO: monitor staging deploy"
- ```
-
- Or pass real newlines inside the quoted argument:
-
- ```bash
- backlog task edit 42 --append-notes "- Added new API endpoint
- - Updated tests
- - TODO: monitor staging deploy"
- ```
-
-### Comments Formatting
-
-- Use comments for task discussion, review notes, questions, and handoff context that should remain visible to humans and agents.
-- Comments are append-only via `backlog task edit <id> --comment "..."`; include `--comment-author @name` when attribution is useful.
-- Comment bodies may contain Markdown, but standalone `---` lines are reserved as comment delimiters.
-- Do not use comments as the primary execution log; use Implementation Notes for progress and Final Summary for the PR description.
-
-### Final Summary Formatting
-
-- Treat the Final Summary as a PR description: lead with the outcome, then add key changes and tests.
-- Keep it clean and structured so it can be pasted directly into GitHub.
-- Prefer short paragraphs or bullet lists and avoid raw progress logs.
-- Aim to cover: **what changed**, **why**, **user impact**, **tests run**, and **risks/follow‑ups** when relevant.
-- Avoid single‑line summaries unless the change is truly tiny.
-
-**Example (good, not rigid):**
-```
-Added Final Summary support across CLI/MCP/Web/TUI to separate PR summaries from progress notes.
-
-Changes:
-- Added `finalSummary` to task types and markdown section parsing/serialization (ordered after notes).
-- CLI/MCP/Web/TUI now render and edit Final Summary; plain output includes it.
-
-Tests:
-- bun test src/test/final-summary.test.ts
-- bun test src/test/cli-final-summary.test.ts
-```
-
-### Task Images (Local Assets)
-
-Tasks may include images for screenshots, diagrams, or visual references. Local images are served automatically when using `backlog browser`.
-
-**Storage location:**
-- Place image files under the `assets/` folder inside your backlog directory (e.g., `backlog/assets/images/screenshot.png`)
-
-**Supported formats:**
-- png, jpg, jpeg, gif, svg, webp, avif (served with correct Content-Type)
-
-**Markdown syntax in tasks:**
-```markdown
-
-```
-
-**Workflow when adding images to tasks:**
-1. Move or copy the image file into the `assets/` folder inside your backlog directory (e.g., `backlog/assets/images/screenshot.png`)
-2. Then add or edit the task content via CLI, referencing the image using the `assets/<relative-path>` path
-
-**Key points:**
-- The path in Markdown starts with `assets/` and maps to the backlog directory's `assets/` folder; do **not** include the backlog directory name itself
-- When `backlog browser` is running, these files are automatically available at `assets/<relative-path>`
-- You can add images to descriptions, implementation notes, or final summaries using the standard CLI commands
-
-### Document Management
-
-> Docs are used for long-term project reference information, such as development standards, configuration guides, architecture documentation, etc. They differ from `tasks/` (specific tasks), `decisions/` (decision records), and `drafts/` (drafts).
-
-Use Backlog.md public interfaces for document creation and updates so IDs, frontmatter, paths, and search metadata stay consistent.
-
-#### CLI Usage
-
-The CLI supports creating, updating, listing, and viewing documents.
-
-```bash
-# Create a new doc (saved under backlog/docs/ by default)
-backlog doc create "API Guidelines"
-
-# Create in a subdirectory (nested paths supported)
-backlog doc create "Setup Guide" -p guides/setup
-
-# Specify type at creation time
-backlog doc create "Architecture" -t guide
-
-# Update content while preserving omitted metadata
-backlog doc update doc-1 --content "Updated markdown"
-
-# Update metadata or move a doc within backlog/docs/
-backlog doc update doc-1 --title "Setup Handbook" -t guide --tags setup,runbook -p guides
-
-# List all docs (searched globally across subdirectories)
-backlog doc list
-
-# View a specific doc
-backlog doc view doc-1
-```
-
-#### MCP / API Usage
-
-- Use `document_create` to create documents with title, content, optional type/tags, and optional docs-directory-relative path.
-- Use `document_update` to update document content, title, type, tags, or path while preserving document metadata.
-- Document responses include the persisted docs-relative file path so agents can reference the created file without scanning source internals.
-
-#### Key Rules
-
-- Document paths are relative to `backlog/docs/`; absolute paths and `..` traversal are rejected.
-- Supported document types are `readme`, `guide`, `specification`, and `other`.
-- Document IDs are global across the entire docs tree, including nested subfolders.
-- Prefer CLI, MCP, or Web document APIs over ad-hoc file writes so frontmatter and metadata remain valid.
-
-### Task Operations
-
-| Action | Command |
-|--------------------|----------------------------------------------|
-| View task | `backlog task 42 --plain` |
-| List tasks | `backlog task list --plain` |
-| Search tasks | `backlog search "topic" --plain` |
-| Search with filter | `backlog search "api" --status "To Do" --plain` |
-| Search by modified file | `backlog search --modified-file src/api.ts --plain` |
-| Filter by status | `backlog task list -s "In Progress" --plain` |
-| Filter by assignee | `backlog task list -a @sara --plain` |
-| Archive task | `backlog task archive 42` |
-| Demote to draft | `backlog task demote 42` |
-
----
-
-## Common Issues
-
-| Problem | Solution |
-|----------------------|--------------------------------------------------------------------|
-| Task not found | Check task ID with `backlog task list --plain` |
-| AC won't check | Use correct index: `backlog task 42 --plain` to see AC numbers |
-| Changes not saving | Ensure you're using CLI, not editing files |
-| Metadata out of sync | Re-edit via CLI to fix: `backlog task edit 42 -s <current-status>` |
-
----
-
-## Remember: The Golden Rule
-
-**🎯 If you want to change ANYTHING in a task, use the `backlog task edit` command.**
-**📖 Use CLI to read tasks, exceptionally READ task files directly, never WRITE to them.**
-
-Full help available: `backlog --help`
-
-<!-- BACKLOG.MD GUIDELINES END -->
LICENSE +21 −0
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 humdrum-tiv
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
Makefile +27 −0
@@ -0,0 +1,27 @@
+# sportsball — live sports TUI
+
+BIN := sportsball
+GOBIN := $(shell go env GOPATH)/bin
+VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
+LDFLAGS := -s -w -X main.version=$(VERSION)
+
+.PHONY: run build install test vet tidy
+
+run: ## run the TUI
+ go run .
+
+build: ## build ./sportsball in the repo
+ go build -ldflags "$(LDFLAGS)" -o sportsball .
+
+install: ## build + install as `$(BIN)` on PATH
+ go build -ldflags "$(LDFLAGS)" -o "$(GOBIN)/$(BIN)" .
+ @echo "installed $(GOBIN)/$(BIN) ($(VERSION))"
+
+test:
+ go test ./...
+
+vet:
+ go vet ./...
+
+tidy:
+ go mod tidy
README.md +126 −0
@@ -0,0 +1,126 @@
+# sportsball
+
+A terminal dashboard for live sports — Plain Text Sports meets [Golazo][golazo],
+as a [Bubble Tea][bubbletea] TUI. On-going, upcoming, and past games across the
+**World Cup, MLB, NBA, WNBA, NHL, and NFL** — filter by league and state, favorite
+teams, browse standings and full team schedules, and open any game into a live
+detail view with box scores, scoring plays, team-stat bars, and a breathing LIVE
+indicator.
+
+```
+ SPORTSBALL! live scores ● 3 live
+ ALL │ ⚽ WC │ ⚾ MLB │ 🏀 NBA │ 🏀 WNBA │ 🏒 NHL │ 🏈 NFL
+ All │ ● Live │ ◼ Recent │ ○ Upcoming
+ …
+```
+
+> _Screenshot / asciicast: TODO_
+
+## Features
+
+- **Live dashboard** — per-league cards (team-colored from ESPN data), live / upcoming / final states, a breathing LIVE pulse, and a state filter (live / recent / upcoming).
+- **Favorites** — star teams; favorite games pin to the top.
+- **Standings** — per-league tables (soccer groups, MLB divisions, NBA/NHL/NFL conferences), ranked.
+- **Team schedules** — any team's full season, past results + upcoming fixtures.
+- **Live detail** — box scores, scoring plays, soccer team-stat comparison bars, and a ticker of other games.
+- **Configurable leagues** — show/hide/reorder; off-season leagues auto-hide and return when in season.
+- **Six themes** — Flexoki / Uchu / Humdrum, light + dark.
+
+## Install
+
+**Homebrew** (macOS / Linux):
+
+```bash
+brew install humdrum-tiv/tap/sportsball
+```
+
+**Go** (1.26+):
+
+```bash
+go install github.com/humdrum-tiv/sportsball@latest
+```
+
+**From source:**
+
+```bash
+git clone https://github.com/humdrum-tiv/sportsball
+cd sportsball
+make install # builds + installs `sportsball` into $(go env GOPATH)/bin
+```
+
+## Usage
+
+```bash
+sportsball # launch the TUI
+sportsball --version # print version
+```
+
+### Keys
+
+**Dashboard**
+
+| Key | Action |
+| --- | --- |
+| `↑`/`↓` (`k`/`j`) | move selection by grid row |
+| `←`/`→` | move selection horizontally |
+| `enter` (`l`) | open the selected game |
+| `tab` / `⇧tab` | next / previous league |
+| `v` / `V` | cycle state filter forward / back (all · live · recent · upcoming) |
+| `f` / `F` | favorite the away / home team |
+| `g` / `G` | open the away / home team's schedule |
+| `s` | standings for the selected game's league |
+| `L` | league settings (show / hide / reorder) |
+| `r` | refresh now |
+| `t` | cycle theme (matches your terminal's light/dark) |
+| `q` (`ctrl+c`) | quit |
+
+**Detail view**
+
+| Key | Action |
+| --- | --- |
+| `tab` / `⇧tab` | move through the live ticker of other games |
+| `enter` | switch to the selected ticker game |
+| `↑`/`↓` | scroll the box score / events |
+| `f` / `F` | favorite away / home |
+| `g` / `G` | open the away / home team's schedule |
+| `esc` (`h`) | back to the dashboard |
+
+**Standings** (`s`)
+
+| Key | Action |
+| --- | --- |
+| `↑`/`↓` | move the team cursor |
+| `tab` / `⇧tab` | switch league |
+| `enter` | open the selected team's schedule |
+| `f` / `F` | favorite the selected team |
+| `esc` / `s` | back |
+
+**League settings** (`L`)
+
+| Key | Action |
+| --- | --- |
+| `↑`/`↓` | move |
+| `space` | show / hide the selected league |
+| `K`/`J` (`⇧↑`/`⇧↓`) | reorder |
+| `0` | reset to seasonal auto |
+| `esc` | done |
+
+## Data & config
+
+Scores come from ESPN's public (unofficial) scoreboard API — **no API key
+required**. Preferences (favorite teams, active theme, league show/hide/order)
+persist to `~/.config/sportsball/config.json` (XDG:
+`$XDG_CONFIG_HOME/sportsball/config.json`).
+
+## Themes
+
+Six palettes ported from the Flexoki, Uchu, and Humdrum token sets, each in a
+light and dark variant. `t` cycles only the variants matching your terminal's
+background, and the whole frame is painted in the theme's colors.
+
+## License
+
+[MIT](LICENSE)
+
+[golazo]: https://github.com/0xjuanma/golazo
+[bubbletea]: https://github.com/charmbracelet/bubbletea
backlog/config.yml +3 −3
@@ -1,6 +1,6 @@
-project_name: "PTS TUI"
-default_status: "To Do"
-statuses: ["To Do", "In Progress", "Done"]
+project_name: "Sportsball"
+default_status: "🟦 Backlog"
+statuses: ["🟦 Backlog", "🟢 In progress", "🚧 Paused", "🏁 Done"]
labels: []
date_format: yyyy-mm-dd
max_column_width: 20
- → Distribution-packaging.md +29 −3
@@ -1,10 +1,11 @@
---
id: TASK-001
title: Distribution & packaging
-status: To Do
-assignee: []
+status: "\U0001F7E2 In progress"
+assignee:
+ - '@humdrum-tiv'
created_date: '2026-06-16 18:03'
-updated_date: '2026-06-16 18:27'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
@@ -24,3 +25,28 @@ - [ ] #1 go install works
- [ ] #2 brew install from tap works
- [ ] #3 tagged release publishes binaries
<!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+Plan (Homebrew via GoReleaser + GH Actions auto-tap; owner humdrum-tiv; binary+formula 'sportsball'; module renamed to github.com/humdrum-tiv/sportsball).
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Prep done (in-repo, no GitHub actions yet):
+- Renamed module github.com/kortum/pts-tui -> github.com/humdrum-tiv/sportsball (16 files); suite green.
+- Added --version (main.go var version=dev + flag) — verified ldflags injection prints 'sportsball <ver>'.
+- Added LICENSE (MIT), user-facing README.md.
+- Makefile now version-stamps via git describe ldflags; .gitignore covers /sportsball and /dist.
+- .goreleaser.yaml (darwin/linux x amd64/arm64, archives, checksums, brews->humdrum-tiv/homebrew-tap).
+- .github/workflows/release.yml (tag v* -> goreleaser) + ci.yml (vet+test).
+- Validated: all 4 targets cross-compile (CGO off); YAML parses.
+
+Remaining (needs GitHub auth, not yet done):
+- Create public repos humdrum-tiv/sportsball + humdrum-tiv/homebrew-tap; set origin (replaces soft:pts-tui alias); push master.
+- Add HOMEBREW_TAP_TOKEN PAT secret (write to homebrew-tap) on the sportsball repo.
+- Tag v0.1.0 -> verify brew install humdrum-tiv/tap/sportsball, go install, brew test.
+- Optional: run 'goreleaser check' / 'goreleaser release --snapshot --clean' locally to validate before tagging.
+<!-- SECTION:NOTES:END -->
- → User-config-select-order-leagues.md +33 −5
@@ -1,9 +1,11 @@
---
id: TASK-002
title: 'User config: select & order leagues'
-status: To Do
-assignee: []
+status: "\U0001F3C1 Done"
+assignee:
+ - '@humdrum-tiv'
created_date: '2026-06-16 18:03'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
@@ -19,7 +21,33 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 user can enable/disable leagues
-- [ ] #2 league order is user-configurable
-- [ ] #3 choice persists across runs
+- [x] #1 user can enable/disable leagues
+- [x] #2 league order is user-configurable
+- [x] #3 choice persists across runs
<!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. config.Leagues = ordered ENABLED league IDs; empty = default (all, default order).
+2. App holds effective `leagues []model.League` built by resolveLeagues(cfg.Leagues); replace all model.Leagues reads in ui (sections/views/ticker/commands/update/app) with a.leagues + a.leagueIndex().
+3. fetchAll takes the league slice so only enabled leagues are polled.
+4. New viewSettings mode: list all leagues (enabled then hidden), space toggles, K/J (shift+up/down) reorder enabled, esc closes+persists+refetches. Open with 'L'.
+5. On close: drop filter if its league got disabled; clampCursor.
+6. Order drives live/recent/upcoming + tabs automatically (already keyed off league order).
+7. Tests: resolveLeagues, toggle, reorder, sort respects custom order.
+<!-- SECTION:PLAN:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Users can now select, hide, and reorder leagues; choice persists.
+
+What changed:
+- App holds an effective league set (App.leagues) built by resolveLeagues(config.Leagues). All view/fetch/sort paths (sections, tabs, ticker, fetchAll, leagueIndex) now iterate a.leagues instead of the hardcoded model.Leagues catalog.
+- config.Leagues persists the ordered ENABLED league IDs; empty/nil (or all-unknown) means default = all leagues in catalog order. Disabling drops a league from views (cached games retained); enabling appends to the end. Last-enabled league can't be disabled.
+- New settings screen (viewSettings / settings.go, opened with 'L'): lists enabled then hidden leagues; space toggles, K/J (shift+up/down) reorder the enabled group, esc closes -> persists, drops a now-disabled active filter, and refetches.
+- League order is the single source of truth for both tab order and the live/recent/upcoming sort, so reordering NBA above World Cup makes NBA lead those views whenever it has games for the date.
+
+Tests: resolveLeagues (default/subset+order/all-unknown fallback), setLeague toggle, moveLeague reorder+clamp, and a state-filter sort honoring custom league order. Existing all-leagues/ticker test fixtures updated to set leagues. go vet + go test ./... pass.
+<!-- SECTION:FINAL_SUMMARY:END -->
- → Favorite-teams.md +2 −2
@@ -1,11 +1,11 @@
---
id: TASK-003
title: Favorite teams
-status: Done
+status: "\U0001F3C1 Done"
assignee:
- '@humdrum-tiv'
created_date: '2026-06-16 18:03'
-updated_date: '2026-06-16 21:26'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
- → Config-persistence-layer.md +2 −2
@@ -1,11 +1,11 @@
---
id: TASK-004
title: Config & persistence layer
-status: Done
+status: "\U0001F3C1 Done"
assignee:
- '@humdrum-tiv'
created_date: '2026-06-16 18:03'
-updated_date: '2026-06-16 21:26'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
- → Theme-system-Flexoki-Humdrum-Uchu-lightdark.md +20 −5
@@ -1,9 +1,10 @@
---
id: TASK-005
title: 'Theme system (Flexoki/Humdrum/Uchu, light+dark)'
-status: To Do
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-16 18:03'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
@@ -14,12 +15,26 @@
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
-Replace hardcoded Flexoki-dark palette in theme.go with a Theme struct + registry. Port the six palettes from _shared-app-kit/tokens.css ([data-theme] sets: flexoki, flexoki-dark, uchu, uchu-dark, humdrum, humdrum-dark). Runtime switch + persist. Currently Flexoki-dark is hardcoded.
+Replace hardcoded Flexoki-dark palette in theme.go with a Theme struct + registry. Port the six palettes from a shared design-token set ([data-theme] sets: flexoki, flexoki-dark, uchu, uchu-dark, humdrum, humdrum-dark). Runtime switch + persist. Currently Flexoki-dark is hardcoded.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 all 3 themes in light + dark selectable at runtime
-- [ ] #2 palettes match app-kit tokens.css
-- [ ] #3 active theme persists
+- [x] #1 all 3 themes in light + dark selectable at runtime
+- [x] #2 palettes match app-kit tokens.css
+- [x] #3 active theme persists
<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Rewrote theme.go: Theme struct + 6-entry registry (flexoki(-dark), uchu(-dark), humdrum(-dark)) ported from the shared token set. uchu/humdrum oklch ramps converted to sRGB hex. Structural tokens concrete per-variant (dropped AdaptiveColor); 6 accents mapped by hue so sport semantics stay constant. applyTheme repoints col* globals + rebuilds style* via buildStyles(); init() applies default so test literals work. 't' key cycles + persists to config.Theme; New() loads persisted theme; active theme name shown in dashboard footer. liveDot pulses active live color. Added theme_test.go.
+
+Follow-up: View() now paints the theme bg across the whole frame (paintBackground) so light-on-dark-terminal mismatch is gone. Theme cycling ('t') restricted to palettes matching terminal appearance (themesFor/resolveTheme via lipgloss.HasDarkBackground); persisted theme honored only if it matches appearance. Tests added; binary reinstalled.
+<!-- SECTION:NOTES:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Replaces the hardcoded Flexoki-dark palette with a swappable Theme registry of all six app-kit palettes (flexoki, flexoki-dark, uchu, uchu-dark, humdrum, humdrum-dark), ported from a shared design-token set with the uchu/humdrum oklch ramps converted to sRGB hex. Each theme is an explicit light/dark variant with concrete structural tokens (no AdaptiveColor); the six accents are hue-mapped from each source ramp so red=live, green=win etc. hold across themes. applyTheme() swaps the col* globals and rebuilds all derived styles; an init() applies the default. Press 't' to cycle (persisted via config.Theme, restored on launch); the active theme name renders bottom-right of the dashboard footer. Tests cover palette validity, applyTheme repointing globals+styles, and themeIndex fallback. go vet/build + full suite green.
+<!-- SECTION:FINAL_SUMMARY:END -->
- → Rich-live-game-detail-box-score-scorers.md +2 −2
@@ -1,10 +1,10 @@
---
id: TASK-006
title: 'Rich live game detail (box score, scorers)'
-status: Done
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-16 18:03'
-updated_date: '2026-06-16 20:51'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
- → Dashboard-polish.md +20 −4
@@ -1,9 +1,11 @@
---
id: TASK-007
title: Dashboard polish
-status: To Do
-assignee: []
+status: "\U0001F3C1 Done"
+assignee:
+ - '@humdrum-tiv'
created_date: '2026-06-16 18:03'
+updated_date: '2026-06-18 01:17'
labels:
- bug
dependencies: []
@@ -19,6 +21,20 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 pre-game card status no longer overlaps
-- [ ] #2 Up/Down navigates grid by column/row
+- [x] #1 pre-game card status no longer overlaps
+- [x] #2 Up/Down navigates grid by column/row
<!-- AC:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Dashboard polish: grid-aware cursor navigation; confirmed pre-game cards no longer overlap.
+
+What:
+- AC#2: Up/Down now move by visual grid ROW (keeping column) instead of ±1 through the flat list. nav.go reconstructs the rendered grid (gridRows, mirroring bodyLines' per-section sub-grids of a.columns()) and moveCursorVert steps rows, clamping the column for short/last rows. Arrow ←/→ move horizontally; they precede the league-nav cases (which also bind the arrows) so on the dashboard arrows move the cursor while tab/shift+tab still switch leagues.
+- AC#1: pre-game card status already shows only '○ <start time>' (statusLine emits no detail pre-game), so there is no verbose ESPN text overlapping the time — verified, no change needed.
+
+Also (adjacent UX from this session): f/F now toggle favorites from the dashboard cursor and from standings rows; footer restored f/F and dropped 'a all' to de-crowd.
+
+Tests: TestMoveCursorVertical (2-wide grid row movement + column clamp). go vet + go test ./... pass.
+<!-- SECTION:FINAL_SUMMARY:END -->
- → Add-more-leagues.md +10 −2
@@ -1,9 +1,11 @@
---
id: TASK-008
title: Add more leagues
-status: To Do
-assignee: []
+status: "\U0001F7E2 In progress"
+assignee:
+ - '@humdrum-tiv'
created_date: '2026-06-16 21:06'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
@@ -23,3 +25,9 @@ - [ ] #1 all listed leagues appear in model.Leagues with correct ESPN paths
- [ ] #2 each has accent color + glyph
- [ ] #3 verified each path returns scoreboard data
<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+WNBA (basketball/wnba) added to model.Leagues — verified 22 games + 2 standings groups live. Remaining team-shaped roadmap: MLS, NWSL, Premier League, La Liga, Bundesliga, Serie A, Ligue 1, Champions League (soccer/uefa.champions), NCAA M basketball (basketball/mens-college-basketball), NCAA W basketball (basketball/womens-college-basketball), NCAA football (football/college-football).
+<!-- SECTION:NOTES:END -->
- → Favorite-last-next-beyond-fetch-window.md +8 −1
@@ -1,9 +1,10 @@
---
id: TASK-009
title: Favorite last/next beyond fetch window
-status: To Do
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-16 21:23'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
@@ -22,3 +23,9 @@ <!-- AC:BEGIN -->
- [ ] #1 favorite last result + next fixture resolve even when outside the league fetch window
- [ ] #2 per-team schedule fetched lazily and cached, not on every poll
<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Superseded by TASK-017 (Full team schedules) — same ESPN team-schedule endpoint covers favorite last/next beyond the fetch window.
+<!-- SECTION:NOTES:END -->
- → Golazo-inspired-detail-view-polish.md +2 −2
@@ -1,11 +1,11 @@
---
id: TASK-010
title: Golazo-inspired detail view polish
-status: Done
+status: "\U0001F3C1 Done"
assignee:
- '@humdrum-tiv'
created_date: '2026-06-16 21:43'
-updated_date: '2026-06-16 21:51'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
- → Team-stat-bars-in-detail-view.md +19 −4
@@ -1,9 +1,11 @@
---
id: TASK-011
title: Team stat bars in detail view
-status: To Do
-assignee: []
+status: "\U0001F3C1 Done"
+assignee:
+ - '@humdrum-tiv'
created_date: '2026-06-16 21:52'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
@@ -19,6 +21,19 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 soccer detail shows possession + shots bars from boxscore.teams stats
-- [ ] #2 bars use block shading and team colors
+- [x] #1 soccer detail shows possession + shots bars from boxscore.teams stats
+- [x] #2 bars use block shading and team colors
<!-- AC:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Added soccer team-stat comparison bars to the game detail view.
+
+What:
+- model.GameDetail gains TeamStats []model.TeamStat (Label/Key/Away/Home).
+- espn: summary boxscore now decodes the flat team stat list (boxscore.teams[].statistics). mapTeamStats pairs home vs away for a curated, ordered subset (possession, shots, on-target, corners, fouls, saves); returns nil unless both sides present (stick-and-ball sports nest team stats differently and are left to the existing per-player box score).
+- UI: teamStatsBlock renders each stat as a proportional split bar (away|home) in team colors with block shading (▓ / ░ fallback when 0-0), value on each side, label centered above. Shown above the soccer Key Events timeline.
+
+Tests: mapTeamStats (curated/ordered pairing + present-keys-only) and both-sides-required guard; live-smoke verified 6 stats map for a real World Cup game. go vet + go test ./... pass.
+<!-- SECTION:FINAL_SUMMARY:END -->
- → Rich-meta-table-in-detail-view.md +2 −1
@@ -1,9 +1,10 @@
---
id: TASK-012
title: Rich meta table in detail view
-status: To Do
+status: "\U0001F7E6 Backlog"
assignee: []
created_date: '2026-06-16 21:52'
+updated_date: '2026-06-18 01:17'
labels:
- feature
dependencies: []
- → Live-ticker-panel-in-detail-view.md +41 −0
@@ -0,0 +1,41 @@
+---
+id: TASK-013
+title: Live ticker panel in detail view
+status: "\U0001F3C1 Done"
+assignee:
+ - '@humdrum-tiv'
+created_date: '2026-06-16 23:50'
+updated_date: '2026-06-18 01:17'
+labels:
+ - feature
+dependencies: []
+priority: high
+ordinal: 13000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+While viewing a live game, show a Golazo-style strip of selectable game boxes at the top (above the watched game's score box): other live games (same league first, then other leagues) plus today's finals. User can tab/arrow between boxes and press enter to switch the detail view to that game. Data comes from already-polled a.games — no new fetch loop.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 Detail view renders a ticker strip of compact game boxes above the watched game's score box
+- [x] #2 Ticker shows live games (same league first, then others) then today's finals, excluding the watched game
+- [x] #3 tab/right and shift+tab/left move ticker selection; enter switches the detail view to the selected game
+- [x] #4 Up/Down still scroll the detail body; selected ticker box is visually highlighted
+- [x] #5 Strip windows horizontally to keep selection on screen with off-screen affordances; hidden when no other games
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Added internal/ui/ticker.go (tickerGames ordering, tickerStrip horizontal windowing, tickerCell mini-box). Wired into detail.go via detailBoxBlock (stacks ticker over score box; shared by detailView + detailScrollMax so body height stays correct). Nav in update.go: NextLg/PrevLg move tickerCursor, Enter routes through shared openDetail to switch games. Added App.tickerCursor. Tests in ticker_test.go cover ordering/exclusion, window clamping, and a render smoke test.
+<!-- SECTION:NOTES:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Adds a Golazo-style live ticker to the detail view: a strip of selectable game boxes above the watched game's score box showing other live games (same league first, then others) then today's finals, sourced from already-polled a.games (no new fetch). tab/→ and ⇧tab/← move the selection, enter switches the detail view to that game; ↑/↓ still scroll the body. Strip windows horizontally with ‹ › off-screen affordances and hides when nothing else is on. New ticker.go + App.tickerCursor + shared openDetail helper; detailScrollMax/detailBodyAvail account for the strip's height. Tests + go vet/build pass.
+<!-- SECTION:FINAL_SUMMARY:END -->
- → Baseball-detail-scoring-plays-live-situation.md +40 −0
@@ -0,0 +1,40 @@
+---
+id: TASK-014
+title: 'Baseball detail: scoring plays + live situation'
+status: "\U0001F3C1 Done"
+assignee:
+ - '@humdrum-tiv'
+created_date: '2026-06-17 00:23'
+updated_date: '2026-06-18 01:17'
+labels:
+ - feature
+dependencies: []
+priority: high
+ordinal: 14000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+MLB detail view: (1) a scoring-plays timeline above the box score, like soccer key events. (2) live situation in the score box — outs, baserunners (bases diamond), current pitcher and current hitter. Situation comes from the already-polled scoreboard endpoint (competitions[].situation: balls/strikes/outs, onFirst/Second/Third, pitcher/batter athlete + summary). Scoring plays come from the summary endpoint plays[] filtered to scoringPlay.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 model.Game carries a Situation (outs, bases, balls/strikes, pitcher, batter) mapped from scoreboard situation
+- [x] #2 Detail score box shows outs, bases diamond, current pitcher and hitter for live baseball games
+- [x] #3 Baseball detail shows a scoring-plays timeline above the box score, team-attributed
+- [x] #4 Non-baseball sports and non-live games are unaffected
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Added model.Situation + Game.Situation (mapped from scoreboard competitions[].situation in mapSituation; only when live + pitcher/batter resolved). Added MatchEvent.TeamID; eventSide/eventColor match by id first. summary.go maps plays[] scoringPlay -> Events via mapScoringPlay (▲/▼ inning clock, HR icon 💣, generic run ⚾). detail.go: situationBlock (count/outs pips/bases diamond/pitcher+batter) in score box; eventsBlock parameterized title, baseball renders 'Scoring Plays' above box score. Fixed bigStatus duplicate 'Top 7th Top 7th'. Verified against live PHI-MIA game; offline fixture tests added.
+<!-- SECTION:NOTES:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Baseball detail view gains two live features (TASK-014). (1) A situation panel in the score box for live games: balls-strikes count, outs as filled pips, a bases diamond (occupied bases popped goal-orange), and the current pitcher/batter with ESPN stat lines — sourced from model.Game.Situation, mapped from the already-polled scoreboard competitions[].situation (no new fetch). (2) A Scoring Plays timeline above the box score, fed by the summary endpoint's plays[] filtered to scoringPlay, reusing soccer's mirrored eventsBlock (home runs get 💣, other run-scoring plays ⚾). MatchEvent.TeamID added since baseball plays carry only a team id; eventSide/eventColor match by id then name. Also fixed a duplicated live-status string for baseball. Other sports/non-live games unaffected. Offline fixture tests for mapSituation, mapScoringPlay, situationBlock, and section ordering; go vet/build + full suite green.
+<!-- SECTION:FINAL_SUMMARY:END -->
- → Dashboard-state-filter-live-recent-upcoming.md +40 −0
@@ -0,0 +1,40 @@
+---
+id: TASK-015
+title: 'Dashboard state filter: live / recent / upcoming'
+status: "\U0001F3C1 Done"
+assignee:
+ - '@humdrum-tiv'
+created_date: '2026-06-17 03:17'
+updated_date: '2026-06-18 01:17'
+labels:
+ - feature
+dependencies: []
+priority: high
+ordinal: 15000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+On the dashboard (esp. all-leagues), let users filter the slate by game state so they don't scroll: All / Live / Recent (finals) / Upcoming. A key cycles the filter; a chip row shows the active one. Applies across leagues in all-leagues view and selects which buckets show in single-league view.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 A state filter (All/Live/Recent/Upcoming) is cyclable via a key and shown as a chip row under the league tabs
+- [x] #2 All-leagues view honors the state filter across every league (live=in-progress, recent=finals, upcoming=scheduled)
+- [x] #3 Single-league view shows only the buckets matching the filter
+- [x] #4 Cursor/selection stays valid when the filter changes (clamped)
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Added gameFilter enum (All/Live/Recent/Upcoming) + App.stateFilter; 's' cycles, resets cursor. sections() delegates to filteredSections/leagueSlate when active: pulls games of that state from full window (live keeps client order, recent most-recent-first, upcoming chronological), no favorites pin. stateTabs() chip row under league tabs; dashboard chrome 5->6; footer 's filter' hint. Also fixed bases diamond (equal-width rows so 2nd centers over the gap).
+<!-- SECTION:NOTES:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Adds a dashboard state filter so users can see just live games, recent finals, or upcoming fixtures without scrolling (TASK-015). New gameFilter enum + App.stateFilter cycled with 's', shown as a highlighted chip row (stateTabs) beneath the league tabs. When active, sections() routes through filteredSections/leagueSlate: games of the chosen state from the full fetched window, ordered per filter, with the favorites pin dropped so the view is purely that state; all-leagues keeps per-league grouping, single-league collapses to one labeled section. Cursor resets/clamps on change. Bundled a fix for the bases diamond (rows were 5 vs 4 wide, skewing 2nd base off-center — now equal width). Offline tests for live/recent/upcoming behavior + diamond width; go vet/build + full suite green.
+<!-- SECTION:FINAL_SUMMARY:END -->
- → League-standings-view.md +49 −0
@@ -0,0 +1,49 @@
+---
+id: TASK-016
+title: League standings view
+status: "\U0001F3C1 Done"
+assignee:
+ - '@humdrum-tiv'
+created_date: '2026-06-17 17:43'
+updated_date: '2026-06-18 01:17'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 16000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Show league tables/standings (W-L, GP, pts, GB/GD, rank) for the selected league, fetched from ESPN's standings endpoint. A new view reachable from the dashboard; respects the user's enabled-league set. Soccer = group/table standings; stick-and-ball = division/conference records.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 User can open a standings view for a single league
+- [x] #2 Standings show rank, record, and league-appropriate columns (pts/GB/GD)
+- [x] #3 View uses the ESPN standings endpoint and the existing model/espn/ui layering
+- [x] #4 Only enabled leagues (App.leagues) are selectable
+<!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. model.Standings{League,Groups[]} -> StandingsGroup{Name,Columns[],Rows[]} -> StandingsRow{Rank,Team,Values[]}.
+2. espn.Client.Standings(ctx,l): GET apis/v2 base (not apis/site/v2); recursive walk of children -> emit a group per node that has entries (soccer=groups, MLB=AL/NL, NBA/NHL/NFL=conferences). Per-sport column spec (soccer P/W/D/L/GD/Pts; baseball+nba PCT/GB; nfl T; nhl OTL/PTS). Stats keyed by type->displayValue. Rank = entry index.
+3. UI viewStandings: open with key from dashboard for selected league (filter=All -> first enabled; tab/arrows cycle leagues). Own cursor over flattened team rows; enter opens that team's schedule (TASK-017). esc closes. Grouped, ranked, team-colored tables; scroll-clamped body like detail.
+<!-- SECTION:PLAN:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Added a standings view (viewStandings, opened with 'S').
+
+What:
+- model.Standings (Groups -> StandingsGroup{Name,Columns,Rows}) with RowCount/RowAt flattening helpers for cursor navigation.
+- espn.Client.Standings fetches from ESPN's standings host (apis/v2/sports, NOT the scoreboard's apis/site/v2). mapStandings recursively walks ESPN's nested league/conference tree, emitting one group per table that has entries (soccer=12 groups, MLB=AL/NL, NBA/NHL/NFL=conferences). Per-sport column spec (soccer P/W/D/L/GD/Pts; baseball/nba W/L/PCT/GB; nfl adds T; nhl OTL/PTS); stats pulled by ESPN type. Rank = entry order.
+- UI: standings.go renders grouped, ranked, team-colored tables with a movable team-row cursor and scroll-follow. Open from the dashboard for the active league (all-leagues -> first enabled); tab/shift+tab cycle leagues, r refreshes, enter opens the selected team's schedule (TASK-017), esc closes. Only enabled leagues (App.leagues) are selectable.
+
+Tests: mapStandings (nested flatten + per-sport columns + missing-stat blanks), Standings.RowAt/RowCount. Live-smoke verified MLB AL/NL and World Cup 12 groups map correctly. go vet + go test ./... pass.
+<!-- SECTION:FINAL_SUMMARY:END -->
- → Full-team-schedules.md +52 −0
@@ -0,0 +1,52 @@
+---
+id: TASK-017
+title: Full team schedules
+status: "\U0001F3C1 Done"
+assignee:
+ - '@humdrum-tiv'
+created_date: '2026-06-17 17:43'
+updated_date: '2026-06-18 01:17'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 17000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Show a team's complete season schedule (past results + upcoming fixtures) beyond the polled fetch window, via ESPN's team-schedule endpoint. Reachable from a team (e.g. from the detail view or a favorite). Generalizes TASK-009 (favorite last/next beyond window) — the schedule endpoint backs both.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 User can view a single team's full schedule (results + upcoming)
+- [x] #2 Data comes from the ESPN team-schedule endpoint, decoded in internal/espn into model types
+- [x] #3 Schedule entries show date, opponent, home/away, and result/score when played
+- [x] #4 Reachable from a team in the UI (detail view or favorites)
+<!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. model.TeamSchedule{Team,Season,Games []model.Game} (reuse Game).
+2. espn.Client.TeamSchedule(ctx,l,teamID): GET teams/{id}/schedule; events[] like scoreboard BUT score is object {value,displayValue} -> schedule-specific competitor decode; reuse mapState/parseTime. Refactor mapTeam to share teamFromJSON.
+3. UI viewSchedule: opened via g=away / G=home from a dashboard game card or detail view, and via enter on a standings row. Past results (score + W/L tint) + upcoming, scroll-clamped. esc returns to prior view.
+4. Closes/supersedes TASK-009 (favorite last/next) via same endpoint.
+<!-- SECTION:PLAN:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Added a full team-schedule view (viewSchedule).
+
+What:
+- model.TeamSchedule{Team,Season,Games []model.Game} (reuses Game).
+- espn.Client.TeamSchedule fetches teams/{id}/schedule and maps events into model.Game. The schedule endpoint sends competitor score as an object {value,displayValue} (vs the scoreboard's bare string), so schedCompetitor/scoreObj decode separately; mapScheduleGame reuses mapState/parseTime and a new shared teamFromJSON helper (mapTeam refactored onto it). Games sorted chronologically.
+- UI: schedule.go renders past results (W/L/T + score, win-tinted) and upcoming fixtures (start time) with an 'upcoming' divider; opens scrolled to the next game. Reached three ways via openSchedule(): g/G (away/home, mirroring f/F favoriting) from a dashboard game card or the detail view, and enter on a standings row. prevMode records the opener so esc returns there; r refreshes.
+
+Supersedes TASK-009 (favorite last/next beyond the fetch window) — same endpoint.
+
+Tests: mapScheduleGame (object score, home/away, winner, state) and the no-competition (bye) skip. Live-smoke verified a 163-game MLB schedule maps with scores. go vet + go test ./... pass.
+<!-- SECTION:FINAL_SUMMARY:END -->
- → Motorsport-F1-support.md +26 −0
@@ -0,0 +1,26 @@
+---
+id: TASK-018
+title: Motorsport (F1) support
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-06-17 23:22'
+updated_date: '2026-06-18 01:17'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 18000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Add Formula 1 (racing/f1) and motorsport generally. These DON'T fit the two-team Game{Home,Away} model: an event is a Grand Prix with ~20+ driver competitors (ESPN competitor carries 'athlete' + 'statistics', not 'team'), plus qualifying/grid and a results table. Needs a race-shaped model (event = race; entries = ranked drivers/constructors) and its own card + detail rendering, separate from head-to-head sports. Standings = drivers' + constructors' championships.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 A race-shaped model + espn mapping (event with ranked driver/constructor entries, not Home/Away)
+- [ ] #2 Dashboard + detail render races (next/last race, results table) without breaking the two-team views
+- [ ] #3 Drivers' and constructors' standings
+<!-- AC:END -->
- → Seasonal-league-handling-auto-hide-off-season-quadrennial-events.md +49 −0
@@ -0,0 +1,49 @@
+---
+id: TASK-019
+title: Seasonal league handling (auto-hide off-season + quadrennial events)
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-06-17 23:22'
+updated_date: '2026-06-18 01:17'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 19000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Leagues clutter the tab bar when out of season. Auto-hide a league when it has no games within ~-30/+30 days of now (its last/next game), surfacing it only when active. Special-case rare events: World Cup, Olympics, etc. recur every ~4 years and should stay hidden until in window. This is distinct from TASK-002's manual enable/disable: manual choice should still override (user can force-show or force-hide). Likely a derived 'in season' flag on the effective league set, computed from the fetched window / a schedule probe, applied in App.leagues resolution.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 Leagues with no games within ~+/-30 days auto-hide from tabs and all views
+- [x] #2 Quadrennial events (World Cup, Olympics) stay hidden until they are in window
+- [x] #3 Manual enable/disable (TASK-002) still overrides the auto behavior
+- [x] #4 An auto-hidden league reappears automatically once it has games in range
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Reworking auto-vs-manual flip into a per-league override model: auto-by-season always on; config carries force-hide + force-show sets + a display order. Effective visible = (inSeason OR forceShow) AND NOT forceHide. Toggling back to auto's choice clears the override (returns that league to auto, doesn't pin it). Hiding one league no longer disables auto-hide for the rest.
+
+Refactored to per-league overrides (no global auto/manual flip). visible(l)=(inSeason OR forceShow) AND NOT forceHide. config now stores LeagueOrder + HideLeagues + ShowLeagues. toggleLeagueVisibility clears the override when the desired state matches the season default, so hiding ONE league no longer disables auto-hide for the rest; '0' clears all overrides. Tests rewritten for the new API (orderedCatalog, applyAutoLeagues, overrides-beat-season, toggle-clears-to-auto, moveLeague). go test ./... passes.
+<!-- SECTION:NOTES:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Seasonal auto-hide: off-season leagues drop out of the tabs and all views automatically, returning when in range.
+
+What:
+- model.League gains SeasonDays + SeasonWindow() (default 30, World Cup/quadrennial 90).
+- App.autoMode (true when the user hasn't customized leagues) derives App.leagues from App.inSeason via applyAutoLeagues (catalog order): a league shows only if it has games within its ±SeasonWindow. Unprobed leagues count as in-season (startup shows all, then narrows — never flashes out/back); all-off-season falls back to the full catalog so the app is never blank.
+- In-season status comes from a separate wide scan (seasonScan/fetchSeason: a ±SeasonWindow scoreboard probe per catalog league) at startup + a slow 10-min ticker, independent of the narrow display fetch. A scan error treats the league as in-season (transient failure never hides it).
+- Manual override (TASK-002): editing leagues in settings flips autoMode off and persists, but only on an ACTUAL change (settingsDirty) so opening settings doesn't lock auto. '0' in settings resets to auto and restarts the scan; manual mode skips scanning. Settings header shows the current mode.
+
+Verified live (June 2026): NFL probes 0 games in +/-30d -> auto-hidden; MLB/WNBA/World Cup/NBA/NHL in season. Unit tests cover the in-season derivation (catalog order, unprobed=in-season) and the never-blank fallback. go vet + go test ./... pass.
+<!-- SECTION:FINAL_SUMMARY:END -->
- → Special-shape-leagues-tennis-slams-cricket-IPL.md +26 −0
@@ -0,0 +1,26 @@
+---
+id: TASK-020
+title: 'Special-shape leagues: tennis slams + cricket (IPL)'
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-06-17 23:22'
+updated_date: '2026-06-18 01:17'
+labels:
+ - feature
+dependencies: []
+priority: low
+ordinal: 20000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Roadmap leagues that don't cleanly fit the integer-score two-team model. Tennis: the four majors for men and women (ESPN tennis has tournament draws; match = player vs player, set/game scores) — fits two 'competitors' loosely but needs set-by-set scoring + bracket. Cricket IPL (cricket/ipl): two teams but score is 'runs/wickets (overs)', not an int, plus innings — needs cricket-aware score formatting. File separately from the straightforward team-sport adds (TASK-008) because each needs model/render work.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Tennis: the 4 Grand Slams (M+W) with set-by-set match scores
+- [ ] #2 Cricket IPL with runs/wickets/overs score formatting
+- [ ] #3 Neither breaks the existing two-team card/detail rendering
+<!-- AC:END -->
- → Team-stat-bars-quarter-linescore-for-stick-and-ball-sports.md +26 −0
@@ -0,0 +1,26 @@
+---
+id: TASK-021
+title: Team stat bars + quarter linescore for stick-and-ball sports
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-06-17 23:47'
+updated_date: '2026-06-18 01:17'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 21000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Extend the soccer team-stat bars (TASK-011) to NBA/WNBA (and likely NHL/NFL). ESPN's basketball summary boxscore.teams[].statistics is a FLAT name/displayValue/label list (FG, FG%, 3PT, FT, rebounds, assists, steals, blocks, turnovers) — same shape soccer uses, so mapTeamStats just needs a per-sport curated key list instead of the hardcoded soccer set. Also add a quarter/period linescore strip from competitors[].linescores (e.g. away [18,11,...] / home [14,13,...]). NOTE: team fouls/timeouts are NOT in the summary stats — treat as unavailable. Baseball stays nested (batting/pitching) and keeps the per-player box only.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 mapTeamStats supports a per-sport curated stat list; NBA/WNBA show team-stat bars
+- [ ] #2 NHL/NFL team-stat bars where ESPN ships flat team stats
+- [ ] #3 Quarter/period linescore strip in the detail view from competitors[].linescores
+<!-- AC:END -->
go.mod +1 −1
@@ -1,4 +1,4 @@
-module github.com/kortum/pts-tui
+module github.com/humdrum-tiv/sportsball
go 1.26.4
internal/config/config.go +15 −5
@@ -1,5 +1,5 @@
// Package config persists user preferences (favorite teams, selected leagues,
-// active theme) under the XDG config dir (~/.config/pts/config.json). Reads are
+// active theme) under the XDG config dir (~/.config/sportsball/config.json). Reads are
// forgiving: a missing or corrupt file yields defaults rather than an error, so
// the app always starts.
package config
@@ -14,8 +14,18 @@ // Config is the persisted user state. Fields are optional; zero values are
// valid defaults.
type Config struct {
Favorites []FavTeam `json:"favorites"`
- Leagues []string `json:"leagues,omitempty"` // selected league IDs (TASK-002)
- Theme string `json:"theme,omitempty"` // active theme name (TASK-005)
+
+ // League preferences (TASK-002 / TASK-019). Leagues auto-show by season;
+ // these layer per-league overrides on top:
+ // LeagueOrder — display order (league IDs); empty = catalog order.
+ // HideLeagues — force-hidden even when in season.
+ // ShowLeagues — force-shown even when out of season.
+ // Toggling a league back to what the season would do clears its override.
+ LeagueOrder []string `json:"league_order,omitempty"`
+ HideLeagues []string `json:"hide_leagues,omitempty"`
+ ShowLeagues []string `json:"show_leagues,omitempty"`
+
+ Theme string `json:"theme,omitempty"` // active theme name (TASK-005)
}
// FavTeam identifies one favorited team. League+ID is the stable key; Abbr/Name
@@ -27,7 +37,7 @@ Abbr string `json:"abbr"`
Name string `json:"name"`
}
-// dir is the config directory: $XDG_CONFIG_HOME/pts, else ~/.config/pts.
+// dir is the config directory: $XDG_CONFIG_HOME/sportsball, else ~/.config/sportsball.
func dir() (string, error) {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
@@ -37,7 +47,7 @@ return "", err
}
base = filepath.Join(home, ".config")
}
- return filepath.Join(base, "pts"), nil
+ return filepath.Join(base, "sportsball"), nil
}
func path() (string, error) {
internal/config/config_test.go +1 −1
@@ -32,7 +32,7 @@
func TestLoadCorruptReturnsDefaults(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
- dir := filepath.Join(tmp, "pts")
+ dir := filepath.Join(tmp, "sportsball")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
internal/espn/client.go +41 −11
@@ -13,7 +13,7 @@ "strconv"
"strings"
"time"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
const baseURL = "https://site.api.espn.com/apis/site/v2/sports"
@@ -107,24 +107,54 @@ } else {
g.Away = t
}
}
+ if g.State == model.StateLive {
+ g.Situation = mapSituation(comp.Situation)
+ }
return g, true
}
+// mapSituation converts ESPN's live competition situation into model.Situation,
+// returning nil when there's nothing useful (no pitcher/batter resolved — the
+// case for sports that don't ship a situation, or a between-innings gap).
+func mapSituation(s situationJSON) *model.Situation {
+ pitcher := firstNonEmpty(s.Pitcher.Athlete.DisplayName, s.Pitcher.Athlete.ShortName)
+ batter := firstNonEmpty(s.Batter.Athlete.DisplayName, s.Batter.Athlete.ShortName)
+ if pitcher == "" && batter == "" {
+ return nil
+ }
+ return &model.Situation{
+ Balls: s.Balls, Strikes: s.Strikes, Outs: s.Outs,
+ OnFirst: s.OnFirst, OnSecond: s.OnSecond, OnThird: s.OnThird,
+ Pitcher: pitcher, Batter: batter,
+ PitcherLine: strings.TrimSpace(s.Pitcher.Summary),
+ BatterLine: strings.TrimSpace(s.Batter.Summary),
+ LastPlay: strings.TrimSpace(s.LastPlay.Text),
+ }
+}
+
func mapTeam(c competitor) model.Team {
- name := c.Team.ShortDisplayName
+ t := teamFromJSON(c.Team)
+ t.Score = atoi(c.Score)
+ t.Record = overallRecord(c.Records)
+ t.Winner = c.Winner
+ return t
+}
+
+// teamFromJSON maps the shared ESPN team object into a model.Team's identity
+// and display fields (no per-game score/record/winner). Used by the scoreboard,
+// standings, and schedule mappers.
+func teamFromJSON(t teamJSON) model.Team {
+ name := t.ShortDisplayName
if name == "" {
- name = c.Team.Name
+ name = t.Name
}
return model.Team{
- ID: c.Team.ID,
- Abbr: c.Team.Abbreviation,
+ ID: t.ID,
+ Abbr: t.Abbreviation,
Name: name,
- FullName: c.Team.DisplayName,
- Color: c.Team.Color,
- AltColor: c.Team.AlternateColor,
- Score: atoi(c.Score),
- Record: overallRecord(c.Records),
- Winner: c.Winner,
+ FullName: t.DisplayName,
+ Color: t.Color,
+ AltColor: t.AlternateColor,
}
}
internal/espn/client_test.go +70 −1
@@ -3,7 +3,7 @@
import (
"testing"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
func TestMapState(t *testing.T) {
@@ -128,3 +128,72 @@ if len(grp.Rows[0].Stats) != 3 || grp.Rows[0].Stats[1] != "2" {
t.Errorf("stats mapped wrong: %+v", grp.Rows[0].Stats)
}
}
+
+func TestMapEventSituation(t *testing.T) {
+ ev := event{
+ ID: "501",
+ Date: "2026-06-16T22:40Z",
+ Competitions: []competition{{
+ Status: status{Type: statusType{State: "in", Detail: "Top 7th"}, Period: 7},
+ Competitors: []competitor{
+ {HomeAway: "home", Score: "8", Team: teamJSON{Abbreviation: "PHI"}},
+ {HomeAway: "away", Score: "0", Team: teamJSON{Abbreviation: "MIA"}},
+ },
+ Situation: situationJSON{
+ Balls: 1, Strikes: 2, Outs: 1, OnSecond: true,
+ Pitcher: playerSituation{Athlete: athleteRef{DisplayName: "Jesus Luzardo"}, Summary: "6.0 IP, 0 ER"},
+ Batter: playerSituation{Athlete: athleteRef{DisplayName: "Leo Jimenez"}, Summary: "0-2, K"},
+ },
+ }},
+ }
+ g, ok := mapEvent(model.MLB, ev)
+ if !ok || g.Situation == nil {
+ t.Fatalf("situation not mapped: ok=%v sit=%v", ok, g.Situation)
+ }
+ s := g.Situation
+ if s.Balls != 1 || s.Strikes != 2 || s.Outs != 1 || !s.OnSecond || s.OnFirst {
+ t.Errorf("count/outs/bases wrong: %+v", s)
+ }
+ if s.Pitcher != "Jesus Luzardo" || s.Batter != "Leo Jimenez" || s.BatterLine != "0-2, K" {
+ t.Errorf("pitcher/batter wrong: %+v", s)
+ }
+}
+
+func TestMapEventNoSituationWhenFinal(t *testing.T) {
+ ev := event{
+ ID: "502", Date: "2026-06-16T22:40Z",
+ Competitions: []competition{{
+ Status: status{Type: statusType{State: "post"}},
+ Competitors: []competitor{{HomeAway: "home", Team: teamJSON{Abbreviation: "PHI"}}, {HomeAway: "away", Team: teamJSON{Abbreviation: "MIA"}}},
+ Situation: situationJSON{Pitcher: playerSituation{Athlete: athleteRef{DisplayName: "X"}}},
+ }},
+ }
+ g, _ := mapEvent(model.MLB, ev)
+ if g.Situation != nil {
+ t.Errorf("final game should have no situation, got %+v", g.Situation)
+ }
+}
+
+func TestMapSummaryScoringPlays(t *testing.T) {
+ s := summary{Plays: []play{
+ {Text: "Bohm singled to right, Harper scored.", ScoringPlay: true,
+ Period: playPeriod{Type: "Bottom", Number: 1}, Team: eventTeam{ID: "22"},
+ AlternativeType: altType{Text: "Single", Abbreviation: "1B"}},
+ {Text: "Strikeout swinging.", ScoringPlay: false,
+ Period: playPeriod{Type: "Top", Number: 2}, Team: eventTeam{ID: "28"}},
+ {Text: "Schwarber homered to right (352 feet).", ScoringPlay: true,
+ Period: playPeriod{Type: "Bottom", Number: 4}, Team: eventTeam{ID: "22"},
+ AlternativeType: altType{Text: "Home Run", Abbreviation: "HR"}},
+ }}
+ d := mapSummary(s)
+ if len(d.Events) != 2 {
+ t.Fatalf("want 2 scoring events (non-scoring dropped), got %d", len(d.Events))
+ }
+ e := d.Events[0]
+ if e.Clock != "▼1" || e.TeamID != "22" || !e.Scoring || e.Type != "Single" {
+ t.Errorf("first scoring play mapped wrong: %+v", e)
+ }
+ if d.Events[1].Clock != "▼4" || d.Events[1].Type != "Home Run" {
+ t.Errorf("HR play mapped wrong: %+v", d.Events[1])
+ }
+}
internal/espn/schedule.go +115 −0
@@ -0,0 +1,115 @@
+package espn
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sort"
+ "strings"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// TeamSchedule fetches a team's full season schedule (completed results and
+// upcoming fixtures) from ESPN's team-schedule endpoint — the data behind the
+// schedule view, reaching beyond the polled scoreboard window.
+func (c *Client) TeamSchedule(ctx context.Context, l model.League, teamID string) (model.TeamSchedule, error) {
+ url := fmt.Sprintf("%s/%s/%s/teams/%s/schedule", baseURL, l.Sport, l.Path, teamID)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return model.TeamSchedule{}, err
+ }
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return model.TeamSchedule{}, fmt.Errorf("schedule %s/%s: %w", l.ID, teamID, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return model.TeamSchedule{}, fmt.Errorf("schedule %s/%s: status %d", l.ID, teamID, resp.StatusCode)
+ }
+
+ var r scheduleResp
+ if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
+ return model.TeamSchedule{}, fmt.Errorf("decode schedule %s/%s: %w", l.ID, teamID, err)
+ }
+
+ out := model.TeamSchedule{
+ Team: teamFromJSON(r.Team),
+ Season: strings.TrimSpace(r.Season.DisplayName + " " + r.Season.Name),
+ }
+ for _, ev := range r.Events {
+ if g, ok := mapScheduleGame(l.ID, ev); ok {
+ out.Games = append(out.Games, g)
+ }
+ }
+ sort.SliceStable(out.Games, func(i, j int) bool {
+ return out.Games[i].Start.Before(out.Games[j].Start)
+ })
+ return out, nil
+}
+
+func mapScheduleGame(id model.LeagueID, ev schedEvent) (model.Game, bool) {
+ if len(ev.Competitions) == 0 {
+ return model.Game{}, false
+ }
+ comp := ev.Competitions[0]
+ g := model.Game{
+ ID: ev.ID,
+ League: id,
+ Start: parseTime(ev.Date),
+ State: mapState(comp.Status.Type.State),
+ Detail: strings.TrimSpace(comp.Status.Type.Detail),
+ Venue: comp.Venue.FullName,
+ }
+ for _, cmp := range comp.Competitors {
+ t := teamFromJSON(cmp.Team)
+ t.Score = int(cmp.Score.Value)
+ t.Winner = cmp.Winner
+ if cmp.HomeAway == "home" {
+ g.Home = t
+ } else {
+ g.Away = t
+ }
+ }
+ return g, true
+}
+
+// --- team-schedule JSON -----------------------------------------------------
+
+type scheduleResp struct {
+ Team teamJSON `json:"team"`
+ Season seasonJSON `json:"season"`
+ Events []schedEvent `json:"events"`
+}
+
+type seasonJSON struct {
+ Name string `json:"name"` // "Regular Season"
+ DisplayName string `json:"displayName"` // "2026"
+}
+
+type schedEvent struct {
+ ID string `json:"id"`
+ Date string `json:"date"`
+ Competitions []schedComp `json:"competitions"`
+}
+
+type schedComp struct {
+ Venue venue `json:"venue"`
+ Status status `json:"status"`
+ Competitors []schedCompetitor `json:"competitors"`
+}
+
+// schedCompetitor mirrors a scoreboard competitor but the score arrives as an
+// object ({value, displayValue}) here rather than the scoreboard's bare string.
+type schedCompetitor struct {
+ HomeAway string `json:"homeAway"`
+ Winner bool `json:"winner"`
+ Score scoreObj `json:"score"`
+ Team teamJSON `json:"team"`
+}
+
+type scoreObj struct {
+ Value float64 `json:"value"`
+ DisplayValue string `json:"displayValue"`
+}
internal/espn/schedule_test.go +44 −0
@@ -0,0 +1,44 @@
+package espn
+
+import (
+ "testing"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// mapScheduleGame must decode the object-shaped score, assign home/away, and
+// carry winner + state for a completed game.
+func TestMapScheduleGameObjectScore(t *testing.T) {
+ ev := schedEvent{
+ ID: "401",
+ Date: "2026-04-12T20:15Z",
+ Competitions: []schedComp{{
+ Status: status{Type: statusType{State: "post", Detail: "Final"}},
+ Competitors: []schedCompetitor{
+ {HomeAway: "home", Winner: true, Score: scoreObj{Value: 5, DisplayValue: "5"}, Team: teamJSON{ID: "22", Abbreviation: "PHI", ShortDisplayName: "Phillies"}},
+ {HomeAway: "away", Winner: false, Score: scoreObj{Value: 3, DisplayValue: "3"}, Team: teamJSON{ID: "13", Abbreviation: "TEX", ShortDisplayName: "Rangers"}},
+ },
+ }},
+ }
+
+ g, ok := mapScheduleGame(model.MLB, ev)
+ if !ok {
+ t.Fatal("mapScheduleGame returned ok=false")
+ }
+ if g.State != model.StateFinal {
+ t.Errorf("state = %v, want Final", g.State)
+ }
+ if g.Home.Abbr != "PHI" || g.Home.Score != 5 || !g.Home.Winner {
+ t.Errorf("home = %+v", g.Home)
+ }
+ if g.Away.Abbr != "TEX" || g.Away.Score != 3 || g.Away.Winner {
+ t.Errorf("away = %+v", g.Away)
+ }
+}
+
+// A bye-week / placeholder event with no competition is dropped, not panicked.
+func TestMapScheduleGameNoCompetition(t *testing.T) {
+ if _, ok := mapScheduleGame(model.NFL, schedEvent{ID: "x"}); ok {
+ t.Error("event with no competitions should be skipped")
+ }
+}
internal/espn/standings.go +156 −0
@@ -0,0 +1,156 @@
+package espn
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// standingsBase is ESPN's standings host, which lives under apis/v2 (NOT the
+// apis/site/v2 path the scoreboard/summary endpoints use).
+const standingsBase = "https://site.api.espn.com/apis/v2/sports"
+
+// colSpec maps a display column header to the ESPN stat `type` it pulls from.
+type colSpec struct{ label, typ string }
+
+// standingsCols is the per-sport column layout: which stats to show and in what
+// order. Soccer is a points table; the stick-and-ball sports show records.
+var standingsCols = map[string][]colSpec{
+ "soccer": {{"P", "gamesplayed"}, {"W", "wins"}, {"D", "ties"}, {"L", "losses"}, {"GD", "pointdifferential"}, {"Pts", "points"}},
+ "baseball": {{"W", "wins"}, {"L", "losses"}, {"PCT", "winpercent"}, {"GB", "gamesbehind"}},
+ "basketball": {{"W", "wins"}, {"L", "losses"}, {"PCT", "winpercent"}, {"GB", "gamesbehind"}},
+ "football": {{"W", "wins"}, {"L", "losses"}, {"T", "ties"}, {"PCT", "winpercent"}},
+ "hockey": {{"W", "wins"}, {"L", "losses"}, {"OTL", "otlosses"}, {"PTS", "points"}},
+}
+
+var defaultCols = []colSpec{{"W", "wins"}, {"L", "losses"}}
+
+// rankStat is the ESPN stat each sport ranks its table by — ESPN returns the
+// entries unsorted (or alphabetized), so we sort on this ourselves. Soccer
+// ships an explicit "rank"; the stick-and-ball sports use "playoffseed".
+var rankStat = map[string]string{
+ "soccer": "rank",
+ "baseball": "playoffseed",
+ "basketball": "playoffseed",
+ "hockey": "playoffseed",
+ "football": "playoffseed",
+}
+
+// Standings fetches and normalizes a league's standings table(s). Groups are
+// flattened from ESPN's nested children (league/conference → entries) so each
+// table that actually has teams becomes one model.StandingsGroup.
+func (c *Client) Standings(ctx context.Context, l model.League) (model.Standings, error) {
+ url := fmt.Sprintf("%s/%s/%s/standings", standingsBase, l.Sport, l.Path)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return model.Standings{}, err
+ }
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return model.Standings{}, fmt.Errorf("standings %s: %w", l.ID, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return model.Standings{}, fmt.Errorf("standings %s: status %d", l.ID, resp.StatusCode)
+ }
+
+ var r standingsNode
+ if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
+ return model.Standings{}, fmt.Errorf("decode standings %s: %w", l.ID, err)
+ }
+ return mapStandings(l.ID, l.Sport, r), nil
+}
+
+func mapStandings(id model.LeagueID, sport string, root standingsNode) model.Standings {
+ cols := standingsCols[sport]
+ if cols == nil {
+ cols = defaultCols
+ }
+ labels := make([]string, len(cols))
+ for i, c := range cols {
+ labels[i] = c.label
+ }
+ rankKey := rankStat[sport]
+ if rankKey == "" {
+ rankKey = "playoffseed"
+ }
+
+ out := model.Standings{League: id}
+ // Walk the tree: a node with entries is a table; recurse into children.
+ var walk func(n standingsNode)
+ walk = func(n standingsNode) {
+ if len(n.Standings.Entries) > 0 {
+ out.Groups = append(out.Groups, mapGroup(n.Name, n.Standings.Entries, cols, labels, rankKey))
+ }
+ for _, ch := range n.Children {
+ walk(ch)
+ }
+ }
+ walk(root)
+ return out
+}
+
+func mapGroup(name string, entries []standingsEntry, cols []colSpec, labels []string, rankKey string) model.StandingsGroup {
+ // ESPN returns entries unsorted; order them by the sport's rank stat. Entries
+ // missing it sink to the bottom while keeping their relative order.
+ sort.SliceStable(entries, func(i, j int) bool {
+ return entryRank(entries[i], rankKey) < entryRank(entries[j], rankKey)
+ })
+
+ g := model.StandingsGroup{Name: name, Columns: labels}
+ for i, e := range entries {
+ byType := make(map[string]string, len(e.Stats))
+ for _, s := range e.Stats {
+ byType[s.Type] = s.DisplayValue
+ }
+ row := model.StandingsRow{Rank: i + 1, Team: teamFromJSON(e.Team)}
+ for _, c := range cols {
+ row.Values = append(row.Values, byType[c.typ])
+ }
+ g.Rows = append(g.Rows, row)
+ }
+ return g
+}
+
+// entryRank reads the rank stat as an int, returning a large sentinel when it's
+// absent or unparseable so those entries sort last.
+func entryRank(e standingsEntry, rankKey string) int {
+ for _, s := range e.Stats {
+ if s.Type == rankKey {
+ if n, err := strconv.Atoi(strings.TrimSpace(s.DisplayValue)); err == nil {
+ return n
+ }
+ }
+ }
+ return 1 << 30
+}
+
+// --- standings JSON ---------------------------------------------------------
+
+// standingsNode is recursive: the root and each child share this shape — a name,
+// an optional standings block (entries), and optional nested children.
+type standingsNode struct {
+ Name string `json:"name"`
+ Standings standingsBlock `json:"standings"`
+ Children []standingsNode `json:"children"`
+}
+
+type standingsBlock struct {
+ Entries []standingsEntry `json:"entries"`
+}
+
+type standingsEntry struct {
+ Team teamJSON `json:"team"`
+ Stats []standingStat `json:"stats"`
+}
+
+type standingStat struct {
+ Type string `json:"type"`
+ DisplayValue string `json:"displayValue"`
+}
internal/espn/standings_test.go +91 −0
@@ -0,0 +1,91 @@
+package espn
+
+import (
+ "testing"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// mapStandings must flatten nested children (conference → entries) into one
+// group per table, select the per-sport columns, and rank by entry order.
+func TestMapStandingsNestedAndColumns(t *testing.T) {
+ root := standingsNode{
+ Name: "National Basketball Association",
+ Children: []standingsNode{
+ {
+ Name: "Eastern Conference",
+ Standings: standingsBlock{Entries: []standingsEntry{
+ {
+ Team: teamJSON{ID: "1", Abbreviation: "BOS", ShortDisplayName: "Celtics"},
+ Stats: []standingStat{{Type: "wins", DisplayValue: "50"}, {Type: "losses", DisplayValue: "20"}, {Type: "winpercent", DisplayValue: ".714"}, {Type: "gamesbehind", DisplayValue: "-"}},
+ },
+ {
+ Team: teamJSON{ID: "2", Abbreviation: "NYK", ShortDisplayName: "Knicks"},
+ Stats: []standingStat{{Type: "wins", DisplayValue: "45"}, {Type: "losses", DisplayValue: "25"}},
+ },
+ }},
+ },
+ },
+ }
+
+ s := mapStandings(model.NBA, "basketball", root)
+ if len(s.Groups) != 1 {
+ t.Fatalf("want 1 group, got %d", len(s.Groups))
+ }
+ g := s.Groups[0]
+ if g.Name != "Eastern Conference" {
+ t.Errorf("group name = %q", g.Name)
+ }
+ wantCols := []string{"W", "L", "PCT", "GB"}
+ if len(g.Columns) != len(wantCols) {
+ t.Fatalf("columns = %v", g.Columns)
+ }
+ for i, c := range wantCols {
+ if g.Columns[i] != c {
+ t.Errorf("col %d = %q, want %q", i, g.Columns[i], c)
+ }
+ }
+ if g.Rows[0].Rank != 1 || g.Rows[0].Team.Abbr != "BOS" {
+ t.Errorf("row0 = %+v", g.Rows[0])
+ }
+ if got := g.Rows[0].Values; got[0] != "50" || got[1] != "20" || got[2] != ".714" || got[3] != "-" {
+ t.Errorf("row0 values = %v", got)
+ }
+ // Missing stats map to empty strings, not a panic.
+ if got := g.Rows[1].Values; got[2] != "" || got[3] != "" {
+ t.Errorf("row1 missing stats should be blank: %v", got)
+ }
+}
+
+// ESPN returns entries unsorted; mapStandings must order each group by the
+// sport's rank stat (basketball = playoffseed) before assigning ranks.
+func TestMapStandingsSortsByRankStat(t *testing.T) {
+ root := standingsNode{Standings: standingsBlock{Entries: []standingsEntry{
+ {Team: teamJSON{Abbreviation: "ATL"}, Stats: []standingStat{{Type: "playoffseed", DisplayValue: "6"}, {Type: "wins", DisplayValue: "46"}}},
+ {Team: teamJSON{Abbreviation: "DET"}, Stats: []standingStat{{Type: "playoffseed", DisplayValue: "1"}, {Type: "wins", DisplayValue: "60"}}},
+ {Team: teamJSON{Abbreviation: "BOS"}, Stats: []standingStat{{Type: "playoffseed", DisplayValue: "2"}, {Type: "wins", DisplayValue: "56"}}},
+ }}}
+ g := mapStandings(model.NBA, "basketball", root).Groups[0]
+ want := []string{"DET", "BOS", "ATL"}
+ for i, abbr := range want {
+ if g.Rows[i].Team.Abbr != abbr || g.Rows[i].Rank != i+1 {
+ t.Errorf("row %d = %s rank %d, want %s rank %d", i, g.Rows[i].Team.Abbr, g.Rows[i].Rank, abbr, i+1)
+ }
+ }
+}
+
+func TestStandingsRowAt(t *testing.T) {
+ s := model.Standings{Groups: []model.StandingsGroup{
+ {Rows: []model.StandingsRow{{Team: model.Team{Abbr: "A"}}, {Team: model.Team{Abbr: "B"}}}},
+ {Rows: []model.StandingsRow{{Team: model.Team{Abbr: "C"}}}},
+ }}
+ if s.RowCount() != 3 {
+ t.Fatalf("RowCount = %d", s.RowCount())
+ }
+ if r, ok := s.RowAt(2); !ok || r.Team.Abbr != "C" {
+ t.Errorf("RowAt(2) = %+v ok=%v, want C", r, ok)
+ }
+ if _, ok := s.RowAt(3); ok {
+ t.Error("RowAt(3) should be out of range")
+ }
+}
internal/espn/summary.go +76 −1
@@ -7,7 +7,7 @@ "fmt"
"net/http"
"strings"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
// Summary fetches the deeper per-game data (key events for soccer, box score
@@ -41,12 +41,86 @@ d := model.GameDetail{}
for _, e := range s.KeyEvents {
d.Events = append(d.Events, mapKeyEvent(e))
}
+ // Scoring plays back the baseball timeline (soccer uses keyEvents above).
+ // They're mutually exclusive per sport, so both feed model.Events.
+ for _, p := range s.Plays {
+ if p.ScoringPlay {
+ d.Events = append(d.Events, mapScoringPlay(p))
+ }
+ }
for _, tb := range s.Boxscore.Players {
d.BoxScore = append(d.BoxScore, mapTeamBox(tb))
}
+ d.TeamStats = mapTeamStats(s.Boxscore.Teams)
return d
}
+// soccerTeamStatOrder is the curated subset of team stats shown as comparison
+// bars, in display order. ESPN ships many more; these are the legible ones.
+var soccerTeamStatOrder = []string{
+ "possessionPct", "totalShots", "shotsOnTarget", "wonCorners", "foulsCommitted", "saves",
+}
+
+// mapTeamStats pairs the two teams' flat stat lists into away-vs-home rows for
+// the curated stats present. Returns nil unless both home and away are found
+// (the stick-and-ball sports nest team stats differently and yield nothing).
+func mapTeamStats(teams []teamStatBox) []model.TeamStat {
+ var home, away map[string]teamStatJSON
+ for _, t := range teams {
+ m := make(map[string]teamStatJSON, len(t.Statistics))
+ for _, s := range t.Statistics {
+ m[s.Name] = s
+ }
+ if t.HomeAway == "home" {
+ home = m
+ } else {
+ away = m
+ }
+ }
+ if home == nil || away == nil {
+ return nil
+ }
+ var out []model.TeamStat
+ for _, key := range soccerTeamStatOrder {
+ h, okh := home[key]
+ a, oka := away[key]
+ if !okh && !oka {
+ continue
+ }
+ out = append(out, model.TeamStat{
+ Label: firstNonEmpty(h.Label, a.Label, key),
+ Key: key,
+ Away: a.DisplayValue,
+ Home: h.DisplayValue,
+ })
+ }
+ return out
+}
+
+// mapScoringPlay converts a baseball scoring play into a MatchEvent. Clock is a
+// compact half-inning marker (▲ top / ▼ bottom + inning); Team is matched by
+// ID since the plays array only carries the team id.
+func mapScoringPlay(p play) model.MatchEvent {
+ mark := ""
+ switch p.Period.Type {
+ case "Top":
+ mark = "▲"
+ case "Bottom":
+ mark = "▼"
+ }
+ clock := mark + fmt.Sprintf("%d", p.Period.Number)
+ text := strings.TrimSpace(p.Text)
+ return model.MatchEvent{
+ Clock: clock,
+ Period: p.Period.Number,
+ Type: strings.TrimSpace(p.AlternativeType.Text),
+ Text: text,
+ ShortText: text,
+ TeamID: p.Team.ID,
+ Scoring: true,
+ }
+}
+
func mapKeyEvent(e keyEvent) model.MatchEvent {
me := model.MatchEvent{
Clock: e.Clock.DisplayValue,
@@ -55,6 +129,7 @@ Type: strings.TrimSpace(e.Type.Text),
Text: strings.TrimSpace(e.Text),
ShortText: strings.TrimSpace(e.ShortText),
Team: e.Team.DisplayName,
+ TeamID: e.Team.ID,
Scoring: e.ScoringPlay,
}
for _, p := range e.Participants {
internal/espn/teamstats_test.go +37 −0
@@ -0,0 +1,37 @@
+package espn
+
+import (
+ "testing"
+)
+
+// mapTeamStats pairs home/away flat stats into curated, ordered comparison rows
+// and skips stats absent from both sides.
+func TestMapTeamStats(t *testing.T) {
+ teams := []teamStatBox{
+ {HomeAway: "home", Statistics: []teamStatJSON{
+ {Name: "possessionPct", Label: "Possession", DisplayValue: "48.4"},
+ {Name: "totalShots", Label: "SHOTS", DisplayValue: "9"},
+ }},
+ {HomeAway: "away", Statistics: []teamStatJSON{
+ {Name: "possessionPct", Label: "Possession", DisplayValue: "51.6"},
+ {Name: "totalShots", Label: "SHOTS", DisplayValue: "4"},
+ }},
+ }
+ got := mapTeamStats(teams)
+ if len(got) != 2 {
+ t.Fatalf("want 2 stats (only present keys), got %d: %+v", len(got), got)
+ }
+ if got[0].Key != "possessionPct" || got[0].Away != "51.6" || got[0].Home != "48.4" {
+ t.Errorf("row0 = %+v", got[0])
+ }
+ if got[1].Label != "SHOTS" || got[1].Away != "4" || got[1].Home != "9" {
+ t.Errorf("row1 = %+v", got[1])
+ }
+}
+
+// Without both home and away present, there's nothing to compare.
+func TestMapTeamStatsNeedsBothSides(t *testing.T) {
+ if got := mapTeamStats([]teamStatBox{{HomeAway: "home"}}); got != nil {
+ t.Errorf("one-sided stats should map to nil, got %+v", got)
+ }
+}
internal/espn/types.go +68 −5
@@ -18,10 +18,35 @@ Competitions []competition `json:"competitions"`
}
type competition struct {
- Venue venue `json:"venue"`
- Competitors []competitor `json:"competitors"`
- Status status `json:"status"`
- Headlines []headline `json:"headlines"`
+ Venue venue `json:"venue"`
+ Competitors []competitor `json:"competitors"`
+ Status status `json:"status"`
+ Headlines []headline `json:"headlines"`
+ Situation situationJSON `json:"situation"`
+}
+
+// situationJSON is the live play state ESPN ships on a baseball competition
+// while in progress: the count, outs, occupied bases, and the current
+// pitcher/batter (with one-line stat summaries).
+type situationJSON struct {
+ Balls int `json:"balls"`
+ Strikes int `json:"strikes"`
+ Outs int `json:"outs"`
+ OnFirst bool `json:"onFirst"`
+ OnSecond bool `json:"onSecond"`
+ OnThird bool `json:"onThird"`
+ Pitcher playerSituation `json:"pitcher"`
+ Batter playerSituation `json:"batter"`
+ LastPlay lastPlayJSON `json:"lastPlay"`
+}
+
+type playerSituation struct {
+ Athlete athleteRef `json:"athlete"`
+ Summary string `json:"summary"`
+}
+
+type lastPlayJSON struct {
+ Text string `json:"text"`
}
type venue struct {
@@ -75,9 +100,33 @@ // detail view: soccer keyEvents and the per-team player box score.
type summary struct {
KeyEvents []keyEvent `json:"keyEvents"`
+ Plays []play `json:"plays"`
Boxscore boxscore `json:"boxscore"`
}
+// play is one entry in the summary's play-by-play. We consume only scoring
+// plays (baseball scoring timeline); the array is large so the rest is ignored.
+type play struct {
+ Text string `json:"text"`
+ ScoringPlay bool `json:"scoringPlay"`
+ AwayScore int `json:"awayScore"`
+ HomeScore int `json:"homeScore"`
+ Period playPeriod `json:"period"`
+ Team eventTeam `json:"team"`
+ AlternativeType altType `json:"alternativeType"`
+}
+
+type playPeriod struct {
+ Type string `json:"type"` // "Top" | "Bottom"
+ Number int `json:"number"` // inning number
+ DisplayValue string `json:"displayValue"` // "4th Inning"
+}
+
+type altType struct {
+ Abbreviation string `json:"abbreviation"` // "HR"
+ Text string `json:"text"` // "Home Run"
+}
+
type keyEvent struct {
Type eventType `json:"type"`
Text string `json:"text"`
@@ -117,7 +166,21 @@ ShortName string `json:"shortName"`
}
type boxscore struct {
- Players []playerBox `json:"players"`
+ Players []playerBox `json:"players"`
+ Teams []teamStatBox `json:"teams"`
+}
+
+// teamStatBox is one team's flat team-level stat list from the soccer summary
+// boxscore (possession, shots, corners…). homeAway pairs the two sides.
+type teamStatBox struct {
+ HomeAway string `json:"homeAway"`
+ Statistics []teamStatJSON `json:"statistics"`
+}
+
+type teamStatJSON struct {
+ Name string `json:"name"` // "possessionPct", "totalShots"
+ Label string `json:"label"` // "Possession", "SHOTS"
+ DisplayValue string `json:"displayValue"` // "63.2", "11"
}
type playerBox struct {
internal/model/detail.go +15 −3
@@ -5,8 +5,19 @@ // ESPN's summary endpoint and shown in the detail view. It is league-agnostic:
// soccer populates Events (goals/cards/subs), the stick-and-ball sports
// populate BoxScore (player stat tables). Either may be empty.
type GameDetail struct {
- Events []MatchEvent // soccer key events, chronological
- BoxScore []TeamBox // per-team player stat tables (away, home)
+ Events []MatchEvent // soccer key events, chronological
+ BoxScore []TeamBox // per-team player stat tables (away, home)
+ TeamStats []TeamStat // team-vs-team comparison stats (soccer), display order
+}
+
+// TeamStat is one away-vs-home comparison metric (possession, shots, corners…),
+// rendered as a split/mirrored bar in the detail view. Values are ESPN's
+// formatted display strings ("63.2", "11", "4").
+type TeamStat struct {
+ Label string // "Possession", "Shots", …
+ Key string // ESPN stat name, e.g. "possessionPct"
+ Away string
+ Home string
}
// MatchEvent is one notable in-match moment (goal, card, substitution…).
@@ -17,8 +28,9 @@ Type string // "Goal", "Yellow Card", "Substitution", …
Text string // full description
ShortText string // concise label, e.g. "Jaidon Anthony Goal"
Team string // team display name the event belongs to
+ TeamID string // ESPN team id (preferred for side matching)
Athletes []string // involved players (scorer first, then assists)
- Scoring bool // true for goals
+ Scoring bool // true for goals / scoring plays
}
// TeamBox is one team's box score: a set of stat groups (batting/pitching,
internal/model/game.go +15 −0
@@ -51,6 +51,21 @@ Clock string // displayClock when live
Period int // inning / quarter / half
Venue string
Headline string // optional recap/odds blurb
+
+ // Situation is the live play state, currently baseball-only (count, outs,
+ // bases, pitcher/batter). Nil when absent (not live, or sport without it).
+ Situation *Situation
+}
+
+// Situation is the live in-game state for baseball: the count, outs, occupied
+// bases, and who's at the plate / on the mound. Populated from the scoreboard
+// endpoint's competition situation while a game is live.
+type Situation struct {
+ Balls, Strikes, Outs int
+ OnFirst, OnSecond, OnThird bool
+ Pitcher, Batter string // display names
+ PitcherLine, BatterLine string // ESPN summary lines ("5.1 IP, 2 ER…", "1-3")
+ LastPlay string
}
// Started reports whether play has begun (live or final).
internal/model/league.go +30 −7
@@ -7,6 +7,7 @@ const (
WorldCup LeagueID = "worldcup"
MLB LeagueID = "mlb"
NBA LeagueID = "nba"
+ WNBA LeagueID = "wnba"
NHL LeagueID = "nhl"
NFL LeagueID = "nfl"
)
@@ -26,23 +27,37 @@ // only a couple days to surface last/next; weekly sports (NFL, soccer)
// need ~10. Zero means fall back to DefaultWindowBack/Forward.
WindowBack int
WindowForward int
+
+ // SeasonDays is the ± window (days) used to decide whether the league is
+ // "in season" for auto-hide (TASK-019): a league with no games within this
+ // span of today is hidden until it returns. Quadrennial events (World Cup,
+ // Olympics) use a wide span so they surface ahead of time. 0 → DefaultSeasonDays.
+ SeasonDays int
}
// Leagues is the ordered, canonical set the app polls and displays.
// Order here is the display/cycle order in the dashboard. World Cup and
// MLB lead because they are the current in-season priorities.
+// Windows are tuned to surface roughly each team's last game and next game,
+// not a whole stretch of fixtures: just wide enough to span one game-to-game
+// gap each way for the league's cadence (daily sports stay tight; weekly NFL
+// needs ~a week each side). Wider windows pull a "ton more" than last/next.
var Leagues = []League{
- {WorldCup, "World Cup", "WC", "soccer", "fifa.world", "6CABDD", "⚽", 5, 10},
- {MLB, "MLB", "MLB", "baseball", "mlb", "C8102E", "⚾", 3, 5},
- {NBA, "NBA", "NBA", "basketball", "nba", "C9082F", "🏀", 5, 7},
- {NHL, "NHL", "NHL", "hockey", "nhl", "6B7280", "🏒", 5, 7},
- {NFL, "NFL", "NFL", "football", "nfl", "013369", "🏈", 10, 10},
+ {WorldCup, "World Cup", "WC", "soccer", "fifa.world", "6CABDD", "⚽", 7, 7, 90},
+ {MLB, "MLB", "MLB", "baseball", "mlb", "C8102E", "⚾", 2, 3, 0},
+ {NBA, "NBA", "NBA", "basketball", "nba", "C9082F", "🏀", 3, 4, 0},
+ {WNBA, "WNBA", "WNBA", "basketball", "wnba", "FF6F1E", "🏀", 3, 4, 0},
+ {NHL, "NHL", "NHL", "hockey", "nhl", "6B7280", "🏒", 3, 4, 0},
+ {NFL, "NFL", "NFL", "football", "nfl", "013369", "🏈", 8, 8, 0},
}
// Default fetch window (days) for leagues that don't specify one.
const (
- DefaultWindowBack = 5
- DefaultWindowForward = 10
+ DefaultWindowBack = 4
+ DefaultWindowForward = 5
+ // DefaultSeasonDays is the ± in-season detection span for leagues that
+ // don't set SeasonDays — roughly a month covers normal between-game gaps.
+ DefaultSeasonDays = 30
)
// Window returns the league's fetch window in days, applying defaults.
@@ -55,6 +70,14 @@ if forward == 0 {
forward = DefaultWindowForward
}
return back, forward
+}
+
+// SeasonWindow returns the league's in-season detection span in days.
+func (l League) SeasonWindow() int {
+ if l.SeasonDays == 0 {
+ return DefaultSeasonDays
+ }
+ return l.SeasonDays
}
// LeagueByID returns the League metadata for id, ok=false if unknown.
internal/model/standings.go +53 −0
@@ -0,0 +1,53 @@
+package model
+
+// Standings is a league table, fetched from ESPN's standings endpoint. Leagues
+// split into one or more groups (soccer = groups, MLB = leagues, NBA/NHL/NFL =
+// conferences); each group is an independent ranked table.
+type Standings struct {
+ League LeagueID
+ Groups []StandingsGroup
+}
+
+// StandingsGroup is one ranked table within a league's standings.
+type StandingsGroup struct {
+ Name string // "Group A", "American League", "Eastern Conference", or ""
+ Columns []string // stat column headers, parallel to each row's Values
+ Rows []StandingsRow
+}
+
+// StandingsRow is one team's line in a group, ranked by ESPN's own ordering.
+type StandingsRow struct {
+ Rank int
+ Team Team
+ Values []string // stat values, parallel to the group's Columns
+}
+
+// RowCount is the total number of team rows across all groups — the size of the
+// flattened selection space the standings cursor walks.
+func (s Standings) RowCount() int {
+ n := 0
+ for _, g := range s.Groups {
+ n += len(g.Rows)
+ }
+ return n
+}
+
+// RowAt returns the team row at flattened index i (across groups, in order),
+// ok=false if i is out of range.
+func (s Standings) RowAt(i int) (StandingsRow, bool) {
+ for _, g := range s.Groups {
+ if i < len(g.Rows) {
+ return g.Rows[i], true
+ }
+ i -= len(g.Rows)
+ }
+ return StandingsRow{}, false
+}
+
+// TeamSchedule is a team's full season schedule (past results + upcoming
+// fixtures) from ESPN's team-schedule endpoint, beyond the polled fetch window.
+type TeamSchedule struct {
+ Team Team
+ Season string // e.g. "2026 Regular Season"
+ Games []Game // chronological; finals carry scores, pre-games are upcoming
+}
internal/ui/app.go +127 −16
@@ -8,10 +8,11 @@ "math"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
- "github.com/kortum/pts-tui/internal/config"
- "github.com/kortum/pts-tui/internal/espn"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/config"
+ "github.com/humdrum-tiv/sportsball/internal/espn"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
type viewMode int
@@ -19,6 +20,9 @@
const (
viewDashboard viewMode = iota
viewDetail
+ viewSettings
+ viewStandings
+ viewSchedule
)
// App is the root Bubble Tea model.
@@ -32,17 +36,51 @@ errs map[model.LeagueID]error
loaded map[model.LeagueID]bool
loading bool
- filter model.LeagueID // "" means all leagues
- cursor int // index into the current visible() slice
- mode viewMode
- detail model.Game // game shown in detail view
+ // leagues is the user's effective league set: enabled leagues in display
+ // order (see config.Leagues / resolveLeagues). Everything — fetch, tabs,
+ // sections, sort order — reads this instead of model.Leagues.
+ leagues []model.League
+
+ // League visibility (TASK-002 / TASK-019): leagues auto-show by season,
+ // with per-league overrides. See leagues.go. `leagues` is the resolved
+ // ordered visible set; order/hide/show are the inputs, inSeason the scan
+ // result.
+ order []model.LeagueID
+ hide map[model.LeagueID]bool
+ show map[model.LeagueID]bool
+ inSeason map[model.LeagueID]bool
+
+ filter model.LeagueID // "" means all leagues
+ stateFilter gameFilter // All/Live/Recent/Upcoming slate filter (see sections.go)
+ cursor int // index into the current visible() slice
+ mode viewMode
+ detail model.Game // game shown in detail view
+ settingsCursor int // selected row in the leagues settings screen
+ settingsDirty bool // user changed leagues in settings → persist as manual
detailData map[string]model.GameDetail // summary data keyed by event ID
detailErr error // last summary fetch error for open game
detailScroll int // vertical scroll offset in detail view
+ tickerCursor int // selected box in the detail-view ticker strip
+ detailPrev string // ID of the game viewed before this one (ticker back-breadcrumb)
- cfg config.Config // persisted preferences
- favs map[string]bool // favorite team keys (see favKey)
+ // Standings view (TASK-016).
+ standings model.Standings
+ standingsErr error
+ standingsLeague model.LeagueID // league whose table is shown
+ standingsCursor int // selected team row (index into the flattened rows)
+
+ // Schedule view (TASK-017).
+ schedule model.TeamSchedule
+ scheduleErr error
+ scheduleScroll int
+ scheduleLeague model.LeagueID // league of the team whose schedule is shown
+ prevMode viewMode // view to return to when leaving the schedule
+
+ cfg config.Config // persisted preferences
+ favs map[string]bool // favorite team keys (see favKey)
+ theme int // active theme index into themes (see theme.go)
+ dark bool // terminal appearance — restricts theme cycling to matching set
spinner spinner.Model
phase float64 // animation phase accumulator (radians)
@@ -53,23 +91,36 @@ func New() App {
sp := spinner.New()
sp.Spinner = spinner.Dot
cfg := config.Load()
- return App{
+ dark := lipgloss.HasDarkBackground()
+ theme := resolveTheme(cfg.Theme, dark)
+ applyTheme(themes[theme])
+ a := App{
client: espn.New(),
games: map[model.LeagueID][]model.Game{},
errs: map[model.LeagueID]error{},
loaded: map[model.LeagueID]bool{},
detailData: map[string]model.GameDetail{},
+ order: leagueOrderFromCfg(cfg.LeagueOrder),
+ hide: idSet(cfg.HideLeagues),
+ show: idSet(cfg.ShowLeagues),
+ inSeason: map[model.LeagueID]bool{},
cfg: cfg,
favs: loadFavorites(cfg),
+ theme: theme,
+ dark: dark,
loading: true,
spinner: sp,
}
+ a.applyAutoLeagues() // resolve the initial visible set (all until the scan narrows)
+ return a
}
// Init kicks off the first fetch and starts the poll + animation loops.
func (a App) Init() tea.Cmd {
return tea.Batch(
- fetchAll(a.client),
+ fetchAll(a.client, a.leagues),
+ seasonScan(a.client, model.Leagues),
+ seasonScanTick(),
pollTick(),
animTick(),
a.spinner.Tick,
@@ -93,7 +144,7 @@ } else {
delete(a.errs, msg.League)
a.games[msg.League] = msg.Games
}
- if len(a.loaded) >= len(model.Leagues) {
+ if len(a.loaded) >= len(a.leagues) {
a.loading = false
}
a.clampCursor()
@@ -108,8 +159,46 @@ a.detailData[msg.ID] = msg.Data
}
return a, nil
+ case standingsMsg:
+ // Ignore a stale fetch if the user switched leagues meanwhile.
+ if msg.League != a.standingsLeague {
+ return a, nil
+ }
+ if msg.Err != nil {
+ a.standingsErr = msg.Err
+ } else {
+ a.standingsErr = nil
+ a.standings = msg.Data
+ }
+ a.clampStandingsCursor()
+ return a, nil
+
+ case scheduleMsg:
+ if msg.Err != nil {
+ a.scheduleErr = msg.Err
+ } else {
+ a.scheduleErr = nil
+ a.schedule = msg.Data
+ // Open the list at the next game rather than the season's start.
+ if _, top := a.scheduleLines(); top > 0 {
+ a.scheduleScroll = min(top, a.scheduleScrollMax())
+ }
+ }
+ return a, nil
+
+ case seasonMsg:
+ a.inSeason[msg.League] = msg.InSeason
+ if a.applyAutoLeagues() {
+ // League set changed — fetch any newly-shown leagues' games now.
+ return a, fetchAll(a.client, a.leagues)
+ }
+ return a, nil
+
+ case seasonScanMsg:
+ return a, tea.Batch(seasonScan(a.client, model.Leagues), seasonScanTick())
+
case pollMsg:
- cmds := []tea.Cmd{fetchAll(a.client), pollTick()}
+ cmds := []tea.Cmd{fetchAll(a.client, a.leagues), pollTick()}
// Refresh the open game's summary in step with the poll loop so the
// detail view stays live without its own ticker.
if a.mode == viewDetail {
@@ -135,10 +224,22 @@ return a, nil
}
func (a App) View() string {
- if a.mode == viewDetail {
- return a.detailView()
+ var frame string
+ switch a.mode {
+ case viewDetail:
+ frame = a.detailView()
+ case viewSettings:
+ frame = a.settingsView()
+ case viewStandings:
+ frame = a.standingsView()
+ case viewSchedule:
+ frame = a.scheduleView()
+ default:
+ frame = a.dashboardView()
}
- return a.dashboardView()
+ // Paint the theme background across the whole frame so a light theme on a
+ // dark terminal (or vice-versa) shows the theme's paper, not the terminal's.
+ return paintBackground(frame, a.width)
}
// pulse is the current live-indicator brightness in [0,1].
@@ -153,3 +254,13 @@ if a.cursor < 0 {
a.cursor = 0
}
}
+
+func (a *App) clampStandingsCursor() {
+ n := a.standings.RowCount()
+ if a.standingsCursor >= n {
+ a.standingsCursor = n - 1
+ }
+ if a.standingsCursor < 0 {
+ a.standingsCursor = 0
+ }
+}
internal/ui/baseball_test.go +63 −0
@@ -0,0 +1,63 @@
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+func mlbLiveGame() model.Game {
+ return model.Game{
+ ID: "g1", League: model.MLB, State: model.StateLive, Detail: "Top 7th",
+ Home: model.Team{ID: "22", Abbr: "PHI", Name: "Phillies", Score: 8},
+ Away: model.Team{ID: "28", Abbr: "MIA", Name: "Marlins", Score: 0},
+ Situation: &model.Situation{
+ Balls: 1, Strikes: 2, Outs: 2, OnFirst: true, OnThird: true,
+ Pitcher: "Jesus Luzardo", PitcherLine: "6.0 IP, 0 ER",
+ Batter: "Leo Jimenez", BatterLine: "0-2, K",
+ },
+ }
+}
+
+func TestSituationBlockShowsCountOutsPlayers(t *testing.T) {
+ out := situationBlock(mlbLiveGame(), 72)
+ for _, want := range []string{"1-2", "2 outs", "Jesus Luzardo", "Leo Jimenez"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("situation missing %q in:\n%s", want, out)
+ }
+ }
+}
+
+func TestSituationBlockNilWhenNoSituation(t *testing.T) {
+ g := mlbLiveGame()
+ g.Situation = nil
+ if out := situationBlock(g, 72); out != "" {
+ t.Errorf("expected empty, got %q", out)
+ }
+}
+
+func TestBaseballDetailShowsScoringPlaysAboveBox(t *testing.T) {
+ g := mlbLiveGame()
+ a := App{width: 100, height: 60, mode: viewDetail, detail: g,
+ games: map[model.LeagueID][]model.Game{model.MLB: {g}},
+ detailData: map[string]model.GameDetail{g.ID: {
+ Events: []model.MatchEvent{
+ {Clock: "▼1", TeamID: "22", Type: "Single", Text: "Bohm singled, Harper scored.", Scoring: true},
+ {Clock: "▼4", TeamID: "22", Type: "Home Run", Text: "Schwarber homered to right.", Scoring: true},
+ },
+ BoxScore: []model.TeamBox{{Abbr: "PHI", Name: "Phillies", Groups: []model.StatGroup{
+ {Name: "batting", Labels: []string{"AB", "H"}, Rows: []model.PlayerRow{{Athlete: "Bohm", Stats: []string{"4", "2"}}}},
+ }}},
+ }},
+ }
+ out := a.detailView()
+ si := strings.Index(out, "SCORING PLAYS")
+ bi := strings.Index(out, "BATTING")
+ if si < 0 || bi < 0 {
+ t.Fatalf("missing sections: scoring=%d batting=%d", si, bi)
+ }
+ if si > bi {
+ t.Errorf("scoring plays should render above box score")
+ }
+}
internal/ui/commands.go +84 −6
@@ -6,8 +6,8 @@ "time"
tea "github.com/charmbracelet/bubbletea"
- "github.com/kortum/pts-tui/internal/espn"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/espn"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
// --- Messages ---------------------------------------------------------------
@@ -26,6 +26,31 @@ Data model.GameDetail
Err error
}
+// standingsMsg carries a finished standings fetch for one league.
+type standingsMsg struct {
+ League model.LeagueID
+ Data model.Standings
+ Err error
+}
+
+// scheduleMsg carries a finished team-schedule fetch, keyed by team ID.
+type scheduleMsg struct {
+ TeamID string
+ Data model.TeamSchedule
+ Err error
+}
+
+// seasonMsg reports whether a league has any games within its in-season span,
+// from the periodic season scan (TASK-019).
+type seasonMsg struct {
+ League model.LeagueID
+ InSeason bool
+}
+
+// seasonScanMsg fires on the slow season-scan interval to re-probe which
+// leagues are in season (season status changes on the scale of days).
+type seasonScanMsg struct{}
+
// pollMsg fires on the data-refresh interval to trigger a new fetch round.
type pollMsg struct{}
@@ -36,6 +61,10 @@ // Intervals tuned so live scores feel fresh without hammering ESPN.
const (
pollInterval = 15 * time.Second
animInterval = 80 * time.Millisecond // ~12.5fps, plenty for terminal flair
+ // seasonScanInterval is slow: in-season status only changes day to day, so
+ // re-probing every several minutes is plenty (and cheap — one scoreboard
+ // call per catalog league).
+ seasonScanInterval = 10 * time.Minute
)
// --- Commands ---------------------------------------------------------------
@@ -67,14 +96,63 @@ return detailMsg{ID: id, Data: data, Err: err}
}
}
-// fetchAll fans out a fetch across every supported league concurrently.
+// fetchAll fans out a fetch across the given (enabled) leagues concurrently.
// Each league resolves into its own gamesMsg as it completes.
-func fetchAll(c *espn.Client) tea.Cmd {
- cmds := make([]tea.Cmd, len(model.Leagues))
- for i, l := range model.Leagues {
+func fetchAll(c *espn.Client, leagues []model.League) tea.Cmd {
+ cmds := make([]tea.Cmd, len(leagues))
+ for i, l := range leagues {
cmds[i] = fetchLeague(c, l)
}
return tea.Batch(cmds...)
+}
+
+// fetchStandings returns a command that pulls one league's standings table(s).
+func fetchStandings(c *espn.Client, l model.League) tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ s, err := c.Standings(ctx, l)
+ return standingsMsg{League: l.ID, Data: s, Err: err}
+ }
+}
+
+// fetchTeamSchedule returns a command that pulls a team's full season schedule.
+func fetchTeamSchedule(c *espn.Client, l model.League, teamID string) tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ s, err := c.TeamSchedule(ctx, l, teamID)
+ return scheduleMsg{TeamID: teamID, Data: s, Err: err}
+ }
+}
+
+// seasonScan fans out a wide scoreboard probe across all catalog leagues to
+// learn which are currently in season. Each resolves into its own seasonMsg.
+func seasonScan(c *espn.Client, leagues []model.League) tea.Cmd {
+ cmds := make([]tea.Cmd, len(leagues))
+ for i, l := range leagues {
+ cmds[i] = fetchSeason(c, l)
+ }
+ return tea.Batch(cmds...)
+}
+
+// fetchSeason reports whether a league has any games within its ±SeasonWindow,
+// the signal behind auto-hiding off-season leagues.
+func fetchSeason(c *espn.Client, l model.League) tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ d := l.SeasonWindow()
+ now := time.Now()
+ games, err := c.ScoreboardRange(ctx, l, now.AddDate(0, 0, -d), now.AddDate(0, 0, d))
+ // On error, treat as in-season so a transient failure never hides a league.
+ return seasonMsg{League: l.ID, InSeason: err != nil || len(games) > 0}
+ }
+}
+
+// seasonScanTick schedules the next season re-probe.
+func seasonScanTick() tea.Cmd {
+ return tea.Tick(seasonScanInterval, func(time.Time) tea.Msg { return seasonScanMsg{} })
}
// pollTick schedules the next data refresh.
internal/ui/components.go +1 −1
@@ -6,7 +6,7 @@ "strings"
"github.com/charmbracelet/lipgloss"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
const cardWidth = 34
internal/ui/detail.go +229 −32
@@ -2,11 +2,12 @@ package ui
import (
"fmt"
+ "strconv"
"strings"
"github.com/charmbracelet/lipgloss"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
// detailView renders a single game as a full-page live screen. It re-reads
@@ -22,26 +23,30 @@ inner := a.detailInner()
box := a.detailBox(g, l, inner)
help := styleHelp.Render(keys.Back.Help().Key + " back " +
+ "tab/enter switch " +
keys.Up.Help().Key + " scroll " +
- "f/F fav away/home " +
+ "f/F fav " +
+ "g/G sched " +
+ keys.Theme.Help().Key + " theme " +
keys.Refresh.Help().Key + " refresh " + keys.Quit.Help().Key + " quit")
extra := a.detailExtra(g, l, inner)
- // Fixed score box on top, a height-clamped scrolling body for the events /
- // box score, and a fixed help footer — the box never scrolls off.
- header := lipgloss.PlaceHorizontal(a.width, lipgloss.Center, box,
+ // Ticker strip on top, fixed score box below it, a height-clamped scrolling
+ // body for the events / box score, then a fixed help footer.
+ boxBlock := a.detailBoxBlock(box)
+ header := lipgloss.PlaceHorizontal(a.width, lipgloss.Center, boxBlock,
lipgloss.WithWhitespaceChars(" "))
helpLine := lipgloss.PlaceHorizontal(a.width, lipgloss.Center, help,
lipgloss.WithWhitespaceChars(" "))
if extra == "" {
- full := lipgloss.JoinVertical(lipgloss.Center, box, "", help)
+ full := lipgloss.JoinVertical(lipgloss.Center, boxBlock, "", help)
return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center,
full, lipgloss.WithWhitespaceChars(" "))
}
- avail := a.detailBodyAvail(box)
+ avail := a.detailBodyAvail(boxBlock)
bodyLines := detailScrollWindow(strings.Split(extra, "\n"), a.detailScroll, avail)
body := lipgloss.PlaceHorizontal(a.width, lipgloss.Center,
strings.Join(bodyLines, "\n"), lipgloss.WithWhitespaceChars(" "))
@@ -65,12 +70,23 @@ }
return inner
}
+// detailBoxBlock stacks the ticker strip (if any) above the watched game's
+// score box. This whole block is fixed above the scrolling body, so both
+// detailView and detailScrollMax measure its height the same way.
+func (a App) detailBoxBlock(box string) string {
+ ticker := a.tickerStrip()
+ if ticker == "" {
+ return box
+ }
+ return lipgloss.JoinVertical(lipgloss.Center, ticker, "", box)
+}
+
// detailBox renders the fixed score box (stage, status, matchup, big block
// score, meta).
func (a App) detailBox(g model.Game, l model.League, inner int) string {
stage := lipgloss.NewStyle().Foreground(teamColor(l.Color)).Bold(true).
Render(l.Icon + " " + l.Name)
- body := lipgloss.JoinVertical(lipgloss.Center,
+ rows := []string{
stage,
"",
a.bigStatus(g),
@@ -78,9 +94,14 @@ "",
a.matchupLine(g, inner),
"",
scoreSection(g),
- "",
- metaBlock(g, inner),
- )
+ }
+ if sit := situationBlock(g, inner); sit != "" {
+ rows = append(rows, "", sit)
+ }
+ if meta := metaBlock(g, inner); meta != "" {
+ rows = append(rows, "", meta)
+ }
+ body := lipgloss.JoinVertical(lipgloss.Center, rows...)
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colAccent).
@@ -172,7 +193,8 @@ extra := a.detailExtra(g, l, inner)
if extra == "" {
return 0
}
- m := strings.Count(extra, "\n") + 1 - a.detailBodyAvail(a.detailBox(g, l, inner))
+ m := strings.Count(extra, "\n") + 1 -
+ a.detailBodyAvail(a.detailBoxBlock(a.detailBox(g, l, inner)))
if m < 0 {
m = 0
}
@@ -192,9 +214,19 @@ if !ok {
return styleSubtle.Render("loading game detail…")
}
if l.Sport == "soccer" {
- return eventsBlock(g, d.Events, w)
+ events := eventsBlock(g, d.Events, "Key Events", w)
+ if stats := teamStatsBlock(g, d.TeamStats, w); stats != "" {
+ return stats + "\n\n" + events
+ }
+ return events
+ }
+ box := boxScoreBlock(d.BoxScore, max(w, min(a.width-6, 108)))
+ // Baseball: a scoring-plays timeline sits above the box score.
+ if l.Sport == "baseball" && len(d.Events) > 0 {
+ plays := eventsBlock(g, d.Events, "Scoring Plays", w)
+ return plays + "\n\n" + box
}
- return boxScoreBlock(d.BoxScore, max(w, min(a.width-6, 108)))
+ return box
}
// scrollWindow is shared with the dashboard; for detail we pass detailScroll as
@@ -231,10 +263,15 @@ clock := g.Clock
if clock == "" || clock == "0:00" {
clock = g.Detail
}
- return liveDot(a.pulse()) + " " +
+ out := liveDot(a.pulse()) + " " +
lipgloss.NewStyle().Foreground(colLive).Bold(true).Render("LIVE") +
- " " + lipgloss.NewStyle().Foreground(colGoal).Bold(true).Render(clock) +
- " " + styleSubtle.Render(g.Detail)
+ " " + lipgloss.NewStyle().Foreground(colGoal).Bold(true).Render(clock)
+ // Detail often repeats the clock for baseball ("Top 7th"); only append
+ // when it adds something.
+ if g.Detail != "" && g.Detail != clock {
+ out += " " + styleSubtle.Render(g.Detail)
+ }
+ return out
case model.StateFinal:
return lipgloss.NewStyle().Foreground(colMuted).Bold(true).
Render("◼ FINAL " + g.Detail)
@@ -243,14 +280,15 @@ return styleSubtle.Render("○ " + startLabel(g) + " " + g.Detail)
}
}
-// eventsBlock renders the soccer key-event timeline mirrored by team: home
-// events align left, away events align right, around a center gutter — so the
-// scoring side reads at a glance (Golazo-style). Minute-stamped, team-colored.
-func eventsBlock(g model.Game, events []model.MatchEvent, w int) string {
+// eventsBlock renders the soccer key-event timeline mirrored by team: away
+// events align left, home events align right, around a center gutter — matching
+// the away-left/home-right layout of the header and score box, so the scoring
+// side reads at a glance (Golazo-style). Minute-stamped, team-colored.
+func eventsBlock(g model.Game, events []model.MatchEvent, title string, w int) string {
if len(events) == 0 {
- return ruleHeader("Key Events", w) + "\n\n" + styleSubtle.Render("no key events yet")
+ return ruleHeader(title, w) + "\n\n" + styleSubtle.Render("nothing yet")
}
- lines := []string{ruleHeader("Key Events", w), ""}
+ lines := []string{ruleHeader(title, w), ""}
// While live, stream newest-first so the latest moment sits on top; a
// finished game reads chronologically as a recap.
ordered := events
@@ -262,14 +300,14 @@ }
}
half := (w - 3) / 2
for _, e := range ordered {
- home := eventSide(g, e)
+ side := eventSide(g, e)
cell := eventCell(g, e, half)
var line string
- switch home {
- case sideHome:
+ switch side {
+ case sideAway:
line = lipgloss.NewStyle().Width(half).Align(lipgloss.Left).Render(cell) +
styleFaint.Render(" │ ")
- case sideAway:
+ case sideHome:
line = lipgloss.NewStyle().Width(half).Render("") +
styleFaint.Render(" │ ") +
lipgloss.NewStyle().Width(half).Align(lipgloss.Right).Render(cell)
@@ -289,8 +327,17 @@ sideHome
sideAway
)
-// eventSide classifies which team an event belongs to.
+// eventSide classifies which team an event belongs to, preferring the team ID
+// (baseball plays carry only an id) and falling back to the display name.
func eventSide(g model.Game, e model.MatchEvent) eventSideT {
+ if e.TeamID != "" {
+ switch e.TeamID {
+ case g.Home.ID:
+ return sideHome
+ case g.Away.ID:
+ return sideAway
+ }
+ }
switch e.Team {
case g.Home.FullName, g.Home.Name:
return sideHome
@@ -322,10 +369,10 @@
// eventColor picks the colour of the team the event belongs to (matched by
// display name), falling back to muted text.
func eventColor(g model.Game, e model.MatchEvent) lipgloss.TerminalColor {
- switch e.Team {
- case g.Home.FullName, g.Home.Name:
+ switch eventSide(g, e) {
+ case sideHome:
return teamColor(g.Home.Color)
- case g.Away.FullName, g.Away.Name:
+ case sideAway:
return teamColor(g.Away.Color)
}
return colText
@@ -335,7 +382,9 @@ // eventIcon maps an event type to a glyph.
func eventIcon(e model.MatchEvent) string {
t := strings.ToLower(e.Type)
switch {
- case e.Scoring || strings.Contains(t, "goal"):
+ case strings.Contains(t, "home run"):
+ return "💣"
+ case strings.Contains(t, "goal"):
return "⚽"
case strings.Contains(t, "yellow"):
return lipgloss.NewStyle().Foreground(colWarn).Render("▌")
@@ -345,11 +394,62 @@ case strings.Contains(t, "substitut"):
return lipgloss.NewStyle().Foreground(colMuted).Render("⇄")
case strings.Contains(t, "penalty"):
return "◎"
+ case e.Scoring: // generic run-scoring play (single, double, sac fly…)
+ return "⚾"
default:
return styleFaint.Render("·")
}
}
+// teamStatsBlock renders the soccer team comparison stats as split bars: each
+// row is the away value, a proportional away|home shaded bar in team colors,
+// and the home value, under a centered label. Returns "" when there are none.
+func teamStatsBlock(g model.Game, stats []model.TeamStat, w int) string {
+ if len(stats) == 0 {
+ return ""
+ }
+ awayC := teamColor(g.Away.Color)
+ homeC := teamColor(g.Home.Color)
+ barW := w - 16 // leave room for the two value columns + spacing
+ if barW < 10 {
+ barW = 10
+ }
+
+ lines := []string{ruleHeader("Team Stats", w), ""}
+ for _, s := range stats {
+ av, hv := parseStat(s.Away), parseStat(s.Home)
+ awayLen := 0
+ if total := av + hv; total > 0 {
+ awayLen = int(float64(barW)*av/total + 0.5)
+ }
+ homeLen := barW - awayLen
+
+ var bar string
+ if av+hv == 0 {
+ bar = styleFaint.Render(strings.Repeat("░", barW))
+ } else {
+ bar = lipgloss.NewStyle().Foreground(awayC).Render(strings.Repeat("▓", awayLen)) +
+ lipgloss.NewStyle().Foreground(homeC).Render(strings.Repeat("▓", homeLen))
+ }
+ left := lipgloss.NewStyle().Foreground(awayC).Bold(true).Render(fmt.Sprintf("%5s", s.Away))
+ right := lipgloss.NewStyle().Foreground(homeC).Bold(true).Render(fmt.Sprintf("%-5s", s.Home))
+ row := left + " " + bar + " " + right
+ label := styleSubtle.Render(s.Label)
+ labelLine := lipgloss.PlaceHorizontal(lipgloss.Width(row), lipgloss.Center, label,
+ lipgloss.WithWhitespaceChars(" "))
+ lines = append(lines, labelLine, row)
+ }
+ return strings.Join(lines, "\n")
+}
+
+// parseStat reads an ESPN stat display value as a float, tolerating a trailing
+// percent sign; non-numeric values become 0.
+func parseStat(s string) float64 {
+ s = strings.TrimSuffix(strings.TrimSpace(s), "%")
+ f, _ := strconv.ParseFloat(s, 64)
+ return f
+}
+
// boxScoreBlock renders each team's player stat tables for the stick-and-ball
// sports. Columns that overflow the width are dropped from the right.
func boxScoreBlock(teams []model.TeamBox, w int) string {
@@ -434,6 +534,103 @@ }
lines = append(lines, row)
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
+}
+
+// situationBlock renders the live baseball play state inside the score box: the
+// count and outs, a bases diamond, and the current pitcher / batter with their
+// stat lines. Returns "" when there's no situation (not live baseball).
+func situationBlock(g model.Game, w int) string {
+ s := g.Situation
+ if s == nil {
+ return ""
+ }
+
+ count := lipgloss.NewStyle().Foreground(colAccent2).Bold(true).
+ Render(fmt.Sprintf("%d-%d", s.Balls, s.Strikes))
+ top := count + styleFaint.Render(" ") + outsDots(s.Outs)
+
+ diamond := basesDiamond(s)
+
+ pb := []string{}
+ if s.Pitcher != "" {
+ pb = append(pb, playerLine("P", s.Pitcher, s.PitcherLine, w))
+ }
+ if s.Batter != "" {
+ pb = append(pb, playerLine("AB", s.Batter, s.BatterLine, w))
+ }
+
+ rows := []string{top, "", diamond}
+ if len(pb) > 0 {
+ rows = append(rows, "")
+ rows = append(rows, pb...)
+ }
+ block := lipgloss.JoinVertical(lipgloss.Center, rows...)
+ return lipgloss.NewStyle().Width(w - 6).Align(lipgloss.Center).Render(block)
+}
+
+// outsDots renders outs as filled/empty pips, e.g. "● ● ○ 2 outs".
+func outsDots(outs int) string {
+ if outs < 0 {
+ outs = 0
+ }
+ if outs > 3 {
+ outs = 3
+ }
+ dots := ""
+ for i := 0; i < 3; i++ {
+ if i < outs {
+ dots += lipgloss.NewStyle().Foreground(colGoal).Render("●")
+ } else {
+ dots += styleFaint.Render("○")
+ }
+ if i < 2 {
+ dots += " "
+ }
+ }
+ label := "outs"
+ if outs == 1 {
+ label = "out"
+ }
+ return dots + styleSubtle.Render(fmt.Sprintf(" %d %s", outs, label))
+}
+
+// basesDiamond draws the three bases as a small diamond (second on top, third
+// left, first right), occupied bases popped on the goal accent. Both rows are
+// the same width so second base centers over the gap between third and first.
+func basesDiamond(s *model.Situation) string {
+ base := func(on bool) string {
+ if on {
+ return lipgloss.NewStyle().Foreground(colGoal).Bold(true).Render("◆")
+ }
+ return styleFaint.Render("◇")
+ }
+ top := " " + base(s.OnSecond) + " " // 2nd at center (col 2)
+ bottom := base(s.OnThird) + " " + base(s.OnFirst) // 3rd col 0, 1st col 4
+ return lipgloss.JoinVertical(lipgloss.Center, top, bottom)
+}
+
+// playerLine renders one "P / AB" situation line: a role tag, the name, and the
+// ESPN stat summary, clamped to width. Truncates the raw text (not the styled
+// string) so ANSI codes stay intact.
+func playerLine(role, name, stat string, w int) string {
+ avail := max(w-6-len(role)-1, 1)
+ if stat != "" {
+ statPart := " " + stat
+ if nameAvail := avail - lipgloss.Width(statPart); nameAvail >= 6 {
+ name = truncate(name, nameAvail)
+ } else {
+ stat = "" // no room — drop the stat line, keep the name
+ name = truncate(name, avail)
+ }
+ } else {
+ name = truncate(name, avail)
+ }
+ out := lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render(role) + " " +
+ lipgloss.NewStyle().Foreground(colText).Render(name)
+ if stat != "" {
+ out += styleFaint.Render(" " + stat)
+ }
+ return out
}
func metaBlock(g model.Game, w int) string {
internal/ui/favorites.go +2 −2
@@ -1,8 +1,8 @@
package ui
import (
- "github.com/kortum/pts-tui/internal/config"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/config"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
// favKey is the stable identity of a favorited team: league + ESPN team id,
internal/ui/favorites_test.go +2 −2
@@ -4,8 +4,8 @@ import (
"testing"
"time"
- "github.com/kortum/pts-tui/internal/config"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/config"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
func TestFavKeyUsesIDThenAbbr(t *testing.T) {
internal/ui/keys.go +76 −11
@@ -5,17 +5,30 @@
// keyMap is the global keybinding set, shared across views. Help text is
// derived from these so the footer never drifts from behavior.
type keyMap struct {
- Up key.Binding
- Down key.Binding
- Enter key.Binding
- Back key.Binding
- NextLg key.Binding
- PrevLg key.Binding
- AllLg key.Binding
- Refresh key.Binding
- FavAway key.Binding
- FavHome key.Binding
- Quit key.Binding
+ Up key.Binding
+ Down key.Binding
+ GridLeft key.Binding
+ GridRight key.Binding
+ Enter key.Binding
+ Back key.Binding
+ NextLg key.Binding
+ PrevLg key.Binding
+ AllLg key.Binding
+ Refresh key.Binding
+ FavAway key.Binding
+ FavHome key.Binding
+ Theme key.Binding
+ State key.Binding
+ StateBack key.Binding
+ Leagues key.Binding
+ Toggle key.Binding
+ MoveUp key.Binding
+ MoveDown key.Binding
+ Standings key.Binding
+ Schedule key.Binding
+ ScheduleHome key.Binding
+ ResetLeagues key.Binding
+ Quit key.Binding
}
var keys = keyMap{
@@ -26,6 +39,14 @@ ),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
+ ),
+ GridLeft: key.NewBinding(
+ key.WithKeys("left"),
+ key.WithHelp("←/→", "move"),
+ ),
+ GridRight: key.NewBinding(
+ key.WithKeys("right"),
+ key.WithHelp("→", "right"),
),
Enter: key.NewBinding(
key.WithKeys("enter", "l"),
@@ -58,6 +79,50 @@ ),
FavHome: key.NewBinding(
key.WithKeys("F"),
key.WithHelp("F", "fav home"),
+ ),
+ Theme: key.NewBinding(
+ key.WithKeys("t"),
+ key.WithHelp("t", "theme"),
+ ),
+ State: key.NewBinding(
+ key.WithKeys("v"),
+ key.WithHelp("v/V", "filter"),
+ ),
+ StateBack: key.NewBinding(
+ key.WithKeys("V"),
+ key.WithHelp("V", "filter back"),
+ ),
+ Leagues: key.NewBinding(
+ key.WithKeys("L"),
+ key.WithHelp("L", "leagues"),
+ ),
+ Toggle: key.NewBinding(
+ key.WithKeys(" ", "space", "x", "enter"),
+ key.WithHelp("space", "toggle"),
+ ),
+ MoveUp: key.NewBinding(
+ key.WithKeys("shift+up", "K"),
+ key.WithHelp("K", "move up"),
+ ),
+ MoveDown: key.NewBinding(
+ key.WithKeys("shift+down", "J"),
+ key.WithHelp("J", "move down"),
+ ),
+ Standings: key.NewBinding(
+ key.WithKeys("s", "S"),
+ key.WithHelp("s", "standings"),
+ ),
+ Schedule: key.NewBinding(
+ key.WithKeys("g"),
+ key.WithHelp("g", "sched away"),
+ ),
+ ScheduleHome: key.NewBinding(
+ key.WithKeys("G"),
+ key.WithHelp("G", "sched home"),
+ ),
+ ResetLeagues: key.NewBinding(
+ key.WithKeys("0"),
+ key.WithHelp("0", "auto"),
),
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
internal/ui/leagues.go +248 −0
@@ -0,0 +1,248 @@
+package ui
+
+import (
+ "github.com/humdrum-tiv/sportsball/internal/config"
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// League visibility (TASK-002 / TASK-019) is a season-driven default with
+// per-league overrides:
+//
+// visible(l) = (inSeason(l) OR forceShow(l)) AND NOT forceHide(l)
+//
+// `order` is the user's display arrangement of the catalog; `hide`/`show` are
+// the explicit overrides. `App.leagues` is the resolved, ordered visible set
+// that the rest of the app reads. Toggling a league back to what the season
+// would do clears its override, so a league only stays pinned when the user
+// genuinely deviates from the seasonal default.
+
+// idSet builds a membership set from a slice of league-ID strings.
+func idSet(ids []string) map[model.LeagueID]bool {
+ m := make(map[model.LeagueID]bool, len(ids))
+ for _, id := range ids {
+ m[model.LeagueID(id)] = true
+ }
+ return m
+}
+
+// leagueOrderFromCfg parses the persisted order into known league IDs, dropping
+// unknown/duplicate entries. Empty → nil (meaning catalog order).
+func leagueOrderFromCfg(ids []string) []model.LeagueID {
+ if len(ids) == 0 {
+ return nil
+ }
+ seen := map[model.LeagueID]bool{}
+ var out []model.LeagueID
+ for _, s := range ids {
+ id := model.LeagueID(s)
+ if _, ok := model.LeagueByID(id); ok && !seen[id] {
+ out = append(out, id)
+ seen[id] = true
+ }
+ }
+ return out
+}
+
+// orderedCatalog returns every catalog league in the user's display order:
+// `order` first (known IDs), then any leagues it omits in catalog order.
+func (a App) orderedCatalog() []model.League {
+ if len(a.order) == 0 {
+ return append([]model.League(nil), model.Leagues...)
+ }
+ seen := map[model.LeagueID]bool{}
+ var out []model.League
+ for _, id := range a.order {
+ if l, ok := model.LeagueByID(id); ok && !seen[id] {
+ out = append(out, l)
+ seen[id] = true
+ }
+ }
+ for _, l := range model.Leagues {
+ if !seen[l.ID] {
+ out = append(out, l)
+ }
+ }
+ return out
+}
+
+// inSeasonOrUnprobed reports a league as in-season until the scan proves
+// otherwise, so startup shows everything and only narrows once confirmed empty.
+func (a App) inSeasonOrUnprobed(id model.LeagueID) bool {
+ in, probed := a.inSeason[id]
+ return in || !probed
+}
+
+// leagueVisible applies the override-over-season rule for one league.
+func (a App) leagueVisible(id model.LeagueID) bool {
+ switch {
+ case a.hide[id]:
+ return false
+ case a.show[id]:
+ return true
+ default:
+ return a.inSeasonOrUnprobed(id)
+ }
+}
+
+// applyAutoLeagues recomputes the effective visible league set (ordered),
+// returning true if it changed. Falls back so the app is never blank: if the
+// overrides hide everything, ignore force-hide; if still empty (all off-season),
+// show the whole catalog. If the active filter falls out, it resets to all.
+func (a *App) applyAutoLeagues() bool {
+ catalog := a.orderedCatalog()
+ var next []model.League
+ for _, l := range catalog {
+ if a.leagueVisible(l.ID) {
+ next = append(next, l)
+ }
+ }
+ if len(next) == 0 {
+ for _, l := range catalog {
+ if a.inSeasonOrUnprobed(l.ID) {
+ next = append(next, l)
+ }
+ }
+ }
+ if len(next) == 0 {
+ next = catalog
+ }
+ if sameLeagues(a.leagues, next) {
+ return false
+ }
+ a.leagues = next
+ if a.filter != "" {
+ if _, ok := a.leagueIndex()[a.filter]; !ok {
+ a.filter = ""
+ a.cursor = 0
+ }
+ }
+ return true
+}
+
+// sameLeagues reports whether two league slices have the same IDs in order.
+func sameLeagues(a, b []model.League) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i].ID != b[i].ID {
+ return false
+ }
+ }
+ return true
+}
+
+// toggleLeagueVisibility flips a league's effective visibility via overrides.
+// If the desired state matches what the season alone would do, the override is
+// cleared (back to auto); otherwise the matching force-hide/force-show is set.
+func (a *App) toggleLeagueVisibility(id model.LeagueID) {
+ auto := a.inSeasonOrUnprobed(id)
+ desired := !a.leagueVisible(id)
+ delete(a.hide, id)
+ delete(a.show, id)
+ if desired == auto {
+ return // back to seasonal default — no override needed
+ }
+ if desired {
+ a.show[id] = true
+ } else {
+ a.hide[id] = true
+ }
+}
+
+// moveLeague swaps the visible league at index i with its visible neighbor in
+// dir (-1 up / +1 down), reflecting the change in the persisted order. No-op at
+// the ends.
+func (a *App) moveLeague(i, dir int) {
+ j := i + dir
+ if i < 0 || i >= len(a.leagues) || j < 0 || j >= len(a.leagues) {
+ return
+ }
+ // Establish a concrete order to mutate (catalog order until customized).
+ if len(a.order) == 0 {
+ for _, l := range a.orderedCatalog() {
+ a.order = append(a.order, l.ID)
+ }
+ }
+ p1 := indexOfID(a.order, a.leagues[i].ID)
+ p2 := indexOfID(a.order, a.leagues[j].ID)
+ if p1 >= 0 && p2 >= 0 {
+ a.order[p1], a.order[p2] = a.order[p2], a.order[p1]
+ }
+}
+
+func indexOfID(ids []model.LeagueID, id model.LeagueID) int {
+ for i, x := range ids {
+ if x == id {
+ return i
+ }
+ }
+ return -1
+}
+
+// leagueIDs returns the visible leagues' IDs in display order.
+func (a App) leagueIDs() []model.LeagueID {
+ ids := make([]model.LeagueID, len(a.leagues))
+ for i, l := range a.leagues {
+ ids[i] = l.ID
+ }
+ return ids
+}
+
+// leagueIndex maps each visible league to its display position, a stable
+// tiebreak when ordering games across leagues.
+func (a App) leagueIndex() map[model.LeagueID]int {
+ m := make(map[model.LeagueID]int, len(a.leagues))
+ for i, l := range a.leagues {
+ m[l.ID] = i
+ }
+ return m
+}
+
+// settingsRows is the settings list: visible leagues first (in display order,
+// reorderable), then the hidden ones. enabled[i] reports row i's visibility.
+func (a App) settingsRows() (rows []model.League, enabled []bool) {
+ visible := map[model.LeagueID]bool{}
+ for _, l := range a.leagues {
+ rows = append(rows, l)
+ enabled = append(enabled, true)
+ visible[l.ID] = true
+ }
+ for _, l := range a.orderedCatalog() {
+ if !visible[l.ID] {
+ rows = append(rows, l)
+ enabled = append(enabled, false)
+ }
+ }
+ return rows, enabled
+}
+
+// persistLeagues writes the current order + overrides to config (best-effort).
+func (a *App) persistLeagues() {
+ a.cfg.LeagueOrder = idsToStrings(a.order)
+ a.cfg.HideLeagues = setToStrings(a.hide)
+ a.cfg.ShowLeagues = setToStrings(a.show)
+ _ = config.Save(a.cfg)
+}
+
+func idsToStrings(ids []model.LeagueID) []string {
+ if len(ids) == 0 {
+ return nil
+ }
+ out := make([]string, len(ids))
+ for i, id := range ids {
+ out[i] = string(id)
+ }
+ return out
+}
+
+// setToStrings serializes an override set in catalog order for stable output.
+func setToStrings(set map[model.LeagueID]bool) []string {
+ var out []string
+ for _, l := range model.Leagues {
+ if set[l.ID] {
+ out = append(out, string(l.ID))
+ }
+ }
+ return out
+}
internal/ui/leagues_test.go +158 −0
@@ -0,0 +1,158 @@
+package ui
+
+import (
+ "testing"
+ "time"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// newLeagueApp builds an App with the given order + in-season map and resolves
+// the visible set, mirroring what New() does.
+func newLeagueApp(order []model.LeagueID, inSeason map[model.LeagueID]bool) App {
+ a := App{
+ order: order,
+ hide: map[model.LeagueID]bool{},
+ show: map[model.LeagueID]bool{},
+ inSeason: inSeason,
+ }
+ a.applyAutoLeagues()
+ return a
+}
+
+// No custom order → catalog order, every league.
+func TestOrderedCatalogDefault(t *testing.T) {
+ a := App{}
+ got := a.orderedCatalog()
+ if len(got) != len(model.Leagues) {
+ t.Fatalf("got %d, want %d", len(got), len(model.Leagues))
+ }
+ for i, l := range model.Leagues {
+ if got[i].ID != l.ID {
+ t.Errorf("at %d got %s want %s", i, got[i].ID, l.ID)
+ }
+ }
+}
+
+// A custom order lists those first, then the rest in catalog order.
+func TestOrderedCatalogCustom(t *testing.T) {
+ a := App{order: []model.LeagueID{model.NBA, model.WorldCup}}
+ got := a.orderedCatalog()
+ if got[0].ID != model.NBA || got[1].ID != model.WorldCup {
+ t.Fatalf("custom order not honored: %s, %s", got[0].ID, got[1].ID)
+ }
+ if len(got) != len(model.Leagues) {
+ t.Errorf("should still contain all leagues, got %d", len(got))
+ }
+}
+
+// applyAutoLeagues keeps only visible (in-season, no override) leagues in order;
+// unprobed leagues count as in-season so startup shows everything.
+func TestApplyAutoLeagues(t *testing.T) {
+ a := newLeagueApp(nil, map[model.LeagueID]bool{
+ model.WorldCup: true, model.MLB: true, model.WNBA: true,
+ model.NBA: false, model.NHL: false, model.NFL: false,
+ })
+ got := a.leagueIDs()
+ want := []model.LeagueID{model.WorldCup, model.MLB, model.WNBA}
+ if len(got) != len(want) {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("at %d got %s want %s", i, got[i], want[i])
+ }
+ }
+}
+
+// Everything off-season must never blank the app — fall back to the full catalog.
+func TestApplyAutoLeaguesNeverBlank(t *testing.T) {
+ in := map[model.LeagueID]bool{}
+ for _, l := range model.Leagues {
+ in[l.ID] = false
+ }
+ a := newLeagueApp(nil, in)
+ if len(a.leagues) != len(model.Leagues) {
+ t.Errorf("all-off-season should fall back to all leagues, got %d", len(a.leagues))
+ }
+}
+
+// Force-show reveals an off-season league; force-hide hides an in-season one,
+// and these overrides beat the season default.
+func TestOverridesBeatSeason(t *testing.T) {
+ // NFL off-season, force-shown → visible.
+ a := newLeagueApp(nil, map[model.LeagueID]bool{model.NFL: false})
+ a.show[model.NFL] = true
+ a.applyAutoLeagues()
+ if _, ok := a.leagueIndex()[model.NFL]; !ok {
+ t.Error("force-shown off-season league should be visible")
+ }
+ // MLB in-season, force-hidden → gone.
+ a.hide[model.MLB] = true
+ a.inSeason[model.MLB] = true
+ a.applyAutoLeagues()
+ if _, ok := a.leagueIndex()[model.MLB]; ok {
+ t.Error("force-hidden in-season league should be hidden")
+ }
+}
+
+// Toggling a league back to what the season would do clears its override
+// (returns to auto) rather than pinning the opposite state.
+func TestToggleClearsBackToAuto(t *testing.T) {
+ a := App{
+ hide: map[model.LeagueID]bool{},
+ show: map[model.LeagueID]bool{},
+ inSeason: map[model.LeagueID]bool{model.MLB: true, model.NFL: false},
+ }
+ // In-season MLB: hide it (override), then toggle back → override cleared.
+ a.toggleLeagueVisibility(model.MLB)
+ if !a.hide[model.MLB] {
+ t.Fatal("hiding an in-season league should set force-hide")
+ }
+ a.toggleLeagueVisibility(model.MLB)
+ if a.hide[model.MLB] || a.show[model.MLB] {
+ t.Error("toggling back to auto should clear the override")
+ }
+ // Off-season NFL: show it (override), then toggle back → cleared.
+ a.toggleLeagueVisibility(model.NFL)
+ if !a.show[model.NFL] {
+ t.Fatal("showing an off-season league should set force-show")
+ }
+ a.toggleLeagueVisibility(model.NFL)
+ if a.hide[model.NFL] || a.show[model.NFL] {
+ t.Error("toggling back to auto should clear the override")
+ }
+}
+
+// moveLeague reorders within the visible set and clamps at the ends.
+func TestMoveLeague(t *testing.T) {
+ a := newLeagueApp(nil, map[model.LeagueID]bool{}) // all unprobed → all visible
+ // catalog: WorldCup, MLB, NBA, ... ; move NBA (idx 2) up to idx 1.
+ a.moveLeague(2, -1)
+ a.applyAutoLeagues()
+ if got := a.leagueIDs(); got[1] != model.NBA {
+ t.Errorf("nba should move to index 1, order = %v", got)
+ }
+ a.moveLeague(0, -1) // already top: no-op
+ a.applyAutoLeagues()
+ if got := a.leagueIDs(); got[0] != model.WorldCup {
+ t.Errorf("top move should be a no-op, order = %v", got)
+ }
+}
+
+// Custom league order drives the recent state-filter ordering: within a shared
+// date, league order follows the user's arrangement.
+func TestStateFilterRespectsCustomLeagueOrder(t *testing.T) {
+ now := time.Now()
+ a := newLeagueApp([]model.LeagueID{model.NBA, model.WorldCup},
+ map[model.LeagueID]bool{model.NBA: true, model.WorldCup: true})
+ a.stateFilter = filterRecent
+ a.games = map[model.LeagueID][]model.Game{
+ model.WorldCup: {{ID: "wc", League: model.WorldCup, State: model.StateFinal, Start: now}},
+ model.NBA: {{ID: "nba", League: model.NBA, State: model.StateFinal, Start: now}},
+ }
+ ids := sectionIDs(a.sections())
+ if len(ids) != 2 || ids[0] != "nba" || ids[1] != "wc" {
+ t.Errorf("custom order not honored: %v", ids)
+ }
+}
internal/ui/nav.go +56 −0
@@ -0,0 +1,56 @@
+package ui
+
+// gridRows reconstructs the dashboard's visual grid as rows of global cursor
+// indices, walking sections exactly as bodyLines renders them (one sub-grid of
+// a.columns() per section). This is what makes Up/Down move to the card the
+// user actually sees above/below, rather than ±1 through the flat list.
+func (a App) gridRows() [][]int {
+ cols := a.columns()
+ var rows [][]int
+ idx := 0
+ for _, s := range a.sections() {
+ for i := 0; i < len(s.games); i += cols {
+ var row []int
+ for j := i; j < i+cols && j < len(s.games); j++ {
+ row = append(row, idx+j)
+ }
+ rows = append(rows, row)
+ }
+ idx += len(s.games)
+ }
+ return rows
+}
+
+// cursorRC locates cursor within rows as (row, col); (0,0) if not found.
+func cursorRC(rows [][]int, cursor int) (r, c int) {
+ for ri, row := range rows {
+ for ci, gi := range row {
+ if gi == cursor {
+ return ri, ci
+ }
+ }
+ }
+ return 0, 0
+}
+
+// moveCursorVert moves the cursor up/down one grid row, keeping the column (or
+// the nearest column when the target row is shorter, e.g. a partial last row or
+// a different section width).
+func (a *App) moveCursorVert(d int) {
+ rows := a.gridRows()
+ if len(rows) == 0 {
+ return
+ }
+ r, c := cursorRC(rows, a.cursor)
+ r2 := r + d
+ if r2 < 0 {
+ r2 = 0
+ }
+ if r2 >= len(rows) {
+ r2 = len(rows) - 1
+ }
+ if c >= len(rows[r2]) {
+ c = len(rows[r2]) - 1
+ }
+ a.cursor = rows[r2][c]
+}
internal/ui/nav_test.go +39 −0
@@ -0,0 +1,39 @@
+package ui
+
+import (
+ "testing"
+ "time"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// Up/Down must move by visual grid row (keeping column), not ±1 through the
+// flat list. With 3 cards in a 2-wide grid the rows are [[0,1],[2]].
+func TestMoveCursorVertical(t *testing.T) {
+ now := time.Now()
+ a := App{
+ width: 80, // columns() => 80/(cardWidth+3) = 2
+ leagues: model.Leagues,
+ filter: model.MLB,
+ games: map[model.LeagueID][]model.Game{
+ model.MLB: {
+ {ID: "g0", League: model.MLB, State: model.StatePre, Start: now},
+ {ID: "g1", League: model.MLB, State: model.StatePre, Start: now},
+ {ID: "g2", League: model.MLB, State: model.StatePre, Start: now},
+ },
+ },
+ }
+ if c := a.columns(); c != 2 {
+ t.Fatalf("expected 2 columns at width 80, got %d", c)
+ }
+
+ a.cursor = 1 // top-right (row0,col1)
+ a.moveCursorVert(1)
+ if a.cursor != 2 { // down → row1 only has col0, clamps to g2
+ t.Errorf("down from 1 = %d, want 2", a.cursor)
+ }
+ a.moveCursorVert(-1) // back up → row0 col0
+ if a.cursor != 0 {
+ t.Errorf("up from 2 = %d, want 0", a.cursor)
+ }
+}
internal/ui/schedule.go +141 −0
@@ -0,0 +1,141 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// scheduleView renders one team's full season schedule: a scrollable list of
+// past results and upcoming fixtures, with an "upcoming" divider between them.
+func (a App) scheduleView() string {
+ t := a.schedule.Team
+ l, _ := model.LeagueByID(a.scheduleLeague)
+ name := t.FullName
+ if name == "" {
+ name = t.Name
+ }
+ title := styleTitle.Render(" SCHEDULE ") + " " +
+ lipgloss.NewStyle().Foreground(teamColor(t.Color)).Bold(true).Render(l.Icon+" "+name)
+ if a.schedule.Season != "" {
+ title += " " + styleSubtle.Render(a.schedule.Season)
+ }
+ footer := styleHelp.Render(strings.Join([]string{
+ keys.Up.Help().Key + "/" + keys.Down.Help().Key + " scroll",
+ keys.Refresh.Help().Key + " refresh",
+ keys.Back.Help().Key + " back",
+ }, styleFaint.Render(" · ")))
+
+ frame := func(body string) string {
+ return styleApp.Render(strings.Join([]string{title, "", body, "", footer}, "\n"))
+ }
+
+ switch {
+ case a.scheduleErr != nil:
+ return frame(styleSubtle.Render("schedule unavailable — " + a.scheduleErr.Error()))
+ case len(a.schedule.Games) == 0:
+ return frame(styleSubtle.Render(a.spinner.View() + " loading schedule…"))
+ }
+
+ lines, _ := a.scheduleLines()
+ avail := a.scheduleAvail()
+ off := a.scheduleScroll
+ if off > len(lines)-avail {
+ off = len(lines) - avail
+ }
+ if off < 0 {
+ off = 0
+ }
+ end := off + avail
+ if end > len(lines) {
+ end = len(lines)
+ }
+ return frame(strings.Join(lines[off:end], "\n"))
+}
+
+func (a App) scheduleAvail() int {
+ avail := a.height - 5 // title, blank, blank, footer (+1 slack)
+ if avail < 3 {
+ avail = 3
+ }
+ return avail
+}
+
+func (a App) scheduleScrollMax() int {
+ lines, _ := a.scheduleLines()
+ if m := len(lines) - a.scheduleAvail(); m > 0 {
+ return m
+ }
+ return 0
+}
+
+// scheduleLines renders each game to a line and reports the line index of the
+// first upcoming game, so the view can open at "now" rather than the top.
+func (a App) scheduleLines() (lines []string, upcomingTop int) {
+ t := a.schedule.Team
+ dividerDone := false
+ for _, g := range a.schedule.Games {
+ if !dividerDone && g.State == model.StatePre {
+ upcomingTop = len(lines)
+ lines = append(lines, styleFaint.Render(" ── upcoming ──"))
+ dividerDone = true
+ }
+ lines = append(lines, a.scheduleRow(g, t))
+ }
+ return lines, upcomingTop
+}
+
+// scheduleRow formats one game from the perspective of team t: date, home/away
+// marker, opponent, and the result (W/L + score for finals, time for upcoming).
+func (a App) scheduleRow(g model.Game, t model.Team) string {
+ home := g.Home.ID == t.ID
+ opp := g.Home
+ if home {
+ opp = g.Away
+ }
+
+ date := styleSubtle.Render(fmt.Sprintf("%-10s", g.Start.Format("Mon Jan 2")))
+ loc := "vs"
+ if !home {
+ loc = "@ "
+ }
+ oppAbbr := lipgloss.NewStyle().Foreground(teamColor(opp.Color)).Bold(true).
+ Render(fmt.Sprintf("%-3s", opp.Abbr))
+ oppName := truncate(opp.Name, 16)
+ oppName += strings.Repeat(" ", max(0, 16-lipgloss.Width(oppName)))
+
+ var result string
+ switch g.State {
+ case model.StateLive:
+ us, them := teamScores(g, home)
+ result = lipgloss.NewStyle().Foreground(colLive).Bold(true).
+ Render(fmt.Sprintf("LIVE %d-%d", us, them))
+ case model.StateFinal:
+ us, them := teamScores(g, home)
+ won := (home && g.Home.Winner) || (!home && g.Away.Winner)
+ mark, style := "L", styleFinal
+ switch {
+ case won:
+ mark, style = "W", styleWin
+ case us == them:
+ mark = "T"
+ }
+ result = style.Render(fmt.Sprintf("%s %d-%d", mark, us, them))
+ default:
+ result = styleSubtle.Render(startLabel(g))
+ }
+
+ return fmt.Sprintf("%s %s %s %s %s", date, styleSubtle.Render(loc), oppAbbr, oppName, result)
+}
+
+// teamScores returns (team's score, opponent's score) given whether the team is
+// home in g.
+func teamScores(g model.Game, home bool) (us, them int) {
+ if home {
+ return g.Home.Score, g.Away.Score
+ }
+ return g.Away.Score, g.Home.Score
+}
internal/ui/sections.go +209 −10
@@ -1,17 +1,53 @@
package ui
import (
+ "sort"
"time"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
// section is a titled group of games in the dashboard. The dashboard renders
// sections in order; the global cursor indexes their flattened concatenation.
+// level selects the header style: a normal section title, a date divider, or
+// an indented league subheader nested under a date (used by filtered views).
type section struct {
title string
color string // accent hex (no '#')
games []model.Game
+ level headerLevel
+}
+
+type headerLevel int
+
+const (
+ lvlNormal headerLevel = iota
+ lvlDate // "# June 16" — date divider, no games of its own
+ lvlLeague // "## WC" — indented league subheader under a date
+)
+
+// gameFilter narrows the dashboard slate by game state so the user can see just
+// live games, just recent finals, or just upcoming fixtures without scrolling.
+type gameFilter int
+
+const (
+ filterAllStates gameFilter = iota
+ filterLive
+ filterRecent
+ filterUpcoming
+)
+
+func (f gameFilter) String() string {
+ switch f {
+ case filterLive:
+ return "Live"
+ case filterRecent:
+ return "Recent"
+ case filterUpcoming:
+ return "Upcoming"
+ default:
+ return "All"
+ }
}
// sections builds the dashboard's groups for the current filter:
@@ -20,14 +56,21 @@ // - All leagues: one section per league, today's games only — keeps the
// overview scannable.
// - Single league: Past / Today / Upcoming buckets across the fetched
// window, so you can see recent finals and what's next.
+//
+// When a state filter (Live/Recent/Upcoming) is active, the slate is narrowed
+// to that state across the fetched window and favorites are not pinned — the
+// view is purely "just the live games", etc.
func (a App) sections() []section {
now := time.Now()
+ if a.stateFilter != filterAllStates {
+ return a.filteredSections(now)
+ }
if a.filter == "" {
var out []section
// Favorites pinned on top: today's games (any league) with a favorite.
var fav []model.Game
favSeen := map[string]bool{}
- for _, l := range model.Leagues {
+ for _, l := range a.leagues {
for _, g := range filterDay(a.games[l.ID], now) {
if a.hasFav(g) {
fav = append(fav, g)
@@ -36,9 +79,9 @@ }
}
}
if len(fav) > 0 {
- out = append(out, section{"★ Favorites", colWarnHex, fav})
+ out = append(out, section{"★ Favorites", colWarnHex, fav, lvlNormal})
}
- for _, l := range model.Leagues {
+ for _, l := range a.leagues {
var today []model.Game
for _, g := range filterDay(a.games[l.ID], now) {
if !favSeen[g.ID] { // already shown in Favorites
@@ -46,9 +89,14 @@ today = append(today, g)
}
}
if len(today) > 0 {
- out = append(out, section{l.Icon + " " + l.Name, l.Color, today})
+ out = append(out, section{l.Icon + " " + l.Name, l.Color, today, lvlNormal})
}
}
+ // Recent finals from before today (across leagues), newest first then
+ // league order, so the all-leagues view ends with the latest scores.
+ if rec := a.recentFinals(now); len(rec) > 0 {
+ out = append(out, section{"◼ Recent", "", rec, lvlNormal})
+ }
return out
}
@@ -58,20 +106,151 @@ var out []section
// Favorites bucket: each favorite team's live + last final + next fixture
// from the fetched window, pinned above the regular buckets.
if fav := a.favoriteHighlights(a.filter, now); len(fav) > 0 {
- out = append(out, section{"★ Favorites", colWarnHex, fav})
+ out = append(out, section{"★ Favorites", colWarnHex, fav, lvlNormal})
}
if len(today) > 0 {
- out = append(out, section{"● Today", l.Color, today})
+ out = append(out, section{"● Today", l.Color, today, lvlNormal})
}
if len(upcoming) > 0 {
- out = append(out, section{"○ Upcoming", l.Color, upcoming})
+ out = append(out, section{"○ Upcoming", l.Color, upcoming, lvlNormal})
}
if len(past) > 0 {
- out = append(out, section{"◼ Past", l.Color, past})
+ out = append(out, section{"◼ Past", l.Color, past, lvlNormal})
+ }
+ return out
+}
+
+// filteredSections builds the slate when a state filter is active: favorites
+// matching the filter are pinned on top, then the rest as one flat list.
+// All-leagues collapses per-league grouping into a single state-ordered list
+// (live keeps client order; recent newest-first; upcoming chronological), with
+// league order as the tiebreak so same-time games stay stable.
+func (a App) filteredSections(now time.Time) []section {
+ want, ok := stateForFilter(a.stateFilter)
+ if !ok {
+ return nil
+ }
+ var leagues []model.LeagueID
+ if a.filter == "" {
+ for _, l := range a.leagues {
+ leagues = append(leagues, l.ID)
+ }
+ } else {
+ leagues = []model.LeagueID{a.filter}
+ }
+
+ var favs, rest []model.Game
+ for _, lid := range leagues {
+ for _, g := range a.games[lid] {
+ if g.State != want {
+ continue
+ }
+ if a.hasFav(g) {
+ favs = append(favs, g)
+ } else {
+ rest = append(rest, g)
+ }
+ }
+ }
+
+ // Most-recent-first for Recent; chronological for Upcoming and Live. Order
+ // by day → league → time so each league's games on a day stay contiguous
+ // (raw time order would interleave leagues with different kickoff times),
+ // matching the date/league grouping below.
+ idx := a.leagueIndex()
+ desc := a.stateFilter == filterRecent
+ sortByDayLeagueTime(favs, idx, desc)
+ sortByDayLeagueTime(rest, idx, desc)
+
+ var out []section
+ if len(favs) > 0 {
+ out = append(out, section{"★ Favorites", colWarnHex, favs, lvlNormal})
+ }
+ // Rest grouped under date dividers ("# June 16"), then league subheaders
+ // ("## WC") within each date.
+ out = append(out, dateLeagueSections(rest)...)
+ return out
+}
+
+// sortByDayLeagueTime orders games by calendar day, then league order, then
+// start time. desc=true puts the most recent day first and, within a league,
+// the later game first (Recent); desc=false is chronological (Upcoming/Live).
+// Keeping a league's games contiguous within a day is what lets
+// dateLeagueSections emit one subheader per league per date.
+func sortByDayLeagueTime(games []model.Game, idx map[model.LeagueID]int, desc bool) {
+ sort.SliceStable(games, func(i, j int) bool {
+ di, dj := dayKey(games[i].Start), dayKey(games[j].Start)
+ if di != dj {
+ if desc {
+ return di > dj
+ }
+ return di < dj
+ }
+ if li, lj := idx[games[i].League], idx[games[j].League]; li != lj {
+ return li < lj
+ }
+ if desc {
+ return games[i].Start.After(games[j].Start)
+ }
+ return games[i].Start.Before(games[j].Start)
+ })
+}
+
+// dateLeagueSections groups a date-then-league sorted slice into a date divider
+// section (no games) followed by an indented league subheader section per
+// league on that date. Games within a (date, league) run stay in input order.
+func dateLeagueSections(games []model.Game) []section {
+ var out []section
+ i := 0
+ for i < len(games) {
+ d := dayKey(games[i].Start)
+ out = append(out, section{games[i].Start.Local().Format("January 2"), "", nil, lvlDate})
+ for i < len(games) && dayKey(games[i].Start) == d {
+ lid := games[i].League
+ l, _ := model.LeagueByID(lid)
+ var gs []model.Game
+ for i < len(games) && dayKey(games[i].Start) == d && games[i].League == lid {
+ gs = append(gs, games[i])
+ i++
+ }
+ out = append(out, section{l.Icon + " " + l.Name, l.Color, gs, lvlLeague})
+ }
}
return out
}
+// dayKey is a stable per-calendar-day key in local time for date grouping.
+func dayKey(t time.Time) string { return t.Local().Format("2006-01-02") }
+
+// stateForFilter maps a state filter to the game State it selects; ok=false for
+// filterAllStates (which has no single target state).
+func stateForFilter(f gameFilter) (model.State, bool) {
+ switch f {
+ case filterLive:
+ return model.StateLive, true
+ case filterRecent:
+ return model.StateFinal, true
+ case filterUpcoming:
+ return model.StatePre, true
+ default:
+ return 0, false
+ }
+}
+
+// sortByStartLeague orders games by start time then league order. desc=true
+// puts the most recent first (finals/recent); desc=false is chronological.
+func sortByStartLeague(games []model.Game, idx map[model.LeagueID]int, desc bool) {
+ sort.SliceStable(games, func(i, j int) bool {
+ if !games[i].Start.Equal(games[j].Start) {
+ if desc {
+ return games[i].Start.After(games[j].Start)
+ }
+ return games[i].Start.Before(games[j].Start)
+ }
+ return idx[games[i].League] < idx[games[j].League]
+ })
+}
+
// favoriteHighlights returns, for each favorited team in the league, the games
// worth pinning: any live game, the most recent final (last result), and the
// next scheduled fixture (next), drawn from the fetched window. Games are
@@ -80,6 +259,7 @@ func (a App) favoriteHighlights(league model.LeagueID, now time.Time) []model.Game {
games := a.games[league]
type pick struct{ live, next, last *model.Game }
picks := map[string]*pick{}
+ var order []string // stable team order (first-seen), avoids map-iteration jitter
teamKeys := func(g model.Game) []string {
var ks []string
@@ -99,6 +279,7 @@ p := picks[k]
if p == nil {
p = &pick{}
picks[k] = p
+ order = append(order, k)
}
switch g.State {
case model.StateLive:
@@ -123,11 +304,29 @@ seen[g.ID] = true
out = append(out, *g)
}
}
- for _, p := range picks {
+ for _, k := range order {
+ p := picks[k]
add(p.live)
add(p.next)
add(p.last)
}
+ return out
+}
+
+// recentFinals returns finals that completed before today (within the fetched
+// window), most-recent-first then league order. Today's finals already appear
+// in each league's section, so this surfaces yesterday-and-earlier scores.
+func (a App) recentFinals(now time.Time) []model.Game {
+ startToday := startOfDay(now)
+ var out []model.Game
+ for _, l := range a.leagues {
+ for _, g := range a.games[l.ID] {
+ if g.State == model.StateFinal && g.Start.Before(startToday) {
+ out = append(out, g)
+ }
+ }
+ }
+ sortByStartLeague(out, a.leagueIndex(), true)
return out
}
internal/ui/settings.go +57 −0
@@ -0,0 +1,57 @@
+package ui
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// settingsView renders the leagues settings screen: a checklist of every
+// league — enabled ones first in display order, then a hidden group — with the
+// cursor row marked. Toggling and reordering happen in handleSettingsKey; this
+// only draws current state. Flows through View()'s paintBackground like the
+// other views.
+func (a App) settingsView() string {
+ header := styleTitle.Render(" LEAGUES ") +
+ lipgloss.NewStyle().Foreground(colAccent2).Bold(true).Render(" show, hide & reorder ") +
+ styleSubtle.Render("(auto-hidden off-season · "+keys.ResetLeagues.Help().Key+" resets overrides)")
+
+ rows, enabled := a.settingsRows()
+ var lines []string
+ dividerShown := false
+ for i := range rows {
+ if !enabled[i] && !dividerShown {
+ lines = append(lines, styleFaint.Render(" ── hidden ──"))
+ dividerShown = true
+ }
+ lines = append(lines, a.settingsRow(rows[i], enabled[i], i == a.settingsCursor))
+ }
+
+ help := styleHelp.Render(strings.Join([]string{
+ keys.Up.Help().Key + "/" + keys.Down.Help().Key + " move",
+ keys.Toggle.Help().Key + " toggle",
+ keys.MoveUp.Help().Key + "/" + keys.MoveDown.Help().Key + " reorder",
+ keys.ResetLeagues.Help().Key + " auto",
+ keys.Back.Help().Key + " done",
+ }, styleFaint.Render(" · ")))
+
+ parts := []string{header, "", strings.Join(lines, "\n"), "", help}
+ return styleApp.Render(strings.Join(parts, "\n"))
+}
+
+// settingsRow renders one league line: cursor marker, checkbox, league glyph
+// and name. Enabled rows get a green check; the cursor row gets an accent ▸.
+func (a App) settingsRow(l model.League, on, selected bool) string {
+ box := styleSubtle.Render("[ ]")
+ if on {
+ box = lipgloss.NewStyle().Foreground(colWin).Bold(true).Render("[x]")
+ }
+ cursor := " "
+ if selected {
+ cursor = lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render("▸ ")
+ }
+ name := lipgloss.NewStyle().Foreground(teamColor(l.Color)).Bold(true).Render(l.Icon + " " + l.Name)
+ return cursor + box + " " + name
+}
internal/ui/standings.go +139 −0
@@ -0,0 +1,139 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// Layout: width of the row prefix (cursor + rank + star + abbr + name) before
+// the stat columns begin. Header and rows share it so columns line up.
+const (
+ stNameW = 18
+ stValW = 6 // right-aligned width per stat column
+ stLeftW = 2 + 2 + 1 + 1 + 1 + 3 + 1 + stNameW
+)
+
+// standingsView renders a league's standings: per-group ranked tables with a
+// movable team cursor. enter on a row opens that team's schedule.
+func (a App) standingsView() string {
+ l, _ := model.LeagueByID(a.standingsLeague)
+ title := styleTitle.Render(" STANDINGS ") + " " +
+ lipgloss.NewStyle().Foreground(teamColor(l.Color)).Bold(true).Render(l.Icon+" "+l.Name)
+ tabs := a.standingsTabs()
+ footer := styleHelp.Render(strings.Join([]string{
+ keys.Up.Help().Key + "/" + keys.Down.Help().Key + " move",
+ keys.NextLg.Help().Key + " league",
+ keys.Enter.Help().Key + " schedule",
+ "f/F fav",
+ keys.Refresh.Help().Key + " refresh",
+ keys.Back.Help().Key + " back",
+ }, styleFaint.Render(" · ")))
+
+ frame := func(body string) string {
+ return styleApp.Render(strings.Join([]string{title, tabs, "", body, "", footer}, "\n"))
+ }
+
+ switch {
+ case a.standingsErr != nil:
+ return frame(styleSubtle.Render("standings unavailable — " + a.standingsErr.Error()))
+ case a.standings.RowCount() == 0:
+ return frame(styleSubtle.Render(a.spinner.View() + " loading standings…"))
+ }
+
+ lines, selTop := a.standingsLines()
+ avail := a.height - 5 // title, tabs, blank, blank, footer
+ if avail < 3 {
+ avail = 3
+ }
+ return frame(strings.Join(clampScroll(lines, selTop, avail), "\n"))
+}
+
+// standingsTabs is the league chip row for the standings view, highlighting the
+// league currently shown.
+func (a App) standingsTabs() string {
+ tabs := make([]string, 0, len(a.leagues))
+ for _, l := range a.leagues {
+ tabs = append(tabs, tab(l.Icon+" "+l.Abbr, l.ID == a.standingsLeague))
+ }
+ return strings.Join(tabs, styleFaint.Render("│"))
+}
+
+// standingsLines renders every group to lines and reports the line index of the
+// selected team row, for scroll-follow.
+func (a App) standingsLines() (lines []string, selTop int) {
+ idx := 0 // flattened team-row index, matches standingsCursor
+ for _, g := range a.standings.Groups {
+ if g.Name != "" {
+ lines = append(lines, ruleHeader(g.Name, stLeftW+len(g.Columns)*stValW))
+ }
+ lines = append(lines, a.standingsColHeader(g))
+ for _, row := range g.Rows {
+ if idx == a.standingsCursor {
+ selTop = len(lines)
+ }
+ lines = append(lines, a.standingsRow(row, idx == a.standingsCursor))
+ idx++
+ }
+ lines = append(lines, "")
+ }
+ return lines, selTop
+}
+
+func (a App) standingsColHeader(g model.StandingsGroup) string {
+ var b strings.Builder
+ b.WriteString(strings.Repeat(" ", stLeftW))
+ for _, c := range g.Columns {
+ b.WriteString(fmt.Sprintf("%*s", stValW, c))
+ }
+ return styleSubtle.Render(b.String())
+}
+
+func (a App) standingsRow(r model.StandingsRow, selected bool) string {
+ cursor := " "
+ if selected {
+ cursor = lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render("▸ ")
+ }
+ rank := styleSubtle.Render(fmt.Sprintf("%2d", r.Rank))
+ star := " "
+ if a.isFav(a.standingsLeague, r.Team) {
+ star = lipgloss.NewStyle().Foreground(colWarn).Render("★")
+ }
+ abbr := lipgloss.NewStyle().Foreground(teamColor(r.Team.Color)).Bold(true).
+ Render(fmt.Sprintf("%-3s", r.Team.Abbr))
+ name := r.Team.Name
+ name = truncate(name, stNameW)
+ name += strings.Repeat(" ", max(0, stNameW-lipgloss.Width(name)))
+ nameStyle := styleScore
+ if selected {
+ nameStyle = lipgloss.NewStyle().Foreground(colText).Bold(true)
+ }
+
+ var vals strings.Builder
+ for _, v := range r.Values {
+ vals.WriteString(fmt.Sprintf("%*s", stValW, v))
+ }
+ return cursor + rank + " " + star + " " + abbr + " " + nameStyle.Render(name) + vals.String()
+}
+
+// clampScroll returns at most avail lines, scrolled so the line at selTop stays
+// visible. For single-line rows (standings), unlike the card-aware scrollWindow.
+func clampScroll(lines []string, selTop, avail int) []string {
+ if len(lines) <= avail {
+ return lines
+ }
+ off := 0
+ if selTop >= avail {
+ off = selTop - avail + 1
+ }
+ if off > len(lines)-avail {
+ off = len(lines) - avail
+ }
+ if off < 0 {
+ off = 0
+ }
+ return lines[off : off+avail]
+}
internal/ui/statefilter_test.go +116 −0
@@ -0,0 +1,116 @@
+package ui
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+func filterTestApp() App {
+ now := time.Now()
+ return App{
+ leagues: model.Leagues,
+ games: map[model.LeagueID][]model.Game{
+ model.MLB: {
+ {ID: "live1", League: model.MLB, State: model.StateLive, Start: now},
+ {ID: "fin-new", League: model.MLB, State: model.StateFinal, Start: now.AddDate(0, 0, -1)},
+ {ID: "fin-old", League: model.MLB, State: model.StateFinal, Start: now.AddDate(0, 0, -3)},
+ {ID: "up1", League: model.MLB, State: model.StatePre, Start: now.AddDate(0, 0, 2)},
+ },
+ model.NBA: {
+ {ID: "nbalive", League: model.NBA, State: model.StateLive, Start: now},
+ },
+ },
+ }
+}
+
+func sectionIDs(secs []section) []string {
+ var ids []string
+ for _, s := range secs {
+ for _, g := range s.games {
+ ids = append(ids, g.ID)
+ }
+ }
+ return ids
+}
+
+func TestStateFilterLiveAllLeagues(t *testing.T) {
+ a := filterTestApp()
+ a.filter = ""
+ a.stateFilter = filterLive
+ ids := sectionIDs(a.sections())
+ want := map[string]bool{"live1": true, "nbalive": true}
+ if len(ids) != 2 {
+ t.Fatalf("live filter got %v, want 2 live games", ids)
+ }
+ for _, id := range ids {
+ if !want[id] {
+ t.Errorf("unexpected %q under Live filter", id)
+ }
+ }
+}
+
+func TestStateFilterRecentOrderedDesc(t *testing.T) {
+ a := filterTestApp()
+ a.filter = model.MLB
+ a.stateFilter = filterRecent
+ secs := a.sections()
+ // Grouped by date divider then league subheader, most-recent date first.
+ if secs[0].level != lvlDate {
+ t.Fatalf("recent should lead with a date divider: %+v", secs)
+ }
+ ids := sectionIDs(secs)
+ if len(ids) != 2 || ids[0] != "fin-new" || ids[1] != "fin-old" {
+ t.Errorf("recent not most-recent-first: %v", ids)
+ }
+}
+
+func TestStateFilterUpcomingNoFavorites(t *testing.T) {
+ a := filterTestApp()
+ a.filter = ""
+ a.stateFilter = filterUpcoming
+ for _, s := range a.sections() {
+ if strings.Contains(s.title, "Favorites") {
+ t.Errorf("no favorites set, so none should pin: %q", s.title)
+ }
+ }
+ ids := sectionIDs(a.sections())
+ if len(ids) != 1 || ids[0] != "up1" {
+ t.Errorf("upcoming filter got %v, want [up1]", ids)
+ }
+}
+
+// With a favorite set, the matching game pins to a ★ Favorites section on top
+// of every state filter (live/recent/upcoming), not just the unfiltered view.
+func TestStateFilterFavoritesPinned(t *testing.T) {
+ a := filterTestApp()
+ a.filter = ""
+ a.stateFilter = filterLive
+ a.favs = map[string]bool{favKey(model.NBA, model.Team{ID: "nbahome"}): true}
+ // Mark nbalive as having that favorite team.
+ g := a.games[model.NBA][0]
+ g.Home = model.Team{ID: "nbahome"}
+ a.games[model.NBA][0] = g
+
+ secs := a.sections()
+ if len(secs) == 0 || secs[0].title != "★ Favorites" {
+ t.Fatalf("favorites not pinned on top under Live filter: %+v", secs)
+ }
+ if len(secs[0].games) != 1 || secs[0].games[0].ID != "nbalive" {
+ t.Errorf("favorite pin = %v, want [nbalive]", sectionIDs(secs[:1]))
+ }
+}
+
+func TestBasesDiamondRowsEqualWidth(t *testing.T) {
+ out := basesDiamond(&model.Situation{OnSecond: true, OnFirst: true})
+ rows := strings.Split(out, "\n")
+ if len(rows) != 2 {
+ t.Fatalf("diamond should be 2 rows, got %d", len(rows))
+ }
+ if w0, w1 := lipgloss.Width(rows[0]), lipgloss.Width(rows[1]); w0 != w1 {
+ t.Errorf("diamond rows unequal width: %d vs %d (skews centering)", w0, w1)
+ }
+}
internal/ui/theme.go +295 −52
@@ -1,93 +1,336 @@
package ui
-import "github.com/charmbracelet/lipgloss"
+import (
+ "fmt"
+ "math"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
-// Palette — Flexoki (Steph Ango) dark mode. The user's terminal runs a
-// Flexoki background, so we match its token ramp for proper contrast:
-// text tokens (tx / tx-2) are warm light greys that read cleanly on the
-// dark paper, and accents use Flexoki's 400-level hues.
-// Ref: https://stephango.com/flexoki
-// Text/structure tokens are background-adaptive (lipgloss queries the terminal
-// background and picks Light vs Dark). Both ramps are Flexoki, so the app reads
-// correctly on a Flexoki-light *and* Flexoki-dark terminal — the dark values
-// match the original hardcoded palette, so dark contrast is unchanged.
+// Theme is one swappable palette. Structural tokens (Bg/Surface/UI/Border/
+// Faint/Text/Muted) are concrete per-theme values — each theme is an explicit
+// light or dark variant, so no background adaptation is needed. The six
+// accents are mapped by hue from the source ramp so sport semantics stay
+// stable across themes (red = live, green = win, …); only the palette shifts.
+// Values ported from a shared design-token set (oklch ramps converted to
+// sRGB hex). See TASK-005.
+type Theme struct {
+ ID string // config key, e.g. "flexoki-dark"
+ Name string // display name, e.g. "Flexoki Dark"
+ Dark bool // dark variant — cycled only when the terminal is dark
+
+ Bg lipgloss.Color // bg (paper/black)
+ Surface lipgloss.Color // bg-card
+ UI lipgloss.Color // bg-elevated
+ Border lipgloss.Color // border-strong
+ Faint lipgloss.Color // text-faint
+ Text lipgloss.Color // text
+ Muted lipgloss.Color // text-muted
+
+ Live lipgloss.Color // red — live state
+ Accent lipgloss.Color // blue — selection / interactive
+ Accent2 lipgloss.Color // purple — group labels / breadcrumb
+ Win lipgloss.Color // green — winner
+ Warn lipgloss.Color // yellow — favorites / section accent
+ Goal lipgloss.Color // orange — score pop
+}
+
+// themes is the registry, in cycle order. flexoki-dark is index 0 (the default
+// and the original hardcoded palette).
+var themes = []Theme{
+ {
+ ID: "flexoki-dark", Name: "Flexoki Dark", Dark: true,
+ Bg: "#100F0F", Surface: "#1C1B1A", UI: "#282726", Border: "#403E3C",
+ Faint: "#575653", Text: "#CECDC3", Muted: "#878580",
+ Live: "#D14D41", Accent: "#4385BE", Accent2: "#8B7EC8",
+ Win: "#879A39", Warn: "#D0A215", Goal: "#DA702C",
+ },
+ {
+ ID: "flexoki", Name: "Flexoki Light", Dark: false,
+ Bg: "#FFFCF0", Surface: "#F2F0E5", UI: "#F2F0E5", Border: "#CECDC3",
+ Faint: "#B7B5AC", Text: "#100F0F", Muted: "#6F6E69",
+ Live: "#D14D41", Accent: "#4385BE", Accent2: "#8B7EC8",
+ Win: "#879A39", Warn: "#D0A215", Goal: "#DA702C",
+ },
+ {
+ ID: "uchu-dark", Name: "Uchu Dark", Dark: true,
+ Bg: "#080A0D", Surface: "#202225", UI: "#383B3D", Border: "#515255",
+ Faint: "#6A6B6E", Text: "#E3E4E6", Muted: "#9A9C9E",
+ Live: "#EA3C65", Accent: "#3984F2", Accent2: "#915AD3",
+ Win: "#64D970", Warn: "#FEDF7B", Goal: "#FF9F5B",
+ },
+ {
+ ID: "uchu", Name: "Uchu Light", Dark: false,
+ Bg: "#FDFDFD", Surface: "#F0F0F2", UI: "#FDFDFD", Border: "#CCCCCF",
+ Faint: "#9A9C9E", Text: "#080A0D", Muted: "#515255",
+ Live: "#EA3C65", Accent: "#3984F2", Accent2: "#915AD3",
+ Win: "#64D970", Warn: "#FEDF7B", Goal: "#FF9F5B",
+ },
+ {
+ ID: "humdrum-dark", Name: "Humdrum Dark", Dark: true,
+ Bg: "#1F1D1A", Surface: "#282622", UI: "#32302C", Border: "#4A4740",
+ Faint: "#6D6A63", Text: "#E8E5DD", Muted: "#A8A49B",
+ Live: "#D6464D", Accent: "#0F80EA", Accent2: "#8A63DE",
+ Win: "#37981B", Warn: "#B07300", Goal: "#D05500",
+ },
+ {
+ ID: "humdrum", Name: "Humdrum Light", Dark: false,
+ Bg: "#F5F3EE", Surface: "#FFFFFF", UI: "#FFFFFF", Border: "#C3BFB3",
+ Faint: "#ADA99F", Text: "#2A2825", Muted: "#6D6A63",
+ Live: "#D6464D", Accent: "#0F80EA", Accent2: "#8A63DE",
+ Win: "#37981B", Warn: "#B07300", Goal: "#D05500",
+ },
+}
+
+// themeIndex returns the registry position of a theme ID, or 0 (the default)
+// when the ID is empty or unknown.
+func themeIndex(id string) int {
+ for i, t := range themes {
+ if t.ID == id {
+ return i
+ }
+ }
+ return 0
+}
+
+// themesFor returns the registry indices of themes matching the terminal's
+// appearance (dark or light), in registry order. The 't' key cycles within
+// this set so a dark terminal only offers dark palettes and vice-versa.
+func themesFor(dark bool) []int {
+ var out []int
+ for i, t := range themes {
+ if t.Dark == dark {
+ out = append(out, i)
+ }
+ }
+ return out
+}
+
+// resolveTheme picks the starting theme index for the current appearance: the
+// persisted theme if it matches, otherwise the first theme of that appearance.
+func resolveTheme(id string, dark bool) int {
+ if i := themeIndex(id); id != "" && themes[i].Dark == dark {
+ return i
+ }
+ if set := themesFor(dark); len(set) > 0 {
+ return set[0]
+ }
+ return 0
+}
+
+// Active palette globals — read at render time across the package. Set by
+// applyTheme; never written outside it. Switching is single-goroutine (a key
+// in Update, before the next View), so mutating these is safe.
var (
- colBg = lipgloss.Color("#100F0F") // base black (paper, dark)
- colSurface = lipgloss.Color("#1C1B1A") // bg-2
- colUI = lipgloss.Color("#282726") // ui
- colBorder = lipgloss.AdaptiveColor{Light: "#DAD8CE", Dark: "#403E3C"} // ui-3, borders
- colFaint = lipgloss.AdaptiveColor{Light: "#B7B5AC", Dark: "#575653"} // tx-3, faint
+ activeTheme Theme
+
+ colBg lipgloss.Color
+ colBorder lipgloss.Color
+ colFaint lipgloss.Color
+ colText lipgloss.Color
+ colMuted lipgloss.Color
+ colLive lipgloss.Color
+ colAccent lipgloss.Color
+ colAccent2 lipgloss.Color
+ colWin lipgloss.Color
+ colWarn lipgloss.Color
+ colGoal lipgloss.Color
+
+ // colWarnHex is the bare hex (no '#') for section accents, matching colWarn —
+ // section colors are stored as ESPN-style hex strings (see teamColor).
+ colWarnHex string
+)
+
+// Style globals, rebuilt from the active palette by buildStyles().
+var (
+ styleApp lipgloss.Style
+ styleTitle lipgloss.Style
+
+ styleSubtle lipgloss.Style
+ styleFaint lipgloss.Style
+
+ styleCard lipgloss.Style
+ styleCardSelected lipgloss.Style
+
+ styleScore lipgloss.Style
+ styleWin lipgloss.Style
+ styleFinal lipgloss.Style
- colText = lipgloss.AdaptiveColor{Light: "#100F0F", Dark: "#CECDC3"} // tx — primary text
- colMuted = lipgloss.AdaptiveColor{Light: "#6F6E69", Dark: "#878580"} // tx-2 — secondary
+ styleLeagueTab lipgloss.Style
+ styleLeagueSel lipgloss.Style
- colLive = lipgloss.Color("#D14D41") // red-400
- colAccent = lipgloss.Color("#4385BE") // blue-400
- colAccent2 = lipgloss.Color("#8B7EC8") // purple-400
- colWin = lipgloss.Color("#879A39") // green-400
- colWarn = lipgloss.Color("#D0A215") // yellow-400
- colGoal = lipgloss.Color("#DA702C") // orange-400 (score pop)
+ styleHelp lipgloss.Style
+ styleErr lipgloss.Style
)
-// colWarnHex is the bare hex (no '#') for section accents, matching colWarn —
-// section colors are stored as ESPN-style hex strings (see teamColor).
-const colWarnHex = "D0A215" // yellow-400
+// applyTheme swaps the active palette and rebuilds every derived style.
+func applyTheme(t Theme) {
+ activeTheme = t
+ colBg = t.Bg
+ colBorder = t.Border
+ colFaint = t.Faint
+ colText = t.Text
+ colMuted = t.Muted
+ colLive = t.Live
+ colAccent = t.Accent
+ colAccent2 = t.Accent2
+ colWin = t.Win
+ colWarn = t.Warn
+ colGoal = t.Goal
+ colWarnHex = strings.TrimPrefix(string(t.Warn), "#")
+ buildStyles()
+}
-var (
+func buildStyles() {
styleApp = lipgloss.NewStyle().Foreground(colText)
styleTitle = lipgloss.NewStyle().
- Bold(true).
- Foreground(colBg).
- Background(colAccent).
- Padding(0, 1)
+ Bold(true).Foreground(colBg).Background(colAccent).Padding(0, 1)
styleSubtle = lipgloss.NewStyle().Foreground(colMuted)
- styleFaint = lipgloss.NewStyle().Foreground(colFaint)
+ styleFaint = lipgloss.NewStyle().Foreground(colFaint)
styleCard = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(colBorder).
- Padding(0, 1)
+ Border(lipgloss.RoundedBorder()).BorderForeground(colBorder).Padding(0, 1)
// Selected card: thick accent border only — no background fill. A bg fill
// looks like a dark box on light terminals and partially-fills anyway
// (inner colored spans reset the bg mid-line). Border + marker is
// terminal-background-agnostic and clean on both light and dark.
styleCardSelected = lipgloss.NewStyle().
- Border(lipgloss.ThickBorder()).
- BorderForeground(colAccent).
- Padding(0, 1)
+ Border(lipgloss.ThickBorder()).BorderForeground(colAccent).Padding(0, 1)
styleScore = lipgloss.NewStyle().Bold(true).Foreground(colText)
- styleWin = lipgloss.NewStyle().Bold(true).Foreground(colWin)
+ styleWin = lipgloss.NewStyle().Bold(true).Foreground(colWin)
styleFinal = lipgloss.NewStyle().Foreground(colMuted)
styleLeagueTab = lipgloss.NewStyle().Padding(0, 1).Foreground(colMuted)
styleLeagueSel = lipgloss.NewStyle().Padding(0, 1).
- Bold(true).Foreground(colBg).Background(colAccent)
+ Bold(true).Foreground(colBg).Background(colAccent)
styleHelp = lipgloss.NewStyle().Foreground(colMuted)
- styleErr = lipgloss.NewStyle().Foreground(colLive).Bold(true)
-)
+ styleErr = lipgloss.NewStyle().Foreground(colLive).Bold(true)
+}
+
+// init applies the default theme so package-level styles are valid even for
+// tests that build an App literal without going through New().
+func init() { applyTheme(themes[0]) }
// teamColor returns a usable lipgloss color from an ESPN hex string, falling
-// back to primary text on empty/short values. Some teams ship near-black
-// colors that vanish on the dark bg; those are lifted to plain text too.
+// back to primary text on empty/short values. Team colors that are too
+// low-contrast against the active theme background (white teams on a light
+// theme, near-black teams on a dark one) are nudged toward the theme text
+// color until they're readable, so they never vanish into the paper.
func teamColor(hex string) lipgloss.TerminalColor {
- if len(hex) == 6 {
- return lipgloss.Color("#" + hex)
+ if len(hex) != 6 {
+ return colText
+ }
+ r, g, b := hexRGB("#" + hex)
+ return lipgloss.Color(rgbHex(contrastFix(r, g, b)))
+}
+
+// contrastFix blends (r,g,b) toward the theme text color until it clears a
+// minimum contrast ratio against the theme background. Decorative team colors
+// only need to be legible, not WCAG-AA, so the target is modest; a color that
+// already passes is returned unchanged.
+func contrastFix(r, g, b int) (int, int, int) {
+ const want = 1.9 // legible-on-paper, well below AA's 4.5 for body text
+ bl := relLuminance(hexRGB(string(colBg)))
+ if contrastRatio(relLuminance(r, g, b), bl) >= want {
+ return r, g, b
+ }
+ tr, tg, tb := hexRGB(string(colText))
+ for t := 0.2; t < 1.0; t += 0.2 {
+ nr := int(float64(r) + (float64(tr)-float64(r))*t)
+ ng := int(float64(g) + (float64(tg)-float64(g))*t)
+ nb := int(float64(b) + (float64(tb)-float64(b))*t)
+ if contrastRatio(relLuminance(nr, ng, nb), bl) >= want {
+ return nr, ng, nb
+ }
+ }
+ return tr, tg, tb
+}
+
+// relLuminance is the WCAG relative luminance of an sRGB color in [0,1].
+func relLuminance(r, g, b int) float64 {
+ lin := func(c int) float64 {
+ v := float64(c) / 255
+ if v <= 0.03928 {
+ return v / 12.92
+ }
+ return math.Pow((v+0.055)/1.055, 2.4)
+ }
+ return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b)
+}
+
+// contrastRatio is the WCAG contrast ratio between two relative luminances.
+func contrastRatio(l1, l2 float64) float64 {
+ if l1 < l2 {
+ l1, l2 = l2, l1
}
- return colText
+ return (l1 + 0.05) / (l2 + 0.05)
}
-// liveDot returns the "●" live indicator pulsed by intensity in [0,1], so
-// live games visibly breathe. Blends dim→bright on the Flexoki red.
+// liveDot returns the "●" live indicator pulsed by intensity in [0,1], so live
+// games visibly breathe. Blends a dimmed live color → full live color on the
+// active theme's red.
func liveDot(intensity float64) string {
- lo, hi := 0x66, 0xD1
- v := lo + int(float64(hi-lo)*intensity)
- c := lipgloss.Color(rgbHex(v, 0x4D, 0x41))
+ r, g, b := hexRGB(string(colLive))
+ const dim = 0.45
+ lerp := func(v int) int { return int(float64(v) * (dim + (1-dim)*intensity)) }
+ c := lipgloss.Color(rgbHex(lerp(r), lerp(g), lerp(b)))
return lipgloss.NewStyle().Foreground(c).Render("●")
+}
+
+// hexRGB parses "#RRGGBB" into its components; bad input yields the Flexoki red.
+func hexRGB(s string) (int, int, int) {
+ s = strings.TrimPrefix(s, "#")
+ if len(s) != 6 {
+ return 0xD1, 0x4D, 0x41
+ }
+ v := func(b byte) int {
+ switch {
+ case b >= '0' && b <= '9':
+ return int(b - '0')
+ case b >= 'a' && b <= 'f':
+ return int(b-'a') + 10
+ case b >= 'A' && b <= 'F':
+ return int(b-'A') + 10
+ }
+ return 0
+ }
+ return v(s[0])*16 + v(s[1]), v(s[2])*16 + v(s[3]), v(s[4])*16 + v(s[5])
+}
+
+// paintBackground fills the whole frame with the active theme's background and
+// text color. lipgloss emits a hard reset (\x1b[0m) after every styled span,
+// which would otherwise drop the background for everything after it on a line;
+// we re-assert the base fg+bg after each reset and pad every line to the full
+// width, so the theme's paper color covers the entire screen regardless of the
+// terminal's own background. Truecolor SGR — matches the profile lipgloss
+// already uses for the app's team colors.
+func paintBackground(s string, w int) string {
+ if colBg == "" {
+ return s
+ }
+ br, bg, bb := hexRGB(string(colBg))
+ fr, fg, fb := hexRGB(string(colText))
+ bgSeq := fmt.Sprintf("\x1b[48;2;%d;%d;%dm", br, bg, bb)
+ base := fmt.Sprintf("\x1b[38;2;%d;%d;%dm", fr, fg, fb) + bgSeq
+ const reset = "\x1b[0m"
+
+ lines := strings.Split(s, "\n")
+ for i, ln := range lines {
+ pad := w - lipgloss.Width(ln)
+ if pad < 0 {
+ pad = 0
+ }
+ // Re-assert base after each inner reset, then frame the line.
+ ln = strings.ReplaceAll(ln, reset, reset+base)
+ lines[i] = base + ln + strings.Repeat(" ", pad) + reset
+ }
+ return strings.Join(lines, "\n")
}
func rgbHex(r, g, b int) string {
internal/ui/theme_test.go +121 −0
@@ -0,0 +1,121 @@
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Every registered theme must carry valid #RRGGBB tokens — guards against a
+// typo in the ported palettes.
+func TestThemesHaveValidHex(t *testing.T) {
+ if len(themes) != 6 {
+ t.Fatalf("expected 6 themes, got %d", len(themes))
+ }
+ valid := func(c string) bool {
+ if len(c) != 7 || c[0] != '#' {
+ return false
+ }
+ _, ok := strings.CutPrefix(c, "#")
+ r, g, b := hexRGB(c)
+ return ok && r >= 0 && g >= 0 && b >= 0
+ }
+ for _, th := range themes {
+ for name, c := range map[string]string{
+ "Bg": string(th.Bg), "Border": string(th.Border), "Faint": string(th.Faint),
+ "Text": string(th.Text), "Muted": string(th.Muted), "Live": string(th.Live),
+ "Accent": string(th.Accent), "Accent2": string(th.Accent2), "Win": string(th.Win),
+ "Warn": string(th.Warn), "Goal": string(th.Goal),
+ } {
+ if !valid(c) {
+ t.Errorf("%s.%s = %q is not #RRGGBB", th.ID, name, c)
+ }
+ }
+ }
+}
+
+// applyTheme must repoint both the color globals and the derived styles.
+func TestApplyThemeUpdatesGlobalsAndStyles(t *testing.T) {
+ applyTheme(themes[themeIndex("uchu")])
+ if colText != themes[themeIndex("uchu")].Text {
+ t.Errorf("colText not updated: %v", colText)
+ }
+ if got := styleScore.GetForeground(); got != colText {
+ t.Errorf("styleScore foreground %v != colText %v", got, colText)
+ }
+ if colWarnHex != strings.TrimPrefix(string(colWarn), "#") {
+ t.Errorf("colWarnHex %q out of sync with colWarn %v", colWarnHex, colWarn)
+ }
+ // Restore default so other tests see the original palette.
+ applyTheme(themes[0])
+}
+
+// themeIndex is forgiving: unknown / empty IDs fall back to the default (0).
+func TestThemeIndexFallback(t *testing.T) {
+ if themeIndex("") != 0 || themeIndex("nope") != 0 {
+ t.Error("unknown theme id should map to index 0")
+ }
+ if themeIndex("humdrum-dark") != themeIndex("humdrum-dark") {
+ t.Error("themeIndex not stable")
+ }
+}
+
+func TestPaintBackgroundFillsAndReasserts(t *testing.T) {
+ applyTheme(themes[0])
+ const reset = "\x1b[0m"
+ // A line with an inner reset (as lipgloss would emit after a styled span).
+ in := "hi" + reset + "yo"
+ out := paintBackground(in, 10)
+
+ // Base (fg+bg truecolor) must lead the line.
+ if !strings.HasPrefix(out, "\x1b[38;2;") {
+ t.Fatalf("no leading base SGR: %q", out)
+ }
+ // The inner reset must be followed by a re-asserted background, so text
+ // after it isn't left on the terminal's own background.
+ if !strings.Contains(out, reset+"\x1b[38;2;") {
+ t.Errorf("reset not followed by base re-assert: %q", out)
+ }
+ // Line padded to the full width (printable width == 10) and ends reset.
+ if w := lipgloss.Width(out); w != 10 {
+ t.Errorf("painted width = %d, want 10", w)
+ }
+ if !strings.HasSuffix(out, reset) {
+ t.Errorf("line should end with reset: %q", out)
+ }
+}
+
+func TestThemesForSplitsByAppearance(t *testing.T) {
+ dark := themesFor(true)
+ light := themesFor(false)
+ if len(dark) != 3 || len(light) != 3 {
+ t.Fatalf("expected 3 dark + 3 light, got %d/%d", len(dark), len(light))
+ }
+ for _, i := range dark {
+ if !themes[i].Dark {
+ t.Errorf("themesFor(true) returned light theme %s", themes[i].ID)
+ }
+ }
+ for _, i := range light {
+ if themes[i].Dark {
+ t.Errorf("themesFor(false) returned dark theme %s", themes[i].ID)
+ }
+ }
+}
+
+func TestResolveThemeHonorsAppearance(t *testing.T) {
+ // Persisted dark theme, but terminal is light → fall back to a light theme.
+ got := resolveTheme("flexoki-dark", false)
+ if themes[got].Dark {
+ t.Errorf("light terminal resolved to dark theme %s", themes[got].ID)
+ }
+ // Persisted theme matches appearance → keep it.
+ if got := resolveTheme("uchu-dark", true); themes[got].ID != "uchu-dark" {
+ t.Errorf("matching persisted theme not kept: %s", themes[got].ID)
+ }
+ // Empty → first of appearance.
+ if got := resolveTheme("", true); !themes[got].Dark {
+ t.Errorf("empty config on dark terminal gave light theme %s", themes[got].ID)
+ }
+}
internal/ui/ticker.go +189 −0
@@ -0,0 +1,189 @@
+package ui
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// tickerCellW is the inner width of one ticker box (before border).
+const tickerCellW = 12
+
+// tickerGames is the ordered list of games shown in the detail-view ticker:
+// live games first (the watched game's league before other leagues), then
+// today's finals (same ordering). The currently-watched game is excluded so
+// the strip is always "around the league" context. Built from already-polled
+// data — no extra fetch.
+func (a App) tickerGames() []model.Game {
+ now := time.Now()
+ watchLeague := a.detail.League
+
+ // Collect today's live + final games (excluding the watched one), preserving
+ // league display order so it survives the stable sort as the final tiebreak.
+ var games []model.Game
+ for _, l := range a.leagues {
+ for _, g := range filterDay(a.games[l.ID], now) {
+ if g.ID == a.detail.ID {
+ continue
+ }
+ if g.State == model.StateLive || g.State == model.StateFinal {
+ games = append(games, g)
+ }
+ }
+ }
+
+ // Rank (low = first):
+ // the game we just navigated away from (back-breadcrumb), then favorites,
+ // then by league (watched before others), then live before final:
+ // fav → same-live → same-final → other-live → other-final.
+ // Stable so same-rank games keep their league/scoreboard order.
+ rank := func(g model.Game) int {
+ if g.ID == a.detailPrev {
+ return -1
+ }
+ r := 0
+ if !a.hasFav(g) {
+ r += 100
+ }
+ if g.League != watchLeague {
+ r += 10
+ }
+ if g.State != model.StateLive {
+ r++
+ }
+ return r
+ }
+ sort.SliceStable(games, func(i, j int) bool {
+ return rank(games[i]) < rank(games[j])
+ })
+ return games
+}
+
+// tickerStrip renders the selectable game boxes above the watched game's score
+// box, windowed horizontally so the selected box stays on screen. Returns ""
+// when there are no other games to show.
+func (a App) tickerStrip() string {
+ games := a.tickerGames()
+ if len(games) == 0 {
+ return ""
+ }
+ sel := a.tickerCursor
+ if sel >= len(games) {
+ sel = len(games) - 1
+ }
+ if sel < 0 {
+ sel = 0
+ }
+
+ cells := make([]string, len(games))
+ for i, g := range games {
+ cells[i] = a.tickerCell(g, i == sel)
+ }
+
+ // Each rendered cell is tickerCellW + 2 (border) wide; +1 for the gap.
+ cellW := tickerCellW + 2 + 1
+ fit := max(1, a.width/cellW)
+ lo, hi := tickerWindow(len(cells), sel, fit)
+
+ row := lipgloss.JoinHorizontal(lipgloss.Top, cells[lo:hi]...)
+ left := " "
+ if lo > 0 {
+ left = lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render("‹ ")
+ }
+ right := " "
+ if hi < len(cells) {
+ right = lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render(" ›")
+ }
+ strip := lipgloss.JoinHorizontal(lipgloss.Center, left, row, right)
+ return lipgloss.PlaceHorizontal(a.width, lipgloss.Center, strip,
+ lipgloss.WithWhitespaceChars(" "))
+}
+
+// tickerWindow returns the [lo,hi) slice of n cells that keeps index sel
+// visible within a fit-wide window.
+func tickerWindow(n, sel, fit int) (int, int) {
+ if n <= fit {
+ return 0, n
+ }
+ lo := sel - fit/2
+ if lo < 0 {
+ lo = 0
+ }
+ if lo+fit > n {
+ lo = n - fit
+ }
+ return lo, lo + fit
+}
+
+// tickerCell renders one compact game box: a state badge over away/home rows
+// with scores. Selected gets the accent border; the back-breadcrumb (the game
+// just navigated away from) is marked with a ↩ and a purple border; others a
+// faint border.
+func (a App) tickerCell(g model.Game, selected bool) string {
+ isPrev := g.ID == a.detailPrev
+ l, _ := model.LeagueByID(g.League)
+ tag := lipgloss.NewStyle().Foreground(teamColor(l.Color)).Bold(true).Render(l.Icon)
+ if isPrev {
+ tag = lipgloss.NewStyle().Foreground(colAccent2).Bold(true).Render("↩") + tag
+ }
+
+ // League glyph on the left, state/clock filling the rest. Truncate the raw
+ // text (not the styled string) so ANSI codes stay intact.
+ avail := max(tickerCellW-lipgloss.Width(tag)-1, 0)
+ var state string
+ switch g.State {
+ case model.StateLive:
+ clock := g.Clock
+ if clock == "" || clock == "0:00" {
+ clock = "LIVE"
+ }
+ state = liveDot(a.pulse()) + " " +
+ lipgloss.NewStyle().Foreground(colLive).Bold(true).
+ Render(truncate(clock, avail-2))
+ case model.StateFinal:
+ state = styleFinal.Render(truncate("◼ FIN", avail))
+ default:
+ state = styleSubtle.Render(truncate(startLabel(g), avail))
+ }
+ badge := lipgloss.NewStyle().Width(tickerCellW).Render(tag + " " + state)
+
+ row := func(t model.Team) string {
+ star := " "
+ if a.isFav(g.League, t) {
+ star = lipgloss.NewStyle().Foreground(colWarn).Render("★")
+ }
+ abbr := star + lipgloss.NewStyle().Foreground(teamColor(t.Color)).Bold(true).
+ Render(fmt.Sprintf("%-3s", t.Abbr))
+ score := "-"
+ if g.Started() {
+ score = fmt.Sprintf("%d", t.Score)
+ }
+ ss := styleScore
+ if g.Started() && t.Winner {
+ ss = styleWin
+ }
+ gap := tickerCellW - lipgloss.Width(abbr) - lipgloss.Width(score)
+ if gap < 1 {
+ gap = 1
+ }
+ return abbr + strings.Repeat(" ", gap) + ss.Render(score)
+ }
+
+ body := lipgloss.JoinVertical(lipgloss.Left, badge, row(g.Away), row(g.Home))
+
+ border := lipgloss.NormalBorder()
+ style := lipgloss.NewStyle().Border(border).BorderForeground(colBorder).
+ Padding(0, 0).Width(tickerCellW)
+ switch {
+ case selected:
+ style = style.BorderForeground(colAccent)
+ case isPrev:
+ style = style.BorderForeground(colAccent2)
+ }
+ return style.Render(body)
+}
internal/ui/ticker_test.go +146 −0
@@ -0,0 +1,146 @@
+package ui
+
+import (
+ "testing"
+ "time"
+
+ "github.com/humdrum-tiv/sportsball/internal/model"
+)
+
+// tickerGames must exclude the watched game and order league-first then state:
+// same-league live, same-league final, other-league live, other-league final.
+// Only today's games count; pre-game and other days are dropped.
+func TestTickerGamesOrderingAndExclusion(t *testing.T) {
+ now := time.Now()
+ a := App{
+ leagues: model.Leagues,
+ detail: model.Game{ID: "watch", League: model.MLB},
+ games: map[model.LeagueID][]model.Game{
+ model.MLB: {
+ {ID: "watch", League: model.MLB, State: model.StateLive, Start: now},
+ {ID: "mlb-live", League: model.MLB, State: model.StateLive, Start: now},
+ {ID: "mlb-final", League: model.MLB, State: model.StateFinal, Start: now},
+ {ID: "mlb-pre", League: model.MLB, State: model.StatePre, Start: now},
+ {ID: "mlb-old", League: model.MLB, State: model.StateFinal, Start: now.AddDate(0, 0, -2)},
+ },
+ model.NBA: {
+ {ID: "nba-live", League: model.NBA, State: model.StateLive, Start: now},
+ {ID: "nba-final", League: model.NBA, State: model.StateFinal, Start: now},
+ },
+ },
+ }
+
+ got := a.tickerGames()
+ var ids []string
+ for _, g := range got {
+ ids = append(ids, g.ID)
+ }
+
+ want := []string{"mlb-live", "mlb-final", "nba-live", "nba-final"}
+ if len(ids) != len(want) {
+ t.Fatalf("got %v, want %v", ids, want)
+ }
+ for i := range want {
+ if ids[i] != want[i] {
+ t.Fatalf("order mismatch at %d: got %v, want %v", i, ids, want)
+ }
+ }
+}
+
+// A favorited game must lead the ticker even when it's a final and the live
+// games belong to the watched league.
+func TestTickerGamesFavoritesLead(t *testing.T) {
+ now := time.Now()
+ fav := model.Team{ID: "22", Abbr: "PHI"}
+ a := App{
+ leagues: model.Leagues,
+ detail: model.Game{ID: "watch", League: model.MLB},
+ favs: map[string]bool{favKey(model.NBA, fav): true},
+ games: map[model.LeagueID][]model.Game{
+ model.MLB: {
+ {ID: "mlb-live", League: model.MLB, State: model.StateLive, Start: now},
+ },
+ model.NBA: {
+ {ID: "nba-fav-final", League: model.NBA, State: model.StateFinal, Start: now, Home: fav},
+ },
+ },
+ }
+ got := a.tickerGames()
+ if len(got) == 0 || got[0].ID != "nba-fav-final" {
+ t.Fatalf("favorite game should lead, got %v", got)
+ }
+}
+
+// The previously-viewed game (back-breadcrumb) must lead the ticker, ahead of
+// even favorites.
+func TestTickerGamesPrevLeads(t *testing.T) {
+ now := time.Now()
+ fav := model.Team{ID: "22", Abbr: "PHI"}
+ a := App{
+ leagues: model.Leagues,
+ detail: model.Game{ID: "watch", League: model.MLB},
+ detailPrev: "nba-prev",
+ favs: map[string]bool{favKey(model.MLB, fav): true},
+ games: map[model.LeagueID][]model.Game{
+ model.MLB: {
+ {ID: "mlb-fav-live", League: model.MLB, State: model.StateLive, Start: now, Home: fav},
+ },
+ model.NBA: {
+ {ID: "nba-prev", League: model.NBA, State: model.StateFinal, Start: now},
+ },
+ },
+ }
+ got := a.tickerGames()
+ if len(got) == 0 || got[0].ID != "nba-prev" {
+ t.Fatalf("previous game should lead, got %v", got)
+ }
+}
+
+// detailView with a populated ticker must render without panicking and include
+// a sibling game's abbreviation in the strip.
+func TestDetailViewRendersTicker(t *testing.T) {
+ now := time.Now()
+ a := App{
+ width: 120, height: 40,
+ leagues: model.Leagues,
+ mode: viewDetail,
+ detail: model.Game{ID: "watch", League: model.MLB, State: model.StateLive, Start: now, Home: model.Team{Abbr: "PHI"}, Away: model.Team{Abbr: "NYM"}},
+ games: map[model.LeagueID][]model.Game{model.MLB: {{ID: "sib", League: model.MLB, State: model.StateLive, Start: now, Home: model.Team{Abbr: "LAD"}, Away: model.Team{Abbr: "SDP"}}}},
+ detailData: map[string]model.GameDetail{},
+ }
+ out := a.detailView()
+ if out == "" {
+ t.Fatal("empty detailView output")
+ }
+ if !contains(out, "LAD") || !contains(out, "SDP") {
+ t.Errorf("ticker missing sibling game abbrs in output")
+ }
+}
+
+func contains(s, sub string) bool {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
+
+// tickerWindow keeps the selected index inside the returned [lo,hi) span.
+func TestTickerWindowKeepsSelectionVisible(t *testing.T) {
+ cases := []struct{ n, sel, fit int }{
+ {10, 0, 4}, {10, 9, 4}, {10, 5, 4}, {3, 2, 4},
+ }
+ for _, c := range cases {
+ lo, hi := tickerWindow(c.n, c.sel, c.fit)
+ if lo < 0 || hi > c.n || lo > hi {
+ t.Fatalf("invalid window [%d,%d) for %+v", lo, hi, c)
+ }
+ if c.sel < lo || c.sel >= hi {
+ t.Errorf("sel %d outside window [%d,%d) for %+v", c.sel, lo, hi, c)
+ }
+ if c.n >= c.fit && hi-lo != c.fit {
+ t.Errorf("window width %d != fit %d for %+v", hi-lo, c.fit, c)
+ }
+ }
+}
internal/ui/update.go +329 −23
@@ -4,7 +4,8 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/config"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
// handleKey routes keystrokes by current view mode.
@@ -13,10 +14,44 @@ if key.Matches(msg, keys.Quit) {
return a, tea.Quit
}
+ // Theme cycle works in every view; only cycles palettes matching the
+ // terminal appearance (dark terminal → dark themes, light → light), and
+ // persists the choice.
+ if key.Matches(msg, keys.Theme) {
+ set := themesFor(a.dark)
+ if len(set) > 0 {
+ pos := 0
+ for i, idx := range set {
+ if idx == a.theme {
+ pos = i
+ break
+ }
+ }
+ a.theme = set[(pos+1)%len(set)]
+ applyTheme(themes[a.theme])
+ a.cfg.Theme = themes[a.theme].ID
+ _ = config.Save(a.cfg)
+ }
+ return a, nil
+ }
+
if a.mode == viewDetail {
+ ticker := a.tickerGames()
switch {
case key.Matches(msg, keys.Back):
a.mode = viewDashboard
+ case key.Matches(msg, keys.NextLg):
+ if len(ticker) > 0 {
+ a.tickerCursor = (a.tickerCursor + 1) % len(ticker)
+ }
+ case key.Matches(msg, keys.PrevLg):
+ if len(ticker) > 0 {
+ a.tickerCursor = (a.tickerCursor - 1 + len(ticker)) % len(ticker)
+ }
+ case key.Matches(msg, keys.Enter):
+ if a.tickerCursor >= 0 && a.tickerCursor < len(ticker) {
+ return a.openDetail(ticker[a.tickerCursor])
+ }
case key.Matches(msg, keys.Down):
a.detailScroll++
if m := a.detailScrollMax(); a.detailScroll > m {
@@ -31,22 +66,43 @@ case key.Matches(msg, keys.FavAway):
a.toggleFav(a.detail.League, a.detail.Away)
case key.Matches(msg, keys.FavHome):
a.toggleFav(a.detail.League, a.detail.Home)
+ case key.Matches(msg, keys.Schedule):
+ return a.openSchedule(a.detail.League, a.detail.Away)
+ case key.Matches(msg, keys.ScheduleHome):
+ return a.openSchedule(a.detail.League, a.detail.Home)
case key.Matches(msg, keys.Refresh):
if l, ok := model.LeagueByID(a.detail.League); ok {
- return a, tea.Batch(fetchAll(a.client), fetchDetail(a.client, l, a.detail.ID))
+ return a, tea.Batch(fetchAll(a.client, a.leagues), fetchDetail(a.client, l, a.detail.ID))
}
- return a, fetchAll(a.client)
+ return a, fetchAll(a.client, a.leagues)
}
return a, nil
+ }
+
+ if a.mode == viewSettings {
+ return a.handleSettingsKey(msg)
+ }
+ if a.mode == viewStandings {
+ return a.handleStandingsKey(msg)
+ }
+ if a.mode == viewSchedule {
+ return a.handleScheduleKey(msg)
}
// Dashboard mode.
switch {
case key.Matches(msg, keys.Down):
- a.cursor++
- a.clampCursor()
+ a.moveCursorVert(1)
case key.Matches(msg, keys.Up):
+ a.moveCursorVert(-1)
+ // Arrow ←/→ move horizontally through the grid. They precede the league-nav
+ // cases (NextLg/PrevLg also bind the arrows) so on the dashboard the arrows
+ // move the cursor while tab/⇧tab still switch leagues.
+ case key.Matches(msg, keys.GridLeft):
a.cursor--
+ a.clampCursor()
+ case key.Matches(msg, keys.GridRight):
+ a.cursor++
a.clampCursor()
case key.Matches(msg, keys.NextLg):
a.cycleLeague(1)
@@ -55,27 +111,285 @@ a.cycleLeague(-1)
case key.Matches(msg, keys.AllLg):
a.filter = ""
a.cursor = 0
+ case key.Matches(msg, keys.State):
+ a.stateFilter = (a.stateFilter + 1) % 4
+ a.cursor = 0
+ case key.Matches(msg, keys.StateBack):
+ a.stateFilter = (a.stateFilter + 3) % 4
+ a.cursor = 0
case key.Matches(msg, keys.Refresh):
a.loading = true
- return a, fetchAll(a.client)
+ return a, fetchAll(a.client, a.leagues)
+ case key.Matches(msg, keys.Leagues):
+ a.mode = viewSettings
+ a.settingsCursor = 0
+ a.settingsDirty = false
+ return a, nil
+ case key.Matches(msg, keys.FavAway):
+ if g, ok := a.cursorGame(); ok {
+ a.toggleFav(g.League, g.Away)
+ }
+ case key.Matches(msg, keys.FavHome):
+ if g, ok := a.cursorGame(); ok {
+ a.toggleFav(g.League, g.Home)
+ }
+ case key.Matches(msg, keys.Standings):
+ // On a game, jump to that game's league standings; otherwise the active
+ // (or first enabled) league.
+ if g, ok := a.cursorGame(); ok {
+ return a.openStandingsFor(g.League)
+ }
+ return a.openStandings()
+ case key.Matches(msg, keys.Schedule):
+ if g, ok := a.cursorGame(); ok {
+ return a.openSchedule(g.League, g.Away)
+ }
+ case key.Matches(msg, keys.ScheduleHome):
+ if g, ok := a.cursorGame(); ok {
+ return a.openSchedule(g.League, g.Home)
+ }
case key.Matches(msg, keys.Enter):
- vis := a.visible()
- if a.cursor >= 0 && a.cursor < len(vis) {
- a.detail = vis[a.cursor]
- a.mode = viewDetail
- a.detailScroll = 0
- a.detailErr = nil
- if l, ok := model.LeagueByID(a.detail.League); ok {
- return a, fetchDetail(a.client, l, a.detail.ID)
+ if g, ok := a.cursorGame(); ok {
+ return a.openDetail(g)
+ }
+ }
+ return a, nil
+}
+
+// cursorGame returns the game under the dashboard cursor, ok=false if none.
+func (a App) cursorGame() (model.Game, bool) {
+ vis := a.visible()
+ if a.cursor >= 0 && a.cursor < len(vis) {
+ return vis[a.cursor], true
+ }
+ return model.Game{}, false
+}
+
+// openStandings switches to standings for the active league (or the first
+// enabled league when viewing all leagues).
+func (a App) openStandings() (tea.Model, tea.Cmd) {
+ l := a.filter
+ if l == "" && len(a.leagues) > 0 {
+ l = a.leagues[0].ID
+ }
+ return a.openStandingsFor(l)
+}
+
+// openStandingsFor switches to standings for a specific league and fetches it.
+func (a App) openStandingsFor(l model.LeagueID) (tea.Model, tea.Cmd) {
+ if l == "" {
+ return a, nil
+ }
+ a.mode = viewStandings
+ a.standingsLeague = l
+ a.standingsCursor = 0
+ a.standings = model.Standings{}
+ a.standingsErr = nil
+ league, _ := model.LeagueByID(l)
+ return a, fetchStandings(a.client, league)
+}
+
+// cycleStandingsLeague steps the standings view to the next/previous enabled
+// league and refetches.
+func (a App) cycleStandingsLeague(dir int) (tea.Model, tea.Cmd) {
+ ids := a.leagueIDs()
+ if len(ids) == 0 {
+ return a, nil
+ }
+ idx := 0
+ for i, id := range ids {
+ if id == a.standingsLeague {
+ idx = i
+ break
+ }
+ }
+ a.standingsLeague = ids[(idx+dir+len(ids))%len(ids)]
+ a.standingsCursor = 0
+ a.standings = model.Standings{}
+ a.standingsErr = nil
+ league, _ := model.LeagueByID(a.standingsLeague)
+ return a, fetchStandings(a.client, league)
+}
+
+// openSchedule switches to the schedule view for one team, remembering the
+// current view so esc returns to it, and kicks off the fetch.
+func (a App) openSchedule(league model.LeagueID, t model.Team) (tea.Model, tea.Cmd) {
+ l, ok := model.LeagueByID(league)
+ if !ok || t.ID == "" {
+ return a, nil
+ }
+ a.prevMode = a.mode
+ a.mode = viewSchedule
+ a.scheduleScroll = 0
+ a.scheduleLeague = league
+ a.schedule = model.TeamSchedule{Team: t} // show header immediately
+ a.scheduleErr = nil
+ return a, fetchTeamSchedule(a.client, l, t.ID)
+}
+
+// handleStandingsKey drives the standings view: move the team cursor, switch
+// leagues, open the selected team's schedule, or close.
+func (a App) handleStandingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch {
+ case key.Matches(msg, keys.Back), key.Matches(msg, keys.Standings):
+ a.mode = viewDashboard
+ return a, nil
+ case key.Matches(msg, keys.Up):
+ a.standingsCursor--
+ a.clampStandingsCursor()
+ case key.Matches(msg, keys.Down):
+ a.standingsCursor++
+ a.clampStandingsCursor()
+ case key.Matches(msg, keys.NextLg):
+ return a.cycleStandingsLeague(1)
+ case key.Matches(msg, keys.PrevLg):
+ return a.cycleStandingsLeague(-1)
+ case key.Matches(msg, keys.Refresh):
+ league, _ := model.LeagueByID(a.standingsLeague)
+ return a, fetchStandings(a.client, league)
+ case key.Matches(msg, keys.FavAway), key.Matches(msg, keys.FavHome):
+ // Standings rows are a single team — f or F both toggle the selected one.
+ if row, ok := a.standings.RowAt(a.standingsCursor); ok {
+ a.toggleFav(a.standingsLeague, row.Team)
+ }
+ case key.Matches(msg, keys.Enter):
+ if row, ok := a.standings.RowAt(a.standingsCursor); ok {
+ return a.openSchedule(a.standingsLeague, row.Team)
+ }
+ }
+ return a, nil
+}
+
+// handleScheduleKey drives the schedule view: scroll, refresh, or return to
+// whichever view opened it.
+func (a App) handleScheduleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch {
+ case key.Matches(msg, keys.Back):
+ a.mode = a.prevMode
+ return a, nil
+ case key.Matches(msg, keys.Down):
+ a.scheduleScroll++
+ if m := a.scheduleScrollMax(); a.scheduleScroll > m {
+ a.scheduleScroll = m
+ }
+ case key.Matches(msg, keys.Up):
+ a.scheduleScroll--
+ if a.scheduleScroll < 0 {
+ a.scheduleScroll = 0
+ }
+ case key.Matches(msg, keys.Refresh):
+ if l, ok := model.LeagueByID(a.scheduleLeague); ok && a.schedule.Team.ID != "" {
+ return a, fetchTeamSchedule(a.client, l, a.schedule.Team.ID)
+ }
+ }
+ return a, nil
+}
+
+// handleSettingsKey drives the leagues settings screen: move the cursor,
+// toggle a league on/off, reorder enabled leagues, and close (which persists
+// the new order and refetches the enabled set).
+func (a App) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ rows, enabled := a.settingsRows()
+ n := len(rows)
+ switch {
+ case key.Matches(msg, keys.Back), key.Matches(msg, keys.Leagues):
+ return a.closeSettings()
+ case key.Matches(msg, keys.ResetLeagues):
+ return a.resetLeaguesToAuto()
+ case key.Matches(msg, keys.Up):
+ if a.settingsCursor > 0 {
+ a.settingsCursor--
+ }
+ case key.Matches(msg, keys.Down):
+ if a.settingsCursor < n-1 {
+ a.settingsCursor++
+ }
+ case key.Matches(msg, keys.Toggle):
+ if a.settingsCursor < n {
+ // Keep at least one league visible — hiding the last would blank
+ // every view (the fallback would just re-show it anyway).
+ if enabled[a.settingsCursor] && len(a.leagues) <= 1 {
+ return a, nil
}
+ a.toggleLeagueVisibility(rows[a.settingsCursor].ID)
+ a.applyAutoLeagues()
+ a.settingsDirty = true
+ }
+ case key.Matches(msg, keys.MoveUp):
+ // Reorder only applies within the visible group (top rows).
+ if a.settingsCursor > 0 && a.settingsCursor < len(a.leagues) {
+ a.moveLeague(a.settingsCursor, -1)
+ a.applyAutoLeagues()
+ a.settingsCursor--
+ a.settingsDirty = true
+ }
+ case key.Matches(msg, keys.MoveDown):
+ if a.settingsCursor < len(a.leagues)-1 {
+ a.moveLeague(a.settingsCursor, +1)
+ a.applyAutoLeagues()
+ a.settingsCursor++
+ a.settingsDirty = true
}
}
return a, nil
}
+// closeSettings leaves the settings screen, persisting only on an actual change
+// (so merely opening settings never writes config). Drops the active filter if
+// its league is now hidden, then refetches.
+func (a App) closeSettings() (tea.Model, tea.Cmd) {
+ a.mode = viewDashboard
+ if a.settingsDirty {
+ a.persistLeagues()
+ a.settingsDirty = false
+ }
+ if a.filter != "" {
+ if _, ok := a.leagueIndex()[a.filter]; !ok {
+ a.filter = ""
+ }
+ }
+ a.cursor = 0
+ a.loading = true
+ return a, fetchAll(a.client, a.leagues)
+}
+
+// resetLeaguesToAuto clears every per-league override, returning all leagues to
+// pure seasonal auto-hide (display order is kept).
+func (a App) resetLeaguesToAuto() (tea.Model, tea.Cmd) {
+ a.hide = map[model.LeagueID]bool{}
+ a.show = map[model.LeagueID]bool{}
+ a.applyAutoLeagues()
+ a.settingsDirty = true // persist the cleared overrides on close
+ a.settingsCursor = 0
+ return a, nil
+}
+
+// openDetail switches into the detail view for g, resetting per-game scroll,
+// ticker selection, and summary error, and kicking off its summary fetch.
+// Shared by the dashboard (open a game) and the ticker (switch games).
+func (a App) openDetail(g model.Game) (tea.Model, tea.Cmd) {
+ // Switching from one open game to another leaves a back-breadcrumb so the
+ // ticker floats the previous game to the front. Opening fresh from the
+ // dashboard clears it.
+ if a.mode == viewDetail && a.detail.ID != g.ID {
+ a.detailPrev = a.detail.ID
+ } else {
+ a.detailPrev = ""
+ }
+ a.detail = g
+ a.mode = viewDetail
+ a.detailScroll = 0
+ a.tickerCursor = 0
+ a.detailErr = nil
+ if l, ok := model.LeagueByID(g.League); ok {
+ return a, fetchDetail(a.client, l, g.ID)
+ }
+ return a, nil
+}
+
// cycleLeague steps the filter through [all, league0, league1, ...] in dir.
func (a *App) cycleLeague(dir int) {
- order := append([]model.LeagueID{""}, leagueIDs()...)
+ order := append([]model.LeagueID{""}, a.leagueIDs()...)
idx := 0
for i, id := range order {
if id == a.filter {
@@ -87,11 +401,3 @@ idx = (idx + dir + len(order)) % len(order)
a.filter = order[idx]
a.cursor = 0
}
-
-func leagueIDs() []model.LeagueID {
- ids := make([]model.LeagueID, len(model.Leagues))
- for i, l := range model.Leagues {
- ids[i] = l.ID
- }
- return ids
-}
internal/ui/views.go +51 −10
@@ -6,7 +6,7 @@ "strings"
"github.com/charmbracelet/lipgloss"
- "github.com/kortum/pts-tui/internal/model"
+ "github.com/humdrum-tiv/sportsball/internal/model"
)
const cardHeight = 5 // 3 content rows + 2 border rows
@@ -17,15 +17,16 @@ // top-cutoff bug), and a fixed footer.
func (a App) dashboardView() string {
header := a.header()
tabs := a.leagueTabs()
+ states := a.stateTabs()
footer := a.footer()
if a.loading && len(a.visible()) == 0 {
body := styleSubtle.Render(a.spinner.View() + " fetching scores…")
- return styleApp.Render(strings.Join([]string{header, tabs, "", body, "", footer}, "\n"))
+ return styleApp.Render(strings.Join([]string{header, tabs, states, "", body, "", footer}, "\n"))
}
- // Reserve rows for header, tabs, two blank spacers, and footer.
- const chrome = 5
+ // Reserve rows for header, league tabs, state tabs, two blank spacers, footer.
+ const chrome = 6
avail := a.height - chrome
if avail < 3 {
avail = 3
@@ -34,7 +35,7 @@
lines, selTop := a.bodyLines()
lines = scrollWindow(lines, selTop, avail)
- parts := []string{header, tabs, "", strings.Join(lines, "\n"), "", footer}
+ parts := []string{header, tabs, states, "", strings.Join(lines, "\n"), "", footer}
return styleApp.Render(strings.Join(parts, "\n"))
}
@@ -137,7 +138,7 @@ }
func (a App) leagueTabs() string {
tabs := []string{tab("ALL", a.filter == "")}
- for _, l := range model.Leagues {
+ for _, l := range a.leagues {
tabs = append(tabs, tab(l.Icon+" "+l.Abbr, a.filter == l.ID))
}
return strings.Join(tabs, styleFaint.Render("│"))
@@ -150,8 +151,37 @@ }
return styleLeagueTab.Render(label)
}
+// stateTabs is the chip row for the game-state filter (All/Live/Recent/Upcoming),
+// the active one highlighted like the league tabs.
+func (a App) stateTabs() string {
+ items := []struct {
+ f gameFilter
+ label string
+ }{
+ {filterAllStates, "All"},
+ {filterLive, "● Live"},
+ {filterRecent, "◼ Recent"},
+ {filterUpcoming, "○ Upcoming"},
+ }
+ chips := make([]string, len(items))
+ for i, it := range items {
+ chips[i] = tab(it.label, a.stateFilter == it.f)
+ }
+ return strings.Join(chips, styleFaint.Render("│"))
+}
+
func sectionHeader(s section) string {
- return lipgloss.NewStyle().Foreground(teamColor(s.color)).Bold(true).Render(s.title)
+ switch s.level {
+ case lvlDate:
+ // Date divider: muted, uppercased, with a faint rule tail for separation.
+ t := lipgloss.NewStyle().Foreground(colMuted).Bold(true).Render(strings.ToUpper(s.title))
+ return t + " " + styleFaint.Render(strings.Repeat("─", 4)+" ▓▒░")
+ case lvlLeague:
+ // Indented league subheader nested under a date divider.
+ return " " + lipgloss.NewStyle().Foreground(teamColor(s.color)).Bold(true).Render(s.title)
+ default:
+ return lipgloss.NewStyle().Foreground(teamColor(s.color)).Bold(true).Render(s.title)
+ }
}
func (a App) footer() string {
@@ -159,16 +189,27 @@ parts := []string{
keys.Up.Help().Key + " move",
keys.Enter.Help().Key + " open",
keys.NextLg.Help().Key + " league",
- keys.AllLg.Help().Key + " all",
keys.Refresh.Help().Key + " refresh",
+ keys.State.Help().Key + " filter",
+ keys.Standings.Help().Key + " standings",
+ "f/F fav",
+ "g/G sched",
+ keys.Theme.Help().Key + " theme",
+ keys.Leagues.Help().Key + " leagues",
keys.Quit.Help().Key + " quit",
}
- return styleHelp.Render(strings.Join(parts, styleFaint.Render(" · ")))
+ help := styleHelp.Render(strings.Join(parts, styleFaint.Render(" · ")))
+ name := styleFaint.Render(activeTheme.Name)
+ gap := a.width - lipgloss.Width(help) - lipgloss.Width(name)
+ if gap < 2 {
+ return help
+ }
+ return help + strings.Repeat(" ", gap) + name
}
func (a App) liveCount() int {
n := 0
- for _, l := range model.Leagues {
+ for _, l := range a.leagues {
for _, g := range a.games[l.ID] {
if g.State == model.StateLive {
n++
main.go +25 −5
@@ -1,6 +1,6 @@
-// Command pts is a terminal dashboard for live sports scores, schedules, and
-// box scores across the World Cup, MLB, NBA, NHL, and NFL — Plain Text Sports
-// meets Golazo, in a Bubble Tea TUI.
+// Command sportsball is a terminal dashboard for live sports scores, schedules,
+// and box scores across the World Cup, MLB, NBA, NHL, and NFL — Plain Text
+// Sports meets Golazo, in a Bubble Tea TUI.
package main
import (
@@ -9,13 +9,33 @@ "os"
tea "github.com/charmbracelet/bubbletea"
- "github.com/kortum/pts-tui/internal/ui"
+ "github.com/humdrum-tiv/sportsball/internal/ui"
)
+// version is the build version, overwritten at release time via
+// -ldflags "-X main.version=...". Defaults to "dev" for local builds.
+var version = "dev"
+
func main() {
+ if wantsVersion(os.Args[1:]) {
+ fmt.Println("sportsball", version)
+ return
+ }
+
p := tea.NewProgram(ui.New(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
- fmt.Fprintln(os.Stderr, "pts:", err)
+ fmt.Fprintln(os.Stderr, "sportsball:", err)
os.Exit(1)
}
}
+
+// wantsVersion reports whether the args request a version print.
+func wantsVersion(args []string) bool {
+ for _, a := range args {
+ switch a {
+ case "--version", "-v", "version":
+ return true
+ }
+ }
+ return false
+}