Compare commits

...

3 Commits

Author SHA1 Message Date
Trey t
39092e5f3d Restore Live shelf on Today, flatten Feed to time-ordered highlights
Today tab: Removed LiveSituationBar, restored the full Live game shelf
below the featured Astros card where it belongs.

Feed tab: Changed from two grouped shelves (condensed / highlights) to a
single horizontal scroll with ALL highlights ordered by timestamp (most
recent first). Added condensed game badge overlay on thumbnails. Added
date field to Highlight model for time-based ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:39:41 -05:00
Trey t
cd605d889d Replace Feed with highlights, remove duplicate live shelf, drop Intel schedule
Feed tab: Replaced news/transaction feed with league-wide highlights and
condensed game replays. FeedViewModel now fetches highlights from all
games concurrently, splits into condensed games vs individual highlights.
Cards show team color thumbnails with play button overlay.

Today tab: Removed duplicate Live games shelf — LiveSituationBar already
shows all live games at the top, so the shelf was redundant.

Intel tab: Removed schedule section (already on Today tab). Updated
header description and stat pills. Added division hydration to standings
API call so division names display correctly instead of "Division".

Focus: Added .platformFocusable() to standings cards and leaderboard
cards so tvOS remote can scroll horizontally through them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:25:36 -05:00
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
23 changed files with 1516 additions and 28 deletions

View File

