From 072ba2063e785c45069362fdd97e9a8bc6def004 Mon Sep 17 00:00:00 2001 From: Danny Miller Date: Fri, 29 Sep 2023 20:27:34 -0400 Subject: [PATCH 1/6] wip --- models/db/level.d.ts | 1 + models/schemas/levelSchema.ts | 46 +++++++++++++++++++++ pages/admin/index.tsx | 75 ++++++++++++++++++++++++++++++----- 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/models/db/level.d.ts b/models/db/level.d.ts index 584c25983..4be02dc0c 100644 --- a/models/db/level.d.ts +++ b/models/db/level.d.ts @@ -8,6 +8,7 @@ interface Level { authorNote?: string; calc_difficulty_estimate: number; calc_playattempts_duration_sum: number; + calc_playattempts_duration_sum_p95: number; calc_playattempts_just_beaten_count: number; calc_playattempts_unique_users: Types.ObjectId[]; calc_reviews_count: number; diff --git a/models/schemas/levelSchema.ts b/models/schemas/levelSchema.ts index 5cca32b40..9a0e6f30c 100644 --- a/models/schemas/levelSchema.ts +++ b/models/schemas/levelSchema.ts @@ -31,6 +31,10 @@ const LevelSchema = new mongoose.Schema( type: Number, default: 0, }, + calc_playattempts_duration_sum_p95: { + type: Number, + default: 0, + }, calc_playattempts_just_beaten_count: { type: Number, default: 0, @@ -126,6 +130,7 @@ LevelSchema.index({ isDraft: 1 }); LevelSchema.index({ leastMoves: 1 }); LevelSchema.index({ calc_difficulty_estimate: 1 }); LevelSchema.index({ calc_playattempts_duration_sum: 1 }); +LevelSchema.index({ calc_playattempts_duration_sum_p95: 1 }); LevelSchema.index({ calc_playattempts_just_beaten_count: 1 }); LevelSchema.index({ calc_playattempts_unique_users: 1 }); LevelSchema.index({ calc_reviews_count: 1 }); @@ -225,6 +230,46 @@ export async function calcPlayAttempts(levelId: Types.ObjectId, options: any = { } ], options); + const sumDurationP95 = await PlayAttemptModel.aggregate([ + { + $match: { + levelId: levelId, + attemptContext: { $ne: AttemptContext.BEATEN } + } + }, + { + $project: { + duration: { + $subtract: ['$endTime', '$startTime'] + } + } + }, + { $sort: { duration: 1 } }, + { + $group: { + _id: null, + durations: { $push: '$duration' } + } + }, + { + $project: { + p95: { + $arrayElemAt: [ + '$durations', + { + $floor: { + $multiply: [ + { $divide: [{ $subtract: [{ $size: '$durations' }, 1] }, 1] }, + 0.95 + ] + } + } + ] + } + } + } + ]); + // get array of unique userIds from playattempt calc_playattempts_unique_users const uniqueUsersList = await PlayAttemptModel.aggregate([ { @@ -272,6 +317,7 @@ export async function calcPlayAttempts(levelId: Types.ObjectId, options: any = { const update = { calc_playattempts_duration_sum: sumDuration[0]?.sumDuration ?? 0, + calc_playattempts_duration_sum_p95: sumDurationP95[0]?.p95 ?? 0, calc_playattempts_just_beaten_count: countJustBeaten, calc_playattempts_unique_users: uniqueUsersList.map(u => u?.userId.toString()), } as Partial; diff --git a/pages/admin/index.tsx b/pages/admin/index.tsx index 4b3c86337..bcf549919 100644 --- a/pages/admin/index.tsx +++ b/pages/admin/index.tsx @@ -1,12 +1,15 @@ import { Menu } from '@headlessui/react'; import FormattedUser from '@root/components/formatted/formattedUser'; +import MultiSelectLevel from '@root/components/page/multiSelectLevel'; import MultiSelectUser from '@root/components/page/multiSelectUser'; import Page from '@root/components/page/page'; import Role from '@root/constants/role'; import dbConnect from '@root/lib/dbConnect'; import { getUserFromToken } from '@root/lib/withAuth'; +import Level from '@root/models/db/level'; import User from '@root/models/db/user'; -import { UserModel } from '@root/models/mongoose'; +import { LevelModel, UserModel } from '@root/models/mongoose'; +import { Types } from 'mongoose'; import { GetServerSidePropsContext, NextApiRequest } from 'next'; import Router from 'next/router'; import React, { useEffect, useState } from 'react'; @@ -24,29 +27,34 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }; } - const { queryUser, queryCommand } = context.query; + const { queryUser, queryLevel, queryCommand } = context.query; return { props: { - queryUser: queryUser ? JSON.parse(JSON.stringify(await UserModel.findOne({ name: queryUser }))) : null, + queryLevel: queryLevel && queryLevel !== 'undefined' ? JSON.parse(JSON.stringify(await LevelModel.findById(new Types.ObjectId(queryLevel as string)))) : null, + queryUser: queryUser && queryUser !== 'undefiend' ? JSON.parse(JSON.stringify(await UserModel.findOne({ name: queryUser }))) : null, queryCommand: queryCommand || null, }, }; } -export default function AdminPage({ queryUser, queryCommand }: {queryUser: User | undefined; queryCommand: string | null}) { +export default function AdminPage({ queryUser, queryLevel, queryCommand }: {queryUser: User | undefined; queryLevel: Level, queryCommand: string | null}) { const [selectedUser, setSelectedUser] = useState(queryUser); + const [selectedLevel, setSelectedLevel] = useState(queryLevel); // TODO: [refactor] [minor const [runningCommand, setRunningCommand] = useState(false); - const commands = [ + const commandsUser = [ { label: 'Refresh Achievements', command: 'refreshAchievements' }, { label: 'Delete Achievements', command: 'deleteAchievements', confirm: true }, - { label: 'Refresh Play Attempts', command: 'calcPlayAttempts' }, - { label: 'Refresh Index Calculations', command: 'refreshIndexCalcs' }, // Add more commands here ]; - const selectedCommandFromQuery = commands.find((cmd) => cmd.command === queryCommand); + + const commandsLevel = [ + { label: 'Refresh Play Attempts', command: 'calcPlayAttempts' }, + { label: 'Refresh Index Calculations', command: 'refreshIndexCalcs' }, + ]; + const selectedCommandFromQuery = commandsUser.find((cmd) => cmd.command === queryCommand); const [selectedCommand, setSelectedCommand] = useState<{ label: string; command: string; confirm?: boolean } | null>(selectedCommandFromQuery || null); @@ -63,14 +71,14 @@ export default function AdminPage({ queryUser, queryCommand }: {queryUser: User }, [queryCommand, queryUser, selectedCommand?.command, selectedCommandFromQuery, selectedUser]); useEffect(() => { - const newUrl = `${window.location.pathname}?queryUser=${selectedUser?.name}&queryCommand=${selectedCommand?.command}`; + const newUrl = `${window.location.pathname}?queryLevel=${selectedLevel?._id}&queryUser=${selectedUser?.name}&queryCommand=${selectedCommand?.command}`; const currentFullURL = `${window.location.pathname}${window.location.search}`; if (currentFullURL !== newUrl) { Router.push(newUrl); } - }, [selectedUser, selectedCommand]); + }, [selectedUser, selectedCommand, selectedLevel?._id]); async function runCommand() { if (selectedCommand?.confirm && !window.confirm('Are you sure you want to proceed?')) return; @@ -152,7 +160,51 @@ export default function AdminPage({ queryUser, queryCommand }: {queryUser: User - {commands.map((cmd) => ( + {commandsUser.map((cmd) => ( + + {({ active }) => ( + { + setSelectedCommand(cmd); + }} + className={`${active ? 'bg-blue-600 text-white' : 'text-gray-900'} block px-4 py-2 text-sm`} + > + {cmd.label} + + )} + + ))} + + + + +
+ {selectedUser && ( +
+ + {display('User', selectedUser)} +
+ )} +
+
+

Run command on level:

+ { + setSelectedLevel(selected); + }} /> + +
+ + {selectedCommand?.label || 'Select Command'} + +
+ + {commandsUser.map((cmd) => ( {({ active }) => (
+ ); } From 5b305b8b8504ca151a69ba6bfe05e7882b8f6a9d Mon Sep 17 00:00:00 2001 From: Danny Miller Date: Fri, 29 Sep 2023 23:00:16 -0400 Subject: [PATCH 2/6] speed up calcPlayAttempts and also calc p95s --- models/db/level.d.ts | 1 + models/schemas/levelSchema.ts | 198 ++++++++++++++++++---------------- pages/admin/index.tsx | 115 +++++++++++++------- 3 files changed, 183 insertions(+), 131 deletions(-) diff --git a/models/db/level.d.ts b/models/db/level.d.ts index 4be02dc0c..38332bb2e 100644 --- a/models/db/level.d.ts +++ b/models/db/level.d.ts @@ -10,6 +10,7 @@ interface Level { calc_playattempts_duration_sum: number; calc_playattempts_duration_sum_p95: number; calc_playattempts_just_beaten_count: number; + calc_playattempts_just_beaten_count_p95: number; calc_playattempts_unique_users: Types.ObjectId[]; calc_reviews_count: number; calc_reviews_score_avg: number; diff --git a/models/schemas/levelSchema.ts b/models/schemas/levelSchema.ts index 9a0e6f30c..6011b36ea 100644 --- a/models/schemas/levelSchema.ts +++ b/models/schemas/levelSchema.ts @@ -39,6 +39,10 @@ const LevelSchema = new mongoose.Schema( type: Number, default: 0, }, + calc_playattempts_just_beaten_count_p95: { + type: Number, + default: 0, + }, calc_playattempts_unique_users: { type: [mongoose.Schema.Types.ObjectId], default: [], @@ -131,6 +135,7 @@ LevelSchema.index({ leastMoves: 1 }); LevelSchema.index({ calc_difficulty_estimate: 1 }); LevelSchema.index({ calc_playattempts_duration_sum: 1 }); LevelSchema.index({ calc_playattempts_duration_sum_p95: 1 }); +LevelSchema.index({ calc_playattempts_just_beaten_count_p95: 1 }); LevelSchema.index({ calc_playattempts_just_beaten_count: 1 }); LevelSchema.index({ calc_playattempts_unique_users: 1 }); LevelSchema.index({ calc_reviews_count: 1 }); @@ -205,119 +210,122 @@ async function calcStats(lvl: Level) { // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function calcPlayAttempts(levelId: Types.ObjectId, options: any = {}) { - const countJustBeaten = await PlayAttemptModel.countDocuments({ - levelId: levelId, - attemptContext: AttemptContext.JUST_BEATEN, - }); - // sumDuration is all of the sum(endTime-startTime) within the playAttempts - const sumDuration = await PlayAttemptModel.aggregate([ - { - $match: { - levelId: levelId, - attemptContext: { $ne: AttemptContext.BEATEN }, + const [countJustBeaten, sumDuration, sumDurationP95, uniqueUsersList] = await Promise.all([ + PlayAttemptModel.countDocuments({ + levelId: levelId, + attemptContext: AttemptContext.JUST_BEATEN, + }), + PlayAttemptModel.aggregate([ + { + $match: { + levelId: levelId, + attemptContext: { $ne: AttemptContext.BEATEN }, + } + }, + { + $group: { + _id: null, + sumDuration: { + $sum: { + $subtract: ['$endTime', '$startTime'] + } + } + } } - }, - { - $group: { - _id: null, - sumDuration: { - $sum: { + ], options), + PlayAttemptModel.aggregate([ + { + $match: { + levelId: levelId, + attemptContext: { $ne: AttemptContext.BEATEN } + } + }, + { + $project: { + duration: { $subtract: ['$endTime', '$startTime'] } } - } - } - ], options); - - const sumDurationP95 = await PlayAttemptModel.aggregate([ - { - $match: { - levelId: levelId, - attemptContext: { $ne: AttemptContext.BEATEN } - } - }, - { - $project: { - duration: { - $subtract: ['$endTime', '$startTime'] + }, + { $sort: { duration: 1 } }, + { + $group: { + _id: null, + durations: { $push: '$duration' }, + count: { $sum: 1 } } - } - }, - { $sort: { duration: 1 } }, - { - $group: { - _id: null, - durations: { $push: '$duration' } - } - }, - { - $project: { - p95: { - $arrayElemAt: [ - '$durations', - { - $floor: { - $multiply: [ - { $divide: [{ $subtract: [{ $size: '$durations' }, 1] }, 1] }, - 0.95 - ] - } + }, + { + $project: { + lowerIndex: { $ceil: { $multiply: ['$count', 0.025] } }, + upperIndex: { $floor: { $multiply: ['$count', 0.975] } }, + durations: 1, + count: 1 + } + }, + { + $project: { + countExcluded: { $subtract: ['$count', { $subtract: ['$upperIndex', '$lowerIndex'] }] }, + sumExcluded: { + $reduce: { + input: { $slice: ['$durations', '$lowerIndex', { $subtract: ['$upperIndex', '$lowerIndex'] }] }, + initialValue: 0, + in: { $add: ['$$value', '$$this'] } } - ] + } } } - } - ]); - - // get array of unique userIds from playattempt calc_playattempts_unique_users - const uniqueUsersList = await PlayAttemptModel.aggregate([ - { - $match: { - $or: [ - { - $and: [ - { - $expr: { - $gt: [ - { - $subtract: ['$endTime', '$startTime'] - }, - 0 - ] + ]), + PlayAttemptModel.aggregate([ + { + $match: { + $or: [ + { + $and: [ + { + $expr: { + $gt: [ + { + $subtract: ['$endTime', '$startTime'] + }, + 0 + ] + } + }, + { + attemptContext: AttemptContext.UNBEATEN, } - }, - { - attemptContext: AttemptContext.UNBEATEN, - } - ], - }, - { - attemptContext: AttemptContext.JUST_BEATEN, + ], + }, + { + attemptContext: AttemptContext.JUST_BEATEN, + }, + ], + levelId: levelId, + }, + }, + { + $group: { + _id: null, + userId: { + $addToSet: '$userId', }, - ], - levelId: levelId, + } }, - }, - { - $group: { - _id: null, - userId: { - $addToSet: '$userId', + { + $unwind: { + path: '$userId', + preserveNullAndEmptyArrays: true, }, - } - }, - { - $unwind: { - path: '$userId', - preserveNullAndEmptyArrays: true, }, - }, + ]) ]); const update = { calc_playattempts_duration_sum: sumDuration[0]?.sumDuration ?? 0, - calc_playattempts_duration_sum_p95: sumDurationP95[0]?.p95 ?? 0, + calc_playattempts_duration_sum_p95: sumDurationP95[0]?.sumExcluded ?? 0, + calc_playattempts_just_beaten_count_p95: sumDurationP95[0]?.countExcluded ?? 0, calc_playattempts_just_beaten_count: countJustBeaten, calc_playattempts_unique_users: uniqueUsersList.map(u => u?.userId.toString()), } as Partial; diff --git a/pages/admin/index.tsx b/pages/admin/index.tsx index bcf549919..22cdea91a 100644 --- a/pages/admin/index.tsx +++ b/pages/admin/index.tsx @@ -1,9 +1,11 @@ import { Menu } from '@headlessui/react'; import FormattedUser from '@root/components/formatted/formattedUser'; +import RecommendedLevel from '@root/components/homepage/recommendedLevel'; import MultiSelectLevel from '@root/components/page/multiSelectLevel'; import MultiSelectUser from '@root/components/page/multiSelectUser'; import Page from '@root/components/page/page'; import Role from '@root/constants/role'; +import useLevelBySlug from '@root/hooks/useLevelBySlug'; import dbConnect from '@root/lib/dbConnect'; import { getUserFromToken } from '@root/lib/withAuth'; import Level from '@root/models/db/level'; @@ -27,18 +29,19 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }; } - const { queryUser, queryLevel, queryCommand } = context.query; + const { queryUser, queryLevel, queryUserCommand, queryLevelCommand } = context.query; return { props: { queryLevel: queryLevel && queryLevel !== 'undefined' ? JSON.parse(JSON.stringify(await LevelModel.findById(new Types.ObjectId(queryLevel as string)))) : null, queryUser: queryUser && queryUser !== 'undefiend' ? JSON.parse(JSON.stringify(await UserModel.findOne({ name: queryUser }))) : null, - queryCommand: queryCommand || null, + queryUserCommand: queryUserCommand || null, + queryLevelCommand: queryLevelCommand || null, }, }; } -export default function AdminPage({ queryUser, queryLevel, queryCommand }: {queryUser: User | undefined; queryLevel: Level, queryCommand: string | null}) { +export default function AdminPage({ queryUser, queryLevel, queryUserCommand, queryLevelCommand }: {queryUser: User | undefined; queryLevel: Level, queryUserCommand: string | null, queryLevelCommand: string | null}) { const [selectedUser, setSelectedUser] = useState(queryUser); const [selectedLevel, setSelectedLevel] = useState(queryLevel); // TODO: [refactor] [minor const [runningCommand, setRunningCommand] = useState(false); @@ -54,34 +57,28 @@ export default function AdminPage({ queryUser, queryLevel, queryCommand }: {quer { label: 'Refresh Play Attempts', command: 'calcPlayAttempts' }, { label: 'Refresh Index Calculations', command: 'refreshIndexCalcs' }, ]; - const selectedCommandFromQuery = commandsUser.find((cmd) => cmd.command === queryCommand); + const selectedUserCommandFromQuery = commandsUser.find((cmd) => cmd.command === queryUserCommand); - const [selectedCommand, setSelectedCommand] = useState<{ label: string; command: string; confirm?: boolean } | null>(selectedCommandFromQuery || null); + const [selectedUserCommand, setSelectedUserCommand] = useState<{ label: string; command: string; confirm?: boolean } | null>(selectedUserCommandFromQuery || null); - useEffect(() => { - if (queryUser && queryUser !== selectedUser) { - setSelectedUser(queryUser); - } + const selectedLevelCommandFromQuery = commandsLevel.find((cmd) => cmd.command === queryLevelCommand); - if (queryCommand && queryCommand !== selectedCommand?.command) { - if (selectedCommandFromQuery) { - setSelectedCommand(selectedCommandFromQuery); - } - } - }, [queryCommand, queryUser, selectedCommand?.command, selectedCommandFromQuery, selectedUser]); + const [selectedLevelCommand, setSelectedLevelCommand] = useState<{ label: string; command: string; confirm?: boolean } | null>(selectedLevelCommandFromQuery || null); + + const { level: levelPreview } = useLevelBySlug(selectedLevel?.slug); useEffect(() => { - const newUrl = `${window.location.pathname}?queryLevel=${selectedLevel?._id}&queryUser=${selectedUser?.name}&queryCommand=${selectedCommand?.command}`; + const newUrl = `${window.location.pathname}?queryLevel=${selectedLevel?._id}&queryLevelCommand=${selectedLevelCommand?.command}&queryUser=${selectedUser?.name}&queryUserCommand=${selectedUserCommand?.command}`; const currentFullURL = `${window.location.pathname}${window.location.search}`; if (currentFullURL !== newUrl) { Router.push(newUrl); } - }, [selectedUser, selectedCommand, selectedLevel?._id]); + }, [selectedUser, selectedUserCommand, selectedLevel?._id, selectedLevelCommand?.command]); - async function runCommand() { - if (selectedCommand?.confirm && !window.confirm('Are you sure you want to proceed?')) return; + async function runCommandUser() { + if (selectedUserCommand?.confirm && !window.confirm('Are you sure you want to proceed?')) return; setRunningCommand(true); toast.dismiss(); @@ -93,7 +90,7 @@ export default function AdminPage({ queryUser, queryLevel, queryCommand }: {quer 'Content-Type': 'application/json', }, body: JSON.stringify({ - command: selectedCommand?.command, + command: selectedUserCommand?.command, targetId: selectedUser?._id, }), }); @@ -108,14 +105,44 @@ export default function AdminPage({ queryUser, queryLevel, queryCommand }: {quer } } - function display(title: string, obj: User) { + async function runCommandLevel() { + if (selectedLevelCommand?.confirm && !window.confirm('Are you sure you want to proceed?')) return; + + setRunningCommand(true); + toast.dismiss(); + toast.loading('Running command...'); + const resp = await fetch('/api/admin', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + command: selectedLevelCommand?.command, + targetId: selectedLevel?._id, + }), + }); + + setRunningCommand(false); + const json = await resp.json(); + + if (json.error) { + toast.error(json.error); + } else { + toast.success('Command ran successfully'); + } + + Router.reload(); + } + + function display(title: string, obj: any) { return (

{title}

{obj && (
{Object.keys(obj).map((value) => { - const key = value as keyof User; + const key = value; const str = obj[key]?.toString() ?? ''; return ( @@ -146,9 +173,14 @@ export default function AdminPage({ queryUser, queryLevel, queryCommand }: {quer return ( +
-

Admin Page

-
+

Admin Page

+

+ User +

+
+
+ +
{selectedUser && (
- + {display('User', selectedUser)}
)}
-
+ +

+ Level

+
+ +
+
- {selectedUser && ( + {selectedLevel && (
- - {display('User', selectedUser)} + + {display('Level', selectedLevel)}
)}
From 661ebee7e90c8ce1caa627facc298e308369c36c Mon Sep 17 00:00:00 2001 From: Danny Miller Date: Sun, 1 Oct 2023 22:37:42 -0400 Subject: [PATCH 3/6] forgot to commit --- components/page/multiSelectLevel.tsx | 125 +++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 components/page/multiSelectLevel.tsx diff --git a/components/page/multiSelectLevel.tsx b/components/page/multiSelectLevel.tsx new file mode 100644 index 000000000..dbb738e2a --- /dev/null +++ b/components/page/multiSelectLevel.tsx @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import Level from '@root/models/db/level'; +import React, { useState } from 'react'; +import AsyncSelect from 'react-select/async'; +import { debounce } from 'throttle-debounce'; + +interface MultiSelectLevelProps { + controlStyles?: any; + defaultValue?: Level | null; + onSelect?: (selectedList: any, selectedItem: any) => void; + placeholder?: string +} + +export default function MultiSelectLevel({ controlStyles, defaultValue, onSelect, placeholder }: MultiSelectLevelProps) { + const [options, setOptions] = useState([]); + const [value, setValue] = useState(defaultValue); + + const doSearch = async (searchText: any, callback: any) => { + const search = encodeURI(searchText) || ''; + + const res = await fetch('/api/search?search=' + search, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + } + }); + + const data = await res.json(); + + setOptions(data?.levels); + callback(data?.levels); + }; + const debounceDoSearch = debounce(500, doSearch); + + return ( + <>{option.name} + )} + getOptionLabel={(option: any) => option.name} + getOptionValue={(option: any) => option._id.toString()} + id='search-author-input-async-select' + instanceId='search-author-input-async-select' + isClearable={true} + loadOptions={debounceDoSearch} + noOptionsMessage={() => 'No levels found'} + onChange={(selectedOption: any, selectedAction: any) => { + setValue(selectedOption); + + if (!selectedAction) { + return; + } + + if (onSelect) { + if (selectedAction.action === 'clear') { + onSelect('', selectedAction); + } else { + onSelect(selectedOption, selectedAction); + } + } + }} + options={options} // Options to display in the dropdown + placeholder={placeholder ? placeholder : 'Search levels...'} + // https://react-select.com/styles + styles={{ + control: (provided: any, state: any) => ({ + ...provided, + backgroundColor: 'white', + borderColor: state.isFocused ? 'rgb(37 99 235)' : 'rgb(209 213 219)', + borderRadius: '0.375rem', + borderWidth: '1px', + boxShadow: 'none', + cursor: 'text', + height: '2.5rem', + width: '13rem', + ...controlStyles, + }), + dropdownIndicator: (provided: any) => ({ + ...provided, + color: 'black', + // change to search icon + '&:hover': { + color: 'gray', + }, + }), + input: (provided: any) => ({ + ...provided, + color: 'rgb(55 65 81)', + }), + menu: (provided: any) => ({ + ...provided, + borderColor: 'rgb(209 213 219)', + borderRadius: '0.375rem', + borderWidth: '1px', + boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + marginTop: '2px', + }), + option: (provided: any, state: any) => ({ + ...provided, + backgroundColor: state.isSelected ? '#e2e8f0' : 'white', + color: 'black', + '&:hover': { + backgroundColor: '#e2e8f0', + }, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }), + placeholder: (provided: any) => ({ + ...provided, + color: 'rgb(156 163 175)', + }), + singleValue: (provided: any) => ({ + ...provided, + color: 'black', + }), + }} + value={value} + />; +} From dc0a98cab91d95cc72f2fca55af7b0e116f80b4e Mon Sep 17 00:00:00 2001 From: Spencer Spenst Date: Mon, 2 Oct 2023 20:42:05 -0700 Subject: [PATCH 4/6] merge --- models/schemas/levelSchema.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/models/schemas/levelSchema.ts b/models/schemas/levelSchema.ts index 6011b36ea..f861160fa 100644 --- a/models/schemas/levelSchema.ts +++ b/models/schemas/levelSchema.ts @@ -11,7 +11,6 @@ export const LEVEL_SEARCH_DEFAULT_PROJECTION = { _id: 1, ts: 1, name: 1, slug: 1 const LevelSchema = new mongoose.Schema( { - archivedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', @@ -122,7 +121,7 @@ const LevelSchema = new mongoose.Schema( locale: 'en_US', strength: 2, }, - } + }, ); LevelSchema.index({ slug: 1 }, { name: 'slug_index', unique: true }); From 8049a19f6a9ffc2152c819da0e35ed54e0675e25 Mon Sep 17 00:00:00 2001 From: Danny Miller Date: Tue, 3 Oct 2023 11:09:05 -0400 Subject: [PATCH 5/6] should group by user first then do the p95 --- models/schemas/levelSchema.ts | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/models/schemas/levelSchema.ts b/models/schemas/levelSchema.ts index f861160fa..269f7cf5c 100644 --- a/models/schemas/levelSchema.ts +++ b/models/schemas/levelSchema.ts @@ -242,16 +242,23 @@ export async function calcPlayAttempts(levelId: Types.ObjectId, options: any = { }, { $project: { - duration: { - $subtract: ['$endTime', '$startTime'] - } + userId: 1, + duration: { $subtract: ['$endTime', '$startTime'] } + } + }, + { + $group: { + _id: '$userId', + userTotalDuration: { $sum: '$duration' } } }, - { $sort: { duration: 1 } }, + { + $sort: { userTotalDuration: 1 } + }, { $group: { _id: null, - durations: { $push: '$duration' }, + userDurations: { $push: '$userTotalDuration' }, count: { $sum: 1 } } }, @@ -259,18 +266,24 @@ export async function calcPlayAttempts(levelId: Types.ObjectId, options: any = { $project: { lowerIndex: { $ceil: { $multiply: ['$count', 0.025] } }, upperIndex: { $floor: { $multiply: ['$count', 0.975] } }, - durations: 1, + userDurations: 1, count: 1 } }, { $project: { - countExcluded: { $subtract: ['$count', { $subtract: ['$upperIndex', '$lowerIndex'] }] }, + countExcluded: '$count', sumExcluded: { - $reduce: { - input: { $slice: ['$durations', '$lowerIndex', { $subtract: ['$upperIndex', '$lowerIndex'] }] }, - initialValue: 0, - in: { $add: ['$$value', '$$this'] } + $cond: { + if: { $gt: [{ $subtract: ['$upperIndex', '$lowerIndex'] }, 0] }, + then: { + $reduce: { + input: { $slice: ['$userDurations', '$lowerIndex', { $subtract: ['$upperIndex', '$lowerIndex'] }] }, + initialValue: 0, + in: { $add: ['$$value', '$$this'] } + } + }, + else: 0 } } } From 5a343457449e077b6065a8f03bb6bae75f32e97d Mon Sep 17 00:00:00 2001 From: Danny Miller Date: Tue, 3 Oct 2023 11:24:40 -0400 Subject: [PATCH 6/6] tests --- models/schemas/levelSchema.ts | 13 ++++- .../api/play-attempt/play-attempt.test.ts | 52 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/models/schemas/levelSchema.ts b/models/schemas/levelSchema.ts index 269f7cf5c..8a97fe71d 100644 --- a/models/schemas/levelSchema.ts +++ b/models/schemas/levelSchema.ts @@ -237,7 +237,16 @@ export async function calcPlayAttempts(levelId: Types.ObjectId, options: any = { { $match: { levelId: levelId, - attemptContext: { $ne: AttemptContext.BEATEN } + attemptContext: { $ne: AttemptContext.BEATEN }, + // where endTime and startTime are not equal + $expr: { + $gt: [ + { + $subtract: ['$endTime', '$startTime'] + }, + 0 + ] + } } }, { @@ -272,7 +281,7 @@ export async function calcPlayAttempts(levelId: Types.ObjectId, options: any = { }, { $project: { - countExcluded: '$count', + countExcluded: { $subtract: ['$count', { $add: ['$lowerIndex', { $subtract: ['$count', '$upperIndex'] }] }] }, sumExcluded: { $cond: { if: { $gt: [{ $subtract: ['$upperIndex', '$lowerIndex'] }, 0] }, diff --git a/tests/pages/api/play-attempt/play-attempt.test.ts b/tests/pages/api/play-attempt/play-attempt.test.ts index e32453ce2..a5a6bd43a 100644 --- a/tests/pages/api/play-attempt/play-attempt.test.ts +++ b/tests/pages/api/play-attempt/play-attempt.test.ts @@ -816,9 +816,10 @@ describe('Testing stats api', () => { }); test('calcDifficultyEstimate', async () => { const level = await initLevel(TestId.USER, 'calcDifficultyEstimate', {}, false); + const toInsert = []; for (let i = 0; i < 9; i++) { - await PlayAttemptModel.create({ + toInsert.push({ _id: new Types.ObjectId(), // half beaten attemptContext: i % 2 === 0 ? AttemptContext.JUST_BEATEN : AttemptContext.UNBEATEN, @@ -830,6 +831,8 @@ describe('Testing stats api', () => { }); } + await PlayAttemptModel.insertMany(toInsert); + await queueCalcPlayAttempts(level._id); await processQueueMessages(); @@ -844,6 +847,7 @@ describe('Testing stats api', () => { const unbeatenUserId = new Types.ObjectId(); // create a playattempt for the 10th unique user + await PlayAttemptModel.create({ _id: new Types.ObjectId(), attemptContext: AttemptContext.UNBEATEN, @@ -864,6 +868,29 @@ describe('Testing stats api', () => { ts: 0, }); + const unbeatenUserId2 = new Types.ObjectId(); + + // create a playattempt for the 11th unique user But make it have 0 play time so it shouldn't affect calculations + await PlayAttemptModel.create({ + _id: new Types.ObjectId(), + attemptContext: AttemptContext.UNBEATEN, + endTime: 0, + levelId: level._id, + startTime: 0, + updateCount: 0, + userId: unbeatenUserId, + }); + await UserModel.create({ + _id: unbeatenUserId2, + calc_records: 0, + email: 'unbeaten2@gmail.com', + last_visited_at: 0, + name: 'unbeaten2', + password: 'unbeaten', + score: 0, + ts: 0, + }); + await queueCalcPlayAttempts(level._id); await processQueueMessages(); @@ -871,10 +898,33 @@ describe('Testing stats api', () => { expect(levelUpdated2).toBeDefined(); expect(levelUpdated2?.calc_difficulty_estimate).toBeCloseTo(29.2 * 1.47629); + + /** + * User 1 has 10 + * User 2 has 11 + * User 3 has 12 + * User 4 has 13 + * User 5 has 14 + * User 6 has 15 + * User 7 has 16 + * User 8 has 17 + * User 9 has 18 + * User 10 (created above manually): 20 + * Sum = 146 + */ expect(levelUpdated2?.calc_playattempts_duration_sum).toBe(146); expect(levelUpdated2?.calc_playattempts_just_beaten_count).toBe(5); + expect(levelUpdated2?.calc_playattempts_unique_users?.length).toBe(10); + /** Take away top 2.5% and bottom 2.5% for p95 + * User 1 and User 10 are the outliers (10 and 20) + * 146 - 10 - 20 = 116 + */ + + expect(levelUpdated2?.calc_playattempts_just_beaten_count_p95).toBe(8); // removing user 1 and 10 + expect(levelUpdated2?.calc_playattempts_duration_sum_p95).toBe(116); + jest.spyOn(TimerUtil, 'getTs').mockReturnValue(30); await testApiHandler({ handler: async (_, res) => {