diff --git a/BroadcastBrain.xcodeproj/project.pbxproj b/BroadcastBrain.xcodeproj/project.pbxproj index 15c4b179..475f3cd9 100644 --- a/BroadcastBrain.xcodeproj/project.pbxproj +++ b/BroadcastBrain.xcodeproj/project.pbxproj @@ -7,8 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 00799F9CED5F78ECAA28985A /* StoryFirstSpottingBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22F62487F851CC49522E8D29 /* StoryFirstSpottingBoardView.swift */; }; 034498E25A8850984493C9E2 /* SessionStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1186F00AC31EE683675AE6 /* SessionStoreTests.swift */; }; 053617CC8A80BFBB884CF402 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1F4DAD40C5C4BFB52EA58D /* SidebarView.swift */; }; + 06A1F33ECBAF18586AC02A37 /* NewsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA68D7CA900438A10D7B4AC9 /* NewsService.swift */; }; 0CC48F247E683B3FB185A6FB /* PlayByPlayStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846A697EC3777956030AC4C3 /* PlayByPlayStore.swift */; }; 0E428B0EC28CFB5A54FCA9CC /* WhisperEngineBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC36DF2FCFDC28B51429E9F3 /* WhisperEngineBehaviorTests.swift */; }; 0F02EBD664051BF052EF7012 /* ModelCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2491DE25F4F75DA94526903 /* ModelCodableTests.swift */; }; @@ -25,6 +27,7 @@ 43DB7EFEFC8C19A84E734EF6 /* DottedGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A885FF039D9F376DE27C84 /* DottedGrid.swift */; }; 45CB73E5789C135680D10F96 /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9C5C443A7D8D3D41E5B0E13 /* FlowLayout.swift */; }; 4888A82003BDA03F00D954CD /* LivePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79E4BA0F9248D3466873BBE /* LivePill.swift */; }; + 4DD529CF07EB0CCAB762BEAF /* NewsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE805D3AE864A2EC89074CF1 /* NewsTabView.swift */; }; 57015F98A968ED10EC4E21A1 /* SpeechSynthesisService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 417593F6AF33889059160790 /* SpeechSynthesisService.swift */; }; 5762330EE915C89A5F296047 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = F449643EC9EA6D40E8E3C210 /* ZIPFoundation */; }; 5874FC398254108334E944D0 /* PlaysSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 721C2BA2DE9BCD5F29F526F0 /* PlaysSearchView.swift */; }; @@ -38,6 +41,8 @@ 75823BF5B5A04A60E2C364D1 /* Tokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51919B20535309C89D4E15B9 /* Tokens.swift */; }; 76BD684BF4DDC88E49946E2E /* BroadcastBrainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90487A3CA3F809A1CFB8ED20 /* BroadcastBrainApp.swift */; }; 77475FBA9D6370A088D71876 /* StatusBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2069035A22848052FAF13B0 /* StatusBarView.swift */; }; + 7B4224664340A548BC7BDC06 /* StatsFirstSpottingBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3CC277C30947157232CFF61 /* StatsFirstSpottingBoardView.swift */; }; + 7B7EF85462746C3A72D2CF0B /* CommentatorStylePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3D58CDEAD133FF9CB941E /* CommentatorStylePickerView.swift */; }; 7EE3DFD6034A989F9599CB72 /* ThemeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF6CAC9075A33368706F52FE /* ThemeStore.swift */; }; 8597296F58177F87BE1CEE5E /* TranscriptOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AAA500ADFDDF5CAD7707BAD /* TranscriptOverlay.swift */; }; 875A1F9F21CE58F78EBD1DAE /* ArchivesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC045EB8451818A6CD5A1ED /* ArchivesListView.swift */; }; @@ -53,15 +58,19 @@ B6F4346E4C192BF816F68EAA /* Cactus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82B9451069B6343C41C337AC /* Cactus.swift */; }; BA8DD13242C5D90231FBF21E /* ArchiveDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05EF9474EB26DF74B8D7FDAA /* ArchiveDetailView.swift */; }; BBE476D5CF788B4125A50962 /* PlaysDBView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1762BD9961ECE5CAC332508 /* PlaysDBView.swift */; }; + C072623647D8C5AF5C70080D /* GeminiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBAB314A003AE79E3D7E3791 /* GeminiService.swift */; }; C2EC1CB5DC0D4914B540E12A /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9FC0FE9D60DC37A1594C96 /* Session.swift */; }; C418A911A2E29B77BEAEDAF8 /* NewMatchSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E714CFC5D1F7C7748D159C9D /* NewMatchSheet.swift */; }; C51482DD2F2F981AD8D640F5 /* SportradarBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46EAD55D55FDE50021FA38E3 /* SportradarBadge.swift */; }; + CC3B9BF6F21086368795B456 /* TeamSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE1F0EA67AFC55CE71E2374 /* TeamSetupView.swift */; }; D2F4BB6524F4500D144AF80C /* CactusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90AFD39809B3E0BE8DF8644B /* CactusService.swift */; }; D44BEC0D3D9840E6A3A7B4E8 /* Fixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449F32AE708B8F0B7E1572BA /* Fixture.swift */; }; + D72A2274675152B2E545ED57 /* TacticalSpottingBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F12695D4CB88A451D3918D /* TacticalSpottingBoardView.swift */; }; DC5AA753C120F87BE6240707 /* PlayByPlayKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5C1A4F9118E151B9F0F57F22 /* PlayByPlayKit */; }; DC645883CE0EC00771D86B39 /* WhisperEngineParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3B870DEFD9BD98A3095FDE /* WhisperEngineParsingTests.swift */; }; DF2E26836A96D0D12400BA94 /* ListeningDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE877181D0D2EB6F44FCF490 /* ListeningDot.swift */; }; E05C57FFDF0F3B9977139753 /* LatencyTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE6D73A8667738DD33BAF2B /* LatencyTag.swift */; }; + E16958E56C7E41CCDE6D3B46 /* GameFetchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12F4621EA52FEBDFBBA9E09 /* GameFetchService.swift */; }; E62E673B7510F8836F233C33 /* GamePickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FDFF807FEA6B6676655170 /* GamePickerSection.swift */; }; ED24D8E788873134C2FEEBCA /* SessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F5FE8926459F794E4A039D /* SessionStore.swift */; }; EEFBFCCA69EEBE73B8C60048 /* WhisperEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E07D63CC5BC4EF184BC54E /* WhisperEngine.swift */; }; @@ -97,15 +106,18 @@ 01550DE4B92113B1173C6B2F /* LivePaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePaneView.swift; sourceTree = ""; }; 029080D103ECBCC33E9E38F8 /* PlayerCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCellView.swift; sourceTree = ""; }; 05EF9474EB26DF74B8D7FDAA /* ArchiveDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveDetailView.swift; sourceTree = ""; }; + 0CE1F0EA67AFC55CE71E2374 /* TeamSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamSetupView.swift; sourceTree = ""; }; 10B521A0B659765D883F5E4C /* StackCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackCard.swift; sourceTree = ""; }; 1227A240F189BB798494FD0F /* Waveform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Waveform.swift; sourceTree = ""; }; 1BDB3FB2A46D89FB8F6E528D /* BroadcastBrain.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BroadcastBrain.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2268C5BB146969E1B046A49E /* ResearchCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResearchCenterView.swift; sourceTree = ""; }; + 22F62487F851CC49522E8D29 /* StoryFirstSpottingBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryFirstSpottingBoardView.swift; sourceTree = ""; }; 4132AE323E5FEB98D3E225B8 /* cactus-macos.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "cactus-macos.xcframework"; path = "BroadcastBrain/Frameworks/cactus-macos.xcframework"; sourceTree = ""; }; 417593F6AF33889059160790 /* SpeechSynthesisService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechSynthesisService.swift; sourceTree = ""; }; 449F32AE708B8F0B7E1572BA /* Fixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fixture.swift; sourceTree = ""; }; 46EAD55D55FDE50021FA38E3 /* SportradarBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SportradarBadge.swift; sourceTree = ""; }; 51919B20535309C89D4E15B9 /* Tokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tokens.swift; sourceTree = ""; }; + 54D3D58CDEAD133FF9CB941E /* CommentatorStylePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentatorStylePickerView.swift; sourceTree = ""; }; 56A3F4819D23C1B76C1674EA /* Info 2.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Info 2.plist"; sourceTree = ""; }; 6B70ED90C7C37542D0CE02C3 /* WhisperSkipReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhisperSkipReason.swift; sourceTree = ""; }; 721C2BA2DE9BCD5F29F526F0 /* PlaysSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaysSearchView.swift; sourceTree = ""; }; @@ -144,17 +156,23 @@ C706D6F845EC11E12CA3B0F5 /* BroadcastBrain.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BroadcastBrain.entitlements; sourceTree = ""; }; C9F5FE8926459F794E4A039D /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = ""; }; CD1186F00AC31EE683675AE6 /* SessionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStoreTests.swift; sourceTree = ""; }; + D12F4621EA52FEBDFBBA9E09 /* GameFetchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameFetchService.swift; sourceTree = ""; }; D4AB5137E64A892F3AD5B6E2 /* ModelSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelSetupView.swift; sourceTree = ""; }; D874A533E0F018A9307A1ED9 /* SentenceExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceExtractorTests.swift; sourceTree = ""; }; DC36DF2FCFDC28B51429E9F3 /* WhisperEngineBehaviorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhisperEngineBehaviorTests.swift; sourceTree = ""; }; + E3CC277C30947157232CFF61 /* StatsFirstSpottingBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsFirstSpottingBoardView.swift; sourceTree = ""; }; E5059B3472C8B2022AC33356 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; E53DB1034B149B17293FE383 /* GlassSegmentedPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassSegmentedPicker.swift; sourceTree = ""; }; E714CFC5D1F7C7748D159C9D /* NewMatchSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMatchSheet.swift; sourceTree = ""; }; E7F6464FB0E115048B55C10D /* ProseFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProseFallbackTests.swift; sourceTree = ""; }; + EA68D7CA900438A10D7B4AC9 /* NewsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsService.swift; sourceTree = ""; }; EE877181D0D2EB6F44FCF490 /* ListeningDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningDot.swift; sourceTree = ""; }; EF6CAC9075A33368706F52FE /* ThemeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStore.swift; sourceTree = ""; }; F1762BD9961ECE5CAC332508 /* PlaysDBView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaysDBView.swift; sourceTree = ""; }; + F1F12695D4CB88A451D3918D /* TacticalSpottingBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalSpottingBoardView.swift; sourceTree = ""; }; + FBAB314A003AE79E3D7E3791 /* GeminiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiService.swift; sourceTree = ""; }; FD1F4DAD40C5C4BFB52EA58D /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; + FE805D3AE864A2EC89074CF1 /* NewsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsTabView.swift; sourceTree = ""; }; FF9FC0FE9D60DC37A1594C96 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -177,10 +195,12 @@ children = ( 05EF9474EB26DF74B8D7FDAA /* ArchiveDetailView.swift */, 8CC045EB8451818A6CD5A1ED /* ArchivesListView.swift */, + 54D3D58CDEAD133FF9CB941E /* CommentatorStylePickerView.swift */, A3FDFF807FEA6B6676655170 /* GamePickerSection.swift */, 01550DE4B92113B1173C6B2F /* LivePaneView.swift */, D4AB5137E64A892F3AD5B6E2 /* ModelSetupView.swift */, E714CFC5D1F7C7748D159C9D /* NewMatchSheet.swift */, + FE805D3AE864A2EC89074CF1 /* NewsTabView.swift */, F1762BD9961ECE5CAC332508 /* PlaysDBView.swift */, 721C2BA2DE9BCD5F29F526F0 /* PlaysSearchView.swift */, 7C131AA2D56A147325E0E848 /* PlaysStreamView.swift */, @@ -188,6 +208,10 @@ C549E020D2946804B2495198 /* SentenceExtractor.swift */, FD1F4DAD40C5C4BFB52EA58D /* SidebarView.swift */, 8F4CA2B25FE5241CA80B742B /* SquadsView.swift */, + E3CC277C30947157232CFF61 /* StatsFirstSpottingBoardView.swift */, + 22F62487F851CC49522E8D29 /* StoryFirstSpottingBoardView.swift */, + F1F12695D4CB88A451D3918D /* TacticalSpottingBoardView.swift */, + 0CE1F0EA67AFC55CE71E2374 /* TeamSetupView.swift */, 576EFBB721C77F668145738B /* Components */, ); path = Views; @@ -282,7 +306,10 @@ B83E5A0C129A26A3CC625311 /* AudioCaptureService.swift */, 82B9451069B6343C41C337AC /* Cactus.swift */, 90AFD39809B3E0BE8DF8644B /* CactusService.swift */, + D12F4621EA52FEBDFBBA9E09 /* GameFetchService.swift */, + FBAB314A003AE79E3D7E3791 /* GeminiService.swift */, B6C9BEB6725DF3FCEF228A54 /* ModelInstaller.swift */, + EA68D7CA900438A10D7B4AC9 /* NewsService.swift */, 417593F6AF33889059160790 /* SpeechSynthesisService.swift */, C2E07D63CC5BC4EF184BC54E /* WhisperEngine.swift */, 6B70ED90C7C37542D0CE02C3 /* WhisperSkipReason.swift */, @@ -456,10 +483,13 @@ B6F4346E4C192BF816F68EAA /* Cactus.swift in Sources */, D2F4BB6524F4500D144AF80C /* CactusService.swift in Sources */, 30702CFDAB6E91475D9965F5 /* ChatMessageRow.swift in Sources */, + 7B7EF85462746C3A72D2CF0B /* CommentatorStylePickerView.swift in Sources */, B28DD1DE6BE6197370BB602C /* ContentView.swift in Sources */, 43DB7EFEFC8C19A84E734EF6 /* DottedGrid.swift in Sources */, 45CB73E5789C135680D10F96 /* FlowLayout.swift in Sources */, + E16958E56C7E41CCDE6D3B46 /* GameFetchService.swift in Sources */, E62E673B7510F8836F233C33 /* GamePickerSection.swift in Sources */, + C072623647D8C5AF5C70080D /* GeminiService.swift in Sources */, FC54722D2160DEDE13F882C0 /* GlassSegmentedPicker.swift in Sources */, E05C57FFDF0F3B9977139753 /* LatencyTag.swift in Sources */, DF2E26836A96D0D12400BA94 /* ListeningDot.swift in Sources */, @@ -469,6 +499,8 @@ 922D8E5F66990FD389F95CAD /* ModelInstaller.swift in Sources */, 3B2F18B5B6543736BD699485 /* ModelSetupView.swift in Sources */, C418A911A2E29B77BEAEDAF8 /* NewMatchSheet.swift in Sources */, + 06A1F33ECBAF18586AC02A37 /* NewsService.swift in Sources */, + 4DD529CF07EB0CCAB762BEAF /* NewsTabView.swift in Sources */, 0CC48F247E683B3FB185A6FB /* PlayByPlayStore.swift in Sources */, AE9607AB8A5E0C197FFB861E /* PlayerCellView.swift in Sources */, BBE476D5CF788B4125A50962 /* PlaysDBView.swift in Sources */, @@ -485,7 +517,11 @@ 70BA18D24CCC880D502CADD7 /* SquadsView.swift in Sources */, 164F7E394D28280CBD14A11D /* StackCard.swift in Sources */, 5982407B776A2A4BF04125BA /* StatCardView.swift in Sources */, + 7B4224664340A548BC7BDC06 /* StatsFirstSpottingBoardView.swift in Sources */, 77475FBA9D6370A088D71876 /* StatusBarView.swift in Sources */, + 00799F9CED5F78ECAA28985A /* StoryFirstSpottingBoardView.swift in Sources */, + D72A2274675152B2E545ED57 /* TacticalSpottingBoardView.swift in Sources */, + CC3B9BF6F21086368795B456 /* TeamSetupView.swift in Sources */, 7EE3DFD6034A989F9599CB72 /* ThemeStore.swift in Sources */, 75823BF5B5A04A60E2C364D1 /* Tokens.swift in Sources */, 8597296F58177F87BE1CEE5E /* TranscriptOverlay.swift in Sources */, diff --git a/BroadcastBrain/ContentView.swift b/BroadcastBrain/ContentView.swift index 5b043770..801665a5 100644 --- a/BroadcastBrain/ContentView.swift +++ b/BroadcastBrain/ContentView.swift @@ -7,20 +7,28 @@ struct ContentView: View { var body: some View { @Bindable var bindable = store - HStack(spacing: 0) { - SidebarView() - .frame(width: theme.sidebarCollapsed ? 68 : 260) + Group { + if store.showingSetup { + TeamSetupView() + .transition(.opacity) + } else { + HStack(spacing: 0) { + SidebarView() + .frame(width: theme.sidebarCollapsed ? 68 : 260) - detailView - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .ignoresSafeArea(.container, edges: .top) - .background(Color.bgBase) - .animation(.easeInOut(duration: 0.2), value: theme.sidebarCollapsed) - .sheet(isPresented: $bindable.showNewMatchSheet) { - NewMatchSheet() - .environment(store) + detailView + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .ignoresSafeArea(.container, edges: .top) + .background(Color.bgBase) + .animation(.easeInOut(duration: 0.2), value: theme.sidebarCollapsed) + .sheet(isPresented: $bindable.showNewMatchSheet) { + NewMatchSheet() + .environment(store) + } + } } + .animation(.easeInOut(duration: 0.2), value: store.showingSetup) } @ViewBuilder @@ -29,6 +37,7 @@ struct ContentView: View { case .live: LivePaneView() case .squads: SquadsView() case .research: ResearchCenterView() + case .news: NewsTabView() case .archive: ArchivesListView() case .plays: PlaysSearchView() case .playsDB: PlaysDBView() diff --git a/BroadcastBrain/Info.plist b/BroadcastBrain/Info.plist index b296f345..7f2bd1e8 100644 --- a/BroadcastBrain/Info.plist +++ b/BroadcastBrain/Info.plist @@ -4,6 +4,8 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Kleos CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -11,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - $(PRODUCT_NAME) + Kleos CFBundlePackageType APPL CFBundleShortVersionString @@ -21,10 +23,10 @@ LSMinimumSystemVersion 14.0 NSHumanReadableCopyright - © 2026 BroadcastBrain + © 2026 Kleos NSMicrophoneUsageDescription - BroadcastBrain listens to match commentary to surface stats. + Kleos listens to match commentary to surface stats. NSSpeechRecognitionUsageDescription - BroadcastBrain transcribes your voice on-device to know which stat to surface. + Kleos transcribes your voice on-device to know which stat to surface. diff --git a/BroadcastBrain/Services/GameFetchService.swift b/BroadcastBrain/Services/GameFetchService.swift new file mode 100644 index 00000000..e30ee9cc --- /dev/null +++ b/BroadcastBrain/Services/GameFetchService.swift @@ -0,0 +1,583 @@ +import Foundation + +// MARK: - Progress + +enum FetchStep: String { + case detectingSport = "Detecting sport…" + case findingGame = "Finding next game…" + case fetchingRosters = "Fetching rosters…" + case fetchingNews = "Fetching news & storylines…" + case buildingCache = "Building match cache…" + case done = "Done" +} + +@Observable +final class GameFetchService { + var step: FetchStep = .detectingSport + var stepDetail: String = "" + var isRunning = false + + // MARK: - Entry point + + func buildMatchCache(teamName: String) async throws -> MatchCache { + isRunning = true + defer { isRunning = false } + + progress(.detectingSport, "") + let (sport, league, display) = await detectSport(teamName) + + progress(.findingGame, "") + var gameInfo: GameInfo? = nil + var ourTeamId = "" + + if sport == "soccer" || sport == "basketball" { + ourTeamId = await espnFindTeamId(teamName, sport: sport, league: league) ?? "" + if !ourTeamId.isEmpty { gameInfo = await espnNextGame(teamId: ourTeamId, sport: sport, league: league) } + } else if sport == "baseball" { + ourTeamId = await mlbFindTeamId(teamName) ?? "" + if !ourTeamId.isEmpty { gameInfo = await mlbNextGame(teamId: ourTeamId) } + } else if sport == "hockey" { + ourTeamId = nhlAbbrev(teamName) ?? "" + if !ourTeamId.isEmpty { gameInfo = await nhlNextGame(abbrev: ourTeamId) } + } + + // Fallback if no upcoming game found + let gi = gameInfo ?? GameInfo( + homeTeam: teamName, awayTeam: "TBD", + homeId: ourTeamId, awayId: "", + venue: "TBD", dateISO: ISO8601DateFormatter().string(from: Date()), + competition: display + ) + progress(.findingGame, "\(gi.homeTeam) vs \(gi.awayTeam)") + + progress(.fetchingRosters, "") + let isHome = teamName.lowercased().contains(gi.homeTeam.lowercased()) || gi.homeTeam.lowercased().contains(teamName.lowercased()) + let oppName = isHome ? gi.awayTeam : gi.homeTeam + var oppId = isHome ? gi.awayId : gi.homeId + + var homeRaw: [RawPlayerInfo] = [] + var awayRaw: [RawPlayerInfo] = [] + + if sport == "soccer" || sport == "basketball" { + if !ourTeamId.isEmpty { homeRaw = await espnRoster(teamId: ourTeamId, sport: sport, league: league) } + if oppId.isEmpty && oppName != "TBD" { oppId = await espnFindTeamId(oppName, sport: sport, league: league) ?? "" } + if !oppId.isEmpty { awayRaw = await espnRoster(teamId: oppId, sport: sport, league: league) } + } else if sport == "baseball" { + if !ourTeamId.isEmpty { homeRaw = await mlbRoster(teamId: ourTeamId) } + if oppName != "TBD" { + let id = oppId.isEmpty ? (await mlbFindTeamId(oppName) ?? "") : oppId + if !id.isEmpty { awayRaw = await mlbRoster(teamId: id) } + } + } else if sport == "hockey" { + if !ourTeamId.isEmpty { homeRaw = await nhlRoster(abbrev: ourTeamId) } + if oppName != "TBD" { + let abbr = oppId.isEmpty ? (nhlAbbrev(oppName) ?? "") : oppId + if !abbr.isEmpty { awayRaw = await nhlRoster(abbrev: abbr) } + } + } + progress(.fetchingRosters, "\(homeRaw.count) + \(awayRaw.count) players") + + progress(.fetchingNews, "") + let homeNews = await googleNews("\(gi.homeTeam) news 2026", max: 5) + let awayNews = gi.awayTeam != "TBD" ? await googleNews("\(gi.awayTeam) news 2026", max: 3) : [] + var storylines = Array((homeNews + awayNews).prefix(8)) + if (sport == "soccer" || sport == "basketball") && !ourTeamId.isEmpty { + let espnH = await espnTeamNews(teamId: ourTeamId, sport: sport, league: league) + storylines = Array((espnH.prefix(4) + storylines).prefix(8)) + } + + progress(.buildingCache, "") + let injuryLines = await googleNews("\(gi.homeTeam) injury doubtful out 2026", max: 8) + + (gi.awayTeam != "TBD" ? await googleNews("\(gi.awayTeam) injury doubtful out 2026", max: 5) : []) + + var players: [Player] = [] + for (raw, teamDisplay, oppDisplay) in [(homeRaw, gi.homeTeam, gi.awayTeam), (awayRaw, gi.awayTeam, gi.homeTeam)] { + for (i, p) in raw.prefix(18).enumerated() { + var stats: [String: String] = [:] + if i < 8 && !p.id.isEmpty { + if sport == "soccer" || sport == "basketball" { + stats = await espnPlayerStats(playerId: p.id, sport: sport, league: league) + } else if sport == "baseball" { + stats = await mlbPlayerStats(playerId: p.id) + } else if sport == "hockey" { + stats = await nhlPlayerStats(playerId: p.id) + } + } + var playerNews: [String] = [] + if i < 6 { + playerNews = await fetchNewsForPlayer(name: p.name, team: teamDisplay) + } + let status = inferStatus(name: p.name, headlines: injuryLines) + var keyStats: [String: String] = [:] + let statPairs = stats.filter { !["","0","0.0","—"].contains($0.value) } + let topThree = statPairs.prefix(4) + let keys = ["stat1","stat2","stat3","stat4"] + for (idx, kv) in topThree.enumerated() { + keyStats[keys[idx]] = "\(kv.value) \(kv.key)" + } + keyStats["storyHero"] = makeStoryline(name: p.name, position: p.position, stats: stats, news: playerNews, status: status) + keyStats["tactical"] = makeMatchupNote(name: p.name, opponent: oppDisplay) + players.append(Player( + name: p.name.isEmpty ? "Player \(i+1)" : p.name, + team: teamDisplay, + jersey: p.number.map(String.init) ?? "\(i+1)", + position: p.position.isEmpty ? "—" : p.position, + keyStats: keyStats + )) + } + } + + let title = "\(gi.homeTeam) vs \(gi.awayTeam) · \(gi.competition.isEmpty ? display : gi.competition) · \(gi.venue)" + let facts = [ + "Match: \(gi.homeTeam) vs \(gi.awayTeam)", + "Competition: \(gi.competition.isEmpty ? display : gi.competition)", + "Venue: \(gi.venue)", + "Date: \(gi.dateISO)", + ] + storylines.prefix(4).map { $0 } + + progress(.done, title) + return MatchCache( + matchId: makeId(gi.homeTeam, gi.awayTeam), + title: title, + players: players, + facts: facts, + storylines: storylines + ) + } + + // MARK: - Progress helper + + private func progress(_ s: FetchStep, _ detail: String) { + step = s; stepDetail = detail + } + + // MARK: - Sport detection + + private func detectSport(_ teamName: String) async -> (String, String, String) { + let lower = teamName.lowercased().trimmingCharacters(in: .whitespaces) + for (key, entry) in Self.knownTeams { + if key.contains(lower) || lower.contains(key) { return entry } + } + // ESPN search fallback + for (sport, league) in [("soccer","eng.1"),("soccer","esp.1"),("soccer","ger.1"),("basketball","nba")] { + guard let url = URL(string: "\(Self.espnBase)/\(sport)/\(league)/teams"), + let data = await getJSON(url) as? [String: Any], + let teams = ((data["sports"] as? [[String:Any]])?.first?["leagues"] as? [[String:Any]])?.first?["teams"] as? [[String:Any]] else { continue } + for entry in teams { + let t = entry["team"] as? [String:Any] ?? [:] + let name = (t["displayName"] as? String ?? "").lowercased() + let nick = (t["nickname"] as? String ?? "").lowercased() + if lower.contains(name) || name.contains(lower) || nick.contains(lower) { + return (sport, league, league.uppercased()) + } + } + } + return ("soccer", "eng.1", "Premier League") + } + + // MARK: - ESPN + + private func espnFindTeamId(_ teamName: String, sport: String, league: String) async -> String? { + guard let url = URL(string: "\(Self.espnBase)/\(sport)/\(league)/teams"), + let data = await getJSON(url) as? [String: Any], + let teams = ((data["sports"] as? [[String:Any]])?.first?["leagues"] as? [[String:Any]])?.first?["teams"] as? [[String:Any]] else { return nil } + let lower = teamName.lowercased() + var bestId: String?; var bestScore = 0 + for entry in teams { + let t = entry["team"] as? [String:Any] ?? [:] + let name = (t["displayName"] as? String ?? "").lowercased() + let nick = (t["nickname"] as? String ?? "").lowercased() + let slug = (t["slug"] as? String ?? "").lowercased() + var score = 0 + if lower == name { score = 100 } + else if lower.contains(name) || name.contains(lower) { score = 80 } + else if nick.contains(lower) || lower.contains(nick) { score = 60 } + else if slug.contains(lower) { score = 50 } + if score > bestScore { bestScore = score; bestId = t["id"] as? String } + } + return bestId + } + + private func espnNextGame(teamId: String, sport: String, league: String) async -> GameInfo? { + guard let url = URL(string: "\(Self.espnBase)/\(sport)/\(league)/teams/\(teamId)/schedule"), + let data = await getJSON(url) as? [String: Any], + let events = data["events"] as? [[String: Any]] else { return nil } + for event in events { + guard let comp = (event["competitions"] as? [[String:Any]])?.first else { continue } + let state = ((comp["status"] as? [String:Any])?["type"] as? [String:Any])?["state"] as? String ?? "" + guard state == "pre" else { continue } + let competitors = comp["competitors"] as? [[String:Any]] ?? [] + let home = competitors.first(where: { ($0["homeAway"] as? String) == "home" }) ?? [:] + let away = competitors.first(where: { ($0["homeAway"] as? String) == "away" }) ?? [:] + let ht = home["team"] as? [String:Any] ?? [:] + let at = away["team"] as? [String:Any] ?? [:] + return GameInfo( + homeTeam: ht["displayName"] as? String ?? "", + awayTeam: at["displayName"] as? String ?? "", + homeId: ht["id"] as? String ?? "", + awayId: at["id"] as? String ?? "", + venue: (comp["venue"] as? [String:Any])?["fullName"] as? String ?? "TBD", + dateISO: event["date"] as? String ?? "", + competition: (data["season"] as? [String:Any])?["displayName"] as? String ?? "" + ) + } + return nil + } + + private func espnRoster(teamId: String, sport: String, league: String) async -> [RawPlayerInfo] { + guard let url = URL(string: "\(Self.espnBase)/\(sport)/\(league)/teams/\(teamId)/roster"), + let data = await getJSON(url) as? [String: Any], + let athletes = data["athletes"] as? [[String:Any]] else { return [] } + var players: [RawPlayerInfo] = [] + for item in athletes { + if let items = item["items"] as? [[String:Any]] { + players.append(contentsOf: items.map(parseAthlete)) + } else { + players.append(parseAthlete(item)) + } + } + return players + } + + private func espnPlayerStats(playerId: String, sport: String, league: String) async -> [String: String] { + guard let url = URL(string: "\(Self.espnCore)/\(sport)/leagues/\(league)/athletes/\(playerId)/statistics/0"), + let data = await getJSON(url) as? [String: Any], + let cats = (data["splits"] as? [String:Any])?["categories"] as? [[String:Any]] else { return [:] } + var stats: [String: String] = [:] + for cat in cats { + for stat in (cat["stats"] as? [[String:Any]] ?? []) { + let name = stat["displayName"] as? String ?? "" + let value = stat["displayValue"] as? String ?? "" + if !name.isEmpty && !["","0","0.0"].contains(value) { stats[name] = value } + } + } + return stats + } + + private func espnTeamNews(teamId: String, sport: String, league: String) async -> [String] { + guard let url = URL(string: "\(Self.espnBase)/\(sport)/\(league)/news?team=\(teamId)&limit=10"), + let data = await getJSON(url) as? [String: Any], + let articles = data["articles"] as? [[String:Any]] else { return [] } + return articles.compactMap { ($0["headline"] as? String)?.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }.prefix(6).map { $0 } + } + + // MARK: - MLB + + private func mlbFindTeamId(_ teamName: String) async -> String? { + guard let url = URL(string: "https://statsapi.mlb.com/api/v1/teams?sportId=1"), + let data = await getJSON(url) as? [String: Any], + let teams = data["teams"] as? [[String:Any]] else { return nil } + let lower = teamName.lowercased() + for team in teams { + let name = (team["name"] as? String ?? "").lowercased() + let short = (team["teamName"] as? String ?? "").lowercased() + if lower.contains(name) || name.contains(lower) || lower.contains(short) { + return String(team["id"] as? Int ?? 0) + } + } + return nil + } + + private func mlbNextGame(teamId: String) async -> GameInfo? { + guard let url = URL(string: "https://statsapi.mlb.com/api/v1/schedule/games/?sportId=1&teamId=\(teamId)"), + let data = await getJSON(url) as? [String: Any], + let dates = data["dates"] as? [[String:Any]], !dates.isEmpty, + let game = (dates[0]["games"] as? [[String:Any]])?.first else { return nil } + let homeTeam = (game["teams"] as? [String:Any])?["home"] as? [String:Any] + let awayTeam = (game["teams"] as? [String:Any])?["away"] as? [String:Any] + return GameInfo( + homeTeam: (homeTeam?["team"] as? [String:Any])?["name"] as? String ?? "", + awayTeam: (awayTeam?["team"] as? [String:Any])?["name"] as? String ?? "", + homeId: String((homeTeam?["team"] as? [String:Any])?["id"] as? Int ?? 0), + awayId: String((awayTeam?["team"] as? [String:Any])?["id"] as? Int ?? 0), + venue: (game["venue"] as? [String:Any])?["name"] as? String ?? "TBD", + dateISO: game["gameDate"] as? String ?? "", + competition: "MLB" + ) + } + + private func mlbRoster(teamId: String) async -> [RawPlayerInfo] { + guard let url = URL(string: "https://statsapi.mlb.com/api/v1/teams/\(teamId)/roster?season=2026&rosterType=active"), + let data = await getJSON(url) as? [String: Any], + let roster = data["roster"] as? [[String:Any]] else { return [] } + return roster.map { entry in + let person = entry["person"] as? [String:Any] ?? [:] + return RawPlayerInfo( + id: String(person["id"] as? Int ?? 0), + name: person["fullName"] as? String ?? "", + number: Int(entry["jerseyNumber"] as? String ?? ""), + position: (entry["position"] as? [String:Any])?["abbreviation"] as? String ?? "" + ) + } + } + + private func mlbPlayerStats(playerId: String) async -> [String: String] { + for group in ["hitting","pitching"] { + guard let url = URL(string: "https://statsapi.mlb.com/api/v1/people/\(playerId)/stats?stats=season&season=2026&group=\(group)"), + let data = await getJSON(url) as? [String: Any], + let splits = (data["stats"] as? [[String:Any]])?.first?["splits"] as? [[String:Any]], + !splits.isEmpty, + let stat = splits[0]["stat"] as? [String:Any] else { continue } + var result: [String: String] = [:] + for (k,v) in stat { let s = "\(v)"; if !["0","0.0",".000","","null"].contains(s) { result[k] = s } } + if !result.isEmpty { return result } + } + return [:] + } + + // MARK: - NHL + + func nhlAbbrev(_ teamName: String) -> String? { + let lower = teamName.lowercased() + for (key, abbrev) in Self.nhlAbbrevs { if lower.contains(key) { return abbrev } } + return nil + } + + private func nhlNextGame(abbrev: String) async -> GameInfo? { + guard let url = URL(string: "https://api-web.nhle.com/v1/club-schedule-season/\(abbrev)/now"), + let data = await getJSON(url) as? [String: Any], + let games = data["games"] as? [[String:Any]] else { return nil } + for game in games { + let state = game["gameState"] as? String ?? "" + guard ["FUT","PRE"].contains(state) else { continue } + let home = game["homeTeam"] as? [String:Any] ?? [:] + let away = game["awayTeam"] as? [String:Any] ?? [:] + let hn = [home["placeName"] as? [String:Any], home["commonName"] as? [String:Any]] + .compactMap { $0?["default"] as? String }.joined(separator: " ").trimmingCharacters(in: .whitespaces) + let an = [away["placeName"] as? [String:Any], away["commonName"] as? [String:Any]] + .compactMap { $0?["default"] as? String }.joined(separator: " ").trimmingCharacters(in: .whitespaces) + return GameInfo(homeTeam: hn, awayTeam: an, + homeId: home["abbrev"] as? String ?? "", + awayId: away["abbrev"] as? String ?? "", + venue: (game["venue"] as? [String:Any])?["default"] as? String ?? "TBD", + dateISO: game["gameDate"] as? String ?? "", competition: "NHL") + } + return nil + } + + private func nhlRoster(abbrev: String) async -> [RawPlayerInfo] { + guard let url = URL(string: "https://api-web.nhle.com/v1/roster/\(abbrev)/current"), + let data = await getJSON(url) as? [String: Any] else { return [] } + var players: [RawPlayerInfo] = [] + for group in ["forwards","defensemen","goalies"] { + for p in (data[group] as? [[String:Any]] ?? []) { + let fn = (p["firstName"] as? [String:Any])?["default"] as? String ?? "" + let ln = (p["lastName"] as? [String:Any])?["default"] as? String ?? "" + players.append(RawPlayerInfo( + id: String(p["id"] as? Int ?? 0), + name: "\(fn) \(ln)".trimmingCharacters(in: .whitespaces), + number: p["sweaterNumber"] as? Int, + position: p["positionCode"] as? String ?? "" + )) + } + } + return players + } + + private func nhlPlayerStats(playerId: String) async -> [String: String] { + guard let url = URL(string: "https://api-web.nhle.com/v1/player/\(playerId)/landing"), + let data = await getJSON(url) as? [String: Any], + let totals = data["seasonTotals"] as? [[String:Any]], !totals.isEmpty else { return [:] } + let latest = totals[totals.count - 1] + var stats: [String: String] = [:] + for key in ["goals","assists","points","plusMinus","shots","gamesPlayed","savePctg","goalsAgainstAvg","wins"] { + if let val = latest[key], "\(val)" != "0" { stats[key] = "\(val)" } + } + return stats + } + + // MARK: - Google News RSS + + private func googleNews(_ query: String, max: Int = 5) async -> [String] { + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://news.google.com/rss/search?q=\(encoded)&hl=en-US&gl=US&ceid=US:en"), + let (data, resp) = try? await URLSession.shared.data(for: URLRequest(url: url)), + (resp as? HTTPURLResponse)?.statusCode == 200, + let xml = String(data: data, encoding: .utf8) else { return [] } + var headlines: [String] = [] + let pattern = try! NSRegularExpression(pattern: "<!\\[CDATA\\[(.*?)\\]\\]>|(.*?)") + for match in pattern.matches(in: xml, range: NSRange(xml.startIndex..., in: xml)) { + let raw: String + if let r = Range(match.range(at: 1), in: xml) { raw = String(xml[r]).trimmingCharacters(in: .whitespaces) } + else if let r = Range(match.range(at: 2), in: xml) { raw = String(xml[r]).trimmingCharacters(in: .whitespaces) } + else { continue } + guard !raw.isEmpty, raw != "Google News" else { continue } + let clean = raw.replacingOccurrences(of: "\\s*-\\s*[^-]+$", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespaces) + if !clean.isEmpty { headlines.append(clean) } + if headlines.count >= max { break } + } + return headlines + } + + // MARK: - Helpers + + private func parseAthlete(_ a: [String:Any]) -> RawPlayerInfo { + RawPlayerInfo( + id: a["id"] as? String ?? "", + name: a["displayName"] as? String ?? a["fullName"] as? String ?? "", + number: Int(a["jersey"] as? String ?? ""), + position: (a["position"] as? [String:Any])?["abbreviation"] as? String ?? "" + ) + } + + private func inferStatus(name: String, headlines: [String]) -> String { + let parts = name.lowercased().split(separator: " ").filter { $0.count > 2 }.map(String.init) + for hl in headlines { + let h = hl.lowercased() + guard parts.contains(where: { h.contains($0) }) else { continue } + if h.contains("suspend") { return "suspended" } + if h.contains("doubtful") { return "doubtful" } + if ["out","ruled out","injured","sidelined","misses"].contains(where: h.contains) { return "injured" } + } + return "fit" + } + + private func fetchNewsForPlayer(name: String, team: String) async -> [String] { + guard !name.isEmpty else { return [] } + return await googleNews("\(name) \(team) 2026", max: 3) + } + + private func makeStoryline(name: String, position: String, stats: [String: String], news: [String], status: String) -> String { + if ["injured", "doubtful", "suspended"].contains(status) { + return "\(name) is listed as \(status) — availability is the key team news heading in." + } + if let headline = news.first { + let clean = headline.replacingOccurrences(of: "\\s*-\\s*[^-]+$", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespaces) + if clean.count > 10 { return clean } + } + if let (key, val) = stats.first(where: { !["", "0", "0.0", "—"].contains($0.value) }) { + return "\(name) brings \(val) \(key) into this matchup — one of the key figures to watch." + } + let pos = position.isEmpty ? "player" : position + return "\(name) is a key \(pos) piece in this lineup — watch how they influence the game." + } + + private func makeMatchupNote(name: String, opponent: String) -> String { + let opp = opponent.isEmpty || opponent == "TBD" ? "their opponent" : opponent + return "\(name) faces \(opp) — a key individual battle to monitor throughout." + } + + private func makeId(_ parts: String...) -> String { + let raw = parts.filter { !$0.isEmpty }.joined(separator: "-").lowercased().trimmingCharacters(in: .whitespaces) + let r = raw.replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return r.isEmpty ? "unknown" : r + } + + private func getJSON(_ url: URL) async -> Any? { + var req = URLRequest(url: url) + req.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + req.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + guard let (data, resp) = try? await URLSession.shared.data(for: req), + (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil } + return try? JSONSerialization.jsonObject(with: data) + } + + // MARK: - Constants + + static let espnBase = "https://site.api.espn.com/apis/site/v2/sports" + static let espnCore = "https://sports.core.api.espn.com/v2/sports" + + static let nhlAbbrevs: [String: String] = [ + "toronto":"TOR","maple leafs":"TOR","leafs":"TOR", + "montreal":"MTL","canadiens":"MTL","boston":"BOS","bruins":"BOS", + "new york rangers":"NYR","rangers":"NYR","edmonton":"EDM","oilers":"EDM", + "colorado":"COL","avalanche":"COL","tampa bay":"TBL","lightning":"TBL", + "vegas":"VGK","golden knights":"VGK","carolina":"CAR","hurricanes":"CAR", + "florida":"FLA","panthers":"FLA","dallas":"DAL","stars":"DAL", + "new york islanders":"NYI","islanders":"NYI","new jersey":"NJD","devils":"NJD", + "pittsburgh":"PIT","penguins":"PIT","detroit":"DET","red wings":"DET", + "nashville":"NSH","predators":"NSH","minnesota":"MIN","wild":"MIN", + "winnipeg":"WPG","jets":"WPG","st. louis":"STL","blues":"STL", + "seattle":"SEA","kraken":"SEA","chicago":"CHI","blackhawks":"CHI", + "ottawa":"OTT","senators":"OTT","calgary":"CGY","flames":"CGY", + "vancouver":"VAN","canucks":"VAN","buffalo":"BUF","sabres":"BUF", + "san jose":"SJS","sharks":"SJS","philadelphia":"PHI","flyers":"PHI", + "anaheim":"ANA","ducks":"ANA","columbus":"CBJ","washington":"WSH","capitals":"WSH", + ] + + static let knownTeams: [String: (String, String, String)] = [ + // Soccer – PL + "manchester city":("soccer","eng.1","Premier League"), + "manchester united":("soccer","eng.1","Premier League"), + "liverpool":("soccer","eng.1","Premier League"), + "arsenal":("soccer","eng.1","Premier League"), + "chelsea":("soccer","eng.1","Premier League"), + "tottenham":("soccer","eng.1","Premier League"), + "newcastle":("soccer","eng.1","Premier League"), + "aston villa":("soccer","eng.1","Premier League"), + // La Liga + "real madrid":("soccer","esp.1","La Liga"), + "barcelona":("soccer","esp.1","La Liga"), + "atletico madrid":("soccer","esp.1","La Liga"), + // Bundesliga + "bayern munich":("soccer","ger.1","Bundesliga"), + "borussia dortmund":("soccer","ger.1","Bundesliga"), + // Serie A + "juventus":("soccer","ita.1","Serie A"), + "inter milan":("soccer","ita.1","Serie A"), + "ac milan":("soccer","ita.1","Serie A"), + "napoli":("soccer","ita.1","Serie A"), + // Ligue 1 + "paris saint-germain":("soccer","fra.1","Ligue 1"), + "psg":("soccer","fra.1","Ligue 1"), + "monaco":("soccer","fra.1","Ligue 1"), + // MLS + "inter miami":("soccer","usa.1","MLS"), + "la galaxy":("soccer","usa.1","MLS"), + "lafc":("soccer","usa.1","MLS"), + // NBA + "los angeles lakers":("basketball","nba","NBA"), + "lakers":("basketball","nba","NBA"), + "golden state warriors":("basketball","nba","NBA"), + "boston celtics":("basketball","nba","NBA"), + "miami heat":("basketball","nba","NBA"), + "chicago bulls":("basketball","nba","NBA"), + "new york knicks":("basketball","nba","NBA"), + "dallas mavericks":("basketball","nba","NBA"), + "denver nuggets":("basketball","nba","NBA"), + "oklahoma city thunder":("basketball","nba","NBA"), + "cleveland cavaliers":("basketball","nba","NBA"), + "houston rockets":("basketball","nba","NBA"), + "indiana pacers":("basketball","nba","NBA"), + "minnesota timberwolves":("basketball","nba","NBA"), + // MLB + "new york yankees":("baseball","mlb","MLB"), + "yankees":("baseball","mlb","MLB"), + "los angeles dodgers":("baseball","mlb","MLB"), + "dodgers":("baseball","mlb","MLB"), + "boston red sox":("baseball","mlb","MLB"), + "chicago cubs":("baseball","mlb","MLB"), + "houston astros":("baseball","mlb","MLB"), + "atlanta braves":("baseball","mlb","MLB"), + "new york mets":("baseball","mlb","MLB"), + "philadelphia phillies":("baseball","mlb","MLB"), + // NHL + "toronto maple leafs":("hockey","nhl","NHL"), + "leafs":("hockey","nhl","NHL"), + "montreal canadiens":("hockey","nhl","NHL"), + "boston bruins":("hockey","nhl","NHL"), + "edmonton oilers":("hockey","nhl","NHL"), + "oilers":("hockey","nhl","NHL"), + "colorado avalanche":("hockey","nhl","NHL"), + "tampa bay lightning":("hockey","nhl","NHL"), + "vegas golden knights":("hockey","nhl","NHL"), + "carolina hurricanes":("hockey","nhl","NHL"), + "florida panthers":("hockey","nhl","NHL"), + ] +} + +// MARK: - Internal types + +private struct GameInfo { + var homeTeam: String; var awayTeam: String + var homeId: String; var awayId: String + var venue: String; var dateISO: String + var competition: String +} + +struct RawPlayerInfo { + var id: String; var name: String; var number: Int?; var position: String +} diff --git a/BroadcastBrain/Services/GeminiService.swift b/BroadcastBrain/Services/GeminiService.swift new file mode 100644 index 00000000..847aa69a --- /dev/null +++ b/BroadcastBrain/Services/GeminiService.swift @@ -0,0 +1,111 @@ +import Foundation + +enum GeminiError: LocalizedError { + case missingKey(String) + case badResponse(String) + case empty + + var errorDescription: String? { + switch self { + case .missingKey(let path): + return "No Gemini API key. Save it to \(path)" + case .badResponse(let s): return "Gemini error: \(s)" + case .empty: return "Gemini returned an empty response." + } + } +} + +enum GeminiService { + + private static let model = "gemini-2.5-flash" + + private static var keyPath: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("BroadcastBrain/gemini_key.txt") + } + + static func apiKey() -> String? { + guard let data = try? Data(contentsOf: keyPath), + let raw = String(data: data, encoding: .utf8) else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + static func synthesizeNews(headlines: [NewsItem], matchTitle: String?, playerNames: [String], userCurated: Bool) async throws -> String { + guard let key = apiKey() else { throw GeminiError.missingKey(keyPath.path) } + + let headlineList = headlines.prefix(80).enumerated().map { idx, h in + "\(idx + 1). [\(h.leagueLabel)] \(h.headline)\(h.description.isEmpty ? "" : " — \(h.description)")" + }.joined(separator: "\n") + + let systemInstruction: String + let userPrompt: String + + if userCurated { + // User hand-picked these — produce a structured per-article block + // with concrete facts the commentator can reach for mid-call. + systemInstruction = """ + You are a broadcast prep assistant. The broadcaster hand-picked the headlines below. + + For EVERY headline, output exactly one block in this format — never skip, never combine, never summarize multiple headlines into one block: + + [N]. [Short title, ≤ 60 chars] + WHO: names of the people, teams, and clubs involved + WHAT: the concrete facts — scores, statlines, dates, injury status, trade terms, exact quotes. Numbers and names are mandatory when the headline provides them. + ANGLE: why a live commentator would mention this — the implication, narrative hook, or record on the line + + Separate blocks with a blank line. Number sequentially starting at 1. Use plain text only (no markdown symbols like **, #, or \\*). If the headline is vague, still produce a block and say "details not in headline" for the missing field instead of omitting it. Total length under 500 words. + """ + userPrompt = "Selected headlines:\n\(headlineList)" + } else { + let matchLine = matchTitle.map { "Match: \($0)" } ?? "No specific match loaded." + let playerLine = playerNames.isEmpty ? "" : "Players on the match roster: \(playerNames.prefix(20).joined(separator: ", "))" + systemInstruction = """ + You are a broadcast prep assistant. From the headlines, produce a tight set of talking points for a live commentator. + Prefer items relevant to the loaded match and its players, but still surface broader league context when nothing ties directly — never refuse with "no relevant information". + Group findings under these headings (omit any that truly have no content): + INJURIES & AVAILABILITY + FORM & RECENT RESULTS + STORYLINES & RIVALRY + WILDCARDS + Use short bullet points (1–2 sentences each). Keep the full response under 300 words. Plain text only, no markdown syntax. + """ + userPrompt = """ + \(matchLine) + \(playerLine) + + Headlines: + \(headlineList) + """ + } + + let body: [String: Any] = [ + "systemInstruction": ["parts": [["text": systemInstruction]]], + "contents": [["role": "user", "parts": [["text": userPrompt]]]], + "generationConfig": ["temperature": 0.4, "maxOutputTokens": 1024], + ] + let jsonData = try JSONSerialization.data(withJSONObject: body) + + var req = URLRequest(url: URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):generateContent?key=\(key)")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = jsonData + + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw GeminiError.badResponse("no response") } + guard (200..<300).contains(http.statusCode) else { + let msg = String(data: data, encoding: .utf8) ?? "status \(http.statusCode)" + throw GeminiError.badResponse(msg) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let candidates = json["candidates"] as? [[String: Any]], + let content = candidates.first?["content"] as? [String: Any], + let parts = content["parts"] as? [[String: Any]], + let text = parts.first?["text"] as? String, + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { throw GeminiError.empty } + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/BroadcastBrain/Services/NewsService.swift b/BroadcastBrain/Services/NewsService.swift new file mode 100644 index 00000000..24d58174 --- /dev/null +++ b/BroadcastBrain/Services/NewsService.swift @@ -0,0 +1,175 @@ +import Foundation + +// MARK: - Types + +struct NewsItem: Codable, Identifiable, Hashable { + let id: String + let headline: String + let description: String + let published: String + let imageUrl: String? + let articleUrl: String? + let leagueKey: String + let leagueLabel: String + let source: NewsSource + + enum NewsSource: String, Codable { + case espn + case googleNews = "google_news" + } +} + +// MARK: - Service + +enum NewsService { + + private struct League { + let key: String + let sport: String + let league: String + let label: String + } + + private static let leagues: [League] = [ + League(key: "mlb", sport: "baseball", league: "mlb", label: "MLB"), + League(key: "nba", sport: "basketball", league: "nba", label: "NBA"), + League(key: "wnba", sport: "basketball", league: "wnba", label: "WNBA"), + League(key: "nfl", sport: "football", league: "nfl", label: "NFL"), + League(key: "ncaaf", sport: "football", league: "college-football",label: "NCAAF"), + League(key: "nhl", sport: "hockey", league: "nhl", label: "NHL"), + League(key: "epl", sport: "soccer", league: "eng.1", label: "EPL"), + League(key: "laliga", sport: "soccer", league: "esp.1", label: "La Liga"), + League(key: "seriea", sport: "soccer", league: "ita.1", label: "Serie A"), + League(key: "bundesliga", sport: "soccer", league: "ger.1", label: "Bundesliga"), + League(key: "ligue1", sport: "soccer", league: "fra.1", label: "Ligue 1"), + League(key: "ucl", sport: "soccer", league: "uefa.champions", label: "UCL"), + League(key: "mls", sport: "soccer", league: "usa.1", label: "MLS"), + ] + + private static let headers: [String: String] = [ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", + ] + + // MARK: - Public API + + static func fetchLeagueNews(leagueKey: String, limit: Int = 20) async -> [NewsItem] { + guard let league = leagues.first(where: { $0.key == leagueKey }), + let url = URL(string: "https://site.api.espn.com/apis/site/v2/sports/\(league.sport)/\(league.league)/news?limit=\(limit)"), + let data = try? await httpGet(url: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let articles = json["articles"] as? [[String: Any]] else { return [] } + return articles.map { espnArticleToNewsItem($0, leagueKey: league.key, leagueLabel: league.label) } + } + + static func fetchAllSportsNews(limit: Int = 10) async -> [NewsItem] { + let mainLeagues = ["nfl", "nba", "mlb", "nhl", "epl", "mls"] + var all: [NewsItem] = [] + await withTaskGroup(of: [NewsItem].self) { group in + for key in mainLeagues { + group.addTask { await fetchLeagueNews(leagueKey: key, limit: limit) } + } + for await items in group { all.append(contentsOf: items) } + } + return all.sorted { + let df = ISO8601DateFormatter() + let a = df.date(from: $0.published) ?? Date.distantPast + let b = df.date(from: $1.published) ?? Date.distantPast + return a > b + } + } + + static func fetchPlayerNews(playerName: String, teamName: String = "", limit: Int = 5) async -> [NewsItem] { + let query = teamName.isEmpty ? playerName : "\(playerName) \(teamName)" + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://news.google.com/rss/search?q=\(encoded)&hl=en-US&gl=US&ceid=US:en"), + let data = try? await httpGet(url: url), + let xml = String(data: data, encoding: .utf8) else { return [] } + return parseGoogleNewsRSS(xml: xml, limit: limit, source: .googleNews) + } + + // MARK: - Parsing + + private static func espnArticleToNewsItem(_ a: [String: Any], leagueKey: String, leagueLabel: String) -> NewsItem { + let id = a["id"].map { "espn-\(leagueKey)-\($0)" } ?? "espn-\(leagueKey)-\(UUID().uuidString)" + let images = a["images"] as? [[String: Any]] + let links = a["links"] as? [String: Any] + let web = links?["web"] as? [String: Any] + return NewsItem( + id: id, + headline: a["headline"] as? String ?? "", + description: a["description"] as? String ?? "", + published: a["published"] as? String ?? ISO8601DateFormatter().string(from: Date()), + imageUrl: images?.first?["url"] as? String, + articleUrl: web?["href"] as? String, + leagueKey: leagueKey, + leagueLabel: leagueLabel, + source: .espn + ) + } + + private static func parseGoogleNewsRSS(xml: String, limit: Int, source: NewsItem.NewsSource) -> [NewsItem] { + var items: [NewsItem] = [] + let pattern = try! NSRegularExpression(pattern: "([\\s\\S]*?)") + let range = NSRange(xml.startIndex..., in: xml) + for match in pattern.matches(in: xml, range: range) { + guard let contentRange = Range(match.range(at: 1), in: xml) else { continue } + let content = String(xml[contentRange]) + let rawTitle = extractTag(xml: content, tag: "title") ?? "" + let headline = rawTitle.replacingOccurrences( + of: "\\s*-\\s*[^-]+$", with: "", options: .regularExpression + ).trimmingCharacters(in: .whitespaces) + let description = stripHtml(extractTag(xml: content, tag: "description") ?? "") + let published = extractTag(xml: content, tag: "pubDate") ?? ISO8601DateFormatter().string(from: Date()) + let link = extractTag(xml: content, tag: "link") ?? "" + guard !headline.isEmpty, headline != "Google News" else { continue } + let idBase = Data(link.utf8).base64EncodedString().prefix(16) + items.append(NewsItem( + id: "gnews-\(idBase)", + headline: headline, + description: description, + published: published, + imageUrl: nil, + articleUrl: link.isEmpty ? nil : link, + leagueKey: "player", + leagueLabel: "Player News", + source: source + )) + if items.count >= limit { break } + } + return items + } + + private static func extractTag(xml: String, tag: String) -> String? { + let open = "<\(tag)" + let close = "" + guard let startRange = xml.range(of: open), + let gtRange = xml.range(of: ">", range: startRange.upperBound..") { + value = String(value.dropFirst(9).dropLast(3)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return value.isEmpty ? nil : value + } + + private static func stripHtml(_ html: String) -> String { + html.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "&", with: "&") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - HTTP + + private static func httpGet(url: URL) async throws -> Data { + var request = URLRequest(url: url) + for (k, v) in headers { request.setValue(v, forHTTPHeaderField: k) } + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw URLError(.badServerResponse) + } + return data + } +} diff --git a/BroadcastBrain/Stores/AppStore.swift b/BroadcastBrain/Stores/AppStore.swift index 9c0ab6c9..a2fd5da5 100644 --- a/BroadcastBrain/Stores/AppStore.swift +++ b/BroadcastBrain/Stores/AppStore.swift @@ -3,7 +3,7 @@ import Observation import PlayByPlayKit enum Surface: String, CaseIterable, Identifiable { - case live, squads, research, archive, plays, playsDB + case live, squads, research, news, archive, plays, playsDB var id: String { rawValue } } @@ -49,10 +49,13 @@ final class AppStore { /// When true the ContentView presents NewMatchSheet. Driven by the sidebar /// `+ New Session` button. Dismissed on Cancel or Create. var showNewMatchSheet: Bool = false + /// Shows TeamSetupView full-screen when true (first launch or user-triggered refresh). + var showingSetup: Bool = false + var spottingMode: SpottingMode? = nil let sessionStore: SessionStore let cactus: CactusService - let matchCache: MatchCache? + var matchCache: MatchCache? let playByPlayStore: PlayByPlayStore let speech: SpeechSynthesisService let whisperEngine: WhisperEngine @@ -70,17 +73,13 @@ final class AppStore { self.speech = speech self.whisperEngine = whisperEngine - if let url = Bundle.main.url(forResource: "match_cache", withExtension: "json"), - let data = try? Data(contentsOf: url), - let cache = try? JSONDecoder().decode(MatchCache.self, from: data) { - self.matchCache = cache - } else { - self.matchCache = nil - } + // Load only the user-saved cache (from a prior TeamSetupView fetch). + // No bundled fallback: first launch has no cache, which forces + // TeamSetupView so every user starts with their own match. + let initialCache = Self.loadSavedCache() + self.matchCache = initialCache - // Seed the default hackathon match so first launch has something live. - let seededMatch = Match.sampleArgFra2022 - let title = seededMatch.title + let title = initialCache?.title ?? "New Match" // Reuse an empty session for today's match if one already exists. let cal = Calendar.current @@ -94,7 +93,7 @@ final class AppStore { }) { self.currentSession = reusable } else { - let fresh = Session(title: title, match: seededMatch) + let fresh = Session(title: title) self.currentSession = fresh sessionStore.save(fresh) } @@ -102,6 +101,12 @@ final class AppStore { // Sweep any stray empty duplicate sessions (from pre-fix launches) sessionStore.purgeEmptyDuplicates(except: self.currentSession.id) + // No cache means the user has never run setup — force TeamSetupView + // on this launch so the app is tailored from the first moment. + if self.matchCache == nil { + self.showingSetup = true + } + // Back-link whisper engine to self so it can read transcript + plays. whisperEngine.attach(store: self) @@ -361,4 +366,43 @@ final class AppStore { selectedArchiveId = nil selectedSurface = .live } + + /// Called by TeamSetupView after the fetch completes — swaps the in-memory + /// match cache, persists it to disk so future launches skip setup, and + /// starts a fresh session for the new matchup. + func loadMatchCache(_ cache: MatchCache) { + matchCache = cache + showingSetup = false + Self.persistCache(cache) + + let fresh = Session(title: cache.title) + sessionStore.save(fresh) + currentSession = fresh + selectedArchiveId = nil + selectedSurface = .research + } + + /// Called by the sidebar "refresh" button to reopen the setup flow. + func presentSetup() { + showingSetup = true + } + + // MARK: - Cache persistence + + private static let savedCacheURL: URL = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("BroadcastBrain/match_cache.json") + + private static func loadSavedCache() -> MatchCache? { + guard FileManager.default.fileExists(atPath: savedCacheURL.path), + let data = try? Data(contentsOf: savedCacheURL) else { return nil } + return try? JSONDecoder().decode(MatchCache.self, from: data) + } + + private static func persistCache(_ cache: MatchCache) { + let dir = savedCacheURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let enc = JSONEncoder(); enc.outputFormatting = [.prettyPrinted, .sortedKeys] + try? enc.encode(cache).write(to: savedCacheURL, options: .atomic) + } } diff --git a/BroadcastBrain/Views/CommentatorStylePickerView.swift b/BroadcastBrain/Views/CommentatorStylePickerView.swift new file mode 100644 index 00000000..97b95a17 --- /dev/null +++ b/BroadcastBrain/Views/CommentatorStylePickerView.swift @@ -0,0 +1,169 @@ +import SwiftUI + +struct CommentatorStylePickerView: View { + @Environment(AppStore.self) private var store + @State private var hovered: SpottingMode? = nil + + private var cache: MatchCache? { store.matchCache } + + private var playerCount: Int { cache?.players.count ?? 0 } + private var storylineCount: Int { cache?.storylines.count ?? 0 } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header pill + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(Color.verified) + Text("MATCH CACHE READY") + .font(Typography.chip) + .foregroundStyle(Color.verified) + } + .padding(.bottom, 14) + + Text("Pick your commentator style.") + .font(.system(size: 20, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + .padding(.bottom, 8) + + Text("\(playerCount) players · \(storylineCount) storylines cached for this match.") + .font(Typography.chip) + .foregroundStyle(Color.textMuted) + .padding(.bottom, 24) + + // Style rows + VStack(spacing: 1) { + StyleRow( + mode: .stats, + title: "STATS-FIRST", + badge: nil, + description: "Numbers lead. Top 3 stats per player, ranked by impact.", + detail: "Best for data-driven calls and quick comparisons.", + hovered: $hovered + ) + StyleRow( + mode: .story, + title: "STORY-FIRST", + badge: "RECOMMENDED", + description: "Narrative leads. Latest headline or storyline on each card.", + detail: "Best for colour commentary and player arcs.", + hovered: $hovered + ) + StyleRow( + mode: .tactical, + title: "TACTICAL", + badge: nil, + description: "Matchups lead. Who each player faces and why it matters.", + detail: "Best for analytical breakdowns and individual battles.", + hovered: $hovered + ) + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + + // Footer + HStack(spacing: 4) { + Text("MODE IS A PREFERENCE — NOT A CAGE.") + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + Spacer() + Button("SKIP — CUSTOMIZE FROM SCRATCH") { + store.spottingMode = .stats + store.selectedSurface = .research + } + .buttonStyle(.plain) + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + .underline() + } + .padding(.top, 16) + } + .padding(28) + .background(Color.bgRaised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.bbBorder, lineWidth: 1)) + .frame(maxWidth: 540) + } +} + +private struct StyleRow: View { + @Environment(AppStore.self) private var store + let mode: SpottingMode + let title: String + let badge: String? + let description: String + let detail: String + @Binding var hovered: SpottingMode? + + var isHovered: Bool { hovered == mode } + + private var iconName: String { + switch mode { + case .stats: return "chart.bar.fill" + case .story: return "book.fill" + case .tactical: return "point.3.connected.trianglepath.dotted" + } + } + + private var label: String { + switch mode { + case .stats: return "DATA" + case .story: return "ARC" + case .tactical: return "MATCH" + } + } + + var body: some View { + Button { + store.spottingMode = mode + store.selectedSurface = .research + } label: { + HStack(alignment: .top, spacing: 14) { + // Mode icon + VStack(spacing: 2) { + Image(systemName: iconName) + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(Color.textPrimary) + Text(label) + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + } + .frame(width: 60) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(title) + .font(Typography.sectionHead) + .foregroundStyle(Color.textPrimary) + if let badge { + Text(badge) + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.bgBase) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.esoteric, in: RoundedRectangle(cornerRadius: 2)) + } + } + Text(description) + .font(Typography.body) + .foregroundStyle(Color.textMuted) + Text(detail) + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(isHovered ? Color.textPrimary : Color.textSubtle) + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(isHovered ? Color.bgHover : Color.bgRaised) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered in hovered = isHovered ? mode : nil } + } +} diff --git a/BroadcastBrain/Views/Components/StatusBarView.swift b/BroadcastBrain/Views/Components/StatusBarView.swift index 00c8a410..b678ba08 100644 --- a/BroadcastBrain/Views/Components/StatusBarView.swift +++ b/BroadcastBrain/Views/Components/StatusBarView.swift @@ -1,11 +1,12 @@ import SwiftUI -struct StatusBarView: View { +struct StatusBarView: View { let matchTitle: String /// Sport of the current session. When nil (e.g. on the Archive list /// where no single sport applies), falls back to a generic icon. let sport: Sport? let latencyMs: Int? + @ViewBuilder let trailing: () -> Trailing var body: some View { HStack(spacing: 12) { @@ -21,13 +22,24 @@ struct StatusBarView: View { LatencyTag(ms: ms) } LivePill() + trailing() } .padding(.horizontal, 16) .padding(.top, 34) - .padding(.bottom, 12) + .padding(.bottom, 14) + .frame(height: 72, alignment: .bottom) .background(Color.bgRaised) .overlay(alignment: .bottom) { Rectangle().fill(Color.bbBorder).frame(height: 1) } } } + +extension StatusBarView where Trailing == EmptyView { + init(matchTitle: String, sport: Sport?, latencyMs: Int?) { + self.matchTitle = matchTitle + self.sport = sport + self.latencyMs = latencyMs + self.trailing = { EmptyView() } + } +} diff --git a/BroadcastBrain/Views/Components/Tokens.swift b/BroadcastBrain/Views/Components/Tokens.swift index 39f5e8fd..28e2cc59 100644 --- a/BroadcastBrain/Views/Components/Tokens.swift +++ b/BroadcastBrain/Views/Components/Tokens.swift @@ -42,6 +42,7 @@ extension Color { static let live = Color.themed(light: "#DC2626", dark: "#EF4444") static let verified = Color.themed(light: "#059669", dark: "#10B981") static let esoteric = Color.themed(light: "#D97706", dark: "#F59E0B") + static let tactical = Color.themed(light: "#D97706", dark: "#F59E0B") } extension NSColor { diff --git a/BroadcastBrain/Views/LivePaneView.swift b/BroadcastBrain/Views/LivePaneView.swift index 1fb5e55e..04fea583 100644 --- a/BroadcastBrain/Views/LivePaneView.swift +++ b/BroadcastBrain/Views/LivePaneView.swift @@ -716,7 +716,7 @@ struct LivePaneView: View { if hadContent { // Reuse the same match for the next recording — don't re-prompt // the commentator for team/sport info after each match. - store.newSessionKeepingCurrentMatch() + store.newSession() } // 4. Route to Archive detail view for the just-ended session diff --git a/BroadcastBrain/Views/NewsTabView.swift b/BroadcastBrain/Views/NewsTabView.swift new file mode 100644 index 00000000..fc1175f8 --- /dev/null +++ b/BroadcastBrain/Views/NewsTabView.swift @@ -0,0 +1,355 @@ +import SwiftUI +import AppKit + +struct NewsTabView: View { + @Environment(AppStore.self) private var store + + @State private var items: [NewsItem] = [] + @State private var selectedLeague: String = "all" + @State private var isLoading = false + @State private var isSynthesizing = false + @State private var errorMessage: String? + @State private var statusMessage: String? + + @State private var selectionMode = false + @State private var selectedIds: Set = [] + + private let leagueFilters: [(key: String, label: String)] = [ + ("all", "ALL"), + ("nfl", "NFL"), + ("nba", "NBA"), + ("mlb", "MLB"), + ("nhl", "NHL"), + ("epl", "EPL"), + ("mls", "MLS"), + ] + + var body: some View { + VStack(spacing: 0) { + header + + if let msg = errorMessage { + Text(msg) + .font(Typography.chip) + .foregroundStyle(Color.live) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.bgRaised) + } + if let msg = statusMessage { + Text(msg) + .font(Typography.chip) + .foregroundStyle(Color.textMuted) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.bgRaised) + } + + if isLoading && items.isEmpty { + Spacer() + ProgressView("Fetching headlines…").progressViewStyle(.circular) + Spacer() + } else if filteredItems.isEmpty { + Spacer() + Text("No headlines yet. Tap Refresh.") + .font(Typography.body) + .foregroundStyle(Color.textMuted) + Spacer() + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(filteredItems) { item in + NewsRow( + item: item, + selectionMode: selectionMode, + isSelected: selectedIds.contains(item.id), + onToggleSelection: { toggleSelection(item) } + ) + } + } + .padding(20) + } + } + } + .background(Color.bgBase) + .task { if items.isEmpty { await refresh() } } + } + + private var filteredItems: [NewsItem] { + selectedLeague == "all" ? items : items.filter { $0.leagueKey == selectedLeague } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("NEWS") + .font(Typography.sectionHead) + .foregroundStyle(Color.textSubtle) + Spacer() + + Button(action: toggleSelectionMode) { + HStack(spacing: 4) { + Image(systemName: selectionMode ? "checkmark.square.fill" : "square") + .font(.system(size: 11)) + Text(selectionMode + ? "\(selectedIds.count) SELECTED" + : "SELECT ARTICLES" + ) + .font(Typography.chip) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(selectionMode ? Color.bgHover : Color.bgRaised, in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + .foregroundStyle(Color.textPrimary) + } + .buttonStyle(.plain) + + Button(action: { Task { await refresh() } }) { + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + Text("Refresh").font(Typography.chip) + } + .foregroundStyle(Color.textMuted) + } + .buttonStyle(.plain) + .disabled(isLoading) + + Button(action: { Task { await synthesize() } }) { + HStack(spacing: 4) { + if isSynthesizing { + ProgressView().controlSize(.small) + } else { + Image(systemName: "sparkles") + } + Text(synthesizeButtonLabel).font(Typography.chip) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.live.opacity(canSynthesize ? 0.9 : 0.3), in: RoundedRectangle(cornerRadius: 4)) + .foregroundStyle(Color.white) + } + .buttonStyle(.plain) + .disabled(!canSynthesize) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(leagueFilters, id: \.key) { filter in + leagueChip(filter) + } + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(Color.bgBase) + .overlay(Divider().background(Color.bbBorder), alignment: .bottom) + } + + private var synthesizeButtonLabel: String { + if selectionMode { + return selectedIds.isEmpty + ? "Select articles to synthesize" + : "Synthesize \(selectedIds.count) selected" + } + return "Synthesize all to notes" + } + + private func leagueChip(_ filter: (key: String, label: String)) -> some View { + let selected = selectedLeague == filter.key + return Button(action: { selectedLeague = filter.key }) { + Text(filter.label) + .font(Typography.chip) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(selected ? Color.live : Color.bgRaised, in: RoundedRectangle(cornerRadius: 4)) + .foregroundStyle(selected ? Color.white : Color.textPrimary) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + } + .buttonStyle(.plain) + } + + private var canSynthesize: Bool { + guard !isSynthesizing else { return false } + if selectionMode { return !selectedIds.isEmpty } + return !items.isEmpty + } + + private var synthesisPool: [NewsItem] { + if selectionMode { + return items.filter { selectedIds.contains($0.id) } + } + let scoped = filteredItems + return scoped.isEmpty ? items : scoped + } + + // MARK: - Actions + + private func toggleSelectionMode() { + selectionMode.toggle() + if !selectionMode { selectedIds.removeAll() } + } + + private func toggleSelection(_ item: NewsItem) { + if selectedIds.contains(item.id) { + selectedIds.remove(item.id) + } else { + selectedIds.insert(item.id) + } + } + + private func refresh() async { + isLoading = true + errorMessage = nil + let fetched = await NewsService.fetchAllSportsNews(limit: 10) + await MainActor.run { + self.items = fetched + self.isLoading = false + if fetched.isEmpty { self.errorMessage = "Couldn't fetch headlines (check network)." } + } + } + + private func synthesize() async { + let pool = synthesisPool + guard !pool.isEmpty else { return } + isSynthesizing = true + errorMessage = nil + let label = selectionMode ? "selected" : "filtered" + statusMessage = "Reading \(pool.count) \(label) headline\(pool.count == 1 ? "" : "s")…" + + let matchTitle = store.matchCache?.title + let playerNames = (store.matchCache?.players ?? []).map(\.name) + + // Short pause so the first message is readable before the request starts. + try? await Task.sleep(nanoseconds: 250_000_000) + await MainActor.run { self.statusMessage = "Writing the digest…" } + + do { + let digest = try await GeminiService.synthesizeNews( + headlines: pool, + matchTitle: matchTitle, + playerNames: playerNames, + userCurated: selectionMode + ) + await MainActor.run { + self.statusMessage = "Appending to Research notes…" + appendDigestToNotes(digest) + self.isSynthesizing = false + self.selectedIds.removeAll() + self.statusMessage = "Saved to Research notes." + } + } catch { + await MainActor.run { + self.isSynthesizing = false + self.statusMessage = nil + self.errorMessage = error.localizedDescription + } + } + } + + private func appendDigestToNotes(_ digest: String) { + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd HH:mm" + let stamp = df.string(from: Date()) + let header = "=== News digest · \(stamp) ===" + let existing = store.currentSession.notes + let combined = existing.isEmpty + ? "\(header)\n\(digest)" + : "\(existing)\n\n\(header)\n\(digest)" + store.updateNotes(combined) + } +} + +private struct NewsRow: View { + let item: NewsItem + let selectionMode: Bool + let isSelected: Bool + let onToggleSelection: () -> Void + + @State private var hovered = false + + private var articleURL: URL? { + guard let s = item.articleUrl, let url = URL(string: s) else { return nil } + return url + } + + private var strokeColor: Color { + if selectionMode && isSelected { return Color.live } + return hovered ? Color.textMuted : Color.bbBorder + } + + var body: some View { + Button(action: handleTap) { + HStack(alignment: .top, spacing: 10) { + if selectionMode { + Image(systemName: isSelected ? "checkmark.square.fill" : "square") + .font(.system(size: 14)) + .foregroundStyle(isSelected ? Color.live : Color.textSubtle) + .padding(.top, 2) + } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(item.leagueLabel) + .font(Typography.chip) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.bgRaised, in: RoundedRectangle(cornerRadius: 3)) + .foregroundStyle(Color.textMuted) + if !item.published.isEmpty { + Text(item.published.prefix(10)) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + } + Spacer() + if !selectionMode && articleURL != nil { + Image(systemName: "arrow.up.right.square") + .font(.system(size: 10)) + .foregroundStyle(hovered ? Color.textPrimary : Color.textSubtle) + } + } + Text(item.headline) + .font(Typography.body) + .foregroundStyle(Color.textPrimary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + if !item.description.isEmpty { + Text(item.description) + .font(Typography.chip) + .foregroundStyle(Color.textMuted) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) + } + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + (selectionMode && isSelected) ? Color.live.opacity(0.08) + : (hovered ? Color.bgHover : Color.bgRaised), + in: RoundedRectangle(cornerRadius: 4) + ) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(strokeColor, lineWidth: 1)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!selectionMode && articleURL == nil) + .onHover { hovered = $0 } + .help(selectionMode + ? (isSelected ? "Deselect" : "Select for synthesis") + : (articleURL?.absoluteString ?? "No link available")) + } + + private func handleTap() { + if selectionMode { + onToggleSelection() + } else if let url = articleURL { + NSWorkspace.shared.open(url) + } + } +} diff --git a/BroadcastBrain/Views/ResearchCenterView.swift b/BroadcastBrain/Views/ResearchCenterView.swift index e9e508fc..55127d11 100644 --- a/BroadcastBrain/Views/ResearchCenterView.swift +++ b/BroadcastBrain/Views/ResearchCenterView.swift @@ -2,8 +2,7 @@ import SwiftUI struct ResearchCenterView: View { @Environment(AppStore.self) private var store - @State private var promptText: String = "" - @State private var isSending = false + @State private var showingNotes = false var body: some View { VStack(spacing: 0) { @@ -11,14 +10,64 @@ struct ResearchCenterView: View { matchTitle: store.currentSession.title, sport: store.currentSession.match?.sport, latencyMs: store.lastLatencyMs - ) + ) { + if store.spottingMode != nil { + notesToggle + } + } + + HStack(spacing: 0) { + modeContent + .frame(maxWidth: .infinity, maxHeight: .infinity) - HSplitView { - notesColumn - chatColumn + if showingNotes && store.spottingMode != nil { + Rectangle().fill(Color.bbBorder).frame(width: 1) + notesColumn + .frame(width: 360) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } } } .background(Color.bgBase) + .animation(.easeInOut(duration: 0.2), value: showingNotes) + } + + @ViewBuilder + private var modeContent: some View { + if store.spottingMode == nil { + ZStack { + Color.bgBase + DottedGrid( + dotColor: Color.textPrimary.opacity(0.28), + spacing: 22, + dotSize: 2.4 + ) + CommentatorStylePickerView() + } + } else if store.spottingMode == .stats { + StatsFirstSpottingBoardView() + } else if store.spottingMode == .story { + StoryFirstSpottingBoardView() + } else if store.spottingMode == .tactical { + TacticalSpottingBoardView() + } + } + + private var notesToggle: some View { + Button(action: { showingNotes.toggle() }) { + HStack(spacing: 5) { + Image(systemName: showingNotes ? "chevron.right" : "note.text") + .font(.system(size: 10, weight: .semibold)) + Text(showingNotes ? "HIDE" : "NOTES") + .font(Typography.chip) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(showingNotes ? Color.live : Color.bgRaised, in: RoundedRectangle(cornerRadius: 4)) + .foregroundStyle(showingNotes ? Color.white : Color.textPrimary) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + } + .buttonStyle(.plain) } private var notesColumn: some View { @@ -44,104 +93,7 @@ struct ResearchCenterView: View { .background(Color.bgRaised, in: RoundedRectangle(cornerRadius: 4)) .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) } - .padding(20) - .frame(minWidth: 340) + .padding(16) .background(Color.bgBase) } - - private var chatColumn: some View { - VStack(spacing: 0) { - HStack { - Text("Q&A · GROUNDED IN MATCH CACHE") - .font(Typography.sectionHead) - .foregroundStyle(Color.textSubtle) - Spacer() - } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 8) - - ScrollView { - VStack(alignment: .leading, spacing: 4) { - ForEach(store.currentSession.researchMessages) { m in - ChatMessageRow(message: m) - } - if store.currentSession.researchMessages.isEmpty { - VStack(alignment: .leading, spacing: 6) { - Text("Ask grounded questions about the match.") - .font(Typography.body) - .foregroundStyle(Color.textMuted) - Text("e.g. \"How many WC goals does Mbappé have?\"") - .font(Typography.chip) - .foregroundStyle(Color.textSubtle) - } - .padding(.top, 12) - } - } - .padding(.horizontal, 20) - } - - HStack(spacing: 8) { - TextField("Ask a research question…", text: $promptText, onCommit: send) - .textFieldStyle(.plain) - .font(Typography.body) - .foregroundStyle(Color.textPrimary) - .padding(10) - .background(Color.bgRaised, in: RoundedRectangle(cornerRadius: 4)) - .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) - - Button(action: send) { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 24)) - .foregroundStyle(canSend ? Color.live : Color.textSubtle) - } - .buttonStyle(.plain) - .disabled(!canSend) - } - .padding(20) - } - .frame(minWidth: 340) - .background(Color.bgBase) - } - - private var canSend: Bool { - !isSending && !promptText.trimmingCharacters(in: .whitespaces).isEmpty - } - - private func send() { - let text = promptText.trimmingCharacters(in: .whitespaces) - guard !text.isEmpty, !isSending else { return } - promptText = "" - isSending = true - store.appendResearchMessage(ChatMessage(role: .user, content: text, grounded: false)) - - Task { - let facts = store.matchCache?.facts.joined(separator: "\n- ") ?? "" - let system = """ - You are a match research assistant. Answer from the verified facts below only. - If you don't know, say exactly: "I don't have verified data on that." - Keep answers under 3 sentences. - """ - let user = """ - Match facts: - - \(facts) - - Match: \(store.currentSession.title). Question: \(text) - """ - do { - let reply = try await store.cactus.complete(system: system, user: user) - let grounded = !reply.lowercased().contains("don't have verified") - && !reply.lowercased().contains("don't have that") - await MainActor.run { - store.appendResearchMessage(ChatMessage(role: .assistant, content: reply, grounded: grounded)) - isSending = false - } - } catch { - await MainActor.run { - store.appendResearchMessage(ChatMessage(role: .assistant, content: "Error: \(error.localizedDescription)", grounded: false)) - isSending = false - } - } - } - } } diff --git a/BroadcastBrain/Views/SidebarView.swift b/BroadcastBrain/Views/SidebarView.swift index 0f5ccc01..5e685af8 100644 --- a/BroadcastBrain/Views/SidebarView.swift +++ b/BroadcastBrain/Views/SidebarView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct SidebarView: View { @@ -29,6 +30,7 @@ struct SidebarView: View { surfaceRow(title: "Live", systemImage: "dot.radiowaves.left.and.right", surface: .live) surfaceRow(title: "Squads", systemImage: "person.2.fill", surface: .squads) surfaceRow(title: "Research", systemImage: "book.fill", surface: .research) + surfaceRow(title: "News", systemImage: "newspaper.fill", surface: .news) surfaceRow(title: "Archive", systemImage: "archivebox.fill", surface: .archive) surfaceRow(title: "Plays", systemImage: "sportscourt.fill", surface: .plays) surfaceRow(title: "Plays DB", systemImage: "tray.full.fill", surface: .playsDB) @@ -109,16 +111,10 @@ private struct BrandHeader: View { HStack(spacing: 10) { LogoMark() .frame(width: 30, height: 30) - VStack(alignment: .leading, spacing: 1) { - Text("BROADCAST") - .font(.system(size: 11, weight: .bold, design: .monospaced)) - .tracking(1.6) - .foregroundStyle(Color.textPrimary) - Text("BRAIN") - .font(.system(size: 11, weight: .bold, design: .monospaced)) - .tracking(1.6) - .foregroundStyle(Color.textPrimary) - } + Text("KLEOS") + .font(.system(size: 15, weight: .bold, design: .monospaced)) + .tracking(2.0) + .foregroundStyle(Color.textPrimary) Spacer() CollapseChevron(collapsed: false) { theme.toggleSidebar() } } @@ -127,7 +123,8 @@ private struct BrandHeader: View { .frame(maxWidth: .infinity, alignment: collapsed ? .center : .leading) .padding(.horizontal, collapsed ? 0 : 16) .padding(.top, 34) - .padding(.bottom, 14) + .padding(.bottom, 8) + .frame(height: 72, alignment: .bottom) .overlay(alignment: .bottom) { Rectangle().fill(Color.borderSoft).frame(height: 1) } @@ -470,6 +467,9 @@ private struct SidebarFooterControls: View { help: theme.mode == .dark ? "Light mode" : "Dark mode") { theme.toggleMode() } + IconButton(systemImage: "sidebar.left", help: "Expand sidebar") { + theme.toggleSidebar() + } } else { FooterPillButton( systemImage: theme.mode == .dark ? "sun.max" : "moon", diff --git a/BroadcastBrain/Views/StatsFirstSpottingBoardView.swift b/BroadcastBrain/Views/StatsFirstSpottingBoardView.swift new file mode 100644 index 00000000..c03e8618 --- /dev/null +++ b/BroadcastBrain/Views/StatsFirstSpottingBoardView.swift @@ -0,0 +1,362 @@ +import SwiftUI + +enum BoardDensity: String, CaseIterable { case compact = "COMPACT", standard = "STANDARD", full = "FULL" } + +struct StatsFirstSpottingBoardView: View { + @Environment(AppStore.self) private var store + @State private var density: BoardDensity = .standard + @State private var leftFilter: String = "" + @State private var rightFilter: String = "" + @State private var pinnedIds: Set = [] + + private var teams: (left: String, right: String) { + let parts = store.currentSession.title.components(separatedBy: " vs ") + let l = parts.first?.components(separatedBy: " ·").first?.trimmingCharacters(in: .whitespaces) ?? "Home" + let r = parts.dropFirst().first?.components(separatedBy: " ·").first?.trimmingCharacters(in: .whitespaces) ?? "Away" + return (l, r) + } + + var body: some View { + VStack(spacing: 0) { + subHeader + ZStack { + DottedGrid() + HStack(alignment: .top, spacing: 0) { + teamColumn( + teamName: teams.left, + accentHex: "#7AB8E3", + filter: $leftFilter + ) + Rectangle().fill(Color.bbBorder).frame(width: 1) + teamColumn( + teamName: teams.right, + accentHex: "#D06060", + filter: $rightFilter + ) + } + } + } + .background(Color.bgBase) + } + + // MARK: - Sub-header + + private var subHeader: some View { + HStack(spacing: 0) { + // Left cluster + HStack(spacing: 10) { + Text("SPOTTING BOARD") + .font(Typography.sectionHead) + .foregroundStyle(Color.textSubtle) + Rectangle().fill(Color.bbBorder).frame(width: 1, height: 14) + Circle().fill(Color.verified).frame(width: 6, height: 6) + Text("STATS-FIRST") + .font(Typography.chip) + .foregroundStyle(Color.verified) + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(Color.verified) + } + .padding(.leading, 20) + + Spacer() + + // My Style back button (standalone, not part of density picker) + backButton + .padding(.trailing, 10) + + // Density picker + HStack(spacing: 0) { + densityPicker + } + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(.trailing, 20) + } + .frame(height: 44) + .background(Color.bgRaised) + .overlay(alignment: .bottom) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } + + private var backButton: some View { + Button { + store.spottingMode = nil + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .semibold)) + Text("MY STYLE") + .font(Typography.chip) + } + .foregroundStyle(Color.textMuted) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.bgSubtle, in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + } + .buttonStyle(.plain) + .help("Back to commentator style picker") + } + + private var densityPicker: some View { + HStack(spacing: 0) { + ForEach(BoardDensity.allCases, id: \.self) { d in + Button { density = d } label: { + Text(d.rawValue) + .font(Typography.chip) + .foregroundStyle(density == d ? Color.textPrimary : Color.textSubtle) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(density == d ? Color.bgHover : Color.clear) + } + .buttonStyle(.plain) + if d != BoardDensity.allCases.last { + Rectangle().fill(Color.bbBorder).frame(width: 1, height: 14) + } + } + } + } + + // MARK: - Team column + + private func teamColumn(teamName: String, accentHex: String, filter: Binding) -> some View { + let accent = Color(hex: accentHex) + let allPlayers = (store.matchCache?.players ?? []).filter { $0.team == teamName } + let query = filter.wrappedValue.lowercased() + let players = query.isEmpty ? allPlayers : allPlayers.filter { + $0.name.lowercased().contains(query) || $0.position.lowercased().contains(query) + } + let pinned = players.filter { pinnedIds.contains($0.name) } + let unpinned = players.filter { !pinnedIds.contains($0.name) } + + return VStack(alignment: .leading, spacing: 0) { + // Column header + columnHeader(teamName: teamName, accent: accent, playerCount: allPlayers.count) + + // Filter bar + filterBar(placeholder: "FILTER \(teamName.uppercased()) PLAYERS...", text: filter) + + // Player list + ScrollView { + LazyVStack(spacing: 6) { + ForEach(pinned + unpinned, id: \.name) { player in + StatsPlayerCard( + player: player, + density: density, + isPinned: pinnedIds.contains(player.name), + onTogglePin: { + if pinnedIds.contains(player.name) { + pinnedIds.remove(player.name) + } else { + pinnedIds.insert(player.name) + } + } + ) + } + } + .padding(12) + } + } + .frame(maxWidth: .infinity) + .background(Color.bgBase) + } + + private func columnHeader(teamName: String, accent: Color, playerCount: Int) -> some View { + HStack(spacing: 8) { + Circle().fill(accent).frame(width: 8, height: 8) + Text(teamName.uppercased()) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + Text(String(teamName.prefix(3)).uppercased()) + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(accent.opacity(0.15), in: RoundedRectangle(cornerRadius: 3)) + + Spacer() + Text("\(playerCount) PLAYERS") + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + SportradarBadge() + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.bgRaised) + .overlay(alignment: .bottom) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } + + private func filterBar(placeholder: String, text: Binding) -> some View { + HStack(spacing: 8) { + Image(systemName: "line.3.horizontal.decrease") + .font(.system(size: 11)) + .foregroundStyle(Color.textSubtle) + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(Typography.chip) + .foregroundStyle(Color.textMuted) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.bgRaised) + .overlay(alignment: .bottom) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } +} + +// MARK: - Player Card + +private struct StatsPlayerCard: View { + let player: Player + let density: BoardDensity + let isPinned: Bool + let onTogglePin: () -> Void + + @State private var hovered = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + cardHeader + if density != .compact { + Divider().background(Color.bbBorder).padding(.horizontal, 12) + statsGrid.padding(.horizontal, 12).padding(.top, 10).padding(.bottom, 8) + } + if density == .full { + Divider().background(Color.bbBorder).padding(.horizontal, 12) + fullExtra.padding(.horizontal, 12).padding(.vertical, 8) + } + HStack { + SportradarBadge() + Spacer() + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + .background(hovered ? Color.bgHover : Color.bgRaised) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(isPinned ? Color.verified.opacity(0.5) : Color.bbBorder, lineWidth: isPinned ? 1.5 : 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .onHover { hovered = $0 } + } + + // Jersey + name + pin + edit + private var cardHeader: some View { + HStack(alignment: .top, spacing: 8) { + Text(player.jersey) + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + .frame(width: 20, alignment: .leading) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(player.name.uppercased()) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.8) + + if isPinned { + HStack(spacing: 3) { + Image(systemName: "pin.fill") + .font(.system(size: 8)) + Text("PINNED") + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + } + .foregroundStyle(Color.bgBase) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.verified, in: RoundedRectangle(cornerRadius: 2)) + } + + Spacer() + + Button(action: onTogglePin) { + Image(systemName: isPinned ? "pin.slash" : "pin") + .font(.system(size: 11)) + .foregroundStyle(isPinned ? Color.verified : Color.textSubtle) + } + .buttonStyle(.plain) + } + + // Position tags + let tags = positionTags + if !tags.isEmpty { + HStack(spacing: 4) { + ForEach(tags, id: \.self) { tag in + Text(tag) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + } + } + } + } + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, density == .compact ? 8 : 6) + } + + // Three big stat columns + private var statsGrid: some View { + let slots = statSlots + return HStack(alignment: .top, spacing: 0) { + ForEach(slots.indices, id: \.self) { i in + let slot = slots[i] + VStack(alignment: .leading, spacing: 2) { + Text(slot.value) + .font(.system(size: 22, weight: .medium, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(slot.label) + .font(.system(size: 9, weight: .regular, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + if i < slots.count - 1 { + Rectangle().fill(Color.bbBorder).frame(width: 1, height: 36) + .padding(.horizontal, 8) + } + } + } + } + + // Tactical note shown in full density + private var fullExtra: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10)) + .foregroundStyle(Color.esoteric) + .padding(.top, 1) + Text(player.keyStats["tactical"] ?? "—") + .font(Typography.chip) + .foregroundStyle(Color.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + } + + // Parse "5.2 xG · Top-3 this WC" → ("5.2", "xG") + private var statSlots: [(value: String, label: String)] { + let keys = ["stat1", "stat2", "stat3"] + return keys.compactMap { key -> (String, String)? in + guard let raw = player.keyStats[key], !raw.isEmpty else { return nil } + let clean = raw.components(separatedBy: " · ").first ?? raw + let parts = clean.components(separatedBy: " ") + let val = parts.first ?? "—" + let lbl = parts.dropFirst().joined(separator: " ") + return (val.isEmpty ? "—" : val, lbl.isEmpty ? key : lbl) + } + } + + private var positionTags: [String] { + var tags = [player.position] + if let s1 = player.keyStats["stat1"] { + // Pull highlighted segment after · + let parts = s1.components(separatedBy: " · ") + if parts.count > 1 { tags.append(parts[1]) } + } + return tags.filter { !$0.isEmpty } + } +} diff --git a/BroadcastBrain/Views/StoryFirstSpottingBoardView.swift b/BroadcastBrain/Views/StoryFirstSpottingBoardView.swift new file mode 100644 index 00000000..cd269f1a --- /dev/null +++ b/BroadcastBrain/Views/StoryFirstSpottingBoardView.swift @@ -0,0 +1,394 @@ +import SwiftUI + +struct StoryFirstSpottingBoardView: View { + @Environment(AppStore.self) private var store + @State private var leftFilter: String = "" + @State private var rightFilter: String = "" + @State private var pinnedIds: Set = [] + @State private var expandedIds: Set = [] + + private var teams: (left: String, right: String) { + let parts = store.currentSession.title.components(separatedBy: " vs ") + let l = parts.first?.components(separatedBy: " ·").first?.trimmingCharacters(in: .whitespaces) ?? "Home" + let r = parts.dropFirst().first?.components(separatedBy: " ·").first?.trimmingCharacters(in: .whitespaces) ?? "Away" + return (l, r) + } + + var body: some View { + VStack(spacing: 0) { + subHeader + ZStack { + DottedGrid() + HStack(alignment: .top, spacing: 0) { + teamColumn(teamName: teams.left, accentHex: "#7AB8E3", filter: $leftFilter) + Rectangle().fill(Color.bbBorder).frame(width: 1) + teamColumn(teamName: teams.right, accentHex: "#D06060", filter: $rightFilter) + } + } + } + .background(Color.bgBase) + } + + // MARK: Sub-header + + private var subHeader: some View { + HStack(spacing: 0) { + HStack(spacing: 10) { + Text("SPOTTING BOARD") + .font(Typography.sectionHead) + .foregroundStyle(Color.textSubtle) + Rectangle().fill(Color.bbBorder).frame(width: 1, height: 14) + Circle().fill(Color.esoteric).frame(width: 6, height: 6) + Text("STORY-FIRST") + .font(Typography.chip) + .foregroundStyle(Color.esoteric) + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(Color.esoteric) + } + .padding(.leading, 20) + + Spacer() + + Button { store.spottingMode = nil } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .semibold)) + Text("MY STYLE") + .font(Typography.chip) + } + .foregroundStyle(Color.textMuted) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.bgSubtle, in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + } + .buttonStyle(.plain) + .help("Back to commentator style picker") + .padding(.trailing, 10) + + HStack(spacing: 0) { + strandPicker + } + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(.trailing, 20) + } + .frame(height: 44) + .background(Color.bgRaised) + .overlay(alignment: .bottom) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } + + @State private var strandFilter: StoryStrand = .all + + private var strandPicker: some View { + HStack(spacing: 0) { + ForEach(StoryStrand.allCases, id: \.self) { s in + Button { strandFilter = s } label: { + Text(s.label) + .font(Typography.chip) + .foregroundStyle(strandFilter == s ? Color.textPrimary : Color.textSubtle) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(strandFilter == s ? Color.bgHover : Color.clear) + } + .buttonStyle(.plain) + if s != StoryStrand.allCases.last { + Rectangle().fill(Color.bbBorder).frame(width: 1, height: 14) + } + } + } + } + + // MARK: Team column + + private func teamColumn(teamName: String, accentHex: String, filter: Binding) -> some View { + let accent = Color(hex: accentHex) + let all = (store.matchCache?.players ?? []).filter { $0.team == teamName } + let query = filter.wrappedValue.lowercased() + var filtered = query.isEmpty ? all : all.filter { + $0.name.lowercased().contains(query) || $0.position.lowercased().contains(query) + } + if strandFilter != .all { + filtered = filtered.filter { strandFilter.matches($0) } + } + let pinned = filtered.filter { pinnedIds.contains($0.name) } + let unpinned = filtered.filter { !pinnedIds.contains($0.name) } + + return VStack(alignment: .leading, spacing: 0) { + columnHeader(teamName: teamName, accent: accent, playerCount: all.count) + filterBar(placeholder: "FILTER \(teamName.uppercased()) PLAYERS...", text: filter) + ScrollView { + LazyVStack(spacing: 8) { + ForEach(pinned + unpinned, id: \.name) { player in + StoryPlayerCard( + player: player, + isPinned: pinnedIds.contains(player.name), + isExpanded: expandedIds.contains(player.name), + onTogglePin: { + if pinnedIds.contains(player.name) { pinnedIds.remove(player.name) } + else { pinnedIds.insert(player.name) } + }, + onToggleExpand: { + if expandedIds.contains(player.name) { expandedIds.remove(player.name) } + else { expandedIds.insert(player.name) } + } + ) + } + } + .padding(12) + } + } + .frame(maxWidth: .infinity) + .background(Color.bgBase) + } + + private func columnHeader(teamName: String, accent: Color, playerCount: Int) -> some View { + HStack(spacing: 8) { + Circle().fill(accent).frame(width: 8, height: 8) + Text(teamName.uppercased()) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + Text(String(teamName.prefix(3)).uppercased()) + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(accent.opacity(0.15), in: RoundedRectangle(cornerRadius: 3)) + Spacer() + Text("\(playerCount) PLAYERS") + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + SportradarBadge() + } + .padding(.horizontal, 12).padding(.vertical, 10) + .background(Color.bgRaised) + .overlay(alignment: .bottom) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } + + private func filterBar(placeholder: String, text: Binding) -> some View { + HStack(spacing: 8) { + Image(systemName: "line.3.horizontal.decrease") + .font(.system(size: 11)).foregroundStyle(Color.textSubtle) + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(Typography.chip).foregroundStyle(Color.textMuted) + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background(Color.bgRaised) + .overlay(alignment: .bottom) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } +} + +// MARK: - Story strand filter + +enum StoryStrand: CaseIterable { + case all, arcs, milestones, rivalries + + var label: String { + switch self { + case .all: return "ALL" + case .arcs: return "ARCS" + case .milestones: return "MILESTONES" + case .rivalries: return "RIVALRIES" + } + } + + func matches(_ player: Player) -> Bool { + switch self { + case .all: return true + case .arcs: + return player.keyStats["storyHero"] != nil + case .milestones: + let hero = (player.keyStats["storyHero"] ?? "").lowercased() + return hero.contains("first") || hero.contains("record") || hero.contains("final") || hero.contains("hat-trick") + case .rivalries: + return (player.keyStats["stat4"] ?? "").contains("vs") || (player.keyStats["storyHero"] ?? "").lowercased().contains("vs") + } + } +} + +// MARK: - Story Player Card + +private struct StoryPlayerCard: View { + let player: Player + let isPinned: Bool + let isExpanded: Bool + let onTogglePin: () -> Void + let onToggleExpand: () -> Void + + @State private var hovered = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + cardHeader + Divider().background(Color.bbBorder).padding(.horizontal, 12) + storyBody + if isExpanded { expandedStats } + cardFooter + } + .background(hovered ? Color.bgHover : Color.bgRaised) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(isPinned ? Color.esoteric.opacity(0.6) : Color.bbBorder, + lineWidth: isPinned ? 1.5 : 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .onHover { hovered = $0 } + } + + // Jersey + name + pin + expand + private var cardHeader: some View { + HStack(alignment: .top, spacing: 8) { + Text(player.jersey) + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + .frame(width: 20, alignment: .leading) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(player.name.uppercased()) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + .lineLimit(1).minimumScaleFactor(0.8) + + if isPinned { + HStack(spacing: 3) { + Image(systemName: "pin.fill").font(.system(size: 8)) + Text("PINNED").font(.system(size: 9, weight: .semibold, design: .monospaced)) + } + .foregroundStyle(Color.bgBase) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(Color.esoteric, in: RoundedRectangle(cornerRadius: 2)) + } + + Spacer() + + Button(action: onTogglePin) { + Image(systemName: isPinned ? "pin.slash" : "pin") + .font(.system(size: 11)) + .foregroundStyle(isPinned ? Color.esoteric : Color.textSubtle) + } + .buttonStyle(.plain) + } + + HStack(spacing: 4) { + Text(player.position) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + if hasStory { + Text("·") + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + Text("HAS ARC") + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.esoteric) + } + } + } + } + .padding(.horizontal, 12).padding(.top, 10).padding(.bottom, 8) + } + + // The main story beat + private var storyBody: some View { + VStack(alignment: .leading, spacing: 8) { + // Headline arc + Text(headline) + .font(.system(size: 14, weight: .medium, design: .monospaced)) + .foregroundStyle(hasStory ? Color.textPrimary : Color.textMuted) + .italic() + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(3) + + // Context lines + VStack(alignment: .leading, spacing: 4) { + ForEach(contextLines, id: \.self) { line in + HStack(alignment: .top, spacing: 6) { + Rectangle() + .fill(Color.esoteric.opacity(0.6)) + .frame(width: 2, height: 10) + .padding(.top, 3) + Text(line) + .font(Typography.chip) + .foregroundStyle(Color.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + // Expand / collapse toggle + if hasExpandableStats { + Button(action: onToggleExpand) { + HStack(spacing: 4) { + Text(isExpanded ? "HIDE STATS" : "SHOW STATS") + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(Color.textSubtle) + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12).padding(.vertical, 10) + } + + // Inline stats revealed on expand + private var expandedStats: some View { + VStack(alignment: .leading, spacing: 6) { + Divider().background(Color.bbBorder).padding(.horizontal, 12) + VStack(alignment: .leading, spacing: 4) { + ForEach(allStatLines, id: \.self) { line in + HStack(spacing: 6) { + Image(systemName: "chart.bar.fill") + .font(.system(size: 9)) + .foregroundStyle(Color.verified) + Text(line) + .font(Typography.chip) + .foregroundStyle(Color.textPrimary) + } + } + } + .padding(.horizontal, 12).padding(.vertical, 8) + } + } + + private var cardFooter: some View { + HStack { + SportradarBadge() + Spacer() + } + .padding(.horizontal, 12).padding(.bottom, 8) + } + + // MARK: Helpers + + private var hasStory: Bool { player.keyStats["storyHero"] != nil } + + private var headline: String { + if let hero = player.keyStats["storyHero"] { return hero } + if let s1 = player.keyStats["stat1"] { return s1 } + return "No narrative arc seeded." + } + + private var contextLines: [String] { + var lines: [String] = [] + if player.keyStats["storyHero"] != nil { + if let s1 = player.keyStats["stat1"] { lines.append(s1) } + if let s2 = player.keyStats["stat2"] { lines.append(s2) } + } else { + if let s2 = player.keyStats["stat2"] { lines.append(s2) } + if let s3 = player.keyStats["stat3"] { lines.append(s3) } + } + return lines + } + + private var hasExpandableStats: Bool { + player.keyStats["stat1"] != nil || player.keyStats["stat2"] != nil + } + + private var allStatLines: [String] { + ["stat1", "stat2", "stat3", "stat4"].compactMap { player.keyStats[$0] } + } +} diff --git a/BroadcastBrain/Views/TacticalSpottingBoardView.swift b/BroadcastBrain/Views/TacticalSpottingBoardView.swift new file mode 100644 index 00000000..2fadc66c --- /dev/null +++ b/BroadcastBrain/Views/TacticalSpottingBoardView.swift @@ -0,0 +1,368 @@ +import SwiftUI + +struct TacticalSpottingBoardView: View { + @Environment(AppStore.self) private var store + @State private var leftFilter: String = "" + @State private var rightFilter: String = "" + @State private var pinnedIds: Set = [] + @State private var density: BoardDensity = .standard + + private var teams: (left: String, right: String) { + let parts = store.currentSession.title.components(separatedBy: " vs ") + let l = parts.first?.components(separatedBy: " ·").first?.trimmingCharacters(in: .whitespaces) ?? "Home" + let r = parts.dropFirst().first?.components(separatedBy: " ·").first?.trimmingCharacters(in: .whitespaces) ?? "Away" + return (l, r) + } + + // Known formations for the 2022 WC Final; derived from title as fallback + private let formations: [String: (code: String, shape: String)] = [ + "Argentina": ("ARG", "4-4-2"), + "France": ("FRA", "4-2-3-1"), + ] + + var body: some View { + VStack(spacing: 0) { + subHeader + ZStack { + DottedGrid() + HStack(alignment: .top, spacing: 0) { + teamColumn(teamName: teams.left, accentHex: "#7AB8E3", filter: $leftFilter) + Rectangle().fill(Color.bbBorder).frame(width: 1) + teamColumn(teamName: teams.right, accentHex: "#D06060", filter: $rightFilter) + } + } + } + .background(Color.bgBase) + } + + // MARK: Sub-header + + private var subHeader: some View { + HStack(spacing: 0) { + HStack(spacing: 10) { + Text("SPOTTING BOARD") + .font(Typography.sectionHead) + .foregroundStyle(Color.textSubtle) + Rectangle().fill(Color.bbBorder).frame(width: 1, height: 14) + Circle().fill(Color.tactical).frame(width: 6, height: 6) + Text("TACTICAL") + .font(Typography.chip) + .foregroundStyle(Color.tactical) + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(Color.tactical) + } + .padding(.leading, 20) + + Spacer() + + Button { store.spottingMode = nil } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .semibold)) + Text("MY STYLE") + .font(Typography.chip) + } + .foregroundStyle(Color.textMuted) + .padding(.horizontal, 10).padding(.vertical, 6) + .background(Color.bgSubtle, in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + } + .buttonStyle(.plain) + .help("Back to commentator style picker") + .padding(.trailing, 10) + + HStack(spacing: 0) { + densityPicker + } + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(.trailing, 20) + } + .frame(height: 44) + .background(Color.bgRaised) + .overlay(alignment: .bottom) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } + + private var densityPicker: some View { + HStack(spacing: 0) { + ForEach(BoardDensity.allCases, id: \.self) { d in + Button { density = d } label: { + Text(d.rawValue) + .font(Typography.chip) + .foregroundStyle(density == d ? Color.textPrimary : Color.textSubtle) + .padding(.horizontal, 10).padding(.vertical, 6) + .background(density == d ? Color.bgHover : Color.clear) + } + .buttonStyle(.plain) + if d != BoardDensity.allCases.last { + Rectangle().fill(Color.bbBorder).frame(width: 1, height: 14) + } + } + } + } + + // MARK: Team column + + private func teamColumn(teamName: String, accentHex: String, filter: Binding) -> some View { + let accent = Color(hex: accentHex) + let all = (store.matchCache?.players ?? []).filter { $0.team == teamName } + let query = filter.wrappedValue.lowercased() + let players = query.isEmpty ? all : all.filter { + $0.name.lowercased().contains(query) || $0.position.lowercased().contains(query) + } + let pinned = players.filter { pinnedIds.contains($0.name) } + let unpinned = players.filter { !pinnedIds.contains($0.name) } + let meta = formations[teamName] + + return VStack(alignment: .leading, spacing: 0) { + columnHeader(teamName: teamName, accent: accent, meta: meta, playerCount: all.count) + filterBar(placeholder: "FILTER \(teamName.uppercased()) PLAYERS...", text: filter) + ScrollView { + LazyVStack(spacing: 6) { + ForEach(pinned + unpinned, id: \.name) { player in + TacticalPlayerCard( + player: player, + density: density, + isPinned: pinnedIds.contains(player.name), + onTogglePin: { + if pinnedIds.contains(player.name) { pinnedIds.remove(player.name) } + else { pinnedIds.insert(player.name) } + } + ) + } + } + .padding(12) + } + } + .frame(maxWidth: .infinity) + .background(Color.bgBase) + } + + private func columnHeader(teamName: String, accent: Color, meta: (code: String, shape: String)?, playerCount: Int) -> some View { + HStack(spacing: 8) { + Circle().fill(accent).frame(width: 8, height: 8) + Text(teamName.uppercased()) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + if let meta { + Text(meta.code) + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(accent.opacity(0.15), in: RoundedRectangle(cornerRadius: 3)) + Text(meta.shape) + .font(Typography.chip) + .foregroundStyle(Color.tactical) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.tactical.opacity(0.12), in: RoundedRectangle(cornerRadius: 3)) + } + Spacer() + Text("\(playerCount) PLAYERS") + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + SportradarBadge() + } + .padding(.horizontal, 12).padding(.vertical, 10) + .background(Color.bgRaised) + .overlay(alignment: .bottom) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } + + private func filterBar(placeholder: String, text: Binding) -> some View { + HStack(spacing: 8) { + Image(systemName: "line.3.horizontal.decrease") + .font(.system(size: 11)).foregroundStyle(Color.textSubtle) + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(Typography.chip).foregroundStyle(Color.textMuted) + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background(Color.bgRaised) + .overlay(alignment: .bottom) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } +} + +// MARK: - Press zone + +private enum PressZone: String { + case high = "HIGH", mid = "MID", passive = "PASSIVE" + + var color: Color { + switch self { + case .high: return Color.verified + case .mid: return Color.esoteric + case .passive: return Color.textSubtle + } + } + + static func from(position: String, tactical: String?) -> PressZone { + let tac = (tactical ?? "").lowercased() + if tac.contains("press") || tac.contains("high line") { return .high } + if tac.contains("screens") || tac.contains("deep") || tac.contains("drops") { return .passive } + switch position { + case "FW": return .high + case "MF": return .mid + default: return .passive + } + } +} + +// MARK: - Tactical Player Card + +private struct TacticalPlayerCard: View { + let player: Player + let density: BoardDensity + let isPinned: Bool + let onTogglePin: () -> Void + + @State private var hovered = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + cardHeader + Divider().background(Color.bbBorder).padding(.horizontal, 12) + tacticalNote + if density != .compact { bottomStats } + if density == .full { statLines } + footerRow + } + .background(hovered ? Color.bgHover : Color.bgRaised) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(isPinned ? Color.tactical.opacity(0.6) : Color.bbBorder, + lineWidth: isPinned ? 1.5 : 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .onHover { hovered = $0 } + } + + private var cardHeader: some View { + HStack(alignment: .top, spacing: 8) { + Text(player.jersey) + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + .frame(width: 20, alignment: .leading) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(player.name.uppercased()) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + .lineLimit(1).minimumScaleFactor(0.8) + + if isPinned { + HStack(spacing: 3) { + Image(systemName: "pin.fill").font(.system(size: 8)) + Text("PINNED").font(.system(size: 9, weight: .semibold, design: .monospaced)) + } + .foregroundStyle(Color.bgBase) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(Color.tactical, in: RoundedRectangle(cornerRadius: 2)) + } + + Spacer() + + Button(action: onTogglePin) { + Image(systemName: isPinned ? "pin.slash" : "pin") + .font(.system(size: 11)) + .foregroundStyle(isPinned ? Color.tactical : Color.textSubtle) + } + .buttonStyle(.plain) + } + + // Position + press zone inline + HStack(spacing: 6) { + Text(player.position) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + let zone = PressZone.from(position: player.position, tactical: player.keyStats["tactical"]) + Text("·") + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + Text("PRESS \(zone.rawValue)") + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundStyle(zone.color) + } + } + } + .padding(.horizontal, 12).padding(.top, 10).padding(.bottom, 8) + } + + private var tacticalNote: some View { + Text(player.keyStats["tactical"] ?? "No tactical note.") + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .foregroundStyle(Color.textMuted) + .italic() + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(3) + .padding(.horizontal, 12).padding(.vertical, 10) + } + + // KEY ACTIONS | KEY PASSES | PRESS ZONE + private var bottomStats: some View { + let zone = PressZone.from(position: player.position, tactical: player.keyStats["tactical"]) + let actions = extractNumber(from: player.keyStats["stat1"]) + let passes = extractNumber(from: player.keyStats["stat2"]) + + return HStack(spacing: 0) { + tacticalStatCell(value: actions ?? "—", label: "KEY ACTIONS") + Rectangle().fill(Color.bbBorder).frame(width: 1, height: 30) + tacticalStatCell(value: passes ?? "—", label: "KEY PASSES") + Rectangle().fill(Color.bbBorder).frame(width: 1, height: 30) + VStack(alignment: .leading, spacing: 2) { + Text(zone.rawValue) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .foregroundStyle(zone.color) + Text("PRESS ZONE") + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + } + .padding(.vertical, 8) + .background(Color.bgSubtle) + .overlay(alignment: .top) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } + + // Full stat lines in expanded density + private var statLines: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(["stat1","stat2","stat3","stat4"].compactMap({ player.keyStats[$0] }), id: \.self) { line in + HStack(spacing: 6) { + Rectangle().fill(Color.tactical).frame(width: 2, height: 10).padding(.top, 2) + Text(line) + .font(Typography.chip).foregroundStyle(Color.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.horizontal, 12).padding(.vertical, 8) + .overlay(alignment: .top) { Rectangle().fill(Color.bbBorder).frame(height: 1) } + } + + private var footerRow: some View { + HStack { SportradarBadge(); Spacer() } + .padding(.horizontal, 12).padding(.vertical, 6) + } + + private func tacticalStatCell(value: String, label: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + Text(label) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + } + + // Extract leading number from strings like "5.2 xG · Top-3" → "5.2", "86% tackle" → "86%" + private func extractNumber(from raw: String?) -> String? { + guard let raw, !raw.isEmpty else { return nil } + let first = raw.components(separatedBy: " ").first ?? "" + return first.isEmpty ? nil : first + } +} diff --git a/BroadcastBrain/Views/TeamSetupView.swift b/BroadcastBrain/Views/TeamSetupView.swift new file mode 100644 index 00000000..2e50f2f4 --- /dev/null +++ b/BroadcastBrain/Views/TeamSetupView.swift @@ -0,0 +1,246 @@ +import SwiftUI + +struct TeamSetupView: View { + @Environment(AppStore.self) private var store + @State private var teamInput: String = "" + @State private var fetchService = GameFetchService() + @State private var error: String? = nil + @State private var isFetching = false + @FocusState private var focused: Bool + + var body: some View { + ZStack { + Color(hex: "#F7F7F6").ignoresSafeArea() + + DottedGrid( + dotColor: Color(hex: "#0A0A0A").opacity(0.22), + spacing: 22, + dotSize: 2.2 + ) + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + card + Spacer() + } + .padding(40) + } + .preferredColorScheme(.light) + } + + private var card: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 8) { + Text("KLEOS") + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + Spacer() + HStack(spacing: 4) { + Circle().fill(Color.verified).frame(width: 6, height: 6) + Text("AIRPLANE MODE SAFE") + .font(Typography.chip) + .foregroundStyle(Color.verified) + } + } + .padding(.horizontal, 28).padding(.top, 28).padding(.bottom, 20) + + Divider().background(Color.bbBorder) + + // Main content + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("Set up tonight's match.") + .font(.system(size: 22, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + Text("Enter a team name — we'll fetch the roster, next game, and storylines.") + .font(Typography.body) + .foregroundStyle(Color.textMuted) + } + + // Input + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 13)) + .foregroundStyle(Color.textSubtle) + TextField("e.g. Manchester City, Lakers, Yankees…", text: $teamInput) + .textFieldStyle(.plain) + .font(.system(size: 15, weight: .regular, design: .monospaced)) + .foregroundStyle(Color.textPrimary) + .focused($focused) + .disabled(isFetching) + .onSubmit { startFetch() } + } + .padding(14) + .background(Color.bgSubtle, in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke( + focused ? Color.verified.opacity(0.6) : Color.bbBorder, lineWidth: 1)) + + // Progress / error + if isFetching { + progressView + } else if let err = error { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 11)) + .foregroundStyle(Color.live) + Text(err) + .font(Typography.chip) + .foregroundStyle(Color.live) + } + } + + // Actions + HStack(spacing: 10) { + Button(action: startFetch) { + HStack(spacing: 6) { + if isFetching { + ProgressView().scaleEffect(0.7).frame(width: 14, height: 14) + } else { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 13)) + } + Text(isFetching ? "BUILDING CACHE…" : "BUILD CACHE") + .font(Typography.sectionHead) + } + .foregroundStyle(canFetch ? Color.bgBase : Color.textSubtle) + .padding(.horizontal, 18).padding(.vertical, 10) + .background(canFetch ? Color.verified : Color.bgSubtle, + in: RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.plain) + .disabled(!canFetch) + + // Only show skip if a cache already exists + if store.matchCache != nil { + Button("KEEP CURRENT MATCH") { + store.showingSetup = false + } + .buttonStyle(.plain) + .font(Typography.chip) + .foregroundStyle(Color.textSubtle) + } + } + } + .padding(28) + + // Quick-pick suggestions + Divider().background(Color.bbBorder) + quickPicks + } + .background(Color.bgRaised, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.bbBorder, lineWidth: 1)) + .frame(maxWidth: 560) + .onAppear { focused = true } + } + + private var progressView: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(FetchStep.allCases, id: \.self) { s in + HStack(spacing: 8) { + stepIcon(s) + VStack(alignment: .leading, spacing: 1) { + Text(s.rawValue) + .font(Typography.chip) + .foregroundStyle(labelColor(s)) + if fetchService.step == s && !fetchService.stepDetail.isEmpty { + Text(fetchService.stepDetail) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + } + } + } + } + } + .padding(14) + .background(Color.bgSubtle, in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.bbBorder, lineWidth: 1)) + } + + @ViewBuilder + private func stepIcon(_ s: FetchStep) -> some View { + let current = fetchService.step + let idx = FetchStep.allCases.firstIndex(of: s) ?? 0 + let curIdx = FetchStep.allCases.firstIndex(of: current) ?? 0 + if idx < curIdx { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 11)).foregroundStyle(Color.verified) + } else if idx == curIdx { + ProgressView().scaleEffect(0.6).frame(width: 11, height: 11) + } else { + Circle().stroke(Color.bbBorder, lineWidth: 1).frame(width: 9, height: 9) + .padding(1) + } + } + + private func labelColor(_ s: FetchStep) -> Color { + let idx = FetchStep.allCases.firstIndex(of: s) ?? 0 + let curIdx = FetchStep.allCases.firstIndex(of: fetchService.step) ?? 0 + if idx < curIdx { return Color.verified } + if idx == curIdx { return Color.textPrimary } + return Color.textSubtle + } + + private var quickPicks: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + Text("QUICK PICK:") + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + .foregroundStyle(Color.textSubtle) + ForEach(suggestions, id: \.self) { team in + Button(team) { + teamInput = team + startFetch() + } + .buttonStyle(.plain) + .font(Typography.chip) + .foregroundStyle(Color.textMuted) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Color.bgSubtle, in: RoundedRectangle(cornerRadius: 3)) + .overlay(RoundedRectangle(cornerRadius: 3).stroke(Color.bbBorder, lineWidth: 1)) + .disabled(isFetching) + } + } + .padding(.horizontal, 28).padding(.vertical, 14) + } + } + + private let suggestions = [ + "Manchester City","Arsenal","Real Madrid", + "Los Angeles Lakers","Boston Celtics", + "New York Yankees","Los Angeles Dodgers", + "Toronto Maple Leafs","Edmonton Oilers", + ] + + private var canFetch: Bool { + !isFetching && !teamInput.trimmingCharacters(in: .whitespaces).isEmpty + } + + private func startFetch() { + let name = teamInput.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty, !isFetching else { return } + error = nil + isFetching = true + Task { + do { + let cache = try await fetchService.buildMatchCache(teamName: name) + await MainActor.run { + store.loadMatchCache(cache) + isFetching = false + } + } catch { + await MainActor.run { + self.error = error.localizedDescription + isFetching = false + } + } + } + } +} + +extension FetchStep: CaseIterable { + static var allCases: [FetchStep] { + [.detectingSport, .findingGame, .fetchingRosters, .fetchingNews, .buildingCache, .done] + } +} diff --git a/landing/components/ArchitectureDiagram.tsx b/landing/components/ArchitectureDiagram.tsx index 25131a64..b8f1cf01 100644 --- a/landing/components/ArchitectureDiagram.tsx +++ b/landing/components/ArchitectureDiagram.tsx @@ -12,18 +12,10 @@ const STAGES = [ accent: "live", icon: "mic", }, - { - id: "stt", - label: "APPLE STT", - sub: "On-device transcription", - chip: "< 200ms · ON-DEVICE", - accent: "text", - icon: "waveform", - }, { id: "gemma", label: "GEMMA 4 · CACTUS", - sub: "E4B · 4.5B params · tool calls", + sub: "Native audio input · E4B · 4.5B params", chip: "ON-DEVICE · HYBRID ROUTER", accent: "routing", icon: "brain", @@ -80,10 +72,10 @@ export function ArchitectureDiagram() {

Voice to stat card in a single pass — no round-trip to the cloud. - Apple captures audio, transcribes on-device. Gemma 4 on Cactus decides - what's relevant and calls deterministic tools against a - Sportradar-sourced match cache. The card renders in the booth in under - a second, airplane-mode-safe from kickoff onward. + Gemma 4 on Cactus takes the booth audio directly, decides what's + relevant, and calls deterministic tools against a Sportradar-sourced + match cache. The card renders in the booth in under a second, + airplane-mode-safe from kickoff onward.

diff --git a/landing/components/Features.tsx b/landing/components/Features.tsx index 4eb9593a..20fa7804 100644 --- a/landing/components/Features.tsx +++ b/landing/components/Features.tsx @@ -22,15 +22,15 @@ export function Features() { number="01" title="Pre-match auto spotting board" body="Overnight at T-12h, BroadcastBrain ingests squads, tournament stats, match history, injuries, and news from Sportradar — then Gemma 4 writes the storylines, head-to-heads, and matchup notes. Opens offline. Works in airplane mode from load onward." - preview="/assets/product/spotting-board-preview.svg" + preview="/assets/product/spotting-board-preview.png" tag="FEATURE 01" accent="verified" /> diff --git a/landing/components/WhyWeWin.tsx b/landing/components/WhyWeWin.tsx index 9ba0269a..b3240e6d 100644 --- a/landing/components/WhyWeWin.tsx +++ b/landing/components/WhyWeWin.tsx @@ -20,7 +20,7 @@ const PILLARS = [ { tag: "VOICE → ACTION LATENCY", title: "Under one second, measured end-to-end.", - body: "Mic capture → Apple STT → Gemma tool call → rendered card. P50 in the hundreds of milliseconds on an iPad Pro. The broadcaster glances and reads before the moment passes.", + body: "Mic capture → Gemma 4 (native audio in) → tool call → rendered card. P50 in the hundreds of milliseconds on an iPad Pro. The broadcaster glances and reads before the moment passes.", metric: "<1s", metricLabel: "P50 END-TO-END", accent: "live", diff --git a/landing/public/assets/product/live-dashboard-mockup.png b/landing/public/assets/product/live-dashboard-mockup.png new file mode 100644 index 00000000..06e7e3d6 Binary files /dev/null and b/landing/public/assets/product/live-dashboard-mockup.png differ diff --git a/landing/public/assets/product/spotting-board-preview.png b/landing/public/assets/product/spotting-board-preview.png new file mode 100644 index 00000000..81fc7935 Binary files /dev/null and b/landing/public/assets/product/spotting-board-preview.png differ diff --git a/project.yml b/project.yml index 5f79e93c..81836c77 100644 --- a/project.yml +++ b/project.yml @@ -39,9 +39,11 @@ targets: info: path: BroadcastBrain/Info.plist properties: - NSMicrophoneUsageDescription: "BroadcastBrain listens to match commentary to surface stats." - NSSpeechRecognitionUsageDescription: "BroadcastBrain transcribes your voice on-device to know which stat to surface." - NSHumanReadableCopyright: "© 2026 BroadcastBrain" + CFBundleName: "Kleos" + CFBundleDisplayName: "Kleos" + NSMicrophoneUsageDescription: "Kleos listens to match commentary to surface stats." + NSSpeechRecognitionUsageDescription: "Kleos transcribes your voice on-device to know which stat to surface." + NSHumanReadableCopyright: "© 2026 Kleos" CFBundleShortVersionString: "0.1.0" CFBundleVersion: "1" LSMinimumSystemVersion: "14.0" diff --git a/src/data/FetchGame.swift b/src/data/FetchGame.swift new file mode 100644 index 00000000..8e1062c6 --- /dev/null +++ b/src/data/FetchGame.swift @@ -0,0 +1,1000 @@ +#!/usr/bin/env swift +/** + * FetchGame.swift — BroadcastBrain overnight game cache builder. + * + * WHAT THIS SCRIPT DOES: + * Run it the night before a game with a team name. It pulls everything the + * spotting board needs — next game details, both rosters, player stats, + * news headlines, and injury reports — then writes it all to + * assets/game_cache.json so the app works in airplane mode during the demo. + * + * HOW IT GETS THE DATA (no paid APIs, no API keys): + * Sport │ API used + * ─────────────┼──────────────────────────────────────── + * Soccer │ ESPN unofficial (site.api.espn.com) + * Basketball │ ESPN unofficial (site.api.espn.com) + * Baseball │ MLB official (statsapi.mlb.com) + * Hockey │ NHL official (api-web.nhle.com) + * News/injury │ Google News RSS (news.google.com/rss) + * + * USAGE: + * swift src/data/FetchGame.swift "Manchester City" + * swift src/data/FetchGame.swift "Los Angeles Lakers" + * swift src/data/FetchGame.swift "New York Yankees" + * swift src/data/FetchGame.swift "Toronto Maple Leafs" + * + * OUTPUT: + * assets/game_cache.json + */ + +import Foundation + +// MARK: - Types + +struct PlayerStats: Codable { + var season: [String: String] + var formLast5: [String: String] + var vsOpponent: [String: String] + enum CodingKeys: String, CodingKey { + case season + case formLast5 = "form_last_5" + case vsOpponent = "vs_opponent" + } +} + +struct Player: Codable { + var id: String + var teamId: String + var shirtNumber: Int + var name: String + var position: String + var age: Int + var stats: PlayerStats + var storyline: String + var matchupNote: String + var topStats: [String] + var status: PlayerStatus + var newsHeadlines: [String] + enum PlayerStatus: String, Codable { case fit, doubtful, injured, suspended } + enum CodingKeys: String, CodingKey { + case id, name, position, age, stats, storyline, status + case teamId = "team_id" + case shirtNumber = "shirt_number" + case matchupNote = "matchup_note" + case topStats = "top_stats" + case newsHeadlines = "news_headlines" + } +} + +struct Team: Codable { + var id: String + var name: String + var colorHex: String + var record: [String: String] + enum CodingKeys: String, CodingKey { + case id, name, record + case colorHex = "color_hex" + } +} + +struct MatchInfo: Codable { + var id: String + var homeTeam: String + var awayTeam: String + var competition: String + var venue: String + var kickoffISO: String + enum CodingKeys: String, CodingKey { + case id, competition, venue + case homeTeam = "home_team" + case awayTeam = "away_team" + case kickoffISO = "kickoff_iso" + } +} + +struct TeamsPayload: Codable { + var home: Team + var away: Team +} + +struct GameCache: Codable { + var match: MatchInfo + var teams: TeamsPayload + var players: [Player] + var storylines: [String] + var source: String + var generatedAt: String + enum CodingKeys: String, CodingKey { + case match, teams, players, storylines, source + case generatedAt = "generated_at" + } +} + +struct GameInfo { + var eventId: String + var homeTeam: String + var awayTeam: String + var homeId: String + var awayId: String + var venue: String + var dateISO: String + var competition: String +} + +struct RawPlayer { + var id: String + var name: String + var number: Int? + var position: String + var age: Int? +} + +typealias SportEntry = (sport: String, league: String, display: String) + +// MARK: - Constants + +let KNOWN_TEAMS: [String: SportEntry] = [ + // Soccer – Premier League + "manchester city": ("soccer", "eng.1", "Premier League"), + "manchester united": ("soccer", "eng.1", "Premier League"), + "liverpool": ("soccer", "eng.1", "Premier League"), + "arsenal": ("soccer", "eng.1", "Premier League"), + "chelsea": ("soccer", "eng.1", "Premier League"), + "tottenham": ("soccer", "eng.1", "Premier League"), + "spurs": ("soccer", "eng.1", "Premier League"), + "newcastle": ("soccer", "eng.1", "Premier League"), + "aston villa": ("soccer", "eng.1", "Premier League"), + "west ham": ("soccer", "eng.1", "Premier League"), + "brighton": ("soccer", "eng.1", "Premier League"), + "everton": ("soccer", "eng.1", "Premier League"), + "fulham": ("soccer", "eng.1", "Premier League"), + "brentford": ("soccer", "eng.1", "Premier League"), + "nottingham forest": ("soccer", "eng.1", "Premier League"), + "wolves": ("soccer", "eng.1", "Premier League"), + "wolverhampton": ("soccer", "eng.1", "Premier League"), + "crystal palace": ("soccer", "eng.1", "Premier League"), + "leicester": ("soccer", "eng.1", "Premier League"), + "ipswich": ("soccer", "eng.1", "Premier League"), + "southampton": ("soccer", "eng.1", "Premier League"), + "leeds": ("soccer", "eng.1", "Premier League"), + // Soccer – La Liga + "real madrid": ("soccer", "esp.1", "La Liga"), + "barcelona": ("soccer", "esp.1", "La Liga"), + "atletico madrid": ("soccer", "esp.1", "La Liga"), + "athletic bilbao": ("soccer", "esp.1", "La Liga"), + "real sociedad": ("soccer", "esp.1", "La Liga"), + "villarreal": ("soccer", "esp.1", "La Liga"), + "sevilla": ("soccer", "esp.1", "La Liga"), + "betis": ("soccer", "esp.1", "La Liga"), + // Soccer – Bundesliga + "bayern munich": ("soccer", "ger.1", "Bundesliga"), + "borussia dortmund": ("soccer", "ger.1", "Bundesliga"), + "bayer leverkusen": ("soccer", "ger.1", "Bundesliga"), + "rb leipzig": ("soccer", "ger.1", "Bundesliga"), + "eintracht frankfurt": ("soccer", "ger.1", "Bundesliga"), + // Soccer – Serie A + "juventus": ("soccer", "ita.1", "Serie A"), + "inter milan": ("soccer", "ita.1", "Serie A"), + "ac milan": ("soccer", "ita.1", "Serie A"), + "napoli": ("soccer", "ita.1", "Serie A"), + "roma": ("soccer", "ita.1", "Serie A"), + "lazio": ("soccer", "ita.1", "Serie A"), + "atalanta": ("soccer", "ita.1", "Serie A"), + "fiorentina": ("soccer", "ita.1", "Serie A"), + // Soccer – Ligue 1 + "paris saint-germain": ("soccer", "fra.1", "Ligue 1"), + "psg": ("soccer", "fra.1", "Ligue 1"), + "monaco": ("soccer", "fra.1", "Ligue 1"), + "marseille": ("soccer", "fra.1", "Ligue 1"), + "lyon": ("soccer", "fra.1", "Ligue 1"), + "nice": ("soccer", "fra.1", "Ligue 1"), + "lille": ("soccer", "fra.1", "Ligue 1"), + // Soccer – MLS + "inter miami": ("soccer", "usa.1", "MLS"), + "la galaxy": ("soccer", "usa.1", "MLS"), + "lafc": ("soccer", "usa.1", "MLS"), + "seattle sounders": ("soccer", "usa.1", "MLS"), + "portland timbers": ("soccer", "usa.1", "MLS"), + "new york city": ("soccer", "usa.1", "MLS"), + "new york red bulls": ("soccer", "usa.1", "MLS"), + "atlanta united": ("soccer", "usa.1", "MLS"), + // NBA + "los angeles lakers": ("basketball", "nba", "NBA"), + "lakers": ("basketball", "nba", "NBA"), + "golden state warriors": ("basketball", "nba", "NBA"), + "warriors": ("basketball", "nba", "NBA"), + "boston celtics": ("basketball", "nba", "NBA"), + "celtics": ("basketball", "nba", "NBA"), + "miami heat": ("basketball", "nba", "NBA"), + "chicago bulls": ("basketball", "nba", "NBA"), + "brooklyn nets": ("basketball", "nba", "NBA"), + "new york knicks": ("basketball", "nba", "NBA"), + "knicks": ("basketball", "nba", "NBA"), + "dallas mavericks": ("basketball", "nba", "NBA"), + "mavs": ("basketball", "nba", "NBA"), + "milwaukee bucks": ("basketball", "nba", "NBA"), + "denver nuggets": ("basketball", "nba", "NBA"), + "phoenix suns": ("basketball", "nba", "NBA"), + "philadelphia 76ers": ("basketball", "nba", "NBA"), + "cleveland cavaliers": ("basketball", "nba", "NBA"), + "oklahoma city thunder": ("basketball", "nba", "NBA"), + "houston rockets": ("basketball", "nba", "NBA"), + "memphis grizzlies": ("basketball", "nba", "NBA"), + "sacramento kings": ("basketball", "nba", "NBA"), + "minnesota timberwolves":("basketball", "nba", "NBA"), + "indiana pacers": ("basketball", "nba", "NBA"), + "new orleans pelicans": ("basketball", "nba", "NBA"), + "toronto raptors": ("basketball", "nba", "NBA"), + "atlanta hawks": ("basketball", "nba", "NBA"), + "orlando magic": ("basketball", "nba", "NBA"), + "washington wizards": ("basketball", "nba", "NBA"), + "detroit pistons": ("basketball", "nba", "NBA"), + "charlotte hornets": ("basketball", "nba", "NBA"), + "portland trail blazers":("basketball", "nba", "NBA"), + "san antonio spurs": ("basketball", "nba", "NBA"), + "utah jazz": ("basketball", "nba", "NBA"), + // MLB + "new york yankees": ("baseball", "mlb", "MLB"), + "yankees": ("baseball", "mlb", "MLB"), + "los angeles dodgers": ("baseball", "mlb", "MLB"), + "dodgers": ("baseball", "mlb", "MLB"), + "boston red sox": ("baseball", "mlb", "MLB"), + "red sox": ("baseball", "mlb", "MLB"), + "chicago cubs": ("baseball", "mlb", "MLB"), + "san francisco giants": ("baseball", "mlb", "MLB"), + "new york mets": ("baseball", "mlb", "MLB"), + "mets": ("baseball", "mlb", "MLB"), + "houston astros": ("baseball", "mlb", "MLB"), + "astros": ("baseball", "mlb", "MLB"), + "atlanta braves": ("baseball", "mlb", "MLB"), + "braves": ("baseball", "mlb", "MLB"), + "philadelphia phillies": ("baseball", "mlb", "MLB"), + "phillies": ("baseball", "mlb", "MLB"), + "st. louis cardinals": ("baseball", "mlb", "MLB"), + "cardinals": ("baseball", "mlb", "MLB"), + "seattle mariners": ("baseball", "mlb", "MLB"), + "mariners": ("baseball", "mlb", "MLB"), + "chicago white sox": ("baseball", "mlb", "MLB"), + "minnesota twins": ("baseball", "mlb", "MLB"), + "cleveland guardians": ("baseball", "mlb", "MLB"), + "miami marlins": ("baseball", "mlb", "MLB"), + "tampa bay rays": ("baseball", "mlb", "MLB"), + "toronto blue jays": ("baseball", "mlb", "MLB"), + "blue jays": ("baseball", "mlb", "MLB"), + "baltimore orioles": ("baseball", "mlb", "MLB"), + "orioles": ("baseball", "mlb", "MLB"), + "texas rangers": ("baseball", "mlb", "MLB"), + "kansas city royals": ("baseball", "mlb", "MLB"), + "royals": ("baseball", "mlb", "MLB"), + "oakland athletics": ("baseball", "mlb", "MLB"), + "athletics": ("baseball", "mlb", "MLB"), + "colorado rockies": ("baseball", "mlb", "MLB"), + "rockies": ("baseball", "mlb", "MLB"), + "san diego padres": ("baseball", "mlb", "MLB"), + "padres": ("baseball", "mlb", "MLB"), + "cincinnati reds": ("baseball", "mlb", "MLB"), + "pittsburgh pirates": ("baseball", "mlb", "MLB"), + "detroit tigers": ("baseball", "mlb", "MLB"), + "tigers": ("baseball", "mlb", "MLB"), + "arizona diamondbacks": ("baseball", "mlb", "MLB"), + "milwaukee brewers": ("baseball", "mlb", "MLB"), + "brewers": ("baseball", "mlb", "MLB"), + "washington nationals": ("baseball", "mlb", "MLB"), + "los angeles angels": ("baseball", "mlb", "MLB"), + "angels": ("baseball", "mlb", "MLB"), + // NHL + "toronto maple leafs": ("hockey", "nhl", "NHL"), + "leafs": ("hockey", "nhl", "NHL"), + "montreal canadiens": ("hockey", "nhl", "NHL"), + "canadiens": ("hockey", "nhl", "NHL"), + "boston bruins": ("hockey", "nhl", "NHL"), + "bruins": ("hockey", "nhl", "NHL"), + "new york rangers": ("hockey", "nhl", "NHL"), + "edmonton oilers": ("hockey", "nhl", "NHL"), + "oilers": ("hockey", "nhl", "NHL"), + "colorado avalanche": ("hockey", "nhl", "NHL"), + "avalanche": ("hockey", "nhl", "NHL"), + "tampa bay lightning": ("hockey", "nhl", "NHL"), + "lightning": ("hockey", "nhl", "NHL"), + "vegas golden knights": ("hockey", "nhl", "NHL"), + "golden knights": ("hockey", "nhl", "NHL"), + "carolina hurricanes": ("hockey", "nhl", "NHL"), + "hurricanes": ("hockey", "nhl", "NHL"), + "florida panthers": ("hockey", "nhl", "NHL"), + "panthers": ("hockey", "nhl", "NHL"), + "dallas stars": ("hockey", "nhl", "NHL"), + "stars": ("hockey", "nhl", "NHL"), + "new york islanders": ("hockey", "nhl", "NHL"), + "islanders": ("hockey", "nhl", "NHL"), + "new jersey devils": ("hockey", "nhl", "NHL"), + "devils": ("hockey", "nhl", "NHL"), + "pittsburgh penguins": ("hockey", "nhl", "NHL"), + "penguins": ("hockey", "nhl", "NHL"), + "detroit red wings": ("hockey", "nhl", "NHL"), + "red wings": ("hockey", "nhl", "NHL"), + "nashville predators": ("hockey", "nhl", "NHL"), + "predators": ("hockey", "nhl", "NHL"), + "minnesota wild": ("hockey", "nhl", "NHL"), + "wild": ("hockey", "nhl", "NHL"), + "winnipeg jets": ("hockey", "nhl", "NHL"), + "jets": ("hockey", "nhl", "NHL"), + "st. louis blues": ("hockey", "nhl", "NHL"), + "blues": ("hockey", "nhl", "NHL"), + "seattle kraken": ("hockey", "nhl", "NHL"), + "kraken": ("hockey", "nhl", "NHL"), + "chicago blackhawks": ("hockey", "nhl", "NHL"), + "blackhawks": ("hockey", "nhl", "NHL"), + "ottawa senators": ("hockey", "nhl", "NHL"), + "senators": ("hockey", "nhl", "NHL"), + "calgary flames": ("hockey", "nhl", "NHL"), + "flames": ("hockey", "nhl", "NHL"), + "vancouver canucks": ("hockey", "nhl", "NHL"), + "canucks": ("hockey", "nhl", "NHL"), + "buffalo sabres": ("hockey", "nhl", "NHL"), + "sabres": ("hockey", "nhl", "NHL"), + "san jose sharks": ("hockey", "nhl", "NHL"), + "sharks": ("hockey", "nhl", "NHL"), + "philadelphia flyers": ("hockey", "nhl", "NHL"), + "flyers": ("hockey", "nhl", "NHL"), + "anaheim ducks": ("hockey", "nhl", "NHL"), + "ducks": ("hockey", "nhl", "NHL"), + "columbus blue jackets": ("hockey", "nhl", "NHL"), + "washington capitals": ("hockey", "nhl", "NHL"), + "capitals": ("hockey", "nhl", "NHL"), +] + +let ESPN_LEAGUES: [(String, String)] = [ + ("soccer", "eng.1"), ("soccer", "esp.1"), ("soccer", "ger.1"), + ("soccer", "ita.1"), ("soccer", "fra.1"), ("soccer", "usa.1"), + ("basketball", "nba"), +] + +let TEAM_COLORS: [String: String] = [ + "manchester city": "#6CABDD", "manchester united": "#DA291C", + "liverpool": "#C8102E", "arsenal": "#EF0107", + "chelsea": "#034694", "tottenham": "#132257", + "newcastle": "#241F20", "aston villa": "#95BFE5", + "real madrid": "#FEBE10", "barcelona": "#A50044", + "atletico madrid": "#CB3524", "juventus": "#000000", + "inter milan": "#010E80", "ac milan": "#FB090B", + "napoli": "#087AC6", "paris saint-germain": "#004170", + "bayern munich": "#DC052D", "borussia dortmund": "#FDE100", + "los angeles lakers": "#552583", "golden state warriors": "#1D428A", + "boston celtics": "#007A33", "chicago bulls": "#CE1141", + "miami heat": "#98002E", "brooklyn nets": "#000000", + "new york yankees": "#003087","los angeles dodgers": "#005A9C", + "boston red sox": "#BD3039", "chicago cubs": "#0E3386", + "houston astros": "#002D62", "atlanta braves": "#CE1141", + "toronto maple leafs": "#003E7E", "montreal canadiens": "#AF1E2D", + "boston bruins": "#FFB81C", "edmonton oilers": "#FF4C00", + "colorado avalanche": "#6F263D", "tampa bay lightning": "#002868", + "default": "#1A1A2E", +] + +let NHL_ABBREVS: [String: String] = [ + "toronto": "TOR", "maple leafs": "TOR", "leafs": "TOR", + "montreal": "MTL", "canadiens": "MTL", + "boston": "BOS", "bruins": "BOS", + "new york rangers": "NYR", "rangers": "NYR", + "new york islanders": "NYI", "islanders": "NYI", + "new jersey": "NJD", "devils": "NJD", + "philadelphia": "PHI", "flyers": "PHI", + "pittsburgh": "PIT", "penguins": "PIT", + "buffalo": "BUF", "sabres": "BUF", + "detroit": "DET", "red wings": "DET", + "ottawa": "OTT", "senators": "OTT", + "carolina": "CAR", "hurricanes": "CAR", + "washington": "WSH", "capitals": "WSH", + "columbus": "CBJ", "blue jackets": "CBJ", + "florida": "FLA", "panthers": "FLA", + "tampa bay": "TBL","lightning": "TBL", + "nashville": "NSH","predators": "NSH", + "chicago": "CHI", "blackhawks": "CHI", + "st. louis": "STL","blues": "STL", + "minnesota": "MIN","wild": "MIN", + "winnipeg": "WPG", "jets": "WPG", + "dallas": "DAL", "stars": "DAL", + "colorado": "COL", "avalanche": "COL", + "edmonton": "EDM", "oilers": "EDM", + "calgary": "CGY", "flames": "CGY", + "vancouver": "VAN","canucks": "VAN", + "seattle": "SEA", "kraken": "SEA", + "vegas": "VGK", "golden knights": "VGK", + "utah": "UTA", "arizona": "UTA", + "san jose": "SJS", "sharks": "SJS", + "anaheim": "ANA", "ducks": "ANA", + "los angeles": "LAK", "kings": "LAK", +] + +// MARK: - HTTP Helpers + +let HTTP_HEADERS: [String: String] = [ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", +] + +func logErr(_ msg: String) { + fputs(" \(msg)\n", stderr) +} + +func getJSON(url: URL) async -> Any? { + try? await Task.sleep(nanoseconds: 500_000_000) + var req = URLRequest(url: url) + for (k, v) in HTTP_HEADERS { req.setValue(v, forHTTPHeaderField: k) } + guard let (data, resp) = try? await URLSession.shared.data(for: req), + (resp as? HTTPURLResponse)?.statusCode == 200 else { + logErr("[http] failed: \(url.absoluteString.suffix(80))") + return nil + } + return try? JSONSerialization.jsonObject(with: data) +} + +func getXML(url: URL) async -> String? { + try? await Task.sleep(nanoseconds: 500_000_000) + var req = URLRequest(url: url) + for (k, v) in HTTP_HEADERS { req.setValue(v, forHTTPHeaderField: k) } + guard let (data, resp) = try? await URLSession.shared.data(for: req), + (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil } + return String(data: data, encoding: .utf8) +} + +// MARK: - Sport Detection + +func detectSport(teamName: String) async -> SportEntry { + let lower = teamName.lowercased().trimmingCharacters(in: .whitespaces) + for (key, entry) in KNOWN_TEAMS { + if key.contains(lower) || lower.contains(key) { + logErr("[detect] matched known team: '\(key)' → \(entry.display)") + return entry + } + } + logErr("[detect] not in known list, searching ESPN leagues...") + for (sport, league) in ESPN_LEAGUES { + guard let url = URL(string: "https://site.api.espn.com/apis/site/v2/sports/\(sport)/\(league)/teams"), + let data = await getJSON(url: url) as? [String: Any], + let teams = (data["sports"] as? [[String: Any]])?.first?["leagues"] as? [[String: Any]], + let entries = teams.first?["teams"] as? [[String: Any]] else { continue } + for entry in entries { + let t = entry["team"] as? [String: Any] ?? [:] + let name = (t["displayName"] as? String ?? "").lowercased() + let nick = (t["nickname"] as? String ?? "").lowercased() + if lower.contains(name) || name.contains(lower) || nick.contains(lower) { + logErr("[detect] ESPN match: \(t["displayName"] as? String ?? "") in \(league)") + return (sport, league, league.uppercased()) + } + } + } + logErr("[detect] could not detect sport, defaulting to PL soccer") + return ("soccer", "eng.1", "Premier League") +} + +// MARK: - ESPN (soccer + basketball) + +let ESPN_BASE = "https://site.api.espn.com/apis/site/v2/sports" +let ESPN_CORE = "https://sports.core.api.espn.com/v2/sports" + +func espnFindTeamId(teamName: String, sport: String, league: String) async -> String? { + guard let url = URL(string: "\(ESPN_BASE)/\(sport)/\(league)/teams"), + let data = await getJSON(url: url) as? [String: Any], + let teams = ((data["sports"] as? [[String: Any]])?.first?["leagues"] as? [[String: Any]])?.first?["teams"] as? [[String: Any]] else { return nil } + let lower = teamName.lowercased() + var bestId: String? = nil + var bestScore = 0 + for entry in teams { + let t = entry["team"] as? [String: Any] ?? [:] + let name = (t["displayName"] as? String ?? "").lowercased() + let nick = (t["nickname"] as? String ?? "").lowercased() + let slug = (t["slug"] as? String ?? "").lowercased() + var score = 0 + if lower == name { score = 100 } + else if lower.contains(name) || name.contains(lower){ score = 80 } + else if nick.contains(lower) || lower.contains(nick){ score = 60 } + else if slug.contains(lower) { score = 50 } + else if lower.split(separator: " ").filter({ $0.count > 3 }).contains(where: { name.contains($0) }) { score = 30 } + if score > bestScore { bestScore = score; bestId = t["id"] as? String } + } + logErr("[espn] team ID = \(bestId ?? "nil") (score=\(bestScore))") + return bestId +} + +func espnNextGame(teamId: String, sport: String, league: String) async -> GameInfo? { + guard let url = URL(string: "\(ESPN_BASE)/\(sport)/\(league)/teams/\(teamId)/schedule"), + let data = await getJSON(url: url) as? [String: Any], + let events = data["events"] as? [[String: Any]] else { return nil } + for event in events { + guard let comp = (event["competitions"] as? [[String: Any]])?.first else { continue } + let state = ((comp["status"] as? [String: Any])?["type"] as? [String: Any])?["state"] as? String ?? "" + guard state == "pre" else { continue } + let competitors = comp["competitors"] as? [[String: Any]] ?? [] + let home = competitors.first(where: { ($0["homeAway"] as? String) == "home" }) ?? [:] + let away = competitors.first(where: { ($0["homeAway"] as? String) == "away" }) ?? [:] + let homeTeam = home["team"] as? [String: Any] ?? [:] + let awayTeam = away["team"] as? [String: Any] ?? [:] + return GameInfo( + eventId: event["id"] as? String ?? "", + homeTeam: homeTeam["displayName"] as? String ?? "", + awayTeam: awayTeam["displayName"] as? String ?? "", + homeId: homeTeam["id"] as? String ?? "", + awayId: awayTeam["id"] as? String ?? "", + venue: (comp["venue"] as? [String: Any])?["fullName"] as? String ?? "TBD", + dateISO: event["date"] as? String ?? "", + competition: (data["season"] as? [String: Any])?["displayName"] as? String ?? "" + ) + } + return nil +} + +func espnRoster(teamId: String, sport: String, league: String) async -> [RawPlayer] { + guard let url = URL(string: "\(ESPN_BASE)/\(sport)/\(league)/teams/\(teamId)/roster"), + let data = await getJSON(url: url) as? [String: Any], + let athletes = data["athletes"] as? [[String: Any]] else { return [] } + var players: [RawPlayer] = [] + for item in athletes { + if let items = item["items"] as? [[String: Any]] { + players.append(contentsOf: items.map(parseEspnAthlete)) + } else { + players.append(parseEspnAthlete(item)) + } + } + logErr("[espn] roster: \(players.count) players") + return players +} + +func parseEspnAthlete(_ a: [String: Any]) -> RawPlayer { + RawPlayer( + id: a["id"] as? String ?? "", + name: a["displayName"] as? String ?? a["fullName"] as? String ?? "", + number: Int(a["jersey"] as? String ?? ""), + position: (a["position"] as? [String: Any])?["abbreviation"] as? String ?? "", + age: a["age"] as? Int + ) +} + +func espnPlayerStats(playerId: String, sport: String, league: String) async -> [String: String] { + guard let url = URL(string: "\(ESPN_CORE)/\(sport)/leagues/\(league)/athletes/\(playerId)/statistics/0"), + let data = await getJSON(url: url) as? [String: Any], + let categories = (data["splits"] as? [String: Any])?["categories"] as? [[String: Any]] else { return [:] } + var stats: [String: String] = [:] + for cat in categories { + for stat in (cat["stats"] as? [[String: Any]] ?? []) { + let name = stat["displayName"] as? String ?? "" + let value = stat["displayValue"] as? String ?? "—" + if !name.isEmpty && !["", "0", "0.0"].contains(value) { stats[name] = value } + } + } + return stats +} + +func espnTeamNews(teamId: String, sport: String, league: String) async -> [String] { + guard let url = URL(string: "\(ESPN_BASE)/\(sport)/\(league)/news?team=\(teamId)&limit=10"), + let data = await getJSON(url: url) as? [String: Any], + let articles = data["articles"] as? [[String: Any]] else { return [] } + return articles.compactMap { ($0["headline"] as? String)?.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty }.prefix(6).map { $0 } +} + +// MARK: - MLB + +let MLB_BASE = "https://statsapi.mlb.com/api/v1" + +func mlbFindTeamId(teamName: String) async -> String? { + guard let url = URL(string: "\(MLB_BASE)/teams?sportId=1"), + let data = await getJSON(url: url) as? [String: Any], + let teams = data["teams"] as? [[String: Any]] else { return nil } + let lower = teamName.lowercased() + for team in teams { + let name = (team["name"] as? String ?? "").lowercased() + let short = (team["teamName"] as? String ?? "").lowercased() + if lower.contains(name) || name.contains(lower) || lower.contains(short) { + let id = String(team["id"] as? Int ?? 0) + logErr("[mlb] team ID = \(id) (\(team["name"] as? String ?? ""))") + return id + } + } + return nil +} + +func mlbNextGame(teamId: String) async -> GameInfo? { + guard let url = URL(string: "\(MLB_BASE)/schedule/games/?sportId=1&teamId=\(teamId)"), + let data = await getJSON(url: url) as? [String: Any], + let dates = data["dates"] as? [[String: Any]], !dates.isEmpty, + let game = (dates[0]["games"] as? [[String: Any]])?.first else { return nil } + let homeTeam = (game["teams"] as? [String: Any])?["home"] as? [String: Any] + let awayTeam = (game["teams"] as? [String: Any])?["away"] as? [String: Any] + return GameInfo( + eventId: String(game["gamePk"] as? Int ?? 0), + homeTeam: (homeTeam?["team"] as? [String: Any])?["name"] as? String ?? "", + awayTeam: (awayTeam?["team"] as? [String: Any])?["name"] as? String ?? "", + homeId: String((homeTeam?["team"] as? [String: Any])?["id"] as? Int ?? 0), + awayId: String((awayTeam?["team"] as? [String: Any])?["id"] as? Int ?? 0), + venue: (game["venue"] as? [String: Any])?["name"] as? String ?? "TBD", + dateISO: game["gameDate"] as? String ?? "", + competition: "MLB" + ) +} + +func mlbRoster(teamId: String) async -> [RawPlayer] { + guard let url = URL(string: "\(MLB_BASE)/teams/\(teamId)/roster?season=2026&rosterType=active"), + let data = await getJSON(url: url) as? [String: Any], + let roster = data["roster"] as? [[String: Any]] else { return [] } + let players = roster.map { entry -> RawPlayer in + let person = entry["person"] as? [String: Any] ?? [:] + return RawPlayer( + id: String(person["id"] as? Int ?? 0), + name: person["fullName"] as? String ?? "", + number: Int(entry["jerseyNumber"] as? String ?? ""), + position: (entry["position"] as? [String: Any])?["abbreviation"] as? String ?? "", + age: nil + ) + } + logErr("[mlb] roster: \(players.count) players") + return players +} + +func mlbPlayerStats(playerId: String) async -> [String: String] { + for group in ["hitting", "pitching"] { + guard let url = URL(string: "\(MLB_BASE)/people/\(playerId)/stats?stats=season&season=2026&group=\(group)"), + let data = await getJSON(url: url) as? [String: Any], + let splits = (data["stats"] as? [[String: Any]])?.first?["splits"] as? [[String: Any]], + !splits.isEmpty, + let stat = splits[0]["stat"] as? [String: Any] else { continue } + var result: [String: String] = [:] + for (k, v) in stat { + let s = "\(v)" + if !["0", "0.0", ".000", "", "null"].contains(s) { result[k] = s } + } + if !result.isEmpty { return result } + } + return [:] +} + +// MARK: - NHL + +let NHL_BASE = "https://api-web.nhle.com/v1" + +func nhlAbbrev(teamName: String) -> String? { + let lower = teamName.lowercased() + for (key, abbrev) in NHL_ABBREVS { + if lower.contains(key) { return abbrev } + } + return nil +} + +func nhlNextGame(abbrev: String) async -> GameInfo? { + guard let url = URL(string: "\(NHL_BASE)/club-schedule-season/\(abbrev)/now"), + let data = await getJSON(url: url) as? [String: Any], + let games = data["games"] as? [[String: Any]] else { return nil } + for game in games { + let state = game["gameState"] as? String ?? "" + guard ["FUT", "PRE"].contains(state) else { continue } + let home = game["homeTeam"] as? [String: Any] ?? [:] + let away = game["awayTeam"] as? [String: Any] ?? [:] + let homeName = [home["placeName"] as? [String: Any], home["commonName"] as? [String: Any]] + .compactMap { $0?["default"] as? String }.joined(separator: " ").trimmingCharacters(in: .whitespaces) + let awayName = [away["placeName"] as? [String: Any], away["commonName"] as? [String: Any]] + .compactMap { $0?["default"] as? String }.joined(separator: " ").trimmingCharacters(in: .whitespaces) + return GameInfo( + eventId: String(game["id"] as? Int ?? 0), + homeTeam: homeName, + awayTeam: awayName, + homeId: home["abbrev"] as? String ?? "", + awayId: away["abbrev"] as? String ?? "", + venue: (game["venue"] as? [String: Any])?["default"] as? String ?? "TBD", + dateISO: game["gameDate"] as? String ?? "", + competition: "NHL" + ) + } + return nil +} + +func nhlRoster(abbrev: String) async -> [RawPlayer] { + guard let url = URL(string: "\(NHL_BASE)/roster/\(abbrev)/current"), + let data = await getJSON(url: url) as? [String: Any] else { return [] } + var players: [RawPlayer] = [] + for group in ["forwards", "defensemen", "goalies"] { + for p in (data[group] as? [[String: Any]] ?? []) { + let firstName = (p["firstName"] as? [String: Any])?["default"] as? String ?? "" + let lastName = (p["lastName"] as? [String: Any])?["default"] as? String ?? "" + let birthDate = p["birthDate"] as? String ?? "" + players.append(RawPlayer( + id: String(p["id"] as? Int ?? 0), + name: "\(firstName) \(lastName)".trimmingCharacters(in: .whitespaces), + number: p["sweaterNumber"] as? Int, + position: p["positionCode"] as? String ?? "", + age: birthDate.isEmpty ? nil : calcAge(birthDate: birthDate) + )) + } + } + logErr("[nhl] roster: \(players.count) players") + return players +} + +func nhlPlayerStats(playerId: String) async -> [String: String] { + guard let url = URL(string: "\(NHL_BASE)/player/\(playerId)/landing"), + let data = await getJSON(url: url) as? [String: Any], + let totals = data["seasonTotals"] as? [[String: Any]], !totals.isEmpty else { return [:] } + let latest = totals[totals.count - 1] + let keys = ["goals","assists","points","plusMinus","pim","shots", + "gamesPlayed","savePctg","goalsAgainstAvg","shutouts","wins"] + var stats: [String: String] = [:] + for key in keys { + if let val = latest[key], "\(val)" != "0" { stats[key] = "\(val)" } + } + return stats +} + +// MARK: - Google News RSS + +func googleNews(query: String, maxResults: Int = 5) async -> [String] { + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://news.google.com/rss/search?q=\(encoded)&hl=en-US&gl=US&ceid=US:en"), + let xml = await getXML(url: url) else { return [] } + var headlines: [String] = [] + let pattern = try! NSRegularExpression(pattern: "<!\\[CDATA\\[(.*?)\\]\\]>|(.*?)") + let range = NSRange(xml.startIndex..., in: xml) + for match in pattern.matches(in: xml, range: range) { + let raw: String + if let r = Range(match.range(at: 1), in: xml) { raw = String(xml[r]).trimmingCharacters(in: .whitespaces) } + else if let r = Range(match.range(at: 2), in: xml) { raw = String(xml[r]).trimmingCharacters(in: .whitespaces) } + else { continue } + guard !raw.isEmpty, raw != "Google News" else { continue } + let clean = raw.replacingOccurrences(of: "\\s*-\\s*[^-]+$", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespaces) + if !clean.isEmpty { headlines.append(clean) } + if headlines.count >= maxResults { break } + } + return headlines +} + +func fetchNewsForTeam(_ teamName: String) async -> [String] { await googleNews(query: "\(teamName) news 2026", maxResults: 6) } +func fetchNewsForPlayer(_ name: String, _ team: String) async -> [String] { await googleNews(query: "\(name) \(team) 2026", maxResults: 3) } +func fetchInjuryReport(_ teamName: String) async -> [String] { await googleNews(query: "\(teamName) injury suspended doubtful out 2026", maxResults: 8) } + +// MARK: - Storyline / Status + +func makeStoryline(name: String, position: String, stats: [String: String], news: [String], status: String) -> String { + if ["injured", "doubtful", "suspended"].contains(status) { + return "\(name) is listed as \(status) — his availability is the key team news heading in." + } + if let headline = news.first { + let clean = headline.replacingOccurrences(of: "\\s*-\\s*[^-]+$", with: "", options: .regularExpression).trimmingCharacters(in: .whitespaces) + if clean.count > 10 { return clean } + } + if let (key, val) = stats.first { + return "\(name) brings \(val) \(key) into this matchup — one of the key figures to watch." + } + return "\(name) is a key \(position) piece in this lineup — watch how they influence the game." +} + +func makeMatchupNote(name: String, opponent: String) -> String { + "\(name) faces \(opponent) — a key individual battle to monitor throughout." +} + +func inferStatus(playerName: String, injuryHeadlines: [String]) -> Player.PlayerStatus { + let parts = playerName.lowercased().split(separator: " ").filter { $0.count > 2 }.map(String.init) + for headline in injuryHeadlines { + let hl = headline.lowercased() + guard parts.contains(where: { hl.contains($0) }) else { continue } + if hl.contains("suspend") { return .suspended } + if hl.contains("doubtful") { return .doubtful } + if ["out","ruled out","injured","sidelined","misses"].contains(where: hl.contains) { return .injured } + } + return .fit +} + +// MARK: - Utilities + +func calcAge(birthDate: String) -> Int? { + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + guard let bd = df.date(from: birthDate) else { return nil } + let cal = Calendar.current + return cal.dateComponents([.year], from: bd, to: Date()).year +} + +func teamColor(_ teamName: String) -> String { + let lower = teamName.lowercased() + for (key, color) in TEAM_COLORS { if lower.contains(key) { return color } } + return TEAM_COLORS["default"]! +} + +func makeId(_ parts: String...) -> String { + let raw = parts.filter { !$0.isEmpty }.joined(separator: "-").lowercased().trimmingCharacters(in: .whitespaces) + let result = raw.replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return result.isEmpty ? "unknown" : result +} + +func topStats(_ stats: [String: String], limit: Int = 3) -> [String] { + stats.filter { !["—", "", "0", "0.0", "None"].contains($0.value) } + .prefix(limit).map { "\($0.value) \($0.key)" } +} + +// MARK: - Main Orchestrator + +func buildGameCache(teamName: String) async throws -> GameCache { + fputs("\n\("=".repeated(50))\n", stderr) + fputs("BroadcastBrain Cache Builder — \(teamName)\n", stderr) + fputs("\("=".repeated(50))\n\n", stderr) + + fputs("[1/5] Detecting sport...\n", stderr) + let (sport, league, competitionDisplay) = await detectSport(teamName: teamName) + fputs(" → \(competitionDisplay)\n\n", stderr) + + fputs("[2/5] Finding next game...\n", stderr) + var gameInfo: GameInfo? = nil + var ourTeamId = "" + + if sport == "soccer" || sport == "basketball" { + ourTeamId = await espnFindTeamId(teamName: teamName, sport: sport, league: league) ?? "" + if !ourTeamId.isEmpty { gameInfo = await espnNextGame(teamId: ourTeamId, sport: sport, league: league) } + } else if sport == "baseball" { + ourTeamId = await mlbFindTeamId(teamName: teamName) ?? "" + if !ourTeamId.isEmpty { gameInfo = await mlbNextGame(teamId: ourTeamId) } + } else if sport == "hockey" { + ourTeamId = nhlAbbrev(teamName: teamName) ?? "" + if !ourTeamId.isEmpty { gameInfo = await nhlNextGame(abbrev: ourTeamId) } + } + + if gameInfo == nil { + logErr("[2/5] No upcoming game in API, trying Google News...") + let newsHints = await googleNews(query: "\(teamName) next match fixture 2026", maxResults: 8) + var opponentHint = "" + for headline in newsHints { + let hl = headline.lowercased() + let teamWords = teamName.lowercased().split(separator: " ").filter { $0.count > 3 }.map(String.init) + guard teamWords.contains(where: { hl.contains($0) }) else { continue } + for (known, _) in KNOWN_TEAMS { + if hl.contains(known) && !teamName.lowercased().contains(known) { + if known.count > opponentHint.count { opponentHint = known } + } + } + if !opponentHint.isEmpty { + opponentHint = opponentHint.split(separator: " ").map { $0.capitalized }.joined(separator: " ") + logErr("[2/5] Extracted opponent: '\(opponentHint)'") + break + } + } + gameInfo = GameInfo(eventId: "tbd", homeTeam: teamName, + awayTeam: opponentHint.isEmpty ? "TBD" : opponentHint, + homeId: ourTeamId, awayId: "", + venue: "TBD", dateISO: ISO8601DateFormatter().string(from: Date()), + competition: competitionDisplay) + } + + let gi = gameInfo! + fputs(" → \(gi.homeTeam) vs \(gi.awayTeam) at \(gi.venue)\n\n", stderr) + + fputs("[3/5] Fetching rosters...\n", stderr) + var homeRaw: [RawPlayer] = [] + var awayRaw: [RawPlayer] = [] + + let isHome = teamName.lowercased().contains(gi.homeTeam.lowercased()) || gi.homeTeam.lowercased().contains(teamName.lowercased()) + let oppName = isHome ? gi.awayTeam : gi.homeTeam + var oppApiId = isHome ? gi.awayId : gi.homeId + + if sport == "soccer" || sport == "basketball" { + if !ourTeamId.isEmpty { homeRaw = await espnRoster(teamId: ourTeamId, sport: sport, league: league) } + if oppApiId.isEmpty && oppName != "TBD" { oppApiId = await espnFindTeamId(teamName: oppName, sport: sport, league: league) ?? "" } + if !oppApiId.isEmpty { awayRaw = await espnRoster(teamId: oppApiId, sport: sport, league: league) } + } else if sport == "baseball" { + if !ourTeamId.isEmpty { homeRaw = await mlbRoster(teamId: ourTeamId) } + if oppName != "TBD" { + let oppId = oppApiId.isEmpty ? (await mlbFindTeamId(teamName: oppName) ?? "") : oppApiId + if !oppId.isEmpty { awayRaw = await mlbRoster(teamId: oppId) } + } + } else if sport == "hockey" { + if !ourTeamId.isEmpty { homeRaw = await nhlRoster(abbrev: ourTeamId) } + if oppName != "TBD" { + let oppAbbr = oppApiId.isEmpty ? (nhlAbbrev(teamName: oppName) ?? "") : oppApiId + if !oppAbbr.isEmpty { awayRaw = await nhlRoster(abbrev: oppAbbr) } + } + } + fputs(" → Home: \(homeRaw.count) | Away: \(awayRaw.count)\n\n", stderr) + + fputs("[4/5] Fetching news & injuries...\n", stderr) + let homeInjuries = await fetchInjuryReport(gi.homeTeam) + let awayInjuries = gi.awayTeam != "TBD" ? await fetchInjuryReport(gi.awayTeam) : [] + let allInjuries = homeInjuries + awayInjuries + let homeNews = await fetchNewsForTeam(gi.homeTeam) + let awayNews = gi.awayTeam != "TBD" ? await fetchNewsForTeam(gi.awayTeam) : [] + var storylines = Array((homeNews.prefix(3) + awayNews.prefix(2))) + if (sport == "soccer" || sport == "basketball") && !ourTeamId.isEmpty { + let espnNews = await espnTeamNews(teamId: ourTeamId, sport: sport, league: league) + storylines = Array((espnNews.prefix(3) + storylines).prefix(8)) + } + fputs(" → \(storylines.count) storylines, \(allInjuries.count) injury items\n\n", stderr) + + fputs("[5/5] Building player records...\n", stderr) + let homeIdStr = makeId(gi.homeTeam) + let awayIdStr = makeId(gi.awayTeam) + + func buildPlayerList(raw: [RawPlayer], teamIdStr: String, teamDisplay: String, oppDisplay: String) async -> [Player] { + var built: [Player] = [] + for (i, p) in raw.prefix(20).enumerated() { + let name = p.name.trimmingCharacters(in: .whitespaces).isEmpty ? "Player \(i+1)" : p.name.trimmingCharacters(in: .whitespaces) + let playerId = p.id.isEmpty ? makeId(teamIdStr, name) : p.id + + var stats: [String: String] = [:] + if i < 10 && !playerId.isEmpty { + logErr("→ Fetching stats for \(name)...") + if sport == "soccer" || sport == "basketball" { stats = await espnPlayerStats(playerId: playerId, sport: sport, league: league) } + else if sport == "baseball" { stats = await mlbPlayerStats(playerId: playerId) } + else if sport == "hockey" { stats = await nhlPlayerStats(playerId: playerId) } + } + var playerNews: [String] = [] + if i < 6 { + logErr("→ Fetching news for \(name)...") + playerNews = await fetchNewsForPlayer(name, teamDisplay) + } + let status = inferStatus(playerName: name, injuryHeadlines: allInjuries) + built.append(Player( + id: makeId(teamIdStr, name), + teamId: teamIdStr, + shirtNumber: p.number ?? (i + 1), + name: name, + position: p.position.isEmpty ? "—" : p.position, + age: p.age ?? 0, + stats: PlayerStats(season: stats, formLast5: [:], vsOpponent: [:]), + storyline: makeStoryline(name: name, position: p.position, stats: stats, news: playerNews, status: status.rawValue), + matchupNote: makeMatchupNote(name: name, opponent: oppDisplay), + topStats: topStats(stats), + status: status, + newsHeadlines: playerNews + )) + } + return built + } + + let homePlayers = await buildPlayerList(raw: homeRaw, teamIdStr: homeIdStr, teamDisplay: gi.homeTeam, oppDisplay: gi.awayTeam) + let awayPlayers = await buildPlayerList(raw: awayRaw, teamIdStr: awayIdStr, teamDisplay: gi.awayTeam, oppDisplay: gi.homeTeam) + fputs(" → Built \(homePlayers.count) home + \(awayPlayers.count) away player records\n\n", stderr) + + return GameCache( + match: MatchInfo(id: makeId(gi.homeTeam, gi.awayTeam), homeTeam: gi.homeTeam, awayTeam: gi.awayTeam, + competition: gi.competition.isEmpty ? competitionDisplay : gi.competition, + venue: gi.venue, kickoffISO: gi.dateISO.isEmpty ? ISO8601DateFormatter().string(from: Date()) : gi.dateISO), + teams: TeamsPayload( + home: Team(id: homeIdStr, name: gi.homeTeam, colorHex: teamColor(gi.homeTeam), record: [:]), + away: Team(id: awayIdStr, name: gi.awayTeam, colorHex: teamColor(gi.awayTeam), record: [:]) + ), + players: homePlayers + awayPlayers, + storylines: storylines, + source: "espn_unofficial + mlb_official + nhl_official + google_news_rss", + generatedAt: ISO8601DateFormatter().string(from: Date()) + ) +} + +// MARK: - String helper + +extension String { + func repeated(_ count: Int) -> String { String(repeating: self, count: count) } +} + +// MARK: - Entry Point + +let args = CommandLine.arguments.dropFirst() +guard !args.isEmpty else { + print("Usage: swift src/data/FetchGame.swift ") + print(" e.g. swift src/data/FetchGame.swift 'Manchester City'") + print(" e.g. swift src/data/FetchGame.swift 'Los Angeles Lakers'") + print(" e.g. swift src/data/FetchGame.swift 'New York Yankees'") + print(" e.g. swift src/data/FetchGame.swift 'Toronto Maple Leafs'") + exit(1) +} + +let teamName = args.joined(separator: " ") + +let cache = try await buildGameCache(teamName: teamName) + +let outDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("assets") +try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) +let outPath = outDir.appendingPathComponent("game_cache.json") +let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys] +try encoder.encode(cache).write(to: outPath) + +print("\n✓ Wrote \(outPath.path)") +print(" Match: \(cache.match.homeTeam) vs \(cache.match.awayTeam)") +print(" Venue: \(cache.match.venue)") +print(" Kickoff: \(cache.match.kickoffISO)") +print(" Players: \(cache.players.count)") +print(" Storylines: \(cache.storylines.count)") diff --git a/src/data/News.swift b/src/data/News.swift new file mode 100644 index 00000000..4aa43056 --- /dev/null +++ b/src/data/News.swift @@ -0,0 +1,176 @@ +import Foundation + +// MARK: - Types + +struct NewsItem: Codable { + let id: String + let headline: String + let description: String + let published: String + let imageUrl: String? + let articleUrl: String? + let leagueKey: String + let leagueLabel: String + let source: NewsSource + + enum NewsSource: String, Codable { + case espn + case googleNews = "google_news" + } +} + +// MARK: - Leagues + +private struct League { + let key: String + let sport: String + let league: String + let label: String +} + +private let LEAGUES: [League] = [ + League(key: "mlb", sport: "baseball", league: "mlb", label: "MLB"), + League(key: "nba", sport: "basketball", league: "nba", label: "NBA"), + League(key: "wnba", sport: "basketball", league: "wnba", label: "WNBA"), + League(key: "nfl", sport: "football", league: "nfl", label: "NFL"), + League(key: "ncaaf", sport: "football", league: "college-football",label: "NCAAF"), + League(key: "nhl", sport: "hockey", league: "nhl", label: "NHL"), + League(key: "epl", sport: "soccer", league: "eng.1", label: "EPL"), + League(key: "laliga", sport: "soccer", league: "esp.1", label: "La Liga"), + League(key: "seriea", sport: "soccer", league: "ita.1", label: "Serie A"), + League(key: "bundesliga", sport: "soccer", league: "ger.1", label: "Bundesliga"), + League(key: "ligue1", sport: "soccer", league: "fra.1", label: "Ligue 1"), + League(key: "ucl", sport: "soccer", league: "uefa.champions", label: "UCL"), + League(key: "mls", sport: "soccer", league: "usa.1", label: "MLS"), +] + +private let HTTP_HEADERS: [String: String] = [ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", +] + +// MARK: - ESPN + +private func espnNewsURL(sport: String, league: String, limit: Int) -> URL { + URL(string: "https://site.api.espn.com/apis/site/v2/sports/\(sport)/\(league)/news?limit=\(limit)")! +} + +func fetchLeagueNews(leagueKey: String, limit: Int = 20) async -> [NewsItem] { + guard let league = LEAGUES.first(where: { $0.key == leagueKey }) else { return [] } + let url = espnNewsURL(sport: league.sport, league: league.league, limit: limit) + guard let data = try? await httpGet(url: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let articles = json["articles"] as? [[String: Any]] else { return [] } + return articles.map { espnArticleToNewsItem($0, leagueKey: league.key, leagueLabel: league.label) } +} + +func fetchAllSportsNews(limit: Int = 10) async -> [NewsItem] { + let mainLeagues = ["nfl", "nba", "mlb", "nhl", "epl", "mls"] + var all: [NewsItem] = [] + await withTaskGroup(of: [NewsItem].self) { group in + for key in mainLeagues { + group.addTask { await fetchLeagueNews(leagueKey: key, limit: limit) } + } + for await items in group { all.append(contentsOf: items) } + } + return all.sorted { + let df = ISO8601DateFormatter() + let a = df.date(from: $0.published) ?? Date.distantPast + let b = df.date(from: $1.published) ?? Date.distantPast + return a > b + } +} + +private func espnArticleToNewsItem(_ a: [String: Any], leagueKey: String, leagueLabel: String) -> NewsItem { + let id = a["id"].map { "espn-\(leagueKey)-\($0)" } ?? "espn-\(leagueKey)-\(Int.random(in: 0.. [NewsItem] { + let query = teamName.isEmpty ? playerName : "\(playerName) \(teamName)" + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: "https://news.google.com/rss/search?q=\(encoded)&hl=en-US&gl=US&ceid=US:en"), + let data = try? await httpGet(url: url), + let xml = String(data: data, encoding: .utf8) else { return [] } + return parseGoogleNewsRSS(xml: xml, limit: limit, source: .googleNews) +} + +private func parseGoogleNewsRSS(xml: String, limit: Int, source: NewsItem.NewsSource) -> [NewsItem] { + var items: [NewsItem] = [] + let pattern = try! NSRegularExpression(pattern: "([\\s\\S]*?)") + let range = NSRange(xml.startIndex..., in: xml) + for match in pattern.matches(in: xml, range: range) { + guard let contentRange = Range(match.range(at: 1), in: xml) else { continue } + let content = String(xml[contentRange]) + let rawTitle = extractTag(xml: content, tag: "title") ?? "" + let headline = rawTitle.replacingOccurrences( + of: "\\s*-\\s*[^-]+$", with: "", options: .regularExpression + ).trimmingCharacters(in: .whitespaces) + let description = stripHtml(extractTag(xml: content, tag: "description") ?? "") + let published = extractTag(xml: content, tag: "pubDate") ?? ISO8601DateFormatter().string(from: Date()) + let link = extractTag(xml: content, tag: "link") ?? "" + guard !headline.isEmpty, headline != "Google News" else { continue } + let idBase = Data(link.utf8).base64EncodedString().prefix(16) + items.append(NewsItem( + id: "gnews-\(idBase)", + headline: headline, + description: description, + published: published, + imageUrl: nil, + articleUrl: link.isEmpty ? nil : link, + leagueKey: "player", + leagueLabel: "Player News", + source: source + )) + if items.count >= limit { break } + } + return items +} + +private func extractTag(xml: String, tag: String) -> String? { + let open = "<\(tag)" + let close = "" + guard let startRange = xml.range(of: open), + let gtRange = xml.range(of: ">", range: startRange.upperBound..") { + value = String(value.dropFirst(9).dropLast(3)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return value.isEmpty ? nil : value +} + +private func stripHtml(_ html: String) -> String { + html.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "&", with: "&") + .trimmingCharacters(in: .whitespacesAndNewlines) +} + +// MARK: - HTTP + +func httpGet(url: URL) async throws -> Data { + var request = URLRequest(url: url) + for (k, v) in HTTP_HEADERS { request.setValue(v, forHTTPHeaderField: k) } + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw URLError(.badServerResponse) + } + return data +}