Skip to content

Commit 8a79209

Browse files
authored
Merge pull request #21 from 0xjuanma/live-view-improvements
feat[live]: goal notifications, improved live render and design
2 parents bd8dc59 + 1058a6d commit 8a79209

File tree

18 files changed

+716
-39
lines changed

18 files changed

+716
-39
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **Goal Notifications** - Desktop notifications and terminal beep for new goals in live matches using score-based detection (macOS, Linux, Windows)
1112

1213
### Changed
14+
- **Poll Spinner Duration** - Increased "Updating..." spinner display time to 1 second for better visibility
1315

1416
### Fixed
17+
- **Card Colors in All Events** - Yellow and red cards now display proper colors (yellow/red) instead of cyan in the FT view's All Events section
18+
- **Live Match Polling** - Poll refreshes now bypass cache to ensure fresh data every 90 seconds
19+
- **Substitution Display** - Fixed inverted player order & colour coding in substitutions
1520

1621
## [0.5.0] - 2025-12-25
1722

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,35 @@ golazo
5555
Many leagues and competitions across Europe, South America, North America, Middle East, and more. [View full list](docs/SUPPORTED_LEAGUES.md)
5656

5757
Customize your leagues and competitions preferences in the **Settings** menu.
58+
59+
## Notification Setup
60+
61+
Goal notifications require one-time setup depending on your operating system.
62+
63+
### macOS
64+
65+
Notifications use AppleScript, which requires enabling notifications for Script Editor:
66+
67+
1. Open **Script Editor** (`/Applications/Utilities/Script Editor.app`)
68+
2. Paste and run: `display notification "test" with title "test"`
69+
3. Open **System Settings → Notifications → Script Editor**
70+
4. Enable/Allow notifications and set alert style to "Banners"
71+
72+
### Linux
73+
74+
Notifications require `libnotify`. Install if not present:
75+
76+
```bash
77+
# Debian/Ubuntu
78+
sudo apt install libnotify-bin
79+
80+
# Fedora
81+
sudo dnf install libnotify
82+
83+
# Arch
84+
sudo pacman -S libnotify
85+
```
86+
87+
### Windows
88+
89+
Notifications should work out-of-box on Windows 10/11.

go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/charmbracelet/bubbles v0.21.0
77
github.com/charmbracelet/bubbletea v1.3.4
88
github.com/charmbracelet/lipgloss v1.1.0
9+
github.com/gen2brain/beeep v0.11.2
910
github.com/goforj/godump v1.9.0
1011
github.com/lucasb-eyer/go-colorful v1.2.0
1112
github.com/spf13/cobra v1.8.0
@@ -14,6 +15,7 @@ require (
1415
)
1516

