From 622a47887a1f760810dff75b9c09af66dbac2b72 Mon Sep 17 00:00:00 2001 From: Emil Jiang Date: Tue, 8 Sep 2026 21:28:25 -0400 Subject: [PATCH 1/4] 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/4] 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")) + } } } } From 661f1b51b8f043b9b9c07f76b51a9ad6c54e5d23 Mon Sep 17 00:00:00 2001 From: Emil Jiang Date: Wed, 23 Sep 2026 11:58:14 -0400 Subject: [PATCH 3/4] Fix queued refreshes and restore games after refresh failure --- app/build.gradle.kts | 1 + .../score/model/ScoreRepository.kt | 18 ++- .../score/model/ScoreRepositoryTest.kt | 106 ++++++++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d70473..006c248 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.runtime.android) testImplementation(libs.junit) + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") debugImplementation("androidx.compose.ui:ui-tooling") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) 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 94cf387..6c22efd 100644 --- a/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt +++ b/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt @@ -104,12 +104,14 @@ class ScoreRepository @Inject constructor( } fun fetchGames() = appScope.launch { - if (!gamesFetchMutex.tryLock()) return@launch + gamesFetchMutex.lock() + val previousSuccess = _upcomingGamesFlow.value as? ApiResponse.Success _upcomingGamesFlow.value = ApiResponse.Loading val allGames = mutableListOf() var offset = 0 var retries = 0 var initialWindow = true + var historyComplete = false try { while (true) { @@ -119,7 +121,7 @@ class ScoreRepository @Inject constructor( val today = LocalDate.now() apolloClient.query( InitialGamesQuery( - today.atStartOfDay().toString(), + today.minusDays(7).atStartOfDay().toString(), today.plusDays(30).atStartOfDay().toString() ) ).execute().toResult().getOrNull()?.gamesByDate @@ -153,6 +155,7 @@ class ScoreRepository @Inject constructor( } if (pageResult.isEmpty()) { + historyComplete = true break } @@ -195,21 +198,24 @@ class ScoreRepository @Inject constructor( continue } - if (pageResult.size < PAGE_LIMIT) break + if (pageResult.size < PAGE_LIMIT) { + historyComplete = true + break + } offset += PAGE_LIMIT } _upcomingGamesFlow.value = if (allGames.isNotEmpty()) ApiResponse.Success(allGames.asReversed().distinctBy { it.id }.asReversed()) - else if (_upcomingGamesFlow.value is ApiResponse.Success) _upcomingGamesFlow.value - else ApiResponse.Error + else if (historyComplete) ApiResponse.Success(emptyList()) + else previousSuccess ?: ApiResponse.Error } catch (e: CancellationException) { throw e } catch (e: Exception) { Log.e("ScoreRepository", "Error fetching upcoming games", e) if (_upcomingGamesFlow.value !is ApiResponse.Success) { - _upcomingGamesFlow.value = ApiResponse.Error + _upcomingGamesFlow.value = previousSuccess ?: ApiResponse.Error } } finally { gamesFetchMutex.unlock() diff --git a/app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt b/app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt new file mode 100644 index 0000000..420dc80 --- /dev/null +++ b/app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt @@ -0,0 +1,106 @@ +package com.cornellappdev.score.model + +import com.apollographql.apollo.ApolloClient +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import okhttp3.mockwebserver.Dispatcher +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class ScoreRepositoryTest { + private lateinit var server: MockWebServer + private lateinit var client: ApolloClient + private lateinit var scope: CoroutineScope + private lateinit var repository: ScoreRepository + private val requests = AtomicInteger() + @Volatile private var fail = false + @Volatile private var empty = false + private var historyStarted: CountDownLatch? = null + private var releaseHistory: CountDownLatch? = null + + @Before fun setup() { + server = MockWebServer() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val body = request.body.readUtf8() + requests.incrementAndGet() + val field = if (body.contains("InitialGames")) "gamesByDate" else "games" + if (field == "games") { + historyStarted?.countDown() + releaseHistory?.await(2, TimeUnit.SECONDS) + } + val games = if (empty) "[]" else """[{"__typename":"GameType","id":"1","city":"Ithaca","date":"2026-09-23","gender":"Mens","result":null,"sport":"Baseball","time":null,"team":{"__typename":"TeamType","name":"Opponent","image":"https://example.com/logo.png","color":"#FFFFFF"}}]""" + val response = if (fail) """{"errors":[{"message":"Unavailable"}]}""" + else """{"data":{"$field":$games}}""" + return MockResponse().setHeader("Content-Type", "application/json").setBody(response) + } + } + server.start() + client = ApolloClient.Builder().serverUrl(server.url("/").toString()).build() + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + repository = ScoreRepository(client, scope) + } + + @After fun teardown() { + releaseHistory?.countDown() + scope.cancel() + client.close() + server.shutdown() + } + + @Test fun failedRefreshRestoresPreviousGames() = runBlocking { + withTimeout(10000) { + repository.fetchGames().join() + val previous = repository.upcomingGamesFlow.value + assertTrue(previous is ApiResponse.Success) + fail = true + repository.fetchGames().join() + assertEquals(previous, repository.upcomingGamesFlow.value) + } + } + + @Test fun successfulEmptyRefreshClearsPreviousGames() = runBlocking { + withTimeout(10000) { + repository.fetchGames().join() + empty = true + repository.fetchGames().join() + assertEquals(ApiResponse.Success(emptyList()), repository.upcomingGamesFlow.value) + } + } + + @Test fun refreshDuringHistoryFetchWaitsAndRuns() = runBlocking { + withTimeout(10000) { + historyStarted = CountDownLatch(1) + releaseHistory = CountDownLatch(1) + val first = repository.fetchGames() + assertTrue(historyStarted!!.await(2, TimeUnit.SECONDS)) + val refresh = repository.fetchGames() + assertFalse(refresh.isCompleted) + releaseHistory!!.countDown() + first.join() + refresh.join() + assertEquals(4, requests.get()) + assertTrue(repository.upcomingGamesFlow.value is ApiResponse.Success) + } + } + + @Test fun firstLoadFailureShowsError() = runBlocking { + withTimeout(10000) { + fail = true + repository.fetchGames().join() + assertEquals(ApiResponse.Error, repository.upcomingGamesFlow.value) + } + } +} From dd06eb9f8392c50de7110b1927419da59c6ab440 Mon Sep 17 00:00:00 2001 From: Emil Jiang Date: Wed, 23 Sep 2026 17:47:21 -0400 Subject: [PATCH 4/4] Address game loading review readability nits --- .../score/model/GameListItemMappers.kt | 30 +++++ .../score/model/ScoreRepository.kt | 114 +++--------------- .../score/model/ScoreRepositoryTest.kt | 9 +- 3 files changed, 49 insertions(+), 104 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/score/model/GameListItemMappers.kt diff --git a/app/src/main/java/com/cornellappdev/score/model/GameListItemMappers.kt b/app/src/main/java/com/cornellappdev/score/model/GameListItemMappers.kt new file mode 100644 index 0000000..208105f --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/model/GameListItemMappers.kt @@ -0,0 +1,30 @@ +package com.cornellappdev.score.model + +import com.cornellappdev.score.util.parseColor +import com.cornellappdev.score.util.parseResultScore +import com.example.score.fragment.GameListItem + +fun GameListItem.isMens(): Boolean = gender == "Mens" + +fun GameListItem.toGame(): Game? { + val gameTeam = team ?: return null + val imageUrl = gameTeam.image ?: return null + val scores = result?.split(",")?.getOrNull(1)?.split("-") + val fallbackScores = parseResultScore(result) + return Game( + id = id ?: "", + teamLogo = imageUrl, + time = time, + teamName = gameTeam.name, + teamColor = parseColor(gameTeam.color).copy(alpha = 0.4f * 255), + gender = if (isMens()) "Men's" else "Women's", + sport = sport, + date = date, + city = city, + cornellScore = scores?.getOrNull(0)?.toNumberOrNull() ?: fallbackScores?.first, + otherScore = scores?.getOrNull(1)?.toNumberOrNull() ?: fallbackScores?.second + ) +} + +private fun String.toNumberOrNull(): Number? = + if (contains(".")) toFloatOrNull() else toIntOrNull() 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 6c22efd..01aca94 100644 --- a/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt +++ b/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt @@ -3,11 +3,8 @@ package com.cornellappdev.score.model import android.util.Log import com.apollographql.apollo.ApolloClient 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 @@ -21,11 +18,13 @@ import java.time.LocalDate import kotlinx.coroutines.withTimeout import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds -private const val TIMEOUT_TIME_MILLIS = 5000L +private const val TAG = "ScoreRepository" +private val GAME_DETAILS_TIMEOUT = 5.seconds private const val PAGE_LIMIT = 100 private const val MAX_RETRIES = 3 -private const val PAGE_TIMEOUT_MILLIS = 3000L +private val PAGE_TIMEOUT = 3.seconds /** * This is a singleton responsible for fetching and caching all data for Score. @@ -46,63 +45,7 @@ class ScoreRepository @Inject constructor( MutableStateFlow>(ApiResponse.Loading) val currentGamesFlow = _currentGameFlow.asStateFlow() - /** - * Asynchronously fetches the list of games from the API. Once finished, will send down - * `upcomingGamesFlow` to be observed. - */ - fun fetchGamesPrev() = appScope.launch { - _upcomingGamesFlow.value = ApiResponse.Loading - try { - val result = - withTimeout(TIMEOUT_TIME_MILLIS) { - apolloClient.query((GamesQuery())).execute().toResult() - } - - if (result.isSuccess) { - val games = result.getOrNull() - - val gamesList: List = - games?.games?.filter { game -> isValidSport(game?.sport ?: "") } - ?.mapNotNull { game -> - /** - * The final scores in the past game cards are obtained by parsing a String - * result from the GameQuery, which is oftentimes in the format - * Result, CornellScore-OpponentScore (e.g. "W, 2-1"). Not all of the strings - * are in this format (e.g. 4th of 6, 1498 points for women's Swimming and - * Diving), but in this case, the cornellScore and otherScore parameters of - * the game and associated card should be null, and as of right now, - * null-scored games are filtered out. - */ - val scores = game?.result?.split(",")?.getOrNull(1)?.split("-") - val cornellScore = scores?.getOrNull(0)?.toNumberOrNull() - val otherScore = scores?.getOrNull(1)?.toNumberOrNull() - game?.team?.image?.let { - Game( - id = game.id ?: "", // Should never be null - teamLogo = it, - teamName = game.team.name, - time = game.time, - teamColor = parseColor(game.team.color).copy(alpha = 0.4f * 255), - gender = if (game.gender == "Mens") "Men's" else "Women's", - sport = game.sport, - date = game.date, - city = game.city, - cornellScore = cornellScore, - otherScore = otherScore - ) - } - } ?: emptyList() - _upcomingGamesFlow.value = ApiResponse.Success(gamesList) - } else { - _upcomingGamesFlow.value = ApiResponse.Error - } - - } catch (e: Exception) { - Log.e("ScoreRepository", "Error fetching posts: ", e) - _upcomingGamesFlow.value = ApiResponse.Error - } - } - + /** Publishes nearby games first, then loads history while preserving results on failure. */ fun fetchGames() = appScope.launch { gamesFetchMutex.lock() val previousSuccess = _upcomingGamesFlow.value as? ApiResponse.Success @@ -114,9 +57,10 @@ class ScoreRepository @Inject constructor( var historyComplete = false try { + // The page count is unknown, and retries must reuse the current offset. while (true) { val pageResult = try { - withTimeoutOrNull(PAGE_TIMEOUT_MILLIS) { + withTimeoutOrNull(PAGE_TIMEOUT) { if (initialWindow) { val today = LocalDate.now() apolloClient.query( @@ -135,7 +79,7 @@ class ScoreRepository @Inject constructor( } } catch (e: CancellationException) { throw e - } catch (e: Exception) { + } catch (_: Exception) { null } @@ -163,30 +107,8 @@ class ScoreRepository @Inject constructor( val pageGames: List = pageResult .filterNotNull() - .filter { gql -> isValidSport(gql.sport ?: "") } - .mapNotNull { graphqlGame -> - val scores = graphqlGame.result?.split(",")?.getOrNull(1)?.split("-") - val cornellScore = scores?.getOrNull(0)?.toNumberOrNull() - ?: parseResultScore(graphqlGame.result)?.first - val otherScore = scores?.getOrNull(1)?.toNumberOrNull() ?: parseResultScore( - graphqlGame.result - )?.second - graphqlGame.team?.image?.let { imageUrl -> - Game( - id = graphqlGame.id ?: "", - teamLogo = imageUrl, - time = graphqlGame.time, - teamName = graphqlGame.team.name, - teamColor = parseColor(graphqlGame.team.color).copy(alpha = 0.4f * 255), - gender = if (graphqlGame.gender == "Mens") "Men's" else "Women's", - sport = graphqlGame.sport, - date = graphqlGame.date, - city = graphqlGame.city, - cornellScore = cornellScore, - otherScore = otherScore - ) - } - } + .filter { isValidSport(it.sport) } + .mapNotNull { it.toGame() } allGames.addAll(pageGames) @@ -213,7 +135,7 @@ class ScoreRepository @Inject constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e("ScoreRepository", "Error fetching upcoming games", e) + Log.e(TAG, "Error fetching upcoming games", e) if (_upcomingGamesFlow.value !is ApiResponse.Success) { _upcomingGamesFlow.value = previousSuccess ?: ApiResponse.Error } @@ -227,11 +149,11 @@ class ScoreRepository @Inject constructor( * `currentGamesFlow` to be observed. */ fun getGameById(id: String) = appScope.launch { - Log.d("ScoreRepository", "Fetching game with id: $id") + Log.d(TAG, "Fetching game with id: $id") _currentGameFlow.value = ApiResponse.Loading try { val result = - withTimeout(TIMEOUT_TIME_MILLIS) { + withTimeout(GAME_DETAILS_TIMEOUT) { apolloClient.query(GameByIdQuery(id)).execute().toResult() } @@ -241,16 +163,8 @@ class ScoreRepository @Inject constructor( } ?: _currentGameFlow.update { ApiResponse.Error } } catch (e: Exception) { - Log.e("ScoreRepository", "Error fetching game with id: ${id}: ", e) + Log.e(TAG, "Error fetching game with id: ${id}: ", e) _currentGameFlow.value = ApiResponse.Error } } } - -fun String.toNumberOrNull(): Number? { - return when { - this.contains(".") -> this.toFloatOrNull() // Try converting to Float if there's a decimal - else -> this.toIntOrNull() // Otherwise, try converting to Int - } -} - diff --git a/app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt b/app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt index 420dc80..bd81675 100644 --- a/app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt +++ b/app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt @@ -18,6 +18,7 @@ import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test +import kotlin.time.Duration.Companion.seconds class ScoreRepositoryTest { private lateinit var server: MockWebServer @@ -61,7 +62,7 @@ class ScoreRepositoryTest { } @Test fun failedRefreshRestoresPreviousGames() = runBlocking { - withTimeout(10000) { + withTimeout(10.seconds) { repository.fetchGames().join() val previous = repository.upcomingGamesFlow.value assertTrue(previous is ApiResponse.Success) @@ -72,7 +73,7 @@ class ScoreRepositoryTest { } @Test fun successfulEmptyRefreshClearsPreviousGames() = runBlocking { - withTimeout(10000) { + withTimeout(10.seconds) { repository.fetchGames().join() empty = true repository.fetchGames().join() @@ -81,7 +82,7 @@ class ScoreRepositoryTest { } @Test fun refreshDuringHistoryFetchWaitsAndRuns() = runBlocking { - withTimeout(10000) { + withTimeout(10.seconds) { historyStarted = CountDownLatch(1) releaseHistory = CountDownLatch(1) val first = repository.fetchGames() @@ -97,7 +98,7 @@ class ScoreRepositoryTest { } @Test fun firstLoadFailureShowsError() = runBlocking { - withTimeout(10000) { + withTimeout(10.seconds) { fail = true repository.fetchGames().join() assertEquals(ApiResponse.Error, repository.upcomingGamesFlow.value)