Skip to content

Commit

Permalink
feat: update sort score
Browse files Browse the repository at this point in the history
- MIN(√Best#,10) + Best% + (Rec% * 0.8)
- take average when more than one
- refactor score calc to service
- add unit tests
  • Loading branch information
David Horm committed Mar 21, 2023
1 parent d67a73a commit 9c08b4e
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ describe(transformToBoardGame.name, () => {
"RecommendedPercent": 16,
"playerCountValue": 1,
"playerCountLabel": "1",
"sortScore": 102,
},
{
"Best": 5,
Expand All @@ -130,7 +129,6 @@ describe(transformToBoardGame.name, () => {
"RecommendedPercent": 57,
"playerCountValue": 2,
"playerCountLabel": "2",
"sortScore": 57,
},
{
"Best": 0,
Expand All @@ -141,7 +139,6 @@ describe(transformToBoardGame.name, () => {
"RecommendedPercent": 29,
"playerCountValue": 3,
"playerCountLabel": "3",
"sortScore": -34,
},
{
"Best": 0,
Expand All @@ -152,7 +149,6 @@ describe(transformToBoardGame.name, () => {
"RecommendedPercent": 12,
"playerCountValue": 4,
"playerCountLabel": "4",
"sortScore": -61,
},
{
"Best": 0,
Expand All @@ -163,7 +159,6 @@ describe(transformToBoardGame.name, () => {
"RecommendedPercent": 0,
"playerCountValue": 5,
"playerCountLabel": "4+",
"sortScore": -80,
},
],
};
Expand Down
15 changes: 0 additions & 15 deletions src/components/BggCollection/hooks/useGetCollectionQuery.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,6 @@ const calcPercentAndAddSortScore = (
const RecommendedPercent = Math.round((Recommended * 100) / totalVotes);
const NotRecommendedPercent = Math.round((notRec * 100) / totalVotes);

const maybeSortScore = Math.round(
Math.min(Math.sqrt(Best), 10) +
BestPercent +
RecommendedPercent * 0.8 -
NotRecommendedPercent * 0.8
);

const sortScore =
totalVotes === 0 || Number.isNaN(maybeSortScore)
? Number.NEGATIVE_INFINITY
: maybeSortScore;

return {
...playerRec,

Expand All @@ -147,9 +135,6 @@ const calcPercentAndAddSortScore = (

/** Percentage of users not recommending this player count. */
NotRecommendedPercent,

/** 2xBest Raw + Best % + Recommended Raw + Recommended % - Not Recommended Row - Not Recommended % */
sortScore,
};
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CollectionFilterState, SimpleBoardGame } from "@/types";
import { CollectionFilterState, SimpleBoardGame, BoardGame } from "@/types";
import { g } from "vitest/dist/index-761e769b";
import { numberSort, SortFn } from "./sort.service";

/** Used to determine which bar to highlight in the graph */
Expand All @@ -21,22 +22,37 @@ const addIsPlayerCountWithinRange =
}));
};

const calcSortScoreSum = (
game: SimpleBoardGame,
minRange: number,
maxRange: number
): number =>
game.recommendedPlayerCount
.filter(
(g) => minRange <= g.playerCountValue && g.playerCountValue <= maxRange
)
.reduce((prev, curr) => curr.sortScore + prev, 0);

const sort: SortFn = (dir, a, b, filterState) => {
const [minRange, maxRange] = filterState.playerCountRange;

const valueA = calcSortScoreSum(a, minRange, maxRange);
const valueB = calcSortScoreSum(b, minRange, maxRange);
/**
* MIN(√Best#,10) + Best% + (Rec% * 0.8)
* See [v1.1.3 in this reply](https://boardgamegeek.com/thread/3028512/article/41805927#41805927) for more details why this formula.
*/
const calcSortScore = ({
Best,
BestPercent,
RecommendedPercent,
}: BoardGame["recommendedPlayerCount"][number]) => {
const maybeSortScore = Math.round(
Math.min(Math.sqrt(Best), 10) + BestPercent + RecommendedPercent * 0.8
);

return Number.isNaN(maybeSortScore)
? Number.NEGATIVE_INFINITY
: maybeSortScore;
};

const calcSortScoreAverage = (game: BoardGame): number => {
const sortScores = game.recommendedPlayerCount
.filter((g) => g.isPlayerCountWithinRange)
.map(calcSortScore);

const sortScoreSum = sortScores.reduce((a, b) => a + b, 0);

return sortScoreSum / sortScores.length;
};

const sort: SortFn = (dir, a, b) => {
const valueA = calcSortScoreAverage(a);
const valueB = calcSortScoreAverage(b);

return numberSort(dir, valueA, valueB);
};
Expand Down
137 changes: 133 additions & 4 deletions src/services/filter-sort-services/useCollectionFilters.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import type { SimpleBoardGame } from "@/types";
import type { BoardGame, SimpleBoardGame } from "@/types";
import { cleanup, render, screen } from "@testing-library/react";
import { describe, test, expect, afterEach } from "vitest";
import {
Expand Down Expand Up @@ -319,6 +319,9 @@ describe(applyFiltersAndSorts.name, () => {

afterEach(() => cleanup());

// When creating a list of mock games to later sort, then create them in the initial 2-3-1 order
const DEFAULT_GAME_NAMES = ["2", "3", "1"];

const GIVEN_GAME_PROPS: Record<string, Partial<SimpleBoardGame>[]> = {
"name": ["second", "third", "first"].map((name) => ({ name })),
"same min max playtime": [20, 30, 10].map((time) => ({
Expand All @@ -334,7 +337,6 @@ describe(applyFiltersAndSorts.name, () => {
"averageRating": [2, 3, 1].map((averageRating) => ({ averageRating })),
};

// TODO: add test cases for Player Count Recommendation
// TODO: add test cases for user ratings

test.each`
Expand All @@ -352,9 +354,8 @@ describe(applyFiltersAndSorts.name, () => {
`(
"GIVEN games ordered 2-3-1, WHEN sortBys=$sortBys, THEN expectedOrder=$expectedOrder",
async ({ gamesProps, sortBys, expectedOrder }) => {
const DEFAULT_NAMES = ["2", "3", "1"];
const games = (gamesProps as Partial<SimpleBoardGame>[]).map(
(game, i) => buildMockGame({ name: DEFAULT_NAMES[i], ...game })
(game, i) => buildMockGame({ name: DEFAULT_GAME_NAMES[i], ...game })
);

render(<MockComponent games={games} sortBys={sortBys} />);
Expand All @@ -365,5 +366,133 @@ describe(applyFiltersAndSorts.name, () => {
expect(actualOrder).toEqual(expectedOrder);
}
);

/**
* Build mock recommendedPlayerCount
* @param [Best, Recommended, NotRecommended, playerCountValue?]
*/
const buildMockRecs = ([
Best,
Recommended,
NotRecommended,
playerCountValue = 1,
]: number[]): BoardGame["recommendedPlayerCount"][number] => {
const sum = Best + Recommended + NotRecommended;

return {
Best,
Recommended,
"Not Recommended": 0 - NotRecommended,
"isPlayerCountWithinRange": true,
playerCountValue,
"playerCountLabel": playerCountValue.toString(),
"BestPercent": Math.round((Best / sum) * 100),
"RecommendedPercent": Math.round((Recommended / sum) * 100),
"NotRecommendedPercent": Math.round((NotRecommended / sum) * 100),
};
};

const GIVEN_PLAYER_RECS: Record<string, Partial<SimpleBoardGame>[]> = {
"best is best": [
[1, 2, 1],
[2, 1, 1],
[1, 1, 2],
].map((recs, i) =>
buildMockGame({
name: DEFAULT_GAME_NAMES[i],
recommendedPlayerCount: [buildMockRecs(recs)],
})
),

/**
* Just because a game has 100% Best votes doesn't mean it should rank to the top. Give bonus points to those with more points.
*
* See up to [v1.1.3 explanation](https://boardgamegeek.com/thread/3028512/article/41805927#41805927) for more reason why.
*/
"bonus points for number of votes": [
[1300, 400, 40], // Catan (13)
[350, 20, 2], // Galaxy Trucker (31481)
[6, 0, 0], // Orient Express (2363) // TODO: fix so [10,0,0] => 1st when ASC
].map((recs, i) =>
buildMockGame({
name: DEFAULT_GAME_NAMES[i],
recommendedPlayerCount: [buildMockRecs(recs)],
})
),

"average ranges": [
[
[1, 1, 1],
[1, 2, 1],
],
[
[1, 1, 1],
[2, 1, 1],
],
[
[1, 1, 1],
[1, 1, 2],
],
].map((recs, i) =>
buildMockGame({
name: DEFAULT_GAME_NAMES[i],
maxPlayers: recs.length,
recommendedPlayerCount: recs.map((r, j) =>
buildMockRecs([...r, j + 1])
),
})
),

"party game ranges": [
[
[15, 90, 67],
[14, 82, 76],
], // Deception (156129)
[
[3, 45, 32],
[11, 45, 26],
], // Wavelength (262543)
[
[0, 3, 7],
[3, 4, 3],
], // Electronic Catch Phrase (135262)
].map((recs, i) =>
buildMockGame({
name: DEFAULT_GAME_NAMES[i],
maxPlayers: recs.length,
recommendedPlayerCount: recs.map((r, j) =>
buildMockRecs([...r, j + 1])
),
})
),
};

test.each`
givenScenario | sortBys | expectedOrder
${"best is best"} | ${["Player Count Recommendation"]} | ${["3", "2", "1"]}
${"best is best"} | ${["Player Count Recommendation", "Player Count Recommendation"]} | ${["1", "2", "3"]}
${"bonus points for number of votes"} | ${["Player Count Recommendation"]} | ${["3", "2", "1"]}
${"bonus points for number of votes"} | ${["Player Count Recommendation", "Player Count Recommendation"]} | ${["1", "2", "3"]}
${"average ranges"} | ${["Player Count Recommendation"]} | ${["3", "2", "1"]}
${"average ranges"} | ${["Player Count Recommendation", "Player Count Recommendation"]} | ${["1", "2", "3"]}
${"party game ranges"} | ${["Player Count Recommendation"]} | ${["3", "2", "1"]}
${"party game ranges"} | ${["Player Count Recommendation", "Player Count Recommendation"]} | ${["1", "2", "3"]}
`(
"GIVEN $givenScenario with games ordered 2-3-1, WHEN sortBys=$sortBys, THEN expectedOrder=$expectedOrder",
async ({ givenScenario, sortBys, expectedOrder }) => {
render(
<MockComponent
games={GIVEN_PLAYER_RECS[givenScenario] as any}
sortBys={sortBys}
/>
);

const actual = await screen.getByTestId(`actual`)?.innerHTML;

const actualOrder = JSON.parse(actual).map((g: any) => g.name);

expect(actualOrder).toEqual(expectedOrder);
}
);
});
});

0 comments on commit 9c08b4e

Please sign in to comment.