feat: pts — live sports TUI (initial)
92d4162d2286637bb8255982c5367d5048cb0cda
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-16 15:06
feat: pts — live sports TUI (initial) Terminal dashboard for live sports (World Cup, MLB, NBA, NHL, NFL) as a Bubble Tea TUI over ESPN's public scoreboard API. Core: - model/espn/ui three-layer split; one-direction deps (ui -> espn -> model) - concurrent per-league poll loop (15s) + breathing live-pulse animation loop - per-league fetch windows tuned to cadence (MLB 3/5d, NFL 10/10d, default 5/10d) with &limit=300 so dense slates aren't truncated - dashboard grid: per-league (all view) / Today-Upcoming-Past (single league), flat cursor, scroll-follow header-pinned layout Detail view: - full-page single game, refreshes in step with poll loop while open - ESPN summary endpoint -> league-agnostic GameDetail: soccer key-event timeline (mirrored home-left/away-right, newest-first live) + box-score tables for the stick-and-ball sports - oversized block-digit scoreline (all sports), /// ruled headers, ▓▒░ accents Favorites + config: - mark teams (f/F in detail), ★ on cards, pinned Favorites section + per-team last/next in league view - XDG config persistence (~/.config/pts), forgiving load, atomic save Theming: - Flexoki, background-adaptive text tokens (light + dark) Tests offline (in-memory fixtures): espn mapping/summary, config, favorites. Issues tracked in backlog/ (Backlog.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
39 files changed
.gitignore +0 −5
@@ -1,5 +0,0 @@
-# Built binary
-/pts
-# Go workspace / test artifacts
-*.out
-*.test
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 -->
backlog/config.yml +0 −17
@@ -1,17 +0,0 @@
-project_name: "PTS TUI"
-default_status: "To Do"
-statuses: ["To Do", "In Progress", "Done"]
-labels: []
-date_format: yyyy-mm-dd
-max_column_width: 20
-default_editor: "micro"
-auto_open_browser: true
-default_port: 6420
-remote_operations: false
-auto_commit: false
-filesystem_only: false
-zero_padded_ids: 3
-bypass_git_hooks: false
-check_active_branches: true
-active_branch_days: 30
-task_prefix: "TASK"
- → Distribution-packaging.md +0 −24
@@ -1,26 +0,0 @@
----
-id: TASK-001
-title: Distribution & packaging
-status: To Do
-assignee: []
-created_date: '2026-06-16 18:03'
-updated_date: '2026-06-16 18:27'
-labels:
- - feature
-dependencies: []
-priority: high
-ordinal: 1000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-Ship pts as an installable binary. GoReleaser config, Homebrew tap, 'go install' support, --version flag, GitHub Actions release on tag, cross-compile (darwin/linux, arm64/amd64).
-<!-- SECTION:DESCRIPTION:END -->
-
-## Acceptance Criteria
-<!-- AC:BEGIN -->
-- [ ] #1 go install works
-- [ ] #2 brew install from tap works
-- [ ] #3 tagged release publishes binaries
-<!-- AC:END -->
- → User-config-select-order-leagues.md +0 −23
@@ -1,25 +0,0 @@
----
-id: TASK-002
-title: 'User config: select & order leagues'
-status: To Do
-assignee: []
-created_date: '2026-06-16 18:03'
-labels:
- - feature
-dependencies: []
-priority: high
-ordinal: 2000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-Let users choose which leagues appear and in what order, instead of the hardcoded model.Leagues set. Persisted config (see config-persistence task).
-<!-- SECTION:DESCRIPTION:END -->
-
-## Acceptance Criteria
-<!-- AC:BEGIN -->
-- [ ] #1 user can enable/disable leagues
-- [ ] #2 league order is user-configurable
-- [ ] #3 choice persists across runs
-<!-- AC:END -->
- → Favorite-teams.md +0 −61
@@ -1,63 +0,0 @@
----
-id: TASK-003
-title: Favorite teams
-status: Done
-assignee:
- - '@humdrum-tiv'
-created_date: '2026-06-16 18:03'
-updated_date: '2026-06-16 21:26'
-labels:
- - feature
-dependencies: []
-priority: high
-ordinal: 3000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-User marks favorite teams. Favorites float to the top of the main dashboard view. In a league view, always show each favorite's last result and next fixture, even outside the current window.
-<!-- SECTION:DESCRIPTION:END -->
-
-## Acceptance Criteria
-<!-- AC:BEGIN -->
-- [x] #1 favorites pinned to top of dashboard
-- [x] #2 league view shows favorite last + next
-- [x] #3 favorites persist
-<!-- AC:END -->
-
-## Implementation Plan
-
-<!-- SECTION:PLAN:BEGIN -->
-Depends on TASK-004 config layer + team identity.
-1. model.Team gains ID string (ESPN team id); espn teamJSON.ID + mapTeam set it.
-2. App: cfg config.Config + favs map[string]bool (key=league+':'+teamID, fallback abbr). New() loads via config.Load(). Helpers favKey(league,team)/isFav(league,team)/toggleFav(league,team){mutate favs+cfg, config.Save best-effort}.
-3. Marking [detail view]: keys Fav(f)=toggle away, FavHome(F)=toggle home. Footer hint. Persist on toggle [AC#3].
-4. Cards: renderCard/teamRow take fav bool -> show ★ before favorite team abbr (components.go). bodyLines passes a.isFav per side.
-5. Ordering (sections.go) [AC#1/#2]:
- - all-leagues: prepend '★ Favorites' section = today's games across leagues involving a favorite; remove those from their per-league section (no dup).
- - single-league: prepend '★ Favorites' bucket before Today/Upcoming/Past = for each favorite team in league, its live game + most-recent final (last) + next scheduled (next) found within fetched window; dedup.
- - sections()/bodyLines() invariant preserved (bodyLines re-walks sections()).
-6. Out-of-window last/next (favorite's next match >7d away or last >2d ago) deferred to follow-up using ESPN team schedule endpoint -> new task.
-7. Tests: favKey stability; favorites-section selection from a fixture set.
-<!-- SECTION:PLAN:END -->
-
-## Final Summary
-
-<!-- SECTION:FINAL_SUMMARY:BEGIN -->
-Favorite teams: mark, persist, pin to top.
-
-What changed:
-- model.Team gains stable ESPN team ID (captured in espn mapping); favKey = league+':'+id (abbr fallback).
-- App loads favorites from config on start; toggleFav persists immediately via config.Save (best-effort) [AC#3].
-- Marking: in detail view, f toggles the away team, F the home team; footer hints added. ★ shown next to favorite teams on dashboard cards and in the detail score box.
-- Ordering [AC#1/#2]:
- * All-leagues dashboard: a '★ Favorites' section pinned on top with today's games involving a favorite, removed from their league section (no dupes).
- * Single-league view: a '★ Favorites' bucket above Today/Upcoming/Past showing each favorite's live game + most-recent final (last) + next scheduled fixture (next), drawn from the fetched window.
-
-Scope note: last/next resolve within the per-league fetch window; matches further out are deferred to TASK-009 (per-team schedule endpoint).
-
-Also in this branch (window infra): per-league fetch windows on model.League (MLB 3/5d, NFL 10/10d, soccer/default 5/10d) and a required &limit=300 on ScoreboardRange — the latter also fixes a latent bug where dense slates were silently truncated to ESPN's default cap.
-
-Tests: ui/favorites_test.go (favKey identity, hasFav, favoriteHighlights live/next/last selection + dedup + non-fav exclusion). All packages build/vet/test clean.
-<!-- SECTION:FINAL_SUMMARY:END -->
- → Config-persistence-layer.md +0 −49
@@ -1,51 +0,0 @@
----
-id: TASK-004
-title: Config & persistence layer
-status: Done
-assignee:
- - '@humdrum-tiv'
-created_date: '2026-06-16 18:03'
-updated_date: '2026-06-16 21:26'
-labels:
- - feature
-dependencies: []
-priority: high
-ordinal: 4000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-XDG config dir (~/.config/pts/). Persists selected leagues, favorite teams, and active theme. Prereq for league-selection, favorites, and theme-switch tasks.
-<!-- SECTION:DESCRIPTION:END -->
-
-## Acceptance Criteria
-<!-- AC:BEGIN -->
-- [x] #1 config read/written under XDG config dir
-- [x] #2 missing/corrupt config falls back to defaults
-<!-- AC:END -->
-
-## Implementation Plan
-
-<!-- SECTION:PLAN:BEGIN -->
-1. New package internal/config:
- - Config{Favorites []FavTeam json:favorites; Leagues []string json:leagues,omitempty; Theme string json:theme,omitempty}
- - FavTeam{League, ID, Abbr, Name string}
- - dir(): XDG_CONFIG_HOME or ~/.config, then /pts ; path(): dir/config.json
- - Load() (Config,error): missing file -> zero Config + nil; corrupt JSON -> zero Config + nil (defaults, swallow) [AC#2]
- - Save(Config) error: mkdir -p dir, marshal indent, atomic write (tmp+rename) [AC#1]
-2. Tests (config_test.go, offline, XDG_CONFIG_HOME=t.TempDir): missing->defaults; round-trip Save/Load; corrupt->defaults.
-<!-- SECTION:PLAN:END -->
-
-## Final Summary
-
-<!-- SECTION:FINAL_SUMMARY:BEGIN -->
-Added internal/config: XDG-based persistence for user prefs.
-
-- Config{Favorites, Leagues, Theme} stored as JSON at $XDG_CONFIG_HOME/pts/config.json (falls back to ~/.config/pts/) [AC#1].
-- Load() is forgiving: missing or corrupt file returns the zero Config (defaults), never an error, so the app always starts [AC#2].
-- Save() writes atomically (temp file + rename), creating the dir as needed.
-- Leagues/Theme fields reserved for TASK-002/TASK-005; only Favorites wired so far (TASK-003).
-
-Tests: config_test.go (offline, XDG_CONFIG_HOME=t.TempDir) covers missing->defaults, Save/Load round-trip, corrupt->defaults.
-<!-- SECTION:FINAL_SUMMARY:END -->
- → Theme-system-Flexoki-Humdrum-Uchu-lightdark.md +0 −23
@@ -1,25 +0,0 @@
----
-id: TASK-005
-title: 'Theme system (Flexoki/Humdrum/Uchu, light+dark)'
-status: To Do
-assignee: []
-created_date: '2026-06-16 18:03'
-labels:
- - feature
-dependencies: []
-priority: medium
-ordinal: 5000
----
-
-## 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.
-<!-- 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
-<!-- AC:END -->
- → Rich-live-game-detail-box-score-scorers.md +0 −50
@@ -1,52 +0,0 @@
----
-id: TASK-006
-title: 'Rich live game detail (box score, scorers)'
-status: Done
-assignee: []
-created_date: '2026-06-16 18:03'
-updated_date: '2026-06-16 20:51'
-labels:
- - feature
-dependencies: []
-priority: medium
-ordinal: 1000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-Extend the full-page detail view with ESPN summary endpoint (.../{sport}/{path}/summary?event={id}): goals/scorers + match events for soccer, box score for MLB/NBA/NHL/NFL. Own tea.Cmd keyed by game ID, refreshed while detail open.
-<!-- SECTION:DESCRIPTION:END -->
-
-## Acceptance Criteria
-<!-- AC:BEGIN -->
-- [x] #1 soccer detail shows goals/scorers + key events
-- [x] #2 detail auto-refreshes while open
-<!-- AC:END -->
-
-## Implementation Plan
-
-<!-- SECTION:PLAN:BEGIN -->
-1. model: add GameDetail{Events []MatchEvent; Leaders []TeamLeaders} + MatchEvent{Clock,Type,Text,ShortText,Team string; Scoring bool; Athletes []string} + TeamLeaders{Abbr string; Leaders []Leader{Category,Athlete,Value}}. model still imports nothing internal.
-2. espn: new summary.go — decode summary endpoint (keyEvents + leaders), add Client.Summary(ctx, league, eventID) returning model.GameDetail. Map soccer keyEvents->MatchEvent, leaders->TeamLeaders.
-3. ui/commands.go: detailMsg{ID,Data,Err} + fetchDetail(client,league,id) cmd.
-4. ui/app.go: App.detailData map[string]model.GameDetail + detailErr; handle detailMsg; on pollMsg when mode==detail also fetchDetail (auto-refresh, no new tick loop).
-5. ui/update.go: on Enter fire fetchDetail; on manual refresh in detail fire fetchDetail too.
-6. ui/detail.go: render eventsBlock (soccer: goals/cards/subs timeline, team-colored) and leadersBlock (other sports) below metaBlock.
-7. espn test: add TestSummary against in-memory fixture (offline).
-<!-- SECTION:PLAN:END -->
-
-## Final Summary
-
-<!-- SECTION:FINAL_SUMMARY:BEGIN -->
-Added rich game detail via ESPN summary endpoint.
-
-What changed:
-- model: new GameDetail/MatchEvent/TeamBox/StatGroup/PlayerRow league-agnostic types (model/detail.go).
-- espn: summary.go with Client.Summary(ctx, league, eventID); decodes keyEvents + boxscore.players from the summary payload (types.go) and maps to GameDetail. gzip auto-handled by the http client.
-- ui: detailData map keyed by event ID + detailErr/detailScroll on App. fetchDetail tea.Cmd fired on Enter, on manual refresh (r), and piggybacked on the 15s poll loop while detail is open (no new ticker). detail.go renders a soccer key-event timeline (goals/cards/subs, minute-stamped, team-colored) and per-team box-score stat tables for MLB/NBA/NHL/NFL, with column-drop-to-fit. Tall pages (box scores) top-align and scroll via Up/Down; short pages stay centered.
-
-Tests: TestMapSummarySoccerEvents + TestMapSummaryBoxScore (offline fixtures). Verified live decode end-to-end against EPL (goals+assists) and NFL (full box score) with a throwaway test, then deleted it. go build/vet/test all clean.
-
-User impact: opening a game now shows scorers/key events (soccer) or full box scores (other leagues), refreshing live while open.
-<!-- SECTION:FINAL_SUMMARY:END -->
- → Dashboard-polish.md +0 −22
@@ -1,24 +0,0 @@
----
-id: TASK-007
-title: Dashboard polish
-status: To Do
-assignee: []
-created_date: '2026-06-16 18:03'
-labels:
- - bug
-dependencies: []
-priority: low
-ordinal: 7000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-Trim verbose ESPN pre-game status text overlapping the start time on cards. Make Up/Down cursor column-aware in the grid instead of linear through the flat list.
-<!-- SECTION:DESCRIPTION:END -->
-
-## Acceptance Criteria
-<!-- AC:BEGIN -->
-- [ ] #1 pre-game card status no longer overlaps
-- [ ] #2 Up/Down navigates grid by column/row
-<!-- AC:END -->
- → Add-more-leagues.md +0 −23
@@ -1,25 +0,0 @@
----
-id: TASK-008
-title: Add more leagues
-status: To Do
-assignee: []
-created_date: '2026-06-16 21:06'
-labels:
- - feature
-dependencies: []
-priority: medium
-ordinal: 8000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-Append to model.Leagues with ESPN sport/path segments: WNBA (basketball/wnba), MLS (soccer/usa.1), NWSL (soccer/usa.nwsl), Premier League (soccer/eng.1), La Liga (soccer/esp.1), Bundesliga (soccer/ger.1), Serie A (soccer/ita.1), Ligue 1 (soccer/fra.1), Champions League (soccer/uefa.champions). Each needs accent color + glyph. Architecture already flows new leagues through fetch/grouping/tabs automatically. Coordinate with TASK-002 (user-selectable leagues) so the tab bar doesn't overflow.
-<!-- SECTION:DESCRIPTION:END -->
-
-## Acceptance Criteria
-<!-- AC:BEGIN -->
-- [ ] #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 -->
- → Favorite-last-next-beyond-fetch-window.md +0 −22
@@ -1,24 +0,0 @@
----
-id: TASK-009
-title: Favorite last/next beyond fetch window
-status: To Do
-assignee: []
-created_date: '2026-06-16 21:23'
-labels:
- - feature
-dependencies: []
-priority: low
-ordinal: 9000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-TASK-003 resolves a favorite's last result + next fixture from the per-league fetch window (e.g. NFL ±10d). When a favorite's next match is further out, or last result older than the window, the Favorites bucket can't show it. Add a per-favorite team schedule fetch (ESPN team schedule endpoint) to always resolve last+next regardless of window. Cache per team; refresh on poll.
-<!-- SECTION:DESCRIPTION:END -->
-
-## Acceptance Criteria
-<!-- 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 -->
- → Golazo-inspired-detail-view-polish.md +0 −40
@@ -1,42 +0,0 @@
----
-id: TASK-010
-title: Golazo-inspired detail view polish
-status: Done
-assignee:
- - '@humdrum-tiv'
-created_date: '2026-06-16 21:43'
-updated_date: '2026-06-16 21:51'
-labels:
- - feature
-dependencies: []
-priority: medium
-ordinal: 10000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-Borrow Golazo's single-match presentation for our detail window (no split pane). Big block-digit scoreline (all sports). Mirrored soccer event timeline (home-left/away-right) with more color. /// ruled section headers and ▓▒░ shading for dividers/accents. Richer color throughout. Stat bars + rich meta table noted as future follow-ups.
-<!-- SECTION:DESCRIPTION:END -->
-
-## Acceptance Criteria
-<!-- AC:BEGIN -->
-- [x] #1 scoreline rendered as large block digits in detail view, team-colored, all sports
-- [x] #2 soccer key events mirrored home-left/away-right with team color
-- [x] #3 section headers use /// rule fills; dividers/accents use block shading
-<!-- AC:END -->
-
-## Final Summary
-
-<!-- SECTION:FINAL_SUMMARY:BEGIN -->
-Golazo-inspired detail-view polish.
-
-- Big block scoreline: new internal/ui/blockfont.go (5-row block-digit font, 0-9 + dash). scoreSection renders away–home as oversized team-colored digits for ALL sports; live pops on goal-orange, winner in green, pre-game shows start time instead.
-- Matchup header line (matchupLine): centered 'AWAY vs HOME', team-colored, ★ on favorites, winner brightened.
-- Mirrored soccer event timeline: eventsBlock now splits events home-left / away-right around a center gutter (Golazo-style), so the scoring side reads at a glance; neutral events (kickoff/HT) centered + faint. Newest-first while live preserved. Minutes in goal-orange.
-- /// ruled section headers (ruleHeader) with ▓▒░ fade tail for Key Events; teamRuleHeader (team abbr accented) heads each box-score block. Box-score group labels recolored to purple accent.
-- More color throughout (accents, team colors, goal-orange minutes).
-No split pane / day tabs (out of scope per request).
-
-Verified rendering via throwaway test (block score + mirroring + headers all correct), then deleted it. go build/vet/test clean.
-<!-- SECTION:FINAL_SUMMARY:END -->
- → Team-stat-bars-in-detail-view.md +0 −22
@@ -1,24 +0,0 @@
----
-id: TASK-011
-title: Team stat bars in detail view
-status: To Do
-assignee: []
-created_date: '2026-06-16 21:52'
-labels:
- - feature
-dependencies: []
-priority: medium
-ordinal: 11000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-Golazo-style team statistics in the game detail: possession as a split gradient bar, shots / shots-on-target / corners / fouls as mirrored comparison bars. Data is already available but unused: ESPN soccer summary boxscore.teams[].statistics (possessionPct, totalShots, shotsOnTarget, wonCorners, foulsCommitted, saves, passes). Extend model.GameDetail + espn mapping to capture team-level stats; render bars with block shading (▓▒░). Also applies to MLB/NBA/NHL/NFL team statistics.
-<!-- 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
-<!-- AC:END -->
- → Rich-meta-table-in-detail-view.md +0 −22
@@ -1,24 +0,0 @@
----
-id: TASK-012
-title: Rich meta table in detail view
-status: To Do
-assignee: []
-created_date: '2026-06-16 21:52'
-labels:
- - feature
-dependencies: []
-priority: low
-ordinal: 12000
----
-
-## Description
-
-<!-- SECTION:DESCRIPTION:BEGIN -->
-Golazo-style label-aligned meta block: League / Venue / Date / Referee / Attendance / Half-time. ESPN summary gameInfo has venue.fullName, attendance, officials; HT score derivable from events/linescore. Map gameInfo into model.GameDetail and render an aligned label/value table in detailBox meta area.
-<!-- SECTION:DESCRIPTION:END -->
-
-## Acceptance Criteria
-<!-- AC:BEGIN -->
-- [ ] #1 detail meta shows venue, date, attendance, referee, HT when available
-- [ ] #2 label/value columns aligned
-<!-- AC:END -->
go.mod +0 −32
@@ -1,32 +0,0 @@
-module github.com/kortum/pts-tui
-
-go 1.26.4
-
-require (
- github.com/charmbracelet/bubbles v1.0.0
- github.com/charmbracelet/bubbletea v1.3.10
- github.com/charmbracelet/lipgloss v1.1.0
-)
-
-require (
- github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
- github.com/charmbracelet/colorprofile v0.4.1 // indirect
- github.com/charmbracelet/x/ansi v0.11.6 // indirect
- github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
- github.com/charmbracelet/x/term v0.2.2 // indirect
- github.com/clipperhouse/displaywidth v0.9.0 // indirect
- github.com/clipperhouse/stringish v0.1.1 // indirect
- github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
- github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
- github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mattn/go-localereader v0.0.1 // indirect
- github.com/mattn/go-runewidth v0.0.19 // indirect
- github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
- github.com/muesli/cancelreader v0.2.2 // indirect
- github.com/muesli/termenv v0.16.0 // indirect
- github.com/rivo/uniseg v0.4.7 // indirect
- github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- golang.org/x/sys v0.38.0 // indirect
- golang.org/x/text v0.3.8 // indirect
-)
go.sum +0 −50
@@ -1,50 +0,0 @@
-github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
-github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
-github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
-github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
-github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
-github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
-github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
-github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
-github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
-github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
-github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
-github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
-github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
-github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
-github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
-github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
-github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
-github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
-github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
-github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
-github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
-github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
-github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
-github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
-github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
-github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
-github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
-github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
-github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
-github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
-github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
-github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
-github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
-github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
-github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
-github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
-golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
-golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
-golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
-golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
internal/config/config.go +0 −99
@@ -1,99 +0,0 @@
-// Package config persists user preferences (favorite teams, selected leagues,
-// active theme) under the XDG config dir (~/.config/pts/config.json). Reads are
-// forgiving: a missing or corrupt file yields defaults rather than an error, so
-// the app always starts.
-package config
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
-)
-
-// 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)
-}
-
-// FavTeam identifies one favorited team. League+ID is the stable key; Abbr/Name
-// are stored for display and as a fallback when ID is unavailable.
-type FavTeam struct {
- League string `json:"league"`
- ID string `json:"id"`
- Abbr string `json:"abbr"`
- Name string `json:"name"`
-}
-
-// dir is the config directory: $XDG_CONFIG_HOME/pts, else ~/.config/pts.
-func dir() (string, error) {
- base := os.Getenv("XDG_CONFIG_HOME")
- if base == "" {
- home, err := os.UserHomeDir()
- if err != nil {
- return "", err
- }
- base = filepath.Join(home, ".config")
- }
- return filepath.Join(base, "pts"), nil
-}
-
-func path() (string, error) {
- d, err := dir()
- if err != nil {
- return "", err
- }
- return filepath.Join(d, "config.json"), nil
-}
-
-// Load reads the config. A missing or unreadable/corrupt file is not an error:
-// it returns the zero Config so callers can proceed with defaults.
-func Load() Config {
- p, err := path()
- if err != nil {
- return Config{}
- }
- data, err := os.ReadFile(p)
- if err != nil {
- return Config{}
- }
- var c Config
- if err := json.Unmarshal(data, &c); err != nil {
- return Config{} // corrupt → defaults
- }
- return c
-}
-
-// Save writes the config atomically (temp file + rename), creating the config
-// directory if needed.
-func Save(c Config) error {
- d, err := dir()
- if err != nil {
- return err
- }
- if err := os.MkdirAll(d, 0o755); err != nil {
- return err
- }
- data, err := json.MarshalIndent(c, "", " ")
- if err != nil {
- return err
- }
- p := filepath.Join(d, "config.json")
- tmp, err := os.CreateTemp(d, "config-*.json")
- if err != nil {
- return err
- }
- tmpName := tmp.Name()
- if _, err := tmp.Write(data); err != nil {
- tmp.Close()
- os.Remove(tmpName)
- return err
- }
- if err := tmp.Close(); err != nil {
- os.Remove(tmpName)
- return err
- }
- return os.Rename(tmpName, p)
-}
internal/config/config_test.go +0 −45
@@ -1,45 +0,0 @@
-package config
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestLoadMissingReturnsDefaults(t *testing.T) {
- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
- c := Load()
- if len(c.Favorites) != 0 || c.Theme != "" {
- t.Errorf("missing config not default: %+v", c)
- }
-}
-
-func TestSaveLoadRoundTrip(t *testing.T) {
- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
- want := Config{
- Favorites: []FavTeam{{League: "mlb", ID: "22", Abbr: "PHI", Name: "Phillies"}},
- Theme: "uchu",
- }
- if err := Save(want); err != nil {
- t.Fatal(err)
- }
- got := Load()
- if len(got.Favorites) != 1 || got.Favorites[0].ID != "22" || got.Theme != "uchu" {
- t.Errorf("round-trip mismatch: %+v", got)
- }
-}
-
-func TestLoadCorruptReturnsDefaults(t *testing.T) {
- tmp := t.TempDir()
- t.Setenv("XDG_CONFIG_HOME", tmp)
- dir := filepath.Join(tmp, "pts")
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{not json"), 0o644); err != nil {
- t.Fatal(err)
- }
- if c := Load(); len(c.Favorites) != 0 {
- t.Errorf("corrupt config not default: %+v", c)
- }
-}
internal/espn/client.go +0 −187
@@ -1,187 +0,0 @@
-// Package espn is a thin client over ESPN's public (unofficial) scoreboard
-// API. It fetches one league's scoreboard and maps the JSON into the
-// league-agnostic model types the UI consumes.
-package espn
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "sort"
- "strconv"
- "strings"
- "time"
-
- "github.com/kortum/pts-tui/internal/model"
-)
-
-const baseURL = "https://site.api.espn.com/apis/site/v2/sports"
-
-// Client fetches scoreboards. Safe for concurrent use.
-type Client struct {
- HTTP *http.Client
-}
-
-// New returns a Client with a sane default timeout.
-func New() *Client {
- return &Client{HTTP: &http.Client{Timeout: 10 * time.Second}}
-}
-
-// Scoreboard fetches and normalizes the current scoreboard for one league.
-// On a given calendar day ESPN returns that day's slate; games carry their
-// own state (pre/live/final) so the dashboard can bucket them.
-func (c *Client) Scoreboard(ctx context.Context, l model.League) ([]model.Game, error) {
- return c.scoreboard(ctx, l, "")
-}
-
-// ScoreboardRange fetches games for the inclusive [start, end] day window so
-// the UI can show recent finals and upcoming schedules, not just today. ESPN
-// accepts a "YYYYMMDD-YYYYMMDD" dates param.
-func (c *Client) ScoreboardRange(ctx context.Context, l model.League, start, end time.Time) ([]model.Game, error) {
- // limit is required: without it ESPN caps results (~50) and silently
- // truncates dense, wide-window slates (e.g. MLB across 10+ days).
- q := fmt.Sprintf("?dates=%s-%s&limit=300", start.Format("20060102"), end.Format("20060102"))
- return c.scoreboard(ctx, l, q)
-}
-
-func (c *Client) scoreboard(ctx context.Context, l model.League, query string) ([]model.Game, error) {
- url := fmt.Sprintf("%s/%s/%s/scoreboard%s", baseURL, l.Sport, l.Path, query)
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
- if err != nil {
- return nil, err
- }
- resp, err := c.HTTP.Do(req)
- if err != nil {
- return nil, fmt.Errorf("fetch %s: %w", l.ID, err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("fetch %s: status %d", l.ID, resp.StatusCode)
- }
-
- var sb scoreboard
- if err := json.NewDecoder(resp.Body).Decode(&sb); err != nil {
- return nil, fmt.Errorf("decode %s: %w", l.ID, err)
- }
-
- games := make([]model.Game, 0, len(sb.Events))
- for _, ev := range sb.Events {
- if g, ok := mapEvent(l.ID, ev); ok {
- games = append(games, g)
- }
- }
- sortGames(games)
- return games, nil
-}
-
-func mapEvent(id model.LeagueID, ev event) (model.Game, bool) {
- if len(ev.Competitions) == 0 {
- return model.Game{}, false
- }
- comp := ev.Competitions[0]
- st := comp.Status
- if st.Type.State == "" {
- st = ev.Status
- }
-
- g := model.Game{
- ID: ev.ID,
- League: id,
- Start: parseTime(ev.Date),
- State: mapState(st.Type.State),
- Detail: strings.TrimSpace(st.Type.Detail),
- Clock: st.DisplayClock,
- Period: st.Period,
- Venue: comp.Venue.FullName,
- }
- if len(comp.Headlines) > 0 {
- g.Headline = comp.Headlines[0].Description
- }
-
- for _, cmp := range comp.Competitors {
- t := mapTeam(cmp)
- if cmp.HomeAway == "home" {
- g.Home = t
- } else {
- g.Away = t
- }
- }
- return g, true
-}
-
-func mapTeam(c competitor) model.Team {
- name := c.Team.ShortDisplayName
- if name == "" {
- name = c.Team.Name
- }
- return model.Team{
- ID: c.Team.ID,
- Abbr: c.Team.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,
- }
-}
-
-// overallRecord returns the "total" record summary if present, else the first.
-func overallRecord(rs []record) string {
- for _, r := range rs {
- if r.Type == "total" {
- return r.Summary
- }
- }
- if len(rs) > 0 {
- return rs[0].Summary
- }
- return ""
-}
-
-func mapState(s string) model.State {
- switch s {
- case "in":
- return model.StateLive
- case "post":
- return model.StateFinal
- default:
- return model.StatePre
- }
-}
-
-// sortGames orders live first, then upcoming (by start), then finals.
-func sortGames(gs []model.Game) {
- rank := func(s model.State) int {
- switch s {
- case model.StateLive:
- return 0
- case model.StatePre:
- return 1
- default:
- return 2
- }
- }
- sort.SliceStable(gs, func(i, j int) bool {
- if rank(gs[i].State) != rank(gs[j].State) {
- return rank(gs[i].State) < rank(gs[j].State)
- }
- return gs[i].Start.Before(gs[j].Start)
- })
-}
-
-func parseTime(s string) time.Time {
- t, err := time.Parse("2006-01-02T15:04Z", s)
- if err != nil {
- // ESPN sometimes uses full RFC3339 with seconds.
- t, _ = time.Parse(time.RFC3339, s)
- }
- return t.Local()
-}
-
-func atoi(s string) int {
- n, _ := strconv.Atoi(strings.TrimSpace(s))
- return n
-}
internal/espn/client_test.go +0 −130
@@ -1,130 +0,0 @@
-package espn
-
-import (
- "testing"
-
- "github.com/kortum/pts-tui/internal/model"
-)
-
-func TestMapState(t *testing.T) {
- cases := map[string]model.State{
- "pre": model.StatePre,
- "in": model.StateLive,
- "post": model.StateFinal,
- "unknown": model.StatePre,
- }
- for in, want := range cases {
- if got := mapState(in); got != want {
- t.Errorf("mapState(%q) = %v, want %v", in, got, want)
- }
- }
-}
-
-func TestMapEventScoresAndHomeAway(t *testing.T) {
- ev := event{
- ID: "401",
- Date: "2026-06-16T22:40Z",
- Competitions: []competition{{
- Status: status{Type: statusType{State: "in", Detail: "Top 5th"}, Period: 5},
- Competitors: []competitor{
- {HomeAway: "home", Score: "3", Team: teamJSON{Abbreviation: "PHI"},
- Records: []record{{Type: "total", Summary: "40-30"}}},
- {HomeAway: "away", Score: "5", Winner: true, Team: teamJSON{Abbreviation: "MIA"}},
- },
- }},
- }
- g, ok := mapEvent(model.MLB, ev)
- if !ok {
- t.Fatal("mapEvent returned ok=false")
- }
- if g.State != model.StateLive {
- t.Errorf("state = %v, want Live", g.State)
- }
- if g.Home.Abbr != "PHI" || g.Home.Score != 3 || g.Home.Record != "40-30" {
- t.Errorf("home mapped wrong: %+v", g.Home)
- }
- if g.Away.Abbr != "MIA" || g.Away.Score != 5 || !g.Away.Winner {
- t.Errorf("away mapped wrong: %+v", g.Away)
- }
-}
-
-func TestSortGamesLiveFirst(t *testing.T) {
- gs := []model.Game{
- {State: model.StateFinal},
- {State: model.StatePre},
- {State: model.StateLive},
- }
- sortGames(gs)
- if gs[0].State != model.StateLive || gs[1].State != model.StatePre || gs[2].State != model.StateFinal {
- t.Errorf("sort order wrong: %v %v %v", gs[0].State, gs[1].State, gs[2].State)
- }
-}
-
-func TestMapSummarySoccerEvents(t *testing.T) {
- s := summary{
- KeyEvents: []keyEvent{
- {
- Type: eventType{Text: "Goal", Type: "goal"},
- ShortText: "Jaidon Anthony Goal",
- Clock: eventClock{DisplayValue: "8'"},
- Period: eventPeriod{Number: 1},
- ScoringPlay: true,
- Team: eventTeam{DisplayName: "Burnley"},
- Participants: []participant{
- {Athlete: athleteRef{DisplayName: "Jaidon Anthony"}},
- },
- },
- {
- Type: eventType{Text: "Yellow Card", Type: "yellow-card"},
- Clock: eventClock{DisplayValue: "23'"},
- Team: eventTeam{DisplayName: "Aston Villa"},
- },
- },
- }
- d := mapSummary(s)
- if len(d.Events) != 2 {
- t.Fatalf("events = %d, want 2", len(d.Events))
- }
- g := d.Events[0]
- if !g.Scoring || g.Clock != "8'" || g.Team != "Burnley" {
- t.Errorf("goal mapped wrong: %+v", g)
- }
- if len(g.Athletes) != 1 || g.Athletes[0] != "Jaidon Anthony" {
- t.Errorf("scorer mapped wrong: %+v", g.Athletes)
- }
- if d.Events[1].Type != "Yellow Card" {
- t.Errorf("card type = %q", d.Events[1].Type)
- }
-}
-
-func TestMapSummaryBoxScore(t *testing.T) {
- s := summary{
- Boxscore: boxscore{
- Players: []playerBox{{
- Team: teamJSON{Abbreviation: "PHI", DisplayName: "Philadelphia Phillies"},
- Statistics: []statGroup{{
- Type: "batting",
- Labels: []string{"AB", "H", "RBI"},
- Athletes: []athleteRow{
- {Athlete: athleteRef{ShortName: "B. Marsh"}, Stats: []string{"4", "2", "1"}},
- },
- }},
- }},
- },
- }
- d := mapSummary(s)
- if len(d.BoxScore) != 1 {
- t.Fatalf("teams = %d, want 1", len(d.BoxScore))
- }
- tb := d.BoxScore[0]
- if tb.Abbr != "PHI" || len(tb.Groups) != 1 {
- t.Fatalf("team mapped wrong: %+v", tb)
- }
- grp := tb.Groups[0]
- if grp.Name != "batting" || len(grp.Rows) != 1 || grp.Rows[0].Athlete != "B. Marsh" {
- t.Errorf("group mapped wrong: %+v", grp)
- }
- if len(grp.Rows[0].Stats) != 3 || grp.Rows[0].Stats[1] != "2" {
- t.Errorf("stats mapped wrong: %+v", grp.Rows[0].Stats)
- }
-}
internal/espn/summary.go +0 −97
@@ -1,97 +0,0 @@
-package espn
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
-
- "github.com/kortum/pts-tui/internal/model"
-)
-
-// Summary fetches the deeper per-game data (key events for soccer, box score
-// for the stick-and-ball sports) from ESPN's summary endpoint and maps it into
-// a league-agnostic model.GameDetail. This is the richer fetch behind the
-// detail view, keyed by ESPN event ID.
-func (c *Client) Summary(ctx context.Context, l model.League, eventID string) (model.GameDetail, error) {
- url := fmt.Sprintf("%s/%s/%s/summary?event=%s", baseURL, l.Sport, l.Path, eventID)
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
- if err != nil {
- return model.GameDetail{}, err
- }
- resp, err := c.HTTP.Do(req)
- if err != nil {
- return model.GameDetail{}, fmt.Errorf("summary %s: %w", eventID, err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return model.GameDetail{}, fmt.Errorf("summary %s: status %d", eventID, resp.StatusCode)
- }
-
- var s summary
- if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
- return model.GameDetail{}, fmt.Errorf("decode summary %s: %w", eventID, err)
- }
- return mapSummary(s), nil
-}
-
-func mapSummary(s summary) model.GameDetail {
- d := model.GameDetail{}
- for _, e := range s.KeyEvents {
- d.Events = append(d.Events, mapKeyEvent(e))
- }
- for _, tb := range s.Boxscore.Players {
- d.BoxScore = append(d.BoxScore, mapTeamBox(tb))
- }
- return d
-}
-
-func mapKeyEvent(e keyEvent) model.MatchEvent {
- me := model.MatchEvent{
- Clock: e.Clock.DisplayValue,
- Period: e.Period.Number,
- Type: strings.TrimSpace(e.Type.Text),
- Text: strings.TrimSpace(e.Text),
- ShortText: strings.TrimSpace(e.ShortText),
- Team: e.Team.DisplayName,
- Scoring: e.ScoringPlay,
- }
- for _, p := range e.Participants {
- if n := p.Athlete.DisplayName; n != "" {
- me.Athletes = append(me.Athletes, n)
- }
- }
- return me
-}
-
-func mapTeamBox(tb playerBox) model.TeamBox {
- box := model.TeamBox{
- Abbr: tb.Team.Abbreviation,
- Name: tb.Team.DisplayName,
- }
- for _, st := range tb.Statistics {
- g := model.StatGroup{
- Name: firstNonEmpty(st.Name, st.Type, st.DisplayName),
- Labels: st.Labels,
- }
- for _, a := range st.Athletes {
- name := a.Athlete.ShortName
- if name == "" {
- name = a.Athlete.DisplayName
- }
- g.Rows = append(g.Rows, model.PlayerRow{Athlete: name, Stats: a.Stats})
- }
- box.Groups = append(box.Groups, g)
- }
- return box
-}
-
-func firstNonEmpty(ss ...string) string {
- for _, s := range ss {
- if s != "" {
- return s
- }
- }
- return ""
-}
internal/espn/types.go +0 −139
@@ -1,139 +0,0 @@
-package espn
-
-// These types mirror only the fields we consume from ESPN's public
-// "site.api.espn.com" scoreboard JSON. Many fields are omitted on purpose;
-// the payload is large and we map the rest away in client.go.
-
-type scoreboard struct {
- Events []event `json:"events"`
-}
-
-type event struct {
- ID string `json:"id"`
- Date string `json:"date"`
- Name string `json:"name"`
- ShortName string `json:"shortName"`
- Status status `json:"status"`
- Competitions []competition `json:"competitions"`
-}
-
-type competition struct {
- Venue venue `json:"venue"`
- Competitors []competitor `json:"competitors"`
- Status status `json:"status"`
- Headlines []headline `json:"headlines"`
-}
-
-type venue struct {
- FullName string `json:"fullName"`
-}
-
-type headline struct {
- ShortText string `json:"shortLinkText"`
- Description string `json:"description"`
-}
-
-type competitor struct {
- HomeAway string `json:"homeAway"`
- Winner bool `json:"winner"`
- Score string `json:"score"` // ESPN sends score as a string
- Team teamJSON `json:"team"`
- Records []record `json:"records"`
-}
-
-type record struct {
- Type string `json:"type"`
- Summary string `json:"summary"`
-}
-
-type teamJSON struct {
- ID string `json:"id"`
- Abbreviation string `json:"abbreviation"`
- Name string `json:"name"`
- ShortDisplayName string `json:"shortDisplayName"`
- DisplayName string `json:"displayName"`
- Color string `json:"color"`
- AlternateColor string `json:"alternateColor"`
-}
-
-type status struct {
- DisplayClock string `json:"displayClock"`
- Period int `json:"period"`
- Type statusType `json:"type"`
-}
-
-type statusType struct {
- State string `json:"state"` // "pre" | "in" | "post"
- Completed bool `json:"completed"`
- Detail string `json:"detail"`
- ShortDetail string `json:"shortDetail"`
-}
-
-// --- summary endpoint -------------------------------------------------------
-// These mirror only the slices of the (large) summary payload we render in the
-// detail view: soccer keyEvents and the per-team player box score.
-
-type summary struct {
- KeyEvents []keyEvent `json:"keyEvents"`
- Boxscore boxscore `json:"boxscore"`
-}
-
-type keyEvent struct {
- Type eventType `json:"type"`
- Text string `json:"text"`
- ShortText string `json:"shortText"`
- Period eventPeriod `json:"period"`
- Clock eventClock `json:"clock"`
- ScoringPlay bool `json:"scoringPlay"`
- Team eventTeam `json:"team"`
- Participants []participant `json:"participants"`
-}
-
-type eventType struct {
- Text string `json:"text"`
- Type string `json:"type"`
-}
-
-type eventPeriod struct {
- Number int `json:"number"`
-}
-
-type eventClock struct {
- DisplayValue string `json:"displayValue"`
-}
-
-type eventTeam struct {
- ID string `json:"id"`
- DisplayName string `json:"displayName"`
-}
-
-type participant struct {
- Athlete athleteRef `json:"athlete"`
-}
-
-type athleteRef struct {
- DisplayName string `json:"displayName"`
- ShortName string `json:"shortName"`
-}
-
-type boxscore struct {
- Players []playerBox `json:"players"`
-}
-
-type playerBox struct {
- Team teamJSON `json:"team"`
- Statistics []statGroup `json:"statistics"`
-}
-
-type statGroup struct {
- Name string `json:"name"`
- Type string `json:"type"`
- DisplayName string `json:"displayName"`
- Labels []string `json:"labels"`
- Athletes []athleteRow `json:"athletes"`
-}
-
-type athleteRow struct {
- Athlete athleteRef `json:"athlete"`
- Stats []string `json:"stats"`
-}
internal/model/detail.go +0 −43
@@ -1,43 +0,0 @@
-package model
-
-// GameDetail is the deeper, on-demand data for a single game, fetched from
-// 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)
-}
-
-// MatchEvent is one notable in-match moment (goal, card, substitution…).
-type MatchEvent struct {
- Clock string // display minute, e.g. "8'"
- Period int // 1=first half, 2=second…
- 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
- Athletes []string // involved players (scorer first, then assists)
- Scoring bool // true for goals
-}
-
-// TeamBox is one team's box score: a set of stat groups (batting/pitching,
-// passing/rushing, a single group for basketball, …).
-type TeamBox struct {
- Abbr string
- Name string
- Groups []StatGroup
-}
-
-// StatGroup is one labeled table of player rows (e.g. "passing").
-type StatGroup struct {
- Name string // group label, e.g. "batting", "passing"
- Labels []string // column headers, parallel to each PlayerRow.Stats
- Rows []PlayerRow
-}
-
-// PlayerRow is one athlete's stat line within a group.
-type PlayerRow struct {
- Athlete string
- Stats []string // parallel to the group's Labels
-}
internal/model/game.go +0 −57
@@ -1,57 +0,0 @@
-// Package model holds league-agnostic domain types used across the UI.
-// ESPN-specific JSON is decoded in the espn package and mapped into these.
-package model
-
-import "time"
-
-// State is the coarse lifecycle of a game, normalized from ESPN's
-// status.type.state ("pre" | "in" | "post").
-type State int
-
-const (
- StatePre State = iota // scheduled, not started
- StateLive // in progress
- StateFinal // completed
-)
-
-func (s State) String() string {
- switch s {
- case StateLive:
- return "LIVE"
- case StateFinal:
- return "FINAL"
- default:
- return "SCHED"
- }
-}
-
-// Team is one side of a competition.
-type Team struct {
- ID string // stable ESPN team id, used to key favorites
- Abbr string // e.g. "PHI"
- Name string // short display name, e.g. "Phillies"
- FullName string // e.g. "Philadelphia Phillies"
- Color string // primary hex color w/o leading '#', from ESPN
- AltColor string
- Score int
- Record string // e.g. "40-30"
- Winner bool
-}
-
-// Game is a single matchup, normalized across all leagues.
-type Game struct {
- ID string
- League LeagueID
- Start time.Time
- State State
- Home Team
- Away Team
- Detail string // status.type.detail, e.g. "Top 5th", "Final/10", "6:40 PM EDT"
- Clock string // displayClock when live
- Period int // inning / quarter / half
- Venue string
- Headline string // optional recap/odds blurb
-}
-
-// Started reports whether play has begun (live or final).
-func (g Game) Started() bool { return g.State != StatePre }
internal/model/league.go +0 −68
@@ -1,68 +0,0 @@
-package model
-
-// LeagueID identifies a supported league.
-type LeagueID string
-
-const (
- WorldCup LeagueID = "worldcup"
- MLB LeagueID = "mlb"
- NBA LeagueID = "nba"
- NHL LeagueID = "nhl"
- NFL LeagueID = "nfl"
-)
-
-// League is static metadata for a supported competition.
-type League struct {
- ID LeagueID
- Name string // display name, e.g. "World Cup"
- Abbr string // short tag, e.g. "WC"
- Sport string // ESPN sport path segment, e.g. "baseball"
- Path string // ESPN league path segment, e.g. "mlb"
- Color string // accent hex (no '#') for UI theming
- Icon string // single-rune emoji/glyph
-
- // Fetch window in days, tuned to game cadence: daily sports (MLB) need
- // 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
-}
-
-// 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.
-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},
-}
-
-// Default fetch window (days) for leagues that don't specify one.
-const (
- DefaultWindowBack = 5
- DefaultWindowForward = 10
-)
-
-// Window returns the league's fetch window in days, applying defaults.
-func (l League) Window() (back, forward int) {
- back, forward = l.WindowBack, l.WindowForward
- if back == 0 {
- back = DefaultWindowBack
- }
- if forward == 0 {
- forward = DefaultWindowForward
- }
- return back, forward
-}
-
-// LeagueByID returns the League metadata for id, ok=false if unknown.
-func LeagueByID(id LeagueID) (League, bool) {
- for _, l := range Leagues {
- if l.ID == id {
- return l, true
- }
- }
- return League{}, false
-}
internal/ui/app.go +0 −155
@@ -1,155 +0,0 @@
-// Package ui is the Bubble Tea front end: a root App model that owns all
-// game data and switches between the dashboard grid and a single-game
-// detail view. Data flows in via async commands (see commands.go).
-package ui
-
-import (
- "math"
-
- "github.com/charmbracelet/bubbles/spinner"
- tea "github.com/charmbracelet/bubbletea"
-
- "github.com/kortum/pts-tui/internal/config"
- "github.com/kortum/pts-tui/internal/espn"
- "github.com/kortum/pts-tui/internal/model"
-)
-
-type viewMode int
-
-const (
- viewDashboard viewMode = iota
- viewDetail
-)
-
-// App is the root Bubble Tea model.
-type App struct {
- client *espn.Client
-
- width, height int
-
- games map[model.LeagueID][]model.Game
- 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
-
- 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
-
- cfg config.Config // persisted preferences
- favs map[string]bool // favorite team keys (see favKey)
-
- spinner spinner.Model
- phase float64 // animation phase accumulator (radians)
-}
-
-// New builds the initial App.
-func New() App {
- sp := spinner.New()
- sp.Spinner = spinner.Dot
- cfg := config.Load()
- return 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{},
- cfg: cfg,
- favs: loadFavorites(cfg),
- loading: true,
- spinner: sp,
- }
-}
-
-// Init kicks off the first fetch and starts the poll + animation loops.
-func (a App) Init() tea.Cmd {
- return tea.Batch(
- fetchAll(a.client),
- pollTick(),
- animTick(),
- a.spinner.Tick,
- )
-}
-
-func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- a.width, a.height = msg.Width, msg.Height
- return a, nil
-
- case tea.KeyMsg:
- return a.handleKey(msg)
-
- case gamesMsg:
- a.loaded[msg.League] = true
- if msg.Err != nil {
- a.errs[msg.League] = msg.Err
- } else {
- delete(a.errs, msg.League)
- a.games[msg.League] = msg.Games
- }
- if len(a.loaded) >= len(model.Leagues) {
- a.loading = false
- }
- a.clampCursor()
- return a, nil
-
- case detailMsg:
- if msg.Err != nil {
- a.detailErr = msg.Err
- } else {
- a.detailErr = nil
- a.detailData[msg.ID] = msg.Data
- }
- return a, nil
-
- case pollMsg:
- cmds := []tea.Cmd{fetchAll(a.client), 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 {
- if l, ok := model.LeagueByID(a.detail.League); ok {
- cmds = append(cmds, fetchDetail(a.client, l, a.detail.ID))
- }
- }
- return a, tea.Batch(cmds...)
-
- case animMsg:
- a.phase += 0.18
- if a.phase > 2*math.Pi {
- a.phase -= 2 * math.Pi
- }
- return a, animTick()
-
- case spinner.TickMsg:
- var cmd tea.Cmd
- a.spinner, cmd = a.spinner.Update(msg)
- return a, cmd
- }
- return a, nil
-}
-
-func (a App) View() string {
- if a.mode == viewDetail {
- return a.detailView()
- }
- return a.dashboardView()
-}
-
-// pulse is the current live-indicator brightness in [0,1].
-func (a App) pulse() float64 { return (math.Sin(a.phase) + 1) / 2 }
-
-func (a *App) clampCursor() {
- n := len(a.visible())
- if a.cursor >= n {
- a.cursor = n - 1
- }
- if a.cursor < 0 {
- a.cursor = 0
- }
-}
internal/ui/blockfont.go +0 −60
@@ -1,60 +0,0 @@
-package ui
-
-import (
- "strings"
-
- "github.com/charmbracelet/lipgloss"
-)
-
-// A 5-row block font for the oversized detail-view scoreline. Each glyph is 6
-// cells wide with 2-cell-thick strokes, so vertical strokes read as bold as
-// the horizontals — rendered with the full block rune for the app's signature
-// flair.
-const blockRows = 5
-
-var blockGlyphs = map[rune][blockRows]string{
- '0': {"██████", "██ ██", "██ ██", "██ ██", "██████"},
- '1': {" ██ ", "████ ", " ██ ", " ██ ", "██████"},
- '2': {"██████", " ██", "██████", "██ ", "██████"},
- '3': {"██████", " ██", " █████", " ██", "██████"},
- '4': {"██ ██", "██ ██", "██████", " ██", " ██"},
- '5': {"██████", "██ ", "██████", " ██", "██████"},
- '6': {"██████", "██ ", "██████", "██ ██", "██████"},
- '7': {"██████", " ██", " ██ ", " ██ ", " ██ "},
- '8': {"██████", "██ ██", "██████", "██ ██", "██████"},
- '9': {"██████", "██ ██", "██████", " ██", "██████"},
- '-': {" ", " ", "██████", " ", " "},
- ' ': {" ", " ", " ", " ", " "},
-}
-
-// blockNumber renders a string of digits (and '-'/' ') as a 5-row block, each
-// row a single string. Unknown runes render as blank glyphs.
-func blockNumber(s string) []string {
- rows := make([]string, blockRows)
- for i, r := range s {
- g, ok := blockGlyphs[r]
- if !ok {
- g = blockGlyphs[' ']
- }
- for row := 0; row < blockRows; row++ {
- if i > 0 {
- rows[row] += " " // 1-cell gap between glyphs
- }
- rows[row] += g[row]
- }
- }
- return rows
-}
-
-// blockScore renders "away - home" as one big colored block: away digits in
-// awayCol, home in homeCol, the dash muted. Returns a multi-line string.
-func blockScore(away, home string, awayCol, homeCol lipgloss.TerminalColor) string {
- render := func(s string, c lipgloss.TerminalColor) string {
- return lipgloss.NewStyle().Foreground(c).Bold(true).
- Render(strings.Join(blockNumber(s), "\n"))
- }
- gap := strings.Join(blockNumber(" "), "\n")
- dash := lipgloss.NewStyle().Foreground(colMuted).Render(strings.Join(blockNumber("-"), "\n"))
- return lipgloss.JoinHorizontal(lipgloss.Top,
- render(away, awayCol), gap, dash, gap, render(home, homeCol))
-}
internal/ui/commands.go +0 −88
@@ -1,88 +0,0 @@
-package ui
-
-import (
- "context"
- "time"
-
- tea "github.com/charmbracelet/bubbletea"
-
- "github.com/kortum/pts-tui/internal/espn"
- "github.com/kortum/pts-tui/internal/model"
-)
-
-// --- Messages ---------------------------------------------------------------
-
-// gamesMsg carries a finished scoreboard fetch for one league.
-type gamesMsg struct {
- League model.LeagueID
- Games []model.Game
- Err error
-}
-
-// detailMsg carries a finished summary fetch for one open game, keyed by ID.
-type detailMsg struct {
- ID string
- Data model.GameDetail
- Err error
-}
-
-// pollMsg fires on the data-refresh interval to trigger a new fetch round.
-type pollMsg struct{}
-
-// animMsg fires on the fast animation tick to advance pulses/transitions.
-type animMsg time.Time
-
-// 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
-)
-
-// --- Commands ---------------------------------------------------------------
-
-// fetchLeague returns a command that fetches one league's games across the
-// Past→Upcoming window.
-func fetchLeague(c *espn.Client, l model.League) tea.Cmd {
- return func() tea.Msg {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- now := time.Now()
- back, forward := l.Window()
- start := now.AddDate(0, 0, -back)
- end := now.AddDate(0, 0, forward)
- games, err := c.ScoreboardRange(ctx, l, start, end)
- return gamesMsg{League: l.ID, Games: games, Err: err}
- }
-}
-
-// fetchDetail returns a command that pulls the summary (key events / box
-// score) for one game. Keyed by event ID so the detail view can refresh the
-// open game without re-fetching everything.
-func fetchDetail(c *espn.Client, l model.League, id string) tea.Cmd {
- return func() tea.Msg {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- data, err := c.Summary(ctx, l, id)
- return detailMsg{ID: id, Data: data, Err: err}
- }
-}
-
-// fetchAll fans out a fetch across every supported league 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 {
- cmds[i] = fetchLeague(c, l)
- }
- return tea.Batch(cmds...)
-}
-
-// pollTick schedules the next data refresh.
-func pollTick() tea.Cmd {
- return tea.Tick(pollInterval, func(time.Time) tea.Msg { return pollMsg{} })
-}
-
-// animTick schedules the next animation frame.
-func animTick() tea.Cmd {
- return tea.Tick(animInterval, func(t time.Time) tea.Msg { return animMsg(t) })
-}
internal/ui/components.go +0 −125
@@ -1,125 +0,0 @@
-package ui
-
-import (
- "fmt"
- "strings"
-
- "github.com/charmbracelet/lipgloss"
-
- "github.com/kortum/pts-tui/internal/model"
-)
-
-const cardWidth = 34
-
-// renderCard draws one game as a bordered card. pulse in [0,1] drives the
-// live indicator brightness; selected swaps in the accent border. favAway/
-// favHome mark a team with a star.
-func renderCard(g model.Game, selected bool, pulse float64, favAway, favHome bool) string {
- var b strings.Builder
-
- b.WriteString(statusLine(g, pulse, selected))
- b.WriteString("\n")
- b.WriteString(teamRow(g.Away, g, g.Away.Winner, favAway))
- b.WriteString("\n")
- b.WriteString(teamRow(g.Home, g, g.Home.Winner, favHome))
-
- style := styleCard
- if selected {
- style = styleCardSelected
- }
- return style.Width(cardWidth).Render(b.String())
-}
-
-// statusLine is the card header: state badge + clock/detail, right-aligned.
-// When selected, a leading accent marker reinforces the highlight border.
-func statusLine(g model.Game, pulse float64, selected bool) string {
- var left string
- switch g.State {
- case model.StateLive:
- left = liveDot(pulse) + " " + lipgloss.NewStyle().
- Foreground(colLive).Bold(true).Render("LIVE")
- case model.StateFinal:
- left = styleFinal.Render("◼ FINAL")
- default:
- left = styleSubtle.Render("○ " + startLabel(g))
- }
-
- marker := " "
- if selected {
- marker = lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render("▸ ")
- }
- left = marker + left
-
- // Pre-game already shows the start time on the left; ESPN's verbose detail
- // ("Tue, June 16th at 3:00 PM EDT") would just duplicate it. Show clock
- // while live, status detail when final, nothing pre-game.
- detail := ""
- switch g.State {
- case model.StateLive:
- if g.Clock != "" && g.Clock != "0:00" {
- detail = g.Clock
- } else {
- detail = g.Detail
- }
- case model.StateFinal:
- detail = g.Detail
- }
- right := styleFaint.Render(truncate(detail, 14))
-
- gap := cardWidth - 2 - lipgloss.Width(left) - lipgloss.Width(right)
- if gap < 1 {
- gap = 1
- }
- return left + strings.Repeat(" ", gap) + right
-}
-
-// teamRow renders one team: abbr (in team color) + name, score on the right.
-// fav prefixes a star.
-func teamRow(t model.Team, g model.Game, leading, fav bool) string {
- abbr := lipgloss.NewStyle().
- Foreground(teamColor(t.Color)).
- Bold(true).
- Render(fmt.Sprintf("%-3s", t.Abbr))
-
- star := ""
- if fav {
- star = lipgloss.NewStyle().Foreground(colWarn).Render("★")
- } else {
- star = " "
- }
- name := star + styleSubtle.Render(truncate(t.Name, 13))
- left := abbr + " " + name
-
- score := " -"
- if g.Started() {
- ss := styleScore
- if leading && g.Started() {
- ss = styleWin
- }
- score = ss.Render(fmt.Sprintf("%3d", t.Score))
- }
-
- gap := cardWidth - 2 - lipgloss.Width(left) - lipgloss.Width(score)
- if gap < 1 {
- gap = 1
- }
- return left + strings.Repeat(" ", gap) + score
-}
-
-// startLabel is the short local start time for pre-game cards.
-func startLabel(g model.Game) string {
- if g.Start.IsZero() {
- return "TBD"
- }
- return g.Start.Format("Mon 3:04 PM")
-}
-
-func truncate(s string, n int) string {
- if lipgloss.Width(s) <= n {
- return s
- }
- if n <= 1 {
- return s[:n]
- }
- return s[:n-1] + "…"
-}
internal/ui/detail.go +0 −452
@@ -1,452 +0,0 @@
-package ui
-
-import (
- "fmt"
- "strings"
-
- "github.com/charmbracelet/lipgloss"
-
- "github.com/kortum/pts-tui/internal/model"
-)
-
-// detailView renders a single game as a full-page live screen. It re-reads
-// the latest game data by ID every frame so scores/clock update live while
-// open. This is the "follow a game" surface (World Cup focus). When the game
-// has deeper data (soccer key events, box scores), it renders below the score
-// box and the page becomes vertically scrollable.
-func (a App) detailView() string {
- g := a.currentDetail()
- l, _ := model.LeagueByID(g.League)
-
- inner := a.detailInner()
- box := a.detailBox(g, l, inner)
-
- help := styleHelp.Render(keys.Back.Help().Key + " back " +
- keys.Up.Help().Key + " scroll " +
- "f/F fav away/home " +
- 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,
- lipgloss.WithWhitespaceChars(" "))
- helpLine := lipgloss.PlaceHorizontal(a.width, lipgloss.Center, help,
- lipgloss.WithWhitespaceChars(" "))
-
- if extra == "" {
- full := lipgloss.JoinVertical(lipgloss.Center, box, "", help)
- return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center,
- full, lipgloss.WithWhitespaceChars(" "))
- }
-
- avail := a.detailBodyAvail(box)
- bodyLines := detailScrollWindow(strings.Split(extra, "\n"), a.detailScroll, avail)
- body := lipgloss.PlaceHorizontal(a.width, lipgloss.Center,
- strings.Join(bodyLines, "\n"), lipgloss.WithWhitespaceChars(" "))
-
- out := []string{header, "", body}
- // Pad so the help footer sits at the bottom edge.
- used := lipgloss.Height(header) + 1 + len(bodyLines) + 1
- for ; used < a.height-1; used++ {
- out = append(out, "")
- }
- out = append(out, helpLine)
- return strings.Join(out, "\n")
-}
-
-// detailInner is the width of the score box.
-func (a App) detailInner() int {
- inner := min(a.width-6, 72)
- if inner < 30 {
- inner = 30
- }
- return inner
-}
-
-// 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,
- stage,
- "",
- a.bigStatus(g),
- "",
- a.matchupLine(g, inner),
- "",
- scoreSection(g),
- "",
- metaBlock(g, inner),
- )
- return lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(colAccent).
- Padding(1, 3).
- Width(inner).
- Render(body)
-}
-
-// matchupLine is the centered "AWAY vs HOME" header, each side team-colored,
-// a ★ on favorites and the winner brightened.
-func (a App) matchupLine(g model.Game, w int) string {
- side := func(t model.Team, fav bool) string {
- name := t.FullName
- if name == "" {
- name = t.Name
- }
- c := teamColor(t.Color)
- st := lipgloss.NewStyle().Foreground(c).Bold(true)
- if g.Started() && t.Winner {
- st = st.Foreground(colWin)
- }
- s := st.Render(name)
- if fav {
- s = lipgloss.NewStyle().Foreground(colWarn).Render("★ ") + s
- }
- return s
- }
- away := side(g.Away, a.isFav(g.League, g.Away))
- home := side(g.Home, a.isFav(g.League, g.Home))
- vs := styleFaint.Render(" vs ")
- return lipgloss.NewStyle().Width(w - 6).Align(lipgloss.Center).
- Render(away + vs + home)
-}
-
-// scoreSection renders the oversized block scoreline (away–home), team-colored.
-// Pre-game shows the start time in place of a score.
-func scoreSection(g model.Game) string {
- if !g.Started() {
- return lipgloss.NewStyle().Foreground(colAccent2).Bold(true).
- Render("○ " + startLabel(g))
- }
- awayCol := teamColor(g.Away.Color)
- homeCol := teamColor(g.Home.Color)
- if g.State == model.StateLive {
- // Live: pop both scores on the goal-orange accent.
- awayCol, homeCol = colGoal, colGoal
- }
- if g.Away.Winner {
- awayCol = colWin
- }
- if g.Home.Winner {
- homeCol = colWin
- }
- return blockScore(fmt.Sprintf("%d", g.Away.Score), fmt.Sprintf("%d", g.Home.Score),
- awayCol, homeCol)
-}
-
-// ruleHeader is a Golazo-style section header: a colored title followed by a
-// "////" rule filling the width, with a ▓▒░ fade tail for texture.
-func ruleHeader(title string, w int) string {
- t := lipgloss.NewStyle().Foreground(colAccent2).Bold(true).Render(strings.ToUpper(title))
- fade := styleFaint.Render("▓▒░")
- n := w - lipgloss.Width(t) - 1 - 4
- if n < 0 {
- n = 0
- }
- rule := lipgloss.NewStyle().Foreground(colBorder).Render(strings.Repeat("/", n))
- return t + " " + rule + " " + fade
-}
-
-// detailBodyAvail is the number of body lines visible between the fixed score
-// box and the footer. Mirrors the layout math in detailView.
-func (a App) detailBodyAvail(box string) int {
- chrome := lipgloss.Height(box) + 1 /*blank under box*/ + 2 /*blank + help*/
- avail := a.height - chrome
- if avail < 3 {
- avail = 3
- }
- return avail
-}
-
-// detailScrollMax is the largest useful scroll offset for the open game's
-// body, so key handling can clamp instead of letting the offset run away.
-func (a App) detailScrollMax() int {
- g := a.currentDetail()
- l, _ := model.LeagueByID(g.League)
- inner := a.detailInner()
- extra := a.detailExtra(g, l, inner)
- if extra == "" {
- return 0
- }
- m := strings.Count(extra, "\n") + 1 - a.detailBodyAvail(a.detailBox(g, l, inner))
- if m < 0 {
- m = 0
- }
- return m
-}
-
-// detailExtra renders the deeper, fetched detail below the score box: soccer
-// key events, or per-team box-score tables for the other sports. Returns "" if
-// the summary has not arrived (or carries nothing).
-func (a App) detailExtra(g model.Game, l model.League, w int) string {
- if a.detailErr != nil && len(a.detailData[g.ID].Events) == 0 &&
- len(a.detailData[g.ID].BoxScore) == 0 {
- return styleErr.Render("summary unavailable: " + a.detailErr.Error())
- }
- d, ok := a.detailData[g.ID]
- if !ok {
- return styleSubtle.Render("loading game detail…")
- }
- if l.Sport == "soccer" {
- return eventsBlock(g, d.Events, w)
- }
- return boxScoreBlock(d.BoxScore, max(w, min(a.width-6, 108)))
-}
-
-// scrollWindow is shared with the dashboard; for detail we pass detailScroll as
-// the desired top line. Re-clamp here since detail has no card semantics.
-func detailScrollWindow(lines []string, offset, avail int) []string {
- if offset > len(lines)-avail {
- offset = len(lines) - avail
- }
- if offset < 0 {
- offset = 0
- }
- if len(lines) <= avail {
- return lines
- }
- return lines[offset : offset+avail]
-}
-
-// currentDetail returns the freshest copy of the open game from polled data,
-// falling back to the snapshot captured when it was opened.
-func (a App) currentDetail() model.Game {
- for _, g := range a.games[a.detail.League] {
- if g.ID == a.detail.ID {
- return g
- }
- }
- return a.detail
-}
-
-// bigStatus is the prominent live/final/scheduled line with the match clock.
-func (a App) bigStatus(g model.Game) string {
- switch g.State {
- case model.StateLive:
- clock := g.Clock
- if clock == "" || clock == "0:00" {
- clock = g.Detail
- }
- return liveDot(a.pulse()) + " " +
- lipgloss.NewStyle().Foreground(colLive).Bold(true).Render("LIVE") +
- " " + lipgloss.NewStyle().Foreground(colGoal).Bold(true).Render(clock) +
- " " + styleSubtle.Render(g.Detail)
- case model.StateFinal:
- return lipgloss.NewStyle().Foreground(colMuted).Bold(true).
- Render("◼ FINAL " + g.Detail)
- default:
- 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 {
- if len(events) == 0 {
- return ruleHeader("Key Events", w) + "\n\n" + styleSubtle.Render("no key events yet")
- }
- lines := []string{ruleHeader("Key Events", w), ""}
- // While live, stream newest-first so the latest moment sits on top; a
- // finished game reads chronologically as a recap.
- ordered := events
- if g.State == model.StateLive {
- ordered = make([]model.MatchEvent, len(events))
- for i, e := range events {
- ordered[len(events)-1-i] = e
- }
- }
- half := (w - 3) / 2
- for _, e := range ordered {
- home := eventSide(g, e)
- cell := eventCell(g, e, half)
- var line string
- switch home {
- case sideHome:
- line = lipgloss.NewStyle().Width(half).Align(lipgloss.Left).Render(cell) +
- styleFaint.Render(" │ ")
- case sideAway:
- line = lipgloss.NewStyle().Width(half).Render("") +
- styleFaint.Render(" │ ") +
- lipgloss.NewStyle().Width(half).Align(lipgloss.Right).Render(cell)
- default: // neutral (kickoff, half-time) — centered
- line = lipgloss.NewStyle().Width(w).Align(lipgloss.Center).Render(cell)
- }
- lines = append(lines, line)
- }
- return lipgloss.NewStyle().Width(w).Render(lipgloss.JoinVertical(lipgloss.Left, lines...))
-}
-
-type eventSideT int
-
-const (
- sideNeutral eventSideT = iota
- sideHome
- sideAway
-)
-
-// eventSide classifies which team an event belongs to.
-func eventSide(g model.Game, e model.MatchEvent) eventSideT {
- switch e.Team {
- case g.Home.FullName, g.Home.Name:
- return sideHome
- case g.Away.FullName, g.Away.Name:
- return sideAway
- }
- return sideNeutral
-}
-
-// eventCell renders one event's "min icon label", team-colored, clamped to w.
-func eventCell(g model.Game, e model.MatchEvent, w int) string {
- clock := e.Clock
- if clock == "" {
- clock = "·"
- }
- mn := lipgloss.NewStyle().Foreground(colGoal).Bold(true).Render(clock)
- label := e.ShortText
- if label == "" {
- label = e.Text
- }
- txt := lipgloss.NewStyle().Foreground(eventColor(g, e)).Render(truncate(label, w-10))
- cell := mn + " " + eventIcon(e) + " " + txt
- if e.Scoring {
- cell = lipgloss.NewStyle().Bold(true).Render(cell)
- }
- return cell
-}
-
-// 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:
- return teamColor(g.Home.Color)
- case g.Away.FullName, g.Away.Name:
- return teamColor(g.Away.Color)
- }
- return colText
-}
-
-// 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"):
- return "⚽"
- case strings.Contains(t, "yellow"):
- return lipgloss.NewStyle().Foreground(colWarn).Render("▌")
- case strings.Contains(t, "red"):
- return lipgloss.NewStyle().Foreground(colLive).Render("▌")
- case strings.Contains(t, "substitut"):
- return lipgloss.NewStyle().Foreground(colMuted).Render("⇄")
- case strings.Contains(t, "penalty"):
- return "◎"
- default:
- return styleFaint.Render("·")
- }
-}
-
-// 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 {
- if len(teams) == 0 {
- return styleSubtle.Render("no box score available")
- }
- var blocks []string
- for _, t := range teams {
- parts := []string{teamRuleHeader(t.Abbr, t.Name, w)}
- for _, grp := range t.Groups {
- if len(grp.Rows) == 0 {
- continue
- }
- parts = append(parts, "", statTable(grp, w))
- }
- blocks = append(blocks, lipgloss.NewStyle().Width(w).Render(
- lipgloss.JoinVertical(lipgloss.Left, parts...)))
- }
- return lipgloss.JoinVertical(lipgloss.Left, blocks...)
-}
-
-// teamRuleHeader is a ruled section header for a box-score team block: the
-// abbreviation accented, the name in text, then a "////" rule to the width.
-func teamRuleHeader(abbr, name string, w int) string {
- title := lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render(abbr) +
- " " + lipgloss.NewStyle().Foreground(colText).Bold(true).Render(name)
- fade := styleFaint.Render("▓▒░")
- n := w - lipgloss.Width(title) - 1 - 4
- if n < 0 {
- n = 0
- }
- rule := lipgloss.NewStyle().Foreground(colBorder).Render(strings.Repeat("/", n))
- return title + " " + rule + " " + fade
-}
-
-// statTable lays out one stat group as an aligned table: a left name column
-// plus right-aligned numeric columns. Trailing columns are dropped if they
-// don't fit in w.
-func statTable(grp model.StatGroup, w int) string {
- const nameW = 18
- // Column widths from header + values.
- colW := make([]int, len(grp.Labels))
- for i, l := range grp.Labels {
- colW[i] = lipgloss.Width(l)
- }
- for _, r := range grp.Rows {
- for i, s := range r.Stats {
- if i < len(colW) && lipgloss.Width(s) > colW[i] {
- colW[i] = lipgloss.Width(s)
- }
- }
- }
- // How many columns fit after the name column?
- fit := nameW
- keep := 0
- for _, cw := range colW {
- if fit+cw+1 > w {
- break
- }
- fit += cw + 1
- keep++
- }
-
- groupName := strings.ToUpper(grp.Name)
- hdr := lipgloss.NewStyle().Width(nameW).Render(
- lipgloss.NewStyle().Foreground(colAccent2).Bold(true).Render(truncate(groupName, nameW-1)))
- for i := 0; i < keep; i++ {
- hdr += " " + styleFaint.Width(colW[i]).Align(lipgloss.Right).Render(grp.Labels[i])
- }
- lines := []string{hdr}
- for _, r := range grp.Rows {
- row := lipgloss.NewStyle().Foreground(colText).Bold(true).Width(nameW).
- Render(truncate(r.Athlete, nameW-1))
- for i := 0; i < keep; i++ {
- v := ""
- if i < len(r.Stats) {
- v = r.Stats[i]
- }
- row += " " + lipgloss.NewStyle().Foreground(colMuted).
- Width(colW[i]).Align(lipgloss.Right).Render(v)
- }
- lines = append(lines, row)
- }
- return lipgloss.JoinVertical(lipgloss.Left, lines...)
-}
-
-func metaBlock(g model.Game, w int) string {
- var lines []string
- if g.Venue != "" {
- lines = append(lines, styleSubtle.Render("📍 "+g.Venue))
- }
- if g.Headline != "" {
- lines = append(lines, lipgloss.NewStyle().Foreground(colMuted).
- Width(w-6).Align(lipgloss.Center).Render(g.Headline))
- }
- if len(lines) == 0 {
- return ""
- }
- return lipgloss.JoinVertical(lipgloss.Center, lines...)
-}
internal/ui/favorites.go +0 −85
@@ -1,85 +0,0 @@
-package ui
-
-import (
- "github.com/kortum/pts-tui/internal/config"
- "github.com/kortum/pts-tui/internal/model"
-)
-
-// favKey is the stable identity of a favorited team: league + ESPN team id,
-// falling back to the abbreviation when no id is present.
-func favKey(league model.LeagueID, t model.Team) string {
- id := t.ID
- if id == "" {
- id = t.Abbr
- }
- return string(league) + ":" + id
-}
-
-// loadFavorites builds the in-memory favorites set from persisted config.
-func loadFavorites(cfg config.Config) map[string]bool {
- favs := make(map[string]bool, len(cfg.Favorites))
- for _, f := range cfg.Favorites {
- id := f.ID
- if id == "" {
- id = f.Abbr
- }
- favs[f.League+":"+id] = true
- }
- return favs
-}
-
-// isFav reports whether a team is favorited.
-func (a App) isFav(league model.LeagueID, t model.Team) bool {
- return a.favs[favKey(league, t)]
-}
-
-// hasFav reports whether either side of a game is a favorite.
-func (a App) hasFav(g model.Game) bool {
- return a.isFav(g.League, g.Home) || a.isFav(g.League, g.Away)
-}
-
-// toggleFav flips a team's favorite state and persists the change. Persistence
-// is best-effort: a write failure leaves the in-memory toggle intact.
-func (a *App) toggleFav(league model.LeagueID, t model.Team) {
- key := favKey(league, t)
- if a.favs[key] {
- delete(a.favs, key)
- } else {
- a.favs[key] = true
- }
- a.cfg.Favorites = a.favoritesList()
- _ = config.Save(a.cfg)
-}
-
-// favoritesList rebuilds the persisted favorites slice from current data,
-// preserving display fields by scanning loaded games for each key.
-func (a App) favoritesList() []config.FavTeam {
- out := make([]config.FavTeam, 0, len(a.favs))
- seen := map[string]bool{}
- for _, games := range a.games {
- for _, g := range games {
- for _, t := range []model.Team{g.Home, g.Away} {
- key := favKey(g.League, t)
- if a.favs[key] && !seen[key] {
- seen[key] = true
- out = append(out, config.FavTeam{
- League: string(g.League), ID: t.ID, Abbr: t.Abbr, Name: t.Name,
- })
- }
- }
- }
- }
- // Keep any favorites we couldn't resolve to a loaded game (id-only).
- for _, f := range a.cfg.Favorites {
- id := f.ID
- if id == "" {
- id = f.Abbr
- }
- key := f.League + ":" + id
- if a.favs[key] && !seen[key] {
- seen[key] = true
- out = append(out, f)
- }
- }
- return out
-}
internal/ui/favorites_test.go +0 −69
@@ -1,69 +0,0 @@
-package ui
-
-import (
- "testing"
- "time"
-
- "github.com/kortum/pts-tui/internal/config"
- "github.com/kortum/pts-tui/internal/model"
-)
-
-func TestFavKeyUsesIDThenAbbr(t *testing.T) {
- if got := favKey(model.MLB, model.Team{ID: "22", Abbr: "PHI"}); got != "mlb:22" {
- t.Errorf("with id = %q, want mlb:22", got)
- }
- if got := favKey(model.MLB, model.Team{Abbr: "PHI"}); got != "mlb:PHI" {
- t.Errorf("no id = %q, want mlb:PHI", got)
- }
-}
-
-func TestLoadFavoritesAndHasFav(t *testing.T) {
- cfg := config.Config{Favorites: []config.FavTeam{{League: "mlb", ID: "22", Abbr: "PHI"}}}
- a := App{favs: loadFavorites(cfg)}
- favGame := model.Game{League: model.MLB, Home: model.Team{ID: "22"}, Away: model.Team{ID: "99"}}
- otherGame := model.Game{League: model.MLB, Home: model.Team{ID: "1"}, Away: model.Team{ID: "2"}}
- if !a.hasFav(favGame) {
- t.Error("favGame should be favorite")
- }
- if a.hasFav(otherGame) {
- t.Error("otherGame should not be favorite")
- }
-}
-
-func TestFavoriteHighlightsPicksLiveNextLast(t *testing.T) {
- now := time.Now()
- fav := model.Team{ID: "22", Abbr: "PHI"}
- other := model.Team{ID: "99", Abbr: "NYM"}
- games := []model.Game{
- {ID: "old", League: model.MLB, State: model.StateFinal, Home: fav, Start: now.AddDate(0, 0, -3)},
- {ID: "recent", League: model.MLB, State: model.StateFinal, Away: fav, Start: now.AddDate(0, 0, -1)},
- {ID: "live", League: model.MLB, State: model.StateLive, Home: fav, Start: now},
- {ID: "soon", League: model.MLB, State: model.StatePre, Away: fav, Start: now.AddDate(0, 0, 1)},
- {ID: "later", League: model.MLB, State: model.StatePre, Home: fav, Start: now.AddDate(0, 0, 4)},
- {ID: "nofav", League: model.MLB, State: model.StatePre, Home: other, Away: other, Start: now},
- }
- a := App{
- games: map[model.LeagueID][]model.Game{model.MLB: games},
- favs: map[string]bool{favKey(model.MLB, fav): true},
- }
- got := a.favoriteHighlights(model.MLB, now)
- ids := map[string]bool{}
- for _, g := range got {
- ids[g.ID] = true
- }
- // live + most-recent final + next scheduled, nothing else.
- want := []string{"live", "recent", "soon"}
- for _, w := range want {
- if !ids[w] {
- t.Errorf("missing %q in highlights %v", w, ids)
- }
- }
- for _, bad := range []string{"old", "later", "nofav"} {
- if ids[bad] {
- t.Errorf("unexpected %q in highlights", bad)
- }
- }
- if len(got) != 3 {
- t.Errorf("got %d highlights, want 3: %v", len(got), ids)
- }
-}
internal/ui/keys.go +0 −66
@@ -1,66 +0,0 @@
-package ui
-
-import "github.com/charmbracelet/bubbles/key"
-
-// 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
-}
-
-var keys = keyMap{
- Up: key.NewBinding(
- key.WithKeys("up", "k"),
- key.WithHelp("↑/k", "up"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down", "j"),
- key.WithHelp("↓/j", "down"),
- ),
- Enter: key.NewBinding(
- key.WithKeys("enter", "l"),
- key.WithHelp("enter", "open game"),
- ),
- Back: key.NewBinding(
- key.WithKeys("esc", "h", "backspace"),
- key.WithHelp("esc", "back"),
- ),
- NextLg: key.NewBinding(
- key.WithKeys("tab", "right"),
- key.WithHelp("tab", "next league"),
- ),
- PrevLg: key.NewBinding(
- key.WithKeys("shift+tab", "left"),
- key.WithHelp("⇧tab", "prev league"),
- ),
- AllLg: key.NewBinding(
- key.WithKeys("a"),
- key.WithHelp("a", "all leagues"),
- ),
- Refresh: key.NewBinding(
- key.WithKeys("r"),
- key.WithHelp("r", "refresh"),
- ),
- FavAway: key.NewBinding(
- key.WithKeys("f"),
- key.WithHelp("f", "fav away"),
- ),
- FavHome: key.NewBinding(
- key.WithKeys("F"),
- key.WithHelp("F", "fav home"),
- ),
- Quit: key.NewBinding(
- key.WithKeys("q", "ctrl+c"),
- key.WithHelp("q", "quit"),
- ),
-}
internal/ui/sections.go +0 −182
@@ -1,182 +0,0 @@
-package ui
-
-import (
- "time"
-
- "github.com/kortum/pts-tui/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.
-type section struct {
- title string
- color string // accent hex (no '#')
- games []model.Game
-}
-
-// sections builds the dashboard's groups for the current filter:
-//
-// - 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.
-func (a App) sections() []section {
- now := time.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 _, g := range filterDay(a.games[l.ID], now) {
- if a.hasFav(g) {
- fav = append(fav, g)
- favSeen[g.ID] = true
- }
- }
- }
- if len(fav) > 0 {
- out = append(out, section{"★ Favorites", colWarnHex, fav})
- }
- for _, l := range model.Leagues {
- var today []model.Game
- for _, g := range filterDay(a.games[l.ID], now) {
- if !favSeen[g.ID] { // already shown in Favorites
- today = append(today, g)
- }
- }
- if len(today) > 0 {
- out = append(out, section{l.Icon + " " + l.Name, l.Color, today})
- }
- }
- return out
- }
-
- l, _ := model.LeagueByID(a.filter)
- past, today, upcoming := bucket(a.games[a.filter], now)
- 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})
- }
- if len(today) > 0 {
- out = append(out, section{"● Today", l.Color, today})
- }
- if len(upcoming) > 0 {
- out = append(out, section{"○ Upcoming", l.Color, upcoming})
- }
- if len(past) > 0 {
- out = append(out, section{"◼ Past", l.Color, past})
- }
- return out
-}
-
-// 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
-// de-duplicated and ordered live → next → last.
-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{}
-
- teamKeys := func(g model.Game) []string {
- var ks []string
- if a.isFav(g.League, g.Home) {
- ks = append(ks, favKey(g.League, g.Home))
- }
- if a.isFav(g.League, g.Away) {
- ks = append(ks, favKey(g.League, g.Away))
- }
- return ks
- }
-
- for i := range games {
- g := games[i]
- for _, k := range teamKeys(g) {
- p := picks[k]
- if p == nil {
- p = &pick{}
- picks[k] = p
- }
- switch g.State {
- case model.StateLive:
- p.live = &games[i]
- case model.StatePre:
- if p.next == nil || g.Start.Before(p.next.Start) {
- p.next = &games[i]
- }
- case model.StateFinal:
- if p.last == nil || g.Start.After(p.last.Start) {
- p.last = &games[i]
- }
- }
- }
- }
-
- var out []model.Game
- seen := map[string]bool{}
- add := func(g *model.Game) {
- if g != nil && !seen[g.ID] {
- seen[g.ID] = true
- out = append(out, *g)
- }
- }
- for _, p := range picks {
- add(p.live)
- add(p.next)
- add(p.last)
- }
- return out
-}
-
-// visible is the cursor-addressable flat list, matching sections() order.
-func (a App) visible() []model.Game {
- var out []model.Game
- for _, s := range a.sections() {
- out = append(out, s.games...)
- }
- return out
-}
-
-// bucket splits a league's window into past finals, today's slate, and future
-// scheduled games. Today keeps the client's live→sched→final ordering;
-// upcoming is chronological; past is reverse-chronological (most recent first).
-func bucket(games []model.Game, now time.Time) (past, today, upcoming []model.Game) {
- startToday := startOfDay(now)
- startTomorrow := startToday.AddDate(0, 0, 1)
- for _, g := range games {
- switch {
- case !g.Start.Before(startTomorrow):
- upcoming = append(upcoming, g)
- case !g.Start.Before(startToday):
- today = append(today, g)
- default:
- past = append(past, g)
- }
- }
- // Reverse past so the most recent final leads.
- for i, j := 0, len(past)-1; i < j; i, j = i+1, j-1 {
- past[i], past[j] = past[j], past[i]
- }
- return past, today, upcoming
-}
-
-// filterDay returns only the games that start on now's calendar day.
-func filterDay(games []model.Game, now time.Time) []model.Game {
- start := startOfDay(now)
- end := start.AddDate(0, 0, 1)
- var out []model.Game
- for _, g := range games {
- if !g.Start.Before(start) && g.Start.Before(end) {
- out = append(out, g)
- }
- }
- return out
-}
-
-func startOfDay(t time.Time) time.Time {
- y, m, d := t.Date()
- return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
-}
internal/ui/theme.go +0 −101
@@ -1,101 +0,0 @@
-package ui
-
-import "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.
-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
-
- colText = lipgloss.AdaptiveColor{Light: "#100F0F", Dark: "#CECDC3"} // tx — primary text
- colMuted = lipgloss.AdaptiveColor{Light: "#6F6E69", Dark: "#878580"} // tx-2 — secondary
-
- 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)
-)
-
-// 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
-
-var (
- styleApp = lipgloss.NewStyle().Foreground(colText)
-
- styleTitle = lipgloss.NewStyle().
- Bold(true).
- Foreground(colBg).
- Background(colAccent).
- Padding(0, 1)
-
- styleSubtle = lipgloss.NewStyle().Foreground(colMuted)
- styleFaint = lipgloss.NewStyle().Foreground(colFaint)
-
- styleCard = lipgloss.NewStyle().
- 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)
-
- styleScore = lipgloss.NewStyle().Bold(true).Foreground(colText)
- 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)
-
- styleHelp = lipgloss.NewStyle().Foreground(colMuted)
- styleErr = lipgloss.NewStyle().Foreground(colLive).Bold(true)
-)
-
-// 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.
-func teamColor(hex string) lipgloss.TerminalColor {
- if len(hex) == 6 {
- return lipgloss.Color("#" + hex)
- }
- return colText
-}
-
-// liveDot returns the "●" live indicator pulsed by intensity in [0,1], so
-// live games visibly breathe. Blends dim→bright on the Flexoki red.
-func liveDot(intensity float64) string {
- lo, hi := 0x66, 0xD1
- v := lo + int(float64(hi-lo)*intensity)
- c := lipgloss.Color(rgbHex(v, 0x4D, 0x41))
- return lipgloss.NewStyle().Foreground(c).Render("●")
-}
-
-func rgbHex(r, g, b int) string {
- const hexd = "0123456789ABCDEF"
- out := []byte("#000000")
- for i, v := range []int{r, g, b} {
- out[1+i*2] = hexd[(v>>4)&0xF]
- out[2+i*2] = hexd[v&0xF]
- }
- return string(out)
-}
internal/ui/update.go +0 −97
@@ -1,97 +0,0 @@
-package ui
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
-
- "github.com/kortum/pts-tui/internal/model"
-)
-
-// handleKey routes keystrokes by current view mode.
-func (a App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
- if key.Matches(msg, keys.Quit) {
- return a, tea.Quit
- }
-
- if a.mode == viewDetail {
- switch {
- case key.Matches(msg, keys.Back):
- a.mode = viewDashboard
- case key.Matches(msg, keys.Down):
- a.detailScroll++
- if m := a.detailScrollMax(); a.detailScroll > m {
- a.detailScroll = m
- }
- case key.Matches(msg, keys.Up):
- a.detailScroll--
- if a.detailScroll < 0 {
- a.detailScroll = 0
- }
- 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.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, fetchAll(a.client)
- }
- return a, nil
- }
-
- // Dashboard mode.
- switch {
- case key.Matches(msg, keys.Down):
- a.cursor++
- a.clampCursor()
- case key.Matches(msg, keys.Up):
- a.cursor--
- a.clampCursor()
- case key.Matches(msg, keys.NextLg):
- a.cycleLeague(1)
- case key.Matches(msg, keys.PrevLg):
- a.cycleLeague(-1)
- case key.Matches(msg, keys.AllLg):
- a.filter = ""
- a.cursor = 0
- case key.Matches(msg, keys.Refresh):
- a.loading = true
- return a, fetchAll(a.client)
- 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)
- }
- }
- }
- return a, nil
-}
-
-// cycleLeague steps the filter through [all, league0, league1, ...] in dir.
-func (a *App) cycleLeague(dir int) {
- order := append([]model.LeagueID{""}, leagueIDs()...)
- idx := 0
- for i, id := range order {
- if id == a.filter {
- idx = i
- break
- }
- }
- 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 +0 −179
@@ -1,179 +0,0 @@
-package ui
-
-import (
- "fmt"
- "strings"
-
- "github.com/charmbracelet/lipgloss"
-
- "github.com/kortum/pts-tui/internal/model"
-)
-
-const cardHeight = 5 // 3 content rows + 2 border rows
-
-// dashboardView composes a fixed header + league tabs, a scrollable card area
-// sized to the terminal (so the header never scrolls off — the earlier
-// top-cutoff bug), and a fixed footer.
-func (a App) dashboardView() string {
- header := a.header()
- tabs := a.leagueTabs()
- 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"))
- }
-
- // Reserve rows for header, tabs, two blank spacers, and footer.
- const chrome = 5
- avail := a.height - chrome
- if avail < 3 {
- avail = 3
- }
-
- lines, selTop := a.bodyLines()
- lines = scrollWindow(lines, selTop, avail)
-
- parts := []string{header, tabs, "", strings.Join(lines, "\n"), "", footer}
- return styleApp.Render(strings.Join(parts, "\n"))
-}
-
-// bodyLines renders all sections to individual lines and reports the first
-// line index of the currently selected card, for scroll-follow.
-func (a App) bodyLines() (lines []string, selTop int) {
- cols := a.columns()
- idx := 0 // global game index, matches visible()
- selTop = 0 // default to top
-
- secs := a.sections()
- if len(secs) == 0 {
- return []string{styleSubtle.Render("No games in this window. Press r to refresh.")}, 0
- }
-
- for _, s := range secs {
- lines = append(lines, sectionHeader(s))
- for i := 0; i < len(s.games); i += cols {
- var cells []string
- for j := i; j < i+cols && j < len(s.games); j++ {
- selected := idx+j == a.cursor
- if selected {
- selTop = len(lines)
- }
- gm := s.games[j]
- cells = append(cells, renderCard(gm, selected, a.pulse(),
- a.isFav(gm.League, gm.Away), a.isFav(gm.League, gm.Home)))
- }
- row := lipgloss.JoinHorizontal(lipgloss.Top, cells...)
- lines = append(lines, strings.Split(row, "\n")...)
- }
- idx += len(s.games)
- lines = append(lines, "")
- }
- return lines, selTop
-}
-
-// scrollWindow returns at most avail lines, scrolled so the line at selTop
-// (and the rest of its card) stays visible.
-func scrollWindow(lines []string, selTop, avail int) []string {
- if len(lines) <= avail {
- return lines
- }
- offset := 0
- if selTop+cardHeight > avail {
- offset = selTop + cardHeight - avail
- }
- if offset > len(lines)-avail {
- offset = len(lines) - avail
- }
- if offset < 0 {
- offset = 0
- }
- end := offset + avail
- if end > len(lines) {
- end = len(lines)
- }
- return lines[offset:end]
-}
-
-func (a App) columns() int {
- if a.width <= 0 {
- return 2
- }
- c := a.width / (cardWidth + 3)
- switch {
- case c < 1:
- return 1
- case c > 4:
- return 4
- default:
- return c
- }
-}
-
-// header is the title bar with a live-game count, padded full width.
-func (a App) header() string {
- title := styleTitle.Render(" SPORTSBALL! ")
- sub := lipgloss.NewStyle().Foreground(colAccent2).Bold(true).Render(" live scores ")
-
- live := a.liveCount()
- var status string
- switch {
- case a.loading:
- status = styleSubtle.Render(a.spinner.View() + " updating")
- case live > 0:
- status = liveDot(a.pulse()) + lipgloss.NewStyle().Foreground(colLive).
- Render(fmt.Sprintf(" %d live", live))
- default:
- status = styleSubtle.Render("no live games")
- }
-
- left := title + sub
- gap := a.width - lipgloss.Width(left) - lipgloss.Width(status)
- if gap < 1 {
- gap = 1
- }
- return left + strings.Repeat(" ", gap) + status
-}
-
-func (a App) leagueTabs() string {
- tabs := []string{tab("ALL", a.filter == "")}
- for _, l := range model.Leagues {
- tabs = append(tabs, tab(l.Icon+" "+l.Abbr, a.filter == l.ID))
- }
- return strings.Join(tabs, styleFaint.Render("│"))
-}
-
-func tab(label string, active bool) string {
- if active {
- return styleLeagueSel.Render(label)
- }
- return styleLeagueTab.Render(label)
-}
-
-func sectionHeader(s section) string {
- return lipgloss.NewStyle().Foreground(teamColor(s.color)).Bold(true).Render(s.title)
-}
-
-func (a App) footer() string {
- 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.Quit.Help().Key + " quit",
- }
- return styleHelp.Render(strings.Join(parts, styleFaint.Render(" · ")))
-}
-
-func (a App) liveCount() int {
- n := 0
- for _, l := range model.Leagues {
- for _, g := range a.games[l.ID] {
- if g.State == model.StateLive {
- n++
- }
- }
- }
- return n
-}
main.go +0 −21
@@ -1,21 +0,0 @@
-// 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.
-package main
-
-import (
- "fmt"
- "os"
-
- tea "github.com/charmbracelet/bubbletea"
-
- "github.com/kortum/pts-tui/internal/ui"
-)
-
-func main() {
- p := tea.NewProgram(ui.New(), tea.WithAltScreen())
- if _, err := p.Run(); err != nil {
- fmt.Fprintln(os.Stderr, "pts:", err)
- os.Exit(1)
- }
-}