aaffa3771c
Two-target restructure: shared sources (models, services, settings, extensions, team logos) move into Shared/, consumed by both the existing macOS menu bar app and a new iOS app. MainService no longer imports AppKit — platform code attaches via a MainServiceObserver protocol (MacObserverAdapter wires back to MenuManager / StatusItemManager / NotificationManager). iPhone app is a single SwiftUI page mirroring the macOS menu (playoff round + yesterday/today/tomorrow), with a gear-icon settings sheet (display option + IndieAbout for license/changelog). Persistent JSON snapshot in Application Support paints last-known data on cold launch; "Updated …" header escalates secondary → orange (>5min) → red (>30min) so staleness is visually unmistakable. Foreground polling, scenePhase refresh, and pull-to-refresh; no notifications on iOS in v1.
133 lines
4.0 KiB
Swift
133 lines
4.0 KiB
Swift
//
|
||
// ScoreboardModel.swift
|
||
// IceGlass
|
||
//
|
||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||
//
|
||
|
||
import Foundation
|
||
|
||
struct Scoreboard: Codable {
|
||
let focusedDate: String
|
||
let focusedDateCount: Int
|
||
let gamesByDate: [GameDay]
|
||
|
||
struct GameDay: Codable {
|
||
let date: String // "YYYY-MM-DD"
|
||
let games: [Game]
|
||
}
|
||
|
||
struct Game: Codable, Equatable {
|
||
static func == (lhs: Game, rhs: Game) -> Bool {
|
||
lhs.id == rhs.id
|
||
}
|
||
|
||
let id: Int
|
||
let season: Int
|
||
let gameType: Int
|
||
let gameDate: String
|
||
let gameCenterLink: String
|
||
let startTimeUTC: String
|
||
let gameState: String
|
||
let gameScheduleState: String
|
||
let awayTeam: Team
|
||
let homeTeam: Team
|
||
let period: Int?
|
||
let periodDescriptor: PeriodDescriptor?
|
||
|
||
struct LocalizedString: Codable {
|
||
let `default`: String
|
||
}
|
||
|
||
struct Team: Codable {
|
||
let id: Int
|
||
let name: LocalizedString
|
||
let commonName: LocalizedString
|
||
let abbrev: String
|
||
let score: Int?
|
||
let record: String?
|
||
let logo: String
|
||
}
|
||
|
||
struct PeriodDescriptor: Codable {
|
||
let number: Int
|
||
let periodType: String
|
||
let maxRegulationPeriods: Int
|
||
}
|
||
|
||
// MARK: - Computed Properties
|
||
|
||
var parsedGameState: GameState {
|
||
GameState(rawValue: gameState) ?? .future
|
||
}
|
||
|
||
var date: Date {
|
||
ISO8601DateFormatter().date(from: startTimeUTC) ?? .now
|
||
}
|
||
|
||
var gameCenterUrl: String {
|
||
"https://www.nhl.com\(gameCenterLink)"
|
||
}
|
||
|
||
var videocastUrl: String {
|
||
"https://videocast.nhl.com/game/\(id)/usnded?autoplay=true"
|
||
}
|
||
|
||
/// Time string in ET, right-aligned to 8 chars (e.g. " 9:30 PM", "10:00 PM")
|
||
var startTimeET: String {
|
||
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 9:30 PM FINAL" (finished/live — padded score + time + state tag)
|
||
/// "DAL @ TOR 7:30 PM" (future — no score gap, no tag)
|
||
var menuTitle: String {
|
||
let state = parsedGameState
|
||
let matchup = "\(awayTeam.abbrev) @ \(homeTeam.abbrev)"
|
||
let tag = state.shortTag
|
||
let tagSuffix = tag.isEmpty ? "" : " \(tag)"
|
||
|
||
if state.isFuture {
|
||
return "\(matchup) \(startTimeET)\(tagSuffix)"
|
||
}
|
||
|
||
let aScore = String(format: "%2d", awayTeam.score ?? 0)
|
||
let hScore = String(format: "%-2d", homeTeam.score ?? 0)
|
||
return "\(matchup) \(aScore):\(hScore) \(startTimeET)\(tagSuffix)"
|
||
}
|
||
|
||
/// Sequential game number encoded in the last 4 digits of `id`.
|
||
/// Regular season: 1…~1312. Playoffs: 111–417 (`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
|
||
|
||
/// A…O 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)!)
|
||
}
|
||
}
|
||
}
|
||
}
|