@@ -4,15 +4,17 @@ private enum mlbIOSSection: String, CaseIterable, Identifiable {
case games
case league
case multiView
case feed
case settings
var id: String { rawValue }
var title: String {
switch self {
case .games: "Games"
case .league: "League"
case .games: "Today"
case .league: "Intel"
case .multiView: "Multi-View"
case .feed: "Feed"
case .settings: "Settings"
}
}
@@ -20,8 +22,9 @@ private enum mlbIOSSection: String, CaseIterable, Identifiable {
var systemImage: String {
switch self {
case .games: "sportscourt.fill"
case .league: "list.bullet.rectangle.portrait.fill"
case .league: "chart.bar.fill"
case .multiView: "rectangle.split.2x2.fill"
case .feed: "newspaper.fill"
case .settings: "gearshape.fill"
}
}
@@ -58,15 +61,18 @@ struct mlbIOSRootView: View {
private var compactTabs: some View {
TabView {
Tab("Games", systemImage: "sportscourt.fill") {
Tab("Today", systemImage: "sportscourt.fill") {
DashboardView()
}
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") {
Tab("Intel", systemImage: "chart.bar.fill") {
LeagueCenterView()
}
Tab(multiViewTitle, systemImage: "rectangle.split.2x2.fill") {
MultiStreamView()
}
Tab("Feed", systemImage: "newspaper.fill") {
FeedView()
}
Tab("Settings", systemImage: "gearshape.fill") {
SettingsView()
}
@@ -99,6 +105,8 @@ struct mlbIOSRootView: View {
LeagueCenterView()
case .multiView:
MultiStreamView()
case .feed:
FeedView()
case .settings:
SettingsView()
}

View File

@@ -73,6 +73,30 @@
FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; };
FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45142562E644AF04F7719083 /* mlbTVOSApp.swift */; };
FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; };
37407BFDD8DE522452EF59A9 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A41C116893411524EA91B1 /* DesignSystem.swift */; };
E1C4B52624855C00D4248CF7 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A41C116893411524EA91B1 /* DesignSystem.swift */; };
1709C71D035868F36D786A6C /* DataPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */; };
9F57F341DE0236C88F5D9AB4 /* DataPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */; };
3A707B3D07159E3794810DA6 /* MiniLinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */; };
394BFB3C349C866C45652736 /* MiniLinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */; };
E1EE34169F3361C53FB042C2 /* DiamondView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92817781B4EB8AC773F94A1B /* DiamondView.swift */; };
C9BAFB42FB62DB304C446C5F /* DiamondView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92817781B4EB8AC773F94A1B /* DiamondView.swift */; };
C3245C14690AF4CE8882130E /* LiveSituationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */; };
492CF47C161A3F31DED29740 /* LiveSituationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */; };
312CC5DD0539384FDF65BEB7 /* WinProbabilityChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */; };
4DDB697A8363736A0E0C345E /* WinProbabilityChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */; };
F17BF3697CF0008A9417FE08 /* PitchArsenalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */; };
026E8F398909A35C0DD41D16 /* PitchArsenalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */; };
CB3E29DA692C18137C037F93 /* MLBWebDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */; };
E1DA8514E6177069C04B0416 /* MLBWebDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */; };
C7C0D9F7B6A3EBD4D9F4B973 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C920FA380D9DDDED113068E3 /* FeedViewModel.swift */; };
560051D308A26379374EC9C4 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C920FA380D9DDDED113068E3 /* FeedViewModel.swift */; };
5802E2BE747B7B59F00AE1E7 /* FeedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F68D38B739C81D7747CC412 /* FeedItemView.swift */; };
9BF7942D0252B0EB87DE58B3 /* FeedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F68D38B739C81D7747CC412 /* FeedItemView.swift */; };
C5057ECFA9FCBFFC6A8A7E15 /* FeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C95E73FFDAFE2E26B06C2 /* FeedView.swift */; };
6581BE8213BB231538C9EB8E /* FeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C95E73FFDAFE2E26B06C2 /* FeedView.swift */; };
DB34919E15ABAA1B40DF9A5B /* LeaderboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */; };
94D2242FE48B5FECE15116FF /* LeaderboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -129,6 +153,18 @@
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterView.swift; sourceTree = "<group>"; };
F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterViewModel.swift; sourceTree = "<group>"; };
FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = "<group>"; };
60A41C116893411524EA91B1 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = "<group>"; };
C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPanel.swift; sourceTree = "<group>"; };
1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniLinescoreView.swift; sourceTree = "<group>"; };
92817781B4EB8AC773F94A1B /* DiamondView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiamondView.swift; sourceTree = "<group>"; };
0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveSituationBar.swift; sourceTree = "<group>"; };
26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WinProbabilityChartView.swift; sourceTree = "<group>"; };
A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitchArsenalView.swift; sourceTree = "<group>"; };
7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBWebDataService.swift; sourceTree = "<group>"; };
C920FA380D9DDDED113068E3 /* FeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewModel.swift; sourceTree = "<group>"; };
9F68D38B739C81D7747CC412 /* FeedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemView.swift; sourceTree = "<group>"; };
981C95E73FFDAFE2E26B06C2 /* FeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedView.swift; sourceTree = "<group>"; };
726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
@@ -137,6 +173,7 @@
children = (
F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */,
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */,
C920FA380D9DDDED113068E3 /* FeedViewModel.swift */,
7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */,
);
path = ViewModels;
@@ -170,6 +207,15 @@
children = (
C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */,
327A33F2676712C2B5B4AF2F /* LinescoreView.swift */,
726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */,
9F68D38B739C81D7747CC412 /* FeedItemView.swift */,
A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */,
26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */,
0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */,
92817781B4EB8AC773F94A1B /* DiamondView.swift */,
1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */,
C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */,
60A41C116893411524EA91B1 /* DesignSystem.swift */,
81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */,
C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */,
7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */,
@@ -227,6 +273,7 @@
isa = PBXGroup;
children = (
70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */,
7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */,
6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */,
C3A003EA560655E4B2200DBE /* VideoShuffle.swift */,
);
@@ -238,6 +285,7 @@
children = (
3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */,
5EA4C33F029665B80F1A6B6D /* DashboardView.swift */,
981C95E73FFDAFE2E26B06C2 /* FeedView.swift */,
102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */,
E21E3115B8A9B4C798ECE828 /* GameCardView.swift */,
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */,
@@ -392,6 +440,18 @@
8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */,
E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */,
4CB947BC5B5ECF83EF84F7E4 /* LinescoreView.swift in Sources */,
94D2242FE48B5FECE15116FF /* LeaderboardView.swift in Sources */,
6581BE8213BB231538C9EB8E /* FeedView.swift in Sources */,
9BF7942D0252B0EB87DE58B3 /* FeedItemView.swift in Sources */,
560051D308A26379374EC9C4 /* FeedViewModel.swift in Sources */,
E1DA8514E6177069C04B0416 /* MLBWebDataService.swift in Sources */,
026E8F398909A35C0DD41D16 /* PitchArsenalView.swift in Sources */,
4DDB697A8363736A0E0C345E /* WinProbabilityChartView.swift in Sources */,
492CF47C161A3F31DED29740 /* LiveSituationBar.swift in Sources */,
C9BAFB42FB62DB304C446C5F /* DiamondView.swift in Sources */,
394BFB3C349C866C45652736 /* MiniLinescoreView.swift in Sources */,
9F57F341DE0236C88F5D9AB4 /* DataPanel.swift in Sources */,
E1C4B52624855C00D4248CF7 /* DesignSystem.swift in Sources */,
0653C2F5CC55F4234E8950E5 /* LiveIndicator.swift in Sources */,
D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */,
8801888DFD48351B1D344D45 /* MLBServerAPI.swift in Sources */,
@@ -439,6 +499,18 @@
07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */,
B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */,
051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */,
DB34919E15ABAA1B40DF9A5B /* LeaderboardView.swift in Sources */,
C5057ECFA9FCBFFC6A8A7E15 /* FeedView.swift in Sources */,
5802E2BE747B7B59F00AE1E7 /* FeedItemView.swift in Sources */,
C7C0D9F7B6A3EBD4D9F4B973 /* FeedViewModel.swift in Sources */,
CB3E29DA692C18137C037F93 /* MLBWebDataService.swift in Sources */,
F17BF3697CF0008A9417FE08 /* PitchArsenalView.swift in Sources */,
312CC5DD0539384FDF65BEB7 /* WinProbabilityChartView.swift in Sources */,
C3245C14690AF4CE8882130E /* LiveSituationBar.swift in Sources */,
E1EE34169F3361C53FB042C2 /* DiamondView.swift in Sources */,
3A707B3D07159E3794810DA6 /* MiniLinescoreView.swift in Sources */,
1709C71D035868F36D786A6C /* DataPanel.swift in Sources */,
37407BFDD8DE522452EF59A9 /* DesignSystem.swift in Sources */,
E7FB366456557069990F550C /* LiveIndicator.swift in Sources */,
820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */,
1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */,

View File

@@ -305,6 +305,7 @@ actor MLBServerAPI {
struct Highlight: Codable, Sendable, Identifiable {
let id: String?
let headline: String?
let date: String?
let playbacks: [Playback]?
struct Playback: Codable, Sendable {

View File

@@ -33,7 +33,7 @@ actor MLBStatsAPI {
func fetchStandingsRecords(season: String) async throws -> [StandingsDivisionRecord] {
let response: StandingsResponse = try await fetchJSON(
"\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team"
"\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team,division"
)
return response.records
}

View File

@@ -0,0 +1,236 @@
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?
}

View File

@@ -0,0 +1,122 @@
import Foundation
import Observation
import OSLog
private let feedLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "Feed")
private func logFeed(_ message: String) {
feedLogger.debug("\(message, privacy: .public)")
print("[Feed] \(message)")
}
private func parseHighlightDate(_ string: String?) -> Date? {
guard let string else { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = iso.date(from: string) { return d }
iso.formatOptions = [.withInternetDateTime]
return iso.date(from: string)
}
struct HighlightItem: Identifiable, Sendable {
let id: String
let headline: String
let gameTitle: String
let awayCode: String
let homeCode: String
let hlsURL: URL?
let mp4URL: URL?
let isCondensedGame: Bool
let timestamp: Date
}
@Observable
@MainActor
final class FeedViewModel {
var highlights: [HighlightItem] = []
var isLoading = false
@ObservationIgnored
private var refreshTask: Task<Void, Never>?
private let serverAPI = MLBServerAPI()
func loadHighlights(games: [Game]) async {
isLoading = true
logFeed("loadHighlights start gameCount=\(games.count)")
let gamesWithPk = games.filter { $0.gamePk != nil }
await withTaskGroup(of: [HighlightItem].self) { group in
for game in gamesWithPk {
group.addTask { [serverAPI] in
do {
let raw = try await serverAPI.fetchHighlights(
gamePk: game.gamePk!,
gameDate: game.gameDate
)
return raw.enumerated().compactMap { index, highlight -> HighlightItem? in
guard let headline = highlight.headline,
let hlsStr = highlight.hlsURL ?? highlight.mp4URL,
let _ = URL(string: hlsStr) else { return nil }
let isCondensed = headline.lowercased().contains("condensed")
|| headline.lowercased().contains("recap")
let timestamp = parseHighlightDate(highlight.date)
?? Date(timeIntervalSince1970: TimeInterval(index))
return HighlightItem(
id: highlight.id ?? "\(game.gamePk!)-\(index)",
headline: headline,
gameTitle: "\(game.awayTeam.code) @ \(game.homeTeam.code)",
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code,
hlsURL: highlight.hlsURL.flatMap(URL.init),
mp4URL: highlight.mp4URL.flatMap(URL.init),
isCondensedGame: isCondensed,
timestamp: timestamp
)
}
} catch {
return []
}
}
}
var allHighlights: [HighlightItem] = []
for await batch in group {
allHighlights.append(contentsOf: batch)
}
// Sort all highlights by time, most recent first
highlights = allHighlights.sorted { $0.timestamp > $1.timestamp }
}
isLoading = false
logFeed("loadHighlights complete count=\(highlights.count)")
}
var condensedGames: [HighlightItem] {
highlights.filter(\.isCondensedGame)
}
var latestHighlights: [HighlightItem] {
highlights.filter { !$0.isCondensedGame }
}
func startAutoRefresh(games: [Game]) {
stopAutoRefresh()
refreshTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(300))
guard !Task.isCancelled, let self else { break }
await self.loadHighlights(games: games)
}
}
}
func stopAutoRefresh() {
refreshTask?.cancel()
refreshTask = nil
}
}

View File

@@ -16,6 +16,7 @@ final class GameCenterViewModel {
var highlights: [Highlight] = []
var winProbabilityHome: Double?
var winProbabilityAway: Double?
var winProbabilityHistory: [WinProbabilityEntry] = []
var isLoading = false
var errorMessage: String?
var lastUpdated: Date?
@@ -81,6 +82,7 @@ final class GameCenterViewModel {
private func refreshWinProbability(gamePk: String) async {
do {
let entries = try await statsAPI.fetchWinProbability(gamePk: gamePk)
winProbabilityHistory = entries
if let latest = entries.last,
let home = latest.homeTeamWinProbability,
let away = latest.awayTeamWinProbability {

View File

@@ -7,6 +7,8 @@ final class LeagueCenterViewModel {
var scheduleGames: [StatsGame] = []
var standings: [StandingsDivisionRecord] = []
var teams: [LeagueTeamSummary] = []
var leagueLeaders: [LeaderCategory] = []
var isLoadingLeaders = false
var selectedTeam: TeamProfile?
var roster: [RosterPlayerSummary] = []
@@ -21,6 +23,7 @@ final class LeagueCenterViewModel {
var playerErrorMessage: String?
private let statsAPI = MLBStatsAPI()
private let webService = MLBWebDataService()
private(set) var scheduleDate = Date()
private var seasonString: String {
@@ -56,6 +59,19 @@ final class LeagueCenterViewModel {
}
isLoadingOverview = false
// Load leaders in the background
Task { await loadLeaders() }
}
func loadLeaders() async {
isLoadingLeaders = true
do {
leagueLeaders = try await webService.fetchLeagueLeaders()
} catch {
// Leaders are supplementary
}
isLoadingLeaders = false
}
func goToPreviousDay() async {

View File

@@ -0,0 +1,69 @@
import SwiftUI
enum DataPanelDensity {
case compact
case standard
case featured
var padding: CGFloat {
switch self {
case .compact: DS.Spacing.panelPadCompact
case .standard: DS.Spacing.panelPadStandard
case .featured: DS.Spacing.panelPadFeatured
}
}
var cornerRadius: CGFloat {
switch self {
case .compact: DS.Radii.compact
case .standard: DS.Radii.standard
case .featured: DS.Radii.featured
}
}
}
struct DataPanel<Content: View>: View {
let density: DataPanelDensity
var teamAccentCode: String? = nil
@ViewBuilder let content: () -> Content
var body: some View {
HStack(spacing: 0) {
if let code = teamAccentCode {
RoundedRectangle(cornerRadius: 1.5)
.fill(TeamAssets.color(for: code))
.frame(width: 3)
.padding(.vertical, 6)
.padding(.leading, 4)
}
VStack(alignment: .leading, spacing: 0) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(density.padding)
}
.background(
RoundedRectangle(cornerRadius: density.cornerRadius)
.fill(DS.Colors.panelFill)
)
.overlay(
RoundedRectangle(cornerRadius: density.cornerRadius)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
)
}
}
// MARK: - Convenience initializers
extension DataPanel {
init(
_ density: DataPanelDensity = .standard,
teamAccent: String? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.density = density
self.teamAccentCode = teamAccent
self.content = content
}
}

View File

@@ -0,0 +1,101 @@
import SwiftUI
// MARK: - The Dugout Design System
enum DS {
// MARK: - Colors
enum Colors {
static let background = Color(red: 0.02, green: 0.03, blue: 0.05)
static let panelFill = Color.white.opacity(0.04)
static let panelStroke = Color.white.opacity(0.06)
static let live = Color(red: 0.95, green: 0.22, blue: 0.22)
static let positive = Color(red: 0.20, green: 0.78, blue: 0.35)
static let warning = Color(red: 0.95, green: 0.55, blue: 0.15)
static let interactive = Color(red: 0.25, green: 0.52, blue: 0.95)
static let media = Color(red: 0.55, green: 0.35, blue: 0.85)
static let textPrimary = Color.white
static let textSecondary = Color.white.opacity(0.7)
static let textTertiary = Color.white.opacity(0.45)
static let textQuaternary = Color.white.opacity(0.2)
}
// MARK: - Typography
enum Fonts {
static let heroScore = Font.system(size: 72, weight: .black, design: .rounded).monospacedDigit()
static let largeScore = Font.system(size: 42, weight: .black, design: .rounded).monospacedDigit()
static let score = Font.system(size: 28, weight: .black, design: .rounded).monospacedDigit()
static let scoreCompact = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit()
static let sectionTitle = Font.system(size: 28, weight: .bold, design: .rounded)
static let cardTitle = Font.system(size: 20, weight: .bold, design: .rounded)
static let cardTitleCompact = Font.system(size: 17, weight: .bold, design: .rounded)
static let dataValue = Font.system(size: 18, weight: .bold, design: .rounded).monospacedDigit()
static let dataValueCompact = Font.system(size: 15, weight: .semibold, design: .rounded).monospacedDigit()
static let body = Font.system(size: 15, weight: .medium)
static let bodySmall = Font.system(size: 13, weight: .medium)
static let caption = Font.system(size: 11, weight: .bold, design: .rounded)
// tvOS scaled variants
#if os(tvOS)
static let tvSectionTitle = Font.system(size: 36, weight: .bold, design: .rounded)
static let tvCardTitle = Font.system(size: 26, weight: .bold, design: .rounded)
static let tvScore = Font.system(size: 34, weight: .black, design: .rounded).monospacedDigit()
static let tvDataValue = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit()
static let tvBody = Font.system(size: 20, weight: .medium)
static let tvCaption = Font.system(size: 15, weight: .bold, design: .rounded)
#endif
}
// MARK: - Spacing
enum Spacing {
#if os(tvOS)
static let panelPadCompact: CGFloat = 18
static let panelPadStandard: CGFloat = 24
static let panelPadFeatured: CGFloat = 32
static let sectionGap: CGFloat = 40
static let cardGap: CGFloat = 20
static let itemGap: CGFloat = 12
static let edgeInset: CGFloat = 50
#else
static let panelPadCompact: CGFloat = 12
static let panelPadStandard: CGFloat = 16
static let panelPadFeatured: CGFloat = 24
static let sectionGap: CGFloat = 28
static let cardGap: CGFloat = 14
static let itemGap: CGFloat = 8
static let edgeInset: CGFloat = 20
#endif
}
// MARK: - Radii
enum Radii {
static let compact: CGFloat = 14
static let standard: CGFloat = 18
static let featured: CGFloat = 22
}
}
// MARK: - Data Label Style
struct DataLabelStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textQuaternary)
.textCase(.uppercase)
.kerning(1.5)
}
}
extension View {
func dataLabelStyle() -> some View {
modifier(DataLabelStyle())
}
}

View File

@@ -0,0 +1,120 @@
import SwiftUI
/// Visual baseball diamond showing base runners, count, and outs
struct DiamondView: View {
var onFirst: Bool = false
var onSecond: Bool = false
var onThird: Bool = false
var balls: Int = 0
var strikes: Int = 0
var outs: Int = 0
var body: some View {
HStack(spacing: diamondCountGap) {
// Diamond
ZStack {
// Diamond shape outline
diamondPath
.stroke(DS.Colors.textQuaternary, lineWidth: 1.5)
// Base markers
baseMarker(at: firstBasePos, occupied: onFirst)
baseMarker(at: secondBasePos, occupied: onSecond)
baseMarker(at: thirdBasePos, occupied: onThird)
baseMarker(at: homePos, occupied: false, isHome: true)
}
.frame(width: diamondSize, height: diamondSize)
// Count + Outs
VStack(alignment: .leading, spacing: countSpacing) {
HStack(spacing: 3) {
Text("B").dataLabelStyle()
ForEach(0..<4, id: \.self) { i in
Circle()
.fill(i < balls ? DS.Colors.positive : DS.Colors.textQuaternary)
.frame(width: dotSize, height: dotSize)
}
}
HStack(spacing: 3) {
Text("S").dataLabelStyle()
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i < strikes ? DS.Colors.live : DS.Colors.textQuaternary)
.frame(width: dotSize, height: dotSize)
}
}
HStack(spacing: 3) {
Text("O").dataLabelStyle()
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i < outs ? DS.Colors.warning : DS.Colors.textQuaternary)
.frame(width: dotSize, height: dotSize)
}
}
}
}
}
// MARK: - Diamond geometry
private var diamondPath: Path {
let s = diamondSize
let mid = s / 2
let inset: CGFloat = baseSize / 2 + 2
var path = Path()
path.move(to: CGPoint(x: mid, y: inset)) // 2nd base (top)
path.addLine(to: CGPoint(x: s - inset, y: mid)) // 1st base (right)
path.addLine(to: CGPoint(x: mid, y: s - inset)) // Home (bottom)
path.addLine(to: CGPoint(x: inset, y: mid)) // 3rd base (left)
path.closeSubpath()
return path
}
private var firstBasePos: CGPoint {
CGPoint(x: diamondSize - baseSize / 2 - 2, y: diamondSize / 2)
}
private var secondBasePos: CGPoint {
CGPoint(x: diamondSize / 2, y: baseSize / 2 + 2)
}
private var thirdBasePos: CGPoint {
CGPoint(x: baseSize / 2 + 2, y: diamondSize / 2)
}
private var homePos: CGPoint {
CGPoint(x: diamondSize / 2, y: diamondSize - baseSize / 2 - 2)
}
@ViewBuilder
private func baseMarker(at position: CGPoint, occupied: Bool, isHome: Bool = false) -> some View {
Group {
if isHome {
// Home plate: small pentagon-like shape
Circle()
.fill(DS.Colors.textQuaternary)
.frame(width: baseSize * 0.7, height: baseSize * 0.7)
} else {
// Base: rotated square
Rectangle()
.fill(occupied ? DS.Colors.interactive : DS.Colors.textQuaternary)
.frame(width: baseSize, height: baseSize)
.rotationEffect(.degrees(45))
}
}
.position(position)
}
// MARK: - Platform sizing
#if os(tvOS)
private var diamondSize: CGFloat { 60 }
private var baseSize: CGFloat { 12 }
private var dotSize: CGFloat { 9 }
private var countSpacing: CGFloat { 5 }
private var diamondCountGap: CGFloat { 14 }
#else
private var diamondSize: CGFloat { 44 }
private var baseSize: CGFloat { 9 }
private var dotSize: CGFloat { 7 }
private var countSpacing: CGFloat { 3 }
private var diamondCountGap: CGFloat { 10 }
#endif
}

View File

@@ -0,0 +1,3 @@
import SwiftUI
// Placeholder highlight cards are now inline in FeedView

View File

@@ -0,0 +1,80 @@
import SwiftUI
/// Top-5 stat leaderboard card with player headshots
struct LeaderboardView: View {
let category: LeaderCategory
var body: some View {
DataPanel(.standard) {
VStack(alignment: .leading, spacing: 10) {
Text(category.name.uppercased())
.dataLabelStyle()
ForEach(category.leaders) { leader in
leaderRow(leader)
}
}
}
}
@ViewBuilder
private func leaderRow(_ leader: LeagueLeader) -> some View {
HStack(spacing: 10) {
Text("\(leader.rank)")
.font(rankFont)
.foregroundStyle(leader.rank <= 3 ? DS.Colors.textPrimary : DS.Colors.textTertiary)
.frame(width: rankWidth, alignment: .center)
// Player headshot
if let personId = leader.personId {
AsyncImage(url: URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_80,q_auto:best/v1/people/\(personId)/headshot/67/current")) { phase in
if let image = phase.image {
image.resizable().aspectRatio(contentMode: .fill)
} else {
Circle().fill(DS.Colors.panelFill)
}
}
.frame(width: headshotSize, height: headshotSize)
.clipShape(Circle())
}
VStack(alignment: .leading, spacing: 1) {
Text(leader.playerName)
.font(nameFont)
.foregroundStyle(DS.Colors.textPrimary)
.lineLimit(1)
if !leader.teamCode.isEmpty {
HStack(spacing: 3) {
RoundedRectangle(cornerRadius: 1)
.fill(TeamAssets.color(for: leader.teamCode))
.frame(width: 2, height: 10)
Text(leader.teamCode)
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
}
}
}
Spacer()
Text(leader.value)
.font(valueFont)
.foregroundStyle(leader.rank == 1 ? DS.Colors.interactive : DS.Colors.textPrimary)
}
}
#if os(tvOS)
private var rankWidth: CGFloat { 28 }
private var headshotSize: CGFloat { 36 }
private var rankFont: Font { .system(size: 16, weight: .bold, design: .rounded).monospacedDigit() }
private var nameFont: Font { .system(size: 17, weight: .semibold) }
private var valueFont: Font { DS.Fonts.tvDataValue }
#else
private var rankWidth: CGFloat { 22 }
private var headshotSize: CGFloat { 28 }
private var rankFont: Font { .system(size: 13, weight: .bold, design: .rounded).monospacedDigit() }
private var nameFont: Font { .system(size: 14, weight: .semibold) }
private var valueFont: Font { DS.Fonts.dataValue }
#endif
}

View File

@@ -0,0 +1,101 @@
import SwiftUI
/// Compact horizontal strip of all live games scores, innings, outs at a glance
struct LiveSituationBar: View {
let games: [Game]
var onTapGame: ((Game) -> Void)? = nil
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: DS.Spacing.cardGap) {
ForEach(games) { game in
liveTile(game)
}
}
.padding(.horizontal, DS.Spacing.edgeInset)
}
}
@ViewBuilder
private func liveTile(_ game: Game) -> some View {
Button {
onTapGame?(game)
} label: {
DataPanel(.compact) {
HStack(spacing: tileSpacing) {
// Teams + scores
VStack(alignment: .leading, spacing: 3) {
teamScoreRow(code: game.awayTeam.code, score: game.awayTeam.score)
teamScoreRow(code: game.homeTeam.code, score: game.homeTeam.score)
}
// Situation
VStack(alignment: .trailing, spacing: 3) {
if let inning = game.currentInningDisplay ?? game.status.liveInning {
Text(inning)
.font(inningFont)
.foregroundStyle(DS.Colors.textSecondary)
}
if let linescore = game.linescore {
HStack(spacing: 2) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i < (linescore.outs ?? 0) ? DS.Colors.warning : DS.Colors.textQuaternary)
.frame(width: outDotSize, height: outDotSize)
}
}
}
}
}
}
}
.platformCardStyle()
}
@ViewBuilder
private func teamScoreRow(code: String, score: Int?) -> some View {
HStack(spacing: 6) {
TeamLogoView(team: TeamInfo(code: code, name: "", score: nil), size: logoSize)
Text(code)
.font(teamFont.weight(.bold))
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: codeWidth, alignment: .leading)
Text(score.map { "\($0)" } ?? "-")
.font(scoreFont.weight(.black).monospacedDigit())
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: scoreWidth, alignment: .trailing)
}
}
#if os(tvOS)
private var tileSpacing: CGFloat { 20 }
private var logoSize: CGFloat { 28 }
private var codeWidth: CGFloat { 44 }
private var scoreWidth: CGFloat { 30 }
private var outDotSize: CGFloat { 8 }
private var teamFont: Font { .system(size: 18, design: .rounded) }
private var scoreFont: Font { .system(size: 20, design: .rounded) }
private var inningFont: Font { .system(size: 15, weight: .semibold, design: .rounded) }
#else
private var tileSpacing: CGFloat { 14 }
private var logoSize: CGFloat { 22 }
private var codeWidth: CGFloat { 34 }
private var scoreWidth: CGFloat { 24 }
private var outDotSize: CGFloat { 6 }
private var teamFont: Font { .system(size: 14, design: .rounded) }
private var scoreFont: Font { .system(size: 16, design: .rounded) }
private var inningFont: Font { .system(size: 12, weight: .semibold, design: .rounded) }
#endif
}
// MARK: - GameStatus helper
private extension GameStatus {
var liveInning: String? {
if case .live(let info) = self { return info }
return nil
}
}

