feat: add progression tracking to game sessions and implement tutorial modal
All checks were successful
Migrate supabase / migrate (push) Successful in 15s

This commit is contained in:
2026-03-08 19:59:58 +01:00
parent fbdf6bc27c
commit 09794bdefd
4 changed files with 135 additions and 44 deletions

View File

@@ -23,6 +23,11 @@ type Session = {
completedAt?: string;
playerCount?: number;
expiresAt: string;
progression?: {
currentStep: number;
completedSteps: number;
totalSteps: number;
};
};
type ResolutionMetric = {
@@ -98,22 +103,38 @@ export const load: PageServerLoad = async () => {
const currentAndIncomingRaw = await db.query.gameSession.findMany({
where: and(eq(gameSession.isActive, 1), gt(gameSession.expiresAt, new Date())),
with: {
escapeGame: true,
players: true
escapeGame: {
with: {
steps: true
}
},
players: true,
progress: true
}
});
const currentAndIncomingSessions: Session[] = currentAndIncomingRaw
.map((session) => ({
id: session.id,
code: session.code,
gameName: session.escapeGame.title,
isActive: session.isActive === 1,
startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : undefined,
completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : undefined,
playerCount: session.players.length,
expiresAt: session.expiresAt.toISOString()
}))
.map((session) => {
const totalSteps = session.escapeGame.steps.length;
const completedSteps = session.progress.filter((p) => p.completedAt !== null).length;
const currentStep = completedSteps + 1;
return {
id: session.id,
code: session.code,
gameName: session.escapeGame.title,
isActive: session.isActive === 1,
startedAt: session.startedAt ? new Date(session.startedAt).toISOString() : undefined,
completedAt: session.completedAt ? new Date(session.completedAt).toISOString() : undefined,
playerCount: session.players.length,
expiresAt: session.expiresAt.toISOString(),
progression: totalSteps > 0 ? {
currentStep,
completedSteps,
totalSteps
} : undefined
};
})
.sort((a, b) => {
const aIsCurrent = Boolean(a.startedAt) && !a.completedAt;
const bIsCurrent = Boolean(b.startedAt) && !b.completedAt;

View File

@@ -96,6 +96,9 @@
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.admin.players}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Progression
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.admin.expires}
</th>
@@ -122,6 +125,28 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{session.playerCount || 0}
</td>
<td class="px-6 py-4 whitespace-nowrap">
{#if session.progression}
<div class="max-w-xs">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-gray-700">
Step {session.progression.currentStep} of {session.progression.totalSteps}
</span>
<span class="text-xs text-gray-500">
{Math.round((session.progression.completedSteps / session.progression.totalSteps) * 100)}%
</span>
</div>
<div class="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full bg-indigo-500 transition-all duration-300"
style="width: {(session.progression.completedSteps / session.progression.totalSteps) * 100}%"
></div>
</div>
</div>
{:else}
<span class="text-xs text-gray-400">No progress</span>
{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(session.expiresAt).toLocaleString()}
</td>

View File

@@ -7,6 +7,7 @@
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
let inventoryOpen = $state(false);
let tutorialOpen = $state(false);
let dragStartY = $state(0);
let dragCurrentY = $state(0);
@@ -23,6 +24,14 @@
dragCurrentY = 0;
};
const toggleTutorial = () => {
tutorialOpen = !tutorialOpen;
};
const closeTutorial = () => {
tutorialOpen = false;
};
const handleTouchStart = (e: TouchEvent) => {
dragStartY = e.touches[0].clientY;
dragCurrentY = e.touches[0].clientY;
@@ -71,15 +80,16 @@
</svg>
</button>
<a
href={resolve('/(game)/game/play/[sessionCode]/tutorial', { sessionCode: data.sessionCode })}
<button
type="button"
onclick={toggleTutorial}
class="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-center transition-colors hover:bg-indigo-100 flex items-center justify-center"
aria-label="{$t.gameplay.tutorial}"
>
<svg class="h-5 w-5 text-indigo-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</a>
</button>
{#if data.previousStepId}
<form method="GET" action={resolve('/(game)/game/play/[sessionCode]', { sessionCode: data.sessionCode })}>
@@ -186,4 +196,68 @@
</div>
</div>
{/if}
{#if tutorialOpen}
<div
class="fixed inset-0 z-40 bg-black/40 transition-opacity duration-300"
onclick={closeTutorial}
onkeydown={(e) => {
if (e.key === 'Escape') closeTutorial();
}}
role="button"
tabindex={0}
></div>
<div
class="fixed inset-0 z-50 flex items-center justify-center px-4 py-8"
role="dialog"
aria-label="Tutorial"
aria-modal="true"
>
<div class="w-full max-w-2xl rounded-2xl border border-gray-200 bg-white p-5 shadow-lg sm:p-7">
<div class="flex items-start justify-between mb-5">
<div>
<h1 class="text-2xl font-bold text-gray-900">{$t.gameplay.tutorialTitle}</h1>
<p class="mt-1 text-sm text-gray-700 sm:text-base">{$t.gameplay.tutorialIntro}</p>
</div>
<button
type="button"
onclick={closeTutorial}
class="text-gray-400 hover:text-gray-600"
aria-label="Close tutorial"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-3">
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">1</span>
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialPrevious}</p>
</div>
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">2</span>
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialInventory}</p>
</div>
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">3</span>
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialNext}</p>
</div>
</div>
<p class="mt-5 rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-2 text-xs text-indigo-800 sm:text-sm">
{$t.gameplay.tutorial}: {$t.gameplay.inventory}, {$t.gameplay.previous}, {$t.gameplay.next}
</p>
<button
type="button"
onclick={closeTutorial}
class="mt-6 w-full rounded-lg bg-indigo-600 px-4 py-3 text-base font-semibold text-white transition-colors hover:bg-indigo-700"
>
Close
</button>
</div>
</div>
{/if}
{/if}

View File

@@ -1,29 +0,0 @@
<script lang="ts">
import { t } from '$lib/i18n';
</script>
<div class="bg-gray-50 px-3 py-5 sm:px-4 sm:py-8">
<div class="mx-auto max-w-2xl rounded-2xl border border-gray-200 bg-white p-5 shadow-sm sm:p-7">
<h1 class="mb-2 text-2xl font-bold text-gray-900">{$t.gameplay.tutorialTitle}</h1>
<p class="mb-5 text-sm text-gray-700 sm:text-base">{$t.gameplay.tutorialIntro}</p>
<div class="space-y-3">
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">1</span>
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialPrevious}</p>
</div>
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">2</span>
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialInventory}</p>
</div>
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-gray-50 p-4">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-xs font-semibold text-gray-700">3</span>
<p class="text-sm text-gray-800 sm:text-base">{$t.gameplay.tutorialNext}</p>
</div>
</div>
<p class="mt-5 rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-2 text-xs text-indigo-800 sm:text-sm">
{$t.gameplay.tutorial}: {$t.gameplay.inventory}, {$t.gameplay.previous}, {$t.gameplay.next}
</p>
</div>
</div>