Add iPhone target with shared data layer and persistent cache
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.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
//
|
||||
// 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)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user