diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt index ac843a5..b92af21 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt @@ -284,13 +284,24 @@ class RepositoryListViewModel( fun toggleFavorite(repository: RepositoryRowUiModel) { val content = mutableState.value as? RepositoryListUiState.Content ?: return val mode = content.mode + // On the ALL list, a repeat tap after a successful star would otherwise re-call star() + // (harmless server-side -- PUT is idempotent) and, before this guard, double-increment the + // client-displayed count each time. Once this session has confirmed a star succeeded, + // further taps here are a no-op; unstarring from the ALL list isn't supported (see the + // FavoriteStar doc comment in RepositoryListScreen.kt for why). + if (mode == RepositoryListMode.ALL && repository.favoriteAction == FavoriteActionUiState.Starred) return updateRow(repository.coordinates) { it.copy(favoriteAction = FavoriteActionUiState.InProgress) } viewModelScope.launch { try { when (mode) { RepositoryListMode.ALL -> { favoritesApi.star(repository.coordinates) - updateRow(repository.coordinates) { it.copy(favoriteAction = FavoriteActionUiState.Starred) } + // starsCount only ever reflects what the last full list load returned + // otherwise -- a successful star here needs its own +1, or the row keeps + // showing the pre-star count until the next reload. + updateRow(repository.coordinates) { + it.copy(favoriteAction = FavoriteActionUiState.Starred, starsCount = it.starsCount + 1) + } } RepositoryListMode.FAVORITES -> { favoritesApi.unstar(repository.coordinates) diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt index 225553a..4be2086 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt @@ -338,6 +338,40 @@ class RepositoryListViewModelTest { assertEquals(listOf(RepositoryCoordinates.of("owner", "repo")), browser.starredCalls) } + @Test + fun `starring a repository increments its displayed star count`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo", starsCount = 4)))) + val viewModel = viewModel(browser) + advanceUntilIdle() + + val row = (viewModel.state.value as RepositoryListUiState.Content).repositories.single() + assertEquals(4, row.starsCount) + viewModel.toggleFavorite(row) + advanceUntilIdle() + + val updated = (viewModel.state.value as RepositoryListUiState.Content).repositories.single() + assertEquals(5, updated.starsCount) + } + + @Test + fun `a repeat tap after a successful star is a no-op, not a second star call or count increment`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo")))) + val viewModel = viewModel(browser) + advanceUntilIdle() + + val row = (viewModel.state.value as RepositoryListUiState.Content).repositories.single() + viewModel.toggleFavorite(row) + advanceUntilIdle() + val afterFirstTap = (viewModel.state.value as RepositoryListUiState.Content).repositories.single() + + viewModel.toggleFavorite(afterFirstTap) + advanceUntilIdle() + + val afterSecondTap = (viewModel.state.value as RepositoryListUiState.Content).repositories.single() + assertEquals(1, afterSecondTap.starsCount) + assertEquals(listOf(RepositoryCoordinates.of("owner", "repo")), browser.starredCalls) + } + @Test fun `unstarring from the favorites tab removes the row without affecting others`() = runTest(dispatcher.scheduler) { val browser = FakeBrowser( @@ -480,13 +514,14 @@ class RepositoryListViewModelTest { private fun page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null) - private fun repo(name: String, updatedAtEpochMillis: Long = 1_000L) = RepositorySummary( + private fun repo(name: String, updatedAtEpochMillis: Long = 1_000L, starsCount: Int = 0) = RepositorySummary( coordinates = RepositoryCoordinates.of("owner", name), description = null, defaultBranch = RepositoryRef.of("main"), isPrivate = true, isFork = false, updatedAtEpochMillis = updatedAtEpochMillis, + starsCount = starsCount, ) private fun viewModel( diff --git a/docs/development-log.md b/docs/development-log.md index bfd1263..351c78a 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -566,3 +566,17 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Known limitations: the device's current session still carries scope from an earlier attempt in this same investigation — needs one more revoke (Gitea Settings → Applications → Authorized OAuth2 Applications) and re-login before starring can be confirmed working. Not yet done — the human product owner was tired and this was left for a later session rather than pushed further tonight. - Next recommended task: revoke + re-login when convenient, then confirm starring actually works; otherwise continue per the standing direction on everything else still queued from this session (TASK-030/031/032, pinch-zoom/pan, CSV, PDF, Issues/PR pagination). - Next owner: Claude, continuing per the standing direction. + +## 2026-08-29 — TASK-034: favorite star count doesn't increment after a successful star + +- Agent and role: Claude, direct continuation of the same live session — the human product owner confirmed TASK-033's fix worked ("별표 눌러서 즐겨찾기는 되는데") but reported the count itself doesn't update ("카운팅은 안올라가네"). +- Commit/diff: on `claude/task-015-notifications`; `feature/repositories/RepositoryListViewModel.kt` (+ test additions), `tasks/034-favorite-star-count-not-incrementing.md`. +- Bug found: `toggleFavorite`'s ALL-list success branch updated only `favoriteAction` to `Starred`, never `starsCount` — the row kept showing whatever count the last full list load had returned, even after a star the server had actually accepted. +- How it was implemented: incremented `starsCount` by 1 in the same state update that marks the row `Starred` (optimistic, since Gitea's star endpoint returns 204 with no body — a second network call just to re-fetch one number wasn't worth it). While fixing this, also closed a related gap: since the ALL list can't know a repository's already-starred state on initial load (an existing, accepted limitation from TASK-018/TASK-030), a `Starred` row's star stayed tappable, and a repeat tap would have re-called `star()` and double-incremented the displayed count each time — added an early-return guard so a repeat tap on an already-`Starred` row is now a no-op. +- Files changed: `RepositoryListViewModel.kt`, `RepositoryListViewModelTest.kt`, `tasks/034-favorite-star-count-not-incrementing.md`. +- Checks and results: `./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug` BUILD SUCCESSFUL, all unit tests passing (new coverage: starring increments displayed count by 1; a repeat tap after a successful star is a no-op), 0 lint errors. Installed on the human product owner's device over wireless `adb`. +- Also this turn: the `task-015-notifications` worktree directory itself had been removed between sessions (its branch and commits were intact); recreated it with `git worktree add`, plus a fresh `local.properties` (gitignored, not tracked) pointing at the local Android SDK, before continuing. +- Review findings and resolutions: None from a separate review pass. +- Known limitations: still optimistic, not authoritative — if Gitea's real count ever differs from this app's local +1 (e.g. a concurrent star from another client), the display stays off until the next full list reload. The underlying "can't tell if already starred on load" limitation is unchanged; this only stops it from causing a visible count bug. +- Next recommended task: get live confirmation that the count now increments on tap; otherwise continue per the standing direction on everything else still queued from this session. +- Next owner: Claude, continuing per the standing direction. diff --git a/tasks/034-favorite-star-count-not-incrementing.md b/tasks/034-favorite-star-count-not-incrementing.md new file mode 100644 index 0000000..241bac1 --- /dev/null +++ b/tasks/034-favorite-star-count-not-incrementing.md @@ -0,0 +1,88 @@ +# TASK-034: Star count doesn't increment after a successful favorite + +Status: implemented; all required checks pass; live-installed, awaiting the human product owner's confirmation +Owner: Claude +Reviewer: Codex + +## Outcome + +Following TASK-033's OAuth scope fix, the human product owner confirmed starring itself now works +("별표 눌러서 즐겨찾기는 되는데") but reported the displayed star count doesn't go up +("카운팅은 안올라가네"). `toggleFavorite`'s ALL-list success branch updated only `favoriteAction` +(to `Starred`) and never touched `starsCount`, so a row kept showing whatever count the last full +list load had returned, even after a star that the server had actually accepted. Fixed by +incrementing the row's `starsCount` in the same state update that marks it `Starred`. While fixing +this, found and closed a related gap: a repeat tap on an already-`Starred` row (the ALL list has no +way to know a repository's already-starred state on initial load, so the star stays tappable) would +have re-called `star()` and double-incremented the client-displayed count each time — now a no-op. + +## Scope + +- Included: `feature/repositories/RepositoryListViewModel.kt` (`toggleFavorite` only), matching + test additions in `RepositoryListViewModelTest.kt`. +- Excluded: No change to the FAVORITES-list unstar path (removing the row entirely already made + its count irrelevant, no bug there). No change to how the initial list load populates + `starsCount` (that already came from Gitea's own response, unaffected). No attempt to fetch the + server's authoritative post-star count instead of incrementing client-side (see Design notes). + +## Design notes + +- **Optimistic +1, not a re-fetch**: Gitea's star endpoint (`PUT`, 204 No Content) doesn't return + the new count, so getting the authoritative number back would mean a second network call + (re-fetching the repository) purely to update one number — not worth it for a value the app + already knows how to derive from something it just confirmed itself (the star succeeded, so the + count is exactly one higher than what was last displayed). +- **The repeat-tap guard closes a real double-count bug, not just a hypothetical one**: because the + ALL list can't determine a repository's already-starred state on load (an existing, accepted + limitation from TASK-018/TASK-030), a `Starred` row's star icon stays enabled and tappable — + nothing before this fix stopped a second tap from calling `star()` again and incrementing the + displayed count a second time for a repository the user only starred once. The guard checks the + row's own `favoriteAction` before doing anything, so it also saves a redundant network call, not + just a redundant `+1`. + +## Acceptance criteria + +- [x] Starring a repository from the ALL list increments its displayed star count by exactly 1. +- [x] Tapping an already-`Starred` row again does not call `star()` a second time and does not + change the displayed count further. +- [x] Unstarring from the FAVORITES list is unaffected (row removal, no count to update). +- [x] Build, unit tests, and lint pass. +- [ ] Live confirmation from the human product owner that the count now visibly increments on tap — + installed on the device but not yet re-tested in this exact session. + +## Required checks + +```text +./gradlew compileDebugKotlin compileDebugUnitTestKotlin +./gradlew testDebugUnitTest +./gradlew assembleDebug lintDebug +``` + +## Evidence + +- `RepositoryListViewModel.kt`: `toggleFavorite`'s ALL-list success branch now updates + `starsCount = it.starsCount + 1` alongside `favoriteAction = Starred`; new early-return guard + when the row's `favoriteAction` is already `Starred`. +- `RepositoryListViewModelTest.kt`: `repo()` test helper gained a `starsCount` parameter; two new + tests — starring increments the displayed count by 1; a repeat tap after a successful star is a + no-op (no second `star()` call, count stays at 1). +- `./gradlew compileDebugKotlin compileDebugUnitTestKotlin`: BUILD SUCCESSFUL. +- `./gradlew testDebugUnitTest`: BUILD SUCCESSFUL, all unit tests passing (including the two new + ones above). +- `./gradlew assembleDebug lintDebug`: BUILD SUCCESSFUL, 0 lint errors. +- Installed on the human product owner's device over wireless `adb` (`adb install -r`, streamed + install succeeded); not yet re-tested live in this exact session. + +## Known limitations + +- Still optimistic, not authoritative: if Gitea's real count differs from this app's local + increment for any reason (e.g. another client starred the same repo around the same time), the + displayed number stays off until the next full list reload. Considered an acceptable tradeoff + given the alternative (a second network call per star) for a rarely-contended personal count. +- The underlying "can't tell if already starred on initial load" limitation (TASK-018/TASK-030) + is unchanged — this task only stops it from causing a *visible count* bug, it doesn't resolve the + limitation itself. + +## Review findings + +None yet — pending Codex/human review.