Files
MLBApp/mlbTVOS/Services/MLBWebDataService.swift
Trey t b5daddefd3 Add UI redesign: design system, dashboard density, game intelligence, feed tab, league leaders
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>
2026-04-12 13:12:09 -05:00

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?
}