View File

@@ -0,0 +1,52 @@
import SwiftUI
/// Compact R-H-E display for game cards
struct MiniLinescoreView: View {
let linescore: StatsLinescore
let awayCode: String
let homeCode: String
var body: some View {
HStack(spacing: 0) {
// Team abbreviations column
VStack(alignment: .leading, spacing: 2) {
Text(awayCode).foregroundStyle(TeamAssets.color(for: awayCode))
Text(homeCode).foregroundStyle(TeamAssets.color(for: homeCode))
}
.font(statFont.weight(.bold))
.frame(width: teamColWidth, alignment: .leading)
// R column
statColumn("R", away: linescore.teams?.away?.runs, home: linescore.teams?.home?.runs, bold: true)
// H column
statColumn("H", away: linescore.teams?.away?.hits, home: linescore.teams?.home?.hits, bold: false)
// E column
statColumn("E", away: linescore.teams?.away?.errors, home: linescore.teams?.home?.errors, bold: false)
}
}
@ViewBuilder
private func statColumn(_ label: String, away: Int?, home: Int?, bold: Bool) -> some View {
VStack(spacing: 2) {
Text(label)
.dataLabelStyle()
Text(away.map { "\($0)" } ?? "-")
.font(statFont.weight(bold ? .bold : .medium).monospacedDigit())
.foregroundStyle(bold ? DS.Colors.textPrimary : DS.Colors.textSecondary)
Text(home.map { "\($0)" } ?? "-")
.font(statFont.weight(bold ? .bold : .medium).monospacedDigit())
.foregroundStyle(bold ? DS.Colors.textPrimary : DS.Colors.textSecondary)
}
.frame(width: statColWidth)
}
#if os(tvOS)
private var statFont: Font { .system(size: 17, design: .rounded) }
private var teamColWidth: CGFloat { 50 }
private var statColWidth: CGFloat { 36 }
#else
private var statFont: Font { .system(size: 13, design: .rounded) }
private var teamColWidth: CGFloat { 36 }
private var statColWidth: CGFloat { 28 }
#endif
}

