TASK-034: fix favorite star count not incrementing after a successful star

toggleFavorite's ALL-list success branch updated favoriteAction to
Starred but never touched starsCount, so a row kept showing its
pre-star count even after the server had actually accepted it. Now
increments starsCount by 1 in the same update. Also guards against a
repeat tap on an already-Starred row (the ALL list can't tell a
repo's already-starred state on load, so the star stays tappable)
double-incrementing the displayed count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-08-29 16:37:17 +09:00
parent b38ddc99e3
commit 6dbb37d067
4 changed files with 150 additions and 2 deletions

View File

@ -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)

View File

@ -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(

View File

@ -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.

View File

@ -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.