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>
This commit is contained in:
Trey t
2026-04-12 13:25:36 -05:00
parent b5daddefd3
commit cd605d889d
6 changed files with 244 additions and 196 deletions

View File

@@ -33,7 +33,7 @@ actor MLBStatsAPI {
func fetchStandingsRecords(season: String) async throws -> [StandingsDivisionRecord] { func fetchStandingsRecords(season: String) async throws -> [StandingsDivisionRecord] {
let response: StandingsResponse = try await fetchJSON( 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 return response.records
} }

View File

@@ -9,81 +9,94 @@ private func logFeed(_ message: String) {
print("[Feed] \(message)") print("[Feed] \(message)")
} }
enum FeedItemType: Sendable { struct HighlightItem: Identifiable, Sendable {
case news
case transaction
case scoring
}
struct FeedItem: Identifiable, Sendable {
let id: String let id: String
let type: FeedItemType let headline: String
let title: String let gameTitle: String
let subtitle: String let awayCode: String
let teamCode: String? let homeCode: String
let timestamp: Date let hlsURL: URL?
let mp4URL: URL?
let isCondensedGame: Bool
} }
@Observable @Observable
@MainActor @MainActor
final class FeedViewModel { final class FeedViewModel {
var items: [FeedItem] = [] var highlights: [HighlightItem] = []
var isLoading = false var isLoading = false
@ObservationIgnored @ObservationIgnored
private var refreshTask: Task<Void, Never>? private var refreshTask: Task<Void, Never>?
private let webService = MLBWebDataService() private let serverAPI = MLBServerAPI()
func loadFeed() async { func loadHighlights(games: [Game]) async {
isLoading = true isLoading = true
logFeed("loadFeed start") logFeed("loadHighlights start gameCount=\(games.count)")
async let newsTask = webService.fetchNewsHeadlines() let gamesWithPk = games.filter { $0.gamePk != nil }
async let transactionsTask = webService.fetchTransactions()
let news = await newsTask // Fetch highlights for all games concurrently
let transactions = await transactionsTask 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.compactMap { highlight -> HighlightItem? in
guard let headline = highlight.headline,
let hlsStr = highlight.hlsURL ?? highlight.mp4URL,
let _ = URL(string: hlsStr) else { return nil }
var allItems: [FeedItem] = [] let isCondensed = headline.lowercased().contains("condensed")
|| headline.lowercased().contains("recap")
// News return HighlightItem(
for headline in news { id: highlight.id ?? UUID().uuidString,
allItems.append(FeedItem( headline: headline,
id: "news-\(headline.id)", gameTitle: "\(game.awayTeam.code) @ \(game.homeTeam.code)",
type: .news, awayCode: game.awayTeam.code,
title: headline.title, homeCode: game.homeTeam.code,
subtitle: headline.summary, hlsURL: highlight.hlsURL.flatMap(URL.init),
teamCode: nil, mp4URL: highlight.mp4URL.flatMap(URL.init),
timestamp: headline.timestamp isCondensedGame: isCondensed
)) )
}
} catch {
return []
}
}
}
var allHighlights: [HighlightItem] = []
for await batch in group {
allHighlights.append(contentsOf: batch)
}
highlights = allHighlights
} }
// Transactions
for tx in transactions {
allItems.append(FeedItem(
id: "tx-\(tx.id)",
type: .transaction,
title: tx.description,
subtitle: tx.type,
teamCode: tx.teamCode.isEmpty ? nil : tx.teamCode,
timestamp: tx.date
))
}
// Sort reverse chronological
items = allItems.sorted { $0.timestamp > $1.timestamp }
isLoading = false isLoading = false
logFeed("loadFeed complete items=\(items.count)") logFeed("loadHighlights complete count=\(highlights.count)")
} }
func startAutoRefresh() { var condensedGames: [HighlightItem] {
highlights.filter(\.isCondensedGame)
}
var latestHighlights: [HighlightItem] {
highlights.filter { !$0.isCondensedGame }
}
func startAutoRefresh(games: [Game]) {
stopAutoRefresh() stopAutoRefresh()
refreshTask = Task { [weak self] in refreshTask = Task { [weak self] in
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(for: .seconds(300)) try? await Task.sleep(for: .seconds(300))
guard !Task.isCancelled, let self else { break } guard !Task.isCancelled, let self else { break }
await self.loadFeed() await self.loadHighlights(games: games)
} }
} }
} }

View File