View File

@@ -0,0 +1,131 @@
import SwiftUI
/// Pitch type breakdown for a pitcher showing distribution and average velocity
struct PitchArsenalView: View {
let allPlays: [LiveFeedPlay]
let pitcherName: String
private var pitchSummary: [PitchTypeSummary] {
var grouped: [String: (count: Int, totalSpeed: Double, speedCount: Int)] = [:]
for play in allPlays {
guard let events = play.playEvents else { continue }
for event in events where event.isPitch == true {
let type = event.pitchTypeDescription
var entry = grouped[type, default: (0, 0, 0)]
entry.count += 1
if let speed = event.speedMPH {
entry.totalSpeed += speed
entry.speedCount += 1
}
grouped[type] = entry
}
}
let total = grouped.values.reduce(0) { $0 + $1.count }
guard total > 0 else { return [] }
return grouped.map { type, data in
PitchTypeSummary(
name: type,
count: data.count,
percentage: Double(data.count) / Double(total) * 100,
avgSpeed: data.speedCount > 0 ? data.totalSpeed / Double(data.speedCount) : nil
)
}
.sorted { $0.count > $1.count }
}
var body: some View {
let summary = pitchSummary
if !summary.isEmpty {
DataPanel(.standard) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("PITCH ARSENAL")
.dataLabelStyle()
Spacer()
Text(pitcherName)
.font(DS.Fonts.bodySmall)
.foregroundStyle(DS.Colors.textTertiary)
}
ForEach(summary, id: \.name) { pitch in
pitchRow(pitch, maxCount: summary.first?.count ?? 1)
}
}
}
}
}
@ViewBuilder
private func pitchRow(_ pitch: PitchTypeSummary, maxCount: Int) -> some View {
HStack(spacing: 10) {
Text(pitch.name)
.font(pitchNameFont)
.foregroundStyle(DS.Colors.textSecondary)
.frame(width: nameWidth, alignment: .leading)
.lineLimit(1)
GeometryReader { geo in
let fraction = maxCount > 0 ? CGFloat(pitch.count) / CGFloat(maxCount) : 0
RoundedRectangle(cornerRadius: 3)
.fill(barColor(for: pitch.name))
.frame(width: geo.size.width * fraction)
}
.frame(height: barHeight)
Text("\(Int(pitch.percentage))%")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: pctWidth, alignment: .trailing)
if let speed = pitch.avgSpeed {
Text("\(Int(speed))")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textTertiary)
.frame(width: speedWidth, alignment: .trailing)
Text("mph")
.dataLabelStyle()
}
}
}
private func barColor(for pitchType: String) -> Color {
let lowered = pitchType.lowercased()
if lowered.contains("fastball") || lowered.contains("sinker") || lowered.contains("cutter") {
return DS.Colors.live
}
if lowered.contains("slider") || lowered.contains("sweep") {
return DS.Colors.interactive
}
if lowered.contains("curve") || lowered.contains("knuckle") {
return DS.Colors.media
}
if lowered.contains("change") || lowered.contains("split") {
return DS.Colors.positive
}
return DS.Colors.warning
}
#if os(tvOS)
private var nameWidth: CGFloat { 140 }
private var pctWidth: CGFloat { 44 }
private var speedWidth: CGFloat { 36 }
private var barHeight: CGFloat { 14 }
private var pitchNameFont: Font { DS.Fonts.bodySmall }
#else
private var nameWidth: CGFloat { 100 }
private var pctWidth: CGFloat { 36 }
private var speedWidth: CGFloat { 30 }
private var barHeight: CGFloat { 10 }
private var pitchNameFont: Font { .system(size: 12, weight: .medium) }
#endif
}
private struct PitchTypeSummary {
let name: String
let count: Int
let percentage: Double
let avgSpeed: Double?
}

