diff --git a/src/lib/data.ts b/src/lib/data.ts index cf26ee642b8e04b85d2253eb05d67a80b21a7d19..bf92eed3b24fb43f0dbdea15f39b15429fc843e7 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -5,7 +5,7 @@ import { PROMOTION_QUERY, USER_DETAILS_QUERY } from './graphql/queries'; type UserId = string; type PromoCache = { lastUpdateMs: number; - promo: Set<UserId>; + promotion: Set<UserId>; }; const cache = new Map<number, PromoCache>(); @@ -26,8 +26,8 @@ async function cacheImages(users: UserId[]): Promise<void> { } } -async function fetchPromotion(promotion: number): Promise<PromoCache> { - const array = await Array.fromAsync(pageIterator(PROMOTION_QUERY, { promotion })); +async function fetchPromotion(year: number): Promise<PromoCache> { + const array = await Array.fromAsync(pageIterator(PROMOTION_QUERY, { year })); const users = array.map((node) => node.id); // background task @@ -35,30 +35,31 @@ async function fetchPromotion(promotion: number): Promise<PromoCache> { return { lastUpdateMs: new Date().getTime(), - promo: new Set(users) + promotion: new Set(users) }; } -export async function getPromotion(promo: number): Promise<Set<UserId>> { - if (cache.has(promo)) { - const data = cache.get(promo)!; - if (new Date().getTime() - data.lastUpdateMs < CACHE_DURATION_MS) return data.promo; +export async function getPromotion(year: number): Promise<Set<UserId>> { + if (cache.has(year)) { + const data = cache.get(year)!; + if (new Date().getTime() - data.lastUpdateMs < CACHE_DURATION_MS) return data.promotion; } - const freshData = await fetchPromotion(promo); - cache.set(promo, freshData); - return freshData.promo; + console.log('year', year, 'not in cache, fetching'); + const freshData = await fetchPromotion(year); + cache.set(year, freshData); + return freshData.promotion; } export async function* promotionIterator( min: number, max: number ): AsyncGenerator<UserId, void, undefined> { - for (let i = min; i <= max; i++) { - const promo = await getPromotion(i); - yield* promo; + for (let i = min; i < max; i++) { + const promotion = await getPromotion(i); + yield* promotion; } } -export function getPromotionRange(min: number, max: number): Promise<Set<UserId>> { +export function getPromotionRange(min: number, max = min): Promise<Set<UserId>> { return Array.fromAsync(promotionIterator(min, max)).then((array) => new Set(array)); } diff --git a/src/lib/game.ts b/src/lib/game.ts index 5ad81ad880d41462c91bf09c702b0f15d5e8fb7d..dcd2697a876147c51d4c9ecdb2d04f56647b8eae 100644 --- a/src/lib/game.ts +++ b/src/lib/game.ts @@ -3,6 +3,7 @@ import { SecureCookie } from './cookie'; import { z } from 'zod'; export enum GameStage { + NEW, PLAYING, SOLUTION, NEXT, @@ -15,6 +16,9 @@ export const gameStateSchema = z.object({ score: z.number(), options: z.array(z.string()), stage: z.nativeEnum(GameStage), + year: z.number(), + maxYear: z.number().optional(), + label: z.string(), solution: z.string() }); @@ -22,7 +26,7 @@ export type GameState = z.infer<typeof gameStateSchema>; export class Game { state: GameState; - protected cookie_name = 'qui-est-ce'; + protected cookie_name = 'quiestce-quiz'; protected cookie: SecureCookie<GameState>; constructor(protected cookies: Cookies) { @@ -32,12 +36,14 @@ export class Game { protected defaultState(): GameState { return { - stage: GameStage.NEXT, + stage: GameStage.NEW, history: [], step: 0, score: 0, + year: 0, options: [], - solution: '' + solution: '', + label: '?' }; } diff --git a/src/lib/graphql/queries.ts b/src/lib/graphql/queries.ts index c9bb02d93671c3f85bb9fe58aea82e47a9bb7bd7..f3aa344b015407f234b11ec08c66b8b6c4f8ae84 100644 --- a/src/lib/graphql/queries.ts +++ b/src/lib/graphql/queries.ts @@ -1,11 +1,11 @@ import { graphql } from '.'; export const PROMOTION_QUERY = graphql(` - query GetPromotion($first: Int!, $after: String, $promotion: Int!) { + query GetYear($first: Int!, $after: String, $year: Int!) { page: users( first: $first after: $after - filter: { promotion: { eq: [$promotion] }, nickname: { null: false }, photo: { null: false } } + filter: { year: { eq: [$year] }, nickname: { null: false }, photo: { null: false } } ) { pageInfo { endCursor diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 705385da79df790db128b858708751cf3cea8f39..c92a033577a09462f8d171059cc79323ff802668 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -11,3 +11,5 @@ export function getRandomItems<T>(array: T[], count: number): T[] { items.push(item); return items; } + +export const sum = (a: number, b: number) => a + b; diff --git a/src/routes/quiz/+page.server.ts b/src/routes/quiz/+page.server.ts index ec9a25ef5e591c1b492d3ed9d296b267d27f31f0..943b4f0f61836b71ec6a298dab4b224ad729bfa0 100644 --- a/src/routes/quiz/+page.server.ts +++ b/src/routes/quiz/+page.server.ts @@ -3,8 +3,8 @@ import { zod } from 'sveltekit-superforms/adapters'; import { schema } from './schema'; import { fail, redirect } from '@sveltejs/kit'; import { getRandomItems } from '$lib/utils'; -import { GameStage, Game } from '$lib/game'; -import { getPromotion } from '$lib/data'; +import { GameStage, Game, type GameState } from '$lib/game'; +import { getPromotionRange } from '$lib/data'; import { client } from '$lib/graphql'; import { USER_DETAILS_QUERY } from '$lib/graphql/queries'; import { handleGqlError } from '$lib/graphql/error'; @@ -14,21 +14,30 @@ type Option = { label: string; }; -export async function load(event) { - const game = new Game(event.cookies); - - if (game.state.stage === GameStage.GAME_OVER) redirect(303, '/quiz/game-over'); +async function next(state: GameState) { + const all = await getPromotionRange(state.year, state.maxYear); + const previous = new Set(state.history); + const available = all.difference(previous); - if (game.state.stage === GameStage.NEXT) { - const all = await getPromotion(2023); + state.options = getRandomItems(Array.from(available), 4); + state.solution = getRandomItems([...state.options], 1)[0]; + state.stage = GameStage.PLAYING; + state.step++; +} - const previous = new Set(game.state.history); - const available = all.difference(previous); +export async function load(event) { + const game = new Game(event.cookies); - game.state.options = getRandomItems(Array.from(available), 4); - game.state.solution = getRandomItems([...game.state.options], 1)[0]; - game.state.stage = GameStage.PLAYING; - game.state.step++; + switch (game.state.stage) { + case GameStage.NEW: + redirect(303, '/quiz/new'); + break; + case GameStage.GAME_OVER: + redirect(303, '/quiz/game-over'); + break; + case GameStage.NEXT: + await next(game.state); + break; } const details = await client @@ -46,7 +55,14 @@ export async function load(event) { const photo = users.find((node) => node.id === game.state.solution)?.photo; const form = await superValidate(zod(schema)); - return { form, options, score: game.state.score, step: game.state.step, photo }; + return { + form, + options, + score: game.state.score, + step: game.state.step, + photo, + label: game.state.label + }; } export const actions = { diff --git a/src/routes/quiz/+page.svelte b/src/routes/quiz/+page.svelte index 5ef86b11af1b52738ed4215eaa71b4d4e81d3f3b..4533b4c700a084b28a86fff6c5fcc783ba93b399 100644 --- a/src/routes/quiz/+page.svelte +++ b/src/routes/quiz/+page.svelte @@ -5,29 +5,31 @@ export let data; export let form; - const form2 = superForm(data.form, { + const sForm = superForm(data.form, { // On récupère les valeurs après affichage des résultats resetForm: false }); - const { form: formData, enhance } = form2; + const { form: formData, enhance } = sForm; const questionAmount = 10; </script> <div class="relative mx-auto my-12 w-full grow"> <section class="relative m-auto flex flex-col items-center"> - <h1 class="absolute -top-8 text-xl">Qui est-ce ?</h1> + <h1 class="absolute -top-8 text-xl">Qui est-ce ? {data.label}</h1> <div class="relative"> - <span class="absolute -left-14 flex flex-col items-end"> - <span class="text-9xl leading-[0.4] text-random-300"> {data.step} </span> - <span> / {questionAmount} </span> - </span> - <span - class="absolute right-[calc(100%_+_0.5rem)] top-24 whitespace-nowrap pt-4 text-xl text-zinc-800 before:absolute before:right-0 before:top-0 before:h-1.5 before:w-10 before:bg-zinc-800" - > - {data.score} - <small>pts</small> + <span class="absolute right-[calc(100%_+_0.5rem)] text-right"> + <span class="flex flex-col items-end"> + <span class="text-9xl leading-[0.4] text-random-300"> {data.step} </span> + <span> / {questionAmount} </span> + </span> + <span class="inline-block h-1.5 w-10 bg-zinc-800"></span> + <br /> + <span class="top-24 pt-4 text-xl text-zinc-800"> + {data.score} + <small>pts</small> + </span> </span> <div class="relative z-10 flex h-64 w-64 items-center justify-center overflow-hidden rounded-2xl border-6 border-solid border-zinc-800 bg-white before:absolute before:-z-10 before:h-32 before:w-32 before:rounded-full before:bg-slate-300 before:opacity-100 before:transition-[0.65s] before:duration-[ease-in-out] after:absolute after:-z-10 after:h-32 after:w-32 after:scale-0 after:rounded-full after:border-solid after:border-slate-300 after:transition-[0.4s] after:duration-[ease-in-out]" @@ -35,7 +37,7 @@ <img src={data.photo?.url} alt="Chargement..." class="w-auto" /> </div> <form method="post" use:enhance> - <Fieldset form={form2} name="choice"> + <Fieldset form={sForm} name="choice"> <div class="relative -top-6 z-20 mx-auto my-0 flex w-44 flex-col items-center space-y-0.5 rounded-2xl bg-zinc-800 px-5 py-0" > @@ -53,8 +55,7 @@ /> <Label tabindex={0} - data-solution={form?.solution || null} - class="relative block cursor-pointer select-none overflow-hidden rounded-2xl border-6 border-solid border-zinc-800 bg-slate-300 p-2 text-center text-lg transition-[0.45s] before:absolute before:left-2/4 before:top-2/4 before:-z-10 before:h-[200px] before:w-[200px] before:-translate-x-2/4 before:-translate-y-2/4 before:scale-0 before:rounded-full before:bg-indigo-300 before:transition-[0.2s] before:duration-[ease-in-out] focus:[outline:none] active:before:-translate-x-2/4 active:before:-translate-y-2/4 active:before:scale-100 peer-enabled:hover:translate-y-[-3px] peer-enabled:hover:bg-slate-400 peer-enabled:focus:border-indigo-500 peer-checked:peer-enabled:bg-indigo-300 data-[solution]:cursor-default data-[solution]:text-[#94acbd] peer-checked:data-[solution]:bg-red-400 peer-checked:data-[solution]:text-inherit peer-data-[valid]:!bg-lime-400 peer-data-[valid]:text-inherit" + class="relative block cursor-pointer select-none overflow-hidden rounded-2xl border-6 border-solid border-zinc-800 bg-slate-300 p-2 text-center text-lg transition-[0.45s] before:absolute before:left-2/4 before:top-2/4 before:-z-10 before:h-[200px] before:w-[200px] before:-translate-x-2/4 before:-translate-y-2/4 before:scale-0 before:rounded-full before:bg-indigo-300 before:transition-[0.2s] before:duration-[ease-in-out] focus:[outline:none] active:before:-translate-x-2/4 active:before:-translate-y-2/4 active:before:scale-100 peer-enabled:hover:translate-y-[-3px] peer-enabled:hover:bg-slate-400 peer-enabled:focus:border-indigo-500 peer-checked:peer-enabled:bg-indigo-300 peer-disabled:cursor-default peer-disabled:text-[#94acbd] peer-checked:peer-disabled:bg-red-400 peer-checked:peer-disabled:text-inherit peer-data-[valid]:!bg-lime-400 peer-data-[valid]:text-inherit" > {option.label} </Label> diff --git a/src/routes/quiz/new/+page.server.ts b/src/routes/quiz/new/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..f839073e5defb3d2beca3fd9e544b55ecc736230 --- /dev/null +++ b/src/routes/quiz/new/+page.server.ts @@ -0,0 +1,109 @@ +import { superValidate } from 'sveltekit-superforms'; +import { zod } from 'sveltekit-superforms/adapters'; +import { schema } from './schema'; +import { fail, redirect } from '@sveltejs/kit'; +import { Game, GameStage } from '$lib/game'; +import { getPromotion } from '$lib/data'; +import images from './images'; +import { sum } from '$lib/utils'; + +type BaseLevel = { + year: number; + maxYear?: number; + name: string; + image: string; +}; + +type Level = BaseLevel & { + size: number; +}; + +const VIIEUX_YEAR = 20; + +export async function load(event) { + const game = new Game(event.cookies); + + if (game.state.stage !== GameStage.NEW) redirect(303, '/quiz'); + + const baseLevels: BaseLevel[] = [ + { + year: 1, + name: '1A', + image: images.baby + }, + { + year: 2, + name: '2A', + image: images.baby + }, + { + year: 3, + name: '3A', + image: images.student + }, + { + year: 1, + maxYear: 3, + name: '1-3A', + image: images.student + }, + { + year: 4, + name: '4A', + image: images.student + }, + { + year: 5, + name: '5A', + image: images.student + }, + { + year: 4, + maxYear: VIIEUX_YEAR, + name: 'Viieux', + image: images.student + }, + { + year: 1, + maxYear: VIIEUX_YEAR, + name: 'IIEns', + image: images.student + } + ]; + + const promoSizes = await Promise.all( + Array.from(new Array(VIIEUX_YEAR), (_, i) => getPromotion(i + 1).then((promo) => promo.size)) + ); + + const levels: Level[] = baseLevels.map((level) => { + const end = (level.maxYear ?? level.year) + 1; + return { + size: promoSizes.slice(level.year, end).reduce(sum, 0), + ...level + }; + }); + + const form = await superValidate(zod(schema)); + return { form, levels }; +} + +export const actions = { + async default(event) { + const form = await superValidate(event, zod(schema)); + + if (!form.valid) { + return fail(400, { form }); + } + + const state = new Game(event.cookies); + + state.state.year = form.data.year; + state.state.label = form.data.label; + state.state.maxYear = form.data.maxYear; + state.state.stage = GameStage.NEXT; + + state.save(); + + redirect(303, '/quiz'); + } +}; diff --git a/src/routes/quiz/new/+page.svelte b/src/routes/quiz/new/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..d7bc2c15f12787e3e40aed0f52befe904b0c31f1 --- /dev/null +++ b/src/routes/quiz/new/+page.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + import { superForm } from 'sveltekit-superforms'; + import { Control, Field } from 'formsnap'; + + export let data; + + const form = superForm(data.form, { + // On récupère les valeurs après affichage des résultats + resetForm: false + }); + const { enhance } = form; +</script> + +<div class="m-16"> + <div class="flex flex-wrap items-center justify-center gap-8 text-gray-700"> + {#each data.levels as level} + <form method="post" use:enhance> + <Field {form} name="year"> + <Control let:attrs> + <input type="hidden" {...attrs} value={level.year} /> + </Control> + </Field> + <Field {form} name="maxYear"> + <Control let:attrs> + <input type="hidden" {...attrs} value={level.maxYear} /> + </Control> + </Field> + <Field {form} name="label"> + <Control let:attrs> + <input type="hidden" {...attrs} value={level.name} /> + </Control> + </Field> + <button + type="submit" + class="col-span-1 flex h-72 w-72 flex-col divide-y divide-gray-200 rounded-2xl border-6 border-solid border-zinc-800 bg-slate-100 text-center shadow transition-[0.45s] hover:translate-y-[-3px] hover:bg-slate-300 focus:border-indigo-500" + > + <div class="flex flex-1 flex-col p-8"> + <img class="mx-auto h-32 w-32 flex-shrink-0" src={level.image} alt="" /> + <h1 class="mt-4 text-6xl font-medium">{level.name}</h1> + <dl class="mt-1 flex flex-grow flex-col justify-between"> + <dd class="text-sm font-light text-gray-500">{level.size} personnes</dd> + </dl> + </div> + </button> + </form> + {/each} + </div> +</div> diff --git a/src/routes/quiz/new/images/baby.png b/src/routes/quiz/new/images/baby.png new file mode 100644 index 0000000000000000000000000000000000000000..60d09632825cea2366b7f0f19f7f2802ec29042c Binary files /dev/null and b/src/routes/quiz/new/images/baby.png differ diff --git a/src/routes/quiz/new/images/index.ts b/src/routes/quiz/new/images/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a947a639425f81e7eb45c8e11b46e1c8ba4f5810 --- /dev/null +++ b/src/routes/quiz/new/images/index.ts @@ -0,0 +1,7 @@ +import baby from './baby.png'; +import student from './student.png'; + +export default { + baby, + student +}; diff --git a/src/routes/quiz/new/images/student.png b/src/routes/quiz/new/images/student.png new file mode 100644 index 0000000000000000000000000000000000000000..4df8222c20902f02cdb712ab556b047e1fa2ad68 Binary files /dev/null and b/src/routes/quiz/new/images/student.png differ diff --git a/src/routes/quiz/new/schema.ts b/src/routes/quiz/new/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..d561d4e5638964a0596660da8115716e51fb6aa9 --- /dev/null +++ b/src/routes/quiz/new/schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const schema = z.object({ + year: z.number(), + maxYear: z.number().optional(), + label: z.string() +});