1617
require (
18+
git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
1719
github.com/atotto/clipboard v0.1.4 // indirect
1820
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1921
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
@@ -22,16 +24,24 @@ require (
2224
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
2325
github.com/charmbracelet/x/term v0.2.1 // indirect
2426
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
27+
github.com/esiqveland/notify v0.13.3 // indirect
28+
github.com/go-ole/go-ole v1.3.0 // indirect
29+
github.com/godbus/dbus/v5 v5.1.0 // indirect
2530
github.com/inconshreveable/mousetrap v1.1.0 // indirect
31+
github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
2632
github.com/mattn/go-isatty v0.0.20 // indirect
2733
github.com/mattn/go-localereader v0.0.1 // indirect
2834
github.com/mattn/go-runewidth v0.0.16 // indirect
2935
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3036
github.com/muesli/cancelreader v0.2.2 // indirect
3137
github.com/muesli/termenv v0.16.0 // indirect
38+
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
3239
github.com/rivo/uniseg v0.4.7 // indirect
3340
github.com/sahilm/fuzzy v0.1.1 // indirect
41+
github.com/sergeymakinen/go-bmp v1.0.0 // indirect
42+
github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
3443
github.com/spf13/pflag v1.0.5 // indirect
44+
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
3545
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3646
golang.org/x/sync v0.11.0 // indirect
3747
golang.org/x/sys v0.39.0 // indirect

go.sum

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
2+
git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
13
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
24
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
35
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -23,14 +25,25 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod
2325
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
2426
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
2527
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
28+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2629
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2730
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2831
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
2932
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
33+
github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
34+
github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
35+
github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA=
36+
github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
37+
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
38+
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
39+
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
40+
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
3041
github.com/goforj/godump v1.9.0 h1:Y/APfWKQKnJetXgVJxDqD7vEpTGSgAwbKJGmj0UAteI=
3142
github.com/goforj/godump v1.9.0/go.mod h1:/Vy+p50JtOkwsFN5dA1HQ7LS5gtPk3f61DaP4UR2o4s=
3243
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3344
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
45+
github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o=
46+
github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
3447
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
3548
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
3649
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -47,6 +60,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
4760
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
4861
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
4962
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
63+
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
64+
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
5065
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
5166
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5267
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -55,19 +70,32 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
5570
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
5671
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
5772
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
73+
github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
74+
github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
75+
github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
76+
github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
5877
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
5978
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
6079
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
6180
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
81+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
82+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
83+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
84+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
85+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
86+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
6287
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
6388
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
89+
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
90+
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
6491
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
6592
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
6693
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
6794
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
6895
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
6996
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
7097
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
98+
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
7199
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
72100
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
73101
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
@@ -77,5 +105,6 @@ golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
77105
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
78106
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
79107
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
108+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
80109
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
81110
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/app/commands.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func schedulePollTick(matchID int) tea.Cmd {
169169
}
170170

171171
// PollSpinnerDuration is how long to show the "Updating..." spinner.
172-
const PollSpinnerDuration = 500 * time.Millisecond
172+
const PollSpinnerDuration = 1 * time.Second
173173

174174
// schedulePollSpinnerHide schedules hiding the spinner after the display duration.
175175
func schedulePollSpinnerHide() tea.Cmd {
@@ -180,6 +180,7 @@ func schedulePollSpinnerHide() tea.Cmd {
180180

181181
// fetchPollMatchDetails fetches match details for a poll refresh.
182182
// This is called when pollTickMsg is received, with loading state visible.
183+
// Uses force refresh to bypass cache and ensure fresh data for live matches.
183184
func fetchPollMatchDetails(client *fotmob.Client, matchID int, useMockData bool) tea.Cmd {
184185
return func() tea.Msg {
185186
if useMockData {
@@ -190,7 +191,8 @@ func fetchPollMatchDetails(client *fotmob.Client, matchID int, useMockData bool)
190191
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
191192
defer cancel()
192193

193-
details, err := client.MatchDetails(ctx, matchID)
194+
// Force refresh to bypass cache - live matches need fresh data
195+
details, err := client.MatchDetailsForceRefresh(ctx, matchID)
194196
if err != nil {
195197
return matchDetailsMsg{details: nil}
196198
}

internal/app/handlers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
4242
m.matchDetails = nil
4343
m.liveUpdates = nil
4444
m.lastEvents = nil
45+
m.lastHomeScore = 0
46+
m.lastAwayScore = 0
4547
m.polling = false
4648
m.upcomingMatchesList.SetItems([]list.Item{})
4749
m.matchDetailsCache = make(map[int]*api.MatchDetails)
@@ -150,6 +152,8 @@ func (m model) handleStatsViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
150152
func (m model) loadMatchDetails(matchID int) (tea.Model, tea.Cmd) {
151153
m.liveUpdates = nil
152154
m.lastEvents = nil
155+
m.lastHomeScore = 0
156+
m.lastAwayScore = 0
153157
m.loading = true
154158
m.liveViewLoading = true
155159
m.polling = false // Reset polling state - this is a new match load, not a poll refresh

internal/app/model.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package app
44
import (
55
"github.com/0xjuanma/golazo/internal/api"
66
"github.com/0xjuanma/golazo/internal/fotmob"
7+
"github.com/0xjuanma/golazo/internal/notify"
78
"github.com/0xjuanma/golazo/internal/ui"
89
"github.com/charmbracelet/bubbles/list"
910
"github.com/charmbracelet/bubbles/spinner"
@@ -38,6 +39,8 @@ type model struct {
3839
matchDetailsCache map[int]*api.MatchDetails // Cache to avoid repeated API calls
3940
liveUpdates []string
4041
lastEvents []api.MatchEvent
42+
lastHomeScore int // Track last known home score for goal notifications
43+
lastAwayScore int // Track last known away score for goal notifications
4144

4245
// Stats data cache - stores 5 days of data, filtered client-side for Today/3d/5d views
4346
statsData *fotmob.StatsData
@@ -80,6 +83,9 @@ type model struct {
8083
// API clients
8184
fotmobClient *fotmob.Client
8285
parser *fotmob.LiveUpdateParser
86+
87+
// Notifications
88+
notifier *notify.DesktopNotifier
8389
}
8490

8591
// New creates a new application model with default values.
@@ -141,6 +147,7 @@ func New(useMockData bool) model {
141147
useMockData: useMockData,
142148
fotmobClient: fotmob.NewClient(),
143149
parser: fotmob.NewLiveUpdateParser(),
150+
notifier: notify.NewDesktopNotifier(),
144151
spinner: s,
145152
randomSpinner: randomSpinner,
146153
statsViewSpinner: statsViewSpinner,

internal/app/update.go

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package app
22

33
import (
4+
"strings"
45
"time"
56

67
"github.com/0xjuanma/golazo/internal/api"
@@ -177,6 +178,27 @@ func (m model) handleMatchDetails(msg matchDetailsMsg) (tea.Model, tea.Cmd) {
177178
if m.currentView == viewLiveMatches || m.pendingSelection == 1 {
178179
m.liveViewLoading = false
179180

181+
// Get current scores
182+
homeScore := 0
183+
awayScore := 0
184+
if msg.details.HomeScore != nil {
185+
homeScore = *msg.details.HomeScore
186+
}
187+
if msg.details.AwayScore != nil {
188+
awayScore = *msg.details.AwayScore
189+
}
190+
191+
// Detect new goals during poll refresh (not initial load)
192+
// Only notify when: polling is active AND we have previous score data
193+
hasScoreData := m.lastHomeScore > 0 || m.lastAwayScore > 0 || len(m.lastEvents) > 0
194+
if m.polling && hasScoreData {
195+
m.notifyNewGoals(msg.details)
196+
}
197+
198+
// Update tracked scores for next comparison
199+
m.lastHomeScore = homeScore
200+
m.lastAwayScore = awayScore
201+
180202
// Parse ALL events to rebuild the live updates list
181203
// This ensures proper ordering (descending by minute) and uniqueness
182204
m.liveUpdates = m.parser.ParseEvents(msg.details.Events, msg.details.HomeTeam, msg.details.AwayTeam)
@@ -185,11 +207,11 @@ func (m model) handleMatchDetails(msg matchDetailsMsg) (tea.Model, tea.Cmd) {
185207
// Continue polling if match is live
186208
if msg.details.Status == api.MatchStatusLive {
187209
// For initial load, clear loading state
188-
// For poll refresh, loading is cleared by 0.5s timer (pollDisplayCompleteMsg)
210+
// For poll refresh, loading is cleared by 1s timer (pollDisplayCompleteMsg)
189211
if !m.polling {
190212
m.loading = false
191213
}
192-
// Note: if m.polling is true, m.loading stays true until the 0.5s timer fires
214+
// Note: if m.polling is true, m.loading stays true until the 1s timer fires
193215

194216
m.polling = true
195217
// Schedule next poll tick (90 seconds from now)
@@ -264,6 +286,8 @@ func (m model) resetToMainView() (tea.Model, tea.Cmd) {
264286
m.matchDetailsCache = make(map[int]*api.MatchDetails)
265287
m.liveUpdates = nil
266288
m.lastEvents = nil
289+
m.lastHomeScore = 0
290+
m.lastAwayScore = 0
267291
m.loading = false
268292
m.polling = false
269293
m.matches = nil
@@ -811,7 +835,7 @@ func (m model) handleMainViewCheck(msg mainViewCheckMsg) (tea.Model, tea.Cmd) {
811835
}
812836

813837
// handlePollTick handles the 90-second poll tick.
814-
// Shows "Updating..." spinner for 0.5s as visual feedback, then fetches data.
838+
// Shows "Updating..." spinner for 1s as visual feedback, then fetches data.
815839
func (m model) handlePollTick(msg pollTickMsg) (tea.Model, tea.Cmd) {
816840
// Only process if we're still in live view and polling is active
817841
if m.currentView != viewLiveMatches || !m.polling {
@@ -826,17 +850,17 @@ func (m model) handlePollTick(msg pollTickMsg) (tea.Model, tea.Cmd) {
826850
// Set loading state to show "Updating..." spinner
827851
m.loading = true
828852

829-
// Start the actual API call, spinner animation, and 0.5s display timer
853+
// Start the actual API call, spinner animation, and 1s display timer
830854
return m, tea.Batch(
831855
fetchPollMatchDetails(m.fotmobClient, msg.matchID, m.useMockData),
832856
ui.SpinnerTick(),
833857
schedulePollSpinnerHide(), // Hide spinner after 0.5 seconds
834858
)
835859
}
836860

837-
// handlePollDisplayComplete hides the spinner after 0.5s display time.
861+
// handlePollDisplayComplete hides the spinner after 1s display time.
838862
func (m model) handlePollDisplayComplete() (tea.Model, tea.Cmd) {
839-
// Hide spinner - the 0.5s visual feedback is complete
863+
// Hide spinner - the 1s visual feedback is complete
840864
m.loading = false
841865
return m, nil
842866
}
@@ -867,6 +891,55 @@ func (m model) handleFilterMatches(msg list.FilterMatchesMsg) (tea.Model, tea.Cm
867891
return m, cmd
868892
}
869893

894+
// notifyNewGoals sends desktop notifications when a goal is scored.
895+
// Uses score-based detection (more reliable than event ID comparison).
896+
// Only called during poll refreshes when we have previous score data.
897+
func (m *model) notifyNewGoals(details *api.MatchDetails) {
898+
if m.notifier == nil || details == nil {
899+
return
900+
}
901+
902+
// Get current scores
903+
homeScore := 0
904+
awayScore := 0
905+
if details.HomeScore != nil {
906+
homeScore = *details.HomeScore
907+
}
908+
if details.AwayScore != nil {
909+
awayScore = *details.AwayScore
910+
}
911+
912+
// Check if score increased (goal scored)
913+
homeGoalScored := homeScore > m.lastHomeScore
914+
awayGoalScored := awayScore > m.lastAwayScore
915+
916+
if !homeGoalScored && !awayGoalScored {
917+
return
918+
}
919+
920+
// Find the most recent goal event to get player details
921+
var goalEvent *api.MatchEvent
922+
for i := len(details.Events) - 1; i >= 0; i-- {
923+
event := details.Events[i]
924+
if strings.ToLower(event.Type) == "goal" {
925+
// Check if this goal matches the team that scored
926+
if homeGoalScored && event.Team.ID == details.HomeTeam.ID {
927+
goalEvent = &event
928+
break
929+
}
930+
if awayGoalScored && event.Team.ID == details.AwayTeam.ID {
931+
goalEvent = &event
932+
break
933+
}
934+
}
935+
}
936+
937+
if goalEvent != nil {
938+
// Send notification - errors are silently ignored to not disrupt the app
939+
_ = m.notifier.Goal(*goalEvent, details.HomeTeam, details.AwayTeam, homeScore, awayScore)
940+
}
941+
}
942+
870943
// max returns the larger of two integers.
871944
func max(a, b int) int {
872945
if a > b {

0 commit comments

Comments
 (0)