Skip to content

Preload Discover Trending list - #109

Open
graeme wants to merge 10 commits into
mainfrom
improve-discover-caching
Open

Preload Discover Trending list#109
graeme wants to merge 10 commits into
mainfrom
improve-discover-caching

Conversation

@graeme

@graeme graeme commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

PR: Preload Discover trending and make its repository observable

Summary

Preloads the Discover tab's "Trending" list at app launch so the screen feels
instant on first visit, and reshapes the Discover data source to match the
observable pattern already used by BrewInstalledPackagesRepository. Also fixes
a decode bug that made trending fail on every load.

Changes

  • Converted BrewDiscoverPackagesRepository from an actor into an
    @Observable @MainActor source of truth exposing a
    LoadState<[DiscoveryBrewPackage], any Error>. Loads are cache-first, coalesce
    a single in-flight task, and re-fetch on return to the tab once the 24h TTL
    lapses. The protocol now vends state plus load(forceRefresh:).
  • BrewApp warms the catalogue and analytics caches, then preloads the trending
    list at window creation, ordered after its dependencies.
  • DiscoverViewModel now reads repository state through a computed property and
    no longer re-ranks rows; sectioning is filter-only.
  • Ranking moved into the analytics DTO. BrewAnalyticsJSON is now a thin wire
    mirror; rankedPackageCounts() derives order from install count with a name
    tie-break.

Why this split

The DTO and ranking change lands first as its own step so the observable refactor
sits on a correct, well-tested data layer.

Bug fix

The live Homebrew analytics API keys entries by name alphabetically and carries no
rank field. An earlier assumption decoded a required number field, so every real
response failed and trending showed an error every time. Ranking is now
reconstructed from count, and a kept end-to-end integration test drives the view
model through the real repository against a live-shaped payload to lock the shape in.

Testing

  • scripts/test (624 tests pass)
  • SwiftFormat, SwiftLint, and BrewUILint clean
  • App target typecheck (swiftc -typecheck) passes

PR checklist

  • Followed the repository's contribution and workflow guidance
  • Explained what changed and why it should land now
  • Ran relevant local checks for the changed scope
  • Changes are scoped and free of unrelated modifications

  • AI was used to generate or assist with generating this PR.
  • Claude Code implemented the refactor and tests step by step; each commit was
    built, tested, and linted, and the API shape was verified against the live
    endpoint before the fix landed.

graeme and others added 5 commits August 2, 2026 18:36
The analytics DTO decoded the ranked wire object into an unordered
`[String: [Entry]]` dictionary, discarding Homebrew's ordering; the
Discover repository then re-derived a ranking by sorting on install
count with a name tie-break. That re-ranking is the client doing the
backend's job.

