diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx
index 25b9e72f7..fba3a5178 100644
--- a/app/features/sendouq/components/GroupCard.tsx
+++ b/app/features/sendouq/components/GroupCard.tsx
@@ -158,6 +158,18 @@ export function GroupCard({
{group.tier.isPlus ? "+" : ""}
) : null}
+ {group.tierRange ? (
+
+
+ ±{group.tierRange.diff}
+
+
+
+ —
+
+
+
+ ) : null}
{group.skillDifference ? (
) : null}
diff --git a/app/features/sendouq/core/groups.server.test.ts b/app/features/sendouq/core/groups.server.test.ts
new file mode 100644
index 000000000..836b4a4cf
--- /dev/null
+++ b/app/features/sendouq/core/groups.server.test.ts
@@ -0,0 +1,92 @@
+import { suite } from "uvu";
+import * as assert from "uvu/assert";
+import { tierDifferenceToRangeOrExact } from "./groups.server";
+
+const paramsToExpected = new Map<
+ [
+ Parameters[0]["ourTier"],
+ Parameters[0]["theirTier"],
+ ],
+ ReturnType["tier"]
+>()
+ // exact
+ .set(
+ [
+ { isPlus: false, name: "GOLD" },
+ { isPlus: false, name: "GOLD" },
+ ],
+ { isPlus: false, name: "GOLD" },
+ )
+ // 1 place difference
+ .set(
+ [
+ { isPlus: false, name: "GOLD" },
+ { isPlus: true, name: "GOLD" },
+ ],
+ [
+ { isPlus: true, name: "SILVER" },
+ { isPlus: true, name: "GOLD" },
+ ],
+ )
+ // 2 places difference
+ .set(
+ [
+ { isPlus: false, name: "GOLD" },
+ { isPlus: false, name: "PLATINUM" },
+ ],
+ [
+ { isPlus: false, name: "SILVER" },
+ { isPlus: false, name: "PLATINUM" },
+ ],
+ )
+ // too high, has to be exact
+ .set(
+ [
+ { isPlus: true, name: "LEVIATHAN" },
+ { isPlus: false, name: "LEVIATHAN" },
+ ],
+ { isPlus: false, name: "LEVIATHAN" },
+ )
+ // too low, has to be exact
+ .set(
+ [
+ { isPlus: false, name: "IRON" },
+ { isPlus: true, name: "IRON" },
+ ],
+ { isPlus: true, name: "IRON" },
+ )
+ // not max rank but still too high
+ .set(
+ [
+ { isPlus: false, name: "LEVIATHAN" },
+ { isPlus: false, name: "DIAMOND" },
+ ],
+ { isPlus: false, name: "DIAMOND" },
+ );
+
+const TierDifferenceToRangeOrExact = suite("tierDifferenceToRangeOrExact()");
+
+for (const [input, expected] of paramsToExpected) {
+ TierDifferenceToRangeOrExact(
+ `works for ${JSON.stringify(input)} -> ${JSON.stringify(expected)}`,
+ () => {
+ const result = tierDifferenceToRangeOrExact({
+ ourTier: input[0],
+ theirTier: input[1],
+ hasLeviathan: true,
+ }).tier;
+ assert.equal(result, expected);
+ },
+ );
+}
+
+TierDifferenceToRangeOrExact("works before leviathan", () => {
+ const result = tierDifferenceToRangeOrExact({
+ ourTier: { isPlus: true, name: "DIAMOND" },
+ theirTier: { isPlus: false, name: "DIAMOND" },
+ hasLeviathan: false,
+ }).tier;
+ assert.equal(result, { isPlus: false, name: "DIAMOND" });
+});
+
+TierDifferenceToRangeOrExact.run();
diff --git a/app/features/sendouq/core/groups.server.ts b/app/features/sendouq/core/groups.server.ts
index bbe76a0c1..16903b433 100644
--- a/app/features/sendouq/core/groups.server.ts
+++ b/app/features/sendouq/core/groups.server.ts
@@ -1,3 +1,4 @@
+import clone from "just-clone";
import type { Tables } from "~/db/tables";
import type { Group } from "~/db/types";
import { TIERS } from "~/features/mmr/mmr-constants";
@@ -304,21 +305,6 @@ export function sortGroupsBySkillAndSentiment({
return {
...groups,
neutral: groups.neutral.sort((a, b) => {
- const aTier =
- a.tier?.name ??
- resolveGroupSkill({
- group: a as LookingGroupWithInviteCode,
- userSkills,
- intervals,
- })?.name;
- const bTier =
- b.tier?.name ??
- resolveGroupSkill({
- group: b as LookingGroupWithInviteCode,
- userSkills,
- intervals,
- })?.name;
-
const aSentiment = groupSentiment(a);
const bSentiment = groupSentiment(b);
@@ -329,6 +315,16 @@ export function sortGroupsBySkillAndSentiment({
if (bSentiment === "POSITIVE") return 1;
}
+ const aDiff = a.tierRange?.diff ?? 0;
+ const bDiff = b.tierRange?.diff ?? 0;
+
+ if (aDiff || bDiff) {
+ return aDiff - bDiff;
+ }
+
+ const aTier = a.tier?.name;
+ const bTier = b.tier?.name;
+
const aTierDiff = tierDiff(aTier);
const bTierDiff = tierDiff(bTier);
@@ -375,6 +371,36 @@ export function addSkillsToGroups({
};
}
+const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const;
+export function addSkillRangeToGroups({
+ groups,
+ hasLeviathan,
+}: { groups: DividedGroups; hasLeviathan: boolean }) {
+ const addRange = (group: LookingGroup) => {
+ if (group.members && group.members.length !== FULL_GROUP_SIZE) return group;
+
+ const range = tierDifferenceToRangeOrExact({
+ ourTier: groups.own?.tier ?? FALLBACK_TIER,
+ theirTier: group.tier ?? FALLBACK_TIER,
+ hasLeviathan,
+ });
+
+ if (!Array.isArray(range.tier)) return group;
+
+ return {
+ ...group,
+ tierRange: { range: range.tier, diff: range.diff },
+ tier: undefined,
+ };
+ };
+
+ return {
+ own: groups.own,
+ neutral: groups.neutral.map(addRange),
+ likesReceived: groups.likesReceived.map(addRange),
+ };
+}
+
export function membersNeededForFull(currentSize: number) {
return FULL_GROUP_SIZE - currentSize;
}
@@ -395,14 +421,11 @@ function resolveGroupSkill({
const averageOrdinal =
skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length;
- const tier = intervals.find(
- (i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal,
- ) ?? { isPlus: false, name: "IRON" };
-
- // For Leviathan we don't specify if it's plus or not
- return tier.name === "LEVIATHAN"
- ? { name: "LEVIATHAN", isPlus: false }
- : { name: tier.name, isPlus: tier.isPlus };
+ return (
+ intervals.find(
+ (i) => i.neededOrdinal && averageOrdinal > i.neededOrdinal,
+ ) ?? { isPlus: false, name: "IRON" }
+ );
}
export function groupExpiryStatus(
@@ -428,3 +451,51 @@ export function groupExpiryStatus(
return null;
}
+
+const allTiersOrdered = TIERS.flatMap((tier) => [
+ { name: tier.name, isPlus: true },
+ { name: tier.name, isPlus: false },
+]).reverse();
+export function tierDifferenceToRangeOrExact({
+ ourTier,
+ theirTier,
+ hasLeviathan,
+}: {
+ ourTier: TieredSkill["tier"];
+ theirTier: TieredSkill["tier"];
+ hasLeviathan: boolean;
+}): {
+ diff: number;
+ tier: TieredSkill["tier"] | [TieredSkill["tier"], TieredSkill["tier"]];
+} {
+ if (ourTier.name === theirTier.name && ourTier.isPlus === theirTier.isPlus) {
+ return { diff: 0, tier: clone(ourTier) };
+ }
+
+ const tiers = hasLeviathan
+ ? allTiersOrdered
+ : allTiersOrdered.filter((tier) => tier.name !== "LEVIATHAN");
+
+ const tier1Idx = tiers.findIndex(
+ (t) => t.name === ourTier.name && t.isPlus === ourTier.isPlus,
+ );
+ const tier2Idx = tiers.findIndex(
+ (t) => t.name === theirTier.name && t.isPlus === theirTier.isPlus,
+ );
+ invariant(tier1Idx !== -1, "tier1 not found");
+ invariant(tier2Idx !== -1, "tier2 not found");
+
+ const idxDiff = Math.abs(tier1Idx - tier2Idx);
+
+ const lowerBound = tier1Idx - idxDiff;
+ const upperBound = tier1Idx + idxDiff;
+
+ if (lowerBound < 0 || upperBound >= tiers.length) {
+ return { diff: idxDiff, tier: clone(theirTier) };
+ }
+
+ const lowerTier = tiers[lowerBound];
+ const upperTier = tiers[upperBound];
+
+ return { diff: idxDiff, tier: [clone(lowerTier), clone(upperTier)] };
+}
diff --git a/app/features/sendouq/q-types.ts b/app/features/sendouq/q-types.ts
index 4f5d148cf..56d8fea29 100644
--- a/app/features/sendouq/q-types.ts
+++ b/app/features/sendouq/q-types.ts
@@ -14,6 +14,10 @@ export type LookingGroup = {
id: number;
createdAt: Group["createdAt"];
tier?: TieredSkill["tier"];
+ tierRange?: {
+ range: [TieredSkill["tier"], TieredSkill["tier"]];
+ diff: number;
+ };
isReplay?: boolean;
isNoScreen?: boolean;
isLiked?: boolean;
diff --git a/app/features/sendouq/q.css b/app/features/sendouq/q.css
index af7c8864d..4418d7685 100644
--- a/app/features/sendouq/q.css
+++ b/app/features/sendouq/q.css
@@ -318,6 +318,10 @@
transform: translate(-50%, -50%);
}
+.q__group__tier-diff-text {
+ font-size: 3rem;
+}
+
.q__member-adder__input {
--input-width: 11rem;
width: 11rem;
diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx
index 71a3d87e2..bdc694a3e 100644
--- a/app/features/sendouq/routes/q.looking.tsx
+++ b/app/features/sendouq/routes/q.looking.tsx
@@ -52,6 +52,7 @@ import {
addFutureMatchModes,
addNoScreenIndicator,
addReplayIndicator,
+ addSkillRangeToGroups,
addSkillsToGroups,
censorGroups,
divideGroups,
@@ -455,9 +456,11 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const season = currentOrPreviousSeason(new Date());
- const { intervals, userSkills: calculatedUserSkills } = userSkills(
- season!.nth,
- );
+ const {
+ intervals,
+ userSkills: calculatedUserSkills,
+ isAccurateTiers,
+ } = userSkills(season!.nth);
const groupsWithSkills = addSkillsToGroups({
groups: dividedGroups,
intervals,
@@ -485,8 +488,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
: false,
});
- const sortedGroups = sortGroupsBySkillAndSentiment({
+ const rangedGroups = addSkillRangeToGroups({
groups: censoredGroups,
+ hasLeviathan: isAccurateTiers,
+ });
+
+ const sortedGroups = sortGroupsBySkillAndSentiment({
+ groups: rangedGroups,
intervals,
userSkills: calculatedUserSkills,
userId: user?.id,