Skip to content

Commit

Permalink
quiz infinite with stats (#875)
Browse files Browse the repository at this point in the history
- [x] do not report partial infinite quiz results
- [x] test: do not report partial infinite quiz results
- [x] add historical stats
- [x] add friend stats
- [x] handle ties
- [x] test: coming back to a partially complete infinite run
- [x] test: coming back to a results page
- [x] test: historical stats display
- [x] test: clicking on numbers in history display
- [x] test: ties in historical stats display
- [x] test: medaling in historical stats display
- [x] test: friend stats display
- [x] if you go to just the normal infinite page and haven't finished
one, it should put you on that one
- [x] test: for default unfininished feature
- [x] ~~handle outdated results pages~~ no longer a real issue
- [x] change copyable link to include medal if you're in a top-3
personal best
- [x] results page should always contain current stat
- [x] historical stats page should be a table
- [x] emoji display should wrap
- [x] ~~compact emoji share toggle~~ its its own issue now
#888
- [x] ~~test: coming back to outdated quiz~~ can't really do this until
we have multiple
- [x] shorter link using all the characters, migrate to not assuming
hexadecimal in the server
  • Loading branch information
kavigupta authored Feb 1, 2025
1 parent 9b30fe2 commit c9196e8
Show file tree
Hide file tree
Showing 23 changed files with 724 additions and 119 deletions.
3 changes: 1 addition & 2 deletions react/src/components/quiz-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,11 @@ function QuizPanelNoResets(props: { quizDescriptor: QuizDescriptor, todayName?:
switch (props.quizDescriptor.kind) {
case 'juxtastat':
case 'retrostat':
case 'infinite':
quizHistory = persistentQuizHistory
setQuizHistory = newHistory => QuizLocalStorage.shared.history.value = newHistory
break
case 'custom':
// TODO stats for infinite quiz
case 'infinite':
quizHistory = transientQuizHistory
setQuizHistory = (newHistory) => { setTransientQuizHistory(newHistory) }
break
Expand Down
20 changes: 16 additions & 4 deletions react/src/navigation/PageDescriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ import { StatGroupSettings } from '../page_template/statistic-settings'
import { allGroups, CategoryIdentifier, StatName, StatPath, statsTree } from '../page_template/statistic-tree'
import { getDailyOffsetNumber, getRetrostatOffsetNumber } from '../quiz/dates'
import { validQuizInfiniteVersions } from '../quiz/infinite'
import { QuizQuestionsModel, wrapQuestionsModel, addFriendFromLink, CustomQuizContent, JuxtaQuestionJSON, loadJuxta, loadRetro, QuizDescriptor, RetroQuestionJSON, infiniteQuiz } from '../quiz/quiz'
import {
QuizQuestionsModel, wrapQuestionsModel, addFriendFromLink, CustomQuizContent, JuxtaQuestionJSON,
loadJuxta, loadRetro, QuizDescriptor, RetroQuestionJSON, infiniteQuiz, QuizHistory,
} from '../quiz/quiz'
import { getInfiniteQuizzes } from '../quiz/statistics'
import { defaultArticleUniverse, defaultComparisonUniverse } from '../universe'
import { Article, IDataList } from '../utils/protos'
import { randomID } from '../utils/random'
import { randomBase62ID } from '../utils/random'
import { followSymlink, followSymlinks } from '../utils/symlinks'
import { NormalizeProto } from '../utils/types'
import { base64Gunzip } from '../utils/urlParamShort'
Expand Down Expand Up @@ -458,12 +462,20 @@ export async function loadPageDescriptor(newDescriptor: PageDescriptor, settings
break
case 'infinite':
if (updatedDescriptor.seed === undefined) {
updatedDescriptor.seed = randomID(10)
const [seedVersions] = getInfiniteQuizzes(JSON.parse(localStorage.quiz_history as string) as QuizHistory, false)
if (seedVersions.length > 0) {
const [seed, version] = seedVersions[0]
updatedDescriptor.seed = seed
updatedDescriptor.v = version
}
else {
updatedDescriptor.seed = randomBase62ID(7)
}
}
if (updatedDescriptor.v === undefined) {
updatedDescriptor.v = Math.max(...validQuizInfiniteVersions)
}
quizDescriptor = { kind: 'infinite', name: updatedDescriptor.seed, seed: updatedDescriptor.seed, version: updatedDescriptor.v }
quizDescriptor = { kind: 'infinite', name: `I_${updatedDescriptor.seed}_${updatedDescriptor.v}`, seed: updatedDescriptor.seed, version: updatedDescriptor.v }
quiz = infiniteQuiz(updatedDescriptor.seed, updatedDescriptor.v)
todayName = undefined
break
Expand Down
104 changes: 93 additions & 11 deletions react/src/quiz/quiz-friends.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import React, { CSSProperties, ReactNode, useEffect, useState } from 'react'
import React, { CSSProperties, ReactNode, useContext, useEffect, useState } from 'react'
import { GridLoader, MoonLoader } from 'react-spinners'

import { EditableString } from '../components/table'
import { Navigator } from '../navigation/Navigator'
import { urlFromPageDescriptor } from '../navigation/PageDescriptor'
import { useColors, useJuxtastatColors } from '../page_template/colors'
import { mixWithBackground } from '../utils/color'

import { endpoint, QuizDescriptorWithTime, QuizFriends, QuizLocalStorage } from './quiz'
import { endpoint, QuizDescriptorWithTime, QuizDescriptorWithStats, QuizFriends, QuizLocalStorage, QuizDescriptor } from './quiz'
import { CorrectPattern } from './quiz-result'
import { parseTimeIdentifier } from './statistics'

interface ResultToDisplayForFriends { corrects: CorrectPattern }
export type ResultToDisplayForFriends = { corrects: CorrectPattern } | { forThisSeed: number | null, maxScore: number | null, maxScoreSeed: string | null, maxScoreVersion: number | null }

interface FriendResponse { result: ResultToDisplayForFriends | null, friends: boolean, idError?: string }
type FriendScore = { name?: string } & FriendResponse
Expand Down Expand Up @@ -39,10 +40,34 @@ async function juxtaRetroResponse(
}))
}

async function infiniteResponse(
user: string,
secureID: string,
quizDescriptor: QuizDescriptor & { kind: 'infinite' },
requesters: string[],
): Promise<FriendResponse[] | undefined> {
const friendScoresResponse = await fetch(`${endpoint}/juxtastat/infinite_results`, {
method: 'POST',
body: JSON.stringify({ user, secureID, requesters, seed: quizDescriptor.seed, version: quizDescriptor.version }),
headers: {
'Content-Type': 'application/json',
},
}).then(x => x.json()) as { results: { forThisSeed: number | null, maxScore: number | null, maxScoreSeed: string | null, maxScoreVersion: number | null, friends: boolean, idError?: string }[] } | { error: string }
if ('error' in friendScoresResponse) {
// probably some kind of auth error. Handled elsewhere
return undefined
}
return friendScoresResponse.results.map(x => ({
result: { forThisSeed: x.forThisSeed, maxScore: x.maxScore, maxScoreSeed: x.maxScoreSeed, maxScoreVersion: x.maxScoreVersion },
friends: x.friends,
idError: x.idError,
}))
}

export function QuizFriendsPanel(props: {
quizFriends: QuizFriends
setQuizFriends: (quizFriends: QuizFriends) => void
quizDescriptor: QuizDescriptorWithTime
quizDescriptor: QuizDescriptorWithStats
myResult: ResultToDisplayForFriends
}): ReactNode {
const colors = useColors()
Expand All @@ -62,7 +87,10 @@ export function QuizFriendsPanel(props: {
// map name to id for quizFriends
const quizIDtoName = Object.fromEntries(props.quizFriends.map(x => [x[1], x[0]]))
const requesters = props.quizFriends.map(x => x[1])
const friendScoresResponse = await juxtaRetroResponse(user, secureID, props.quizDescriptor, requesters)
const friendScoresResponse
= props.quizDescriptor.kind === 'infinite'
? await infiniteResponse(user, secureID, props.quizDescriptor, requesters)
: await juxtaRetroResponse(user, secureID, props.quizDescriptor, requesters)
if (friendScoresResponse === undefined) {
return
}
Expand All @@ -79,13 +107,16 @@ export function QuizFriendsPanel(props: {
})()
}, [props.quizDescriptor, props.quizFriends, user, secureID])

const allResults = [props.myResult, ...friendScores.map(x => x.result)].filter(x => x !== null)

const content = (
<div>
<div style={{ margin: 'auto', width: '100%' }}>
<div className="quiz_summary">Friends</div>
</div>
<>
<PlayerScore result={props.myResult} />
{props.quizDescriptor.kind === 'infinite' ? <InfiniteHeader /> : undefined}
<PlayerScore result={props.myResult} otherResults={allResults} />

{
friendScores.map((friendScore, idx) => (
Expand All @@ -106,6 +137,7 @@ export function QuizFriendsPanel(props: {
}}
quizFriends={props.quizFriends}
setQuizFriends={props.setQuizFriends}
otherResults={allResults}
/>
),
)
Expand All @@ -128,15 +160,35 @@ export function QuizFriendsPanel(props: {
<div style={{ opacity: isLoading ? 0.5 : 1, pointerEvents: isLoading ? 'none' : undefined }}>
<WithError content={content} error={error} />
</div>
{ isLoading ? <MoonLoader size={spinnerSize} color={colors.textMain} cssOverride={spinnerStyle} /> : null}
{isLoading ? <MoonLoader size={spinnerSize} color={colors.textMain} cssOverride={spinnerStyle} /> : null}
</div>
)
}

function InfiniteHeader(): ReactNode {
return (
<div
style={{ display: 'flex', flexDirection: 'row', height: scoreCorrectHeight, alignItems: 'center' }}
className="testing-friends-section"
>
<div style={{ width: '25%' }} />
<div style={{ width: '50%', display: 'flex', flexDirection: 'row' }}>
<div style={{ width: '50%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
On This Seed
</div>
<div style={{ width: '50%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
Overall Best
</div>
</div>
<div style={{ width: '25%' }} />
</div>
)
}

const scoreCorrectHeight = '2em'
const addFriendHeight = '1.5em'

function PlayerScore(props: { result: ResultToDisplayForFriends }): ReactNode {
function PlayerScore(props: { result: ResultToDisplayForFriends, otherResults: ResultToDisplayForFriends[] }): ReactNode {
const copyFriendLink = async (): Promise<void> => {
const playerName = prompt('Enter your name:')

Expand All @@ -161,7 +213,7 @@ function PlayerScore(props: { result: ResultToDisplayForFriends }): ReactNode {
You
</div>
<div style={{ width: '50%' }}>
<FriendScoreCorrects result={props.result} friends={true} />
<FriendScoreCorrects result={props.result} friends={true} otherResults={props.otherResults} />
</div>
<div style={{ width: '25%', display: 'flex', height: addFriendHeight }}>
<button
Expand All @@ -182,6 +234,7 @@ function FriendScore(props: {
removeFriend: () => Promise<void>
quizFriends: QuizFriends
setQuizFriends: (x: QuizFriends) => void
otherResults: ResultToDisplayForFriends[]
}): ReactNode {
const colors = useColors()

Expand Down Expand Up @@ -230,7 +283,7 @@ function FriendScore(props: {
/>
</div>
<div style={{ width: '50%' }}>
<FriendScoreCorrects {...props.friendScore} />
<FriendScoreCorrects {...props.friendScore} otherResults={props.otherResults} />
</div>
<div style={{ width: '25%', display: 'flex', height: addFriendHeight }}>
<button
Expand All @@ -248,9 +301,10 @@ function FriendScore(props: {
return <WithError error={error} content={row} />
}

function FriendScoreCorrects(props: FriendScore): ReactNode {
function FriendScoreCorrects(props: FriendScore & { otherResults: ResultToDisplayForFriends[] }): ReactNode {
const colors = useColors()
const juxtaColors = useJuxtastatColors()
const navContext = useContext(Navigator.Context)
const border = `1px solid ${colors.background}`
const greyedOut = {
backgroundColor: mixWithBackground(colors.hueColors.orange, 0.5, colors.background),
Expand Down Expand Up @@ -282,6 +336,34 @@ function FriendScoreCorrects(props: FriendScore): ReactNode {
<div style={greyedOut}>Not Done Yet</div>
)
}
if ('forThisSeed' in props.result) {
const link
= props.result.maxScoreSeed === null || props.result.maxScoreVersion === null
// eslint-disable-next-line @typescript-eslint/no-empty-function -- this is a dummy onClick function for when there is no link
? { href: undefined, onClick: () => { } }
: navContext.link({
kind: 'quiz', mode: 'infinite', seed: props.result.maxScoreSeed, v: props.result.maxScoreVersion,
}, { scroll: { kind: 'position', top: 0 } })
const relevantOtherResults = props.otherResults.filter(
x => 'forThisSeed' in x,
)
const baseStyle = { width: '50%', border, display: 'flex', justifyContent: 'center', alignItems: 'center', color: '#fff', fontWeight: 'bold' }
const maxMaxScore = Math.max(...relevantOtherResults.map(x => x.maxScore ?? 0)) === props.result.maxScore
const maxForThisSeed = Math.max(...relevantOtherResults.map(x => x.forThisSeed ?? 0)) === props.result.forThisSeed
return (
<div style={{ display: 'flex', flexDirection: 'row', height: scoreCorrectHeight }}>
<div style={{ ...baseStyle, backgroundColor: maxForThisSeed ? colors.hueColors.green : colors.hueColors.blue }}>
{props.result.forThisSeed ?? '-'}
</div>
<div
style={{ ...baseStyle, backgroundColor: maxMaxScore ? colors.hueColors.green : colors.hueColors.blue }}
onClick={link.onClick}
>
<a style={{ textDecoration: 'none', color: '#fff' }} href={link.href}>{props.result.maxScore ?? '-'}</a>
</div>
</div>
)
}
const corrects = props.result.corrects
return (
<div
Expand Down
Loading

0 comments on commit c9196e8

Please sign in to comment.