fix(client/apple): a pinned card belongs to its profile, not to its host's binding

Grouping by profile asked each HOST which profile it was bound to, then handed the
answer to that host's every card. So a card visibly wearing a "Gaming" chip — a pin,
on a host bound to nothing — was filed under "No Profile" along with the rest. Every
pinned card was, which is most of what the grouping exists to separate.

Pins are not bindings. The arrangement works on CARDS now: a host's own card follows
its binding, a pinned card follows its pin, and both can land in the same band when a
host pinned the profile it is also bound to. The expansion moved out of the view and
into the arrangement, because it is exactly what the grouping has to see.

The regression is pinned by a test whose fixture is the shape that broke: a host
bound to nothing, carrying two pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:06:41 +02:00
co-authored by Claude Opus 5
parent f071467cbb
commit 2e7b4aea79
3 changed files with 102 additions and 55 deletions
@@ -63,8 +63,8 @@ struct HomeView: View {
groupHeader(title, accent: group.accent)
}
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
ForEach(entries(in: group)) { entry in
hostCard(entry.host, pinned: entry.profile)
ForEach(group.cards) { card in
hostCard(card.host, pinned: card.pinned)
}
}
}
@@ -210,16 +210,9 @@ struct HomeView: View {
// MARK: - Cards
/// One grid tile: a host's primary card, or one of its pinned host+profile cards. A pin is
/// presentation only same record, same live status, so it is an extra ENTRY here rather than
/// a duplicated host (which would fork pairing, WoL and renames design §5.2a).
private struct HostGridEntry: Identifiable {
let host: StoredHost
let profile: StreamProfile?
var id: String { "\(host.id.uuidString)#\(profile?.id ?? "")" }
}
/// The grid's bands, ordered and divided per this device's preference (`HostArrangement`).
/// The grid's bands, ordered and divided per this device's preference cards and all, so a
/// pinned card can be filed under the profile it connects with rather than under its host's
/// binding (`HostArrangement`).
private var hostGroups: [HostGroup] {
HostArrangement.groups(
hosts: store.hosts, catalog: profiles.catalog,
@@ -228,15 +221,6 @@ struct HomeView: View {
grouping: HostGrouping(rawValue: groupingRaw) ?? .none)
}
/// A band's hosts, each immediately followed by its pinned cards so a pin reads as belonging
/// to its host rather than as a stray tile somewhere in the grid.
private func entries(in group: HostGroup) -> [HostGridEntry] {
group.hosts.flatMap { host in
[HostGridEntry(host: host, profile: nil)]
+ profiles.pinned(for: host).map { HostGridEntry(host: host, profile: $0) }
}
}
/// Online = advertising on mDNS OR answered the reachability probe (a routed/VPN host never
/// advertises). One definition, used by the cards and by the Status grouping alike.
private func isOnline(_ host: StoredHost) -> Bool {