diff --git a/src/api/orbit.ts b/src/api/orbit.ts index 55732fb7..45eb3079 100644 --- a/src/api/orbit.ts +++ b/src/api/orbit.ts @@ -106,6 +106,14 @@ export interface OrbitState { * before they reach the approval list. Symmetric — host can re-enable. */ suggestionBlocked?: string[]; + /** + * Authoritative count of suggestions actually waiting on host action right + * now (`state.queue` minus host-authored, merged, declined). The host + * rewrites it every tick — guests can't compute it themselves because the + * merged/declined sets live in the host's local store. Older clients that + * don't write the field fall back to a `state.queue` count in the UI. + */ + pendingApprovalCount?: number; } /** diff --git a/src/components/OrbitQueueHead.tsx b/src/components/OrbitQueueHead.tsx index f014d65f..2229832c 100644 --- a/src/components/OrbitQueueHead.tsx +++ b/src/components/OrbitQueueHead.tsx @@ -37,13 +37,13 @@ export default function OrbitQueueHead({ state }: Props) { const showPresence = role === 'guest' && state.positionAt > 0; const hostAway = showPresence && (nowMs - state.positionAt) > HOST_AWAY_THRESHOLD_MS; const cap = state.settings?.maxPending ?? 0; - // Approximate visible-pending count — same heuristic as evaluateOrbitSuggestGate. - // Conservative for guests (over-counts declined entries) but the host's - // own queue view sees the exact number because it has the merged/declined - // sets locally; we don't bother surfacing the exact count here either way - // since guests just need to know if they're near the cap. + // Authoritative count comes from the host's own tick (state.pendingApprovalCount). + // Older clients that predate that field fall back to the raw queue length + // — that one over-counts because merged/declined items stay in state.queue + // as history, but it's the best a non-host client can do. const pendingCount = cap > 0 - ? state.queue.filter(q => q.addedBy !== state.host).length + ? (state.pendingApprovalCount + ?? state.queue.filter(q => q.addedBy !== state.host).length) : 0; return ( diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index 49978a6b..5a97a613 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -130,11 +130,24 @@ export function useOrbitHost(): void { trackId: t.id, addedBy: suggesterByTrack.get(t.id) ?? base.host, })); + // Authoritative pending count — same predicate the approval list uses + // (state.queue minus host-authored, minus merged, minus declined). We + // recompute fresh each tick from the *current* store sets so an approve + // / decline that happened mid-sweep is reflected on the very next push. + const mergedNow = new Set(useOrbitStore.getState().mergedSuggestionKeys); + const declinedNow = new Set(useOrbitStore.getState().declinedSuggestionKeys); + const pendingApprovalCount = afterShuffle.queue.filter(q => + q.addedBy !== afterShuffle.host + && !mergedNow.has(suggestionKey(q)) + && !declinedNow.has(suggestionKey(q)) + ).length; + const next: OrbitState = { ...afterShuffle, ...snapshotPlayerPatch(base.host), playQueue, playQueueTotal: upcoming.length, + pendingApprovalCount, }; // 5) Commit locally + push remote. diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 1ce70a02..128c3e17 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -480,9 +480,14 @@ export function evaluateOrbitSuggestGate(): { allowed: boolean; reason: OrbitSug } const cap = state.settings?.maxPending ?? 0; if (cap > 0) { - const inState = state.queue.filter(q => q.addedBy !== state.host).length; - const total = inState + pendingSuggestions.length; - if (total >= cap) return { allowed: false, reason: 'cap-reached' }; + // Prefer the host-pushed authoritative count when present; fall back to a + // raw queue scan for older hosts. Add the local guest's pending list on + // top — those are tracks the host hasn't swept in yet. + const knownPending = state.pendingApprovalCount + ?? state.queue.filter(q => q.addedBy !== state.host).length; + if (knownPending + pendingSuggestions.length >= cap) { + return { allowed: false, reason: 'cap-reached' }; + } } return { allowed: true, reason: null }; }