View File

@@ -0,0 +1,94 @@
import Charts
import SwiftUI
/// Full-game win probability line chart using Swift Charts
struct WinProbabilityChartView: View {
let entries: [WinProbabilityEntry]
let homeCode: String
let awayCode: String
private var homeColor: Color { TeamAssets.color(for: homeCode) }
private var awayColor: Color { TeamAssets.color(for: awayCode) }
var body: some View {
DataPanel(.standard) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("WIN PROBABILITY")
.dataLabelStyle()
Spacer()
if let latest = entries.last {
HStack(spacing: 12) {
probLabel(code: awayCode, value: latest.awayTeamWinProbability)
probLabel(code: homeCode, value: latest.homeTeamWinProbability)
}
}
}
Chart {
ForEach(Array(entries.enumerated()), id: \.offset) { index, entry in
if let wp = entry.homeTeamWinProbability {
LineMark(
x: .value("AB", index),
y: .value("WP", wp)
)
.foregroundStyle(homeColor)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("AB", index),
yStart: .value("Base", 50),
yEnd: .value("WP", wp)
)
.foregroundStyle(
wp >= 50
? homeColor.opacity(0.15)
: awayColor.opacity(0.15)
)
.interpolationMethod(.catmullRom)
}
}
RuleMark(y: .value("Even", 50))
.foregroundStyle(DS.Colors.textQuaternary)
.lineStyle(StrokeStyle(lineWidth: 0.5, dash: [4, 4]))
}
.chartYScale(domain: 0...100)
.chartYAxis {
AxisMarks(values: [0, 25, 50, 75, 100]) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.3))
.foregroundStyle(DS.Colors.textQuaternary)
AxisValueLabel {
Text("\(value.as(Int.self) ?? 0)%")
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
}
}
}
.chartXAxis(.hidden)
.frame(height: chartHeight)
}
}
}
@ViewBuilder
private func probLabel(code: String, value: Double?) -> some View {
HStack(spacing: 4) {
RoundedRectangle(cornerRadius: 2)
.fill(TeamAssets.color(for: code))
.frame(width: 3, height: 14)
Text(code)
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
Text(value.map { "\(Int($0))%" } ?? "-")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textPrimary)
}
}
#if os(tvOS)
private var chartHeight: CGFloat { 180 }
#else
private var chartHeight: CGFloat { 140 }
#endif
}