@@ -1,112 +1,3 @@
import SwiftUI import SwiftUI
struct FeedItemView: View { // Placeholder highlight cards are now inline in FeedView
let item: FeedItem
private var accentColor: Color {
switch item.type {
case .news: DS.Colors.interactive
case .transaction: DS.Colors.positive
case .scoring: DS.Colors.live
}
}
private var iconName: String {
switch item.type {
case .news: "newspaper.fill"
case .transaction: "arrow.left.arrow.right"
case .scoring: "sportscourt.fill"
}
}
private var typeLabel: String {
switch item.type {
case .news: "NEWS"
case .transaction: "TRANSACTION"
case .scoring: "SCORING"
}
}
var body: some View {
HStack(spacing: 0) {
// Colored edge bar
RoundedRectangle(cornerRadius: 1.5)
.fill(accentColor)
.frame(width: 3)
.padding(.vertical, 8)
HStack(spacing: 12) {
Image(systemName: iconName)
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(accentColor)
.frame(width: iconFrame, height: iconFrame)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(typeLabel)
.font(DS.Fonts.caption)
.foregroundStyle(accentColor)
.kerning(1)
if let code = item.teamCode {
HStack(spacing: 4) {
RoundedRectangle(cornerRadius: 1)
.fill(TeamAssets.color(for: code))
.frame(width: 2, height: 10)
Text(code)
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
}
}
Spacer()
Text(timeAgo(item.timestamp))
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textQuaternary)
}
Text(item.title)
.font(titleFont)
.foregroundStyle(DS.Colors.textPrimary)
.lineLimit(2)
if !item.subtitle.isEmpty {
Text(item.subtitle)
.font(subtitleFont)
.foregroundStyle(DS.Colors.textTertiary)
.lineLimit(1)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
.background(DS.Colors.panelFill)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
.overlay(
RoundedRectangle(cornerRadius: DS.Radii.compact)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
)
}
private func timeAgo(_ date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 { return "Just now" }
if interval < 3600 { return "\(Int(interval / 60))m ago" }
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
return "\(Int(interval / 86400))d ago"
}
#if os(tvOS)
private var iconSize: CGFloat { 18 }
private var iconFrame: CGFloat { 36 }
private var titleFont: Font { .system(size: 18, weight: .semibold) }
private var subtitleFont: Font { DS.Fonts.bodySmall }
#else
private var iconSize: CGFloat { 14 }
private var iconFrame: CGFloat { 28 }
private var titleFont: Font { .system(size: 15, weight: .semibold) }
private var subtitleFont: Font { .system(size: 12, weight: .medium) }
#endif
}

View File

@@ -147,9 +147,6 @@ struct DashboardView: View {
} }
} }
if !viewModel.liveGames.isEmpty {
gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id)
}
if !viewModel.scheduledGames.isEmpty { if !viewModel.scheduledGames.isEmpty {
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id) gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id)
} }

View File

@@ -1,19 +1,22 @@
import AVKit
import SwiftUI import SwiftUI
struct FeedView: View { struct FeedView: View {
@Environment(GamesViewModel.self) private var gamesViewModel
@State private var viewModel = FeedViewModel() @State private var viewModel = FeedViewModel()
@State private var playingURL: URL?
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: DS.Spacing.cardGap) { VStack(alignment: .leading, spacing: DS.Spacing.sectionGap) {
// Header // Header
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("FEED") Text("HIGHLIGHTS")
.font(DS.Fonts.caption) .font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textQuaternary) .foregroundStyle(DS.Colors.textQuaternary)
.kerning(3) .kerning(3)
Text("Latest Intel") Text("Across the League")
#if os(tvOS) #if os(tvOS)
.font(DS.Fonts.tvSectionTitle) .font(DS.Fonts.tvSectionTitle)
#else #else
@@ -29,43 +32,174 @@ struct FeedView: View {
} }
} }
if viewModel.items.isEmpty && !viewModel.isLoading { if viewModel.highlights.isEmpty && !viewModel.isLoading {
VStack(spacing: 16) { emptyState
Image(systemName: "newspaper")
.font(.system(size: 44))
.foregroundStyle(DS.Colors.textQuaternary)
Text("No feed items yet")
.font(DS.Fonts.body)
.foregroundStyle(DS.Colors.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.top, 80)
} else { } else {
LazyVStack(spacing: DS.Spacing.itemGap) { // Condensed Games
ForEach(viewModel.items) { item in if !viewModel.condensedGames.isEmpty {
FeedItemView(item: item) highlightShelf(
} title: "Condensed Games",
icon: "film.stack",
items: viewModel.condensedGames
)
}
// Latest Highlights
if !viewModel.latestHighlights.isEmpty {
highlightShelf(
title: "Latest Highlights",
icon: "play.circle.fill",
items: viewModel.latestHighlights
)
} }
} }
} }
.padding(.horizontal, edgeInset) .padding(.horizontal, edgeInset)
.padding(.vertical, DS.Spacing.sectionGap) .padding(.vertical, DS.Spacing.sectionGap)
} }
.background(DS.Colors.background)
.task { .task {
await viewModel.loadFeed() await viewModel.loadHighlights(games: gamesViewModel.games)
} }
.onAppear { .onChange(of: gamesViewModel.games.count) {
viewModel.startAutoRefresh() Task { await viewModel.loadHighlights(games: gamesViewModel.games) }
} }
.onDisappear { .onAppear { viewModel.startAutoRefresh(games: gamesViewModel.games) }
viewModel.stopAutoRefresh() .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 highlightShelf(title: String, icon: String, items: [HighlightItem]) -> some View {
VStack(alignment: .leading, spacing: 14) {
Label(title, systemImage: icon)
#if os(tvOS)
.font(.system(size: 24, weight: .bold, design: .rounded))
#else
.font(.system(size: 18, weight: .bold, design: .rounded))
#endif
.foregroundStyle(DS.Colors.textSecondary)
ScrollView(.horizontal) {
LazyHStack(spacing: DS.Spacing.cardGap) {
ForEach(items) { item in
highlightCard(item)
.frame(width: cardWidth)
}
}
.padding(.vertical, 8)
}
.platformFocusSection()
.scrollClipDisabled()
}
}
@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)
}
.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) #if os(tvOS)
private var edgeInset: CGFloat { 60 } 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 #else
private var edgeInset: CGFloat { 20 } 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 #endif
} }

