Add highscores page

pull/1/head
Francesco Minnocci 2 years ago
parent c8c33c9aa0
commit 0f1f00c9eb
Signed by: BachoSeven
GPG Key ID: 2BE4AB7FDAD828A4

@ -10,6 +10,7 @@ import { HomePage } from './pages/HomePage'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { ProblemPage } from './pages/ProblemPage' import { ProblemPage } from './pages/ProblemPage'
import { ProfilePage } from './pages/ProfilePage' import { ProfilePage } from './pages/ProfilePage'
import { ScoresPage } from './pages/ScoresPage'
const Redirect = ({ to }: { to: string }) => { const Redirect = ({ to }: { to: string }) => {
useEffect(() => { useEffect(() => {
@ -50,6 +51,10 @@ export const App = ({ url }: { url?: string }) => {
// @ts-ignore // @ts-ignore
path={pbu('/admin')} path={pbu('/admin')}
/> />
<ScoresPage
// @ts-ignore
path={pbu('/scores')}
/>
<Redirect <Redirect
// @ts-ignore // @ts-ignore
default default

@ -1,6 +1,6 @@
import { route } from 'preact-router' import { route } from 'preact-router'
import { useState } from 'preact/hooks' import { useState } from 'preact/hooks'
import { isAdministrator, isStudent, Solution as SolutionModel, SolutionId } from '../../shared/model' import { isAdministrator, Solution as SolutionModel, SolutionId } from '../../shared/model'
import { sortByStringKey } from '../../shared/utils' import { sortByStringKey } from '../../shared/utils'
import { prependBaseUrl, server } from '../api' import { prependBaseUrl, server } from '../api'
import { Header } from '../components/Header' import { Header } from '../components/Header'

@ -6,7 +6,6 @@ import { Header } from '../components/Header'
import { Problem } from '../components/Problem' import { Problem } from '../components/Problem'
import { Select } from '../components/Select' import { Select } from '../components/Select'
import { useResource, MetadataContext } from '../hooks' import { useResource, MetadataContext } from '../hooks'
import { useCurrentUser } from '../hooks/useCurrentUser'
function byTime(p: ProblemModel): string { function byTime(p: ProblemModel): string {
return p.createdAt return p.createdAt

@ -0,0 +1,79 @@
import { useContext, useState } from 'preact/hooks'
import { Solution as SolutionModel, SolutionStat, User } from '../../shared/model'
import { sortByNumericKey, sortByStringKey } from '../../shared/utils'
import { Header } from '../components/Header'
import { useResource, MetadataContext } from '../hooks'
export const ScoresPage = () => {
const metadata = useContext(MetadataContext)
metadata.title = `PHC Problemi`
metadata.description = 'Bacheca di problemi del PHC'
const [stats] = useResource<Record<string, SolutionStat>>(`/api/stats`, {})
type Column = 'student' | 'sent' | 'correct'
type Order = 'ascending' | 'descending'
type SortStateSpace = [Column, Order]
const [[sortStateColumn, sortStateOrder], setSortState] = useState<SortStateSpace>(['sent', 'descending'])
const transitionColumn: (k: Column) => Partial<Record<Column, Record<Order, (c: Column) => SortStateSpace>>> = k => ({
[k]: {
'ascending': (c: Column) => c === k ? [k, 'descending'] : [c, 'ascending'],
'descending': (c: Column) => c === k ? [k, 'ascending'] : [c, 'ascending']
}
})
const transitionMap: Record<Column, Record<Order, (c: Column) => SortStateSpace>> = {
...(transitionColumn('student') as Record<'student', Record<Order, (c: Column) => SortStateSpace>>),
...(transitionColumn('sent') as Record<'sent', Record<Order, (c: Column) => SortStateSpace>>),
...(transitionColumn('correct') as Record<'correct', Record<Order, (c: Column) => SortStateSpace>>),
}
let orderedStats
if (sortStateColumn === 'student') {
orderedStats = sortByStringKey(Object.entries(stats), ([user, s]) => user, sortStateOrder === 'ascending')
} else {
// @ts-ignore
orderedStats = sortByNumericKey(Object.entries(stats), ([user, s]) => s[sortStateColumn + 'SolutionsCount'], sortStateOrder === 'ascending')
}
return (
<>
<Header />
<main class="page-scores">
<div class="subtitle">Classifica</div>
<div class="table">
<div class="cell header">
<span>Studente</span>
<span onClick={() => setSortState(transitionMap[sortStateColumn][sortStateOrder]('student'))}>
<span class="material-symbols-outlined icon">{sortStateColumn === 'student' ? (sortStateOrder === 'ascending' ? 'expand_more' : 'expand_less') : 'unfold_more'}</span>
</span>
</div>
<div class="cell header">
<span>Soluzioni Inviate</span>
<span onClick={() => setSortState(transitionMap[sortStateColumn][sortStateOrder]('sent'))}>
<span class="material-symbols-outlined icon">{sortStateColumn === 'sent' ? (sortStateOrder === 'ascending' ? 'expand_more' : 'expand_less') : 'unfold_more'}</span>
</span>
</div>
<div class="cell header">
<span>Soluzioni Corrette</span>
<span onClick={() => setSortState(transitionMap[sortStateColumn][sortStateOrder]('correct'))}>
<span class="material-symbols-outlined icon">{sortStateColumn === 'correct' ? (sortStateOrder === 'ascending' ? 'expand_more' : 'expand_less') : 'unfold_more'}</span>
</span>
</div>
{orderedStats.map(([user, s]) => (
<>
<div class="cell">{user}</div>
<div class="cell">{s.sentSolutionsCount}</div>
<div class="cell">{s.correctSolutionsCount}</div>
</>
))}
</div>
</main >
</>
)
}

@ -223,7 +223,7 @@ p {
margin: 0; margin: 0;
} }
p + p { p+p {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@ -233,7 +233,7 @@ $heading-scale: 1.25;
@function pow($number, $exponent) { @function pow($number, $exponent) {
$value: 1; $value: 1;
@if $exponent > 0 { @if $exponent >0 {
@for $i from 1 through $exponent { @for $i from 1 through $exponent {
$value: $value * $number; $value: $value * $number;
} }
@ -347,6 +347,60 @@ main.page-problem {
} }
} }
main.page-scores {
.table {
display: grid;
grid-template-columns: auto auto auto;
box-shadow: -2px 4px 6px 1px #00000018, 0 0 4px 0px #00000010;
border-radius: 0.5rem;
background: #ffffff;
user-select: none;
.icon {
cursor: pointer;
display: grid;
place-content: center;
}
overflow: hidden;
.cell {
text-align: center;
padding: 1rem 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
border-left: 1px solid #ddd;
border-top: 1px solid #ddd;
&:nth-child(3n+1) {
border-left: none;
}
&:nth-child(1),
&:nth-child(2),
&:nth-child(3) {
border-top: none;
}
&.header {
font-weight: 400;
font-size: 22px;
background: #fdfdfd;
padding-right: 1rem;
}
}
}
}
// //
// Components // Components
// //
@ -473,6 +527,7 @@ header {
&.correct { &.correct {
border: 4px solid #00aa00; border: 4px solid #00aa00;
} }
&.wrong { &.wrong {
border: 4px solid #cc0000; border: 4px solid #cc0000;
} }
@ -531,10 +586,12 @@ header {
color: #1653a3; color: #1653a3;
font-style: italic; font-style: italic;
} }
.correct { .correct {
color: green; color: green;
// font-weight: 400; // font-weight: 400;
} }
.wrong { .wrong {
color: darkred; color: darkred;
// font-weight: 400; // font-weight: 400;
@ -635,7 +692,8 @@ header {
} }
// On mobile // On mobile
@media screen and (max-width: $device-s-width), (pointer: coarse) { @media screen and (max-width: $device-s-width),
(pointer: coarse) {
main { main {
padding: 2rem 1rem 6rem; padding: 2rem 1rem 6rem;
@ -702,7 +760,8 @@ header {
} }
} }
@media screen and (max-width: $device-m-width), (pointer: coarse) { @media screen and (max-width: $device-m-width),
(pointer: coarse) {
.markdown-editor { .markdown-editor {
grid-template-columns: auto; grid-template-columns: auto;
grid-template-rows: auto auto; grid-template-rows: auto auto;

@ -14,7 +14,7 @@ import { RenderFunction } from './shared/ssr'
// Load ".env" // Load ".env"
dotenv.config() dotenv.config()
const HTML_ROUTES = ['/', '/login', '/problem/:id', '/admin', '/profile'] const HTML_ROUTES = ['/', '/login', '/problem/:id', '/admin', '/profile', '/scores']
const config = { const config = {
isDevelopment: process.env.MODE === 'development', isDevelopment: process.env.MODE === 'development',

@ -32,6 +32,7 @@ import {
ProblemId, ProblemId,
Solution as SolutionModel, Solution as SolutionModel,
SolutionId, SolutionId,
SolutionStat,
User as UserModel, User as UserModel,
UserId, UserId,
} from '../shared/model' } from '../shared/model'
@ -208,6 +209,37 @@ export async function createApiRouter() {
res.json(id) res.json(id)
}) })
// questa route ritorna la lista di utenti con associato numero di soluzioni inviate, corrette.
r.get('/api/stats', async (req, res) => {
let queryUser = (req.query.user ?? null) as UserId | null
let solutions = await getSolutions(db)
if (queryUser !== null) {
solutions = solutions.filter(s => s.sentBy === queryUser)
}
const stats: Record<string, SolutionStat> = {}
solutions.forEach(s => {
if (!stats[s.sentBy]) {
stats[s.sentBy] = {
sentSolutionsCount: s.status !== "pending" ? 1 : 0,
correctSolutionsCount: s.status === "correct" ? 1 : 0
}
} else {
if (s.status !== "pending") {
stats[s.sentBy].sentSolutionsCount += 1
}
if (s.status === "correct") {
stats[s.sentBy].correctSolutionsCount += 1
}
}
})
return res.json(stats)
})
r.get('/api/solutions', async (req, res) => { r.get('/api/solutions', async (req, res) => {
let queryUser = (req.query.user ?? null) as UserId | null let queryUser = (req.query.user ?? null) as UserId | null
let queryProblem = (req.query.problem ?? null) as ProblemId | null let queryProblem = (req.query.problem ?? null) as ProblemId | null

@ -56,6 +56,11 @@ export type SolutionStatus = 'pending' | 'correct' | 'wrong'
export type SolutionId = Id<Solution> export type SolutionId = Id<Solution>
export type SolutionStat = {
sentSolutionsCount: number,
correctSolutionsCount: number,
}
export type Solution = { export type Solution = {
id: SolutionId id: SolutionId
createdAt: string createdAt: string

Loading…
Cancel
Save