▍ humdrum codex / sportsball
license AGPL-3.0
11.2 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
package ui

import (
	"sort"
	"time"

	"github.com/humdrum-tiv/sportsball/internal/model"
)

// section is a titled group of games in the dashboard. The dashboard renders
// sections in order; the global cursor indexes their flattened concatenation.
// level selects the header style: a normal section title, a date divider, or
// an indented league subheader nested under a date (used by filtered views).
type section struct {
	title string
	color string // accent hex (no '#')
	games []model.Game
	level headerLevel
}

type headerLevel int

const (
	lvlNormal headerLevel = iota
	lvlDate               // "# June 16" — date divider, no games of its own
	lvlLeague             // "## WC" — indented league subheader under a date
)

// gameFilter narrows the dashboard slate by game state so the user can see just
// live games, just recent finals, or just upcoming fixtures without scrolling.
type gameFilter int

const (
	filterAllStates gameFilter = iota
	filterLive
	filterRecent
	filterUpcoming
)

func (f gameFilter) String() string {
	switch f {
	case filterLive:
		return "Live"
	case filterRecent:
		return "Recent"
	case filterUpcoming:
		return "Upcoming"
	default:
		return "All"
	}
}

// sections builds the dashboard's groups for the current filter:
//
//   - All leagues: one section per league, today's games only — keeps the
//     overview scannable.
//   - Single league: Past / Today / Upcoming buckets across the fetched
//     window, so you can see recent finals and what's next.
//
// When a state filter (Live/Recent/Upcoming) is active, the slate is narrowed
// to that state across the fetched window and favorites are not pinned — the
// view is purely "just the live games", etc.
func (a App) sections() []section {
	now := time.Now()
	if a.stateFilter != filterAllStates {
		return a.filteredSections(now)
	}
	if a.filter == "" {
		var out []section
		// Favorites pinned on top: today's games (any league) with a favorite.
		var fav []model.Game
		favSeen := map[string]bool{}
		for _, l := range a.leagues {
			for _, g := range filterDay(a.games[l.ID], now) {
				if a.hasFav(g) {
					fav = append(fav, g)
					favSeen[g.ID] = true
				}
			}
		}
		if len(fav) > 0 {
			out = append(out, section{"★ Favorites", colWarnHex, fav, lvlNormal})
		}
		for _, l := range a.leagues {
			var today []model.Game
			for _, g := range filterDay(a.games[l.ID], now) {
				if !favSeen[g.ID] { // already shown in Favorites
					today = append(today, g)
				}
			}
			if len(today) > 0 {
				out = append(out, section{l.Icon + " " + l.Name, l.Color, today, lvlNormal})
			}
		}
		// Recent finals from before today (across leagues), newest first then
		// league order, so the all-leagues view ends with the latest scores.
		if rec := a.recentFinals(now); len(rec) > 0 {
			out = append(out, section{"◼ Recent", "", rec, lvlNormal})
		}
		return out
	}

	l, _ := model.LeagueByID(a.filter)
	past, today, upcoming := bucket(a.games[a.filter], now)
	var out []section
	// Favorites bucket: each favorite team's live + last final + next fixture
	// from the fetched window, pinned above the regular buckets.
	if fav := a.favoriteHighlights(a.filter, now); len(fav) > 0 {
		out = append(out, section{"★ Favorites", colWarnHex, fav, lvlNormal})
	}
	if len(today) > 0 {
		out = append(out, section{"● Today", l.Color, today, lvlNormal})
	}
	if len(upcoming) > 0 {
		out = append(out, section{"○ Upcoming", l.Color, upcoming, lvlNormal})
	}
	if len(past) > 0 {
		out = append(out, section{"◼ Past", l.Color, past, lvlNormal})
	}
	return out
}

