Skip to content

Commit f6d9b7c

Browse files
fehmerMiodec
andauthored
impr: lazy load chartData on results (@fehmer) (monkeytypegame#6428)
Optimize results endpoint by removing heavy or unused data. We load the whole result chart data for up to 1000 results each time, but it is very unlikely the user will view the charts for all old results. By removing the size in my tests went down from 1152kb to 276kb. --------- Co-authored-by: Miodec <[email protected]>
1 parent cd4d72b commit f6d9b7c

File tree

9 files changed

+236
-22
lines changed

9 files changed

+236
-22
lines changed

backend/__tests__/api/controllers/result.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,87 @@ describe("result controller test", () => {
333333
).toBeRateLimited({ max: 30, windowMs: 24 * 60 * 60 * 1000 });
334334
});
335335
});
336+
describe("getResultById", () => {
337+
const getResultMock = vi.spyOn(ResultDal, "getResult");
338+
339+
afterEach(() => {
340+
getResultMock.mockReset();
341+
});
342+
343+
it("should get result", async () => {
344+
//GIVEN
345+
const result = givenDbResult(uid);
346+
getResultMock.mockResolvedValue(result);
347+
348+
//WHEN
349+
const { body } = await mockApp
350+
.get(`/results/id/${result._id}`)
351+
.set("Authorization", `Bearer ${uid}`)
352+
.send()
353+
.expect(200);
354+
355+
//THEN
356+
expect(body.message).toEqual("Result retrieved");
357+
expect(body.data).toEqual({ ...result, _id: result._id.toHexString() });
358+
});
359+
it("should get last result with ape key", async () => {
360+
//GIVEN
361+
await acceptApeKeys(true);
362+
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
363+
const result = givenDbResult(uid);
364+
getResultMock.mockResolvedValue(result);
365+
366+
//WHEN
367+
await mockApp
368+
.get(`/results/id/${result._id}`)
369+
.set("Authorization", `ApeKey ${apeKey}`)
370+
.send()
371+
.expect(200);
372+
});
373+
it("should get last result with legacy values", async () => {
374+
//GIVEN
375+
const result = givenDbResult(uid, {
376+
charStats: undefined,
377+
incorrectChars: 5,
378+
correctChars: 12,
379+
});
380+
getResultMock.mockResolvedValue(result);
381+
382+
//WHEN
383+
const { body } = await mockApp
384+
.get(`/results/id/${result._id}`)
385+
.set("Authorization", `Bearer ${uid}`)
386+
.send()
387+
.expect(200);
388+
389+
//THEN
390+
expect(body.message).toEqual("Result retrieved");
391+
expect(body.data).toMatchObject({
392+
_id: result._id.toHexString(),
393+
charStats: [12, 5, 0, 0],
394+
});
395+
expect(body.data).not.toHaveProperty("correctChars");
396+
expect(body.data).not.toHaveProperty("incorrectChars");
397+
});
398+
it("should rate limit get result with ape key", async () => {
399+
//GIVEN
400+
const result = givenDbResult(uid, {
401+
charStats: undefined,
402+
incorrectChars: 5,
403+
correctChars: 12,
404+
});
405+
getResultMock.mockResolvedValue(result);
406+
await acceptApeKeys(true);
407+
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
408+
409+
//WHEN
410+
await expect(
411+
mockApp
412+
.get(`/results/id/${result._id}`)
413+
.set("Authorization", `ApeKey ${apeKey}`)
414+
).toBeRateLimited({ max: 60, windowMs: 60 * 60 * 1000 });
415+
});
416+
});
336417
describe("getLastResult", () => {
337418
const getLastResultMock = vi.spyOn(ResultDal, "getLastResult");
338419

backend/src/api/controllers/result.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import {
3737
AddResultRequest,
3838
AddResultResponse,
3939
GetLastResultResponse,
40+
GetResultByIdPath,
41+
GetResultByIdResponse,
4042
GetResultsQuery,
4143
GetResultsResponse,
4244
UpdateResultTagsRequest,
@@ -131,12 +133,22 @@ export async function getResults(
131133
return new MonkeyResponse("Results retrieved", results.map(convertResult));
132134
}
133135

136+
export async function getResultById(
137+
req: MonkeyRequest<undefined, undefined, GetResultByIdPath>
138+
): Promise<GetResultByIdResponse> {
139+
const { uid } = req.ctx.decodedToken;
140+
const { resultId } = req.params;
141+
142+
const result = await ResultDAL.getResult(uid, resultId);
143+
return new MonkeyResponse("Result retrieved", convertResult(result));
144+
}
145+
134146
export async function getLastResult(
135147
req: MonkeyRequest
136148
): Promise<GetLastResultResponse> {
137149
const { uid } = req.ctx.decodedToken;
138-
const results = await ResultDAL.getLastResult(uid);
139-
return new MonkeyResponse("Result retrieved", convertResult(results));
150+
const result = await ResultDAL.getLastResult(uid);
151+
return new MonkeyResponse("Result retrieved", convertResult(result));
140152
}
141153

142154
export async function deleteAll(req: MonkeyRequest): Promise<MonkeyResponse> {

backend/src/api/routes/results.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export default s.router(resultsContract, {
88
get: {
99
handler: async (r) => callController(ResultController.getResults)(r),
1010
},
11+
getById: {
12+
handler: async (r) => callController(ResultController.getResultById)(r),
13+
},
1114
add: {
1215
handler: async (r) => callController(ResultController.addResult)(r),
1316
},

backend/src/dal/result.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,23 @@ export async function getResults(
100100
): Promise<DBResult[]> {
101101
const { onOrAfterTimestamp, offset, limit } = opts ?? {};
102102
let query = getResultCollection()
103-
.find({
104-
uid,
105-
...(!_.isNil(onOrAfterTimestamp) &&
106-
!_.isNaN(onOrAfterTimestamp) && {
107-
timestamp: { $gte: onOrAfterTimestamp },
108-
}),
109-
})
103+
.find(
104+
{
105+
uid,
106+
...(!_.isNil(onOrAfterTimestamp) &&
107+
!_.isNaN(onOrAfterTimestamp) && {
108+
timestamp: { $gte: onOrAfterTimestamp },
109+
}),
110+
},
111+
{
112+
projection: {
113+
chartData: 0,
114+
keySpacingStats: 0,
115+
keyDurationStats: 0,
116+
name: 0,
117+
},
118+
}
119+
)
110120
.sort({ timestamp: -1 });
111121

112122
if (limit !== undefined) {

frontend/src/styles/account.scss

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,12 +330,19 @@
330330
margin: 0 0.1rem;
331331
}
332332
.miniResultChartButton {
333-
opacity: 0.25;
334333
transition: 0.25s;
335334
cursor: pointer;
335+
color: var(--text-color);
336336
&:hover {
337337
opacity: 1;
338338
}
339+
&.loading {
340+
pointer-events: none;
341+
}
342+
&.disabled .fas {
343+
opacity: 0.5;
344+
color: var(--sub-color);
345+
}
339346
}
340347
}
341348

frontend/src/ts/pages/account.ts

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { ResultFiltersGroupItem } from "@monkeytype/contracts/schemas/users";
3838
import { findLineByLeastSquares } from "../utils/numbers";
3939
import defaultResultFilters from "../constants/default-result-filters";
4040
import { SnapshotResult } from "../constants/default-snapshot";
41+
import Ape from "../ape";
4142

4243
let filterDebug = false;
4344
//toggle filterdebug
@@ -105,12 +106,10 @@ function loadMoreLines(lineIndex?: number): void {
105106
)}" data-balloon-pos="up"><i class="fas fa-gamepad"></i></span>`;
106107
}
107108

108-
if (result.chartData === undefined) {
109-
icons += `<span class="miniResultChartButton" aria-label="No chart data found" data-balloon-pos="up"><i class="fas fa-chart-line"></i></span>`;
110-
} else if (result.chartData === "toolong") {
111-
icons += `<span class="miniResultChartButton" aria-label="Chart history is not available for long tests" data-balloon-pos="up"><i class="fas fa-chart-line"></i></span>`;
109+
if (result.chartData === "toolong" || result.testDuration > 122) {
110+
icons += `<span class="miniResultChartButton disabled" aria-label="Graph history is not available for long tests" data-balloon-pos="up"><i class="fas fa-fw fa-chart-line"></i></span>`;
112111
} else {
113-
icons += `<span class="miniResultChartButton" aria-label="View graph" data-balloon-pos="up" filteredResultsId="${i}" style="opacity: 1"><i class="fas fa-chart-line"></i></span>`;
112+
icons += `<span class="miniResultChartButton" aria-label="View graph" data-balloon-pos="up" filteredResultsId="${i}"><i class="fas fa-fw fa-chart-line"></i></span>`;
114113
}
115114

116115
let tagNames = "no tags";
@@ -1152,13 +1151,64 @@ $(".pageAccount #accountHistoryChart").on("click", () => {
11521151
$(`#result-${index}`).addClass("active");
11531152
});
11541153

1155-
$(".pageAccount").on("click", ".miniResultChartButton", (event) => {
1156-
console.log("updating");
1157-
const filteredId = $(event.currentTarget).attr("filteredResultsId");
1154+
$(".pageAccount").on("click", ".miniResultChartButton", async (event) => {
1155+
const target = $(event.currentTarget);
1156+
if (target.hasClass("loading")) return;
1157+
if (target.hasClass("disabled")) return;
1158+
1159+
const filteredId = target.attr("filteredResultsId");
11581160
if (filteredId === undefined) return;
1159-
MiniResultChartModal.show(
1160-
filteredResults[parseInt(filteredId)]?.chartData as ChartData
1161-
);
1161+
1162+
const result = filteredResults[parseInt(filteredId)];
1163+
if (result === undefined) return;
1164+
1165+
let chartData = result.chartData as ChartData;
1166+
1167+
if (chartData === undefined) {
1168+
//need to load full result
1169+
target.addClass("loading");
1170+
target.attr("aria-label", null);
1171+
target.html('<i class="fas fa-fw fa-spin fa-circle-notch"></i>');
1172+
Loader.show();
1173+
1174+
const response = await Ape.results.getById({
1175+
params: { resultId: result._id },
1176+
});
1177+
Loader.hide();
1178+
1179+
target.html('<i class="fas fa-fw fa-chart-line"></i>');
1180+
target.removeClass("loading");
1181+
1182+
if (response.status !== 200) {
1183+
Notifications.add("Error fetching result: " + response.body.message, -1);
1184+
return;
1185+
}
1186+
1187+
chartData = response.body.data.chartData as ChartData;
1188+
1189+
//update local cache
1190+
result.chartData = chartData;
1191+
const dbResult = DB.getSnapshot()?.results?.find(
1192+
(it) => it._id === result._id
1193+
);
1194+
if (dbResult !== undefined) {
1195+
dbResult["chartData"] = result.chartData;
1196+
}
1197+
1198+
if (response.body.data.chartData === "toolong") {
1199+
target.attr(
1200+
"aria-label",
1201+
"Graph history is not available for long tests"
1202+
);
1203+
target.attr("data-baloon-pos", "up");
1204+
target.addClass("disabled");
1205+
1206+
Notifications.add("Graph history is not available for long tests", 0);
1207+
return;
1208+
}
1209+
}
1210+
target.attr("aria-label", "View graph");
1211+
MiniResultChartModal.show(chartData);
11621212
});
11631213

11641214
$(".pageAccount .group.history").on("click", ".history-wpm-header", () => {

packages/contracts/src/rate-limit/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,18 @@ export const limits = {
138138
max: 30,
139139
},
140140

141+
// Result by id
142+
resultByIdGet: {
143+
window: "hour",
144+
max: 300,
145+
},
146+
147+
// Result by id
148+
resultByIdGetApe: {
149+
window: "hour",
150+
max: 60,
151+
},
152+
141153
resultsAdd: {
142154
window: "hour",
143155
max: 300,

packages/contracts/src/results.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import {
1010
CompletedEventSchema,
1111
PostResultResponseSchema,
12+
ResultMinifiedSchema,
1213
ResultSchema,
1314
} from "./schemas/results";
1415
import { IdSchema } from "./schemas/util";
@@ -38,9 +39,20 @@ export const GetResultsQuerySchema = z.object({
3839
});
3940
export type GetResultsQuery = z.infer<typeof GetResultsQuerySchema>;
4041

41-
export const GetResultsResponseSchema = responseWithData(z.array(ResultSchema));
42+
export const GetResultsResponseSchema = responseWithData(
43+
z.array(ResultMinifiedSchema)
44+
);
4245
export type GetResultsResponse = z.infer<typeof GetResultsResponseSchema>;
4346

47+
export const GetResultByIdPathSchema = z.object({
48+
resultId: IdSchema,
49+
});
50+
export type GetResultByIdPath = z.infer<typeof GetResultByIdPathSchema>;
51+
52+
export const GetResultByIdResponseSchema = responseWithData(ResultSchema);
53+
54+
export type GetResultByIdResponse = z.infer<typeof GetResultByIdResponseSchema>;
55+
4456
export const AddResultRequestSchema = z.object({
4557
result: CompletedEventSchema,
4658
});
@@ -92,6 +104,25 @@ export const resultsContract = c.router(
92104
},
93105
}),
94106
},
107+
getById: {
108+
summary: "get result by id",
109+
description: "Get result by id",
110+
method: "GET",
111+
path: "/id/:resultId",
112+
pathParams: GetResultByIdPathSchema,
113+
responses: {
114+
200: GetResultByIdResponseSchema,
115+
},
116+
metadata: meta({
117+
authenticationOptions: {
118+
acceptApeKeys: true,
119+
},
120+
rateLimit: {
121+
normal: "resultByIdGet",
122+
apeKey: "resultByIdGetApe",
123+
},
124+
}),
125+
},
95126
add: {
96127
summary: "add result",
97128
description: "Add a test result for the current user",

packages/contracts/src/schemas/results.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ export type Result<M extends Mode> = Omit<
9595
mode2: Mode2<M>;
9696
};
9797

98+
export const ResultMinifiedSchema = ResultSchema.omit({
99+
name: true,
100+
keySpacingStats: true,
101+
keyDurationStats: true,
102+
chartData: true,
103+
});
104+
export type ResultMinified = z.infer<typeof ResultMinifiedSchema>;
105+
98106
export const CompletedEventSchema = ResultBaseSchema.required({
99107
restartCount: true,
100108
incompleteTestSeconds: true,

0 commit comments

Comments
 (0)