View File

@@ -13,15 +13,18 @@ struct ContentView: View {
var body: some View {
TabView {
Tab("Games", systemImage: "sportscourt.fill") {
Tab("Today", systemImage: "sportscourt.fill") {
DashboardView()
}
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") {
Tab("Intel", systemImage: "chart.bar.fill") {
LeagueCenterView()
}
Tab(multiViewLabel, systemImage: "rectangle.split.2x2.fill") {
MultiStreamView()
}
Tab("Feed", systemImage: "newspaper.fill") {
FeedView()
}
Tab("Settings", systemImage: "gearshape.fill") {
SettingsView()
}

View File

@@ -427,19 +427,22 @@ struct DashboardView: View {
}
@ViewBuilder
private func statPill(_ value: String, label: String, color: Color = .blue) -> some View {
private func statPill(_ value: String, label: String, color: Color = DS.Colors.interactive) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.title3.weight(.bold).monospacedDigit())
.font(DS.Fonts.dataValue)
.foregroundStyle(color)
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
.dataLabelStyle()
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
.background(DS.Colors.panelFill)
.overlay(
RoundedRectangle(cornerRadius: DS.Radii.compact)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
}
// MARK: - Featured Channels

View File

@@ -0,0 +1,194 @@
import AVKit
import SwiftUI
struct FeedView: View {
@Environment(GamesViewModel.self) private var gamesViewModel
@State private var viewModel = FeedViewModel()
@State private var playingURL: URL?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: DS.Spacing.sectionGap) {
// Header
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("HIGHLIGHTS")
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textQuaternary)
.kerning(3)
Text("Across the League")
#if os(tvOS)
.font(DS.Fonts.tvSectionTitle)
#else
.font(DS.Fonts.sectionTitle)
#endif
.foregroundStyle(DS.Colors.textPrimary)
}
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
if viewModel.highlights.isEmpty && !viewModel.isLoading {
emptyState
} else {
// All highlights in one horizontal scroll, ordered by time
ScrollView(.horizontal) {
LazyHStack(spacing: DS.Spacing.cardGap) {
ForEach(viewModel.highlights) { item in
highlightCard(item)
.frame(width: cardWidth)
}
}
.padding(.vertical, 8)
}
.platformFocusSection()
.scrollClipDisabled()
}
}
.padding(.horizontal, edgeInset)
.padding(.vertical, DS.Spacing.sectionGap)
}
.task {
await viewModel.loadHighlights(games: gamesViewModel.games)
}
.onChange(of: gamesViewModel.games.count) {
Task { await viewModel.loadHighlights(games: gamesViewModel.games) }
}
.onAppear { viewModel.startAutoRefresh(games: gamesViewModel.games) }
.onDisappear { viewModel.stopAutoRefresh() }
.fullScreenCover(isPresented: Binding(
get: { playingURL != nil },
set: { if !$0 { playingURL = nil } }
)) {
if let url = playingURL {
let player = AVPlayer(url: url)
VideoPlayer(player: player)
.ignoresSafeArea()
.onAppear { player.play() }
.onDisappear { player.pause() }
}
}
}
@ViewBuilder
private func highlightCard(_ item: HighlightItem) -> some View {
Button {
playingURL = item.hlsURL ?? item.mp4URL
} label: {
VStack(alignment: .leading, spacing: 10) {
// Thumbnail area with team colors
ZStack {
HStack(spacing: 0) {
Rectangle().fill(TeamAssets.color(for: item.awayCode).opacity(0.3))
Rectangle().fill(TeamAssets.color(for: item.homeCode).opacity(0.3))
}
HStack(spacing: thumbnailLogoGap) {
TeamLogoView(
team: TeamInfo(code: item.awayCode, name: "", score: nil),
size: thumbnailLogoSize
)
Text("@")
.font(.system(size: atFontSize, weight: .bold))
.foregroundStyle(DS.Colors.textTertiary)
TeamLogoView(
team: TeamInfo(code: item.homeCode, name: "", score: nil),
size: thumbnailLogoSize
)
}
// Play icon overlay
Image(systemName: "play.circle.fill")
.font(.system(size: playIconSize))
.foregroundStyle(.white.opacity(0.8))
.shadow(radius: 4)
// Condensed/Recap badge
if item.isCondensedGame {
VStack {
HStack {
Spacer()
Text("CONDENSED")
.font(DS.Fonts.caption)
.foregroundStyle(.white)
.kerning(0.8)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(DS.Colors.media)
.clipShape(Capsule())
}
Spacer()
}
.padding(8)
}
}
.frame(height: thumbnailHeight)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
// Info
VStack(alignment: .leading, spacing: 4) {
Text(item.gameTitle)
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
.kerning(1)
Text(item.headline)
.font(headlineFont)
.foregroundStyle(DS.Colors.textPrimary)
.lineLimit(2)
}
.padding(.horizontal, 4)
}
.padding(DS.Spacing.panelPadCompact)
.background(DS.Colors.panelFill)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.standard))
.overlay(
RoundedRectangle(cornerRadius: DS.Radii.standard)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
)
}
.platformCardStyle()
}
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "play.rectangle.on.rectangle")
.font(.system(size: 44))
.foregroundStyle(DS.Colors.textQuaternary)
Text("No highlights available yet")
.font(DS.Fonts.body)
.foregroundStyle(DS.Colors.textTertiary)
Text("Highlights appear as games are played")
.font(DS.Fonts.bodySmall)
.foregroundStyle(DS.Colors.textQuaternary)
}
.frame(maxWidth: .infinity)
.padding(.top, 80)
}
// MARK: - Platform sizing
#if os(tvOS)
private var edgeInset: CGFloat { 60 }
private var cardWidth: CGFloat { 420 }
private var thumbnailHeight: CGFloat { 200 }
private var thumbnailLogoSize: CGFloat { 56 }
private var thumbnailLogoGap: CGFloat { 24 }
private var playIconSize: CGFloat { 44 }
private var atFontSize: CGFloat { 20 }
private var headlineFont: Font { .system(size: 18, weight: .semibold) }
#else
private var edgeInset: CGFloat { 20 }
private var cardWidth: CGFloat { 280 }
private var thumbnailHeight: CGFloat { 140 }
private var thumbnailLogoSize: CGFloat { 40 }
private var thumbnailLogoGap: CGFloat { 16 }
private var playIconSize: CGFloat { 32 }
private var atFontSize: CGFloat { 15 }
private var headlineFont: Font { .system(size: 15, weight: .semibold) }
#endif
}

