From 2a927761820b37dd2662a5f2abda64fc3c9127c0 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 22 Mar 2026 23:51:32 +0100 Subject: [PATCH 1/9] Remove Timeline panel; merge events into score header and lineup annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goals now show score progression labels (1-0) inline. Red cards appear in the score header interleaved with goals. HT/FT dividers stay in the score section. Lineup players are annotated with goal glyphs, card glyphs, and substitution arrows (→ sub-off with player dimmed, ← sub-on) matched by last-name lookup. Removes: Timeline section, formatEventLabel, formatSubstitutionLabel, scorerTimeline, scorerLines, formatScorerLabel. Adds: headerEventRows, formatScorerLabelWithScore, playerLastName, playerEventIndex, renderAnnotatedPlayer. Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/fixture_navigation_test.go | 5 +- internal/ui/render_helpers.go | 225 +++++++++++++++++-------- internal/ui/view.go | 65 +++---- internal/ui/view_test.go | 203 +++++++++++----------- 4 files changed, 281 insertions(+), 217 deletions(-) diff --git a/internal/ui/fixture_navigation_test.go b/internal/ui/fixture_navigation_test.go index 70b2e8a..226e47a 100644 --- a/internal/ui/fixture_navigation_test.go +++ b/internal/ui/fixture_navigation_test.go @@ -2,6 +2,7 @@ package ui import ( "context" + "fmt" "testing" tea "github.com/charmbracelet/bubbletea" @@ -188,9 +189,9 @@ func TestMatchViewScrollKeysDoNotChangeFixture(t *testing.T) { t.Fatalf("expected match load command on enter") } m, _ = updateModelWithMsg(t, m, cmd()) - m.match.Events = make([]site.MatchEvent, 0, 60) + m.match.HomeLineup = make([]site.PlayerLine, 0, 60) for i := 1; i <= 60; i++ { - m.match.Events = append(m.match.Events, site.MatchEvent{MinuteText: "1", TeamSide: "home", Kind: "SUB", Text: "event"}) + m.match.HomeLineup = append(m.match.HomeLineup, site.PlayerLine{Name: fmt.Sprintf("Player%02d", i)}) } m, cmd = updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyPgDown}) diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index e35b610..5e3f849 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -296,48 +296,6 @@ func atoiOrNeg(s string) int { return value } -func formatEventLabel(event site.MatchEvent) string { - if event.Kind == "SUB" { - return formatSubstitutionLabel(event) - } - - text := trimEventMinute(event) - prefix := eventPrefix(event.Kind) - if text == "" { - return prefix - } - if event.TeamSide == "home" { - return formatLeftEventLabel(event.Kind, text) - } - - return formatRightEventLabel(event.Kind, text) -} - -func formatSubstitutionLabel(event site.MatchEvent) string { - outgoing, incoming := substitutionPlayers(event.Text) - if incoming == "" { - fallback := trimEventMinute(event) - if fallback == "" { - return eventPrefix(event.Kind) - } - if event.TeamSide == "home" { - return formatLeftEventLabel(event.Kind, fallback) - } - return formatRightEventLabel(event.Kind, fallback) - } - - arrow := eventPrefix(event.Kind) - if event.TeamSide == "home" { - if outgoing == "" { - return incoming + " " + arrow - } - return faintText(outgoing) + " " + arrow + " " + incoming - } - if outgoing == "" { - return arrow + " " + incoming - } - return incoming + " " + arrow + " " + faintText(outgoing) -} func substitutionPlayers(text string) (string, string) { parts := strings.SplitN(normalizeDisplayText(text), "->", 2) @@ -524,58 +482,181 @@ func matchStatus(page *site.MatchPage) string { } type scorerLine struct { - label string - minute string - side string + label string + minute string + side string + isDivider bool } -func scorerLines(events []site.MatchEvent, side string) []scorerLine { - +// headerEventRows returns goal events (with running score labels) and red card events +// sorted by minute, with an HT divider injected between halves when both exist. +func headerEventRows(events []site.MatchEvent) []scorerLine { ordered := sortedEvents(events) - lines := make([]scorerLine, 0, 4) + home, away := 0, 0 + htLabel := halftimeScore(events) + insertedHT := false + lines := make([]scorerLine, 0, 8) + for _, event := range ordered { - if event.Kind != "GOAL" || event.TeamSide != side { + if event.Kind != "GOAL" && event.Kind != "RC" { continue } - - name := trimEventMinute(event) - if name == "" { + if event.Kind == "RC" && strings.TrimSpace(event.MinuteText) == "" { continue } - lines = append(lines, scorerLine{label: name, minute: formatMatchMinute(event.MinuteText), side: side}) + + if !insertedHT && htLabel != "" { + if key, ok := minuteSortKey(event.MinuteText); ok && key > 4599 { + lines = append(lines, scorerLine{label: htLabel, isDivider: true}) + insertedHT = true + } + } + + switch event.Kind { + case "GOAL": + if event.TeamSide == "home" { + home++ + } else if event.TeamSide == "away" { + away++ + } + name := trimEventMinute(event) + if name == "" { + continue + } + lines = append(lines, scorerLine{ + label: formatScorerLabelWithScore(name, event.TeamSide, fmt.Sprintf("(%d-%d)", home, away)), + minute: formatMatchMinute(event.MinuteText), + side: event.TeamSide, + }) + case "RC": + name := trimEventMinute(event) + var label string + if event.TeamSide == "home" { + label = formatLeftEventLabel("RC", name) + } else { + label = formatRightEventLabel("RC", name) + } + lines = append(lines, scorerLine{ + label: label, + minute: formatMatchMinute(event.MinuteText), + side: event.TeamSide, + }) + } } return lines } -func scorerTimeline(events []site.MatchEvent) []scorerLine { - ordered := sortedEvents(events) - lines := make([]scorerLine, 0, 4) - for _, event := range ordered { - if event.Kind != "GOAL" { - continue +func formatScorerLabelWithScore(name, side, scoreLabel string) string { + glyph := eventPrefix("GOAL") + if side == "home" { + return name + " " + glyph + " " + scoreLabel + } + return glyph + " " + name + " " + scoreLabel +} + +// playerLastName returns a lowercase last-name key for fuzzy event-to-player matching. +// Strips trailing parentheticals, then takes the last whitespace-delimited word. +func playerLastName(label string) string { + s := strings.TrimSpace(label) + for { + m := trailingParenRe.FindStringSubmatch(s) + if len(m) != 3 { + break } + s = strings.TrimSpace(m[1]) + } + fields := strings.Fields(s) + if len(fields) == 0 { + return "" + } + return strings.ToLower(fields[len(fields)-1]) +} - name := trimEventMinute(event) - if name == "" { +// playerEventIndex maps lowercase last name → events for the given side. +// SUB events are indexed under both outgoing and incoming player last names. +func playerEventIndex(events []site.MatchEvent, side string) map[string][]site.MatchEvent { + idx := make(map[string][]site.MatchEvent) + for _, e := range events { + if e.TeamSide != side { continue } + if e.Kind == "SUB" { + out, in := substitutionPlayers(e.Text) + if key := playerLastName(out); key != "" { + idx[key] = append(idx[key], e) + } + if key := playerLastName(in); key != "" { + idx[key] = append(idx[key], e) + } + continue + } + name := trimEventMinute(e) + if key := playerLastName(name); key != "" { + idx[key] = append(idx[key], e) + } + } + return idx +} - lines = append(lines, scorerLine{ - label: formatScorerLabel(name, event.TeamSide), - minute: formatMatchMinute(event.MinuteText), - side: event.TeamSide, - }) +// renderAnnotatedPlayer returns the player label with event annotations appended. +// Goals get ⚽+minute, cards get their glyph+minute, substitutions get directional +// arrows (→ for sub-off with the name dimmed, ← for sub-on). +func renderAnnotatedPlayer(player site.PlayerLine, side string, idx map[string][]site.MatchEvent) string { + base := formatPlayerLabel(player.Name) + key := playerLastName(base) + if key == "" { + return base + } + + matched, ok := idx[key] + if !ok { + return base + } + + dimName := false + annotations := make([]string, 0, 3) + + for _, e := range matched { + minute := strings.TrimSpace(formatMatchMinute(e.MinuteText)) + switch e.Kind { + case "GOAL": + annotations = append(annotations, eventPrefix("GOAL")+" "+minute) + case "YC": + annotations = append(annotations, eventPrefix("YC")+" "+minute) + case "RC": + annotations = append(annotations, eventPrefix("RC")+" "+minute) + case "SUB": + out, in := substitutionPlayers(e.Text) + outKey := playerLastName(out) + inKey := playerLastName(in) + if outKey == key { + dimName = true + if in != "" { + annotations = append(annotations, "→ "+in+" "+minute) + } else { + annotations = append(annotations, "→ "+minute) + } + } else if inKey == key { + if out != "" { + annotations = append(annotations, "← "+out+" "+minute) + } else { + annotations = append(annotations, "← "+minute) + } + } + } } - return lines -} + name := base + if dimName { + name = faintText(base) + } -func formatScorerLabel(name, side string) string { - if side == "home" { - return name + " " + eventPrefix("GOAL") + if len(annotations) == 0 { + return name } - return eventPrefix("GOAL") + " " + name + + return name + " " + strings.Join(annotations, " ") } func halftimeScore(events []site.MatchEvent) string { diff --git a/internal/ui/view.go b/internal/ui/view.go index 1f2fe0e..dde035f 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -455,56 +455,32 @@ func (m Model) matchDetailContent(width int) string { b.WriteString("\n") status := matchStatus(m.match) - scorers := scorerTimeline(m.match.Events) - if len(scorers) > 0 || status != "" { + headerEvents := headerEventRows(m.match.Events) + if len(headerEvents) > 0 || status != "" { if status != "" { b.WriteString(renderMatchDetailRow("", status, "", width-4)) b.WriteString("\n") } - for _, scorer := range scorers { - homeText, awayText := "", "" - if scorer.side == "home" { - homeText = scorer.label - } else { - awayText = scorer.label - } - b.WriteString(renderMatchDetailRow(homeText, scorer.minute, awayText, width-4)) - b.WriteString("\n") - } - } - - if len(m.match.Events) > 0 { - b.WriteString("\n") - b.WriteString(title.Render(renderCenteredText("Timeline", width-4))) - b.WriteString("\n") - htDivider := halftimeScore(m.match.Events) - insertedDivider := false - for _, event := range sortedEvents(m.match.Events) { - // Cards without minute data render as detached badges, so the compact timeline omits them. - if strings.TrimSpace(event.MinuteText) == "" && (event.Kind == "YC" || event.Kind == "RC") { + for _, row := range headerEvents { + if row.isDivider { + b.WriteString(renderMatchDividerRow(row.label, width-4)) + b.WriteString("\n") continue } - if !insertedDivider && htDivider != "" { - if minute, ok := minuteSortKey(event.MinuteText); ok && minute > 4599 { - b.WriteString(renderMatchDividerRow(htDivider, width-4)) - b.WriteString("\n") - insertedDivider = true - } - } - homeText, awayText := "", "" - eventText := formatEventLabel(event) - if event.TeamSide == "home" { - homeText = eventText + if row.side == "home" { + homeText = row.label } else { - awayText = eventText + awayText = row.label } - b.WriteString(renderMatchDetailRow(homeText, formatMatchMinute(event.MinuteText), awayText, width-4)) + b.WriteString(renderMatchDetailRow(homeText, row.minute, awayText, width-4)) b.WriteString("\n") } - if ftDivider := finalScoreLine(m.match); ftDivider != "" { - b.WriteString(renderMatchDividerRow(ftDivider, width-4)) - b.WriteString("\n") + if len(headerEvents) > 0 { + if ftDivider := finalScoreLine(m.match); ftDivider != "" { + b.WriteString(renderMatchDividerRow(ftDivider, width-4)) + b.WriteString("\n") + } } } @@ -520,18 +496,17 @@ func (m Model) matchDetailContent(width int) string { )) b.WriteString("\n") - maxPlayers := len(m.match.HomeLineup) - if len(m.match.AwayLineup) > maxPlayers { - maxPlayers = len(m.match.AwayLineup) - } + homeIdx := playerEventIndex(m.match.Events, "home") + awayIdx := playerEventIndex(m.match.Events, "away") + maxPlayers := max(len(m.match.HomeLineup), len(m.match.AwayLineup)) for i := 0; i < maxPlayers; i++ { homeText, awayText := "", "" if i < len(m.match.HomeLineup) { - homeText = renderPlayerLine(m.match.HomeLineup[i]) + homeText = renderAnnotatedPlayer(m.match.HomeLineup[i], "home", homeIdx) } if i < len(m.match.AwayLineup) { - awayText = renderPlayerLine(m.match.AwayLineup[i]) + awayText = renderAnnotatedPlayer(m.match.AwayLineup[i], "away", awayIdx) } b.WriteString(renderLineupRow(homeText, awayText, width-4)) b.WriteString("\n") diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index d6ac2d9..f79ac49 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -157,7 +157,7 @@ func TestMatchDetailRemovesRedundantMetadata(t *testing.T) { } } -func TestMatchTimelineShowsSymbolsAndHalftimeDivider(t *testing.T) { +func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { m := sketchModel() m.width = 140 m.matchView = true @@ -167,120 +167,92 @@ func TestMatchTimelineShowsSymbolsAndHalftimeDivider(t *testing.T) { Score: "2-1", Events: []site.MatchEvent{ {MinuteText: "39", Kind: "GOAL", TeamSide: "home", Text: "Wdowiak 39"}, - {MinuteText: "52", Kind: "MISS", TeamSide: "away", Text: "Barkowskij 52 (nk)"}, - {MinuteText: "46", Kind: "SUB", TeamSide: "away", Text: "O. Lesniak -> Pllana (4)"}, {MinuteText: "46", Kind: "SUB", TeamSide: "home", Text: "Igor Strzalek (86) -> Damian Nowak"}, + {MinuteText: "46", Kind: "SUB", TeamSide: "away", Text: "O. Lesniak -> Pllana"}, + {MinuteText: "52", Kind: "MISS", TeamSide: "away", Text: "Barkowskij 52 (nk)"}, {MinuteText: "60", Kind: "GOAL", TeamSide: "home", Text: "Szkurin 60"}, {MinuteText: "70", Kind: "GOAL", TeamSide: "away", Text: "Karol Czubak (k) 70"}, + {MinuteText: "85", Kind: "RC", TeamSide: "away", Text: "Pllana 85"}, + }, + HomeLineup: []site.PlayerLine{ + {Name: "Wdowiak"}, + {Name: "Igor Strzalek (86)"}, + {Name: "Szkurin"}, + {Name: "Damian Nowak"}, + }, + AwayLineup: []site.PlayerLine{ + {Name: "Karol Czubak (k)"}, + {Name: "Barkowskij"}, + {Name: "O. Lesniak"}, + {Name: "Pllana"}, }, } view := m.View() plainView := ansi.Strip(view) + + // Score header: goals with progression labels, red card, HT/FT dividers for _, want := range []string{ - "Wdowiak", - "Szkurin", - "Wdowiak ⚽", + "Wdowiak ⚽ (1-0)", "39'", "HT 1-0", - "FT 2-1", - "Pllana ↕", - "I. Strzalek", - "D. Nowak", - "O. Lesniak", - "❌ Barkowskij (pen)", - "52'", - "Szkurin ⚽", + "Szkurin ⚽ (2-0)", "60'", - "⚽ K. Czubak (pen)", "70'", + "🟥", + "85'", + "FT 2-1", } { if !strings.Contains(plainView, want) { t.Fatalf("expected match view to contain %q\n%s", want, view) } } - if !strings.Contains(view, "\x1b[2m(pen)\x1b[0m") { - t.Fatalf("expected rendered match view to dim penalty suffixes\n%s", view) - } - if strings.Contains(plainView, "Wdowiak 39', Szkurin 60'") { - t.Fatalf("expected scorers to render as separate rows\n%s", view) + + // Score header must not contain MISS or SUB events + for _, unwanted := range []string{ + "Timeline", + "❌", + "↕", + } { + if strings.Contains(plainView, unwanted) { + t.Fatalf("expected view to omit %q\n%s", unwanted, view) + } } - timeline := plainView[strings.Index(plainView, "Timeline"):] - indexes := []int{ - strings.Index(timeline, "39'"), - strings.Index(timeline, "Pllana ↕"), - strings.Index(timeline, "D. Nowak"), - strings.Index(timeline, "52'"), - strings.Index(timeline, "Szkurin ⚽"), - strings.Index(timeline, "70'"), - strings.Index(timeline, "⚽ K. Czubak (pen)"), + // Score header ordering: 39' before HT before 60' before 70' before 85' + headerIndexes := []int{ + strings.Index(plainView, "39'"), + strings.Index(plainView, "HT 1-0"), + strings.Index(plainView, "60'"), + strings.Index(plainView, "70'"), + strings.Index(plainView, "85'"), + strings.Index(plainView, "FT 2-1"), } - for _, idx := range indexes { + for _, idx := range headerIndexes { if idx < 0 { - t.Fatalf("expected ordered timeline markers in view\n%s", timeline) + t.Fatalf("expected ordered score header in view\n%s", plainView) } } - for i := 1; i < len(indexes); i++ { - if indexes[i-1] >= indexes[i] { - t.Fatalf("expected timeline order 39 -> 46 -> 46 -> 52 -> 60 -> 70\n%s", timeline) + for i := 1; i < len(headerIndexes); i++ { + if headerIndexes[i-1] >= headerIndexes[i] { + t.Fatalf("expected score header order 39 -> HT -> 60 -> 70 -> 85 -> FT\n%s", plainView) } } -} - -func TestFormatEventLabelFormatsScoredPenalty(t *testing.T) { - home := formatEventLabel(site.MatchEvent{MinuteText: "70", Kind: "GOAL", TeamSide: "home", Text: "Karol Czubak (k) 70"}) - away := formatEventLabel(site.MatchEvent{MinuteText: "70", Kind: "GOAL", TeamSide: "away", Text: "Karol Czubak (k) 70"}) - - if got := ansi.Strip(home); got != "K. Czubak (pen) ⚽" { - t.Fatalf("unexpected home scored penalty label: %q", got) - } - if got := ansi.Strip(away); got != "⚽ K. Czubak (pen)" { - t.Fatalf("unexpected away scored penalty label: %q", got) - } - if !strings.Contains(home, "\x1b[2m(pen)\x1b[0m") { - t.Fatalf("expected home scored penalty suffix to be dimmed, got %q", home) - } - if !strings.Contains(away, "\x1b[2m(pen)\x1b[0m") { - t.Fatalf("expected away scored penalty suffix to be dimmed, got %q", away) - } -} -func TestFormatEventLabelFormatsMissedPenalty(t *testing.T) { - home := formatEventLabel(site.MatchEvent{MinuteText: "52", Kind: "MISS", TeamSide: "home", Text: "Gierman Barkowskij 52 (nk)"}) - away := formatEventLabel(site.MatchEvent{MinuteText: "52", Kind: "MISS", TeamSide: "away", Text: "Gierman Barkowskij 52 (nk)"}) - - if got := ansi.Strip(home); got != "G. Barkowskij (pen) ❌" { - t.Fatalf("unexpected home missed penalty label: %q", got) - } - if got := ansi.Strip(away); got != "❌ G. Barkowskij (pen)" { - t.Fatalf("unexpected away missed penalty label: %q", got) - } - if !strings.Contains(home, "\x1b[2m(pen)\x1b[0m") { - t.Fatalf("expected home missed penalty suffix to be dimmed, got %q", home) - } - if !strings.Contains(away, "\x1b[2m(pen)\x1b[0m") { - t.Fatalf("expected away missed penalty suffix to be dimmed, got %q", away) + // Lineup annotations: sub arrows present, scorer annotated + for _, want := range []string{ + "Wdowiak", + "⚽", + "→", + "←", + "🟥", + } { + if !strings.Contains(plainView, want) { + t.Fatalf("expected lineup annotation %q in view\n%s", want, view) + } } } -func TestFormatEventLabelFormatsSubstitutionOrderAndStyles(t *testing.T) { - home := formatEventLabel(site.MatchEvent{MinuteText: "66", Kind: "SUB", TeamSide: "home", Text: "Oskar Lesniak -> Damian Nowak"}) - away := formatEventLabel(site.MatchEvent{MinuteText: "66", Kind: "SUB", TeamSide: "away", Text: "Oskar Lesniak -> Damian Nowak"}) - - if got := ansi.Strip(home); got != "O. Lesniak ↕ D. Nowak" { - t.Fatalf("unexpected home substitution label: %q", got) - } - if got := ansi.Strip(away); got != "D. Nowak ↕ O. Lesniak" { - t.Fatalf("unexpected away substitution label: %q", got) - } - if !strings.Contains(home, "\x1b[2mO. Lesniak") { - t.Fatalf("expected outgoing home player to be dimmed, got %q", home) - } - if !strings.Contains(away, "\x1b[2mO. Lesniak") { - t.Fatalf("expected outgoing away player to be dimmed, got %q", away) - } -} func TestMatchDetailRowsAnchorTowardCenteredMinuteColumn(t *testing.T) { line := renderMatchDetailRow("Wdowiak ⚽", "39'", "↕ Pllana (4)", 76) @@ -325,17 +297,26 @@ func TestMatchDividerSharesCenteredMinuteColumn(t *testing.T) { } } -func TestScorerTimelineUsesCenteredMinuteColumn(t *testing.T) { - rows := scorerTimeline([]site.MatchEvent{{MinuteText: "17", Kind: "GOAL", TeamSide: "home", Text: "Krzysztof Kubica 17"}, {MinuteText: "30", Kind: "GOAL", TeamSide: "away", Text: "Karol Czubak (k) 30"}}) +func TestHeaderEventRowsIncludeScoreProgressionLabels(t *testing.T) { + rows := headerEventRows([]site.MatchEvent{ + {MinuteText: "17", Kind: "GOAL", TeamSide: "home", Text: "Krzysztof Kubica 17"}, + {MinuteText: "30", Kind: "GOAL", TeamSide: "away", Text: "Karol Czubak (k) 30"}, + }) + // Two goal rows only (no HT divider — all events in same half) if len(rows) != 2 { - t.Fatalf("expected two scorer rows, got %d", len(rows)) + t.Fatalf("expected two header rows, got %d: %#v", len(rows), rows) } - if ansi.Strip(rows[0].label) != "K. Kubica ⚽" || ansi.Strip(rows[1].label) != "⚽ K. Czubak (pen)" { - t.Fatalf("unexpected scorer labels: %#v", rows) + if ansi.Strip(rows[0].label) != "K. Kubica ⚽ (1-0)" { + t.Fatalf("unexpected home scorer label: %q", ansi.Strip(rows[0].label)) } + if ansi.Strip(rows[1].label) != "⚽ K. Czubak (pen) (1-1)" { + t.Fatalf("unexpected away scorer label: %q", ansi.Strip(rows[1].label)) + } + // Penalty suffix must still be dimmed if !strings.Contains(rows[1].label, "\x1b[2m(pen)\x1b[0m") { t.Fatalf("expected scored penalty suffix to be dimmed, got %q", rows[1].label) } + // Minute column alignment home := renderMatchDetailRow(rows[0].label, rows[0].minute, "", 76) away := renderMatchDetailRow("", rows[1].minute, rows[1].label, 76) homeMid := strings.Index(home, "17'") + (len("17'") / 2) @@ -345,6 +326,32 @@ func TestScorerTimelineUsesCenteredMinuteColumn(t *testing.T) { } } +func TestHeaderEventRowsIncludesRedCardsAndHTDivider(t *testing.T) { + rows := headerEventRows([]site.MatchEvent{ + {MinuteText: "39", Kind: "GOAL", TeamSide: "home", Text: "Wdowiak 39"}, + {MinuteText: "60", Kind: "GOAL", TeamSide: "home", Text: "Szkurin 60"}, + {MinuteText: "85", Kind: "RC", TeamSide: "away", Text: "Pllana 85"}, + }) + // Expect: goal (39'), HT divider, goal (60'), red card (85') + if len(rows) != 4 { + t.Fatalf("expected 4 rows (goal, HT, goal, RC), got %d: %#v", len(rows), rows) + } + if rows[1].isDivider { + // ok + } else { + t.Fatalf("expected row 1 to be HT divider, got %#v", rows[1]) + } + if ansi.Strip(rows[0].label) != "Wdowiak ⚽ (1-0)" { + t.Fatalf("unexpected first goal label: %q", ansi.Strip(rows[0].label)) + } + if !strings.Contains(ansi.Strip(rows[3].label), "🟥") { + t.Fatalf("expected red card row to contain 🟥, got %q", ansi.Strip(rows[3].label)) + } + if rows[3].minute != "85'" { + t.Fatalf("expected red card minute 85', got %q", rows[3].minute) + } +} + func TestRenderPlayerLineAbbreviatesNameAndDropsEvents(t *testing.T) { got := renderPlayerLine(site.PlayerLine{Name: "(86) Igor Strzalek", Events: []string{"YC", "RC"}}) if got != "I. Strzalek" { @@ -622,14 +629,14 @@ func TestMatchViewScrollsLongContent(t *testing.T) { Competition: "Ekstraklasa", } for i := 1; i <= 20; i++ { - m.match.Events = append(m.match.Events, site.MatchEvent{MinuteText: fmt.Sprintf("%d", i), TeamSide: "home", Kind: "SUB", Text: fmt.Sprintf("event-%02d", i)}) + m.match.HomeLineup = append(m.match.HomeLineup, site.PlayerLine{Name: fmt.Sprintf("Player%02d", i)}) } view := m.View() if got := strings.Count(view, "\n") + 1; got > m.height { t.Fatalf("expected match view to fit terminal height, got %d lines for height %d\n%s", got, m.height, view) } - if !strings.Contains(view, "event-01") { + if !strings.Contains(view, "Player01") { t.Fatalf("expected initial match view to show top content\n%s", view) } for _, want := range []string{"Standings", "Fixtures", "LEG 2-1 LEC"} { @@ -638,14 +645,14 @@ func TestMatchViewScrollsLongContent(t *testing.T) { } } - m.matchScroll = 12 + m.matchScroll = m.matchScrollLimit() view = m.View() - if !strings.Contains(view, "event-13") { - t.Fatalf("expected scrolled match view to show later content\n%s", view) - } - if strings.Contains(view, "event-01") { + if strings.Contains(view, "Player01") { t.Fatalf("expected scrolled match view to hide top content\n%s", view) } + if !strings.Contains(view, "Player20") { + t.Fatalf("expected scrolled match view to show later content\n%s", view) + } for _, want := range []string{"Standings", "Fixtures", "LEG 2-1 LEC"} { if !strings.Contains(view, want) { t.Fatalf("expected scrolled match view to keep sidebar content %q visible\n%s", want, view) @@ -682,7 +689,7 @@ func TestMatchViewScrollLimitIsClamped(t *testing.T) { m.matchView = true m.match = &site.MatchPage{Title: "Compact match"} for i := 1; i <= 20; i++ { - m.match.Events = append(m.match.Events, site.MatchEvent{MinuteText: fmt.Sprintf("%d", i), TeamSide: "home", Kind: "SUB", Text: fmt.Sprintf("event-%02d", i)}) + m.match.HomeLineup = append(m.match.HomeLineup, site.PlayerLine{Name: fmt.Sprintf("Player%02d", i)}) } limit := m.matchScrollLimit() From 4dd9f2d93a35cdce49780bd56b3562ae8f643da2 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:05:06 +0100 Subject: [PATCH 2/9] Refine score header alignment and lineup substitution display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Score header: goals now embed the minute in the side label with icon adjacent to center ("Name min' ⚽" / "⚽ min' Name"), and the center column carries the score progression "(1-0)". This matches the original web layout's centric design. Missed penalties (MISS) restored to the score header — they are significant events that belong alongside goals and red cards. Lineup subs: remove dimming from sub-off player; both directions now render at equal visual priority with plain "→ min'" / "← min'" annotations. Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/render_helpers.go | 66 +++++++++++++++++++---------------- internal/ui/view_test.go | 59 ++++++++++++++++++------------- 2 files changed, 69 insertions(+), 56 deletions(-) diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 5e3f849..6462843 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -488,8 +488,10 @@ type scorerLine struct { isDivider bool } -// headerEventRows returns goal events (with running score labels) and red card events -// sorted by minute, with an HT divider injected between halves when both exist. +// headerEventRows returns goal, missed-penalty, and red-card events sorted by minute, +// with an HT divider injected between halves when both exist. +// Goals embed the minute in the side label; the center column carries the score progression. +// MISS and RC events keep the minute in the center column (no score change). func headerEventRows(events []site.MatchEvent) []scorerLine { ordered := sortedEvents(events) home, away := 0, 0 @@ -498,10 +500,12 @@ func headerEventRows(events []site.MatchEvent) []scorerLine { lines := make([]scorerLine, 0, 8) for _, event := range ordered { - if event.Kind != "GOAL" && event.Kind != "RC" { + switch event.Kind { + case "GOAL", "MISS", "RC": + default: continue } - if event.Kind == "RC" && strings.TrimSpace(event.MinuteText) == "" { + if strings.TrimSpace(event.MinuteText) == "" { continue } @@ -512,6 +516,7 @@ func headerEventRows(events []site.MatchEvent) []scorerLine { } } + name := trimEventMinute(event) switch event.Kind { case "GOAL": if event.TeamSide == "home" { @@ -519,17 +524,28 @@ func headerEventRows(events []site.MatchEvent) []scorerLine { } else if event.TeamSide == "away" { away++ } - name := trimEventMinute(event) if name == "" { continue } + minute := strings.TrimSpace(formatMatchMinute(event.MinuteText)) + lines = append(lines, scorerLine{ + label: formatGoalLabel(name, minute, event.TeamSide), + minute: fmt.Sprintf("(%d-%d)", home, away), + side: event.TeamSide, + }) + case "MISS": + var label string + if event.TeamSide == "home" { + label = formatLeftEventLabel("MISS", name) + } else { + label = formatRightEventLabel("MISS", name) + } lines = append(lines, scorerLine{ - label: formatScorerLabelWithScore(name, event.TeamSide, fmt.Sprintf("(%d-%d)", home, away)), + label: label, minute: formatMatchMinute(event.MinuteText), side: event.TeamSide, }) case "RC": - name := trimEventMinute(event) var label string if event.TeamSide == "home" { label = formatLeftEventLabel("RC", name) @@ -547,12 +563,14 @@ func headerEventRows(events []site.MatchEvent) []scorerLine { return lines } -func formatScorerLabelWithScore(name, side, scoreLabel string) string { +// formatGoalLabel builds a goal side-label with the minute embedded and the icon +// adjacent to the center column: "Name min' ⚽" (home) or "⚽ min' Name" (away). +func formatGoalLabel(name, minute, side string) string { glyph := eventPrefix("GOAL") if side == "home" { - return name + " " + glyph + " " + scoreLabel + return name + " " + minute + " " + glyph } - return glyph + " " + name + " " + scoreLabel + return glyph + " " + minute + " " + name } // playerLastName returns a lowercase last-name key for fuzzy event-to-player matching. @@ -600,8 +618,9 @@ func playerEventIndex(events []site.MatchEvent, side string) map[string][]site.M } // renderAnnotatedPlayer returns the player label with event annotations appended. -// Goals get ⚽+minute, cards get their glyph+minute, substitutions get directional -// arrows (→ for sub-off with the name dimmed, ← for sub-on). +// Goals get ⚽+minute, cards get their glyph+minute. +// Substitutions show a directional arrow with minute: → for sub-off, ← for sub-on. +// No dimming is applied — all players share equal visual priority. func renderAnnotatedPlayer(player site.PlayerLine, side string, idx map[string][]site.MatchEvent) string { base := formatPlayerLabel(player.Name) key := playerLastName(base) @@ -614,7 +633,6 @@ func renderAnnotatedPlayer(player site.PlayerLine, side string, idx map[string][ return base } - dimName := false annotations := make([]string, 0, 3) for _, e := range matched { @@ -631,32 +649,18 @@ func renderAnnotatedPlayer(player site.PlayerLine, side string, idx map[string][ outKey := playerLastName(out) inKey := playerLastName(in) if outKey == key { - dimName = true - if in != "" { - annotations = append(annotations, "→ "+in+" "+minute) - } else { - annotations = append(annotations, "→ "+minute) - } + annotations = append(annotations, "→ "+minute) } else if inKey == key { - if out != "" { - annotations = append(annotations, "← "+out+" "+minute) - } else { - annotations = append(annotations, "← "+minute) - } + annotations = append(annotations, "← "+minute) } } } - name := base - if dimName { - name = faintText(base) - } - if len(annotations) == 0 { - return name + return base } - return name + " " + strings.Join(annotations, " ") + return base + " " + strings.Join(annotations, " ") } func halftimeScore(events []site.MatchEvent) string { diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index f79ac49..4452c88 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -191,16 +191,14 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { view := m.View() plainView := ansi.Strip(view) - // Score header: goals with progression labels, red card, HT/FT dividers + // Score header: goals with minute-in-label, score progression in center, HT/FT dividers for _, want := range []string{ - "Wdowiak ⚽ (1-0)", - "39'", + "Wdowiak", "39'", "⚽", "(1-0)", // home goal row "HT 1-0", - "Szkurin ⚽ (2-0)", - "60'", - "70'", - "🟥", - "85'", + "❌", "52'", // away missed penalty row + "Szkurin", "60'", "(2-0)", // second home goal row + "K. Czubak", "70'", "(2-1)", // away goal row + "🟥", "85'", // away red card row "FT 2-1", } { if !strings.Contains(plainView, want) { @@ -208,10 +206,9 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { } } - // Score header must not contain MISS or SUB events + // No Timeline section, no sub glyph in score header for _, unwanted := range []string{ "Timeline", - "❌", "↕", } { if strings.Contains(plainView, unwanted) { @@ -219,10 +216,11 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { } } - // Score header ordering: 39' before HT before 60' before 70' before 85' + // Score header ordering: 39' before HT before 52' before 60' before 70' before 85' headerIndexes := []int{ strings.Index(plainView, "39'"), strings.Index(plainView, "HT 1-0"), + strings.Index(plainView, "52'"), strings.Index(plainView, "60'"), strings.Index(plainView, "70'"), strings.Index(plainView, "85'"), @@ -235,22 +233,25 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { } for i := 1; i < len(headerIndexes); i++ { if headerIndexes[i-1] >= headerIndexes[i] { - t.Fatalf("expected score header order 39 -> HT -> 60 -> 70 -> 85 -> FT\n%s", plainView) + t.Fatalf("expected score header order 39 -> HT -> 52 -> 60 -> 70 -> 85 -> FT\n%s", plainView) } } - // Lineup annotations: sub arrows present, scorer annotated + // Lineup annotations: goal, sub arrows, red card for _, want := range []string{ - "Wdowiak", - "⚽", - "→", - "←", - "🟥", + "Wdowiak", "⚽", // scorer annotated + "→", // sub-off arrow + "←", // sub-on arrow + "🟥", // red card annotation on player } { if !strings.Contains(plainView, want) { t.Fatalf("expected lineup annotation %q in view\n%s", want, view) } } + // Lineup sub-off player must NOT be dimmed (equal visual priority) + if strings.Contains(view, "\x1b[2mI. Strzalek") { + t.Fatalf("expected sub-off player to not be dimmed\n%s", view) + } } @@ -306,23 +307,31 @@ func TestHeaderEventRowsIncludeScoreProgressionLabels(t *testing.T) { if len(rows) != 2 { t.Fatalf("expected two header rows, got %d: %#v", len(rows), rows) } - if ansi.Strip(rows[0].label) != "K. Kubica ⚽ (1-0)" { + // Label has minute embedded, icon adjacent to center + if ansi.Strip(rows[0].label) != "K. Kubica 17' ⚽" { t.Fatalf("unexpected home scorer label: %q", ansi.Strip(rows[0].label)) } - if ansi.Strip(rows[1].label) != "⚽ K. Czubak (pen) (1-1)" { + if ansi.Strip(rows[1].label) != "⚽ 30' K. Czubak (pen)" { t.Fatalf("unexpected away scorer label: %q", ansi.Strip(rows[1].label)) } // Penalty suffix must still be dimmed if !strings.Contains(rows[1].label, "\x1b[2m(pen)\x1b[0m") { t.Fatalf("expected scored penalty suffix to be dimmed, got %q", rows[1].label) } - // Minute column alignment + // Center column carries the score progression, not the minute + if rows[0].minute != "(1-0)" { + t.Fatalf("expected home center to carry score (1-0), got %q", rows[0].minute) + } + if rows[1].minute != "(1-1)" { + t.Fatalf("expected away center to carry score (1-1), got %q", rows[1].minute) + } + // Score columns align on the same center axis home := renderMatchDetailRow(rows[0].label, rows[0].minute, "", 76) away := renderMatchDetailRow("", rows[1].minute, rows[1].label, 76) - homeMid := strings.Index(home, "17'") + (len("17'") / 2) - awayMid := strings.Index(away, "30'") + (len("30'") / 2) + homeMid := strings.Index(home, "(1-0)") + (len("(1-0)") / 2) + awayMid := strings.Index(away, "(1-1)") + (len("(1-1)") / 2) if diff := homeMid - awayMid; diff < -1 || diff > 1 { - t.Fatalf("expected scorer minutes to share centered column\nhome: %q\naway: %q", home, away) + t.Fatalf("expected score labels to share centered column\nhome: %q\naway: %q", home, away) } } @@ -341,7 +350,7 @@ func TestHeaderEventRowsIncludesRedCardsAndHTDivider(t *testing.T) { } else { t.Fatalf("expected row 1 to be HT divider, got %#v", rows[1]) } - if ansi.Strip(rows[0].label) != "Wdowiak ⚽ (1-0)" { + if ansi.Strip(rows[0].label) != "Wdowiak 39' ⚽" { t.Fatalf("unexpected first goal label: %q", ansi.Strip(rows[0].label)) } if !strings.Contains(ansi.Strip(rows[3].label), "🟥") { From a7408b1781a9f8cdd000825318b1a2108305abee Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:13:03 +0100 Subject: [PATCH 3/9] Rework lineup event display: dedicated event column near centre separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each lineup row now uses a 5-column layout: [home name →right] [home events →right] | [away events ←left] [away name ←left] Events (cards, sub arrows) cluster next to the | separator. Empty event columns produce a wider gap — no visual clutter for players with no events. Goals removed from lineup annotations; they belong in the score header only. Sub display: plain → min' (sub-off) and ← min' (sub-on), no dimming. HT divider format unified with FT: "HT X - Y" instead of "HT X-Y". Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/render_helpers.go | 70 +++++++++++++++++++++++++---------- internal/ui/view.go | 11 ++++-- internal/ui/view_test.go | 25 ++++++++----- 3 files changed, 74 insertions(+), 32 deletions(-) diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 6462843..35f28fa 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -617,50 +617,82 @@ func playerEventIndex(events []site.MatchEvent, side string) map[string][]site.M return idx } -// renderAnnotatedPlayer returns the player label with event annotations appended. -// Goals get ⚽+minute, cards get their glyph+minute. -// Substitutions show a directional arrow with minute: → for sub-off, ← for sub-on. -// No dimming is applied — all players share equal visual priority. -func renderAnnotatedPlayer(player site.PlayerLine, side string, idx map[string][]site.MatchEvent) string { +// playerEventAnnotation returns the event badge string for a lineup player — +// cards and sub arrows only (goals are shown in the score header, not lineups). +// Empty string means no events; the caller renders a wider gap in that case. +func playerEventAnnotation(player site.PlayerLine, side string, idx map[string][]site.MatchEvent) string { base := formatPlayerLabel(player.Name) key := playerLastName(base) if key == "" { - return base + return "" } matched, ok := idx[key] if !ok { - return base + return "" } - annotations := make([]string, 0, 3) - + parts := make([]string, 0, 2) for _, e := range matched { minute := strings.TrimSpace(formatMatchMinute(e.MinuteText)) switch e.Kind { - case "GOAL": - annotations = append(annotations, eventPrefix("GOAL")+" "+minute) case "YC": - annotations = append(annotations, eventPrefix("YC")+" "+minute) + parts = append(parts, eventPrefix("YC")+" "+minute) case "RC": - annotations = append(annotations, eventPrefix("RC")+" "+minute) + parts = append(parts, eventPrefix("RC")+" "+minute) case "SUB": out, in := substitutionPlayers(e.Text) outKey := playerLastName(out) inKey := playerLastName(in) if outKey == key { - annotations = append(annotations, "→ "+minute) + if minute != "" { + parts = append(parts, "→ "+minute) + } else { + parts = append(parts, "→") + } } else if inKey == key { - annotations = append(annotations, "← "+minute) + if minute != "" { + parts = append(parts, "← "+minute) + } else { + parts = append(parts, "←") + } } } } - if len(annotations) == 0 { - return base + return strings.Join(parts, " ") +} + +// renderAnnotatedLineupRow renders a lineup player row with a dedicated event column +// between each player name and the centre separator: +// +// [home name →right] [home events →right] | [away events ←left] [away name ←left] +// +// When a player has no events the event column is empty, producing a wider gap +// that keeps the visual centre clean. +func renderAnnotatedLineupRow(homePlayer, homeEvents, awayPlayer, awayEvents string, width int) string { + if width < 36 { + home := homePlayer + if homeEvents != "" { + home += " " + homeEvents + } + away := awayPlayer + if awayEvents != "" { + away = awayEvents + " " + away + } + return renderLineupRow(home, away, width) } - return base + " " + strings.Join(annotations, " ") + const eventWidth = 9 + const gap = 1 + playerWidth := max(8, (width-1-2*eventWidth-2*gap)/2) + + leftPlayer := padLeft(truncate(homePlayer, playerWidth), playerWidth) + leftEvents := padLeft(truncate(homeEvents, eventWidth), eventWidth) + rightEvents := padRight(truncate(awayEvents, eventWidth), eventWidth) + rightPlayer := truncate(awayPlayer, playerWidth) + + return leftPlayer + strings.Repeat(" ", gap) + leftEvents + "|" + rightEvents + strings.Repeat(" ", gap) + rightPlayer } func halftimeScore(events []site.MatchEvent) string { @@ -693,7 +725,7 @@ func halftimeScore(events []site.MatchEvent) string { return "" } - return fmt.Sprintf("HT %d-%d", homeGoals, awayGoals) + return fmt.Sprintf("HT %d - %d", homeGoals, awayGoals) } func finalScoreLine(page *site.MatchPage) string { diff --git a/internal/ui/view.go b/internal/ui/view.go index dde035f..16843d5 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -501,14 +501,17 @@ func (m Model) matchDetailContent(width int) string { maxPlayers := max(len(m.match.HomeLineup), len(m.match.AwayLineup)) for i := 0; i < maxPlayers; i++ { - homeText, awayText := "", "" + homeName, homeEvents := "", "" + awayName, awayEvents := "", "" if i < len(m.match.HomeLineup) { - homeText = renderAnnotatedPlayer(m.match.HomeLineup[i], "home", homeIdx) + homeName = formatPlayerLabel(m.match.HomeLineup[i].Name) + homeEvents = playerEventAnnotation(m.match.HomeLineup[i], "home", homeIdx) } if i < len(m.match.AwayLineup) { - awayText = renderAnnotatedPlayer(m.match.AwayLineup[i], "away", awayIdx) + awayName = formatPlayerLabel(m.match.AwayLineup[i].Name) + awayEvents = playerEventAnnotation(m.match.AwayLineup[i], "away", awayIdx) } - b.WriteString(renderLineupRow(homeText, awayText, width-4)) + b.WriteString(renderAnnotatedLineupRow(homeName, homeEvents, awayName, awayEvents, width-4)) b.WriteString("\n") } } diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index 4452c88..16f9c3d 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -194,7 +194,7 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { // Score header: goals with minute-in-label, score progression in center, HT/FT dividers for _, want := range []string{ "Wdowiak", "39'", "⚽", "(1-0)", // home goal row - "HT 1-0", + "HT 1 - 0", "❌", "52'", // away missed penalty row "Szkurin", "60'", "(2-0)", // second home goal row "K. Czubak", "70'", "(2-1)", // away goal row @@ -219,7 +219,7 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { // Score header ordering: 39' before HT before 52' before 60' before 70' before 85' headerIndexes := []int{ strings.Index(plainView, "39'"), - strings.Index(plainView, "HT 1-0"), + strings.Index(plainView, "HT 1 - 0"), strings.Index(plainView, "52'"), strings.Index(plainView, "60'"), strings.Index(plainView, "70'"), @@ -237,18 +237,25 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { } } - // Lineup annotations: goal, sub arrows, red card + // Lineup event columns: cards and sub arrows visible; no goals in lineup for _, want := range []string{ - "Wdowiak", "⚽", // scorer annotated - "→", // sub-off arrow - "←", // sub-on arrow - "🟥", // red card annotation on player + "→", // sub-off arrow in event column + "←", // sub-on arrow in event column + "🟥", // red card badge in event column } { if !strings.Contains(plainView, want) { - t.Fatalf("expected lineup annotation %q in view\n%s", want, view) + t.Fatalf("expected lineup event column to contain %q\n%s", want, view) } } - // Lineup sub-off player must NOT be dimmed (equal visual priority) + // Goals must NOT be annotated in the lineup (they belong to the score header) + lineupIdx := strings.Index(plainView, "Lineups") + if lineupIdx >= 0 { + lineupSection := plainView[lineupIdx:] + if strings.Contains(lineupSection, "⚽") { + t.Fatalf("expected lineup section to omit goal annotations\n%s", lineupSection) + } + } + // Sub-off player must NOT be dimmed (equal visual priority with sub-on) if strings.Contains(view, "\x1b[2mI. Strzalek") { t.Fatalf("expected sub-off player to not be dimmed\n%s", view) } From 99bb6deea836cd320bcefb2f736245bd8dd5592d Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:24:49 +0100 Subject: [PATCH 4/9] Refine match detail layout: minute in center, slim card column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goals now render name+icon in the side label with the minute in the centre column — removing score-progression labels (1-0) etc. Card event column next to the separator narrowed to a single emoji (YC/RC or empty), dropping the minute from that column. Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/render_helpers.go | 113 ++++++++++++++++++++++++---------- internal/ui/view.go | 34 +++++++--- internal/ui/view_test.go | 53 ++++++++-------- 3 files changed, 132 insertions(+), 68 deletions(-) diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 35f28fa..7205492 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -490,8 +490,7 @@ type scorerLine struct { // headerEventRows returns goal, missed-penalty, and red-card events sorted by minute, // with an HT divider injected between halves when both exist. -// Goals embed the minute in the side label; the center column carries the score progression. -// MISS and RC events keep the minute in the center column (no score change). +// All events carry the minute in the center column; the side label holds name + icon. func headerEventRows(events []site.MatchEvent) []scorerLine { ordered := sortedEvents(events) home, away := 0, 0 @@ -527,10 +526,9 @@ func headerEventRows(events []site.MatchEvent) []scorerLine { if name == "" { continue } - minute := strings.TrimSpace(formatMatchMinute(event.MinuteText)) lines = append(lines, scorerLine{ - label: formatGoalLabel(name, minute, event.TeamSide), - minute: fmt.Sprintf("(%d-%d)", home, away), + label: formatGoalLabel(name, event.TeamSide), + minute: formatMatchMinute(event.MinuteText), side: event.TeamSide, }) case "MISS": @@ -563,14 +561,14 @@ func headerEventRows(events []site.MatchEvent) []scorerLine { return lines } -// formatGoalLabel builds a goal side-label with the minute embedded and the icon -// adjacent to the center column: "Name min' ⚽" (home) or "⚽ min' Name" (away). -func formatGoalLabel(name, minute, side string) string { +// formatGoalLabel builds a goal side-label with the icon adjacent to the center column. +// "Name ⚽" (home, icon on right nearest center) or "⚽ Name" (away, icon on left nearest center). +func formatGoalLabel(name, side string) string { glyph := eventPrefix("GOAL") if side == "home" { - return name + " " + minute + " " + glyph + return name + " " + glyph } - return glyph + " " + minute + " " + name + return glyph + " " + name } // playerLastName returns a lowercase last-name key for fuzzy event-to-player matching. @@ -617,10 +615,9 @@ func playerEventIndex(events []site.MatchEvent, side string) map[string][]site.M return idx } -// playerEventAnnotation returns the event badge string for a lineup player — -// cards and sub arrows only (goals are shown in the score header, not lineups). -// Empty string means no events; the caller renders a wider gap in that case. -func playerEventAnnotation(player site.PlayerLine, side string, idx map[string][]site.MatchEvent) string { +// cardAnnotation returns the YC/RC badge string for a lineup player, intended +// for the dedicated event column next to the centre separator. Empty when clean. +func cardAnnotation(player site.PlayerLine, idx map[string][]site.MatchEvent) string { base := formatPlayerLabel(player.Name) key := playerLastName(base) if key == "" { @@ -632,35 +629,85 @@ func playerEventAnnotation(player site.PlayerLine, side string, idx map[string][ return "" } - parts := make([]string, 0, 2) for _, e := range matched { - minute := strings.TrimSpace(formatMatchMinute(e.MinuteText)) switch e.Kind { case "YC": - parts = append(parts, eventPrefix("YC")+" "+minute) + return eventPrefix("YC") case "RC": - parts = append(parts, eventPrefix("RC")+" "+minute) - case "SUB": + return eventPrefix("RC") + } + } + return "" +} + +// lineupEntry is a display-ready lineup row entry. +// subMinute is non-empty for sub-on players and carries the substitution minute. +type lineupEntry struct { + player site.PlayerLine + subMinute string +} + +// reorderedLineup returns lineup entries with each sub-on player inserted +// immediately after the player they replaced. Sub-on players are identified +// via the event index; those that cannot be matched are appended at the end. +func reorderedLineup(players []site.PlayerLine, idx map[string][]site.MatchEvent) []lineupEntry { + if len(players) == 0 { + return nil + } + + type subInfo struct{ onKey, minute string } + subOffMap := make(map[string]subInfo, 4) + subOnSet := make(map[string]bool, 4) + + for _, player := range players { + key := playerLastName(formatPlayerLabel(player.Name)) + for _, e := range idx[key] { + if e.Kind != "SUB" { + continue + } out, in := substitutionPlayers(e.Text) outKey := playerLastName(out) inKey := playerLastName(in) - if outKey == key { - if minute != "" { - parts = append(parts, "→ "+minute) - } else { - parts = append(parts, "→") - } - } else if inKey == key { - if minute != "" { - parts = append(parts, "← "+minute) - } else { - parts = append(parts, "←") - } + minute := strings.TrimSpace(formatMatchMinute(e.MinuteText)) + if outKey == key && inKey != "" { + subOffMap[key] = subInfo{onKey: inKey, minute: minute} + subOnSet[inKey] = true } } } - return strings.Join(parts, " ") + // Build key → PlayerLine lookup + byKey := make(map[string]site.PlayerLine, len(players)) + for _, p := range players { + byKey[playerLastName(formatPlayerLabel(p.Name))] = p + } + + result := make([]lineupEntry, 0, len(players)) + insertedSubOns := make(map[string]bool, len(subOnSet)) + + for _, player := range players { + key := playerLastName(formatPlayerLabel(player.Name)) + if subOnSet[key] { + continue // will be placed after their sub-off player + } + result = append(result, lineupEntry{player: player}) + if info, ok := subOffMap[key]; ok { + if onPlayer, exists := byKey[info.onKey]; exists { + result = append(result, lineupEntry{player: onPlayer, subMinute: info.minute}) + insertedSubOns[info.onKey] = true + } + } + } + + // Append unmatched sub-on players (name mismatch between event and lineup) + for _, player := range players { + key := playerLastName(formatPlayerLabel(player.Name)) + if subOnSet[key] && !insertedSubOns[key] { + result = append(result, lineupEntry{player: player}) + } + } + + return result } // renderAnnotatedLineupRow renders a lineup player row with a dedicated event column @@ -683,7 +730,7 @@ func renderAnnotatedLineupRow(homePlayer, homeEvents, awayPlayer, awayEvents str return renderLineupRow(home, away, width) } - const eventWidth = 9 + const eventWidth = 2 // one emoji wide (YC/RC or empty) const gap = 1 playerWidth := max(8, (width-1-2*eventWidth-2*gap)/2) diff --git a/internal/ui/view.go b/internal/ui/view.go index 16843d5..3d1896e 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -498,20 +498,34 @@ func (m Model) matchDetailContent(width int) string { homeIdx := playerEventIndex(m.match.Events, "home") awayIdx := playerEventIndex(m.match.Events, "away") - maxPlayers := max(len(m.match.HomeLineup), len(m.match.AwayLineup)) + homeEntries := reorderedLineup(m.match.HomeLineup, homeIdx) + awayEntries := reorderedLineup(m.match.AwayLineup, awayIdx) + maxPlayers := max(len(homeEntries), len(awayEntries)) for i := 0; i < maxPlayers; i++ { - homeName, homeEvents := "", "" - awayName, awayEvents := "", "" - if i < len(m.match.HomeLineup) { - homeName = formatPlayerLabel(m.match.HomeLineup[i].Name) - homeEvents = playerEventAnnotation(m.match.HomeLineup[i], "home", homeIdx) + var hEntry, aEntry lineupEntry + if i < len(homeEntries) { + hEntry = homeEntries[i] } - if i < len(m.match.AwayLineup) { - awayName = formatPlayerLabel(m.match.AwayLineup[i].Name) - awayEvents = playerEventAnnotation(m.match.AwayLineup[i], "away", awayIdx) + if i < len(awayEntries) { + aEntry = awayEntries[i] } - b.WriteString(renderAnnotatedLineupRow(homeName, homeEvents, awayName, awayEvents, width-4)) + + // Sub-on players get their substitution minute at the outer edge of the name. + homeName := formatPlayerLabel(hEntry.player.Name) + if hEntry.subMinute != "" { + homeName = hEntry.subMinute + " " + homeName + } + awayName := formatPlayerLabel(aEntry.player.Name) + if aEntry.subMinute != "" { + awayName = awayName + " " + aEntry.subMinute + } + + b.WriteString(renderAnnotatedLineupRow( + homeName, cardAnnotation(hEntry.player, homeIdx), + awayName, cardAnnotation(aEntry.player, awayIdx), + width-4, + )) b.WriteString("\n") } } diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index 16f9c3d..f96e92e 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -191,14 +191,14 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { view := m.View() plainView := ansi.Strip(view) - // Score header: goals with minute-in-label, score progression in center, HT/FT dividers + // Score header: goals with minute-in-center, HT/FT dividers for _, want := range []string{ - "Wdowiak", "39'", "⚽", "(1-0)", // home goal row + "Wdowiak", "39'", "⚽", // home goal row (minute in center, icon adjacent) "HT 1 - 0", - "❌", "52'", // away missed penalty row - "Szkurin", "60'", "(2-0)", // second home goal row - "K. Czubak", "70'", "(2-1)", // away goal row - "🟥", "85'", // away red card row + "❌", "52'", // away missed penalty row + "Szkurin", "60'", // second home goal row + "K. Czubak", "70'", // away goal row + "🟥", "85'", // away red card row "FT 2-1", } { if !strings.Contains(plainView, want) { @@ -237,14 +237,14 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { } } - // Lineup event columns: cards and sub arrows visible; no goals in lineup + // Lineup section: sub minutes at outer edge, sub-on player visible, cards in event column for _, want := range []string{ - "→", // sub-off arrow in event column - "←", // sub-on arrow in event column - "🟥", // red card badge in event column + "46'", // sub minute visible at outer edge of player name + "D. Nowak", // sub-on player visible immediately after sub-off + "🟥", // red card badge in event column } { if !strings.Contains(plainView, want) { - t.Fatalf("expected lineup event column to contain %q\n%s", want, view) + t.Fatalf("expected lineup section to contain %q\n%s", want, view) } } // Goals must NOT be annotated in the lineup (they belong to the score header) @@ -305,7 +305,7 @@ func TestMatchDividerSharesCenteredMinuteColumn(t *testing.T) { } } -func TestHeaderEventRowsIncludeScoreProgressionLabels(t *testing.T) { +func TestHeaderEventRowsMinuteInCenterColumn(t *testing.T) { rows := headerEventRows([]site.MatchEvent{ {MinuteText: "17", Kind: "GOAL", TeamSide: "home", Text: "Krzysztof Kubica 17"}, {MinuteText: "30", Kind: "GOAL", TeamSide: "away", Text: "Karol Czubak (k) 30"}, @@ -314,31 +314,31 @@ func TestHeaderEventRowsIncludeScoreProgressionLabels(t *testing.T) { if len(rows) != 2 { t.Fatalf("expected two header rows, got %d: %#v", len(rows), rows) } - // Label has minute embedded, icon adjacent to center - if ansi.Strip(rows[0].label) != "K. Kubica 17' ⚽" { + // Label has name + icon only; no minute embedded in label + if ansi.Strip(rows[0].label) != "K. Kubica ⚽" { t.Fatalf("unexpected home scorer label: %q", ansi.Strip(rows[0].label)) } - if ansi.Strip(rows[1].label) != "⚽ 30' K. Czubak (pen)" { + if ansi.Strip(rows[1].label) != "⚽ K. Czubak (pen)" { t.Fatalf("unexpected away scorer label: %q", ansi.Strip(rows[1].label)) } // Penalty suffix must still be dimmed if !strings.Contains(rows[1].label, "\x1b[2m(pen)\x1b[0m") { t.Fatalf("expected scored penalty suffix to be dimmed, got %q", rows[1].label) } - // Center column carries the score progression, not the minute - if rows[0].minute != "(1-0)" { - t.Fatalf("expected home center to carry score (1-0), got %q", rows[0].minute) + // Center column carries the minute + if strings.TrimSpace(rows[0].minute) != "17'" { + t.Fatalf("expected home center to carry minute 17', got %q", rows[0].minute) } - if rows[1].minute != "(1-1)" { - t.Fatalf("expected away center to carry score (1-1), got %q", rows[1].minute) + if strings.TrimSpace(rows[1].minute) != "30'" { + t.Fatalf("expected away center to carry minute 30', got %q", rows[1].minute) } - // Score columns align on the same center axis + // Minutes align on the same center axis home := renderMatchDetailRow(rows[0].label, rows[0].minute, "", 76) away := renderMatchDetailRow("", rows[1].minute, rows[1].label, 76) - homeMid := strings.Index(home, "(1-0)") + (len("(1-0)") / 2) - awayMid := strings.Index(away, "(1-1)") + (len("(1-1)") / 2) + homeMid := strings.Index(home, "17'") + awayMid := strings.Index(away, "30'") if diff := homeMid - awayMid; diff < -1 || diff > 1 { - t.Fatalf("expected score labels to share centered column\nhome: %q\naway: %q", home, away) + t.Fatalf("expected minutes to share centered column\nhome: %q\naway: %q", home, away) } } @@ -357,9 +357,12 @@ func TestHeaderEventRowsIncludesRedCardsAndHTDivider(t *testing.T) { } else { t.Fatalf("expected row 1 to be HT divider, got %#v", rows[1]) } - if ansi.Strip(rows[0].label) != "Wdowiak 39' ⚽" { + if ansi.Strip(rows[0].label) != "Wdowiak ⚽" { t.Fatalf("unexpected first goal label: %q", ansi.Strip(rows[0].label)) } + if strings.TrimSpace(rows[0].minute) != "39'" { + t.Fatalf("expected first goal minute 39' in center, got %q", rows[0].minute) + } if !strings.Contains(ansi.Strip(rows[3].label), "🟥") { t.Fatalf("expected red card row to contain 🟥, got %q", ansi.Strip(rows[3].label)) } From b4081a4eb54dcbb1bf82952553c0d94ded32a228 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:32:36 +0100 Subject: [PATCH 5/9] Align HT/FT divider score with minute column Moves "HT"/"FT" tags into the left dash run and puts only the score ("X - Y" or "X-Y") into the shared 9-char centre column, so the dash in the score sits at the same column position as event-row minutes. Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/render_helpers.go | 35 ++++++++++++++++++++++------- internal/ui/view.go | 6 ++--- internal/ui/view_test.go | 42 ++++++++++++++++++++--------------- 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 7205492..460441a 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -449,18 +449,37 @@ func renderDividerLabel(label string, width int) string { return strings.Repeat("-", left) + " " + cleaned + " " + strings.Repeat("-", right) } -func renderMatchDividerRow(label string, width int) string { +// renderMatchDividerRow renders a dash-line divider with the score in the +// shared centre column (aligning it with minutes on event rows) and the +// period tag (e.g. "HT", "FT") embedded in the left dash run. +func renderMatchDividerRow(tag, score string, width int) string { if width < 30 { + label := score + if tag != "" { + label = tag + " " + score + } return renderDividerLabel(label, width) } midWidth := 9 gap := 1 sideWidth := max(8, (width-midWidth-(gap*2))/2) - left := strings.Repeat("-", sideWidth) + + // Embed tag centred within the left dash run so the score occupies the + // shared centre column and aligns with event-row minutes. + var left string + if tag != "" { + tagDisplay := " " + tag + " " + dashCount := max(0, sideWidth-len(tagDisplay)) + leading := dashCount / 2 + trailing := dashCount - leading + left = strings.Repeat("-", leading) + tagDisplay + strings.Repeat("-", trailing) + } else { + left = strings.Repeat("-", sideWidth) + } right := strings.Repeat("-", sideWidth) - return left + strings.Repeat(" ", gap) + padCenter(truncate(label, midWidth), midWidth) + strings.Repeat(" ", gap) + right + return left + strings.Repeat(" ", gap) + padCenter(truncate(score, midWidth), midWidth) + strings.Repeat(" ", gap) + right } func matchStatus(page *site.MatchPage) string { @@ -494,7 +513,7 @@ type scorerLine struct { func headerEventRows(events []site.MatchEvent) []scorerLine { ordered := sortedEvents(events) home, away := 0, 0 - htLabel := halftimeScore(events) + htScore := halftimeScore(events) // "X - Y" — goes in centre column insertedHT := false lines := make([]scorerLine, 0, 8) @@ -508,9 +527,9 @@ func headerEventRows(events []site.MatchEvent) []scorerLine { continue } - if !insertedHT && htLabel != "" { + if !insertedHT && htScore != "" { if key, ok := minuteSortKey(event.MinuteText); ok && key > 4599 { - lines = append(lines, scorerLine{label: htLabel, isDivider: true}) + lines = append(lines, scorerLine{label: "HT", minute: htScore, isDivider: true}) insertedHT = true } } @@ -772,7 +791,7 @@ func halftimeScore(events []site.MatchEvent) string { return "" } - return fmt.Sprintf("HT %d - %d", homeGoals, awayGoals) + return fmt.Sprintf("%d - %d", homeGoals, awayGoals) } func finalScoreLine(page *site.MatchPage) string { @@ -785,7 +804,7 @@ func finalScoreLine(page *site.MatchPage) string { return "" } - return "FT " + normalizeScore(score) + return normalizeScore(score) } func matchMetaParts(meta, weather string) []string { diff --git a/internal/ui/view.go b/internal/ui/view.go index 3d1896e..0c47556 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -463,7 +463,7 @@ func (m Model) matchDetailContent(width int) string { } for _, row := range headerEvents { if row.isDivider { - b.WriteString(renderMatchDividerRow(row.label, width-4)) + b.WriteString(renderMatchDividerRow(row.label, row.minute, width-4)) b.WriteString("\n") continue } @@ -477,8 +477,8 @@ func (m Model) matchDetailContent(width int) string { b.WriteString("\n") } if len(headerEvents) > 0 { - if ftDivider := finalScoreLine(m.match); ftDivider != "" { - b.WriteString(renderMatchDividerRow(ftDivider, width-4)) + if ftScore := finalScoreLine(m.match); ftScore != "" { + b.WriteString(renderMatchDividerRow("FT", ftScore, width-4)) b.WriteString("\n") } } diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index f96e92e..b6f8a3e 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -135,7 +135,7 @@ func TestMatchDetailRemovesRedundantMetadata(t *testing.T) { "S. Mraz", "17'", "62'", - "FT 1-2", + "FT", "1-2", "Details", "13 March 2026, 18:00 | Attendance 3542 | Ref. Damian Kos | Weather 15 C", } { @@ -194,12 +194,12 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { // Score header: goals with minute-in-center, HT/FT dividers for _, want := range []string{ "Wdowiak", "39'", "⚽", // home goal row (minute in center, icon adjacent) - "HT 1 - 0", + "HT", "1 - 0", // HT tag in dashes, score in centre column "❌", "52'", // away missed penalty row "Szkurin", "60'", // second home goal row "K. Czubak", "70'", // away goal row "🟥", "85'", // away red card row - "FT 2-1", + "FT", "2-1", // FT tag in dashes, score in centre column } { if !strings.Contains(plainView, want) { t.Fatalf("expected match view to contain %q\n%s", want, view) @@ -219,12 +219,12 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { // Score header ordering: 39' before HT before 52' before 60' before 70' before 85' headerIndexes := []int{ strings.Index(plainView, "39'"), - strings.Index(plainView, "HT 1 - 0"), + strings.Index(plainView, "HT"), strings.Index(plainView, "52'"), strings.Index(plainView, "60'"), strings.Index(plainView, "70'"), strings.Index(plainView, "85'"), - strings.Index(plainView, "FT 2-1"), + strings.Index(plainView, "FT"), } for _, idx := range headerIndexes { if idx < 0 { @@ -297,11 +297,14 @@ func TestFormatMatchMinuteLeftPadsSingleDigitMinute(t *testing.T) { func TestMatchDividerSharesCenteredMinuteColumn(t *testing.T) { row := renderMatchDetailRow("Wdowiak G", "39'", "S Pllana (4)", 76) - divider := renderMatchDividerRow("HT 1-0", 76) - rowMid := strings.Index(row, "39'") + (len("39'") / 2) - dividerMid := strings.Index(divider, "HT 1-0") + (len("HT 1-0") / 2) - if rowMid != dividerMid { - t.Fatalf("expected divider label to align with minute column\nrow: %q\ndiv: %q", row, divider) + divider := renderMatchDividerRow("HT", "1 - 0", 76) + // The score "1 - 0" in the divider and the minute "39'" in event rows both + // go through padCenter in the same 9-char centre column, so their midpoints align. + rowMid := strings.Index(row, "39'") + 1 // middle char of "39'" + score := "1 - 0" + dividerMid := strings.Index(divider, score) + (len(score) / 2) // middle '-' + if diff := rowMid - dividerMid; diff < -1 || diff > 1 { + t.Fatalf("expected divider score to align with minute column\nrow: %q\ndiv: %q", row, divider) } } @@ -352,11 +355,12 @@ func TestHeaderEventRowsIncludesRedCardsAndHTDivider(t *testing.T) { if len(rows) != 4 { t.Fatalf("expected 4 rows (goal, HT, goal, RC), got %d: %#v", len(rows), rows) } - if rows[1].isDivider { - // ok - } else { + if !rows[1].isDivider { t.Fatalf("expected row 1 to be HT divider, got %#v", rows[1]) } + if rows[1].label != "HT" || rows[1].minute != "1 - 0" { + t.Fatalf("expected HT divider tag=%q score=%q, got label=%q minute=%q", "HT", "1 - 0", rows[1].label, rows[1].minute) + } if ansi.Strip(rows[0].label) != "Wdowiak ⚽" { t.Fatalf("unexpected first goal label: %q", ansi.Strip(rows[0].label)) } @@ -380,9 +384,10 @@ func TestRenderPlayerLineAbbreviatesNameAndDropsEvents(t *testing.T) { func TestRenderLineupRowUsesCenteredSeparatorColumn(t *testing.T) { row := renderLineupRow("K. Kubica", "B. Mrozek", 76) - divider := renderMatchDividerRow("HT 1-0", 76) + divider := renderMatchDividerRow("HT", "1 - 0", 76) rowMid := strings.Index(row, "|") - dividerMid := strings.Index(divider, "HT 1-0") + (len("HT 1-0") / 2) + score := "1 - 0" + dividerMid := strings.Index(divider, score) + (len(score) / 2) if rowMid != dividerMid { t.Fatalf("expected lineup separator to share center axis\nrow: %q\ndiv: %q", row, divider) } @@ -396,7 +401,7 @@ func TestRenderLineupRowUsesCenteredSeparatorColumn(t *testing.T) { func TestRenderLineupHeaderRowUsesBlankCenteredGap(t *testing.T) { row := renderLineupRowWithMarker("Piast Gliwice", "Radomiak Radom", " ", 76) - divider := renderMatchDividerRow("HT 1-0", 76) + divider := renderMatchDividerRow("HT", "1 - 0", 76) if !strings.Contains(row, "Piast Gliwice") || !strings.Contains(row, "Radomiak Radom") { t.Fatalf("expected lineup header row to contain both team names, got %q", row) @@ -408,7 +413,8 @@ func TestRenderLineupHeaderRowUsesBlankCenteredGap(t *testing.T) { leftEnd := strings.Index(row, "Piast Gliwice") + len("Piast Gliwice") rightStart := strings.Index(row, "Radomiak Radom") gapMid := leftEnd + ((rightStart - leftEnd) / 2) - dividerMid := strings.Index(divider, "HT 1-0") + (len("HT 1-0") / 2) + score := "1 - 0" + dividerMid := strings.Index(divider, score) + (len(score) / 2) if diff := gapMid - dividerMid; diff < -1 || diff > 1 { t.Fatalf("expected lineup header gap to stay centered\nrow: %q\ndiv: %q", row, divider) } @@ -465,7 +471,7 @@ func TestRenderCenteredTextCentersSectionLabels(t *testing.T) { func TestFinalScoreLineUsesMatchScore(t *testing.T) { got := finalScoreLine(&site.MatchPage{Score: "2-0"}) - if got != "FT 2-0" { + if got != "2-0" { t.Fatalf("unexpected final score line: %q", got) } } From 0cccb03c14e0210e58f6f9ff5ad9c1022a16bb61 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:41:52 +0100 Subject: [PATCH 6/9] Align event minutes with HT/FT divider score dash Widens the shared centre column from 9 to 11 chars and switches renderMatchDividerRow to padRight so "HT 1 - 0" keeps its natural left-to-right layout. padCenter'd minutes in the same 11-char column now land their midpoint at the same position as the dash in "HT X - Y". Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/render_helpers.go | 42 +++++++++------------------- internal/ui/view.go | 6 ++-- internal/ui/view_test.go | 52 +++++++++++++++++------------------ 3 files changed, 42 insertions(+), 58 deletions(-) diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 460441a..4f84d88 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -449,37 +449,21 @@ func renderDividerLabel(label string, width int) string { return strings.Repeat("-", left) + " " + cleaned + " " + strings.Repeat("-", right) } -// renderMatchDividerRow renders a dash-line divider with the score in the -// shared centre column (aligning it with minutes on event rows) and the -// period tag (e.g. "HT", "FT") embedded in the left dash run. -func renderMatchDividerRow(tag, score string, width int) string { +// renderMatchDividerRow renders a dash-line divider with label (e.g. "HT 1 - 0") +// in the shared centre column, left-aligned within that column so the dash in +// "X - Y" sits at the same position as the centre of a padCenter'd minute string. +func renderMatchDividerRow(label string, width int) string { if width < 30 { - label := score - if tag != "" { - label = tag + " " + score - } return renderDividerLabel(label, width) } - midWidth := 9 + midWidth := 11 gap := 1 sideWidth := max(8, (width-midWidth-(gap*2))/2) - - // Embed tag centred within the left dash run so the score occupies the - // shared centre column and aligns with event-row minutes. - var left string - if tag != "" { - tagDisplay := " " + tag + " " - dashCount := max(0, sideWidth-len(tagDisplay)) - leading := dashCount / 2 - trailing := dashCount - leading - left = strings.Repeat("-", leading) + tagDisplay + strings.Repeat("-", trailing) - } else { - left = strings.Repeat("-", sideWidth) - } + left := strings.Repeat("-", sideWidth) right := strings.Repeat("-", sideWidth) - return left + strings.Repeat(" ", gap) + padCenter(truncate(score, midWidth), midWidth) + strings.Repeat(" ", gap) + right + return left + strings.Repeat(" ", gap) + padRight(truncate(label, midWidth), midWidth) + strings.Repeat(" ", gap) + right } func matchStatus(page *site.MatchPage) string { @@ -513,7 +497,7 @@ type scorerLine struct { func headerEventRows(events []site.MatchEvent) []scorerLine { ordered := sortedEvents(events) home, away := 0, 0 - htScore := halftimeScore(events) // "X - Y" — goes in centre column + htLabel := halftimeScore(events) insertedHT := false lines := make([]scorerLine, 0, 8) @@ -527,9 +511,9 @@ func headerEventRows(events []site.MatchEvent) []scorerLine { continue } - if !insertedHT && htScore != "" { + if !insertedHT && htLabel != "" { if key, ok := minuteSortKey(event.MinuteText); ok && key > 4599 { - lines = append(lines, scorerLine{label: "HT", minute: htScore, isDivider: true}) + lines = append(lines, scorerLine{label: htLabel, isDivider: true}) insertedHT = true } } @@ -791,7 +775,7 @@ func halftimeScore(events []site.MatchEvent) string { return "" } - return fmt.Sprintf("%d - %d", homeGoals, awayGoals) + return fmt.Sprintf("HT %d - %d", homeGoals, awayGoals) } func finalScoreLine(page *site.MatchPage) string { @@ -804,7 +788,7 @@ func finalScoreLine(page *site.MatchPage) string { return "" } - return normalizeScore(score) + return "FT " + normalizeScore(score) } func matchMetaParts(meta, weather string) []string { @@ -860,7 +844,7 @@ func renderMatchDetailRow(left, middle, right string, width int) string { return renderSideBySide(left, middle, right, width) } - midWidth := 9 + midWidth := 11 gap := 1 sideWidth := max(8, (width-midWidth-(gap*2))/2) diff --git a/internal/ui/view.go b/internal/ui/view.go index 0c47556..3d1896e 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -463,7 +463,7 @@ func (m Model) matchDetailContent(width int) string { } for _, row := range headerEvents { if row.isDivider { - b.WriteString(renderMatchDividerRow(row.label, row.minute, width-4)) + b.WriteString(renderMatchDividerRow(row.label, width-4)) b.WriteString("\n") continue } @@ -477,8 +477,8 @@ func (m Model) matchDetailContent(width int) string { b.WriteString("\n") } if len(headerEvents) > 0 { - if ftScore := finalScoreLine(m.match); ftScore != "" { - b.WriteString(renderMatchDividerRow("FT", ftScore, width-4)) + if ftDivider := finalScoreLine(m.match); ftDivider != "" { + b.WriteString(renderMatchDividerRow(ftDivider, width-4)) b.WriteString("\n") } } diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index b6f8a3e..2d3582e 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -135,7 +135,7 @@ func TestMatchDetailRemovesRedundantMetadata(t *testing.T) { "S. Mraz", "17'", "62'", - "FT", "1-2", + "FT 1-2", "Details", "13 March 2026, 18:00 | Attendance 3542 | Ref. Damian Kos | Weather 15 C", } { @@ -194,12 +194,12 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { // Score header: goals with minute-in-center, HT/FT dividers for _, want := range []string{ "Wdowiak", "39'", "⚽", // home goal row (minute in center, icon adjacent) - "HT", "1 - 0", // HT tag in dashes, score in centre column + "HT 1 - 0", // HT divider with score "❌", "52'", // away missed penalty row - "Szkurin", "60'", // second home goal row - "K. Czubak", "70'", // away goal row - "🟥", "85'", // away red card row - "FT", "2-1", // FT tag in dashes, score in centre column + "Szkurin", "60'", // second home goal row + "K. Czubak", "70'", // away goal row + "🟥", "85'", // away red card row + "FT 2-1", // FT divider with score } { if !strings.Contains(plainView, want) { t.Fatalf("expected match view to contain %q\n%s", want, view) @@ -219,12 +219,12 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { // Score header ordering: 39' before HT before 52' before 60' before 70' before 85' headerIndexes := []int{ strings.Index(plainView, "39'"), - strings.Index(plainView, "HT"), + strings.Index(plainView, "HT 1 - 0"), strings.Index(plainView, "52'"), strings.Index(plainView, "60'"), strings.Index(plainView, "70'"), strings.Index(plainView, "85'"), - strings.Index(plainView, "FT"), + strings.Index(plainView, "FT 2-1"), } for _, idx := range headerIndexes { if idx < 0 { @@ -297,14 +297,14 @@ func TestFormatMatchMinuteLeftPadsSingleDigitMinute(t *testing.T) { func TestMatchDividerSharesCenteredMinuteColumn(t *testing.T) { row := renderMatchDetailRow("Wdowiak G", "39'", "S Pllana (4)", 76) - divider := renderMatchDividerRow("HT", "1 - 0", 76) - // The score "1 - 0" in the divider and the minute "39'" in event rows both - // go through padCenter in the same 9-char centre column, so their midpoints align. - rowMid := strings.Index(row, "39'") + 1 // middle char of "39'" - score := "1 - 0" - dividerMid := strings.Index(divider, score) + (len(score) / 2) // middle '-' - if diff := rowMid - dividerMid; diff < -1 || diff > 1 { - t.Fatalf("expected divider score to align with minute column\nrow: %q\ndiv: %q", row, divider) + divider := renderMatchDividerRow("HT 1 - 0", 76) + // "HT 1 - 0" is padRight'd in the 11-char centre column; the '-' lands at col 5 + // within that column. "39'" is padCenter'd in the same 11-char column; its + // centre '9' also lands at col 5. The two should align. + rowMid := strings.Index(row, "39'") + 1 // '9' = middle char of "39'" + dividerMid := strings.Index(divider, "1 - 0") + 2 // '-' at offset 2 within "1 - 0" + if diff := rowMid - dividerMid; diff < -2 || diff > 2 { + t.Fatalf("expected divider score dash to align with minute centre\nrow: %q\ndiv: %q", row, divider) } } @@ -358,8 +358,8 @@ func TestHeaderEventRowsIncludesRedCardsAndHTDivider(t *testing.T) { if !rows[1].isDivider { t.Fatalf("expected row 1 to be HT divider, got %#v", rows[1]) } - if rows[1].label != "HT" || rows[1].minute != "1 - 0" { - t.Fatalf("expected HT divider tag=%q score=%q, got label=%q minute=%q", "HT", "1 - 0", rows[1].label, rows[1].minute) + if rows[1].label != "HT 1 - 0" { + t.Fatalf("expected HT divider label %q, got %q", "HT 1 - 0", rows[1].label) } if ansi.Strip(rows[0].label) != "Wdowiak ⚽" { t.Fatalf("unexpected first goal label: %q", ansi.Strip(rows[0].label)) @@ -384,11 +384,12 @@ func TestRenderPlayerLineAbbreviatesNameAndDropsEvents(t *testing.T) { func TestRenderLineupRowUsesCenteredSeparatorColumn(t *testing.T) { row := renderLineupRow("K. Kubica", "B. Mrozek", 76) - divider := renderMatchDividerRow("HT", "1 - 0", 76) + divider := renderMatchDividerRow("HT 1 - 0", 76) rowMid := strings.Index(row, "|") - score := "1 - 0" - dividerMid := strings.Index(divider, score) + (len(score) / 2) - if rowMid != dividerMid { + // The '-' in "HT 1 - 0" (padRight'd in 11-char centre column) and the "|" + // in lineup rows (padCenter'd in a 3-char centre column) share the same axis. + dividerMid := strings.Index(divider, "1 - 0") + 2 // '-' is at offset 2 in "1 - 0" + if diff := rowMid - dividerMid; diff < -1 || diff > 1 { t.Fatalf("expected lineup separator to share center axis\nrow: %q\ndiv: %q", row, divider) } if !strings.Contains(row, "K. Kubica") || !strings.Contains(row, "B. Mrozek") { @@ -401,7 +402,7 @@ func TestRenderLineupRowUsesCenteredSeparatorColumn(t *testing.T) { func TestRenderLineupHeaderRowUsesBlankCenteredGap(t *testing.T) { row := renderLineupRowWithMarker("Piast Gliwice", "Radomiak Radom", " ", 76) - divider := renderMatchDividerRow("HT", "1 - 0", 76) + divider := renderMatchDividerRow("HT 1 - 0", 76) if !strings.Contains(row, "Piast Gliwice") || !strings.Contains(row, "Radomiak Radom") { t.Fatalf("expected lineup header row to contain both team names, got %q", row) @@ -413,8 +414,7 @@ func TestRenderLineupHeaderRowUsesBlankCenteredGap(t *testing.T) { leftEnd := strings.Index(row, "Piast Gliwice") + len("Piast Gliwice") rightStart := strings.Index(row, "Radomiak Radom") gapMid := leftEnd + ((rightStart - leftEnd) / 2) - score := "1 - 0" - dividerMid := strings.Index(divider, score) + (len(score) / 2) + dividerMid := strings.Index(divider, "1 - 0") + 2 // '-' at offset 2 in "1 - 0" if diff := gapMid - dividerMid; diff < -1 || diff > 1 { t.Fatalf("expected lineup header gap to stay centered\nrow: %q\ndiv: %q", row, divider) } @@ -471,7 +471,7 @@ func TestRenderCenteredTextCentersSectionLabels(t *testing.T) { func TestFinalScoreLineUsesMatchScore(t *testing.T) { got := finalScoreLine(&site.MatchPage{Score: "2-0"}) - if got != "2-0" { + if got != "FT 2-0" { t.Fatalf("unexpected final score line: %q", got) } } From b19ab1a7aa8479dfed2045d4576fc7a46e40da81 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:59:01 +0100 Subject: [PATCH 7/9] Tighten score section gap and lineup name spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderMatchDetailRow: midWidth 11→7, gap 1→0. padCenter of a 3-char minute now leaves 2 leading spaces between the icon and the first digit; the minute centre column still aligns with the dash in the HT/FT divider (both land at the same terminal column). renderAnnotatedLineupRow: gap 1→0. Player names sit directly against the card event column, removing the extra space between each name and the separator. Co-Authored-By: Claude Sonnet 4.6 --- internal/ui/render_helpers.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 4f84d88..bce047b 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -734,7 +734,7 @@ func renderAnnotatedLineupRow(homePlayer, homeEvents, awayPlayer, awayEvents str } const eventWidth = 2 // one emoji wide (YC/RC or empty) - const gap = 1 + const gap = 0 // names sit directly against the event column playerWidth := max(8, (width-1-2*eventWidth-2*gap)/2) leftPlayer := padLeft(truncate(homePlayer, playerWidth), playerWidth) @@ -844,8 +844,11 @@ func renderMatchDetailRow(left, middle, right string, width int) string { return renderSideBySide(left, middle, right, width) } - midWidth := 11 - gap := 1 + // midWidth=7 with gap=0: padCenter of a 3-char minute leaves exactly 2 leading + // spaces between the icon and the first digit, while keeping the minute centre + // aligned with the dash in the HT/FT divider (which uses midWidth=11, gap=1). + midWidth := 7 + gap := 0 sideWidth := max(8, (width-midWidth-(gap*2))/2) leftText := padLeft(truncate(left, sideWidth), sideWidth) From c4a6ea1f0eebc35289252a705df22f700f1e0503 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Wed, 25 Mar 2026 05:40:08 +0100 Subject: [PATCH 8/9] Polish match detail timeline spacing and dividers --- internal/ui/render_helpers.go | 26 ++++++------- internal/ui/view.go | 2 + internal/ui/view_test.go | 70 +++++++++++++++++++++++++---------- 3 files changed, 65 insertions(+), 33 deletions(-) diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index bce047b..31089dd 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -296,7 +296,6 @@ func atoiOrNeg(s string) int { return value } - func substitutionPlayers(text string) (string, string) { parts := strings.SplitN(normalizeDisplayText(text), "->", 2) if len(parts) != 2 { @@ -449,21 +448,24 @@ func renderDividerLabel(label string, width int) string { return strings.Repeat("-", left) + " " + cleaned + " " + strings.Repeat("-", right) } -// renderMatchDividerRow renders a dash-line divider with label (e.g. "HT 1 - 0") -// in the shared centre column, left-aligned within that column so the dash in -// "X - Y" sits at the same position as the centre of a padCenter'd minute string. +// renderMatchDividerRow renders a full-width dash-line divider with label +// (e.g. "HT 1 - 0") positioned so the score dash shares the event-minute axis. func renderMatchDividerRow(label string, width int) string { if width < 30 { return renderDividerLabel(label, width) } - midWidth := 11 - gap := 1 - sideWidth := max(8, (width-midWidth-(gap*2))/2) - left := strings.Repeat("-", sideWidth) - right := strings.Repeat("-", sideWidth) + label = truncate(label, max(1, width-2)) + dashOffset := strings.Index(label, " - ") + 1 + if dashOffset < 1 { + return renderDividerLabel(label, width) + } + + minuteAxis := max(0, (width-7)/2+3) + leftWidth := max(0, minuteAxis-1-dashOffset) + rightWidth := max(0, width-leftWidth-ansi.StringWidth(label)-2) - return left + strings.Repeat(" ", gap) + padRight(truncate(label, midWidth), midWidth) + strings.Repeat(" ", gap) + right + return strings.Repeat("-", leftWidth) + " " + label + " " + strings.Repeat("-", rightWidth) } func matchStatus(page *site.MatchPage) string { @@ -749,7 +751,6 @@ func halftimeScore(events []site.MatchEvent) string { homeGoals := 0 awayGoals := 0 hasSecondHalf := false - hasFirstHalf := false for _, event := range sortedEvents(events) { minute, ok := minuteSortKey(event.MinuteText) @@ -758,7 +759,6 @@ func halftimeScore(events []site.MatchEvent) string { } // minuteSortKey encodes stoppage as MM*100+extra, so 45:59 is the first-half ceiling. if minute <= 4599 { - hasFirstHalf = true if event.Kind == "GOAL" { if event.TeamSide == "home" { homeGoals++ @@ -771,7 +771,7 @@ func halftimeScore(events []site.MatchEvent) string { hasSecondHalf = true } - if !hasFirstHalf || !hasSecondHalf { + if !hasSecondHalf { return "" } diff --git a/internal/ui/view.go b/internal/ui/view.go index 3d1896e..604f5d8 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -457,6 +457,8 @@ func (m Model) matchDetailContent(width int) string { status := matchStatus(m.match) headerEvents := headerEventRows(m.match.Events) if len(headerEvents) > 0 || status != "" { + b.WriteString(renderMatchDetailRow("", "", "", width-4)) + b.WriteString("\n") if status != "" { b.WriteString(renderMatchDetailRow("", status, "", width-4)) b.WriteString("\n") diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index 2d3582e..7feabc9 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -190,16 +190,23 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { view := m.View() plainView := ansi.Strip(view) + _, centerWidth, _ := matchLayoutWidths(m.width) + content := ansi.Strip(m.matchDetailContent(centerWidth)) + scoreHeader := renderMatchDetailRow("GKS Katowice", "2-1", "Lechia Gdansk", centerWidth-4) + spacerRow := renderMatchDetailRow("", "", "", centerWidth-4) + if !strings.Contains(content, scoreHeader+"\n"+spacerRow+"\n") { + t.Fatalf("expected spacer row between score header and event log\n%s", content) + } // Score header: goals with minute-in-center, HT/FT dividers for _, want := range []string{ "Wdowiak", "39'", "⚽", // home goal row (minute in center, icon adjacent) - "HT 1 - 0", // HT divider with score - "❌", "52'", // away missed penalty row - "Szkurin", "60'", // second home goal row - "K. Czubak", "70'", // away goal row - "🟥", "85'", // away red card row - "FT 2-1", // FT divider with score + "HT 1 - 0", // HT divider with score + "❌", "52'", // away missed penalty row + "Szkurin", "60'", // second home goal row + "K. Czubak", "70'", // away goal row + "🟥", "85'", // away red card row + "FT 2-1", // FT divider with score } { if !strings.Contains(plainView, want) { t.Fatalf("expected match view to contain %q\n%s", want, view) @@ -241,7 +248,7 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { for _, want := range []string{ "46'", // sub minute visible at outer edge of player name "D. Nowak", // sub-on player visible immediately after sub-off - "🟥", // red card badge in event column + "🟥", // red card badge in event column } { if !strings.Contains(plainView, want) { t.Fatalf("expected lineup section to contain %q\n%s", want, view) @@ -261,7 +268,6 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { } } - func TestMatchDetailRowsAnchorTowardCenteredMinuteColumn(t *testing.T) { line := renderMatchDetailRow("Wdowiak ⚽", "39'", "↕ Pllana (4)", 76) minuteIdx := strings.Index(line, "39'") @@ -298,16 +304,25 @@ func TestFormatMatchMinuteLeftPadsSingleDigitMinute(t *testing.T) { func TestMatchDividerSharesCenteredMinuteColumn(t *testing.T) { row := renderMatchDetailRow("Wdowiak G", "39'", "S Pllana (4)", 76) divider := renderMatchDividerRow("HT 1 - 0", 76) - // "HT 1 - 0" is padRight'd in the 11-char centre column; the '-' lands at col 5 - // within that column. "39'" is padCenter'd in the same 11-char column; its - // centre '9' also lands at col 5. The two should align. - rowMid := strings.Index(row, "39'") + 1 // '9' = middle char of "39'" + // The divider label stays visually centered, and its score dash should remain close + // to the minute center used by event rows. + rowMid := strings.Index(row, "39'") + 1 // '9' = middle char of "39'" dividerMid := strings.Index(divider, "1 - 0") + 2 // '-' at offset 2 within "1 - 0" if diff := rowMid - dividerMid; diff < -2 || diff > 2 { t.Fatalf("expected divider score dash to align with minute centre\nrow: %q\ndiv: %q", row, divider) } } +func TestMatchDividerFillsCenterPaddingWithDashes(t *testing.T) { + divider := renderMatchDividerRow("HT 0 - 0", 76) + if strings.Contains(divider, "HT 0 - 0 ") { + t.Fatalf("expected divider to avoid wide trailing spaces after label, got %q", divider) + } + if !strings.Contains(divider, " HT 0 - 0 ") { + t.Fatalf("expected divider to keep single spaces around label, got %q", divider) + } +} + func TestHeaderEventRowsMinuteInCenterColumn(t *testing.T) { rows := headerEventRows([]site.MatchEvent{ {MinuteText: "17", Kind: "GOAL", TeamSide: "home", Text: "Krzysztof Kubica 17"}, @@ -375,6 +390,25 @@ func TestHeaderEventRowsIncludesRedCardsAndHTDivider(t *testing.T) { } } +func TestHeaderEventRowsIncludesGoallessHTDividerBeforeSecondHalfEvents(t *testing.T) { + rows := headerEventRows([]site.MatchEvent{ + {MinuteText: "60", Kind: "RC", TeamSide: "away", Text: "Pllana 60"}, + }) + + if len(rows) != 2 { + t.Fatalf("expected 2 rows (HT, RC), got %d: %#v", len(rows), rows) + } + if !rows[0].isDivider { + t.Fatalf("expected row 0 to be HT divider, got %#v", rows[0]) + } + if rows[0].label != "HT 0 - 0" { + t.Fatalf("expected HT divider label %q, got %q", "HT 0 - 0", rows[0].label) + } + if rows[1].minute != "60'" { + t.Fatalf("expected second-half event minute 60', got %q", rows[1].minute) + } +} + func TestRenderPlayerLineAbbreviatesNameAndDropsEvents(t *testing.T) { got := renderPlayerLine(site.PlayerLine{Name: "(86) Igor Strzalek", Events: []string{"YC", "RC"}}) if got != "I. Strzalek" { @@ -384,13 +418,10 @@ func TestRenderPlayerLineAbbreviatesNameAndDropsEvents(t *testing.T) { func TestRenderLineupRowUsesCenteredSeparatorColumn(t *testing.T) { row := renderLineupRow("K. Kubica", "B. Mrozek", 76) - divider := renderMatchDividerRow("HT 1 - 0", 76) rowMid := strings.Index(row, "|") - // The '-' in "HT 1 - 0" (padRight'd in 11-char centre column) and the "|" - // in lineup rows (padCenter'd in a 3-char centre column) share the same axis. - dividerMid := strings.Index(divider, "1 - 0") + 2 // '-' is at offset 2 in "1 - 0" + dividerMid := 76 / 2 if diff := rowMid - dividerMid; diff < -1 || diff > 1 { - t.Fatalf("expected lineup separator to share center axis\nrow: %q\ndiv: %q", row, divider) + t.Fatalf("expected lineup separator to share center axis\nrow: %q", row) } if !strings.Contains(row, "K. Kubica") || !strings.Contains(row, "B. Mrozek") { t.Fatalf("expected lineup row to contain both players, got %q", row) @@ -402,7 +433,6 @@ func TestRenderLineupRowUsesCenteredSeparatorColumn(t *testing.T) { func TestRenderLineupHeaderRowUsesBlankCenteredGap(t *testing.T) { row := renderLineupRowWithMarker("Piast Gliwice", "Radomiak Radom", " ", 76) - divider := renderMatchDividerRow("HT 1 - 0", 76) if !strings.Contains(row, "Piast Gliwice") || !strings.Contains(row, "Radomiak Radom") { t.Fatalf("expected lineup header row to contain both team names, got %q", row) @@ -414,9 +444,9 @@ func TestRenderLineupHeaderRowUsesBlankCenteredGap(t *testing.T) { leftEnd := strings.Index(row, "Piast Gliwice") + len("Piast Gliwice") rightStart := strings.Index(row, "Radomiak Radom") gapMid := leftEnd + ((rightStart - leftEnd) / 2) - dividerMid := strings.Index(divider, "1 - 0") + 2 // '-' at offset 2 in "1 - 0" + dividerMid := 76 / 2 if diff := gapMid - dividerMid; diff < -1 || diff > 1 { - t.Fatalf("expected lineup header gap to stay centered\nrow: %q\ndiv: %q", row, divider) + t.Fatalf("expected lineup header gap to stay centered\nrow: %q", row) } } From c354422d65a062598d993d23971c54ec790e4622 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Wed, 25 Mar 2026 05:58:51 +0100 Subject: [PATCH 9/9] Fix match detail event association edge cases --- internal/ui/render_helpers.go | 164 ++++++++++++++++++++++------------ internal/ui/view.go | 11 ++- internal/ui/view_test.go | 107 +++++++++++++++++++++- 3 files changed, 216 insertions(+), 66 deletions(-) diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 31089dd..229bf63 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -308,7 +308,7 @@ func substitutionPlayers(text string) (string, string) { outgoing = "" } - return formatPlayerLabel(outgoing), formatPlayerLabel(incoming) + return canonicalPlayerName(outgoing), canonicalPlayerName(incoming) } func faintText(text string) string { @@ -359,27 +359,19 @@ func eventPrefix(kind string) string { } func formatPlayerLabel(value string) string { - cleaned := normalizeDisplayText(value) + cleaned := canonicalPlayerName(value) if cleaned == "" { return "" } - // Compact match rows drop shirt numbers but keep semantic suffixes like (k). - cleaned = playerNumberPrefixRe.ReplaceAllString(cleaned, "") - cleaned = playerNumberSuffixRe.ReplaceAllString(cleaned, "") - suffixes := make([]string, 0, 2) for { matches := trailingParenRe.FindStringSubmatch(cleaned) if len(matches) != 3 { break } - inner := strings.TrimSpace(strings.Trim(matches[2], " ()")) - cleaned = normalizeDisplayText(matches[1]) - if digitsOnly(inner) { - continue - } suffixes = append([]string{strings.TrimSpace(matches[2])}, suffixes...) + cleaned = normalizeDisplayText(matches[1]) } words := strings.Fields(cleaned) @@ -403,6 +395,36 @@ func formatPlayerLabel(value string) string { return faintPenaltySuffix(cleaned) } +func canonicalPlayerName(value string) string { + cleaned := normalizeDisplayText(value) + if cleaned == "" { + return "" + } + + cleaned = playerNumberPrefixRe.ReplaceAllString(cleaned, "") + cleaned = playerNumberSuffixRe.ReplaceAllString(cleaned, "") + + suffixes := make([]string, 0, 2) + for { + matches := trailingParenRe.FindStringSubmatch(cleaned) + if len(matches) != 3 { + break + } + inner := strings.TrimSpace(strings.Trim(matches[2], " ()")) + cleaned = normalizeDisplayText(matches[1]) + if digitsOnly(inner) { + continue + } + suffixes = append([]string{strings.TrimSpace(matches[2])}, suffixes...) + } + + if len(suffixes) > 0 { + cleaned += " " + strings.Join(suffixes, " ") + } + + return cleaned +} + func digitsOnly(value string) bool { if value == "" { return false @@ -498,12 +520,32 @@ type scorerLine struct { // All events carry the minute in the center column; the side label holds name + icon. func headerEventRows(events []site.MatchEvent) []scorerLine { ordered := sortedEvents(events) - home, away := 0, 0 htLabel := halftimeScore(events) + firstSecondHalfKey := 0 + hasSecondHalfEvent := false + for _, event := range ordered { + key, ok := minuteSortKey(event.MinuteText) + if !ok || key <= 4599 { + continue + } + firstSecondHalfKey = key + hasSecondHalfEvent = true + break + } + insertedHT := false lines := make([]scorerLine, 0, 8) for _, event := range ordered { + key, ok := minuteSortKey(event.MinuteText) + if !ok { + continue + } + if !insertedHT && htLabel != "" && hasSecondHalfEvent && key >= firstSecondHalfKey { + lines = append(lines, scorerLine{label: htLabel, isDivider: true}) + insertedHT = true + } + switch event.Kind { case "GOAL", "MISS", "RC": default: @@ -513,21 +555,9 @@ func headerEventRows(events []site.MatchEvent) []scorerLine { continue } - if !insertedHT && htLabel != "" { - if key, ok := minuteSortKey(event.MinuteText); ok && key > 4599 { - lines = append(lines, scorerLine{label: htLabel, isDivider: true}) - insertedHT = true - } - } - name := trimEventMinute(event) switch event.Kind { case "GOAL": - if event.TeamSide == "home" { - home++ - } else if event.TeamSide == "away" { - away++ - } if name == "" { continue } @@ -563,6 +593,10 @@ func headerEventRows(events []site.MatchEvent) []scorerLine { } } + if !insertedHT && htLabel != "" && hasSecondHalfEvent { + lines = append(lines, scorerLine{label: htLabel, isDivider: true}) + } + return lines } @@ -576,26 +610,16 @@ func formatGoalLabel(name, side string) string { return glyph + " " + name } -// playerLastName returns a lowercase last-name key for fuzzy event-to-player matching. -// Strips trailing parentheticals, then takes the last whitespace-delimited word. -func playerLastName(label string) string { - s := strings.TrimSpace(label) - for { - m := trailingParenRe.FindStringSubmatch(s) - if len(m) != 3 { - break - } - s = strings.TrimSpace(m[1]) - } - fields := strings.Fields(s) - if len(fields) == 0 { +func playerMatchKey(label string) string { + formatted := normalizeDisplayText(canonicalPlayerName(label)) + if formatted == "" { return "" } - return strings.ToLower(fields[len(fields)-1]) + return strings.ToLower(formatted) } -// playerEventIndex maps lowercase last name → events for the given side. -// SUB events are indexed under both outgoing and incoming player last names. +// playerEventIndex maps normalized compact player labels to events for the given side. +// SUB events are indexed under both outgoing and incoming player names. func playerEventIndex(events []site.MatchEvent, side string) map[string][]site.MatchEvent { idx := make(map[string][]site.MatchEvent) for _, e := range events { @@ -604,16 +628,16 @@ func playerEventIndex(events []site.MatchEvent, side string) map[string][]site.M } if e.Kind == "SUB" { out, in := substitutionPlayers(e.Text) - if key := playerLastName(out); key != "" { + if key := playerMatchKey(out); key != "" { idx[key] = append(idx[key], e) } - if key := playerLastName(in); key != "" { + if key := playerMatchKey(in); key != "" { idx[key] = append(idx[key], e) } continue } - name := trimEventMinute(e) - if key := playerLastName(name); key != "" { + name := eventPlayerText(e) + if key := playerMatchKey(name); key != "" { idx[key] = append(idx[key], e) } } @@ -623,8 +647,7 @@ func playerEventIndex(events []site.MatchEvent, side string) map[string][]site.M // cardAnnotation returns the YC/RC badge string for a lineup player, intended // for the dedicated event column next to the centre separator. Empty when clean. func cardAnnotation(player site.PlayerLine, idx map[string][]site.MatchEvent) string { - base := formatPlayerLabel(player.Name) - key := playerLastName(base) + key := playerMatchKey(player.Name) if key == "" { return "" } @@ -634,14 +657,18 @@ func cardAnnotation(player site.PlayerLine, idx map[string][]site.MatchEvent) st return "" } + hasYellow := false for _, e := range matched { switch e.Kind { - case "YC": - return eventPrefix("YC") case "RC": return eventPrefix("RC") + case "YC": + hasYellow = true } } + if hasYellow { + return eventPrefix("YC") + } return "" } @@ -665,14 +692,14 @@ func reorderedLineup(players []site.PlayerLine, idx map[string][]site.MatchEvent subOnSet := make(map[string]bool, 4) for _, player := range players { - key := playerLastName(formatPlayerLabel(player.Name)) + key := playerMatchKey(player.Name) for _, e := range idx[key] { if e.Kind != "SUB" { continue } out, in := substitutionPlayers(e.Text) - outKey := playerLastName(out) - inKey := playerLastName(in) + outKey := playerMatchKey(out) + inKey := playerMatchKey(in) minute := strings.TrimSpace(formatMatchMinute(e.MinuteText)) if outKey == key && inKey != "" { subOffMap[key] = subInfo{onKey: inKey, minute: minute} @@ -684,14 +711,14 @@ func reorderedLineup(players []site.PlayerLine, idx map[string][]site.MatchEvent // Build key → PlayerLine lookup byKey := make(map[string]site.PlayerLine, len(players)) for _, p := range players { - byKey[playerLastName(formatPlayerLabel(p.Name))] = p + byKey[playerMatchKey(p.Name)] = p } result := make([]lineupEntry, 0, len(players)) insertedSubOns := make(map[string]bool, len(subOnSet)) for _, player := range players { - key := playerLastName(formatPlayerLabel(player.Name)) + key := playerMatchKey(player.Name) if subOnSet[key] { continue // will be placed after their sub-off player } @@ -706,7 +733,7 @@ func reorderedLineup(players []site.PlayerLine, idx map[string][]site.MatchEvent // Append unmatched sub-on players (name mismatch between event and lineup) for _, player := range players { - key := playerLastName(formatPlayerLabel(player.Name)) + key := playerMatchKey(player.Name) if subOnSet[key] && !insertedSubOns[key] { result = append(result, lineupEntry{player: player}) } @@ -788,7 +815,27 @@ func finalScoreLine(page *site.MatchPage) string { return "" } - return "FT " + normalizeScore(score) + return "FT " + dividerScore(score) +} + +func dividerScore(score string) string { + trimmed := strings.TrimSpace(score) + if trimmed == "" { + return "?-?" + } + + parts := strings.SplitN(trimmed, "-", 2) + if len(parts) != 2 { + return normalizeScore(trimmed) + } + + left := strings.TrimSpace(parts[0]) + right := strings.TrimSpace(parts[1]) + if left == "" || right == "" { + return normalizeScore(trimmed) + } + + return left + " - " + right } func matchMetaParts(meta, weather string) []string { @@ -1137,6 +1184,11 @@ func displayMatchMeta(meta, weather string) string { } func trimEventMinute(event site.MatchEvent) string { + text := eventPlayerText(event) + return formatPlayerLabel(text) +} + +func eventPlayerText(event site.MatchEvent) string { text := normalizeDisplayText(event.Text) if text == "" || event.MinuteText == "" { return text @@ -1159,7 +1211,7 @@ func trimEventMinute(event site.MatchEvent) string { text = strings.ReplaceAll(text, "(nk)", "(pen)") } - return formatPlayerLabel(text) + return canonicalPlayerName(text) } func normalizeSubstitutionText(text string) string { diff --git a/internal/ui/view.go b/internal/ui/view.go index 604f5d8..234bc24 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -456,7 +456,8 @@ func (m Model) matchDetailContent(width int) string { status := matchStatus(m.match) headerEvents := headerEventRows(m.match.Events) - if len(headerEvents) > 0 || status != "" { + ftDivider := finalScoreLine(m.match) + if len(headerEvents) > 0 || status != "" || ftDivider != "" { b.WriteString(renderMatchDetailRow("", "", "", width-4)) b.WriteString("\n") if status != "" { @@ -478,11 +479,9 @@ func (m Model) matchDetailContent(width int) string { b.WriteString(renderMatchDetailRow(homeText, row.minute, awayText, width-4)) b.WriteString("\n") } - if len(headerEvents) > 0 { - if ftDivider := finalScoreLine(m.match); ftDivider != "" { - b.WriteString(renderMatchDividerRow(ftDivider, width-4)) - b.WriteString("\n") - } + if ftDivider != "" { + b.WriteString(renderMatchDividerRow(ftDivider, width-4)) + b.WriteString("\n") } } diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index 7feabc9..bd8cf8e 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -135,7 +135,7 @@ func TestMatchDetailRemovesRedundantMetadata(t *testing.T) { "S. Mraz", "17'", "62'", - "FT 1-2", + "FT 1 - 2", "Details", "13 March 2026, 18:00 | Attendance 3542 | Ref. Damian Kos | Weather 15 C", } { @@ -206,7 +206,7 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { "Szkurin", "60'", // second home goal row "K. Czubak", "70'", // away goal row "🟥", "85'", // away red card row - "FT 2-1", // FT divider with score + "FT 2 - 1", // FT divider with score } { if !strings.Contains(plainView, want) { t.Fatalf("expected match view to contain %q\n%s", want, view) @@ -231,7 +231,7 @@ func TestMatchDetailShowsEventsInScoreHeaderAndLineups(t *testing.T) { strings.Index(plainView, "60'"), strings.Index(plainView, "70'"), strings.Index(plainView, "85'"), - strings.Index(plainView, "FT 2-1"), + strings.Index(plainView, "FT 2 - 1"), } for _, idx := range headerIndexes { if idx < 0 { @@ -409,6 +409,24 @@ func TestHeaderEventRowsIncludesGoallessHTDividerBeforeSecondHalfEvents(t *testi } } +func TestHeaderEventRowsKeepsHTDividerWhenSecondHalfHasOnlyHiddenEvents(t *testing.T) { + rows := headerEventRows([]site.MatchEvent{ + {MinuteText: "39", Kind: "GOAL", TeamSide: "home", Text: "Wdowiak 39"}, + {MinuteText: "60", Kind: "SUB", TeamSide: "home", Text: "Igor Strzalek -> Damian Nowak"}, + {MinuteText: "72", Kind: "YC", TeamSide: "away", Text: "Pllana 72"}, + }) + + if len(rows) != 2 { + t.Fatalf("expected 2 rows (goal, HT), got %d: %#v", len(rows), rows) + } + if rows[0].isDivider { + t.Fatalf("expected first row to be visible event, got %#v", rows[0]) + } + if !rows[1].isDivider || rows[1].label != "HT 1 - 0" { + t.Fatalf("expected final row to be HT divider, got %#v", rows[1]) + } +} + func TestRenderPlayerLineAbbreviatesNameAndDropsEvents(t *testing.T) { got := renderPlayerLine(site.PlayerLine{Name: "(86) Igor Strzalek", Events: []string{"YC", "RC"}}) if got != "I. Strzalek" { @@ -416,6 +434,69 @@ func TestRenderPlayerLineAbbreviatesNameAndDropsEvents(t *testing.T) { } } +func TestCardAnnotationPrefersRedCardOverEarlierYellow(t *testing.T) { + idx := playerEventIndex([]site.MatchEvent{ + {MinuteText: "20", Kind: "YC", TeamSide: "home", Text: "Pllana 20"}, + {MinuteText: "85", Kind: "RC", TeamSide: "home", Text: "Pllana 85"}, + }, "home") + + if got := cardAnnotation(site.PlayerLine{Name: "Pllana"}, idx); got != eventPrefix("RC") { + t.Fatalf("expected red-card badge, got %q", got) + } +} + +func TestReorderedLineupMatchesSubstitutionByCompactNameNotSurnameOnly(t *testing.T) { + idx := playerEventIndex([]site.MatchEvent{{ + MinuteText: "60", + Kind: "SUB", + TeamSide: "home", + Text: "Jan Kowalski -> Piotr Kowalski", + }}, "home") + + players := []site.PlayerLine{ + {Name: "Adam Kowalski"}, + {Name: "Jan Kowalski"}, + {Name: "Piotr Kowalski"}, + } + + got := reorderedLineup(players, idx) + if len(got) != 3 { + t.Fatalf("expected 3 lineup entries, got %d", len(got)) + } + if got[0].player.Name != "Adam Kowalski" { + t.Fatalf("expected unrelated Kowalski to stay first, got %#v", got) + } + if got[1].player.Name != "Jan Kowalski" || got[2].player.Name != "Piotr Kowalski" { + t.Fatalf("expected substitute to follow substituted player, got %#v", got) + } +} + +func TestReorderedLineupDistinguishesSameInitialSameSurname(t *testing.T) { + idx := playerEventIndex([]site.MatchEvent{{ + MinuteText: "60", + Kind: "SUB", + TeamSide: "home", + Text: "Jan Kowalski -> Piotr Kowalski", + }}, "home") + + players := []site.PlayerLine{ + {Name: "Jerzy Kowalski"}, + {Name: "Jan Kowalski"}, + {Name: "Piotr Kowalski"}, + } + + got := reorderedLineup(players, idx) + if len(got) != 3 { + t.Fatalf("expected 3 lineup entries, got %d", len(got)) + } + if got[0].player.Name != "Jerzy Kowalski" { + t.Fatalf("expected same-initial teammate to stay in place, got %#v", got) + } + if got[1].player.Name != "Jan Kowalski" || got[2].player.Name != "Piotr Kowalski" { + t.Fatalf("expected substitution to match full name, got %#v", got) + } +} + func TestRenderLineupRowUsesCenteredSeparatorColumn(t *testing.T) { row := renderLineupRow("K. Kubica", "B. Mrozek", 76) rowMid := strings.Index(row, "|") @@ -487,6 +568,24 @@ func TestMatchDetailContentStylesLineupTeamsWithoutSeparator(t *testing.T) { t.Fatal("expected match detail content to include lineup team header row") } +func TestMatchDetailContentShowsFTDividerWithoutVisibleHeaderEvents(t *testing.T) { + m := sketchModel() + m.match = &site.MatchPage{ + HomeTeam: "Motor Lublin", + AwayTeam: "Zaglebie Lubin", + Score: "1-0", + Events: []site.MatchEvent{ + {MinuteText: "46", Kind: "SUB", TeamSide: "home", Text: "Jan Kowalski -> Piotr Kowalski"}, + {MinuteText: "72", Kind: "YC", TeamSide: "away", Text: "Nowak 72"}, + }, + } + + content := ansi.Strip(m.matchDetailContent(80)) + if !strings.Contains(content, "FT 1 - 0") { + t.Fatalf("expected FT divider even without visible header events\n%s", content) + } +} + func TestRenderCenteredTextCentersSectionLabels(t *testing.T) { centered := renderCenteredText("Timeline", 21) leftPad := strings.Index(centered, "Timeline") @@ -501,7 +600,7 @@ func TestRenderCenteredTextCentersSectionLabels(t *testing.T) { func TestFinalScoreLineUsesMatchScore(t *testing.T) { got := finalScoreLine(&site.MatchPage{Score: "2-0"}) - if got != "FT 2-0" { + if got != "FT 2 - 0" { t.Fatalf("unexpected final score line: %q", got) } }