Skip to content

Commit 00a104d

Browse files
Fix featured list channel sorting (#2559)
* create channels with 12 resources by default * fix featured list sorting
1 parent acb530f commit 00a104d

File tree

4 files changed

+167
-34
lines changed

4 files changed

+167
-34
lines changed

frontends/api/src/hooks/learningResources/queries.ts

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
LearningResourcesSearchResponse,
2121
} from "../../generated/v1"
2222
import { queryOptions } from "@tanstack/react-query"
23+
import { hasPosition, randomizeGroups } from "./util"
2324

2425
/* List memberships were previously determined in the learningResourcesApi
2526
* from user_list_parents and learning_path_parents on each resource.
@@ -37,35 +38,6 @@ export const clearListMemberships = (
3738
learning_path_parents: [],
3839
})
3940

40-
const shuffle = ([...arr]) => {
41-
let m = arr.length
42-
while (m) {
43-
const i = Math.floor(Math.random() * m--)
44-
;[arr[m], arr[i]] = [arr[i], arr[m]]
45-
}
46-
return arr
47-
}
48-
49-
const randomizeResults = ([...results]) => {
50-
const resultsByPosition: {
51-
[position: string]: (LearningResource & { position?: string })[] | undefined
52-
} = {}
53-
const randomizedResults: LearningResource[] = []
54-
results.forEach((result) => {
55-
if (!resultsByPosition[result?.position]) {
56-
resultsByPosition[result?.position] = []
57-
}
58-
resultsByPosition[result?.position ?? ""]?.push(result)
59-
})
60-
Object.keys(resultsByPosition)
61-
.sort()
62-
.forEach((position) => {
63-
const shuffled = shuffle(resultsByPosition[position] ?? [])
64-
randomizedResults.push(...shuffled)
65-
})
66-
return randomizedResults
67-
}
68-
6941
const learningResourceKeys = {
7042
root: ["learning_resources"],
7143
// list
@@ -183,11 +155,17 @@ const learningResourceQueries = {
183155
queryKey: learningResourceKeys.featured(params),
184156
queryFn: () =>
185157
featuredApi.featuredList(params).then((res) => {
158+
const results = res.data.results
159+
const withPosition = results.filter(hasPosition)
160+
if (withPosition.length !== results.length) {
161+
// Should not happen. The featured API always sets position.
162+
console.warn(
163+
"Some featured results are missing position information.",
164+
)
165+
}
186166
return {
187167
...res.data,
188-
results: randomizeResults(
189-
res.data.results.map(clearListMemberships),
190-
),
168+
results: randomizeGroups(withPosition),
191169
}
192170
}),
193171
}),
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { faker } from "@faker-js/faker/locale/en"
2+
import { randomizeGroups, hasPosition } from "./util"
3+
4+
faker.seed(12345) // Seed faker for consistent test results
5+
jest
6+
.spyOn(Math, "random")
7+
.mockImplementation(() => faker.number.float({ min: 0, max: 1 }))
8+
9+
describe("randomizeGroups", () => {
10+
it("should group by position and randomize within groups with duplicates", () => {
11+
const items = [
12+
{ id: "a1", position: 1 },
13+
{ id: "a2", position: 1 },
14+
{ id: "b1", position: 12 },
15+
{ id: "b2", position: 12 },
16+
{ id: "c1", position: 2 },
17+
{ id: "c2", position: 2 },
18+
{ id: "d1", position: 3 },
19+
]
20+
21+
const result = randomizeGroups(items)
22+
23+
// Should be grouped by position in numerical order
24+
expect(result[0].position).toBe(1)
25+
expect(result[1].position).toBe(1)
26+
expect(result[2].position).toBe(2)
27+
expect(result[3].position).toBe(2)
28+
expect(result[4].position).toBe(3)
29+
expect(result[5].position).toBe(12)
30+
expect(result[6].position).toBe(12)
31+
32+
// Should contain all items
33+
expect(result).toHaveLength(7)
34+
expect(result.map((item) => item.id).sort()).toEqual([
35+
"a1",
36+
"a2",
37+
"b1",
38+
"b2",
39+
"c1",
40+
"c2",
41+
"d1",
42+
])
43+
})
44+
45+
it("should handle positions greater than 10 correctly (avoid lexicographical sorting)", () => {
46+
const items = [
47+
{ id: "item15", position: 15 },
48+
{ id: "item2", position: 2 },
49+
{ id: "item11", position: 11 },
50+
{ id: "item1", position: 1 },
51+
{ id: "item20", position: 20 },
52+
]
53+
54+
const result = randomizeGroups(items)
55+
56+
// Should be numerically sorted: 1, 2, 11, 15, 20
57+
// NOT lexicographically sorted: 1, 11, 15, 2, 20
58+
const positions = result.map((item) => item.position)
59+
expect(positions).toEqual([1, 2, 11, 15, 20])
60+
})
61+
62+
it("should handle empty array", () => {
63+
const items: Array<{ id: string; position: number }> = []
64+
const result = randomizeGroups(items)
65+
expect(result).toEqual([])
66+
})
67+
})
68+
69+
describe("hasPosition", () => {
70+
it("should return true for objects with non-null position", () => {
71+
const obj = { id: "test", position: 5 }
72+
expect(hasPosition(obj)).toBe(true)
73+
})
74+
75+
it("should return false for objects with null position", () => {
76+
const obj = { id: "test", position: null }
77+
expect(hasPosition(obj)).toBe(false)
78+
})
79+
80+
it("should return true for objects with position 0", () => {
81+
const obj = { id: "test", position: 0 }
82+
expect(hasPosition(obj)).toBe(true)
83+
})
84+
85+
it("should act as a type guard", () => {
86+
const obj: { id: string; position: number | null } = {
87+
id: "test",
88+
position: 5,
89+
}
90+
91+
if (hasPosition(obj)) {
92+
// TypeScript should now know that obj.position is number, not number | null
93+
const position: number = obj.position
94+
expect(position).toBe(5)
95+
}
96+
})
97+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const hasPosition = <T extends { position: number | null }>(
2+
r: T,
3+
): r is T & { position: number } => r.position !== null
4+
5+
const shuffle = ([...arr]) => {
6+
let m = arr.length
7+
while (m) {
8+
const i = Math.floor(Math.random() * m--)
9+
;[arr[m], arr[i]] = [arr[i], arr[m]]
10+
}
11+
return arr
12+
}
13+
14+
/**
15+
* Randomize a group of ordered items, where item positions might be duplicated.
16+
* Ordering is preserved between groups, but randomized within groups.
17+
*
18+
* E.g., given the items
19+
* [
20+
* { id: 1, position: 1 },
21+
* { id: 2, position: 1 },
22+
* { id: 3, position: 2 },
23+
* { id: 4, position: 3 },
24+
* { id: 5, position: 3 },
25+
* { id: 6, position: 3 },
26+
* ]
27+
*
28+
* The results would be:
29+
* [
30+
* ...items with position 1 in random order...,
31+
* ...items with position 2 in random order...,
32+
* ...items with position 3 in random order...
33+
* ]
34+
*/
35+
const randomizeGroups = <T extends { position: number }>(results: T[]): T[] => {
36+
const resultsByPosition: {
37+
[position: string]: T[] | undefined
38+
} = {}
39+
const randomizedResults: T[] = []
40+
results.forEach((result) => {
41+
const pos = result?.position
42+
if (!resultsByPosition[pos]) {
43+
resultsByPosition[pos] = []
44+
}
45+
resultsByPosition[pos]?.push(result)
46+
})
47+
Object.keys(resultsByPosition)
48+
.sort(
49+
(a, b) => Number(a) - Number(b), // Sort positions numerically
50+
)
51+
.forEach((position) => {
52+
const shuffled = shuffle(resultsByPosition[position] ?? [])
53+
randomizedResults.push(...shuffled)
54+
})
55+
return randomizedResults
56+
}
57+
58+
export { randomizeGroups, hasPosition }

learning_resources/management/commands/populate_featured_lists.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def add_arguments(self, parser):
3131
"resource_count",
3232
nargs="?",
3333
type=int,
34-
default=10,
34+
default=12,
3535
help="Set the number of courses per featured list (default is 10)",
3636
)
3737

@@ -44,7 +44,7 @@ def handle(self, *args, **options): # noqa: ARG002
4444
self.stdout.write("Creating featured list for each featured offeror channel")
4545

4646
start = now_in_utc()
47-
resource_count = options.get("resource_count", 10)
47+
resource_count = options["resource_count"]
4848
for offeror in LearningResourceOfferor.objects.all():
4949
self.stdout.write(f"Creating featured list for {offeror.name} channel")
5050

0 commit comments

Comments
 (0)