From 622a47887a1f760810dff75b9c09af66dbac2b72 Mon Sep 17 00:00:00 2001 From: Emil Jiang Date: Tue, 8 Sep 2026 21:28:25 -0400 Subject: [PATCH 1/2] Load upcoming games before full game history --- app/build.gradle.kts | 3 +- app/src/main/graphql/FragmentedGame.graphql | 51 +++++------ app/src/main/graphql/schema.graphqls | 86 ++++++++++++++++++- .../score/model/ScoreRepository.kt | 66 +++++++++++--- 4 files changed, 160 insertions(+), 46 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9fe52df..db6223c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,9 +109,8 @@ apollo { service("service") { packageName.set("com.example.score") introspection { - endpointUrl.set("\"${secrets.getProperty("API_URL_DEV")}\"") + endpointUrl.set(secrets.getProperty("API_URL_DEV")) schemaFile.set(file("src/main/graphql/schema.graphqls")) } } } - diff --git a/app/src/main/graphql/FragmentedGame.graphql b/app/src/main/graphql/FragmentedGame.graphql index 4f5d398..cbef1c3 100644 --- a/app/src/main/graphql/FragmentedGame.graphql +++ b/app/src/main/graphql/FragmentedGame.graphql @@ -1,33 +1,26 @@ query PagedGames($limit: Int!, $offset: Int!) { games(limit: $limit, offset: $offset) { - id - city - date - gender - location - opponentId - result - sport - state - time - scoreBreakdown - utcDate - team { - id - color - image - name - } - boxScore { - team - period - time - description - scorer - assist - scoreBy - corScore - oppScore - } + ...GameListItem + } +} + +query InitialGames($startDate: DateTime!, $endDate: DateTime!) { + gamesByDate(startDate: $startDate, endDate: $endDate) { + ...GameListItem + } +} + +fragment GameListItem on GameType { + id + city + date + gender + result + sport + time + team { + color + image + name } } diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls index e1ebfad..e6c5c91 100644 --- a/app/src/main/graphql/schema.graphqls +++ b/app/src/main/graphql/schema.graphqls @@ -9,7 +9,7 @@ type Query { game(id: String!): GameType - gameByData(city: String!, date: String!, gender: String!, location: String, opponentId: String!, sport: String!, state: String!, time: String!, ticketLink: String): GameType + gameByData(city: String!, date: String!, gender: String!, location: String, opponentId: String!, sport: String!, state: String!, time: String!): GameType gamesBySport(sport: String!): [GameType] @@ -17,6 +17,13 @@ type Query { gamesBySportGender(sport: String!, gender: String!): [GameType] + gamesByDate(startDate: DateTime!, endDate: DateTime!): [GameType] + + """ + Current user's favorited games (requires auth). + """ + myFavoritedGames: [GameType] + teams: [TeamType] team(id: String!): TeamType @@ -55,9 +62,11 @@ Attributes: - id: The YouTube video ID (optional). - title: The title of the video. - description: The description of the video. - - thumbnail: The URL of the video's thumbnail. + - thumbnail: The URL of the video's thumbnail. (optional) - url: The URL to the video. - published_at: The date and time the video was published. + - duration: The duration of the video (optional). + - sportsType: The sport type extracted from the video title. """ type YoutubeVideoType { id: String @@ -68,11 +77,15 @@ type YoutubeVideoType { thumbnail: String! - b64Thumbnail: String! + b64Thumbnail: String url: String! publishedAt: String! + + duration: String + + sportsType: String } """ @@ -181,6 +194,13 @@ type TeamType { name: String! } +""" +The `DateTime` scalar type represents a DateTime +value as specified by +[iso8601](https://en.wikipedia.org/wiki/ISO_8601). +""" +scalar DateTime + type Mutation { """ Creates a new game. @@ -195,12 +215,42 @@ type Mutation { """ Creates a new youtube video. """ - createYoutubeVideo(b64Thumbnail: String!, description: String!, id: String!, publishedAt: String!, thumbnail: String!, title: String!, url: String!): CreateYoutubeVideo + createYoutubeVideo(b64Thumbnail: String, description: String!, duration: String!, id: String!, publishedAt: String!, thumbnail: String!, title: String!, url: String!): CreateYoutubeVideo """ Creates a new article. """ createArticle(image: String, publishedAt: String!, slug: String!, sportsType: String!, title: String!, url: String!): CreateArticle + + """ + Login by net_id; returns access_token and refresh_token. + """ + loginUser("User's net ID (e.g. Cornell netid)." netId: String!): LoginUser + + """ + Create a new user by net_id; returns access_token and refresh_token (no separate login needed). + """ + signupUser("Email address." email: String, "Display name." name: String, "User's net ID (e.g. Cornell netid)." netId: String!): SignupUser + + """ + Exchange a valid refresh token (in Authorization header) for a new access_token. + """ + refreshAccessToken: RefreshAccessToken + + """ + Revoke the current token (access or refresh). Send token in Authorization header. + """ + logoutUser: LogoutUser + + """ + Add a game to the current user's favorites (requires auth). + """ + addFavoriteGame("ID of the game to add to favorites." gameId: String!): AddFavoriteGame + + """ + Remove a game from the current user's favorites (requires auth). + """ + removeFavoriteGame("ID of the game to remove from favorites." gameId: String!): RemoveFavoriteGame } type CreateGame { @@ -219,6 +269,34 @@ type CreateArticle { article: ArticleType } +type LoginUser { + accessToken: String + + refreshToken: String +} + +type SignupUser { + accessToken: String + + refreshToken: String +} + +type RefreshAccessToken { + newAccessToken: String +} + +type LogoutUser { + success: Boolean +} + +type AddFavoriteGame { + success: Boolean +} + +type RemoveFavoriteGame { + success: Boolean +} + """ A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation and subscription operations. """ diff --git a/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt b/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt index 1e9d91b..94cf387 100644 --- a/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt +++ b/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt @@ -6,13 +6,18 @@ import com.cornellappdev.score.util.isValidSport import com.cornellappdev.score.util.parseColor import com.cornellappdev.score.util.parseResultScore import com.example.score.GameByIdQuery +import com.example.score.InitialGamesQuery import com.example.score.GamesQuery import com.example.score.PagedGamesQuery +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.sync.Mutex +import java.time.LocalDate import kotlinx.coroutines.withTimeout import javax.inject.Inject import javax.inject.Singleton @@ -24,15 +29,15 @@ private const val PAGE_TIMEOUT_MILLIS = 3000L /** * This is a singleton responsible for fetching and caching all data for Score. - * Right now, it makes a network request for all possible games. In the future, - * we should limit this to games only in a certain time range, to prevent the - * app from slowing down and improve load times. + * Publishes a small date window first, then loads the full game history. */ @Singleton class ScoreRepository @Inject constructor( private val apolloClient: ApolloClient, private val appScope: CoroutineScope, ) { + private val gamesFetchMutex = Mutex() + private val _upcomingGamesFlow = MutableStateFlow>>(ApiResponse.Loading) val upcomingGamesFlow = _upcomingGamesFlow.asStateFlow() @@ -99,20 +104,44 @@ class ScoreRepository @Inject constructor( } fun fetchGames() = appScope.launch { + if (!gamesFetchMutex.tryLock()) return@launch _upcomingGamesFlow.value = ApiResponse.Loading val allGames = mutableListOf() var offset = 0 var retries = 0 + var initialWindow = true try { while (true) { - val pageResult = runCatching { - withTimeout(PAGE_TIMEOUT_MILLIS) { - apolloClient.query( - PagedGamesQuery(limit = PAGE_LIMIT, offset = offset) - ).execute().data?.games + val pageResult = try { + withTimeoutOrNull(PAGE_TIMEOUT_MILLIS) { + if (initialWindow) { + val today = LocalDate.now() + apolloClient.query( + InitialGamesQuery( + today.atStartOfDay().toString(), + today.plusDays(30).atStartOfDay().toString() + ) + ).execute().toResult().getOrNull()?.gamesByDate + ?.map { it?.gameListItem } + } else { + apolloClient.query( + PagedGamesQuery(limit = PAGE_LIMIT, offset = offset) + ).execute().toResult().getOrNull()?.games + ?.map { it?.gameListItem } + } } - }.getOrNull() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + + // A failed or empty date window falls back to the full fetch. + if (initialWindow && pageResult.isNullOrEmpty()) { + initialWindow = false + continue + } if (pageResult == null) { if (retries < MAX_RETRIES) { @@ -158,17 +187,32 @@ class ScoreRepository @Inject constructor( allGames.addAll(pageGames) + if (initialWindow) { + if (allGames.isNotEmpty()) { + _upcomingGamesFlow.value = ApiResponse.Success(allGames.toList()) + } + initialWindow = false + continue + } + if (pageResult.size < PAGE_LIMIT) break offset += PAGE_LIMIT } _upcomingGamesFlow.value = - if (allGames.isNotEmpty()) ApiResponse.Success(allGames) + if (allGames.isNotEmpty()) ApiResponse.Success(allGames.asReversed().distinctBy { it.id }.asReversed()) + else if (_upcomingGamesFlow.value is ApiResponse.Success) _upcomingGamesFlow.value else ApiResponse.Error + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e("ScoreRepository", "Error fetching upcoming games", e) - _upcomingGamesFlow.value = ApiResponse.Error + if (_upcomingGamesFlow.value !is ApiResponse.Success) { + _upcomingGamesFlow.value = ApiResponse.Error + } + } finally { + gamesFetchMutex.unlock() } } From fce7ed748e376fb25c559adf0360273fe6d4e350 Mon Sep 17 00:00:00 2001 From: Emil Jiang Date: Tue, 8 Sep 2026 21:36:47 -0400 Subject: [PATCH 2/2] Allow CI builds without schema download endpoint --- app/build.gradle.kts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db6223c..7d70473 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -108,9 +108,11 @@ dependencies { apollo { service("service") { packageName.set("com.example.score") - introspection { - endpointUrl.set(secrets.getProperty("API_URL_DEV")) - schemaFile.set(file("src/main/graphql/schema.graphqls")) + secrets.getProperty("API_URL_DEV")?.takeIf { it.isNotBlank() }?.let { apiUrl -> + introspection { + endpointUrl.set(apiUrl) + schemaFile.set(file("src/main/graphql/schema.graphqls")) + } } } }