View File

@@ -28,13 +28,24 @@ struct GameCardView: View {
}
.padding(.horizontal, 22)
Spacer(minLength: 14)
// Mini linescore for live/final games
if let linescore = game.linescore, !game.status.isScheduled {
MiniLinescoreView(
linescore: linescore,
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code
)
.padding(.horizontal, 22)
.padding(.top, 10)
}
Spacer(minLength: 10)
footer
.padding(.horizontal, 22)
.padding(.vertical, 16)
.padding(.vertical, 14)
}
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: 320, alignment: .topLeading)
.frame(maxWidth: .infinity, minHeight: 320, alignment: .topLeading)
.background(cardBackground)
.overlay(alignment: .top) {
HStack(spacing: 0) {

View File

@@ -21,7 +21,22 @@ struct GameCenterView: View {
atBatPanel(feed: feed)
}
if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
// Pitch Arsenal for current pitcher
if let pitcherName = feed.currentPitcher?.fullName {
PitchArsenalView(
allPlays: feed.liveData.plays.allPlays,
pitcherName: pitcherName
)
}
// Win Probability Chart (full game timeline)
if !viewModel.winProbabilityHistory.isEmpty {
WinProbabilityChartView(
entries: viewModel.winProbabilityHistory,
homeCode: game.homeTeam.code,
awayCode: game.awayTeam.code
)
} else if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
winProbabilityPanel(home: wpHome, away: wpAway)
}

View File

@@ -34,8 +34,13 @@ struct LeagueCenterView: View {
messagePanel(overviewErrorMessage, tint: .orange)
}
scheduleSection
standingsSection
// League Leaders
if !viewModel.leagueLeaders.isEmpty {
leadersSection
}
teamsSection
if let selectedTeam = viewModel.selectedTeam {
@@ -73,7 +78,7 @@ struct LeagueCenterView: View {
.font(.system(size: 42, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Schedules, standings, team context, roster access, and player snapshots in one control room.")
Text("Standings, league leaders, team context, roster access, and player snapshots in one control room.")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
}
@@ -81,7 +86,7 @@ struct LeagueCenterView: View {
Spacer()
HStack(spacing: 12) {
infoPill(title: "\(viewModel.scheduleGames.count)", label: "Games", color: .blue)
infoPill(title: "\(viewModel.leagueLeaders.count)", label: "Leaders", color: .blue)
infoPill(title: "\(viewModel.teams.count)", label: "Teams", color: .green)
infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange)
}
@@ -138,8 +143,10 @@ struct LeagueCenterView: View {
selectedGame = linkedGame
}
} label: {
HStack(spacing: 18) {
HStack(spacing: 0) {
teamMiniColumn(team: game.teams.away)
.frame(width: scheduleTeamColWidth, alignment: .leading)
VStack(spacing: 6) {
Text(scoreText(for: game))
.font(.system(size: 28, weight: .black, design: .rounded))
@@ -150,11 +157,10 @@ struct LeagueCenterView: View {
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(statusColor(for: game))
}
.frame(width: 160)
.frame(width: scheduleScoreColWidth)
teamMiniColumn(team: game.teams.home, alignTrailing: true)
Spacer()
.frame(width: scheduleTeamColWidth, alignment: .trailing)
VStack(alignment: .trailing, spacing: 6) {
if let venue = game.venue?.name {
@@ -168,6 +174,7 @@ struct LeagueCenterView: View {
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : .white.opacity(0.34))
}
.frame(width: scheduleVenueColWidth, alignment: .trailing)
}
.padding(22)
.background(sectionPanel)
@@ -176,6 +183,16 @@ struct LeagueCenterView: View {
.disabled(linkedGame == nil)
}
#if os(tvOS)
private var scheduleTeamColWidth: CGFloat { 340 }
private var scheduleScoreColWidth: CGFloat { 160 }
private var scheduleVenueColWidth: CGFloat { 220 }
#else
private var scheduleTeamColWidth: CGFloat { 200 }
private var scheduleScoreColWidth: CGFloat { 120 }
private var scheduleVenueColWidth: CGFloat { 160 }
#endif
private func teamMiniColumn(team: StatsTeamGameInfo, alignTrailing: Bool = false) -> some View {
let info = TeamInfo(
code: team.team.abbreviation ?? "MLB",
@@ -211,7 +228,43 @@ struct LeagueCenterView: View {
TeamLogoView(team: info, size: 56)
}
}
.frame(maxWidth: .infinity, alignment: alignTrailing ? .trailing : .leading)
}
private var leadersSection: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
Text("League Leaders")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Spacer()
if viewModel.isLoadingLeaders {
ProgressView()
}
}
ScrollView(.horizontal) {
LazyHStack(spacing: DS.Spacing.cardGap) {
ForEach(viewModel.leagueLeaders) { category in
LeaderboardView(category: category)
.frame(width: leaderCardWidth)
.platformFocusable()
}
}
.padding(.vertical, 8)
}
.platformFocusSection()
.scrollClipDisabled()
}
}
private var leaderCardWidth: CGFloat {
#if os(tvOS)
380
#else
280
#endif
}
private var standingsSection: some View {
@@ -224,13 +277,14 @@ struct LeagueCenterView: View {
loadingPanel(title: "Loading standings...")
} else {
ScrollView(.horizontal) {
HStack(spacing: 18) {
LazyHStack(spacing: 18) {
ForEach(viewModel.standings, id: \.division?.id) { record in
standingsCard(record)
.frame(width: 360)
.platformFocusable()
}
}
.padding(.vertical, 4)
.padding(.vertical, 8)
}
.platformFocusSection()
.scrollClipDisabled()