diff --git a/internal/site/match_parser.go b/internal/site/match_parser.go
index b0ff86b..0f59725 100644
--- a/internal/site/match_parser.go
+++ b/internal/site/match_parser.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/PuerkitoBio/goquery"
+ "golang.org/x/net/html"
)
var matchMetaLineRe = regexp.MustCompile(`\d{1,2}\s+\p{L}+.*\d{1,2}:\d{2}`)
@@ -77,17 +78,19 @@ func parseMatchPage(doc *goquery.Document, url string) *MatchPage {
return
}
- if player := parsePlayerCell(tds.Eq(0)); player != nil {
- page.HomeLineup = append(page.HomeLineup, *player)
- for _, event := range playerTimelineEvents(*player, "home") {
+ if parsed := parsePlayerCell(tds.Eq(0), "home"); parsed != nil {
+ page.HomeLineup = append(page.HomeLineup, *parsed.Player)
+ for _, event := range playerTimelineEvents(*parsed.Player, "home") {
page.Events = append(page.Events, event)
}
+ page.Events = append(page.Events, parsed.ExtraEvents...)
}
- if player := parsePlayerCell(tds.Eq(2)); player != nil {
- page.AwayLineup = append(page.AwayLineup, *player)
- for _, event := range playerTimelineEvents(*player, "away") {
+ if parsed := parsePlayerCell(tds.Eq(2), "away"); parsed != nil {
+ page.AwayLineup = append(page.AwayLineup, *parsed.Player)
+ for _, event := range playerTimelineEvents(*parsed.Player, "away") {
page.Events = append(page.Events, event)
}
+ page.Events = append(page.Events, parsed.ExtraEvents...)
}
})
@@ -302,32 +305,59 @@ func substitutionEventText(outgoing, marker string) string {
return outgoing + " -> " + incoming
}
-func parsePlayerCell(cell *goquery.Selection) *PlayerLine {
+type parsedPlayerCell struct {
+ Player *PlayerLine
+ ExtraEvents []MatchEvent
+}
+
+func parsePlayerCell(cell *goquery.Selection, side string) *parsedPlayerCell {
raw := normalizeWhitespace(cell.Text())
if raw == "" {
return nil
}
+ name := raw
anchors := make([]string, 0, 3)
- cell.Find("a").Each(func(_ int, a *goquery.Selection) {
- name := normalizeWhitespace(a.Text())
- if name != "" {
- anchors = append(anchors, name)
+ markersByPlayer := make(map[string][]string, 2)
+ currentPlayer := ""
+
+ for _, node := range cell.Contents().Nodes {
+ switch node.Type {
+ case html.ElementNode:
+ switch strings.ToLower(node.Data) {
+ case "a":
+ playerName := normalizeWhitespace(goquery.NewDocumentFromNode(node).Text())
+ if playerName == "" {
+ continue
+ }
+ anchors = append(anchors, playerName)
+ if len(anchors) == 1 {
+ name = playerName
+ }
+ currentPlayer = playerName
+ case "img":
+ src := ""
+ for _, attr := range node.Attr {
+ if strings.EqualFold(attr.Key, "src") {
+ src = strings.ToLower(strings.TrimSpace(attr.Val))
+ break
+ }
+ }
+ switch {
+ case strings.Contains(src, "sub.gif"):
+ currentPlayer = ""
+ case currentPlayer == "":
+ continue
+ case strings.Contains(src, "yel.gif"):
+ markersByPlayer[currentPlayer] = append(markersByPlayer[currentPlayer], "YC")
+ case strings.Contains(src, "red.gif"), strings.Contains(src, "red2.gif"):
+ markersByPlayer[currentPlayer] = append(markersByPlayer[currentPlayer], "RC")
+ }
+ }
}
- })
-
- name := raw
- if len(anchors) > 0 {
- name = anchors[0]
}
- events := make([]string, 0, 3)
- if cell.Find("img[src*='yel.gif']").Length() > 0 {
- events = append(events, "YC")
- }
- if cell.Find("img[src*='red.gif'], img[src*='red2.gif']").Length() > 0 {
- events = append(events, "RC")
- }
+ events := append([]string(nil), markersByPlayer[name]...)
if cell.Find("img[src*='sub.gif']").Length() > 0 && len(anchors) > 1 {
// In 90minut substitution cells, the last linked player is the replacement.
@@ -340,7 +370,21 @@ func parsePlayerCell(cell *goquery.Selection) *PlayerLine {
}
}
- return &PlayerLine{Name: name, Events: events, RawText: raw}
+ extraEvents := make([]MatchEvent, 0, 2)
+ for i := 1; i < len(anchors); i++ {
+ for _, marker := range markersByPlayer[anchors[i]] {
+ extraEvents = append(extraEvents, MatchEvent{
+ Kind: marker,
+ TeamSide: side,
+ Text: anchors[i],
+ })
+ }
+ }
+
+ return &parsedPlayerCell{
+ Player: &PlayerLine{Name: name, Events: events, RawText: raw},
+ ExtraEvents: extraEvents,
+ }
}
func substitutionMinute(raw, replacement string) string {
diff --git a/internal/site/parser_edgecases_test.go b/internal/site/parser_edgecases_test.go
index bcb484d..cd5268b 100644
--- a/internal/site/parser_edgecases_test.go
+++ b/internal/site/parser_edgecases_test.go
@@ -135,3 +135,53 @@ func TestParseMatchPageMissedPenaltyTimelineEvent(t *testing.T) {
t.Fatalf("unexpected missed penalty text: %#v", event)
}
}
+
+func TestParseMatchPageSubstitutionCellAssignsCardsToBothPlayers(t *testing.T) {
+ html := `
+
Match Test
+
+ | Ekstraklasa |
+ | 20 marca 2026, 18:00 |
+ | Arka Gdynia | 3 - 1 | Zaglebie Lubin |
+
+ Oskar Jakubczyk 
+
+ 72 Michal Rzuchowski  |
+ | |
+
+ `
+
+ doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
+ if err != nil {
+ t.Fatalf("parse synthetic html: %v", err)
+ }
+
+ page := parseMatchPage(doc, "http://www.90minut.pl/mecz.php?id_mecz=2023999")
+ if page == nil {
+ t.Fatalf("expected parsed match page")
+ }
+ if len(page.HomeLineup) != 1 {
+ t.Fatalf("expected one starter row, got %#v", page.HomeLineup)
+ }
+ if page.HomeLineup[0].Name != "Oskar Jakubczyk" {
+ t.Fatalf("unexpected starter: %#v", page.HomeLineup[0])
+ }
+
+ seenSub := false
+ seenStarterCard := false
+ seenEntrantCard := false
+ for _, event := range page.Events {
+ switch {
+ case event.Kind == "SUB" && event.TeamSide == "home" && event.Text == "Oskar Jakubczyk -> Michal Rzuchowski":
+ seenSub = true
+ case event.Kind == "YC" && event.TeamSide == "home" && event.Text == "Oskar Jakubczyk":
+ seenStarterCard = true
+ case event.Kind == "YC" && event.TeamSide == "home" && event.Text == "Michal Rzuchowski":
+ seenEntrantCard = true
+ }
+ }
+
+ if !seenSub || !seenStarterCard || !seenEntrantCard {
+ t.Fatalf("expected sub and both YC events, got %#v", page.Events)
+ }
+}
diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go
index d2f60d8..e720afd 100644
--- a/internal/ui/render_helpers.go
+++ b/internal/ui/render_helpers.go
@@ -664,7 +664,11 @@ 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 {
- key := playerMatchKey(player.Name)
+ return cardAnnotationName(player.Name, idx)
+}
+
+func cardAnnotationName(name string, idx map[string][]site.MatchEvent) string {
+ key := playerMatchKey(name)
if key == "" {
return ""
}
@@ -690,11 +694,13 @@ func cardAnnotation(player site.PlayerLine, idx map[string][]site.MatchEvent) st
}
type lineupEntry struct {
- player site.PlayerLine
- enteredAt string
- replaced string
- leftAt string
- replacedBy string
+ player site.PlayerLine
+ enteredAt string
+ replaced string
+ replacedYC string
+ leftAt string
+ replacedBy string
+ replacedByYC string
}
// A lineup row can carry both entry and exit notes for players who came on and were later replaced.
@@ -753,6 +759,9 @@ func entryNote(entry lineupEntry, side string, shortenNotes bool) string {
text := "(" + entry.enteredAt
if replaced != "" {
text += " for " + replaced
+ if entry.replacedYC != "" {
+ text += entry.replacedYC
+ }
}
return faintText(text + ")")
}
@@ -761,6 +770,9 @@ func entryNote(entry lineupEntry, side string, shortenNotes bool) string {
if replaced != "" {
text += replaced + " "
}
+ if entry.replacedYC != "" {
+ text += entry.replacedYC + " "
+ }
text += entry.enteredAt
return faintText(text + ")")
}
@@ -776,6 +788,9 @@ func exitNote(entry lineupEntry, side string, shortenNotes bool) string {
text += entry.leftAt + " "
}
text += replacement
+ if entry.replacedByYC != "" {
+ text += entry.replacedByYC
+ }
if side != "home" && entry.leftAt != "" {
text += " " + entry.leftAt
}
@@ -816,28 +831,19 @@ func annotateLineupPlayer(player site.PlayerLine, idx map[string][]site.MatchEve
if playerMatchKey(in) == key {
entry.enteredAt = minute
entry.replaced = out
+ entry.replacedYC = cardAnnotationName(out, idx)
}
if playerMatchKey(out) == key {
entry.leftAt = minute
entry.replacedBy = in
+ entry.replacedByYC = cardAnnotationName(in, idx)
}
}
return entry
}
-func hasLineupBadgeEvent(key string, idx map[string][]site.MatchEvent) bool {
- for _, event := range idx[key] {
- switch event.Kind {
- case "YC", "RC":
- return true
- }
- }
-
- return false
-}
-
-// Synthetic entrant rows inherit the same substitution annotations so later card badges keep context.
+// Synthetic entrant rows are only added when a substitute was later replaced again.
func annotatedLineup(players []site.PlayerLine, idx map[string][]site.MatchEvent) []lineupEntry {
if len(players) == 0 {
return nil
@@ -858,11 +864,16 @@ func annotatedLineup(players []site.PlayerLine, idx map[string][]site.MatchEvent
if inKey == "" || addedSynthetic[inKey] {
continue
}
- if _, exists := byKey[inKey]; exists || !hasLineupBadgeEvent(inKey, idx) {
+ if _, exists := byKey[inKey]; exists {
+ continue
+ }
+
+ synthetic := annotateLineupPlayer(site.PlayerLine{Name: entry.replacedBy}, idx)
+ if synthetic.replacedBy == "" {
continue
}
- entries = append(entries, annotateLineupPlayer(site.PlayerLine{Name: entry.replacedBy}, idx))
+ entries = append(entries, synthetic)
addedSynthetic[inKey] = true
}
diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go
index 54fbe5c..e5601b6 100644
--- a/internal/ui/view_test.go
+++ b/internal/ui/view_test.go
@@ -614,25 +614,19 @@ func TestAnnotatedLineupDistinguishesSameInitialSameSurname(t *testing.T) {
}
}
-func TestAnnotatedLineupAddsMissingEntrantWhenTheyHaveCardEvent(t *testing.T) {
+func TestAnnotatedLineupKeepsBookedEntrantInReplacementNote(t *testing.T) {
idx := playerEventIndex([]site.MatchEvent{
{MinuteText: "66", Kind: "SUB", TeamSide: "home", Text: "Jason Lokilo -> Oskar Lesniak"},
{MinuteText: "84", Kind: "YC", TeamSide: "home", Text: "Oskar Lesniak 84"},
}, "home")
got := annotatedLineup([]site.PlayerLine{{Name: "Jason Lokilo"}}, idx)
- if len(got) != 2 {
- t.Fatalf("expected starter plus synthetic entrant, got %#v", got)
+ if len(got) != 1 {
+ t.Fatalf("expected only starter row, got %#v", got)
}
- if got[0].player.Name != "Jason Lokilo" || got[0].replacedBy != "Oskar Lesniak" || got[0].leftAt != "66'" {
+ if got[0].player.Name != "Jason Lokilo" || got[0].replacedBy != "Oskar Lesniak" || got[0].leftAt != "66'" || ansi.Strip(got[0].replacedByYC) != "■" {
t.Fatalf("expected starter row to retain substitution note, got %#v", got)
}
- if got[1].player.Name != "Oskar Lesniak" || got[1].replacedBy != "" {
- t.Fatalf("expected entrant row added for card badge visibility, got %#v", got)
- }
- if got[1].enteredAt != "66'" || got[1].replaced != "Jason Lokilo" {
- t.Fatalf("expected synthetic entrant row to retain entry note, got %#v", got)
- }
}
func TestAnnotatedLineupSkipsMissingEntrantWithoutBadgeEvent(t *testing.T) {
@@ -668,6 +662,32 @@ func TestAnnotatedLineupSyntheticEntrantKeepsLaterSubstitutionOff(t *testing.T)
}
}
+func TestFormatLineupPlayerShowsBookedReplacementInsideHomeNote(t *testing.T) {
+ home := formatLineupPlayer(lineupEntry{
+ player: site.PlayerLine{Name: "Oskar Jakubczyk"},
+ leftAt: "72'",
+ replacedBy: "Michal Rzuchowski",
+ replacedByYC: eventPrefix("YC"),
+ }, "home", 64)
+
+ if got := ansi.Strip(home); got != "(72' M. Rzuchowski■) O. Jakubczyk" {
+ t.Fatalf("unexpected home booked-replacement label: %q", got)
+ }
+}
+
+func TestFormatLineupPlayerShowsBookedReplacedPlayerInsideAwayNote(t *testing.T) {
+ away := formatLineupPlayer(lineupEntry{
+ player: site.PlayerLine{Name: "Jakub Sypek"},
+ enteredAt: "46'",
+ replaced: "Jan Kowalczyk",
+ replacedYC: eventPrefix("YC"),
+ }, "away", 64)
+
+ if got := ansi.Strip(away); got != "J. Sypek (for J. Kowalczyk ■ 46')" {
+ t.Fatalf("unexpected away booked-replaced label: %q", got)
+ }
+}
+
func TestRenderLineupRowUsesCenteredSeparatorColumn(t *testing.T) {
row := renderLineupRow("K. Kubica", "B. Mrozek", 76)
rowMid := strings.Index(row, "|")