Decode the per-entry `number` (the backend's authoritative rank) and
reassemble the published order from it. `topPackages` now enriches the
counts in rank order and drops its count-sort entirely.

While here, make the Decodable type a thin, faithful mirror of the wire
(synthesized decode, snake_case CodingKeys, `count` as the raw String).
The flatten/validate/rank/parse transformation moves out of `init(from:)`
into `rankedPackageCounts()`, so decoding stays dumb and mapping owns the
domain logic. This trades away the old lossy int tolerance for
`total_*`, which the wire never needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`sortedSection` re-sorted each section by install count then name, which
re-derived a ranking the sources already provide: trending now arrives in
the backend's install-rank order, and catalogue search arrives in the
catalogue's match order (prefix matches first). Re-sorting discarded both.

Rename it to `section` and make it filter-only, so rows render in the
order they were handed down. This also fixes search results, which were
being flattened to plain alphabetical order over the catalogue's ranking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… truth

Mirror BrewInstalledPackagesRepository: convert the repository from an
`actor` returning a snapshot into an `@Observable @MainActor` type that
owns `state: LoadState<[DiscoveryBrewPackage], any Error>`. IO and
persistence still live in the DiscoverAnalyticsCache actor; only the
in-memory UI state moves here.

- Protocol becomes the observable contract (`state` + cache-first
  `load(forceRefresh:)`), replacing `loadTopPackages`.
- `load` is cache-first and coalesced: fresh in-memory data returns
  instantly, a stale window revalidates while the prior list stays on
  screen, and a single `loadTask` merges the launch preload with the
  tab's on-appear load into one fetch. Analytics staleness (the 24h TTL)
  stays the single source of truth for both the in-memory revalidation
  and the network refetch.
- DiscoverViewModel projects `trending` from the repository's `state`
  and delegates load/retry; the top-packages limit and window move onto
  the repository.
- Stub/Unimplemented/preview doubles become observable.

This lets the trending list be preloaded at launch and survive tab
switches (wired up next), while the tab still shows loading mid-fetch and
revalidates on return once the cache goes stale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Warm the catalogue and analytics caches, then kick off the Discover
repository's load in the same window-creation task, so the trending list
is already loaded (or loading) by the time the user opens the tab. The
prepares are awaited first so enrichment reads the just-prepared disk
caches instead of racing them into a needless network fetch.

Opening the tab mid-load still shows the loading state (the on-appear
load joins the in-flight preload), and a session outliving the analytics
TTL revalidates on return — both handled by the repository's cache-first
load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live analytics endpoint keys entries by package name alphabetically
and each object carries only `formula`/`cask` + a string `count` — there
is no `number` (or any) rank field. Decoding it as a required
`Entry.number: Int` failed on every real response, so trending errored
every time it loaded.

Drop `number` from the DTO and reconstruct the ranking from `count`
(descending, name tie-break) in `rankedPackageCounts()` — the only
ranking signal the API actually provides. The view model still stays
filter-only; ranking lives in one place in the mapping layer.

Adds DiscoverTrendingIntegrationTests: an end-to-end path from
DiscoverViewModel through the real BrewDiscoverPackagesRepository,
decoding a payload shaped like the live API (name-keyed, alphabetical,
string counts, no rank). It reproduced the failure and now guards the
decode/rank seam. Fixtures drop the fictional `number` field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

BrewAnalyticsJSON.rankedPackageCounts() currently drops empty buckets silently, which can mask malformed payloads and produce incomplete rankings without surfacing an error.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Preloads the Discover tab’s Trending list at app launch by making BrewDiscoverPackagesRepository an app-scoped observable source of truth with a LoadState, and reshapes the Discover flow so the view model renders repository state directly. It also refactors analytics decoding/mapping so Trending ranking is derived from install counts (since the live payload has no rank field), and adds integration coverage for the live-shaped payload.

Changes:

  • Converted DiscoverPackagesRepository to an observable, @MainActor protocol exposing state + load(forceRefresh:), and updated consumers/stubs accordingly.
  • Updated BrewDiscoverPackagesRepository to coalesce in-flight loads, use cache-first semantics with TTL revalidation, and expose a single session-scoped Trending list.
  • Refactored BrewAnalyticsJSON into a thin wire mirror with mapping via rankedPackageCounts(), plus added/updated unit + end-to-end integration tests.
File summaries
File Description
Tests/BrewRepositoriesTests/BrewDiscoverPackagesRepositoryTests.swift Updates repository tests to assert against observable state and new load semantics/limits.
Tests/BrewRepositoriesTests/BrewAPIClientConcurrentIntegrationTests.swift Updates integration tests to use repository load() and validate combined formula+cask ordering via state.
Tests/BrewNetworkingTests/BrewAPIClientURLSessionIntegrationTests.swift Switches assertions to rankedPackageCounts() to validate ranking/mapping behavior.
Tests/BrewFeatureDiscoverTests/DiscoverViewModelTests.swift Adapts Discover VM tests to repository state projection and removes VM-side reranking assumptions.
Tests/BrewFeatureDiscoverTests/DiscoverTrendingIntegrationTests.swift Adds end-to-end test that locks in live-shaped analytics payload decoding + ranking behavior.
Tests/BrewCoreTests/BrewAnalyticsJSONTests.swift Reworks analytics DTO tests to separate decode-time vs mapping-time failures and verify ranking rules.
Sources/BrewRepositoryInterfaces/Protocols/DiscoverPackagesRepository.swift Redefines the Discover repository interface as observable state + cache-first load(forceRefresh:).
Sources/BrewRepositoryInterfaces/Fakes/Stubs.swift Updates shared stub discover repository to the new observable state contract.
Sources/BrewRepositories/BrewDiscoverPackagesRepository.swift Refactors concrete repository into @Observable @MainActor, adds coalesced loading + TTL revalidation, returns a single ranked list.
Sources/BrewFeatureDiscover/Views/DiscoverPackagesView.swift Updates view sectioning to partition only (no reranking) via new VM helper.
Sources/BrewFeatureDiscover/ViewModels/DiscoverViewModel.swift Projects repository state into view-facing LoadState, removes reranking, and delegates load/refresh to repository.
Sources/BrewCore/Models/BrewAnalyticsJSON.swift Makes analytics DTO a wire mirror and introduces rankedPackageCounts() mapping/ranking.
Sources/BrewAppEnvironment/UnimplementedRepositories.swift Updates unimplemented Discover repository to match the new protocol surface.
Package.swift Adds BrewRepositories/BrewNetworking dependencies to enable end-to-end Trending integration tests.
Homebrew/BrewApp.swift Warms caches then preloads Trending at window creation to make first-tab open instant.
Review details
  • Files reviewed: 15/15 changed files
  • Comments generated: 2
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread Sources/BrewCore/Models/BrewAnalyticsJSON.swift Outdated
Comment thread Sources/BrewRepositories/BrewDiscoverPackagesRepository.swift Outdated
graeme and others added 4 commits August 3, 2026 18:56
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Drop comments that merely restate the code or a self-explanatory name;
keep public-API documentation and the non-obvious rationale (endpoint key
reuse, no-rank-field, load coalescing, cache-order invariant). Also correct
the topPackages note: rows are ranked by count here, not by a backend order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rankedPackageCounts() dropped any key whose entry array was empty via
compactMap(\.first), so a malformed payload could silently produce an
incomplete ranking. Iterate key/value pairs and throw
emptyPackageEntries(key:) naming the offending key instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keep only public-API doc comments (plus MARK markers and file headers);
remove the inline and internal-declaration comments introduced across the
branch's Discover work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

The new integration tests should explicitly run on @MainActor (or await actor-isolated reads) and one updated test name encodes an incorrect “backend rank” assumption.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (2)

Tests/BrewFeatureDiscoverTests/DiscoverTrendingIntegrationTests.swift:16

  • These tests read @MainActor-isolated DiscoverViewModel properties (e.g. trending, visiblePackages, selectedPackage) without await, which is only safe/legal if the test itself runs on the MainActor. Make the test type (or individual test functions) @MainActor to ensure isolation is explicit and resilient to stricter concurrency checking.
struct DiscoverTrendingIntegrationTests {

Tests/BrewRepositoriesTests/BrewDiscoverPackagesRepositoryTests.swift:40

  • Test name says it "honors backend rank", but the production code now reconstructs ranking from install count and a name tie-break (the backend carries no rank field). Renaming this test avoids encoding incorrect assumptions about the API contract.
    @Test @MainActor func `load parses string counts and honors backend rank`() async throws {
  • Files reviewed: 15/15 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

@graeme
graeme marked this pull request as ready for review August 3, 2026 12:37
`honors backend rank` predated removing the `number` field. With equal
counts and no rank, the alpha/beta order comes from the name tie-break, so
rename to `breaks equal counts by name` to match what the fixture asserts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@graeme
graeme requested a review from MikeMcQuaid August 3, 2026 12:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants