Ubisam-MobileGit/tasks/034-favorite-star-count-not-incrementing.md
choiminyeong f6ac20c8ed TASK-063: task records say what is actually open
No task waits on a Codex review; the redesign tasks are confirmed on the
product owner's Z Fold7; TASK-016's leftovers are on hold; no biometric
reauthentication.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:25:49 +09:00

127 lines
8.5 KiB
Markdown

# TASK-034: Star count doesn't increment, repeat tap doesn't un-star, then feels laggy
Status: implemented; all required checks pass; live-installed, awaiting the human product owner's confirmation
Owner: Claude
Reviewer: Codex
## Outcome
Three rounds of live feedback on the same feature, following TASK-033's OAuth scope fix. First: the
human product owner confirmed starring itself now works ("별표 눌러서 즐겨찾기는 되는데") but the
displayed star count didn't go up ("카운팅은 안올라가네") — `toggleFavorite`'s ALL-list success
branch updated only `favoriteAction` (to `Starred`) and never touched `starsCount`. Fixed by
incrementing `starsCount` in the same state update. Second, immediately after: with a naive
repeat-tap guard in place, tapping an already-starred row no longer un-starred it either
("이제 올라가는데 다시 눌렀을때 취소가 안되네") — the first fix's guard made a repeat tap a dead
no-op instead of a real toggle. Replaced that guard with an actual toggle: a repeat tap on a
row this session knows is starred now calls `unstar()` and decrements the count back down. Third:
with the toggle working correctly, the human product owner reported the response still felt slow
("좀 늦게 카운팅이 변하는 느낌") — every state/count update was applied only *after* the full
network round trip to this app's home-NAS-hosted Gitea completed, so a tap visibly did nothing for
however long that took. Made the star/count update optimistically, on tap, before the network call
starts; the update is only rolled back if the request actually turns out to have failed.
## 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/unstar count instead of adjusting it client-side (see Design
notes).
## Design notes
- **Optimistic ±1, not a re-fetch**: Gitea's star/unstar endpoints (`PUT`/`DELETE`, 204 No Content)
don't return the new count, so getting the authoritative number back would mean a second network
call 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 action succeeded, so the count is exactly one
higher or lower than what was last displayed). `starsCount` is floored at 0 via
`coerceAtLeast(0)` as a defensive guard, not because normal use can reach it.
- **A real toggle, not a guard**: the first pass at this fix added an early-return guard that made
a repeat tap on an already-`Starred` row do nothing — that stopped the double-count bug but also,
as immediately reported, made the star impossible to undo from the ALL list without switching to
the FAVORITES tab. The actual fix branches on the row's own `favoriteAction`: not-yet-starred-this-
session taps call `star()` (`+1`, → `Starred`), already-starred-this-session taps call
`unstar()` (`-1`, → `Idle`) — this both fixes the double-count and restores the obviously-expected
behavior of tapping a filled star to unfill it.
- **The underlying "can't tell if already starred on load" limitation is untouched**: this toggle
only tracks what happened *within this session's own interaction* with a row, same as
`favoriteAction` always has (TASK-018/TASK-030). A repository already starred before the app
session started still shows outlined/untapped until acted on directly.
- **Optimistic on tap, not after the network call, for the ALL list specifically**: `updateRow(...)`
for the star/unstar case now runs synchronously in `toggleFavorite`, before
`viewModelScope.launch { ... }` even starts, instead of inside the coroutine after
`favoritesApi.star()/unstar()` returns. This app's Gitea host is a home NAS (the same root cause
category as TASK-031's timeout fix), so waiting for a full round trip before moving the star or
count at all made every tap feel delayed rather than instant. The catch block now explicitly
rolls the optimistic change back (`Failed` state, count restored to what it was before the tap)
if the request turns out to have actually failed, rather than only ever moving the display forward
on success as the first two rounds of this fix did. The FAVORITES-list unstar (which removes the
whole row) deliberately stays non-optimistic — reverting a *removed* row is a more jarring failure
case than reverting a color/count, and wasn't part of what was reported as feeling slow.
## 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 un-stars it: calls `unstar()`, decrements the count by
1, and returns the row to `Idle` (outlined, tappable to star again).
- [x] The count never displays below 0.
- [x] The star icon and count update immediately on tap, not after the network call resolves; a
failed request rolls the display back to its pre-tap state instead of leaving it stuck at the
optimistic value.
- [x] Unstarring from the FAVORITES list is unaffected (row removal, no count to update, still
waits for confirmation as before).
- [x] Build, unit tests, and lint pass.
- [ ] Live confirmation from the human product owner that starring/un-starring now feels instant —
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 `favoriteAction`/`starsCount` update
now happens synchronously before the coroutine launches (optimistic), with the network call
itself reduced to just `favoritesApi.star()/unstar()` inside `viewModelScope.launch`; the catch
block now rolls the optimistic change back on failure (restoring the pre-tap count, marking
`Failed`) instead of only ever advancing the display on success.
- `RepositoryListViewModelTest.kt`: `repo()` test helper gained a `starsCount` parameter; tests
cover starring incrementing the count by 1, a repeat tap un-starring (calls both `star()` and
`unstar()`, decrements the count, returns to `Idle`), a full star→unstar round trip landing back
at the original count, the state/count updating immediately on tap *before* the queued coroutine
has run on the `StandardTestDispatcher` (the optimism itself), and a failed star/unstar each
rolling the count back to its pre-tap value.
- `./gradlew compileDebugKotlin compileDebugUnitTestKotlin`: BUILD SUCCESSFUL.
- `./gradlew testDebugUnitTest`: BUILD SUCCESSFUL, all unit tests passing.
- `./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) three times across this task's three rounds; not yet re-tested live against
the optimistic-update behavior in this exact session.
## Known limitations
- Still optimistic, not authoritative: if Gitea's real count differs from this app's local ±1 for
any reason (e.g. another client starred/unstarred 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 action) for a rarely-contended personal count.
- The underlying "can't tell if already starred on initial load" limitation (TASK-018/TASK-030)
is unchanged — a repository starred before the current app session still shows outlined until
acted on directly in this session, same as before this task.
- A failed *un*star attempt (network error mid-toggle) still collapses to the same flat `Failed`
state as a failed star attempt, which renders as outlined/not-starred on the ALL list even though
the repository may still actually be starred server-side — `FavoriteActionUiState.Failed` carries
no memory of which action was being attempted. Accepted as a minor, retryable rendering gap
rather than adding state to track it, since a retry from that visual state is harmless either way
(an unwanted second `star()` on an already-starred repo is a no-op server-side).
## Review findings
None yet — no Codex review pending (Codex retired, 2026-09-29).