View File

@@ -34,7 +34,6 @@ struct LeagueCenterView: View {
messagePanel(overviewErrorMessage, tint: .orange) messagePanel(overviewErrorMessage, tint: .orange)
} }
scheduleSection
standingsSection standingsSection
// League Leaders // League Leaders
@@ -79,7 +78,7 @@ struct LeagueCenterView: View {
.font(.system(size: 42, weight: .bold, design: .rounded)) .font(.system(size: 42, weight: .bold, design: .rounded))
.foregroundStyle(.white) .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)) .font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.58)) .foregroundStyle(.white.opacity(0.58))
} }
@@ -87,7 +86,7 @@ struct LeagueCenterView: View {
Spacer() Spacer()
HStack(spacing: 12) { 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.teams.count)", label: "Teams", color: .green)
infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange) infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange)
} }
@@ -144,8 +143,10 @@ struct LeagueCenterView: View {
selectedGame = linkedGame selectedGame = linkedGame
} }
} label: { } label: {
HStack(spacing: 18) { HStack(spacing: 0) {
teamMiniColumn(team: game.teams.away) teamMiniColumn(team: game.teams.away)
.frame(width: scheduleTeamColWidth, alignment: .leading)
VStack(spacing: 6) { VStack(spacing: 6) {
Text(scoreText(for: game)) Text(scoreText(for: game))
.font(.system(size: 28, weight: .black, design: .rounded)) .font(.system(size: 28, weight: .black, design: .rounded))
@@ -156,11 +157,10 @@ struct LeagueCenterView: View {
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(statusColor(for: game)) .foregroundStyle(statusColor(for: game))
} }
.frame(width: 160) .frame(width: scheduleScoreColWidth)
teamMiniColumn(team: game.teams.home, alignTrailing: true) teamMiniColumn(team: game.teams.home, alignTrailing: true)
.frame(width: scheduleTeamColWidth, alignment: .trailing)
Spacer()
VStack(alignment: .trailing, spacing: 6) { VStack(alignment: .trailing, spacing: 6) {
if let venue = game.venue?.name { if let venue = game.venue?.name {
@@ -174,6 +174,7 @@ struct LeagueCenterView: View {
.font(.system(size: 13, weight: .bold, design: .rounded)) .font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : .white.opacity(0.34)) .foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : .white.opacity(0.34))
} }
.frame(width: scheduleVenueColWidth, alignment: .trailing)
} }
.padding(22) .padding(22)
.background(sectionPanel) .background(sectionPanel)
@@ -182,6 +183,16 @@ struct LeagueCenterView: View {
.disabled(linkedGame == nil) .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 { private func teamMiniColumn(team: StatsTeamGameInfo, alignTrailing: Bool = false) -> some View {
let info = TeamInfo( let info = TeamInfo(
code: team.team.abbreviation ?? "MLB", code: team.team.abbreviation ?? "MLB",
@@ -217,7 +228,6 @@ struct LeagueCenterView: View {
TeamLogoView(team: info, size: 56) TeamLogoView(team: info, size: 56)
} }
} }
.frame(maxWidth: .infinity, alignment: alignTrailing ? .trailing : .leading)
} }
private var leadersSection: some View { private var leadersSection: some View {
@@ -239,10 +249,12 @@ struct LeagueCenterView: View {
ForEach(viewModel.leagueLeaders) { category in ForEach(viewModel.leagueLeaders) { category in
LeaderboardView(category: category) LeaderboardView(category: category)
.frame(width: leaderCardWidth) .frame(width: leaderCardWidth)
.platformFocusable()
} }
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} }
.platformFocusSection()
.scrollClipDisabled() .scrollClipDisabled()
} }
} }
@@ -265,13 +277,14 @@ struct LeagueCenterView: View {
loadingPanel(title: "Loading standings...") loadingPanel(title: "Loading standings...")
} else { } else {
ScrollView(.horizontal) { ScrollView(.horizontal) {
HStack(spacing: 18) { LazyHStack(spacing: 18) {
ForEach(viewModel.standings, id: \.division?.id) { record in ForEach(viewModel.standings, id: \.division?.id) { record in
standingsCard(record) standingsCard(record)
.frame(width: 360) .frame(width: 360)
.platformFocusable()
} }
} }
.padding(.vertical, 4) .padding(.vertical, 8)
} }
.platformFocusSection() .platformFocusSection()
.scrollClipDisabled() .scrollClipDisabled()