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..229bf63 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -296,49 +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) if len(parts) != 2 { @@ -351,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 { @@ -402,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) @@ -446,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 @@ -491,18 +470,24 @@ func renderDividerLabel(label string, width int) string { return strings.Repeat("-", left) + " " + cleaned + " " + strings.Repeat("-", right) } +// 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 := 9 - 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) + } - return left + strings.Repeat(" ", gap) + padCenter(truncate(label, midWidth), midWidth) + strings.Repeat(" ", gap) + right + minuteAxis := max(0, (width-7)/2+3) + leftWidth := max(0, minuteAxis-1-dashOffset) + rightWidth := max(0, width-leftWidth-ansi.StringWidth(label)-2) + + return strings.Repeat("-", leftWidth) + " " + label + " " + strings.Repeat("-", rightWidth) } func matchStatus(page *site.MatchPage) string { @@ -524,65 +509,275 @@ 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, missed-penalty, and red-card events sorted by minute, +// with an HT divider injected between halves when both exist. +// All events carry the minute in the center column; the side label holds name + icon. +func headerEventRows(events []site.MatchEvent) []scorerLine { ordered := sortedEvents(events) - lines := make([]scorerLine, 0, 4) + htLabel := halftimeScore(events) + firstSecondHalfKey := 0 + hasSecondHalfEvent := false for _, event := range ordered { - if event.Kind != "GOAL" || event.TeamSide != side { + key, ok := minuteSortKey(event.MinuteText) + if !ok || key <= 4599 { continue } + firstSecondHalfKey = key + hasSecondHalfEvent = true + break + } - name := trimEventMinute(event) - if name == "" { + insertedHT := false + lines := make([]scorerLine, 0, 8) + + for _, event := range ordered { + key, ok := minuteSortKey(event.MinuteText) + if !ok { continue } - lines = append(lines, scorerLine{label: name, minute: formatMatchMinute(event.MinuteText), side: side}) + if !insertedHT && htLabel != "" && hasSecondHalfEvent && key >= firstSecondHalfKey { + lines = append(lines, scorerLine{label: htLabel, isDivider: true}) + insertedHT = true + } + + switch event.Kind { + case "GOAL", "MISS", "RC": + default: + continue + } + if strings.TrimSpace(event.MinuteText) == "" { + continue + } + + name := trimEventMinute(event) + switch event.Kind { + case "GOAL": + if name == "" { + continue + } + lines = append(lines, scorerLine{ + label: formatGoalLabel(name, event.TeamSide), + minute: formatMatchMinute(event.MinuteText), + 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: label, + minute: formatMatchMinute(event.MinuteText), + side: event.TeamSide, + }) + case "RC": + 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, + }) + } + } + + if !insertedHT && htLabel != "" && hasSecondHalfEvent { + lines = append(lines, scorerLine{label: htLabel, isDivider: true}) } return lines } -func scorerTimeline(events []site.MatchEvent) []scorerLine { - ordered := sortedEvents(events) - lines := make([]scorerLine, 0, 4) - for _, event := range ordered { - if event.Kind != "GOAL" { +// 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 + " " + glyph + } + return glyph + " " + name +} + +func playerMatchKey(label string) string { + formatted := normalizeDisplayText(canonicalPlayerName(label)) + if formatted == "" { + return "" + } + return strings.ToLower(formatted) +} + +// 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 { + if e.TeamSide != side { continue } - - name := trimEventMinute(event) - if name == "" { + if e.Kind == "SUB" { + out, in := substitutionPlayers(e.Text) + if key := playerMatchKey(out); key != "" { + idx[key] = append(idx[key], e) + } + if key := playerMatchKey(in); key != "" { + idx[key] = append(idx[key], e) + } continue } + name := eventPlayerText(e) + if key := playerMatchKey(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, - }) +// 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 { + key := playerMatchKey(player.Name) + if key == "" { + return "" } - return lines + matched, ok := idx[key] + if !ok { + return "" + } + + hasYellow := false + for _, e := range matched { + switch e.Kind { + case "RC": + return eventPrefix("RC") + case "YC": + hasYellow = true + } + } + if hasYellow { + return eventPrefix("YC") + } + return "" } -func formatScorerLabel(name, side string) string { - if side == "home" { - return name + " " + eventPrefix("GOAL") +// 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 } - return eventPrefix("GOAL") + " " + name + + type subInfo struct{ onKey, minute string } + subOffMap := make(map[string]subInfo, 4) + subOnSet := make(map[string]bool, 4) + + for _, player := range players { + key := playerMatchKey(player.Name) + for _, e := range idx[key] { + if e.Kind != "SUB" { + continue + } + out, in := substitutionPlayers(e.Text) + outKey := playerMatchKey(out) + inKey := playerMatchKey(in) + minute := strings.TrimSpace(formatMatchMinute(e.MinuteText)) + if outKey == key && inKey != "" { + subOffMap[key] = subInfo{onKey: inKey, minute: minute} + subOnSet[inKey] = true + } + } + } + + // Build key → PlayerLine lookup + byKey := make(map[string]site.PlayerLine, len(players)) + for _, p := range players { + byKey[playerMatchKey(p.Name)] = p + } + + result := make([]lineupEntry, 0, len(players)) + insertedSubOns := make(map[string]bool, len(subOnSet)) + + for _, player := range players { + key := playerMatchKey(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 := playerMatchKey(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 +// 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) + } + + const eventWidth = 2 // one emoji wide (YC/RC or empty) + 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) + 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 { homeGoals := 0 awayGoals := 0 hasSecondHalf := false - hasFirstHalf := false for _, event := range sortedEvents(events) { minute, ok := minuteSortKey(event.MinuteText) @@ -591,7 +786,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++ @@ -604,11 +798,11 @@ func halftimeScore(events []site.MatchEvent) string { hasSecondHalf = true } - if !hasFirstHalf || !hasSecondHalf { + if !hasSecondHalf { return "" } - return fmt.Sprintf("HT %d-%d", homeGoals, awayGoals) + return fmt.Sprintf("HT %d - %d", homeGoals, awayGoals) } func finalScoreLine(page *site.MatchPage) string { @@ -621,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 { @@ -677,8 +891,11 @@ func renderMatchDetailRow(left, middle, right string, width int) string { return renderSideBySide(left, middle, right, width) } - midWidth := 9 - 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) @@ -967,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 @@ -989,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 1f2fe0e..234bc24 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -455,54 +455,31 @@ 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) + ftDivider := finalScoreLine(m.match) + if len(headerEvents) > 0 || status != "" || ftDivider != "" { + b.WriteString(renderMatchDetailRow("", "", "", width-4)) + b.WriteString("\n") 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 != "" { + if ftDivider != "" { b.WriteString(renderMatchDividerRow(ftDivider, width-4)) b.WriteString("\n") } @@ -520,20 +497,36 @@ 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") + homeEntries := reorderedLineup(m.match.HomeLineup, homeIdx) + awayEntries := reorderedLineup(m.match.AwayLineup, awayIdx) + maxPlayers := max(len(homeEntries), len(awayEntries)) for i := 0; i < maxPlayers; i++ { - homeText, awayText := "", "" - if i < len(m.match.HomeLineup) { - homeText = renderPlayerLine(m.match.HomeLineup[i]) + var hEntry, aEntry lineupEntry + if i < len(homeEntries) { + hEntry = homeEntries[i] } - if i < len(m.match.AwayLineup) { - awayText = renderPlayerLine(m.match.AwayLineup[i]) + if i < len(awayEntries) { + aEntry = awayEntries[i] } - b.WriteString(renderLineupRow(homeText, awayText, 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 d6ac2d9..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", } { @@ -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,118 +167,104 @@ 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) + _, 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", - "Szkurin", - "Wdowiak ⚽", - "39'", - "HT 1-0", - "FT 2-1", - "Pllana ↕", - "I. Strzalek", - "D. Nowak", - "O. Lesniak", - "❌ Barkowskij (pen)", - "52'", - "Szkurin ⚽", - "60'", - "⚽ K. Czubak (pen)", - "70'", + "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 } { 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) + + // No Timeline section, no sub glyph in score header + 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 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'"), + 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 -> 52 -> 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) - } -} - -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) + // Lineup section: sub minutes at outer edge, sub-on player visible, cards in event column + 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 + } { + if !strings.Contains(plainView, want) { + t.Fatalf("expected lineup section to contain %q\n%s", want, view) + } } - if !strings.Contains(home, "\x1b[2mO. Lesniak") { - t.Fatalf("expected outgoing home player to be dimmed, got %q", home) + // 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) + } } - if !strings.Contains(away, "\x1b[2mO. Lesniak") { - t.Fatalf("expected outgoing away player to be dimmed, got %q", away) + // 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) } } @@ -317,31 +303,127 @@ 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 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 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 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"}, + }) + // 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) + // 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) != "⚽ 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 minute + if strings.TrimSpace(rows[0].minute) != "17'" { + t.Fatalf("expected home center to carry minute 17', got %q", rows[0].minute) + } + if strings.TrimSpace(rows[1].minute) != "30'" { + t.Fatalf("expected away center to carry minute 30', got %q", rows[1].minute) + } + // 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, "17'") + (len("17'") / 2) - awayMid := strings.Index(away, "30'") + (len("30'") / 2) + homeMid := strings.Index(home, "17'") + awayMid := strings.Index(away, "30'") 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 minutes to share centered column\nhome: %q\naway: %q", home, away) + } +} + +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 { + t.Fatalf("expected row 1 to be HT divider, got %#v", rows[1]) + } + 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)) + } + 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)) + } + if rows[3].minute != "85'" { + t.Fatalf("expected red card minute 85', got %q", rows[3].minute) + } +} + +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 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]) } } @@ -352,13 +434,75 @@ 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) - divider := renderMatchDividerRow("HT 1-0", 76) rowMid := strings.Index(row, "|") - dividerMid := strings.Index(divider, "HT 1-0") + (len("HT 1-0") / 2) - if rowMid != dividerMid { - t.Fatalf("expected lineup separator to share center axis\nrow: %q\ndiv: %q", row, divider) + dividerMid := 76 / 2 + if diff := rowMid - dividerMid; diff < -1 || diff > 1 { + 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) @@ -370,7 +514,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) @@ -382,9 +525,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, "HT 1-0") + (len("HT 1-0") / 2) + 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) } } @@ -425,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") @@ -439,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) } } @@ -622,14 +783,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 +799,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 +843,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()