diff --git a/app/features/global-status/core/global-status.server.test.ts b/app/features/global-status/core/global-status.server.test.ts index 6ce448161..5d9b478f8 100644 --- a/app/features/global-status/core/global-status.server.test.ts +++ b/app/features/global-status/core/global-status.server.test.ts @@ -1,4 +1,4 @@ -import { addHours, addMinutes, subHours } from "date-fns"; +import { addHours, addMinutes, subHours, subMinutes } from "date-fns"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { backdate } from "~/db/seed/core/backdate"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; @@ -125,6 +125,25 @@ describe("resolveGlobalStatus", () => { }); }); + test("does not count a like from a group gone stale in the looking pool", async () => { + const likerGroup = await SQGroupFactory.create({ + memberUserIds: userIds([5]), + }); + await SQGroupFactory.create( + { memberUserIds: userIds([1, 2]) }, + { likedByGroupIds: [likerGroup.id] }, + ); + await backdate("Group", likerGroup.id, { + latestActionAt: subMinutes(new Date(), 45), + }); + await refreshSendouQInstance(); + + expect(await resolveGlobalStatus(users.id(1))).toMatchObject({ + state: "SQ_QUEUED", + count: 0, + }); + }); + test("resolves a group inactive for too long as expired", async () => { const group = await SQGroupFactory.create({ memberUserIds: userIds([1, 2]), diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index f2dd2b473..95fccb2c3 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -470,20 +470,23 @@ export async function insertMember( return { chatRoomIdToRevalidate }; } -/** Count of pending likes each non-inactive group has received, keyed by group id. */ -export async function findCurrentReceivedLikeCounts() { +/** Ids of the groups that have pending likes on each non-inactive group, keyed by the liked group's id. */ +export async function findCurrentReceivedLikerGroupIds() { const rows = await db .selectFrom("GroupLike") .innerJoin("Group", "Group.id", "GroupLike.targetGroupId") - .select((eb) => [ - "GroupLike.targetGroupId", - eb.fn.countAll().as("count"), - ]) + .select(["GroupLike.targetGroupId", "GroupLike.likerGroupId"]) .where("Group.status", "!=", "INACTIVE") - .groupBy("GroupLike.targetGroupId") .execute(); - return new Map(rows.map((row) => [row.targetGroupId, row.count])); + const likerGroupIdsByTarget = new Map(); + for (const row of rows) { + const likerGroupIds = likerGroupIdsByTarget.get(row.targetGroupId) ?? []; + likerGroupIds.push(row.likerGroupId); + likerGroupIdsByTarget.set(row.targetGroupId, likerGroupIds); + } + + return likerGroupIdsByTarget; } export async function findAllLikesByGroupId(groupId: number) { diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts index edf94feac..7ba88256f 100644 --- a/app/features/sendouq/core/SendouQ.server.ts +++ b/app/features/sendouq/core/SendouQ.server.ts @@ -61,7 +61,7 @@ class SendouQClass { readonly #isAccurateTiers; readonly #userSkills; readonly #intervals; - readonly #receivedLikeCounts; + readonly #receivedLikerGroupIds; usersInQueue; constructor( @@ -72,9 +72,9 @@ class SendouQClass { userSkills: calculatedUserSkills, isAccurateTiers, }: Awaited>, - receivedLikeCounts: Map, + receivedLikerGroupIds: Map, ) { - this.#receivedLikeCounts = receivedLikeCounts; + this.#receivedLikerGroupIds = receivedLikerGroupIds; this.#recentMatches = recentMatches; this.#isAccurateTiers = isAccurateTiers; this.#userSkills = calculatedUserSkills; @@ -131,9 +131,18 @@ class SendouQClass { ); } - /** Pending likes/challenges the group has received, 0 when none. */ + /** Pending likes/challenges the group has received from groups it can see in the looking pool, 0 when none. */ likesReceivedCount(groupId: number) { - return this.#receivedLikeCounts.get(groupId) ?? 0; + const group = this.findUncensoredGroupById(groupId); + if (!group) return 0; + + const visibleGroupIds = new Set( + this.#suitableLookingGroupsFor(group).map((visible) => visible.id), + ); + + return (this.#receivedLikerGroupIds.get(groupId) ?? []).filter( + (likerGroupId) => visibleGroupIds.has(likerGroupId), + ).length; } /** A group by id without censoring sensitive data. */ @@ -243,6 +252,14 @@ class SendouQClass { const ownGroup = this.findOwnGroup(userId); if (!ownGroup) return []; + return this.#suitableLookingGroupsFor(ownGroup) + .map(this.#getGroupReplayMapper(userId)) + .sort(this.#getSkillSortComparator(ownGroup.tier)) + .map(this.#getAddTierRangeMapper(ownGroup.tier)) + .map((group) => this.#censorGroup(group)); + } + + #suitableLookingGroupsFor(ownGroup: SendouQClass["groups"][number]) { const currentMemberCountOptions = ownGroup.members.length === 4 ? [4] @@ -252,18 +269,13 @@ class SendouQClass { ? [1, 2] : [1, 2, 3]; - return this.groups - .filter((group) => - this.#isSuitableLookingGroup({ - group, - ownGroupId: ownGroup.id, - currentMemberCountOptions, - }), - ) - .map(this.#getGroupReplayMapper(userId)) - .sort(this.#getSkillSortComparator(ownGroup.tier)) - .map(this.#getAddTierRangeMapper(ownGroup.tier)) - .map((group) => this.#censorGroup(group)); + return this.groups.filter((group) => + this.#isSuitableLookingGroup({ + group, + ownGroupId: ownGroup.id, + currentMemberCountOptions, + }), + ); } #getGroupReplayMapper(userId: number) { @@ -588,16 +600,15 @@ export async function refreshSendouQInstance() { async function freshSendouQInstance() { const season = Seasons.currentOrPrevious(); - const [groups, recentMatches, skills, receivedLikeCounts] = await Promise.all( - [ + const [groups, recentMatches, skills, receivedLikerGroupIds] = + await Promise.all([ SQGroupRepository.findCurrentGroups(), SQGroupRepository.findRecentlyFinishedMatches(), userSkills(season!.nth), - SQGroupRepository.findCurrentReceivedLikeCounts(), - ], - ); + SQGroupRepository.findCurrentReceivedLikerGroupIds(), + ]); - return new SendouQClass(groups, recentMatches, skills, receivedLikeCounts); + return new SendouQClass(groups, recentMatches, skills, receivedLikerGroupIds); } /** Throws a redirect when the user loads a page other than the one their SendouQ group status puts them on. */ diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts index 8b58caa59..531303c98 100644 --- a/app/features/sendouq/loaders/q.looking.server.ts +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -55,7 +55,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { groups: groupsToShow, ownGroup, likes: ownGroup - ? await SQGroupRepository.findAllLikesByGroupId(ownGroup.id) + ? await likesWithVisibleReceived(ownGroup.id, groups) : { given: [], received: [], @@ -78,3 +78,18 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { ), }; }; + +/** Leaves out likes from groups the looking pool hides (e.g. gone stale), matching the header's count. */ +async function likesWithVisibleReceived( + ownGroupId: number, + groups: Array<{ id: number }>, +) { + const likes = await SQGroupRepository.findAllLikesByGroupId(ownGroupId); + + return { + ...likes, + received: likes.received.filter((like) => + groups.some((group) => group.id === like.groupId), + ), + }; +} diff --git a/changelog/2026-10-03-sq-likes-indicator-sync.md b/changelog/2026-10-03-sq-likes-indicator-sync.md new file mode 100644 index 000000000..3578caa13 --- /dev/null +++ b/changelog/2026-10-03-sq-likes-indicator-sync.md @@ -0,0 +1,5 @@ +--- +navItem: sendouq +type: bug +--- +SendouQ likes count in the header no longer includes likes from groups that have gone inactive and are not shown in the queue diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 3b997f9a3..23284ad47 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -874,8 +874,8 @@ export function buildCases(fx: Fixtures): { addStatic("SQGroupRepository.findRecentlyFinishedMatches", () => SQGroupRepository.findRecentlyFinishedMatches(), ); - addStatic("SQGroupRepository.findCurrentReceivedLikeCounts", () => - SQGroupRepository.findCurrentReceivedLikeCounts(), + addStatic("SQGroupRepository.findCurrentReceivedLikerGroupIds", () => + SQGroupRepository.findCurrentReceivedLikerGroupIds(), ); addStatic("SplatoonRotationRepository.findAll", () =>