// filteredSections builds the slate when a state filter is active: favorites
// matching the filter are pinned on top, then the rest as one flat list.
// All-leagues collapses per-league grouping into a single state-ordered list
// (live keeps client order; recent newest-first; upcoming chronological), with
// league order as the tiebreak so same-time games stay stable.
func (a App) filteredSections(now time.Time) []section {
	want, ok := stateForFilter(a.stateFilter)
	if !ok {
		return nil
	}
	var leagues []model.LeagueID
	if a.filter == "" {
		for _, l := range a.leagues {
			leagues = append(leagues, l.ID)
		}
	} else {
		leagues = []model.LeagueID{a.filter}
	}

	var favs, rest []model.Game
	for _, lid := range leagues {
		for _, g := range a.games[lid] {
			if g.State != want {
				continue
			}
			if a.hasFav(g) {
				favs = append(favs, g)
			} else {
				rest = append(rest, g)
			}
		}
	}

	// Most-recent-first for Recent; chronological for Upcoming and Live. Order
	// by day → league → time so each league's games on a day stay contiguous
	// (raw time order would interleave leagues with different kickoff times),
	// matching the date/league grouping below.
	idx := a.leagueIndex()
	desc := a.stateFilter == filterRecent
	sortByDayLeagueTime(favs, idx, desc)
	sortByDayLeagueTime(rest, idx, desc)

	var out []section
	if len(favs) > 0 {
		out = append(out, section{"★ Favorites", colWarnHex, favs, lvlNormal})
	}
	// Rest grouped under date dividers ("# June 16"), then league subheaders
	// ("## WC") within each date.
	out = append(out, dateLeagueSections(rest)...)
	return out
}

// sortByDayLeagueTime orders games by calendar day, then league order, then
// start time. desc=true puts the most recent day first and, within a league,
// the later game first (Recent); desc=false is chronological (Upcoming/Live).
// Keeping a league's games contiguous within a day is what lets
// dateLeagueSections emit one subheader per league per date.
func sortByDayLeagueTime(games []model.Game, idx map[model.LeagueID]int, desc bool) {
	sort.SliceStable(games, func(i, j int) bool {
		di, dj := dayKey(games[i].Start), dayKey(games[j].Start)
		if di != dj {
			if desc {
				return di > dj
			}
			return di < dj
		}
		if li, lj := idx[games[i].League], idx[games[j].League]; li != lj {
			return li < lj
		}
		if desc {
			return games[i].Start.After(games[j].Start)
		}
		return games[i].Start.Before(games[j].Start)
	})
}

// dateLeagueSections groups a date-then-league sorted slice into a date divider
// section (no games) followed by an indented league subheader section per
// league on that date. Games within a (date, league) run stay in input order.
func dateLeagueSections(games []model.Game) []section {
	var out []section
	i := 0
	for i < len(games) {
		d := dayKey(games[i].Start)
		out = append(out, section{games[i].Start.Local().Format("January 2"), "", nil, lvlDate})
		for i < len(games) && dayKey(games[i].Start) == d {
			lid := games[i].League
			l, _ := model.LeagueByID(lid)
			var gs []model.Game
			for i < len(games) && dayKey(games[i].Start) == d && games[i].League == lid {
				gs = append(gs, games[i])
				i++
			}
			out = append(out, section{l.Icon + " " + l.Name, l.Color, gs, lvlLeague})
		}
	}
	return out
}

// dayKey is a stable per-calendar-day key in local time for date grouping.
func dayKey(t time.Time) string { return t.Local().Format("2006-01-02") }

// stateForFilter maps a state filter to the game State it selects; ok=false for
// filterAllStates (which has no single target state).
func stateForFilter(f gameFilter) (model.State, bool) {
	switch f {
	case filterLive:
		return model.StateLive, true
	case filterRecent:
		return model.StateFinal, true
	case filterUpcoming:
		return model.StatePre, true
	default:
		return 0, false
	}
}

// sortByStartLeague orders games by start time then league order. desc=true
// puts the most recent first (finals/recent); desc=false is chronological.
func sortByStartLeague(games []model.Game, idx map[model.LeagueID]int, desc bool) {
	sort.SliceStable(games, func(i, j int) bool {
		if !games[i].Start.Equal(games[j].Start) {
			if desc {
				return games[i].Start.After(games[j].Start)
			}
			return games[i].Start.Before(games[j].Start)
		}
		return idx[games[i].League] < idx[games[j].League]
	})
}

