diff --git a/internal/site/league_parser.go b/internal/site/league_parser.go index 1023840..4f3d9d2 100644 --- a/internal/site/league_parser.go +++ b/internal/site/league_parser.go @@ -161,43 +161,93 @@ func parseFixturesTable(table *goquery.Selection) []Fixture { fixtures := make([]Fixture, 0, 16) table.Find("tr").Each(func(_ int, row *goquery.Selection) { - matchLinks := row.Find("a[href*='mecz.php']") - if matchLinks.Length() != 1 { + fixture, ok := parseFixtureRow(row) + if !ok { return } + fixtures = append(fixtures, fixture) + }) + + return fixtures +} + +func parseFixtureRow(row *goquery.Selection) (Fixture, bool) { + tds := row.Find("td") + if tds.Length() < 3 { + return Fixture{}, false + } + + scoreCell, scoreIdx, matchLink, ok := fixtureScoreCell(row, tds) + if !ok { + return Fixture{}, false + } + + home, _ := nearestTeamCellText(tds, scoreIdx-1, -1) + away, awayIdx := nearestTeamCellText(tds, scoreIdx+1, 1) + if home == "" || away == "" || isScoreLikeText(home) || isScoreLikeText(away) { + return Fixture{}, false + } + + score := normalizeWhitespace(scoreCell.Text()) + if !isFixtureScoreText(score) { + return Fixture{}, false + } + + return Fixture{ + Home: home, + Away: away, + Score: score, + WhenInfo: joinNonEmptyCells(tds, awayIdx+1), + MatchURL: matchLink, + MatchID: extractMatchID(matchLink), + }, true +} + +func fixtureScoreCell(row *goquery.Selection, tds *goquery.Selection) (*goquery.Selection, int, string, bool) { + matchLinks := row.Find("a[href*='mecz.php']") + if matchLinks.Length() > 1 { + return nil, -1, "", false + } + if matchLinks.Length() == 1 { scoreCell := matchLinks.First().Closest("td") if scoreCell.Length() == 0 { - return + return nil, -1, "", false } - tds := row.Find("td") - scoreIdx := scoreCell.Index() - home, _ := nearestTeamCellText(tds, scoreIdx-1, -1) - away, awayIdx := nearestTeamCellText(tds, scoreIdx+1, 1) - if home == "" || away == "" || isScoreLikeText(home) || isScoreLikeText(away) { - return + matchLink := strings.TrimSpace(matchLinks.First().AttrOr("href", "")) + if matchLink == "" { + return nil, -1, "", false } - matchLink := strings.TrimSpace(scoreCell.Find("a[href*='mecz.php']").First().AttrOr("href", "")) - score := normalizeWhitespace(scoreCell.Text()) - whenInfo := joinNonEmptyCells(tds, awayIdx+1) + return scoreCell, scoreCell.Index(), matchLink, true + } - if score == "" || matchLink == "" { - return + scoreIdx := -1 + for idx := 0; idx < tds.Length(); idx++ { + if !isFixtureScoreText(tds.Eq(idx).Text()) { + continue } + home, _ := nearestTeamCellText(tds, idx-1, -1) + away, _ := nearestTeamCellText(tds, idx+1, 1) + if home == "" || away == "" || isScoreLikeText(home) || isScoreLikeText(away) { + continue + } + if scoreIdx >= 0 { + return nil, -1, "", false + } + scoreIdx = idx + } + if scoreIdx < 0 { + return nil, -1, "", false + } - fixtures = append(fixtures, Fixture{ - Home: home, - Away: away, - Score: score, - WhenInfo: whenInfo, - MatchURL: matchLink, - MatchID: extractMatchID(matchLink), - }) - }) + return tds.Eq(scoreIdx), scoreIdx, "", true +} - return fixtures +func isFixtureScoreText(text string) bool { + cleaned := normalizeWhitespace(text) + return cleaned == "-" || isScoreLikeText(cleaned) } func parseStandings(doc *goquery.Document) []StandingRow { diff --git a/internal/site/models.go b/internal/site/models.go index d3c8d7f..64fc671 100644 --- a/internal/site/models.go +++ b/internal/site/models.go @@ -13,6 +13,12 @@ type Competition struct { LeagueKey string } +type CompetitionMenu struct { + Title string + URL string + Items []Competition +} + type Fixture struct { Home string Away string diff --git a/internal/site/parser_archive_test.go b/internal/site/parser_archive_test.go index 60daa62..728de9d 100644 --- a/internal/site/parser_archive_test.go +++ b/internal/site/parser_archive_test.go @@ -83,10 +83,16 @@ func TestParseSeasonsAndCompetitionsFromArchiveFixtures(t *testing.T) { ekstraklasaURL := c.Resolve("/liga/1/liga11233.html") iLigaURL := c.Resolve("/liga/1/liga11234.html") iiLigaURL := c.Resolve("/liga/1/liga11235.html") + iiiLigaSelectorURL := c.Resolve("/ligireg.php?poziom=4&id_sezon=97") + regionalneURL := c.Resolve("/ligireg.php?id_sezon=97") + regionalCupsURL := c.Resolve("/polcups.php?id_sezon=97") ekstraklasaIdx := competitionIndexByURL(competitions, ekstraklasaURL) iLigaIdx := competitionIndexByURL(competitions, iLigaURL) iiLigaIdx := competitionIndexByURL(competitions, iiLigaURL) + iiiLigaSelectorIdx := competitionIndexByURL(competitions, iiiLigaSelectorURL) + regionalneIdx := competitionIndexByURL(competitions, regionalneURL) + regionalCupsIdx := competitionIndexByURL(competitions, regionalCupsURL) if ekstraklasaIdx < 0 || iLigaIdx < 0 || iiLigaIdx < 0 { t.Fatalf("missing expected league links in 2020/21 archive") @@ -94,6 +100,12 @@ func TestParseSeasonsAndCompetitionsFromArchiveFixtures(t *testing.T) { if !(ekstraklasaIdx < iLigaIdx && iLigaIdx < iiLigaIdx) { t.Fatalf("competition order broken: Ekstraklasa=%d I liga=%d II liga=%d", ekstraklasaIdx, iLigaIdx, iiLigaIdx) } + if iiiLigaSelectorIdx < 0 || regionalneIdx < 0 { + t.Fatalf("missing III liga or ligi regionalne links in 2020/21 archive") + } + if regionalCupsIdx < 0 { + t.Fatalf("missing regional cups link in 2020/21 archive") + } } for _, season := range seasons { @@ -117,3 +129,86 @@ func TestParseSeasonsAndCompetitionsFromArchiveFixtures(t *testing.T) { t.Fatalf("expected at least one decoded Polish diacritic in archive fixtures") } } + +func TestParseCompetitionMenuForIIILigaSelector(t *testing.T) { + html := `

III liga 2025/26

I
II
` + doc, err := decodeAndParse([]byte(html), "text/html; charset=utf-8") + if err != nil { + t.Fatalf("parse synthetic HTML: %v", err) + } + + menu := parseCompetitionMenu(doc, "http://www.90minut.pl/ligireg.php?poziom=4&id_sezon=107", NewClient()) + if menu == nil { + t.Fatalf("expected III liga submenu") + } + if menu.Title != "III liga 2025/26" { + t.Fatalf("unexpected menu title: %q", menu.Title) + } + if len(menu.Items) != 2 || menu.Items[0].Name != "III liga 2025/26, gr. I" || menu.Items[1].Name != "III liga 2025/26, gr. II" { + t.Fatalf("unexpected III liga submenu items: %+v", menu.Items) + } +} + +func TestParseCompetitionMenuForRegionalRoot(t *testing.T) { + html := `

Ligi regionalne 2025/26

Dolnośląski ZPNDziś grająKujawsko-Pomorski ZPN
` + doc, err := decodeAndParse([]byte(html), "text/html; charset=utf-8") + if err != nil { + t.Fatalf("parse synthetic HTML: %v", err) + } + + menu := parseCompetitionMenu(doc, "http://www.90minut.pl/ligireg.php?id_sezon=107", NewClient()) + if menu == nil { + t.Fatalf("expected regional submenu") + } + if len(menu.Items) != 2 || menu.Items[0].Name != "Dolnośląski ZPN" || menu.Items[1].Name != "Kujawsko-Pomorski ZPN" { + t.Fatalf("unexpected regional submenu items: %+v", menu.Items) + } +} + +func TestParseCompetitionMenuForRegionalRootWithAssociationQueryLinks(t *testing.T) { + html := `

Ligi regionalne 2025/26

Dolnośląski ZPNLubuski ZPN
` + doc, err := decodeAndParse([]byte(html), "text/html; charset=utf-8") + if err != nil { + t.Fatalf("parse synthetic HTML: %v", err) + } + + menu := parseCompetitionMenu(doc, "http://www.90minut.pl/ligireg.php?id_sezon=107", NewClient()) + if menu == nil { + t.Fatalf("expected regional submenu") + } + if len(menu.Items) != 2 { + t.Fatalf("unexpected regional submenu items: %+v", menu.Items) + } +} + +func TestParseCompetitionMenuForRegionalAssociationPage(t *testing.T) { + html := `

Ligi regionalne 2025/26 - Dolnośląski ZPN

IV liga 2025/2026, grupa: dolnośląskaKlasa okręgowa 2025/2026, grupa: Jelenia Góra
` + doc, err := decodeAndParse([]byte(html), "text/html; charset=utf-8") + if err != nil { + t.Fatalf("parse synthetic HTML: %v", err) + } + + menu := parseCompetitionMenu(doc, "http://www.90minut.pl/ligireg-16.html", NewClient()) + if menu == nil { + t.Fatalf("expected regional association submenu") + } + if len(menu.Items) != 2 { + t.Fatalf("unexpected regional association item count: %d", len(menu.Items)) + } +} + +func TestParseCompetitionMenuForRegionalCupsPage(t *testing.T) { + html := `

Puchary krajowe 2025/26

Puchar PolskiSuperpuchar PolskiPuchar Polski 2025/2026, grupa: Lubuski ZPNPuchar Polski 2025/2026, grupa: Lubuski ZPN - Gorzów Wielkopolski
` + doc, err := decodeAndParse([]byte(html), "text/html; charset=utf-8") + if err != nil { + t.Fatalf("parse synthetic HTML: %v", err) + } + + menu := parseCompetitionMenu(doc, "http://www.90minut.pl/polcups.php?id_sezon=107", NewClient()) + if menu == nil { + t.Fatalf("expected regional cups submenu") + } + if len(menu.Items) != 2 { + t.Fatalf("expected regional cups only, got %+v", menu.Items) + } +} diff --git a/internal/site/parser_league_test.go b/internal/site/parser_league_test.go index aa09605..64f9aad 100644 --- a/internal/site/parser_league_test.go +++ b/internal/site/parser_league_test.go @@ -17,8 +17,8 @@ func TestParseLeagueFixturesFromCorpus(t *testing.T) { "league_14073": {firstRoundName: "Kolejka 1 - 19-20 lipca", firstRoundFixtures: 9}, } leagues := fixturesByKind(m, "league") - if len(leagues) < 6 { - t.Fatalf("expected at least 6 league fixtures, got %d", len(leagues)) + if len(leagues) < 7 { + t.Fatalf("expected at least 7 league fixtures, got %d", len(leagues)) } if !containsFixtureName(leagues, "league_14072") { t.Fatalf("expected league fixture for liga14072") @@ -26,6 +26,9 @@ func TestParseLeagueFixturesFromCorpus(t *testing.T) { if !containsFixtureName(leagues, "league_14073") { t.Fatalf("expected league fixture for liga14073") } + if !containsFixtureName(leagues, "league_14141") { + t.Fatalf("expected league fixture for liga14141") + } for _, fixture := range leagues { fixture := fixture @@ -61,6 +64,12 @@ func TestParseLeagueFixturesFromCorpus(t *testing.T) { if isScoreLikeText(match.Home) || isScoreLikeText(match.Away) { t.Fatalf("fixture side parsed as score token in %s: home=%q away=%q", fixture.Name, match.Home, match.Away) } + if match.MatchURL == "" { + if match.MatchID != "" { + t.Fatalf("fixture without match url should keep empty match id in %s: %q", fixture.Name, match.MatchID) + } + continue + } if !strings.Contains(match.MatchURL, "mecz.php") { t.Fatalf("fixture match url is not a match link in %s: %q", fixture.Name, match.MatchURL) } @@ -102,6 +111,37 @@ func TestParseLeagueFixturesFromCorpus(t *testing.T) { } } +func TestParseLeagueFixturesWithoutMatchLinksFromCorpus(t *testing.T) { + doc, _ := fixtureDoc(t, "fixtures/league_14141.html") + page := parseLeaguePage(doc, "http://www.90minut.pl/liga/1/liga14141.html") + if page == nil { + t.Fatalf("expected league page") + } + if len(page.Rounds) == 0 { + t.Fatalf("expected rounds for linkless fixture league") + } + + linklessFixtures := 0 + for _, round := range page.Rounds { + for _, fixture := range round.Fixtures { + if fixture.MatchURL != "" { + continue + } + linklessFixtures++ + if fixture.MatchID != "" { + t.Fatalf("expected empty match id for linkless fixture, got %q", fixture.MatchID) + } + if fixture.Home == "" || fixture.Away == "" || fixture.Score == "" { + t.Fatalf("expected sides and score for linkless fixture, got %+v", fixture) + } + } + } + + if linklessFixtures == 0 { + t.Fatalf("expected at least one linkless fixture in liga14141") + } +} + func assertFixturesSortedByDate(t *testing.T, fixtureName string, round Round) { t.Helper() diff --git a/internal/site/parser_regression_test.go b/internal/site/parser_regression_test.go index 1b33d39..2a3b3d8 100644 --- a/internal/site/parser_regression_test.go +++ b/internal/site/parser_regression_test.go @@ -192,6 +192,60 @@ func TestParseFixturesTableSkipsRowsWithMultipleMatchLinks(t *testing.T) { } } +func TestParseFixturesTableFallsBackToPlainTextScoresWithoutMatchLinks(t *testing.T) { + html := ` + + + + +
Slask Wroclaw-Pogon Tczew16 maja, 12:00
w pierwotnym terminie odwolany
Gornik Leczna3-0UKS SMS Lodz25 marca, 16:00
` + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + t.Fatalf("parse synthetic HTML: %v", err) + } + + fixtures := parseFixturesTable(doc.Find("table").First()) + if len(fixtures) != 2 { + t.Fatalf("expected 2 fixtures, got %d", len(fixtures)) + } + if fixtures[0].Home != "Slask Wroclaw" || fixtures[0].Away != "Pogon Tczew" || fixtures[0].Score != "-" || fixtures[0].WhenInfo != "16 maja, 12:00" { + t.Fatalf("unexpected plain-text fixture: %+v", fixtures[0]) + } + if fixtures[0].MatchURL != "" || fixtures[0].MatchID != "" { + t.Fatalf("expected empty match details for plain-text score fixture: %+v", fixtures[0]) + } + if fixtures[1].Home != "Gornik Leczna" || fixtures[1].Away != "UKS SMS Lodz" || fixtures[1].Score != "3-0" { + t.Fatalf("unexpected second plain-text fixture: %+v", fixtures[1]) + } +} + +func TestParseLeaguePageHandlesSavedAmbiguousLinklessFixture(t *testing.T) { + doc, _ := fixtureDoc(t, "fixtures/league_ambiguous_linkless.html") + + page := parseLeaguePage(doc, "http://www.90minut.pl/liga/1/liga99998.html") + if page == nil { + t.Fatalf("expected league page") + } + if len(page.Rounds) != 1 { + t.Fatalf("expected 1 round, got %d", len(page.Rounds)) + } + if len(page.Rounds[0].Fixtures) != 2 { + t.Fatalf("expected 2 fixtures, got %d", len(page.Rounds[0].Fixtures)) + } + + first := page.Rounds[0].Fixtures[0] + if first.Home != "Team A" || first.Away != "Team B" || first.Score != "1-0" { + t.Fatalf("unexpected first fixture: %+v", first) + } + if first.WhenInfo != "walkower 3-0 24 lipca, 18:00" { + t.Fatalf("unexpected first fixture metadata: %+v", first) + } + if first.MatchURL != "" || first.MatchID != "" { + t.Fatalf("expected linkless fixture, got %+v", first) + } +} + func TestRoundNameFromTableSkipsNavigationBlocks(t *testing.T) { html := ` diff --git a/internal/site/service.go b/internal/site/service.go index 6625f36..99b0b9d 100644 --- a/internal/site/service.go +++ b/internal/site/service.go @@ -36,32 +36,57 @@ func (s *Service) LoadArchive(ctx context.Context, archiveURL string) ([]Season, return seasons, selectedIdx, competitions, nil } -// LoadLeague fetches and parses a league page into a normalized LeaguePage. -// Rounds are ordered by detected round number when available, and fixtures -// inside each round are ordered by parsed date/time in the site layer. -func (s *Service) LoadLeague(ctx context.Context, leagueURL string) (*LeaguePage, error) { - doc, err := s.client.Document(ctx, leagueURL) +func (s *Service) LoadCompetition(ctx context.Context, competitionURL string) (*CompetitionMenu, *LeaguePage, error) { + doc, err := s.client.Document(ctx, competitionURL) if err != nil { - return nil, err + return nil, nil, err } - league := parseLeaguePage(doc, s.client.Resolve(leagueURL)) - if league == nil { - return nil, fmt.Errorf("league parse: no round fixtures found") - } - league.LeagueKey = extractLeagueKey(league.URL) + resolvedURL := s.client.Resolve(competitionURL) + var leagueErr error + if league := parseLeaguePage(doc, resolvedURL); league != nil { + league.LeagueKey = extractLeagueKey(league.URL) + + for i := range league.Rounds { + for j := range league.Rounds[i].Fixtures { + if league.Rounds[i].Fixtures[j].MatchURL == "" { + league.Rounds[i].Fixtures[j].MatchID = "" + continue + } + league.Rounds[i].Fixtures[j].MatchURL = s.client.Resolve(league.Rounds[i].Fixtures[j].MatchURL) + league.Rounds[i].Fixtures[j].MatchID = extractMatchID(league.Rounds[i].Fixtures[j].MatchURL) + } + } - for i := range league.Rounds { - for j := range league.Rounds[i].Fixtures { - league.Rounds[i].Fixtures[j].MatchURL = s.client.Resolve(league.Rounds[i].Fixtures[j].MatchURL) - league.Rounds[i].Fixtures[j].MatchID = extractMatchID(league.Rounds[i].Fixtures[j].MatchURL) + if err := validateLeaguePage(league); err == nil { + return nil, league, nil + } else { + leagueErr = err } } - if err := validateLeaguePage(league); err != nil { - return league, err + menu := parseCompetitionMenu(doc, resolvedURL, s.client) + if menu == nil || len(menu.Items) == 0 { + if leagueErr != nil { + return nil, nil, leagueErr + } + return nil, nil, fmt.Errorf("competition parse: no submenu or fixtures found") } + return menu, nil, nil +} + +// LoadLeague fetches and parses a league page into a normalized LeaguePage. +// Rounds are ordered by detected round number when available, and fixtures +// inside each round are ordered by parsed date/time in the site layer. +func (s *Service) LoadLeague(ctx context.Context, leagueURL string) (*LeaguePage, error) { + _, league, err := s.LoadCompetition(ctx, leagueURL) + if err != nil { + return nil, err + } + if league == nil { + return nil, fmt.Errorf("league parse: competition is a submenu") + } return league, nil } @@ -160,9 +185,8 @@ func parseCompetitions(doc *goquery.Document, c *Client) []Competition { links := make([]Competition, 0, 64) seen := map[string]struct{}{} - // Archive pages render season-specific competition links in the central - // content area as a.main href="/liga/..." entries. - doc.Find("a.main[href*='/liga/']").Each(func(_ int, s *goquery.Selection) { + // Archive pages mix direct league pages with seasonal regional overviews. + doc.Find("a.main").Each(func(_ int, s *goquery.Selection) { rawURL, ok := s.Attr("href") if !ok { return @@ -191,7 +215,168 @@ func parseCompetitions(doc *goquery.Document, c *Client) []Competition { return links } +func parseCompetitionMenu(doc *goquery.Document, resolvedURL string, c *Client) *CompetitionMenu { + switch { + case isIIIligaSelectorURL(resolvedURL): + return parseIIIligaMenu(doc, resolvedURL, c) + case isRegionalRootURL(resolvedURL): + return parseRegionalAssociationMenu(doc, resolvedURL, c) + case isRegionalAssociationURL(resolvedURL): + return parseRegionalLeagueMenu(doc, resolvedURL, c) + case isRegionalCupsURL(resolvedURL): + return parseRegionalCupMenu(doc, resolvedURL, c) + default: + return nil + } +} + +func parseIIIligaMenu(doc *goquery.Document, resolvedURL string, c *Client) *CompetitionMenu { + title := competitionMenuTitle(doc, "III liga") + items := make([]Competition, 0, 4) + seen := map[string]struct{}{} + + doc.Find("a.main[href*='/liga/']").Each(func(_ int, s *goquery.Selection) { + name := strings.TrimSpace(s.Text()) + if name == "" { + return + } + switch name { + case "I", "II", "III", "IV": + default: + return + } + + rawURL, ok := s.Attr("href") + if !ok || !isLeagueLikeURL(rawURL) { + return + } + + absoluteURL := c.Resolve(strings.TrimSpace(rawURL)) + if _, exists := seen[absoluteURL]; exists { + return + } + seen[absoluteURL] = struct{}{} + items = append(items, Competition{Name: title + ", gr. " + name, URL: absoluteURL, LeagueKey: extractLeagueKey(absoluteURL)}) + }) + + if len(items) == 0 { + return nil + } + + return &CompetitionMenu{Title: title, URL: resolvedURL, Items: items} +} + +func parseRegionalAssociationMenu(doc *goquery.Document, resolvedURL string, c *Client) *CompetitionMenu { + items := parseCompetitionLinks(doc.Find("a.main"), c, func(name string) bool { + return name != "" + }, func(rawURL string) bool { + trimmed := strings.TrimSpace(rawURL) + return strings.HasPrefix(trimmed, "/ligireg-") || isRegionalAssociationURL(trimmed) + }) + if len(items) == 0 { + return nil + } + + return &CompetitionMenu{Title: competitionMenuTitle(doc, "Ligi regionalne"), URL: resolvedURL, Items: items} +} + +func parseRegionalLeagueMenu(doc *goquery.Document, resolvedURL string, c *Client) *CompetitionMenu { + items := parseCompetitionLinks(doc.Find("a.main"), c, func(name string) bool { + return name != "" + }, func(rawURL string) bool { + return isLeagueLikeURL(rawURL) && strings.Contains(strings.ToLower(rawURL), "/liga/") + }) + if len(items) == 0 { + return nil + } + + return &CompetitionMenu{Title: competitionMenuTitle(doc, "Ligi regionalne"), URL: resolvedURL, Items: items} +} + +func parseRegionalCupMenu(doc *goquery.Document, resolvedURL string, c *Client) *CompetitionMenu { + items := parseCompetitionLinks(doc.Find("a.main"), c, func(name string) bool { + if name == "" || name == "Puchar Polski" || name == "Superpuchar Polski" { + return false + } + return strings.Contains(strings.ToLower(name), "puchar") || strings.Contains(strings.ToLower(name), "superpuchar") + }, func(rawURL string) bool { + return isLeagueLikeURL(rawURL) && strings.Contains(strings.ToLower(rawURL), "/liga/") + }) + if len(items) == 0 { + return nil + } + + return &CompetitionMenu{Title: competitionMenuTitle(doc, "Puchary regionalne"), URL: resolvedURL, Items: items} +} + +func parseCompetitionLinks(selection *goquery.Selection, c *Client, keep func(string) bool, keepURL func(string) bool) []Competition { + items := make([]Competition, 0, 32) + seen := map[string]struct{}{} + + selection.Each(func(_ int, s *goquery.Selection) { + rawURL, ok := s.Attr("href") + if !ok || !keepURL(rawURL) { + return + } + + name := strings.TrimSpace(s.Text()) + if !keep(name) { + return + } + + absoluteURL := c.Resolve(strings.TrimSpace(rawURL)) + if _, exists := seen[absoluteURL]; exists { + return + } + seen[absoluteURL] = struct{}{} + items = append(items, Competition{Name: name, URL: absoluteURL, LeagueKey: extractLeagueKey(absoluteURL)}) + }) + + return items +} + +func competitionMenuTitle(doc *goquery.Document, fallback string) string { + title := strings.TrimSpace(doc.Find("td[valign='top'] p[align='center'] b").First().Text()) + if title == "" { + return fallback + } + return title +} + func isLeagueLikeURL(raw string) bool { lower := strings.ToLower(raw) - return strings.Contains(lower, "/liga/") && strings.HasSuffix(lower, ".html") + if strings.Contains(lower, "/liga/") && strings.HasSuffix(lower, ".html") { + return true + } + + return strings.Contains(lower, "/ligireg.php") || strings.Contains(lower, "/polcups.php") +} + +func isIIIligaSelectorURL(raw string) bool { + lower := strings.ToLower(strings.TrimSpace(raw)) + return strings.Contains(lower, "/ligireg.php") && strings.Contains(lower, "poziom=4") +} + +func isRegionalRootURL(raw string) bool { + lower := strings.ToLower(strings.TrimSpace(raw)) + if !strings.Contains(lower, "/ligireg") || strings.Contains(lower, "poziom=") { + return false + } + if strings.Contains(lower, "id_okreg=") || strings.Contains(lower, "/ligireg-") { + return false + } + return strings.Contains(lower, "id_sezon=") || strings.HasSuffix(lower, "/ligireg.html") +} + +func isRegionalAssociationURL(raw string) bool { + lower := strings.ToLower(strings.TrimSpace(raw)) + if strings.Contains(lower, "/ligireg-") { + return true + } + return strings.Contains(lower, "/ligireg.php") && strings.Contains(lower, "id_okreg=") +} + +func isRegionalCupsURL(raw string) bool { + lower := strings.ToLower(strings.TrimSpace(raw)) + return strings.Contains(lower, "/polcups.php") } diff --git a/internal/site/service_test.go b/internal/site/service_test.go new file mode 100644 index 0000000..647790c --- /dev/null +++ b/internal/site/service_test.go @@ -0,0 +1,101 @@ +package site + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestLoadLeagueAllowsFixturesWithoutMatchLinks(t *testing.T) { + _, body := fixtureDoc(t, "fixtures/league_14141.html") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=iso-8859-2") + _, _ = w.Write(body) + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + svc := &Service{client: &Client{baseURL: baseURL, http: server.Client()}} + page, err := svc.LoadLeague(context.Background(), server.URL+"/liga/1/liga14141.html") + if err != nil { + t.Fatalf("expected LoadLeague to succeed, got %v", err) + } + + linklessFixtures := 0 + for _, round := range page.Rounds { + for _, fixture := range round.Fixtures { + if fixture.MatchURL != "" { + continue + } + linklessFixtures++ + if fixture.MatchID != "" { + t.Fatalf("expected empty match id for linkless fixture, got %q", fixture.MatchID) + } + } + } + + if linklessFixtures == 0 { + t.Fatalf("expected linkless fixtures in loaded league page") + } +} + +func TestLoadArchiveIncludesRegionalCupsCompetition(t *testing.T) { + _, body := fixtureDoc(t, "fixtures/archive_2020_21.html") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=iso-8859-2") + _, _ = w.Write(body) + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + svc := &Service{client: &Client{baseURL: baseURL, http: server.Client()}} + _, _, competitions, err := svc.LoadArchive(context.Background(), server.URL+"/archiwum/2020-21") + if err != nil { + t.Fatalf("expected LoadArchive to succeed, got %v", err) + } + + for _, competition := range competitions { + if competition.URL == server.URL+"/polcups.php?id_sezon=97" { + return + } + } + + t.Fatalf("expected regional cups competition in archive: %+v", competitions) +} + +func TestLoadCompetitionLoadsRegionalCupsMenu(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprint(w, `

Puchary krajowe 2025/26

Puchar PolskiPuchar Polski 2025/2026, grupa: Lubuski ZPNPuchar Polski 2025/2026, grupa: Lubuski ZPN - Gorzów Wielkopolski
`) + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + svc := &Service{client: &Client{baseURL: baseURL, http: server.Client()}} + menu, league, err := svc.LoadCompetition(context.Background(), server.URL+"/polcups.php?id_sezon=107") + if err == nil { + if league != nil { + t.Fatalf("expected submenu, got league %+v", league) + } + if menu == nil || len(menu.Items) != 2 { + t.Fatalf("expected regional cups submenu, got %+v", menu) + } + return + } + t.Fatalf("expected submenu load to succeed, got %v", err) +} diff --git a/internal/site/testdata/fixtures/league_14141.html b/internal/site/testdata/fixtures/league_14141.html new file mode 100644 index 0000000..d18a8b7 --- /dev/null +++ b/internal/site/testdata/fixtures/league_14141.html @@ -0,0 +1,2232 @@ + + + + + + + + + + + +Orlen Ekstraliga kobiet 2025/2026 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
[ Strona g³ówna ]
+ + + + +
+
+ +
+
+
+
+ + + + + + + +
Jedyny w swoim rodzaju Skarb Ekstraklasy - statystyki, sylwetki, kariery graczy, historie, osi±gniêcia klubówSkarb I ligi - statystyki, kariery graczy, historie i osi±gniêcia klubówRozgrywki regionalne 2025/2026 - od IV ligi do Klasy C !Wyszukiwarka klubów, zawodników i sêdziów w serwisie 90 MinutJe¶li chcesz skontaktowaæ siê z redakcj± - najpierw to przeczytaj!
+
+ + + + + + + + + + +
+ + + + + + + + + + + + +
90minut.pl +
+ + + + + + + + + + + + + + + + + + +
+ I liga
+ + + + + + + + + +
+ CLJ
+ + + + + + + + + + + + + + + + + + + +
+ Futsal
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ PZPN
+ + + + + +
+ Serie
+
+ +
+ Nabór
+ + + + + +
+
RSSSF + +
+
+ +

+
+
 
+    +

+.: Wyniki | Strzelcy | Statystyki | Ostatnia kolejka :. +

+ +
+
+

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +Orlen Ekstraliga kobiet 2025/2026 +
 
RAZEMDOMWYJAZDMECZE BEZPO¦REDNIE
NazwaM.Pkt.Z.R.P.BramkiZ.R.P.BramkiZ.R.P.BramkiM.Pkt.Z.R.P.Bramki
1. Czarni Sosnowiec1643141149-1080029-561120-5
2. Pogoñ Szczecin1638122246-1261124-861122-4131003-2
3. Górnik £êczna1638122238-1061119-461119-6100012-3
4. GKS Katowice142783328-1931311-1252017-7
5. ¦l±sk Wroc³aw152170826-203049-1140417-9131003-0
6. Lech/UAM Poznañ152163620-2940311-132339-16100010-3
7. UKS SMS £ód¼161954726-2823312-1431414-14131004-1
8. Rekord Bielsko-Bia³a161954719-271256-1742213-10100011-4
9. APLG Gdañsk161752919-353148-1621511-19
10. UJ Kraków161125916-341349-191257-15
11. Stomilanki Olsztyn157211215-471178-281057-19
12. Pogoñ Tczew153031210-410253-160177-25
+ +

+ + + + + + + + + + + + + + +
 1  2  3  4  5  6  7  8  9  10  11  12 
 1 Czarni Sosnowiec    2-1 2-0 7-1 1-0 4-0 1-0 5-3 7-0 
 2 Pogoñ Szczecin    3-2 0-2 5-0 3-2 3-0 0-0 7-2 3-0 
 3 Górnik £êczna0-0     2-1 4-0 3-0 0-2 3-0 4-0 3-1 
 4 GKS Katowice1-2 0-4     1-1 2-1 2-3 2-0 3-1 
 5 ¦l±sk Wroc³aw0-5 0-1 0-1     2-1 0-1 3-2 4-0 
 6 Lech/UAM Poznañ0-4 1-3 0-3     2-1 4-1 1-0 3-1 
 7 UKS SMS £ód¼2-3 0-3 1-1 1-0 3-1     2-2 0-1 3-3 
 8 Rekord Bielsko-Bia³a0-3 0-1 1-4 1-1 1-3 0-0 1-4     2-1 
 9 APLG Gdañsk1-0 0-0 1-4 2-5 1-0 0-6 0-1     3-0 
 10 UJ Kraków0-3 0-6 0-1 2-3 1-1 1-1 4-4     1-0 
 11 Stomilanki Olsztyn1-5 1-3 0-2 1-4 0-5 0-4 2-3 1-1     2-1 
 12 Pogoñ Tczew1-1 2-5 0-1 0-1 0-0 0-3 0-5     
+

+
+
+
+
+ +

+ + + + +
+ + Kolejka 1 - 9-10 sierpnia +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UJ Kraków 1-0 Pogoñ Tczew 10 sierpnia, 17:00
Wiktoria Kaczmarek 7
Lech/UAM Poznañ 1-0 Stomilanki Olsztyn 9 sierpnia, 12:00
Julia Przyby³ 45
GKS Katowice 2-3 Rekord Bielsko-Bia³a 9 sierpnia, 15:00
D¿esika Jaszek 13, Aleksandra Nieci±g 90 - Dominika Dereñ 29 (k), Oliwia Zgoda 38, Kristýna Jankù 40
Czarni Sosnowiec 4-0 APLG Gdañsk 9 sierpnia, 15:00
Karlīna Miksone 36 (k), Patrycja Sarapata 55, Zuzanna Witek 61, Zofia Buszewska 86
Pogoñ Szczecin 5-0 UKS SMS £ód¼ 9 sierpnia, 12:00
Lena ¦wirska 3, 61, Weronika Szymaszek 45 (k), 83, Alexis Legowski 55
Górnik £êczna 2-1 ¦l±sk Wroc³aw 10 sierpnia, 11:00
Paulina Tomasiak 53, Julia Ostrowska 90 - Aleksandra Dudziak 90 (k)
+

+ + + + +
+ + Kolejka 2 - 16-17 sierpnia +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew 2-5 ¦l±sk Wroc³aw 16 sierpnia, 15:00
Klaudia Kamiñska 5, 45 - Karolina Gec 20, Paulina Guzik 47, Aleksandra Dudziak 62 (k), Daria Soko³owska 72, Katarzyna Bia³oszewska 90
UKS SMS £ód¼ 1-1 Górnik £êczna 16 sierpnia, 12:00
Anna Potrykus 61 - Anna Rêdzia 85
APLG Gdañsk 0-0 Pogoñ Szczecin 17 sierpnia, 14:00
Rekord Bielsko-Bia³a 0-3 Czarni Sosnowiec 16 sierpnia, 12:30
Luiza Kozielska 11, Klaudia Mi³ek 22, Nikol Kaletka 25
Stomilanki Olsztyn 1-4 GKS Katowice 16 sierpnia, 12:00
Klaudia Maci±¿ka 58 (s) - Anita Turkiewicz 3, Katarzyna Nowak 29, Patrícia Hmírová 32, Julia W³odarczyk 37
UJ Kraków 1-1 Lech/UAM Poznañ 17 sierpnia, 12:00
Anita Romuzga 13 - Zofia Porada 21
+

+ + + + +
+ + Kolejka 3 - 23-24 sierpnia +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Lech/UAM Poznañ 3-1 Pogoñ Tczew 23 sierpnia, 12:00
Julia Przyby³ 40, 85 (k), Marta Kwiatkowska 87 - Aleksandra Witczak 79 (k)
GKS Katowice 2-0 UJ Kraków 22 sierpnia, 18:00
Aleksandra Nieci±g 40, 58
Czarni Sosnowiec 5-3 Stomilanki Olsztyn 23 sierpnia, 15:00
Patrycja Sarapata 24, Karlīna Miksone 29 (k), Daria Kurzawa 40, Klaudia Mi³ek 61, Nikol Kaletka 63 - Martyna Duchnowska 48, Alisa Renee Arthur 57, 70
Pogoñ Szczecin 3-2 Rekord Bielsko-Bia³a 24 sierpnia, 12:00
Lena ¦wirska 32, 53, Zofia Giêtkowska 90 - Dominika Dereñ 11 (k), Daria D³ugokêcka 20
Górnik £êczna 3-0 APLG Gdañsk 23 sierpnia, 13:00
Anna Rêdzia 45, Julia Piêtakiewicz 65, 68
¦l±sk Wroc³aw 2-1 UKS SMS £ód¼ 23 sierpnia, 11:30
Joanna Wróblewska 14, Aleksandra Dudziak 27 - Magdalena D±browska 64
+

+ + + + +
+ + Kolejka 4 - 30-31 sierpnia +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew 0-1 UKS SMS £ód¼ 31 sierpnia, 14:00
Patrycja Balcerzak 61
APLG Gdañsk 1-0 ¦l±sk Wroc³aw 30 sierpnia, 12:00
Roksana Jagodziñska 37
Rekord Bielsko-Bia³a 1-4 Górnik £êczna 31 sierpnia, 13:00
Roksana Gulec 49 - Anna Rêdzia 36, 62, Julia Piêtakiewicz 74, Karla Kurkutoviæ 78
Stomilanki Olsztyn 1-3 Pogoñ Szczecin 31 sierpnia, 11:45
Abigail Roy 57 (k) - Zofia Giêtkowska 21, Choi Da-kyung 23, Alicja Soko³owska 67 (s)
UJ Kraków 0-3 Czarni Sosnowiec 30 sierpnia, 14:00
Oliwia £apiñska 21, Klaudia Mi³ek 45, 78
Lech/UAM Poznañ - GKS Katowice 20 maja, 15:00
w pierwotnym terminie (22 listopada, 11:00) odwo³any z powodu powo³añ zawodniczek gospodarzy do reprezentacji narodowej
+

+ + + + +
+ + Kolejka 5 - 13-14 wrze¶nia +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
GKS Katowice 3-1 Pogoñ Tczew 19 listopada, 16:00
Aleksandra Nieci±g 29, Adriana Banaszkiewicz 76 (s), Victoria Kaláberová 90 - Annabel Hogan 65
w pierwotnym terminie (14 wrze¶nia, 13:00) odwo³any
Czarni Sosnowiec 7-1 Lech/UAM Poznañ 11 listopada, 12:00
Patrycja Sarapata 10, Zofia Buszewska 30, 66, Klaudia Mi³ek 54, 90, Karlīna Miksone 82, Zuzanna Witek 90 - Aleksandra Ku¶mierzyk 17
w pierwotnym terminie (14 wrze¶nia, 15:00) odwo³any z powodu z³ego stanu boiska
Pogoñ Szczecin 0-0 UJ Kraków 13 wrze¶nia, 12:00
Górnik £êczna 4-0 Stomilanki Olsztyn 13 wrze¶nia, 14:00
Karla Kurkutoviæ 5, 10, Anna Rêdzia 34, Oliwia Rapacka 48
¦l±sk Wroc³aw 0-1 Rekord Bielsko-Bia³a 13 wrze¶nia, 12:00
Dominika Dereñ 61 (k)
UKS SMS £ód¼ 2-2 APLG Gdañsk 13 wrze¶nia, 15:00
Paulina Filipczak 7, Gabriela Fesinger 23 - Roksana Jagodziñska 28, Klaudia Fabová 78
+

+ + + + +
+ + Kolejka 6 - 20-21 wrze¶nia +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew 0-3 APLG Gdañsk 19 wrze¶nia, 13:00
Klaudia Fabová 23, Angelika Ko³odziejek 29, Roksana Jagodziñska 48
Rekord Bielsko-Bia³a 1-4 UKS SMS £ód¼ 20 wrze¶nia, 12:00
Julia Gutowska 38 - Magdalena D±browska 6 (k), Gabriela Fesinger 7, Paulina Filipczak 60, 75
Stomilanki Olsztyn 0-5 ¦l±sk Wroc³aw 21 wrze¶nia, 12:00
Natalia Siciarz 7, Marcelina Bu¶ 44, 50, Olga Polewska 89, Katarzyna Musia³owska 90
UJ Kraków 0-1 Górnik £êczna 20 wrze¶nia, 14:00
Klaudia Lefeld 79
Lech/UAM Poznañ 1-3 Pogoñ Szczecin 20 wrze¶nia, 11:00
Marta Kwiatkowska 28 - Karolina £aniewska 49, Kornelia Okoniewska 77, Zofia Giêtkowska 89
GKS Katowice 1-2 Czarni Sosnowiec 22 wrze¶nia, 17:00
Klaudia Maci±¿ka 20 - Zuzanna Witek 28, Klaudia Mi³ek 60
+

+ + + + +
+ + Kolejka 7 - 27-28 wrze¶nia +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Czarni Sosnowiec 7-0 Pogoñ Tczew 27 wrze¶nia, 15:00
Nikol Kaletka 14, 56, Karlīna Miksone 21, 54, Zofia Buszewska 26, Klaudia Mi³ek 45, Weronika Wójcik 71
Pogoñ Szczecin 0-2 GKS Katowice 27 wrze¶nia, 12:00
Katarzyna Nowak 61, Nicola Brzêczek 77
Górnik £êczna 4-0 Lech/UAM Poznañ 27 wrze¶nia, 18:00
Karla Kurkutoviæ 27, 33, 49, Klaudia Lefeld 58
¦l±sk Wroc³aw 4-0 UJ Kraków 28 wrze¶nia, 12:00
Julia Jêdrzejewska 4, Joanna Wróblewska 45, Daria Soko³owska 69, Karolina Gec 76
UKS SMS £ód¼ 0-1 Stomilanki Olsztyn 27 wrze¶nia, 12:00
Abigail Roy 49
APLG Gdañsk 0-1 Rekord Bielsko-Bia³a 27 wrze¶nia, 12:00
Daria D³ugokêcka 90
+

+ + + + +
+ + Kolejka 8 - 4-5 pa¼dziernika +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew 0-0 Rekord Bielsko-Bia³a 4 pa¼dziernika, 13:00
Stomilanki Olsztyn 2-3 APLG Gdañsk 5 pa¼dziernika, 13:00
Alisa Renee Arthur 73, Alicja Soko³owska 79 - Roksana Jagodziñska 32, Klaudia Fabová 47, Katarzyna Konat 87
UJ Kraków 1-1 UKS SMS £ód¼ 5 pa¼dziernika, 15:00
Magdalena Kowalska 81 - Paulina Filipczak 20
Lech/UAM Poznañ 0-3 ¦l±sk Wroc³aw 4 pa¼dziernika, 12:00
Julia Jêdrzejewska 17, Patrycja Ziemba 78, Martyna Bu¶ 87
GKS Katowice 0-4 Górnik £êczna 3 pa¼dziernika, 17:00
Weronika K³oda 19, Paulina Tomasiak 60, Julia Piêtakiewicz 73, 90
Czarni Sosnowiec 2-1 Pogoñ Szczecin 4 pa¼dziernika, 11:45
Zofia Buszewska 9, Zuzanna Grzywiñska 90 - Choi Da-kyung 41
+

+ + + + +
+ + Kolejka 9 - 11-12 pa¼dziernika +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Szczecin 3-0 Pogoñ Tczew 11 pa¼dziernika, 12:00
Suzuka Yosue 74, Anastasija Poļuhovièa 86, Zuzanna Rybiñska 90
Górnik £êczna 0-0 Czarni Sosnowiec 11 pa¼dziernika, 11:00
¦l±sk Wroc³aw 0-1 GKS Katowice 12 pa¼dziernika, 12:00
Aleksandra Nieci±g 2
UKS SMS £ód¼ 3-1 Lech/UAM Poznañ 11 pa¼dziernika, 12:00
Julia Kolis 20, Magdalena D±browska 48, Paulina Filipczak 83 - Oliwia Zwi±zek 17
APLG Gdañsk 3-0 UJ Kraków 11 pa¼dziernika, 12:00
Klaudia Fabová 17, Roksana Jagodziñska 57, 71
Rekord Bielsko-Bia³a 2-1 Stomilanki Olsztyn 11 pa¼dziernika, 13:00
Julia Sikora 76, Abigail Sowa 90 (k) - Natalia Kulig 82
+

+ + + + +
+ + Kolejka 10 - 2 listopada +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UJ Kraków 4-4 Rekord Bielsko-Bia³a 31 pa¼dziernika, 19:30
Julia Dy¶kowska 12, Wiktoria Kaczmarek 13, 77, Weronika Smaza 18 - Oliwia Zgoda 2, 10, Dominika Dereñ 35 (k), Daria D³ugokêcka 90
Lech/UAM Poznañ 2-1 APLG Gdañsk 31 pa¼dziernika, 12:00
Julia Przyby³ 4, Liwia Prochwicz 52 - Julia Maskiewicz 56
GKS Katowice 2-1 UKS SMS £ód¼ 2 listopada, 12:00
Aleksandra Nieci±g 37, Marcjanna Zawadzka 77 - Inez Sikora 58
Czarni Sosnowiec 2-0 ¦l±sk Wroc³aw 2 listopada, 15:00
Oliwia £apiñska 88, Klaudia Mi³ek 90
Pogoñ Szczecin 3-2 Górnik £êczna 2 listopada, 11:45
Lena ¦wirska 44, Weronika Szymaszek 65 (k), Anastasija Poļuhovièa 84 - Anna Rêdzia 49, Maja Hrelja 86
Stomilanki Olsztyn 2-1 Pogoñ Tczew 30 pa¼dziernika, 12:00
Alisa Renee Arthur 33, Karolina Taranowska 56 - Aleksandra Witczak 67
+

+ + + + +
+ + Kolejka 11 - 8-9 listopada +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Górnik £êczna 3-1 Pogoñ Tczew 8 listopada, 14:00
Klaudia Lefeld 49, Oliwia Rapacka 90, Julia Ostrowska 90 - Nanoka Iriguchi 53
¦l±sk Wroc³aw 0-5 Pogoñ Szczecin 8 listopada, 12:15
Anastasija Poļuhovièa 20, Lena ¦wirska 54, Zofia Giêtkowska 64, Suzuka Yosue 66, Zuzanna Radochoñska 83
UKS SMS £ód¼ 2-3 Czarni Sosnowiec 8 listopada, 12:00
Paulina Filipczak 7, Zuzanna Mi±zek 90 - Karlina Miksone 52, Patrycja Sarapata 56, 67
APLG Gdañsk 2-5 GKS Katowice 8 listopada, 12:00
Klaudia Fabová 23, 29 - Nicola Brzêczek 2, 61, 73, Patrícia Hmírová 76 (k), Julia Langosz 90
Rekord Bielsko-Bia³a 0-0 Lech/UAM Poznañ 8 listopada, 13:00
Stomilanki Olsztyn 1-1 UJ Kraków 8 listopada, 19:45
Alisa Renee Arthur 28 - Wiktoria Kaczmarek 83
na boisku Szko³y Podstawowej nr 18
+

+ + + + +
+ + Kolejka 12 - 21-22 lutego +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew 0-5 UJ Kraków 21 lutego, 12:30
Anita Romuzga 9, Emilia Sabuda 43, Wiktoria Kaczmarek 65, 72, Lidia Kulka 82
Stomilanki Olsztyn 0-4 Lech/UAM Poznañ 25 marca, 12:00
Maja Kuleczka 17, Julia Przyby³ 39, Lena Sworska 78, Amelia Guzenda 86
w pierwotnym terminie (21 lutego, 13:00) odwo³any z powodu z³ego stanu boiska
Rekord Bielsko-Bia³a 1-1 GKS Katowice 25 marca, 15:00
Agnieszka Glinka 8 - Aleksandra Nieci±g 15
w pierwotnym terminie (22 lutego, 12:00) odwo³any z powodu z³ego stanu boiska
APLG Gdañsk 1-0 Czarni Sosnowiec 21 lutego, 12:00
Courtney Butlion 54
UKS SMS £ód¼ 0-3 Pogoñ Szczecin 22 lutego, 12:15
Kornelia Okoniewska 2, 29, Alexis Legowski 87
mecz pierwotnie zaplanowany na 12:00 rozpocz±³ siê z 15-minutowym opó¼nieniem
¦l±sk Wroc³aw 0-1 Górnik £êczna 21 lutego, 12:00
Julia Piêtakiewicz 6 (k)
+

+ + + + +
+ + Kolejka 13 - 28 lutego-1 marca +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
¦l±sk Wroc³aw - Pogoñ Tczew 16 maja, 12:00
w pierwotnym terminie (1 marca, 16:00) odwo³any
Górnik £êczna 3-0 UKS SMS £ód¼ 25 marca, 16:00
Julia Piêtakiewicz 10, Klaudia Lefeld 80, Paulina Tomasiak 90
Pogoñ Szczecin 3-0 APLG Gdañsk 25 marca, 17:00
Choi Da-kyung 9, Lena ¦wirska 59, Zuzanna Radochoñska 61
w pierwotnym terminie (1 marca, 16:00) odwo³any
Czarni Sosnowiec 1-0 Rekord Bielsko-Bia³a 18 marca, 15:00
Klaudia Mi³ek 44
GKS Katowice - Stomilanki Olsztyn
w pierwotnym terminie odwo³any z powodu powo³añ zawodniczek gospodarzy do reprezentacji narodowych
Lech/UAM Poznañ 4-1 UJ Kraków 28 lutego, 14:00
Liwia Prochwicz 11, Klaudia Wojtkowiak 23, Zofia Wojciechowska 36, Blanka Borowa 65 - Aleksandra Pleban 55 (k)
+

+ + + + +
+ + Kolejka 14 - 14-15 marca +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew 0-1 Lech/UAM Poznañ 14 marca, 11:00
Maja Kuleczka 49 (k)
UJ Kraków 2-3 GKS Katowice 14 marca, 17:30
Magdalena Kowalska 12, Wiktoria Kaczmarek 90 - Santa Sanija Vu¹kāne 15, Patrycja Kozarzewska 65, Amelia Biñkowska 72
Stomilanki Olsztyn 1-5 Czarni Sosnowiec 14 marca, 17:00
Alisa Renee Arthur 90 - Patrycja Sarapata 10, 40, 50, Klaudia Mi³ek 18 (k), 62
Rekord Bielsko-Bia³a 0-1 Pogoñ Szczecin 15 marca, 12:00
Daria D³ugokêcka 40 (s)
APLG Gdañsk 1-4 Górnik £êczna 15 marca, 12:30
Alina Choroszczak 74 - Paulina Tomasiak 35, 85, Roksana Ratajczyk 63, Julia Piêtakiewicz 65
UKS SMS £ód¼ 1-0 ¦l±sk Wroc³aw 15 marca, 10:30
Magdalena D±browska 53
+

+ + + + +
+ + Kolejka 15 - 21-22 marca +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UKS SMS £ód¼ 3-3 Pogoñ Tczew 21 marca, 12:00
Zofia P±gowska 2, 30, Paulina Filipczak 38 - Meghan McKye 33, Ana 55, Nanoka Iriguchi 90
¦l±sk Wroc³aw 3-2 APLG Gdañsk 22 marca, 12:00
Karolina Gec 10 (k), Kinga Wyrwas 23, Katarzyna Bia³oszewska 32 - Viktoria Osowska 4, Klaudia Fabová 45
Górnik £êczna 0-2 Rekord Bielsko-Bia³a 21 marca, 14:00
Julia Gutowska 19, Dominika Dereñ 74 (k)
Pogoñ Szczecin 7-2 Stomilanki Olsztyn 21 marca, 12:00
Choi Da-kyung 3, 44, Lena ¦wirska 8, 67, Weronika Szymaszek 16 (k), Matína Darzánou 62, Zuzanna Radochoñska 72 - Alisa Renee Arthur 22, Kim Se-yeon 74
Czarni Sosnowiec 1-0 UJ Kraków 21 marca, 13:00
Nikol Kaletka 3
GKS Katowice 1-1 Lech/UAM Poznañ 21 marca, 13:00
Victoria Kaláberová 43 - Oliwia Zwi±zek 63
+ + +

+

+ + + + +
+ + Kolejka 16 - 28-29 marca +
+
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew 1-1 GKS Katowice 29 marca, 12:00
Julia Dro¿ak 76 - nieznany 22
Lech/UAM Poznañ 0-4 Czarni Sosnowiec 28 marca, 12:00
Karlīna Miksone 24, Nikol Kaletka 53, Klaudia Mi³ek 67, 90
UJ Kraków 0-6 Pogoñ Szczecin 28 marca, 12:30
Suzuka Yosue 9, 21, 32, Choi Da-kyung 23, 56, Anastasija Poļuhovièa 42
Stomilanki Olsztyn 0-2 Górnik £êczna 28 marca, 17:00
Julia Piêtakiewicz 44 (k), Paulina Tomasiak 62
Rekord Bielsko-Bia³a 1-3 ¦l±sk Wroc³aw 29 marca, 15:00
Dominika Dereñ 18 (k) - Patrycja Ziemba 27, Katarzyna Musia³owska 90, Karolina Gec 90
w Ustroniu
APLG Gdañsk 0-6 UKS SMS £ód¼ 29 marca, 15:00
Zofia P±gowska 10, 37, Martyna Bartczak 26, Paulina Filipczak 45, 66, 87
+

+
+

+ + + + +
+ + Kolejka 17 - 4 kwietnia +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
APLG Gdañsk - Pogoñ Tczew 4 kwietnia, 12:00
UKS SMS £ód¼ - Rekord Bielsko-Bia³a 3 kwietnia, 12:00
¦l±sk Wroc³aw - Stomilanki Olsztyn 4 kwietnia, 12:00
Górnik £êczna - UJ Kraków 4 kwietnia, 13:00
Pogoñ Szczecin - Lech/UAM Poznañ 4 kwietnia, 12:00
Czarni Sosnowiec - GKS Katowice 4 kwietnia, 10:45
+ +

+
+

+ + + + +
+ + Kolejka 18 - 25-26 kwietnia +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew - Czarni Sosnowiec
GKS Katowice - Pogoñ Szczecin 25 kwietnia, 11:00
Lech/UAM Poznañ - Górnik £êczna 25 kwietnia, 12:00
UJ Kraków - ¦l±sk Wroc³aw 25 kwietnia, 17:30
Stomilanki Olsztyn - UKS SMS £ód¼ 25 kwietnia, 17:00
Rekord Bielsko-Bia³a - APLG Gdañsk
+

+ + + + +
+ + Kolejka 19 - 2-3 maja +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Rekord Bielsko-Bia³a - Pogoñ Tczew
APLG Gdañsk - Stomilanki Olsztyn
UKS SMS £ód¼ - UJ Kraków 2 maja, 15:00
¦l±sk Wroc³aw - Lech/UAM Poznañ
Górnik £êczna - GKS Katowice
Pogoñ Szczecin - Czarni Sosnowiec
+

+ + + + +
+ + Kolejka 20 - 9-10 maja +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew - Pogoñ Szczecin
Czarni Sosnowiec - Górnik £êczna
GKS Katowice - ¦l±sk Wroc³aw
Lech/UAM Poznañ - UKS SMS £ód¼
UJ Kraków - APLG Gdañsk
Stomilanki Olsztyn - Rekord Bielsko-Bia³a 10 maja, 15:00
+

+ + + + +
+ + Kolejka 21 - 23-24 maja +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew - Stomilanki Olsztyn
Rekord Bielsko-Bia³a - UJ Kraków
APLG Gdañsk - Lech/UAM Poznañ
UKS SMS £ód¼ - GKS Katowice 23 maja, 12:00
¦l±sk Wroc³aw - Czarni Sosnowiec
Górnik £êczna - Pogoñ Szczecin
+

+ + + + +
+ + Kolejka 22 - 30-31 maja +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pogoñ Tczew - Górnik £êczna
Pogoñ Szczecin - ¦l±sk Wroc³aw
Czarni Sosnowiec - UKS SMS £ód¼
GKS Katowice - APLG Gdañsk 30 maja, 11:30
Lech/UAM Poznañ - Rekord Bielsko-Bia³a
UJ Kraków - Stomilanki Olsztyn
+ +

+

+ + +
+Orlen I liga kobiet 2025/2026 +
+

+ + +
+Autor: Pawe³ Mogielnicki +

+

 

+
Napisz do nas
+ + + diff --git a/internal/site/testdata/fixtures/league_ambiguous_linkless.html b/internal/site/testdata/fixtures/league_ambiguous_linkless.html new file mode 100644 index 0000000..818744f --- /dev/null +++ b/internal/site/testdata/fixtures/league_ambiguous_linkless.html @@ -0,0 +1,25 @@ + + + Ambiguous Linkless League + + + + +
1. kolejka
+ + + + + + + + + + + + + + +
Team A1-0Team Bwalkower 3-024 lipca, 18:00
Team C-Team D25 lipca, 19:00
+ + diff --git a/internal/site/testdata/manifest.json b/internal/site/testdata/manifest.json index 7603ed1..b0c6e1c 100644 --- a/internal/site/testdata/manifest.json +++ b/internal/site/testdata/manifest.json @@ -1,156 +1,172 @@ -{ - "generated_at": "sha256:969307a13d9cda4b3df5545eb17073087987b5fed3fb53026767851fa12c67de", - "source": "http://www.90minut.pl/archsezon.php", - "fixtures": [ - { - "name": "archive_current", - "kind": "archive", - "url": "http://www.90minut.pl/archsezon.php", - "season": "current", - "file": "fixtures/archive_current.html" - }, - { - "name": "archive_2019_20", - "kind": "archive", - "url": "http://www.90minut.pl/archsezon.php?id_sezon=95", - "season": "2019/20", - "file": "fixtures/archive_2019_20.html" - }, - { - "name": "archive_2020_21", - "kind": "archive", - "url": "http://www.90minut.pl/archsezon.php?id_sezon=97", - "season": "2020/21", - "file": "fixtures/archive_2020_21.html" - }, - { - "name": "archive_1975_76", - "kind": "archive", - "url": "http://www.90minut.pl/archsezon.php?id_sezon=7", - "season": "1975/76", - "file": "fixtures/archive_1975_76.html" - }, - { - "name": "archive_1958", - "kind": "archive", - "url": "http://www.90minut.pl/archsezon.php?id_sezon=-28", - "season": "1958", - "file": "fixtures/archive_1958.html" - }, - { - "name": "league_14072", - "kind": "league", - "url": "http://www.90minut.pl/liga/1/liga14072.html", - "season": "2025/26", - "file": "fixtures/league_14072.html", - "note": "addenda-heavy fixture rows" - }, - { - "name": "league_14073", - "kind": "league", - "url": "http://www.90minut.pl/liga/1/liga14073.html", - "season": "2025/26", - "file": "fixtures/league_14073.html", - "note": "round text annotations" - }, - { - "name": "league_liga11233", - "kind": "league", - "url": "http://www.90minut.pl/liga/1/liga11233.html", - "season": "2020/21", - "file": "fixtures/league_liga11233.html" - }, - { - "name": "league_liga11234", - "kind": "league", - "url": "http://www.90minut.pl/liga/1/liga11234.html", - "season": "2020/21", - "file": "fixtures/league_liga11234.html" - }, - { - "name": "league_liga10550", - "kind": "league", - "url": "http://www.90minut.pl/liga/1/liga10550.html", - "season": "2019/20", - "file": "fixtures/league_liga10550.html" - }, - { - "name": "league_liga10551", - "kind": "league", - "url": "http://www.90minut.pl/liga/1/liga10551.html", - "season": "2019/20", - "file": "fixtures/league_liga10551.html" - }, - { - "name": "league_liga11235", - "kind": "league", - "url": "http://www.90minut.pl/liga/1/liga11235.html", - "season": "2020/21", - "file": "fixtures/league_liga11235.html" - }, - { - "name": "match_2022810", - "kind": "match", - "url": "http://www.90minut.pl/mecz.php?id_mecz=2022810", - "season": "2025/26", - "file": "fixtures/match_2022810.html" - }, - { - "name": "match_2022745", - "kind": "match", - "url": "http://www.90minut.pl/mecz.php?id_mecz=2022745", - "season": "2025/26", - "file": "fixtures/match_2022745.html" - }, - { - "name": "match_2022961", - "kind": "match", - "url": "http://www.90minut.pl/mecz.php?id_mecz=2022961", - "season": "2025/26", - "file": "fixtures/match_2022961.html", - "note": "missed penalty timeline row" - }, - { - "name": "match_2023487", - "kind": "match", - "url": "http://www.90minut.pl/mecz.php?id_mecz=2023487", - "season": "2025/26", - "file": "fixtures/match_2023487.html" - }, - { - "name": "match_2023485", - "kind": "match", - "url": "http://www.90minut.pl/mecz.php?id_mecz=2023485", - "season": "2025/26", - "file": "fixtures/match_2023485.html" - }, - { - "name": "match_1659735", - "kind": "match", - "url": "http://www.90minut.pl/mecz.php?id_mecz=1659735", - "season": "2020/21", - "file": "fixtures/match_1659735.html" - }, - { - "name": "match_1659853", - "kind": "match", - "url": "http://www.90minut.pl/mecz.php?id_mecz=1659853", - "season": "2020/21", - "file": "fixtures/match_1659853.html" - }, - { - "name": "match_1666115", - "kind": "match", - "url": "http://www.90minut.pl/mecz.php?id_mecz=1666115", - "season": "2020/21", - "file": "fixtures/match_1666115.html" - }, - { - "name": "match_1666308", - "kind": "match", - "url": "http://www.90minut.pl/mecz.php?id_mecz=1666308", - "season": "2020/21", - "file": "fixtures/match_1666308.html" - } - ] -} +{ + "generated_at": "sha256:280abfa7e319aa9300e56579033fd8b362fa84ca80aa38d3115c5358711a6c46", + "source": "http://www.90minut.pl/archsezon.php", + "fixtures": [ + { + "name": "archive_current", + "kind": "archive", + "url": "http://www.90minut.pl/archsezon.php", + "season": "current", + "file": "fixtures/archive_current.html" + }, + { + "name": "archive_2019_20", + "kind": "archive", + "url": "http://www.90minut.pl/archsezon.php?id_sezon=95", + "season": "2019/20", + "file": "fixtures/archive_2019_20.html" + }, + { + "name": "archive_2020_21", + "kind": "archive", + "url": "http://www.90minut.pl/archsezon.php?id_sezon=97", + "season": "2020/21", + "file": "fixtures/archive_2020_21.html" + }, + { + "name": "archive_1975_76", + "kind": "archive", + "url": "http://www.90minut.pl/archsezon.php?id_sezon=7", + "season": "1975/76", + "file": "fixtures/archive_1975_76.html" + }, + { + "name": "archive_1958", + "kind": "archive", + "url": "http://www.90minut.pl/archsezon.php?id_sezon=-28", + "season": "1958", + "file": "fixtures/archive_1958.html" + }, + { + "name": "league_14072", + "kind": "league", + "url": "http://www.90minut.pl/liga/1/liga14072.html", + "season": "2025/26", + "file": "fixtures/league_14072.html", + "note": "addenda-heavy fixture rows" + }, + { + "name": "league_14073", + "kind": "league", + "url": "http://www.90minut.pl/liga/1/liga14073.html", + "season": "2025/26", + "file": "fixtures/league_14073.html", + "note": "round text annotations" + }, + { + "name": "league_14141", + "kind": "league", + "url": "http://www.90minut.pl/liga/1/liga14141.html", + "season": "2025/26", + "file": "fixtures/league_14141.html", + "note": "current women league page without match links" + }, + { + "name": "league_liga11233", + "kind": "league", + "url": "http://www.90minut.pl/liga/1/liga11233.html", + "season": "2020/21", + "file": "fixtures/league_liga11233.html" + }, + { + "name": "league_liga11234", + "kind": "league", + "url": "http://www.90minut.pl/liga/1/liga11234.html", + "season": "2020/21", + "file": "fixtures/league_liga11234.html" + }, + { + "name": "league_liga10550", + "kind": "league", + "url": "http://www.90minut.pl/liga/1/liga10550.html", + "season": "2019/20", + "file": "fixtures/league_liga10550.html" + }, + { + "name": "league_liga10551", + "kind": "league", + "url": "http://www.90minut.pl/liga/1/liga10551.html", + "season": "2019/20", + "file": "fixtures/league_liga10551.html" + }, + { + "name": "league_liga11235", + "kind": "league", + "url": "http://www.90minut.pl/liga/1/liga11235.html", + "season": "2020/21", + "file": "fixtures/league_liga11235.html" + }, + { + "name": "match_2022810", + "kind": "match", + "url": "http://www.90minut.pl/mecz.php?id_mecz=2022810", + "season": "2025/26", + "file": "fixtures/match_2022810.html" + }, + { + "name": "match_2022745", + "kind": "match", + "url": "http://www.90minut.pl/mecz.php?id_mecz=2022745", + "season": "2025/26", + "file": "fixtures/match_2022745.html" + }, + { + "name": "match_2022961", + "kind": "match", + "url": "http://www.90minut.pl/mecz.php?id_mecz=2022961", + "season": "2025/26", + "file": "fixtures/match_2022961.html", + "note": "missed penalty timeline row" + }, + { + "name": "match_2023487", + "kind": "match", + "url": "http://www.90minut.pl/mecz.php?id_mecz=2023487", + "season": "2025/26", + "file": "fixtures/match_2023487.html" + }, + { + "name": "match_2023485", + "kind": "match", + "url": "http://www.90minut.pl/mecz.php?id_mecz=2023485", + "season": "2025/26", + "file": "fixtures/match_2023485.html" + }, + { + "name": "match_1659735", + "kind": "match", + "url": "http://www.90minut.pl/mecz.php?id_mecz=1659735", + "season": "2020/21", + "file": "fixtures/match_1659735.html" + }, + { + "name": "match_1659853", + "kind": "match", + "url": "http://www.90minut.pl/mecz.php?id_mecz=1659853", + "season": "2020/21", + "file": "fixtures/match_1659853.html" + }, + { + "name": "match_1666115", + "kind": "match", + "url": "http://www.90minut.pl/mecz.php?id_mecz=1666115", + "season": "2020/21", + "file": "fixtures/match_1666115.html" + }, + { + "name": "match_1666308", + "kind": "match", + "url": "http://www.90minut.pl/mecz.php?id_mecz=1666308", + "season": "2020/21", + "file": "fixtures/match_1666308.html" + }, + { + "name": "league_ambiguous_linkless", + "kind": "league", + "url": "http://www.90minut.pl/liga/1/liga99998.html", + "season": "synthetic", + "file": "fixtures/league_ambiguous_linkless.html", + "note": "plain-text score plus extra score-like metadata" + } + ] +} diff --git a/internal/ui/commands.go b/internal/ui/commands.go index 9d7d5a3..a1f9d17 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -28,6 +28,15 @@ func (m Model) loadSeasonCompetitionsCmd(url, seasonKey string) tea.Cmd { } } +func (m Model) loadCompetitionCmd(url, competitionKey string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + menu, league, err := m.service.LoadCompetition(ctx, url) + return competitionMenuLoadedMsg{competitionKey: competitionKey, menu: menu, league: league, err: err} + } +} + func (m Model) loadLeagueCmd(url, competitionKey string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) diff --git a/internal/ui/fixture_navigation_test.go b/internal/ui/fixture_navigation_test.go index 226e47a..9d6f9b2 100644 --- a/internal/ui/fixture_navigation_test.go +++ b/internal/ui/fixture_navigation_test.go @@ -2,6 +2,7 @@ package ui import ( "context" + "errors" "fmt" "testing" @@ -13,11 +14,13 @@ import ( type recordingLoader struct { archiveCalls int leagueCalls int + menuCalls int matchCalls int seasons []site.Season selectedIdx int competitions []site.Competition + menus map[string]*site.CompetitionMenu league *site.LeaguePage match *site.MatchPage } @@ -32,6 +35,15 @@ func (l *recordingLoader) LoadLeague(context.Context, string) (*site.LeaguePage, return l.league, nil } +func (l *recordingLoader) LoadCompetition(_ context.Context, rawURL string) (*site.CompetitionMenu, *site.LeaguePage, error) { + l.menuCalls++ + if menu, ok := l.menus[rawURL]; ok { + return menu, nil, nil + } + l.leagueCalls++ + return nil, l.league, nil +} + func (l *recordingLoader) LoadMatch(context.Context, string) (*site.MatchPage, error) { l.matchCalls++ return l.match, nil @@ -81,6 +93,178 @@ func TestFixtureNavigationDoesNotReloadLeague(t *testing.T) { } } +func TestLeagueLoadPrefersLatestCompletedFixtureWhenLeagueHasNoMatchLinks(t *testing.T) { + loader := newRecordingLoader() + loader.league.Rounds = []site.Round{ + { + Name: "1. kolejka", + Fixtures: []site.Fixture{ + {Home: "Team A", Away: "Team B", Score: "1-0"}, + {Home: "Team C", Away: "Team D", Score: "2-2"}, + }, + }, + { + Name: "2. kolejka", + Fixtures: []site.Fixture{ + {Home: "Team E", Away: "Team F", Score: "-"}, + {Home: "Team G", Away: "Team H", Score: "-"}, + }, + }, + } + m := bootstrapLeagueLoadedModel(t, loader) + + if m.roundCursor != 0 { + t.Fatalf("expected latest completed round selected, got %d", m.roundCursor) + } + if m.fixtureCursor != 1 { + t.Fatalf("expected latest completed fixture selected, got %d", m.fixtureCursor) + } + if fixture := m.currentFixture(); fixture == nil || fixture.Home != "Team C" || fixture.Score != "2-2" { + t.Fatalf("expected latest completed fixture selected, got %+v", fixture) + } +} + +func TestLeagueLoadPrefersLatestDrillableFixtureWhenLeagueHasMixedLinks(t *testing.T) { + loader := newRecordingLoader() + loader.league.Rounds = []site.Round{ + { + Name: "1. kolejka", + Fixtures: []site.Fixture{{Home: "Team A", Away: "Team B", Score: "1-0", MatchURL: "http://www.90minut.pl/mecz.php?id_mecz=1", MatchID: "1"}}, + }, + { + Name: "2. kolejka", + Fixtures: []site.Fixture{ + {Home: "Team C", Away: "Team D", Score: "-"}, + {Home: "Team E", Away: "Team F", Score: "2-1", MatchURL: "http://www.90minut.pl/mecz.php?id_mecz=2", MatchID: "2"}, + }, + }, + } + m := bootstrapLeagueLoadedModel(t, loader) + + if m.roundCursor != 1 { + t.Fatalf("expected latest round with drillable fixture selected, got %d", m.roundCursor) + } + if m.fixtureCursor != 1 { + t.Fatalf("expected latest drillable fixture selected, got %d", m.fixtureCursor) + } + if fixture := m.currentFixture(); fixture == nil || fixture.MatchID != "2" { + t.Fatalf("expected latest drillable fixture selected, got %+v", fixture) + } +} + +func TestCompetitionEnterOpensIIILigaSubmenu(t *testing.T) { + loader := newRecordingLoader() + loader.competitions = []site.Competition{ + {Name: "PKO Bank Polski Ekstraklasa 2025/2026", URL: "http://www.90minut.pl/liga/1/liga14072.html", LeagueKey: "liga14072"}, + {Name: "III liga 2025/26", URL: "http://www.90minut.pl/ligireg.php?poziom=4&id_sezon=107", LeagueKey: "www.90minut.pl/ligireg.php?id_sezon=107&poziom=4"}, + } + loader.menus = map[string]*site.CompetitionMenu{ + "http://www.90minut.pl/ligireg.php?poziom=4&id_sezon=107": { + Title: "III liga 2025/26", + Items: []site.Competition{{Name: "III liga 2025/26, gr. I", URL: "http://www.90minut.pl/liga/1/liga14154.html", LeagueKey: "liga14154"}}, + }, + } + m := bootstrapLeagueLoadedModel(t, loader) + + m, _ = updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + m.competitionCursor = 1 + m, cmd := updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("expected submenu load command") + } + m, cmd = updateModelWithMsg(t, m, cmd()) + if cmd != nil { + t.Fatalf("expected submenu load to settle without chained command") + } + if !m.selectorVisible { + t.Fatalf("expected selector to stay visible on submenu open") + } + if got := m.competitionTitle; got != "III liga 2025/26" { + t.Fatalf("unexpected submenu title: %q", got) + } + if len(m.competitions) != 1 || m.competitions[0].Name != "III liga 2025/26, gr. I" { + t.Fatalf("unexpected submenu items: %+v", m.competitions) + } + if len(m.competitionStack) != 1 { + t.Fatalf("expected one previous menu on stack, got %d", len(m.competitionStack)) + } + if loader.menuCalls < 2 { + t.Fatalf("expected startup load plus submenu load, got %d", loader.menuCalls) + } +} + +func TestCompetitionSubmenuEscReturnsToPreviousMenu(t *testing.T) { + loader := newRecordingLoader() + loader.competitions = []site.Competition{ + {Name: "PKO Bank Polski Ekstraklasa 2025/2026", URL: "http://www.90minut.pl/liga/1/liga14072.html", LeagueKey: "liga14072"}, + {Name: "Ligi regionalne 2025/26", URL: "http://www.90minut.pl/ligireg.php?id_sezon=107", LeagueKey: "www.90minut.pl/ligireg.php?id_sezon=107"}, + } + loader.menus = map[string]*site.CompetitionMenu{ + "http://www.90minut.pl/ligireg.php?id_sezon=107": { + Title: "Ligi regionalne 2025/26", + Items: []site.Competition{{Name: "Dolnoslaski ZPN", URL: "http://www.90minut.pl/ligireg-16.html", LeagueKey: "www.90minut.pl/ligireg-16.html"}}, + }, + "http://www.90minut.pl/ligireg-16.html": { + Title: "Ligi regionalne 2025/26 - Dolnoslaski ZPN", + Items: []site.Competition{{Name: "IV liga 2025/2026, grupa: dolnoslaska", URL: "http://www.90minut.pl/liga/1/liga14169.html", LeagueKey: "liga14169"}}, + }, + } + m := bootstrapLeagueLoadedModel(t, loader) + + m, _ = updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + m.competitionCursor = 1 + m, cmd := updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + m, _ = updateModelWithMsg(t, m, cmd()) + m, cmd = updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + m, _ = updateModelWithMsg(t, m, cmd()) + + if got := m.competitionTitle; got != "Ligi regionalne 2025/26 - Dolnoslaski ZPN" { + t.Fatalf("unexpected nested submenu title: %q", got) + } + + m, cmd = updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + if cmd != nil { + t.Fatalf("expected esc in submenu to pop without command") + } + if got := m.competitionTitle; got != "Ligi regionalne 2025/26" { + t.Fatalf("expected previous submenu title, got %q", got) + } + if len(m.competitions) != 1 || m.competitions[0].Name != "Dolnoslaski ZPN" { + t.Fatalf("expected previous submenu items restored, got %+v", m.competitions) + } +} + +func TestStaleCompetitionLoadErrorDoesNotOverwriteCurrentSelection(t *testing.T) { + loader := newRecordingLoader() + loader.competitions = []site.Competition{ + {Name: "Ekstraklasa", URL: "http://www.90minut.pl/liga/1/liga11233.html", LeagueKey: "liga11233"}, + {Name: "Ligi regionalne 2025/26", URL: "http://www.90minut.pl/ligireg.php?id_sezon=107", LeagueKey: "www.90minut.pl/ligireg.php?id_sezon=107"}, + } + m := bootstrapLeagueLoadedModel(t, loader) + + m, _ = updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + m.competitionCursor = 1 + staleKey := competitionRequestKey(m.competitions[m.competitionCursor]) + m.competitionCursor = 0 + currentKey := competitionRequestKey(m.competitions[m.competitionCursor]) + + m, cmd := updateModelWithMsg(t, m, competitionMenuLoadedMsg{competitionKey: staleKey, err: errors.New("stale submenu failed")}) + if cmd != nil { + t.Fatalf("expected stale async error to be ignored") + } + if m.err != "" { + t.Fatalf("expected stale error to be ignored, got %q", m.err) + } + + m, cmd = updateModelWithMsg(t, m, competitionMenuLoadedMsg{competitionKey: currentKey, err: errors.New("current submenu failed")}) + if cmd != nil { + t.Fatalf("expected current async error to settle without command") + } + if m.err != "current submenu failed" { + t.Fatalf("expected current error to be shown, got %q", m.err) + } +} + func TestFixtureEnterLoadsMatchWithoutReloadingLeague(t *testing.T) { loader := newRecordingLoader() m := bootstrapLeagueLoadedModel(t, loader) @@ -116,6 +300,83 @@ func TestFixtureEnterLoadsMatchWithoutReloadingLeague(t *testing.T) { } } +func TestFixtureEnterKeepsLeagueViewWhenFixtureHasNoDetails(t *testing.T) { + loader := newRecordingLoader() + loader.league.Rounds[0].Fixtures[0].MatchURL = "" + loader.league.Rounds[0].Fixtures[0].MatchID = "" + m := bootstrapLeagueLoadedModel(t, loader) + m.roundCursor = 0 + m.fixtureCursor = 0 + + m, cmd := updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + t.Fatalf("expected no command for non-drillable fixture") + } + if m.matchView { + t.Fatalf("expected league view to stay open for non-drillable fixture") + } + if m.match != nil { + t.Fatalf("expected no match payload for non-drillable fixture") + } + if m.err != unavailableFixtureMatchDetailsMessage { + t.Fatalf("unexpected unavailable-details message: %q", m.err) + } + if loader.matchCalls != 0 { + t.Fatalf("expected no match loads, got %d", loader.matchCalls) + } +} + +func TestMatchViewNavigationFallsBackToLeagueWhenNextFixtureHasNoDetails(t *testing.T) { + loader := newRecordingLoader() + loader.league.Rounds[0].Fixtures[1].MatchURL = "" + loader.league.Rounds[0].Fixtures[1].MatchID = "" + m := bootstrapLeagueLoadedModel(t, loader) + m.roundCursor = 0 + m.fixtureCursor = 0 + + m, cmd := updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("expected match load command on enter") + } + m, _ = updateModelWithMsg(t, m, cmd()) + + m, cmd = updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyDown}) + if cmd != nil { + t.Fatalf("expected no load command for non-drillable fixture in match view") + } + if m.matchView { + t.Fatalf("expected navigation to return to league view for non-drillable fixture") + } + if m.match != nil { + t.Fatalf("expected stale match details to clear when fixture has no details") + } + if m.err != unavailableFixtureMatchDetailsMessage { + t.Fatalf("unexpected unavailable-details message: %q", m.err) + } + if loader.matchCalls != 1 { + t.Fatalf("expected no extra match load, got %d", loader.matchCalls) + } +} + +func TestFixtureEnterUsesFixtureSpecificMessageWhenLeagueHasOtherDetails(t *testing.T) { + loader := newRecordingLoader() + loader.league.Rounds[0].Fixtures[0].MatchURL = "" + loader.league.Rounds[0].Fixtures[0].MatchID = "" + loader.league.Rounds[0].Fixtures[1].MatchURL = "http://www.90minut.pl/mecz.php?id_mecz=2" + loader.league.Rounds[0].Fixtures[1].MatchID = "2" + m := bootstrapLeagueLoadedModel(t, loader) + m.roundCursor = 0 + m.fixtureCursor = 0 + + m, cmd := updateModelWithMsg(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + t.Fatalf("expected no command for non-drillable fixture") + } + if m.err != unavailableFixtureMatchDetailsMessage { + t.Fatalf("unexpected fixture-specific message: %q", m.err) + } +} + func TestMatchViewNavigationLoadsAdjacentFixture(t *testing.T) { loader := newRecordingLoader() m := bootstrapLeagueLoadedModel(t, loader) @@ -332,6 +593,7 @@ func newRecordingLoader() *recordingLoader { Current: true, }}, selectedIdx: 0, + menus: map[string]*site.CompetitionMenu{}, competitions: []site.Competition{{ Name: "Ekstraklasa", URL: "http://www.90minut.pl/liga/1/liga11233.html", diff --git a/internal/ui/model.go b/internal/ui/model.go index e2427a6..17d8a8c 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -2,6 +2,7 @@ package ui import ( "context" + "regexp" "strings" "time" @@ -16,8 +17,14 @@ const ( focusFixtures ) +const unavailableCompetitionMatchDetailsMessage = "Match details unavailable for this competition" +const unavailableFixtureMatchDetailsMessage = "Match details unavailable for this fixture" + +var fixtureResultRe = regexp.MustCompile(`^\d+\s*-\s*\d+$`) + type archiveLoader interface { LoadArchive(ctx context.Context, archiveURL string) ([]site.Season, int, []site.Competition, error) + LoadCompetition(ctx context.Context, competitionURL string) (*site.CompetitionMenu, *site.LeaguePage, error) LoadLeague(ctx context.Context, leagueURL string) (*site.LeaguePage, error) LoadMatch(ctx context.Context, matchURL string) (*site.MatchPage, error) } @@ -35,6 +42,19 @@ type competitionsLoadedMsg struct { err error } +type competitionMenuLoadedMsg struct { + competitionKey string + menu *site.CompetitionMenu + league *site.LeaguePage + err error +} + +type competitionMenuState struct { + title string + items []site.Competition + cursor int +} + type leagueLoadedMsg struct { competitionKey string league *site.LeaguePage @@ -62,6 +82,8 @@ type Model struct { competitions []site.Competition competitionCursor int + competitionTitle string + competitionStack []competitionMenuState league *site.LeaguePage roundCursor int @@ -129,6 +151,36 @@ func (m Model) currentCompetition() *site.Competition { return &m.competitions[m.competitionCursor] } +func (m *Model) resetCompetitionMenu(title string, items []site.Competition) { + m.competitionTitle = title + m.competitions = items + m.competitionCursor = m.preferredCompetitionIndex() + m.competitionStack = nil +} + +func (m *Model) pushCompetitionMenu(title string, items []site.Competition) { + m.competitionStack = append(m.competitionStack, competitionMenuState{ + title: m.competitionTitle, + items: append([]site.Competition(nil), m.competitions...), + cursor: m.competitionCursor, + }) + m.competitionTitle = title + m.competitions = items + m.competitionCursor = 0 +} + +func (m *Model) popCompetitionMenu() bool { + if len(m.competitionStack) == 0 { + return false + } + prev := m.competitionStack[len(m.competitionStack)-1] + m.competitionStack = m.competitionStack[:len(m.competitionStack)-1] + m.competitionTitle = prev.title + m.competitions = prev.items + m.competitionCursor = clamp(prev.cursor, 0, len(prev.items)-1) + return true +} + func (m Model) currentFixture() *site.Fixture { round := m.currentRound() if round == nil || len(round.Fixtures) == 0 { @@ -140,6 +192,66 @@ func (m Model) currentFixture() *site.Fixture { return &round.Fixtures[m.fixtureCursor] } +func (m Model) currentFixtureDrillable() bool { + fixture := m.currentFixture() + return fixture != nil && strings.TrimSpace(fixture.MatchURL) != "" +} + +func (m Model) unavailableMatchDetailsMessage() string { + if m.leagueHasDrillableFixtures() { + return unavailableFixtureMatchDetailsMessage + } + return unavailableCompetitionMatchDetailsMessage +} + +func (m Model) initialFixtureSelection() (int, int) { + if m.league == nil || len(m.league.Rounds) == 0 { + return 0, 0 + } + + if m.leagueHasDrillableFixtures() { + for roundIdx := len(m.league.Rounds) - 1; roundIdx >= 0; roundIdx-- { + fixtures := m.league.Rounds[roundIdx].Fixtures + for fixtureIdx := len(fixtures) - 1; fixtureIdx >= 0; fixtureIdx-- { + if strings.TrimSpace(fixtures[fixtureIdx].MatchURL) == "" { + continue + } + return roundIdx, fixtureIdx + } + } + } + + for roundIdx := len(m.league.Rounds) - 1; roundIdx >= 0; roundIdx-- { + fixtures := m.league.Rounds[roundIdx].Fixtures + for fixtureIdx := len(fixtures) - 1; fixtureIdx >= 0; fixtureIdx-- { + if !fixtureHasResult(fixtures[fixtureIdx]) { + continue + } + return roundIdx, fixtureIdx + } + } + + return clamp(len(m.league.Rounds)-1, 0, len(m.league.Rounds)-1), 0 +} + +func (m Model) leagueHasDrillableFixtures() bool { + if m.league == nil { + return false + } + for _, round := range m.league.Rounds { + for _, fixture := range round.Fixtures { + if strings.TrimSpace(fixture.MatchURL) != "" { + return true + } + } + } + return false +} + +func fixtureHasResult(fixture site.Fixture) bool { + return fixtureResultRe.MatchString(strings.TrimSpace(fixture.Score)) +} + func (m Model) preferredCompetitionIndex() int { if len(m.competitions) == 0 { return 0 diff --git a/internal/ui/render_helpers.go b/internal/ui/render_helpers.go index 53a475e..5fa824c 100644 --- a/internal/ui/render_helpers.go +++ b/internal/ui/render_helpers.go @@ -101,7 +101,12 @@ func renderFixtureWindow(fixtures []site.Fixture, cursor, maxItems, width int, c prefix = "> " } - line := prefix + fixtureLine(&fixtures[i], width-len([]rune(prefix)), whenWidth, compact) + lineWidth := width - len([]rune(prefix)) + suffix := fixtureAvailabilitySuffix(&fixtures[i], lineWidth, compact) + line := prefix + fixtureLine(&fixtures[i], lineWidth-ansi.StringWidth(suffix), whenWidth, compact) + if suffix != "" { + line += suffix + } if whenInfo := formatFixtureWhenInfo(fixtures[i].WhenInfo); whenInfo != "" { line += " | " + whenInfo } @@ -1210,6 +1215,23 @@ func fixtureLine(fixture *site.Fixture, width, whenWidth int, compact bool) stri return home + " " + score + " " + away } +func fixtureAvailabilitySuffix(fixture *site.Fixture, width int, compact bool) string { + if fixture == nil || strings.TrimSpace(fixture.MatchURL) != "" { + return "" + } + + suffix := " [no details]" + minWidth := 64 + if compact { + minWidth = 40 + } + if width < minWidth+ansi.StringWidth(suffix) { + return "" + } + + return suffix +} + func normalizeScore(score string) string { trimmed := strings.TrimSpace(score) if trimmed == "" { diff --git a/internal/ui/update.go b/internal/ui/update.go index 23fce55..04d9ccd 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -74,7 +74,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.league != nil { m.err = "" - if m.selectorVisible { + if m.selectorVisible && m.popCompetitionMenu() { + m.focus = focusCompetitions + } else if m.selectorVisible { m.closeSelector() } else { m.openSelector() @@ -96,8 +98,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lastFetchAt = time.Now() m.seasons = msg.seasons m.seasonCursor = clamp(msg.selectedIdx, 0, len(m.seasons)-1) - m.competitions = msg.competitions - m.competitionCursor = m.preferredCompetitionIndex() + m.resetCompetitionMenu("Competitions", msg.competitions) if len(m.competitions) == 0 { return m, nil @@ -109,7 +110,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = false return m, nil } - return m, m.loadLeagueCmd(competition.URL, competitionRequestKey(*competition)) + return m, m.loadCompetitionCmd(competition.URL, competitionRequestKey(*competition)) case competitionsLoadedMsg: m.loading = false @@ -126,8 +127,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = "" m.lastFetchAt = time.Now() - m.competitions = msg.competitions - m.competitionCursor = m.preferredCompetitionIndex() + m.resetCompetitionMenu("Competitions", msg.competitions) m.matchView = false m.match = nil m.matchScroll = 0 @@ -142,7 +142,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = false return m, nil } - return m, m.loadLeagueCmd(competition.URL, competitionRequestKey(*competition)) + return m, m.loadCompetitionCmd(competition.URL, competitionRequestKey(*competition)) + + case competitionMenuLoadedMsg: + m.loading = false + competition := m.currentCompetition() + if competition == nil || msg.competitionKey != competitionRequestKey(*competition) { + return m, nil + } + + if msg.err != nil { + m.err = msg.err.Error() + return m, nil + } + + m.err = "" + m.lastFetchAt = time.Now() + if msg.menu != nil { + m.pushCompetitionMenu(msg.menu.Title, msg.menu.Items) + m.matchView = false + m.match = nil + m.matchScroll = 0 + m.openSelector() + m.focus = focusCompetitions + return m, nil + } + if msg.league == nil { + m.err = "competition parse: empty result" + return m, nil + } + + m.matchView = false + m.match = nil + m.matchScroll = 0 + m.league = msg.league + m.roundCursor, m.fixtureCursor = m.initialFixtureSelection() + m.closeSelector() + return m, nil case leagueLoadedMsg: m.loading = false @@ -163,8 +199,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.match = nil m.matchScroll = 0 m.league = msg.league - m.roundCursor = clamp(len(msg.league.Rounds)-1, 0, len(msg.league.Rounds)-1) - m.fixtureCursor = 0 + m.roundCursor, m.fixtureCursor = m.initialFixtureSelection() m.closeSelector() return m, nil @@ -304,7 +339,7 @@ func (m *Model) handleEnter() tea.Cmd { m.loading = false return nil } - return m.loadLeagueCmd(competition.URL, competitionRequestKey(*competition)) + return m.loadCompetitionCmd(competition.URL, competitionRequestKey(*competition)) } m.loading = false @@ -316,6 +351,14 @@ func (m *Model) handleEnter() tea.Cmd { m.loading = false return nil } + if !m.currentFixtureDrillable() { + m.loading = false + m.matchView = false + m.match = nil + m.matchScroll = 0 + m.err = m.unavailableMatchDetailsMessage() + return nil + } m.matchView = true m.match = nil m.matchScroll = 0 @@ -332,6 +375,14 @@ func (m *Model) handleReload() tea.Cmd { m.loading = false return nil } + if !m.currentFixtureDrillable() { + m.loading = false + m.matchView = false + m.match = nil + m.matchScroll = 0 + m.err = m.unavailableMatchDetailsMessage() + return nil + } m.match = nil m.matchScroll = 0 return m.loadMatchCmd(fixture.MatchURL, fixtureRequestKey(*fixture)) @@ -362,7 +413,7 @@ func (m *Model) handleReload() tea.Cmd { } m.matchView = false - return m.loadLeagueCmd(competition.URL, competitionRequestKey(*competition)) + return m.loadCompetitionCmd(competition.URL, competitionRequestKey(*competition)) } func (m *Model) scrollMatch(delta int) { @@ -389,6 +440,14 @@ func (m *Model) loadCurrentMatch() tea.Cmd { m.matchScroll = 0 return nil } + if !m.currentFixtureDrillable() { + m.loading = false + m.err = m.unavailableMatchDetailsMessage() + m.matchView = false + m.match = nil + m.matchScroll = 0 + return nil + } m.loading = true m.err = "" diff --git a/internal/ui/view.go b/internal/ui/view.go index c141a28..f3462ea 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -87,7 +87,11 @@ func (m Model) selectorPopupView(width int) string { b.WriteString(title.Render("Season + league")) b.WriteString("\n\n") left := selectorPaneView(leftWidth, "Season", m.focus == focusSeasons, seasonLines, title, focusStyle) - right := selectorPaneView(rightWidth, "Leagues", m.focus == focusCompetitions, renderCompetitionWindow(m.competitions, m.competitionCursor), title, focusStyle) + rightHeading := "Leagues" + if strings.TrimSpace(m.competitionTitle) != "" { + rightHeading = m.competitionTitle + } + right := selectorPaneView(rightWidth, rightHeading, m.focus == focusCompetitions, renderCompetitionWindow(m.competitions, m.competitionCursor), title, focusStyle) b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, left, right)) if m.loading { @@ -418,7 +422,11 @@ func (m Model) matchDetailPaneView(width int) string { } func (m Model) statusBarView() string { - parts := []string{"j/k: move", "left/right: round", "enter: open", "esc: selector", "q: quit"} + enterHint := "enter: unavailable" + if m.currentFixtureDrillable() { + enterHint = "enter: details" + } + parts := []string{"j/k: move", "left/right: round", enterHint, "esc: selector", "q: quit"} if m.matchView { parts = []string{"h/l: round", "j/k: fixture", "pgup/pgdn: scroll", "ctrl+u/d: scroll", "esc: league", "r: reload", "q: quit"} } @@ -427,6 +435,9 @@ func (m Model) statusBarView() string { if m.league != nil { parts = []string{"tab: focus", "j/k: move", "enter: load", "esc: close", "q: quit"} } + if len(m.competitionStack) > 0 { + parts = []string{"tab: focus", "j/k: move", "enter: open", "esc: back", "q: quit"} + } } status := strings.Join(parts, " ") + " | fetched: " + formatFetchTime(m.lastFetchAt) diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index d7d333a..8ea2806 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -798,6 +798,7 @@ func TestRenderFixtureWindowUsesFullNamesOutsideMatchSidebar(t *testing.T) { Away: "Lech Poznan", Score: "2-1", WhenInfo: "24 stycznia, 20:30 (16 580)", + MatchURL: "http://www.90minut.pl/mecz.php?id_mecz=1", }}, 0, 5, 80, false) if len(lines) != 1 || !strings.Contains(lines[0], "Legia Warszawa") || !strings.Contains(lines[0], "Lech Poznan") || !strings.Contains(lines[0], "| 24/01 20:30") { @@ -811,6 +812,7 @@ func TestRenderFixtureWindowUsesCompactNamesInMatchSidebar(t *testing.T) { Away: "Lech Poznan", Score: "2-1", WhenInfo: "24 stycznia, 20:30 (16 580)", + MatchURL: "http://www.90minut.pl/mecz.php?id_mecz=1", }}, 0, 5, 40, true) if len(lines) != 1 || !strings.Contains(lines[0], "LEG 2-1 LEC | 24/01 20:30") { @@ -818,10 +820,40 @@ func TestRenderFixtureWindowUsesCompactNamesInMatchSidebar(t *testing.T) { } } +func TestRenderFixtureWindowMarksNonDrillableFixturesWhenSpaceAllows(t *testing.T) { + lines := renderFixtureWindow([]site.Fixture{{ + Home: "Legia Warszawa", + Away: "Lech Poznan", + Score: "-", + WhenInfo: "24 stycznia, 20:30", + }}, 0, 5, 120, false) + + if len(lines) != 1 || !strings.Contains(lines[0], "[no details]") { + t.Fatalf("expected non-drillable marker, got %v", lines) + } +} + +func TestStatusBarViewReflectsFixtureDrillability(t *testing.T) { + m := sketchModel() + m.width = 120 + + status := m.statusBarView() + if !strings.Contains(status, "enter: details") { + t.Fatalf("expected drillable status hint, got %q", status) + } + + m.league.Rounds[0].Fixtures[0].MatchURL = "" + m.league.Rounds[0].Fixtures[0].MatchID = "" + status = m.statusBarView() + if !strings.Contains(status, "enter: unavailable") { + t.Fatalf("expected non-drillable status hint, got %q", status) + } +} + func TestRenderFixtureWindowAlignsFullFixtureColumns(t *testing.T) { fixtures := []site.Fixture{ - {Home: "Bruk-Bet Termalica Nieciecza", Away: "Motor Lublin", Score: "1-2", WhenInfo: "13 marca, 18:00 (3542)"}, - {Home: "Jagiellonia Bialystok", Away: "Piast Gliwice", Score: "1-2", WhenInfo: "14 marca, 14:45 (16 580)"}, + {Home: "Bruk-Bet Termalica Nieciecza", Away: "Motor Lublin", Score: "1-2", WhenInfo: "13 marca, 18:00 (3542)", MatchURL: "http://www.90minut.pl/mecz.php?id_mecz=1"}, + {Home: "Jagiellonia Bialystok", Away: "Piast Gliwice", Score: "1-2", WhenInfo: "14 marca, 14:45 (16 580)", MatchURL: "http://www.90minut.pl/mecz.php?id_mecz=2"}, } lines := renderFixtureWindow(fixtures, 0, 5, 84, false) if len(lines) != 2 {