Add highscores page
This commit is contained in:
@@ -10,6 +10,7 @@ import { HomePage } from './pages/HomePage'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { ProblemPage } from './pages/ProblemPage'
|
||||
import { ProfilePage } from './pages/ProfilePage'
|
||||
import { ScoresPage } from './pages/ScoresPage'
|
||||
|
||||
const Redirect = ({ to }: { to: string }) => {
|
||||
useEffect(() => {
|
||||
@@ -50,6 +51,10 @@ export const App = ({ url }: { url?: string }) => {
|
||||
// @ts-ignore
|
||||
path={pbu('/admin')}
|
||||
/>
|
||||
<ScoresPage
|
||||
// @ts-ignore
|
||||
path={pbu('/scores')}
|
||||
/>
|
||||
<Redirect
|
||||
// @ts-ignore
|
||||
default
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { route } from 'preact-router'
|
||||
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 { prependBaseUrl, server } from '../api'
|
||||
import { Header } from '../components/Header'
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Header } from '../components/Header'
|
||||
import { Problem } from '../components/Problem'
|
||||
import { Select } from '../components/Select'
|
||||
import { useResource, MetadataContext } from '../hooks'
|
||||
import { useCurrentUser } from '../hooks/useCurrentUser'
|
||||
|
||||
function byTime(p: ProblemModel): string {
|
||||
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 >
|
||||
</>
|
||||
)
|
||||
}
|
||||
+64
-5
@@ -223,7 +223,7 @@ p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p + p {
|
||||
p+p {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ $heading-scale: 1.25;
|
||||
@function pow($number, $exponent) {
|
||||
$value: 1;
|
||||
|
||||
@if $exponent > 0 {
|
||||
@if $exponent >0 {
|
||||
@for $i from 1 through $exponent {
|
||||
$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
|
||||
//
|
||||
@@ -473,6 +527,7 @@ header {
|
||||
&.correct {
|
||||
border: 4px solid #00aa00;
|
||||
}
|
||||
|
||||
&.wrong {
|
||||
border: 4px solid #cc0000;
|
||||
}
|
||||
@@ -531,10 +586,12 @@ header {
|
||||
color: #1653a3;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.correct {
|
||||
color: green;
|
||||
// font-weight: 400;
|
||||
}
|
||||
|
||||
.wrong {
|
||||
color: darkred;
|
||||
// font-weight: 400;
|
||||
@@ -635,7 +692,8 @@ header {
|
||||
}
|
||||
|
||||
// On mobile
|
||||
@media screen and (max-width: $device-s-width), (pointer: coarse) {
|
||||
@media screen and (max-width: $device-s-width),
|
||||
(pointer: coarse) {
|
||||
main {
|
||||
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 {
|
||||
grid-template-columns: auto;
|
||||
grid-template-rows: auto auto;
|
||||
@@ -712,4 +771,4 @@ header {
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { RenderFunction } from './shared/ssr'
|
||||
// Load ".env"
|
||||
dotenv.config()
|
||||
|
||||
const HTML_ROUTES = ['/', '/login', '/problem/:id', '/admin', '/profile']
|
||||
const HTML_ROUTES = ['/', '/login', '/problem/:id', '/admin', '/profile', '/scores']
|
||||
|
||||
const config = {
|
||||
isDevelopment: process.env.MODE === 'development',
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
ProblemId,
|
||||
Solution as SolutionModel,
|
||||
SolutionId,
|
||||
SolutionStat,
|
||||
User as UserModel,
|
||||
UserId,
|
||||
} from '../shared/model'
|
||||
@@ -208,6 +209,37 @@ export async function createApiRouter() {
|
||||
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) => {
|
||||
let queryUser = (req.query.user ?? null) as UserId | 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 SolutionStat = {
|
||||
sentSolutionsCount: number,
|
||||
correctSolutionsCount: number,
|
||||
}
|
||||
|
||||
export type Solution = {
|
||||
id: SolutionId
|
||||
createdAt: string
|
||||
|
||||
Reference in New Issue
Block a user