// favoriteHighlights returns, for each favorited team in the league, the games
// worth pinning: any live game, the most recent final (last result), and the
// next scheduled fixture (next), drawn from the fetched window. Games are
// de-duplicated and ordered live → next → last.
func (a App) favoriteHighlights(league model.LeagueID, now time.Time) []model.Game {
	games := a.games[league]
	type pick struct{ live, next, last *model.Game }
	picks := map[string]*pick{}
	var order []string // stable team order (first-seen), avoids map-iteration jitter

	teamKeys := func(g model.Game) []string {
		var ks []string
		if a.isFav(g.League, g.Home) {
			ks = append(ks, favKey(g.League, g.Home))
		}
		if a.isFav(g.League, g.Away) {
			ks = append(ks, favKey(g.League, g.Away))
		}
		return ks
	}

	for i := range games {
		g := games[i]
		for _, k := range teamKeys(g) {
			p := picks[k]
			if p == nil {
				p = &pick{}
				picks[k] = p
				order = append(order, k)
			}
			switch g.State {
			case model.StateLive:
				p.live = &games[i]
			case model.StatePre:
				if p.next == nil || g.Start.Before(p.next.Start) {
					p.next = &games[i]
				}
			case model.StateFinal:
				if p.last == nil || g.Start.After(p.last.Start) {
					p.last = &games[i]
				}
			}
		}
	}

	var out []model.Game
	seen := map[string]bool{}
	add := func(g *model.Game) {
		if g != nil && !seen[g.ID] {
			seen[g.ID] = true
			out = append(out, *g)
		}
	}
	for _, k := range order {
		p := picks[k]
		add(p.live)
		add(p.next)
		add(p.last)
	}
	return out
}

// recentFinals returns finals that completed before today (within the fetched
// window), most-recent-first then league order. Today's finals already appear
// in each league's section, so this surfaces yesterday-and-earlier scores.
func (a App) recentFinals(now time.Time) []model.Game {
	startToday := startOfDay(now)
	var out []model.Game
	for _, l := range a.leagues {
		for _, g := range a.games[l.ID] {
			if g.State == model.StateFinal && g.Start.Before(startToday) {
				out = append(out, g)
			}
		}
	}
	sortByStartLeague(out, a.leagueIndex(), true)
	return out
}

// visible is the cursor-addressable flat list, matching sections() order.
func (a App) visible() []model.Game {
	var out []model.Game
	for _, s := range a.sections() {
		out = append(out, s.games...)
	}
	return out
}

// bucket splits a league's window into past finals, today's slate, and future
// scheduled games. Today keeps the client's live→sched→final ordering;
// upcoming is chronological; past is reverse-chronological (most recent first).
func bucket(games []model.Game, now time.Time) (past, today, upcoming []model.Game) {
	startToday := startOfDay(now)
	startTomorrow := startToday.AddDate(0, 0, 1)
	for _, g := range games {
		switch {
		case !g.Start.Before(startTomorrow):
			upcoming = append(upcoming, g)
		case !g.Start.Before(startToday):
			today = append(today, g)
		default:
			past = append(past, g)
		}
	}
	// Reverse past so the most recent final leads.
	for i, j := 0, len(past)-1; i < j; i, j = i+1, j-1 {
		past[i], past[j] = past[j], past[i]
	}
	return past, today, upcoming
}

// filterDay returns only the games that start on now's calendar day.
func filterDay(games []model.Game, now time.Time) []model.Game {
	start := startOfDay(now)
	end := start.AddDate(0, 0, 1)
	var out []model.Game
	for _, g := range games {
		if !g.Start.Before(start) && g.Start.Before(end) {
			out = append(out, g)
		}
	}
	return out
}

func startOfDay(t time.Time) time.Time {
	y, m, d := t.Date()
	return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}