Phase 1 - Design System: DesignSystem.swift (typography, colors, spacing constants) and DataPanel.swift (reusable panel container with 3 densities and optional team accent bar). Phase 2 - Dashboard Density: LiveSituationBar (compact strip of all live games with scores/innings/outs), MiniLinescoreView (R-H-E footer for game cards), DiamondView (visual baseball diamond with runners and count). Dashboard shows live situation bar when games are active. Game cards now display mini linescore for live/final games. Phase 3 - Game Center Intelligence: WinProbabilityChartView (full-game line chart using Swift Charts with area fills), PitchArsenalView (pitch type distribution with velocity bars). GameCenterViewModel now stores full WP history array instead of just latest values. Phase 4 - Feed Tab: MLBWebDataService (fetches league leaders from Stats API, news headlines, transactions), FeedViewModel, FeedView with reverse-chronological feed items. FeedItemView with colored edge bars by category. Added 5th "Feed" tab to both tvOS and iOS. Phase 5 - Intel Tab: LeaderboardView (top-5 stat cards with headshots), integrated into LeagueCenterView. Renamed tabs: Games->Today, League->Intel. LeagueCenterViewModel now fetches league leaders. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
237 lines
7.1 KiB
Swift
237 lines
7.1 KiB
Swift
import Foundation
|
|
import OSLog
|
|
|
|
private let webDataLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "WebData")
|
|
|
|
private func logWebData(_ message: String) {
|
|
webDataLogger.debug("\(message, privacy: .public)")
|
|
print("[WebData] \(message)")
|
|
}
|
|
|
|
// MARK: - Models
|
|
|
|
struct NewsHeadline: Identifiable, Sendable {
|
|
let id: String
|
|
let title: String
|
|
let summary: String
|
|
let timestamp: Date
|
|
let imageURL: URL?
|
|
}
|
|
|
|
struct InjuryReport: Identifiable, Sendable {
|
|
let id: String
|
|
let teamCode: String
|
|
let playerName: String
|
|
let position: String
|
|
let status: String // "10-Day IL", "60-Day IL", "Day-to-Day"
|
|
let date: Date
|
|
let notes: String
|
|
}
|
|
|
|
struct Transaction: Identifiable, Sendable {
|
|
let id: String
|
|
let teamCode: String
|
|
let description: String
|
|
let date: Date
|
|
let type: String // "Trade", "DFA", "Call-Up", "Placed on IL"
|
|
}
|
|
|
|
struct LeagueLeader: Identifiable, Sendable {
|
|
let id: String
|
|
let rank: Int
|
|
let playerName: String
|
|
let teamCode: String
|
|
let value: String
|
|
let personId: Int?
|
|
}
|
|
|
|
struct LeaderCategory: Identifiable, Sendable {
|
|
let id: String
|
|
let name: String
|
|
let leaders: [LeagueLeader]
|
|
}
|
|
|
|
// MARK: - Service
|
|
|
|
actor MLBWebDataService {
|
|
private let statsAPI = MLBStatsAPI()
|
|
private let session: URLSession
|
|
|
|
init() {
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 15
|
|
session = URLSession(configuration: config)
|
|
}
|
|
|
|
// MARK: - League Leaders (via Stats API)
|
|
|
|
func fetchLeagueLeaders() async throws -> [LeaderCategory] {
|
|
let categories = [
|
|
("homeRuns", "Home Runs"),
|
|
("battingAverage", "Batting Average"),
|
|
("runsBattedIn", "RBI"),
|
|
("earnedRunAverage", "ERA"),
|
|
("strikeouts", "Strikeouts"),
|
|
("wins", "Wins"),
|
|
("saves", "Saves"),
|
|
("stolenBases", "Stolen Bases"),
|
|
("onBasePlusSlugging", "OPS"),
|
|
("walksHitsPerInningPitched", "WHIP"),
|
|
]
|
|
|
|
var results: [LeaderCategory] = []
|
|
|
|
for (code, name) in categories {
|
|
let url = URL(string: "https://statsapi.mlb.com/api/v1/stats/leaders?leaderCategories=\(code)&season=\(currentSeason)&limit=5&sportId=1")!
|
|
logWebData("fetchLeagueLeaders category=\(code)")
|
|
|
|
do {
|
|
let (data, _) = try await session.data(from: url)
|
|
let response = try JSONDecoder().decode(LeaderResponse.self, from: data)
|
|
|
|
if let category = response.leagueLeaders?.first {
|
|
let leaders = (category.leaders ?? []).prefix(5).enumerated().map { index, leader in
|
|
LeagueLeader(
|
|
id: "\(code)-\(index)",
|
|
rank: leader.rank ?? (index + 1),
|
|
playerName: leader.person?.fullName ?? "Unknown",
|
|
teamCode: leader.team?.abbreviation ?? "",
|
|
value: leader.value ?? "",
|
|
personId: leader.person?.id
|
|
)
|
|
}
|
|
results.append(LeaderCategory(id: code, name: name, leaders: Array(leaders)))
|
|
}
|
|
} catch {
|
|
logWebData("fetchLeagueLeaders FAILED category=\(code) error=\(error)")
|
|
}
|
|
}
|
|
|
|
logWebData("fetchLeagueLeaders complete categories=\(results.count)")
|
|
return results
|
|
}
|
|
|
|
// MARK: - News Headlines (from MLB.com)
|
|
|
|
func fetchNewsHeadlines() async -> [NewsHeadline] {
|
|
let url = URL(string: "https://statsapi.mlb.com/api/v1/news?sportId=1")!
|
|
logWebData("fetchNewsHeadlines")
|
|
|
|
do {
|
|
let (data, _) = try await session.data(from: url)
|
|
let response = try JSONDecoder().decode(NewsResponse.self, from: data)
|
|
|
|
let headlines = (response.articles ?? []).prefix(15).map { article in
|
|
NewsHeadline(
|
|
id: article.slug ?? UUID().uuidString,
|
|
title: article.headline ?? "Untitled",
|
|
summary: article.subhead ?? "",
|
|
timestamp: ISO8601DateFormatter().date(from: article.date ?? "") ?? Date(),
|
|
imageURL: article.image?.cuts?.first?.src.flatMap(URL.init)
|
|
)
|
|
}
|
|
logWebData("fetchNewsHeadlines success count=\(headlines.count)")
|
|
return Array(headlines)
|
|
} catch {
|
|
logWebData("fetchNewsHeadlines FAILED error=\(error)")
|
|
return []
|
|
}
|
|
}
|
|
|
|
// MARK: - Transactions (from Stats API)
|
|
|
|
func fetchTransactions() async -> [Transaction] {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "MM/dd/yyyy"
|
|
let today = formatter.string(from: Date())
|
|
let weekAgo = formatter.string(from: Calendar.current.date(byAdding: .day, value: -7, to: Date())!)
|
|
|
|
let url = URL(string: "https://statsapi.mlb.com/api/v1/transactions?startDate=\(weekAgo)&endDate=\(today)&sportId=1")!
|
|
logWebData("fetchTransactions from=\(weekAgo) to=\(today)")
|
|
|
|
do {
|
|
let (data, _) = try await session.data(from: url)
|
|
let response = try JSONDecoder().decode(TransactionResponse.self, from: data)
|
|
|
|
let transactions = (response.transactions ?? []).prefix(30).enumerated().map { index, tx in
|
|
Transaction(
|
|
id: "\(tx.id ?? index)",
|
|
teamCode: tx.team?.abbreviation ?? "",
|
|
description: tx.description ?? "",
|
|
date: ISO8601DateFormatter().date(from: tx.date ?? "") ?? Date(),
|
|
type: tx.typeDesc ?? "Transaction"
|
|
)
|
|
}
|
|
logWebData("fetchTransactions success count=\(transactions.count)")
|
|
return Array(transactions)
|
|
} catch {
|
|
logWebData("fetchTransactions FAILED error=\(error)")
|
|
return []
|
|
}
|
|
}
|
|
|
|
private var currentSeason: Int {
|
|
Calendar.current.component(.year, from: Date())
|
|
}
|
|
}
|
|
|
|
// MARK: - API Response Models
|
|
|
|
private struct LeaderResponse: Codable {
|
|
let leagueLeaders: [LeaderCategoryResponse]?
|
|
}
|
|
|
|
private struct LeaderCategoryResponse: Codable {
|
|
let leaderCategory: String?
|
|
let leaders: [LeaderEntry]?
|
|
}
|
|
|
|
private struct LeaderEntry: Codable {
|
|
let rank: Int?
|
|
let value: String?
|
|
let person: PersonRef?
|
|
let team: TeamRef?
|
|
}
|
|
|
|
private struct PersonRef: Codable {
|
|
let id: Int?
|
|
let fullName: String?
|
|
}
|
|
|
|
private struct TeamRef: Codable {
|
|
let id: Int?
|
|
let abbreviation: String?
|
|
}
|
|
|
|
private struct NewsResponse: Codable {
|
|
let articles: [NewsArticle]?
|
|
}
|
|
|
|
private struct NewsArticle: Codable {
|
|
let slug: String?
|
|
let headline: String?
|
|
let subhead: String?
|
|
let date: String?
|
|
let image: NewsImage?
|
|
}
|
|
|
|
private struct NewsImage: Codable {
|
|
let cuts: [NewsImageCut]?
|
|
}
|
|
|
|
private struct NewsImageCut: Codable {
|
|
let src: String?
|
|
}
|
|
|
|
private struct TransactionResponse: Codable {
|
|
let transactions: [TransactionEntry]?
|
|
}
|
|
|
|
private struct TransactionEntry: Codable {
|
|
let id: Int?
|
|
let description: String?
|
|
let date: String?
|
|
let typeDesc: String?
|
|
let team: TeamRef?
|
|
}
|