Add playoff round view, game numbers, goal scorer notifications, standings

- Fetch NHL standings and surface league/season game counts in the menu bar
- Prefix regular-season rows with the league-wide game number (from gameId)
- New ROUND section shows each active playoff series (matchup, series score,
  next game number + time) derived from /v1/playoff-bracket; rows always open
  the NHL series page so completed series remain clickable
- Goal notifications include scorer sweater, abbreviated name, and strength
  (PPG/SHG/EN), resolved via /v1/gamecenter/{id}/play-by-play
- Drop the per-team filter submenu and NHLTeam enum
- Regenerate AppIcon with the full 10-size macOS set (alpha preserved) so
  notifications render the app icon correctly; rename the iOS marketing PNG
  to icon-ios-1024.png
- gitignore .claude/ local tooling settings
This commit is contained in:
2026-04-18 21:51:27 -04:00
parent 8f8f8b2755
commit 57358797e1
44 changed files with 596 additions and 286 deletions
+40 -12
View File
@@ -73,30 +73,58 @@ struct Scoreboard: Codable {
"https://videocast.nhl.com/game/\(id)/usnded?autoplay=true"
}
/// Time string in ET for display (e.g., "7:00 PM")
/// Time string in ET, right-aligned to 8 chars (e.g. " 9:30 PM", "10:00 PM")
var startTimeET: String {
date.formatDateET(format: "h:mm a")
let raw = date.formatDateET(format: "h:mm a")
return raw.count < 8
? String(repeating: " ", count: 8 - raw.count) + raw
: raw
}
/// Formatted menu title: "NYR @ WAS 0:2 (FINAL)" or "DAL @ TOR Today @ 7:30 PM"
/// Formatted menu title:
/// "NYR @ WAS 0: 2 9:30 PM" (finished/live padded score + time)
/// "DAL @ TOR 7:30 PM" (future no score gap)
var menuTitle: String {
let state = parsedGameState
let matchup = "\(awayTeam.abbrev) @ \(homeTeam.abbrev)"
if state.isFuture {
let isToday = gameDate == Date.todayET
let prefix = isToday ? "Today @ " : ""
return "\(matchup) \(prefix)\(startTimeET)"
return "\(matchup) \(startTimeET)"
}
// Has scores
let score = "\(awayTeam.score ?? 0):\(homeTeam.score ?? 0)"
return "\(matchup) \(score) (\(gameState))"
let aScore = String(format: "%2d", awayTeam.score ?? 0)
let hScore = String(format: "%-2d", homeTeam.score ?? 0)
return "\(matchup) \(aScore):\(hScore) \(startTimeET)"
}
/// Whether this game involves a specific team
func involves(team abbrev: String) -> Bool {
awayTeam.abbrev == abbrev || homeTeam.abbrev == abbrev
/// Sequential game number encoded in the last 4 digits of `id`.
/// Regular season: 1~1312. Playoffs: 111417 (`RSG` round/series/game).
var seasonGameNumber: Int { id % 10_000 }
/// Parsed playoff context; nil for non-playoff games.
var playoffContext: PlayoffContext? {
guard gameType == 3 else { return nil }
let n = id % 1000
return PlayoffContext(
round: n / 100,
seriesInRound: (n / 10) % 10,
gameInSeries: n % 10
)
}
struct PlayoffContext: Equatable {
let round: Int
let seriesInRound: Int
let gameInSeries: Int
/// AO by cumulative position (R1 S1 = A, R1 S8 = H, R2 S1 = I, , R4 S1 = O).
var seriesLetter: String {
let roundStartIndex = [0, 0, 8, 12, 14]
guard round >= 1, round <= 4 else { return "" }
let index = roundStartIndex[round] + (seriesInRound - 1)
guard index >= 0, index < 15 else { return "" }
return String(UnicodeScalar(65 + index)!)
}
}
}
}