Files
iceglass/IceGlass-iOS/ViewModel/ScoreboardViewModel.swift
T
rzen 541aa3d52c Show full playoff bracket, mark series results, harden API decoding
Playoffs:
- List every round played so far (Round 1 → current) instead of only the
  current round, on both macOS menu and iPhone
- Strike through the eliminated team's tricode in a finished series and drop
  the now-redundant "(Final … wins)" tag on completed earlier rounds
- Refetch the bracket when a finished game implies more completed games than
  the cached bracket records, so the series score and round no longer get
  stuck on stale data after cold launch or the NHL bracket endpoint's lag

API robustness:
- Tolerate optional gameCenterLink/startTimeUTC on TBD playoff matchups so the
  scoreboard decode no longer aborts
- Reject API state regressions via a monotonic FUT→…→OFF progression rank so a
  brief glitch can't downgrade a finished game back to "-:-"
2026-05-30 08:21:28 -04:00

99 lines
2.8 KiB
Swift

//
// ScoreboardViewModel.swift
// IceGlass-iOS
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
import Observation
/// Bridges MainService SwiftUI. Re-published whenever data lands so views
/// re-render. Owns refresh kick-off on scene activation and pull-to-refresh.
@Observable
@MainActor
final class ScoreboardViewModel {
/// Bumped on every observer callback; views read it implicitly via the
/// Observation framework so the entire tree re-evaluates.
private var revision: Int = 0
/// Tracks the most recent successful fetch time for the "Updated " header.
var lastUpdated: Date?
/// Toggled by pull-to-refresh; views can show a spinner.
var isRefreshing: Bool = false
/// Settings surfaced separately so the settings sheet can bind directly.
var displayOption: AppSettings.DisplayOption {
get {
_ = revision
return AppSettings.shared.displayOption
}
set {
AppSettings.shared.displayOption = newValue
revision += 1
}
}
var gamesByDate: [Scoreboard.GameDay] {
_ = revision
return MainService.shared.gamesByDate
}
var roundSeriesGroups: [MainService.RoundGroup] {
_ = revision
return MainService.shared.roundSeriesGroups
}
/// Bridge object that forwards MainService callbacks back to this view model.
/// Held strongly so MainService's weak observer reference stays alive.
private var bridge: ObserverBridge?
init() {}
/// Wire this view model up to MainService. Idempotent; called from `.onAppear`.
func attach() {
guard bridge == nil else { return }
let bridge = ObserverBridge { [weak self] in self?.handleUpdate() }
self.bridge = bridge
MainService.shared.observer = bridge
// Initial sync MainService may have already loaded a cached snapshot.
handleUpdate()
}
private func handleUpdate() {
lastUpdated = MainService.shared.lastUpdated
revision += 1
}
func refreshNow() async {
isRefreshing = true
await MainService.shared.fetchAll()
isRefreshing = false
}
func handleScenePhaseActive() {
MainService.shared.resumePolling()
Task { await refreshNow() }
}
func handleScenePhaseInactive() {
MainService.shared.suspendPolling()
}
}
/// Class adapter so MainService's `weak var observer` has something to hold;
/// @Observable view-model classes don't compose cleanly with weak refs.
@MainActor
private final class ObserverBridge: MainServiceObserver {
private let onUpdate: () -> Void
init(onUpdate: @escaping () -> Void) {
self.onUpdate = onUpdate
}
func mainServiceDidUpdate() {
onUpdate()
}
}