SendouQ group suggestions

This commit is contained in:
Kalle
2026-08-07 17:23:51 +03:00
parent 35412ffe91
commit bea6d2166e
35 changed files with 698 additions and 55 deletions

View File

@@ -44,9 +44,16 @@ export const { create } = defineFactory({
},
applyOptions: async (group, { isMatchmade, likedByGroupIds }: Options) => {
for (const likerGroupId of likedByGroupIds ?? []) {
const liker = await db
.selectFrom("GroupMember")
.select("GroupMember.userId")
.where("GroupMember.groupId", "=", likerGroupId)
.executeTakeFirstOrThrow();
await SQGroupRepository.insertLike({
likerGroupId,
targetGroupId: group.id,
createdByUserId: liker.userId,
});
}

View File

@@ -312,6 +312,9 @@ export interface GroupLike {
likerGroupId: number;
targetGroupId: number;
isRechallenge: Generated<DBBoolean>;
// TODO: migrate to not null
/** Member of the liker group who sent the invite. `null` for invites sent before the column existed. */
createdByUserId: number | null;
}
export interface GroupMatch {
@@ -371,6 +374,15 @@ export interface GroupMember {
userId: number;
}
/** A group member pointing their own group at another group, without inviting it */
export interface GroupSuggestion {
createdAt: Generated<number>;
/** Group whose members see the suggestion */
suggesterGroupId: number;
targetGroupId: number;
createdByUserId: number;
}
export interface PrivateUserNote {
authorId: number;
targetId: number;
@@ -1254,6 +1266,7 @@ export interface DB {
GroupMatchContinueVote: GroupMatchContinueVote;
GroupMatchMap: GroupMatchMap;
GroupMember: GroupMember;
GroupSuggestion: GroupSuggestion;
PrivateUserNote: PrivateUserNote;
LogInLink: LogInLink;
LFGPost: LFGPost;

View File

@@ -1,10 +1,16 @@
import { add } from "date-fns";
import { beforeEach, describe, expect, test } from "vitest";
import * as SplatoonFaker from "~/db/seed/core/SplatoonFaker";
import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import * as Seasons from "~/features/mmr/core/Seasons";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import {
FULL_GROUP_SIZE,
SENDOUQ_BEST_OF,
} from "~/features/sendouq/q-constants";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { withUserId } from "~/utils/Test";
import * as SQMatchRepository from "./SQMatchRepository.server";
@@ -55,6 +61,60 @@ const fetchSkills = async (matchId: number) => {
.execute();
};
describe("insert", () => {
test("deletes the matched groups' pending likes and suggestions", async () => {
const users = await UserFactory.createMany(FULL_GROUP_SIZE * 2 + 1);
const alphaMembers = users.slice(0, FULL_GROUP_SIZE);
const bravoMembers = users.slice(FULL_GROUP_SIZE, FULL_GROUP_SIZE * 2);
const bystander = users[FULL_GROUP_SIZE * 2];
const alphaGroup = await SQGroupFactory.create({
memberUserIds: alphaMembers.map((member) => member.id),
});
const bravoGroup = await SQGroupFactory.create({
memberUserIds: bravoMembers.map((member) => member.id),
});
const bystanderGroup = await SQGroupFactory.create({
memberUserIds: [bystander.id],
});
await SQGroupRepository.insertLike({
likerGroupId: bystanderGroup.id,
targetGroupId: alphaGroup.id,
createdByUserId: bystander.id,
});
await SQGroupRepository.insertLike({
likerGroupId: bravoGroup.id,
targetGroupId: bystanderGroup.id,
createdByUserId: bravoMembers[0].id,
});
await SQGroupRepository.insertSuggestion({
suggesterGroupId: alphaGroup.id,
targetGroupId: bystanderGroup.id,
createdByUserId: alphaMembers[0].id,
});
await SQMatchRepository.insert({
alphaGroupId: alphaGroup.id,
bravoGroupId: bravoGroup.id,
mapList: SplatoonFaker.mapList(SENDOUQ_BEST_OF).map((map) => ({
...map,
source: "BOTH" as const,
})),
memento: { users: {}, groups: {}, pools: [] },
});
const likes = await db.selectFrom("GroupLike").selectAll().execute();
const suggestions = await db
.selectFrom("GroupSuggestion")
.selectAll()
.execute();
expect(likes).toHaveLength(0);
expect(suggestions).toHaveLength(0);
});
});
describe("lockMatchWithoutSkillChange", () => {
test("inserts dummy skill to lock match", async () => {
const { match } = await setupMatch();

View File

@@ -612,6 +612,16 @@ export function insert({
await syncGroupTeamId(alphaGroupId, trx);
await syncGroupTeamId(bravoGroupId, trx);
// both groups are locked into this match, so anything pending is moot
await SQGroupRepository.deleteLikesAndSuggestionsByGroupId(
alphaGroupId,
trx,
);
await SQGroupRepository.deleteLikesAndSuggestionsByGroupId(
bravoGroupId,
trx,
);
await validateCreatedMatch(trx, alphaGroupId, bravoGroupId);
return match;

View File

@@ -1,8 +1,11 @@
import { sub } from "date-fns";
import { describe, expect, test } from "vitest";
import { backdate } from "~/db/seed/core/backdate";
import * as GroupMatchContinueVoteFactory from "~/db/seed/factories/GroupMatchContinueVoteFactory";
import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server";
import { FULL_GROUP_SIZE } from "./q-constants";
import * as SQGroupRepository from "./SQGroupRepository.server";
@@ -128,3 +131,42 @@ describe("insertMember", () => {
expect(chatCodeToRevalidate).toBe(matchChatCode);
});
});
describe("setOldGroupsAsInactive", () => {
test("deletes the expired groups' likes and suggestions", async () => {
const [expiring, other] = await UserFactory.createMany(2);
const expiringGroup = await SQGroupFactory.create({
memberUserIds: [expiring.id],
});
const otherGroup = await SQGroupFactory.create({
memberUserIds: [other.id],
});
await SQGroupRepository.insertLike({
likerGroupId: otherGroup.id,
targetGroupId: expiringGroup.id,
createdByUserId: other.id,
});
await SQGroupRepository.insertSuggestion({
suggesterGroupId: expiringGroup.id,
targetGroupId: otherGroup.id,
createdByUserId: expiring.id,
});
await backdate("Group", expiringGroup.id, {
latestActionAt: sub(new Date(), { hours: 2 }),
});
await SQGroupRepository.setOldGroupsAsInactive();
const likes = await db.selectFrom("GroupLike").selectAll().execute();
const suggestions = await db
.selectFrom("GroupSuggestion")
.selectAll()
.execute();
expect(likes).toHaveLength(0);
expect(suggestions).toHaveLength(0);
});
});

View File

@@ -197,6 +197,31 @@ function deleteLikesByGroupId(groupId: number, trx: Transaction<DB>) {
.execute();
}
function deleteSuggestionsByGroupId(groupId: number, trx: Transaction<DB>) {
return trx
.deleteFrom("GroupSuggestion")
.where((eb) =>
eb.or([
eb("GroupSuggestion.suggesterGroupId", "=", groupId),
eb("GroupSuggestion.targetGroupId", "=", groupId),
]),
)
.execute();
}
/**
* Deletes every like and suggestion where the given group is on either side.
* Called when the group's pending challenges and suggestions stop being
* actionable e.g. its roster changed or it started a match.
*/
export async function deleteLikesAndSuggestionsByGroupId(
groupId: number,
trx: Transaction<DB>,
) {
await deleteLikesByGroupId(groupId, trx);
await deleteSuggestionsByGroupId(groupId, trx);
}
export function morphGroups({
survivingGroupId,
otherGroupId,
@@ -218,7 +243,7 @@ export function morphGroups({
.where("GroupMember.groupId", "=", otherGroupId)
.execute();
await deleteLikesByGroupId(survivingGroupId, trx);
await deleteLikesAndSuggestionsByGroupId(survivingGroupId, trx);
await refreshGroup(survivingGroupId, trx);
await trx
@@ -280,7 +305,7 @@ export async function insertMember(
})
.execute();
await deleteLikesByGroupId(groupId, trx);
await deleteLikesAndSuggestionsByGroupId(groupId, trx);
if (!(await isGroupCorrect(groupId, trx))) {
throw new SendouQError(
@@ -297,10 +322,12 @@ export async function insertMember(
export async function findAllLikesByGroupId(groupId: number) {
const rows = await db
.selectFrom("GroupLike")
.leftJoin("User", "User.id", "GroupLike.createdByUserId")
.select([
"GroupLike.likerGroupId",
"GroupLike.targetGroupId",
"GroupLike.isRechallenge",
"User.username as createdByUsername",
])
.where((eb) =>
eb.or([
@@ -316,6 +343,7 @@ export async function findAllLikesByGroupId(groupId: number) {
.map((row) => ({
groupId: row.targetGroupId,
isRechallenge: row.isRechallenge,
createdByUsername: row.createdByUsername,
})),
received: rows
.filter((row) => row.targetGroupId === groupId)
@@ -326,6 +354,25 @@ export async function findAllLikesByGroupId(groupId: number) {
};
}
/** Suggestions the given group's members have made to each other, newest first. */
export async function findAllSuggestionsByGroupId(groupId: number) {
const rows = await db
.selectFrom("GroupSuggestion")
.innerJoin("User", "User.id", "GroupSuggestion.createdByUserId")
.select([
"GroupSuggestion.targetGroupId",
"User.username as createdByUsername",
])
.where("GroupSuggestion.suggesterGroupId", "=", groupId)
.orderBy("GroupSuggestion.createdAt", "desc")
.execute();
return rows.map((row) => ({
groupId: row.targetGroupId,
createdByUsername: row.createdByUsername,
}));
}
export function rechallenge({
likerGroupId,
targetGroupId,
@@ -428,14 +475,32 @@ export async function setOldGroupsAsInactive() {
.where("latestActionAt", "<", dateToDatabaseTimestamp(oneHourAgo))
.execute();
const groupIds = groupsToSetInactive.map((g) => g.id);
await trx
.deleteFrom("GroupLike")
.where((eb) =>
eb.or([
eb("GroupLike.likerGroupId", "in", groupIds),
eb("GroupLike.targetGroupId", "in", groupIds),
]),
)
.execute();
await trx
.deleteFrom("GroupSuggestion")
.where((eb) =>
eb.or([
eb("GroupSuggestion.suggesterGroupId", "in", groupIds),
eb("GroupSuggestion.targetGroupId", "in", groupIds),
]),
)
.execute();
return trx
.updateTable("Group")
.set({ status: "INACTIVE" })
.where(
"Group.id",
"in",
groupsToSetInactive.map((g) => g.id),
)
.where("Group.id", "in", groupIds)
.executeTakeFirst();
});
}
@@ -554,15 +619,17 @@ export async function findRecentlyFinishedMatches() {
export function insertLike({
likerGroupId,
targetGroupId,
createdByUserId,
}: {
likerGroupId: number;
targetGroupId: number;
createdByUserId: number;
}) {
return db.transaction().execute(async (trx) => {
try {
await trx
.insertInto("GroupLike")
.values({ likerGroupId, targetGroupId })
.values({ likerGroupId, targetGroupId, createdByUserId })
.onConflict((oc) =>
oc.columns(["likerGroupId", "targetGroupId"]).doNothing(),
)
@@ -574,10 +641,47 @@ export function insertLike({
throw error;
}
// inviting says everything the suggestion was there to say
await trx
.deleteFrom("GroupSuggestion")
.where("suggesterGroupId", "=", likerGroupId)
.where("targetGroupId", "=", targetGroupId)
.execute();
await refreshGroup(likerGroupId, trx);
});
}
/** Marks a group as worth a look for the suggester's own teammates. Suggesting twice is a no-op. */
export function insertSuggestion({
suggesterGroupId,
targetGroupId,
createdByUserId,
}: {
suggesterGroupId: number;
targetGroupId: number;
createdByUserId: number;
}) {
return db.transaction().execute(async (trx) => {
try {
await trx
.insertInto("GroupSuggestion")
.values({ suggesterGroupId, targetGroupId, createdByUserId })
.onConflict((oc) =>
oc.columns(["suggesterGroupId", "targetGroupId"]).doNothing(),
)
.execute();
} catch (error) {
if (errorIsSqliteForeignKeyConstraintFailure(error)) {
throw new SendouQError(error.message);
}
throw error;
}
await refreshGroup(suggesterGroupId, trx);
});
}
export function deleteLike({
likerGroupId,
targetGroupId,

View File

@@ -13,7 +13,7 @@ import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.ser
import { parseFormData } from "~/form/parse.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
import { groupAfterMorph } from "../core/groups";
import { canSuggest, groupAfterMorph } from "../core/groups";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
import { lookingSchema } from "../q-action-schemas";
import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants";
@@ -68,6 +68,7 @@ export const action: ActionFunction = async ({ request }) => {
await SQGroupRepository.insertLike({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
createdByUserId: user.id,
});
notifyLikeReceived(data.targetGroupId);
@@ -75,6 +76,32 @@ export const action: ActionFunction = async ({ request }) => {
break;
}
case "SUGGEST": {
if (!canSuggest(currentGroup)) return null;
const targetIsInPool = SendouQ.lookingGroups(user.id).some(
(group) => group.id === data.targetGroupId,
);
if (!targetIsInPool) return null;
// a group we already invited needs no pointing out
const likes = await SQGroupRepository.findAllLikesByGroupId(
currentGroup.id,
);
if (likes.given.some((like) => like.groupId === data.targetGroupId)) {
return null;
}
await SQGroupRepository.insertSuggestion({
suggesterGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
createdByUserId: user.id,
});
revalidateGroupTopic(currentGroup.id);
break;
}
case "RECHALLENGE": {
await SQGroupRepository.rechallenge({
likerGroupId: currentGroup.id,

View File

@@ -61,6 +61,16 @@ function createGroup(
};
}
/** A group whose members are censored, as full groups are in the looking page. */
function createFullGroup(
overrides: Partial<Omit<SQGroup, "members">> = {},
): SQGroup {
const group = createGroup(overrides);
group.members = undefined;
return group;
}
type OwnGroupMember = SQOwnGroup["members"][number];
function createOwnGroupMember(
@@ -276,10 +286,9 @@ describe("GroupCard", () => {
};
const screen = await renderGroupCard({
group: createGroup({
group: createFullGroup({
tierRange,
tier: null,
members: undefined,
}),
});
@@ -316,9 +325,8 @@ describe("GroupCard", () => {
};
const screen = await renderGroupCard({
group: createGroup({
group: createFullGroup({
skillDifference,
members: undefined,
}),
});
@@ -345,12 +353,8 @@ describe("GroupCard", () => {
test("shows Challenge for LIKE with full group (no visible members)", async () => {
const ownGroup = createOwnGroup({ id: 2 });
// Create a group with members explicitly set to undefined (censored/full group)
const fullGroup = createGroup({});
fullGroup.members = undefined;
const screen = await renderGroupCard({
group: fullGroup,
group: createFullGroup(),
action: "LIKE",
ownGroup,
displayOnly: false,
@@ -364,7 +368,7 @@ describe("GroupCard", () => {
const ownGroup = createOwnGroup({ id: 2 });
const screen = await renderGroupCard({
group: createGroup({ members: undefined }),
group: createFullGroup(),
action: "MATCH_UP",
ownGroup,
displayOnly: false,
@@ -375,6 +379,58 @@ describe("GroupCard", () => {
});
});
describe("trail", () => {
test("shows suggested trail for a partial group", async () => {
const screen = await renderGroupCard({
group: createGroup({ members: [createMember()] }),
trail: { type: "SUGGESTED", username: "Suggester" },
});
const trail = screen.getByTestId("group-card-trail");
await expect.element(trail).toHaveTextContent("Suggested by Suggester");
});
test("shows suggested trail for a full group", async () => {
const screen = await renderGroupCard({
group: createFullGroup(),
trail: { type: "SUGGESTED", username: "Suggester" },
});
const trail = screen.getByTestId("group-card-trail");
await expect.element(trail).toHaveTextContent("Suggested by Suggester");
});
test("shows invited trail for a partial group", async () => {
const screen = await renderGroupCard({
group: createGroup({ members: [createMember()] }),
trail: { type: "INVITED", username: "Inviter" },
});
const trail = screen.getByTestId("group-card-trail");
await expect.element(trail).toHaveTextContent("Invited by Inviter");
});
test("shows challenged trail for a full group", async () => {
const screen = await renderGroupCard({
group: createFullGroup(),
trail: { type: "INVITED", username: "Challenger" },
});
const trail = screen.getByTestId("group-card-trail");
await expect.element(trail).toHaveTextContent("Challenged by Challenger");
});
test("renders no trail when not given", async () => {
const screen = await renderGroupCard({
group: createGroup({ members: [createMember()] }),
});
await expect
.element(screen.getByTestId("group-card-trail"))
.not.toBeInTheDocument();
});
});
describe("props", () => {
test("hides VC when hideVc=1", async () => {
const screen = await renderGroupCard({

View File

@@ -3,7 +3,7 @@ import type { SqlBool } from "kysely";
import { Mic, Volume2, VolumeX } from "lucide-react";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { Avatar } from "~/components/Avatar";
@@ -41,9 +41,17 @@ import { updateGroupNoteSchema } from "../q-schemas";
import { resolveFutureMatchModes } from "../q-utils";
import styles from "./GroupCard.module.css";
/** Who in the viewer's own group acted on the shown group, and how. */
export type GroupCardTrail = {
type: "INVITED" | "SUGGESTED";
username: string;
};
export function GroupCard({
group,
action,
suggestable = false,
trail,
displayOnly = false,
hideVc = false,
hideWeapons = false,
@@ -53,6 +61,9 @@ export function GroupCard({
}: {
group: SQGroup | SQOwnGroup;
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE";
/** Can the viewer point their own teammates at this group? */
suggestable?: boolean;
trail?: GroupCardTrail;
displayOnly?: boolean;
hideVc?: SqlBool;
hideWeapons?: SqlBool;
@@ -194,32 +205,83 @@ export function GroupCard({
{group.skillDifference ? (
<GroupSkillDifference skillDifference={group.skillDifference} />
) : null}
{action ? (
<ActionButton
schema={lookingSchema}
action={action === "MATCH_UP_RECHALLENGE" ? "MATCH_UP" : action}
fields={{ targetGroupId: group.id }}
formClassName="stack items-center"
size="small"
variant={action === "UNLIKE" ? "destructive" : "outlined"}
testId="group-card-action-button"
>
{action === "MATCH_UP" || action === "MATCH_UP_RECHALLENGE"
? t("q:looking.groups.actions.startMatch")
: action === "LIKE" && !group.members
? t("q:looking.groups.actions.challenge")
: action === "LIKE"
? t("q:looking.groups.actions.invite")
: action === "GROUP_UP"
? t("q:looking.groups.actions.groupUp")
: t("q:looking.groups.actions.undo")}
</ActionButton>
{action || suggestable || trail ? (
<div className="stack xs items-center">
<div className="stack sm horizontal items-center justify-center">
{action ? (
<ActionButton
schema={lookingSchema}
action={
action === "MATCH_UP_RECHALLENGE" ? "MATCH_UP" : action
}
fields={{ targetGroupId: group.id }}
size="small"
variant={action === "UNLIKE" ? "destructive" : undefined}
testId="group-card-action-button"
>
{action === "MATCH_UP" || action === "MATCH_UP_RECHALLENGE"
? t("q:looking.groups.actions.startMatch")
: action === "LIKE" && !group.members
? t("q:looking.groups.actions.challenge")
: action === "LIKE"
? t("q:looking.groups.actions.invite")
: action === "GROUP_UP"
? t("q:looking.groups.actions.groupUp")
: t("q:looking.groups.actions.undo")}
</ActionButton>
) : null}
{suggestable ? (
<ActionButton
schema={lookingSchema}
action="SUGGEST"
fields={{ targetGroupId: group.id }}
size="small"
variant="outlined"
testId="group-card-suggest-button"
>
{t("q:looking.groups.actions.suggest")}
</ActionButton>
) : null}
</div>
{trail ? (
<GroupCardTrailText trail={trail} isFullGroup={!group.members} />
) : null}
</div>
) : null}
</section>
</GroupCardContainer>
);
}
function GroupCardTrailText({
trail,
isFullGroup,
}: {
trail: GroupCardTrail;
isFullGroup: boolean;
}) {
const { t } = useTranslation(["q"]);
const i18nKey = () => {
if (trail.type === "SUGGESTED") return "q:looking.groups.trail.suggested";
return isFullGroup
? "q:looking.groups.trail.challenged"
: "q:looking.groups.trail.invited";
};
return (
<div className="text-xxs text-lighter mt-1" data-testid="group-card-trail">
<Trans
t={t}
i18nKey={i18nKey()}
values={{ username: trail.username }}
components={[<span key="username" className="font-bold" />]}
/>
</div>
);
}
function GroupCardContainer({
isOwnGroup,
groupId,

View File

@@ -32,6 +32,14 @@ export function groupAfterMorph({
return ourGroup;
}
/**
* Whether the group's members can suggest other groups to each other. A suggestion
* is a pointer for teammates, so a solo group has no one to point at anything.
*/
export function canSuggest(group: { members: unknown[] }) {
return group.members.length > 1;
}
export function groupExpiryStatus(
latestActionAt: number,
): GroupExpiryStatus | null {

View File

@@ -52,6 +52,9 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
given: [],
received: [],
},
suggestions: ownGroup
? await SQGroupRepository.findAllSuggestionsByGroupId(ownGroup.id)
: [],
lastUpdated: Date.now(),
streamsCount: (await cachedStreams()).length,
chatCode:

View File

@@ -36,6 +36,10 @@ export const lookingSchema = z.union([
_action: _action("UNLIKE"),
targetGroupId: id,
}),
z.object({
_action: _action("SUGGEST"),
targetGroupId: id,
}),
z.object({
_action: _action("GROUP_UP"),
targetGroupId: id,

View File

@@ -33,10 +33,10 @@ import {
SENDOUQ_STREAMS_PAGE,
} from "~/utils/urls";
import { action } from "../actions/q.looking.server";
import { GroupCard } from "../components/GroupCard";
import { GroupCard, type GroupCardTrail } from "../components/GroupCard";
import { GroupLeaver } from "../components/GroupLeaver";
import { MemberAdder } from "../components/MemberAdder";
import { groupExpiryStatus } from "../core/groups";
import { canSuggest, groupExpiryStatus } from "../core/groups";
import { loader } from "../loaders/q.looking.server";
import { lookingSchema } from "../q-action-schemas";
import {
@@ -236,7 +236,37 @@ function Groups() {
const isFullGroup =
data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE;
const groups = sortGroupsByPrivateNoteSentiment(data.groups, data.userCards);
const suggestedByUsernames = new Map(
data.suggestions.map((suggestion) => [
suggestion.groupId,
suggestion.createdByUsername,
]),
);
const invitedByUsernames = new Map(
data.likes.given.map((like) => [like.groupId, like.createdByUsername]),
);
const groups = sortGroups(data.groups, {
userCards: data.userCards,
suggestedGroupIds: new Set(suggestedByUsernames.keys()),
});
const trailOf = (groupId: number): GroupCardTrail | undefined => {
const invitedBy = invitedByUsernames.get(groupId);
if (invitedBy) return { type: "INVITED", username: invitedBy };
const suggestedBy = suggestedByUsernames.get(groupId);
if (suggestedBy) return { type: "SUGGESTED", username: suggestedBy };
return undefined;
};
const canSuggestGroups = Boolean(data.ownGroup && canSuggest(data.ownGroup));
// a group already invited or suggested has nothing left to point out
const isSuggestable = (groupId: number) =>
canSuggestGroups &&
!invitedByUsernames.has(groupId) &&
!suggestedByUsernames.has(groupId);
const invitedGroupsDesktop = (
<div className="stack sm">
@@ -257,6 +287,7 @@ function Groups() {
key={group.id}
group={group}
action="UNLIKE"
trail={trailOf(group.id)}
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -358,6 +389,8 @@ function Groups() {
? "UNLIKE"
: "LIKE"
}
suggestable={isSuggestable(group.id)}
trail={trailOf(group.id)}
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -385,6 +418,8 @@ function Groups() {
key={group.id}
group={group}
action={action()}
suggestable={isSuggestable(group.id)}
trail={trailOf(group.id)}
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -422,6 +457,8 @@ function Groups() {
key={group.id}
group={group}
action={action()}
suggestable={isSuggestable(group.id)}
trail={trailOf(group.id)}
ownGroup={data.ownGroup}
layout={layout}
/>
@@ -435,15 +472,22 @@ function Groups() {
}
/**
* Floats groups the viewer has a positive private note on up and groups with a
* negative note down, while preserving the server's tier/activity ordering
* within each sentiment bucket and keeping full (censored) groups last. The
* note sentiment is read from the already-loaded `userCards` data so the server
* does not need to attach notes to group members.
* Floats groups a teammate suggested to the very top, then groups the viewer has
* a positive private note on up and groups with a negative note down, while
* preserving the server's tier/activity ordering within each bucket and keeping
* full (censored) groups last. The note sentiment is read from the already-loaded
* `userCards` data so the server does not need to attach notes to group members.
*/
function sortGroupsByPrivateNoteSentiment<
T extends { members?: { id: number }[] },
>(groups: T[], userCards: Map<number, UserCardData>): T[] {
function sortGroups<T extends { id: number; members?: { id: number }[] }>(
groups: T[],
{
userCards,
suggestedGroupIds,
}: {
userCards: Map<number, UserCardData>;
suggestedGroupIds: Set<number>;
},
): T[] {
const sentimentScore = (group: T) => {
if (!group.members) return 0;
@@ -458,6 +502,10 @@ function sortGroupsByPrivateNoteSentiment<
};
return groups.toSorted((a, b) => {
const aIsSuggested = suggestedGroupIds.has(a.id);
const bIsSuggested = suggestedGroupIds.has(b.id);
if (aIsSuggested !== bIsSuggested) return aIsSuggested ? -1 : 1;
const aIsFull = !a.members;
const bIsFull = !b.members;
if (aIsFull !== bIsFull) return aIsFull ? 1 : -1;

View File

@@ -250,9 +250,16 @@ async function retryPost(
throw new Error(`${name}: unreachable`);
}
export async function submit(page: Page, testId?: string) {
/** Clicks a submit button and waits for the POST it fires. Takes a locator when
* the test id alone is ambiguous, e.g. one button per card on a list page. */
export async function submit(page: Page, target?: string | Locator) {
const button =
typeof target === "object"
? target
: page.getByTestId(target ?? "submit-button");
await waitForPOSTResponse(page, async () => {
await page.getByTestId(testId ?? "submit-button").click();
await button.click();
});
// Toast flash params are stripped right after via a replace navigation

View File

@@ -1,15 +1,30 @@
import type { Locator } from "@playwright/test";
import { submit } from "../../helpers/playwright";
import { UserCard } from "../user/user-card";
export class GroupCard {
readonly root: Locator;
readonly members: Locator;
readonly actionButton: Locator;
readonly suggestButton: Locator;
/** Note of who in the own group invited or suggested this group. */
readonly trail: Locator;
constructor(root: Locator) {
this.root = root;
this.members = root.getByTestId("sendouq-group-card-member");
this.actionButton = root.getByTestId("group-card-action-button");
this.suggestButton = root.getByTestId("group-card-suggest-button");
this.trail = root.getByTestId("group-card-trail");
}
/** Challenges or invites the group, accepts what it offered, or undoes either. */
pressAction() {
return submit(this.root.page(), this.actionButton);
}
pressSuggest() {
return submit(this.root.page(), this.suggestButton);
}
openMemberCard(name: string) {

View File

@@ -12,6 +12,7 @@ export class SendouQLookingPage {
this.locators = {
groupCards: page.getByTestId("sendouq-group-card"),
undoButtons: page.getByRole("button", { name: "Undo" }),
suggestButtons: page.getByTestId("group-card-suggest-button"),
};
}

View File

@@ -1,10 +1,11 @@
import { sub } from "date-fns";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import {
SENDOUQ_LOOKING_PAGE,
SENDOUQ_PAGE,
SENDOUQ_PREPARING_PAGE,
} from "~/utils/urls";
import { expect, impersonate, test } from "./helpers/playwright";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { SendouQLookingPage } from "./pages/sendouq/sendouq-looking-page";
import { SendouQPage } from "./pages/sendouq/sendouq-page";
import { MatchProfilePage } from "./pages/settings/match-profile-page";
@@ -117,6 +118,72 @@ test.describe("SendouQ", () => {
await expect(looking.locators.undoButtons).toHaveCount(0);
});
test("Suggesting floats a group to the top and leaves a trail for the group", async ({
page,
factories,
}) => {
const owner = await factories.UserFactory.create({
discordName: "Owner",
profile: null,
});
const teammate = await factories.UserFactory.create({
discordName: "Teammate",
profile: null,
});
const target = await factories.UserFactory.create({
discordName: "Target",
profile: null,
});
const other = await factories.UserFactory.create({
discordName: "Other",
profile: null,
});
await factories.SQGroupFactory.create({
memberUserIds: [owner.id, teammate.id],
});
const suggestedGroup = await factories.SQGroupFactory.create({
memberUserIds: [target.id],
});
await factories.SQGroupFactory.create({ memberUserIds: [other.id] });
// the least recently active group sorts last, so the suggestion has somewhere to move it from
await factories.backdate("Group", suggestedGroup.id, {
latestActionAt: sub(new Date(), { minutes: 10 }),
});
await impersonate(page, owner.id);
const looking = new SendouQLookingPage(page);
await looking.goto();
// the own group's card is always the first one
await expect(looking.groupCard(2).root).toContainText("Target");
await looking.groupCard(2).pressSuggest();
await expect(looking.groupCard(1).root).toContainText("Target");
await expect(looking.groupCard(1).trail).toHaveText("Suggested by Owner");
// a suggestion can't be undone or repeated
await isNotVisible(looking.groupCard(1).suggestButton);
// the whole group sees the suggestion, and any of them can act on it
await impersonate(page, teammate.id);
await looking.goto();
await expect(looking.groupCard(1).trail).toHaveText("Suggested by Owner");
await looking.groupCard(1).pressAction();
// inviting says everything the suggestion said, so it takes the trail over
await expect(looking.groupCard(1).trail).toHaveText("Invited by Teammate");
await isNotVisible(looking.groupCard(1).suggestButton);
// a solo queuer has no teammates to suggest anything to
await impersonate(page, other.id);
await looking.goto();
await isNotVisible(looking.locators.suggestButtons);
});
test("Joining the queue is blocked when the season's initial powers were never seeded", async ({
page,
factories,

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "",
"looking.groups.actions.challenge": "",
"looking.groups.actions.invite": "",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "",
"looking.groups.adder.error": "",
"looking.groups.adder.others": "",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "",
"looking.groups.actions.challenge": "",
"looking.groups.actions.invite": "",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "",
"looking.groups.adder.error": "",
"looking.groups.adder.others": "",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "Start match",
"looking.groups.actions.challenge": "Challenge",
"looking.groups.actions.invite": "Invite",
"looking.groups.actions.suggest": "Suggest",
"looking.groups.actions.groupUp": "Group up",
"looking.groups.actions.undo": "Undo",
"looking.groups.actions.giveManager": "Give manager",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "Remove {{name}} from LFG?",
"looking.groups.actions.leaveQ": "Leave queue",
"looking.groups.actions.goBack": "Go back",
"looking.groups.trail.invited": "Invited by <0>{{username}}</0>",
"looking.groups.trail.challenged": "Challenged by <0>{{username}}</0>",
"looking.groups.trail.suggested": "Suggested by <0>{{username}}</0>",
"looking.groups.adder.quickAdd": "Quick add",
"looking.groups.adder.error": "That user is already in a group",
"looking.groups.adder.others": "Others",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "Comenzar partido",
"looking.groups.actions.challenge": "Desafiar",
"looking.groups.actions.invite": "Invitar",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "Crear grupo",
"looking.groups.actions.undo": "Deshacer",
"looking.groups.actions.giveManager": "Hacer mánager",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "¿Eliminar a {{name}} de LFG?",
"looking.groups.actions.leaveQ": "Dejar fila",
"looking.groups.actions.goBack": "Regresar",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "Añadir rápido",
"looking.groups.adder.error": "Ese usuario ya está en un grupo",
"looking.groups.adder.others": "Otros",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "Comenzar partido",
"looking.groups.actions.challenge": "Desafiar",
"looking.groups.actions.invite": "Invitar",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "Crear grupo",
"looking.groups.actions.undo": "Deshacer",
"looking.groups.actions.giveManager": "Hacer mánager",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "Dejar fila",
"looking.groups.actions.goBack": "Regresar",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "Añadir rápido",
"looking.groups.adder.error": "Ese usuario ya está en un grupo",
"looking.groups.adder.others": "",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "",
"looking.groups.actions.challenge": "",
"looking.groups.actions.invite": "",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "",
"looking.groups.adder.error": "",
"looking.groups.adder.others": "",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "Commencé le match",
"looking.groups.actions.challenge": "Challenge",
"looking.groups.actions.invite": "Invite",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "Accepté",
"looking.groups.actions.undo": "Annulé",
"looking.groups.actions.giveManager": "Promouvoir",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "Quitter la queue",
"looking.groups.actions.goBack": "Retourner en arrière",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "Ajouter rapidement",
"looking.groups.adder.error": "Cet utilisateur est déjà dans un groupe",
"looking.groups.adder.others": "Autres",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "",
"looking.groups.actions.challenge": "",
"looking.groups.actions.invite": "",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "",
"looking.groups.adder.error": "",
"looking.groups.adder.others": "",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "Inizia match",
"looking.groups.actions.challenge": "Sfida",
"looking.groups.actions.invite": "Invita",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "Forma un gruppo",
"looking.groups.actions.undo": "Annulla",
"looking.groups.actions.giveManager": "Dai manager",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "Lascia coda",
"looking.groups.actions.goBack": "Indietro",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "Aggiungi direttamente",
"looking.groups.adder.error": "L'utente è già in un gruppo",
"looking.groups.adder.others": "Altri",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "マッチ開始",
"looking.groups.actions.challenge": "挑戦",
"looking.groups.actions.invite": "招待する",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "グループに分かれる",
"looking.groups.actions.undo": "戻す",
"looking.groups.actions.giveManager": "マネージャーにあげる",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "列から出る",
"looking.groups.actions.goBack": "戻る",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "クイック追加",
"looking.groups.adder.error": "そのユーザーはグループに入っています",
"looking.groups.adder.others": "その他",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "",
"looking.groups.actions.challenge": "",
"looking.groups.actions.invite": "",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "",
"looking.groups.adder.error": "",
"looking.groups.adder.others": "",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "",
"looking.groups.actions.challenge": "",
"looking.groups.actions.invite": "",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "",
"looking.groups.adder.error": "",
"looking.groups.adder.others": "",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "",
"looking.groups.actions.challenge": "",
"looking.groups.actions.invite": "",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "",
"looking.groups.actions.undo": "",
"looking.groups.actions.giveManager": "",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "",
"looking.groups.actions.goBack": "",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "",
"looking.groups.adder.error": "",
"looking.groups.adder.others": "",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "Iniciar partida",
"looking.groups.actions.challenge": "Desafiar",
"looking.groups.actions.invite": "Convidar",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "Se juntar",
"looking.groups.actions.undo": "Desfazer",
"looking.groups.actions.giveManager": "Dar gerência",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "Sair da fila",
"looking.groups.actions.goBack": "Voltar",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "Adicionar rapidamente (quick add)",
"looking.groups.adder.error": "Esse usuário já está em um grupo",
"looking.groups.adder.others": "",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "Начать матч",
"looking.groups.actions.challenge": "Бросить вызов",
"looking.groups.actions.invite": "Пригласить",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "Вступить в группу",
"looking.groups.actions.undo": "Отменить",
"looking.groups.actions.giveManager": "Дать роль менеджера",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "",
"looking.groups.actions.leaveQ": "Покинуть очередь",
"looking.groups.actions.goBack": "Вернуться назад",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "Быстро добавить",
"looking.groups.adder.error": "Этот пользователь сейчас в другой группе",
"looking.groups.adder.others": "Другие",

View File

@@ -62,6 +62,7 @@
"looking.groups.actions.startMatch": "开始对战",
"looking.groups.actions.challenge": "挑战",
"looking.groups.actions.invite": "邀请",
"looking.groups.actions.suggest": "",
"looking.groups.actions.groupUp": "组队",
"looking.groups.actions.undo": "撤销",
"looking.groups.actions.giveManager": "授予管理者权限",
@@ -73,6 +74,9 @@
"looking.groups.actions.organizerRemove.confirm": "要将 {{name}} 移出组队吗?",
"looking.groups.actions.leaveQ": "停止匹配",
"looking.groups.actions.goBack": "返回",
"looking.groups.trail.invited": "",
"looking.groups.trail.challenged": "",
"looking.groups.trail.suggested": "",
"looking.groups.adder.quickAdd": "快速添加",
"looking.groups.adder.error": "该用户已加入其他小组",
"looking.groups.adder.others": "其他",

View File

@@ -0,0 +1,40 @@
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.createTable("GroupSuggestion")
.addColumn("suggesterGroupId", "integer", (col) =>
col.notNull().references("Group.id").onDelete("cascade"),
)
.addColumn("targetGroupId", "integer", (col) =>
col.notNull().references("Group.id").onDelete("cascade"),
)
.addColumn("createdByUserId", "integer", (col) =>
col.notNull().references("User.id").onDelete("cascade"),
)
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
.addUniqueConstraint(
"group_suggestion_suggester_group_id_target_group_id",
["suggesterGroupId", "targetGroupId"],
)
// every table in this schema is strict
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("group_suggestion_target_group_id")
.on("GroupSuggestion")
.column("targetGroupId")
.execute();
await trx.schema
.alterTable("GroupLike")
.addColumn("createdByUserId", "integer", (col) =>
col.references("User.id").onDelete("set null"),
)
.execute();
});
}

View File

@@ -584,6 +584,11 @@ export function buildCases(fx: Fixtures): {
add("SQGroupRepository.findAllLikesByGroupId", fx.heavyGroupIds, (groupIds) =>
SQGroupRepository.findAllLikesByGroupId(groupIds[0]),
);
add(
"SQGroupRepository.findAllSuggestionsByGroupId",
fx.heavyGroupIds,
(groupIds) => SQGroupRepository.findAllSuggestionsByGroupId(groupIds[0]),
);
add("SQGroupRepository.findFriendsAndTeammates", fx.sq, (sq) =>
SQGroupRepository.findFriendsAndTeammates(sq.userId),
);