feat: sentence trainer prototype

feat: layout learner prototype
This commit is contained in:
2025-01-16 17:12:56 +01:00
parent a3bf9ac32b
commit e37b38085d
17 changed files with 1056 additions and 260 deletions

View File

@@ -1,231 +1,24 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import {
words,
nextWord,
scores,
learnConfigDefault,
learnConfig,
learnConfigStored,
} from "$lib/learn/chords";
import { blur, fade } from "svelte/transition";
import ChordActionEdit from "../config/chords/ChordActionEdit.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
let recorder = $derived(new ReplayRecorder($nextWord));
let start = performance.now();
$effect(() => {
start = recorder && performance.now();
});
let chords: InferredChord[] = $state([]);
function onkeyboard(event: KeyboardEvent) {
recorder.next(event);
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
$inspect(chords);
$effect(() => {
const [chord] = chords;
if (!chord) return;
console.log(chord);
if (chord.output.trim() === $nextWord) {
scores.update((scores) => {
const score = Math.max(
$learnConfig.minScore,
$learnConfig.maxScore - (performance.now() - start) / 1000,
);
if (!scores[$nextWord]) {
scores[$nextWord] = {
score,
lastTyped: performance.now(),
total: 1,
};
return scores;
}
const oldScore = scores[$nextWord].score;
scores[$nextWord].score = lerp(
score,
oldScore,
$learnConfig.scoreBlend,
);
scores[$nextWord].lastTyped = performance.now();
scores[$nextWord].total += 1;
return scores;
});
}
});
function skip() {
button?.blur();
scores.update((scores) => {
return scores;
});
}
let button = $state<HTMLButtonElement>();
</script>
<h2>WIP</h2>
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
{#key $nextWord}
<h3>
{$nextWord}
{#if $scores[$nextWord!] === undefined}
<sup class="new-word">new</sup>
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
<sup class="weak">weak</sup>
{/if}
</h3>
<div class="chord" in:fade>
<CharRecorder replay={recorder.player} cursor={true}>
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
{#key $nextWord}
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
</div>
{/key}
<button onclick={skip} bind:this={button}>skip</button>
<section class="stats">
<table>
<thead>
<tr><th>Weak</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => a.score - b.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Strong</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.score - a.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Rehearse</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<details>
<summary>Settings</summary>
<button onclick={() => ($scores = {})}>Reset</button>
<table>
<tbody>
{#each Object.entries(learnConfigDefault) as [key, value]}
<tr>
<th>{key}</th>
<td
><input
type="number"
value={$learnConfig[key] ?? value}
step="0.1"
oninput={(event) =>
($learnConfigStored[key] = event.target.value)}
/>
</td>
<td>
<button
disabled={!$learnConfigStored[key]}
onclick={() => ($learnConfigStored[key] = undefined)}></button
>
</td>
</tr>
{/each}
</tbody>
</table>
</details>
<ul>
<li><a href="/learn/layout/">Layout</a></li>
<li><a href="/learn/chords/">Chords</a></li>
<li><a href="/learn/sentence/">Sentences</a></li>
</ul>
<style lang="scss">
@use "sass:math";
input {
background: none;
font: inherit;
color: inherit;
border: none;
width: 5ch;
text-align: right;
}
div {
min-width: 20ch;
padding: 1ch;
ul {
margin: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
list-style-type: none;
padding: 0;
}
.stats {
display: flex;
gap: 3em;
}
sup {
font-weight: normal;
font-size: 0.8em;
&.new-word {
color: var(--md-sys-color-primary);
}
&.weak {
color: var(--md-sys-color-error);
}
}
@for $i from 1 through 10 {
tr.decay:nth-child(#{$i}) {
opacity: 1 - math.div($i, 10);
}
a {
border: 1px solid var(--md-sys-color-outline);
width: 128px;
height: 128px;
}
</style>

View File

@@ -0,0 +1,231 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import {
words,
nextWord,
scores,
learnConfigDefault,
learnConfig,
learnConfigStored,
} from "$lib/learn/chords";
import { blur, fade } from "svelte/transition";
import ChordActionEdit from "../../config/chords/ChordActionEdit.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
let recorder = $derived(new ReplayRecorder($nextWord));
let start = performance.now();
$effect(() => {
start = recorder && performance.now();
});
let chords: InferredChord[] = $state([]);
function onkeyboard(event: KeyboardEvent) {
recorder.next(event);
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
$inspect(chords);
$effect(() => {
const [chord] = chords;
if (!chord) return;
console.log(chord);
if (chord.output.trim() === $nextWord) {
scores.update((scores) => {
const score = Math.max(
$learnConfig.minScore,
$learnConfig.maxScore - (performance.now() - start) / 1000,
);
if (!scores[$nextWord]) {
scores[$nextWord] = {
score,
lastTyped: performance.now(),
total: 1,
};
return scores;
}
const oldScore = scores[$nextWord].score;
scores[$nextWord].score = lerp(
score,
oldScore,
$learnConfig.scoreBlend,
);
scores[$nextWord].lastTyped = performance.now();
scores[$nextWord].total += 1;
return scores;
});
}
});
function skip() {
button?.blur();
scores.update((scores) => {
return scores;
});
}
let button = $state<HTMLButtonElement>();
</script>
<h2>WIP</h2>
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
{#key $nextWord}
<h3>
{$nextWord}
{#if $scores[$nextWord!] === undefined}
<sup class="new-word">new</sup>
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
<sup class="weak">weak</sup>
{/if}
</h3>
<div class="chord" in:fade>
<CharRecorder replay={recorder.player} cursor={true}>
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
{#key $nextWord}
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
</div>
{/key}
<button onclick={skip} bind:this={button}>skip</button>
<section class="stats">
<table>
<thead>
<tr><th>Weak</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => a.score - b.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Strong</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.score - a.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Rehearse</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<details>
<summary>Settings</summary>
<button onclick={() => ($scores = {})}>Reset</button>
<table>
<tbody>
{#each Object.entries(learnConfigDefault) as [key, value]}
<tr>
<th>{key}</th>
<td
><input
type="number"
value={$learnConfig[key] ?? value}
step="0.1"
oninput={(event) =>
($learnConfigStored[key] = event.target.value)}
/>
</td>
<td>
<button
disabled={!$learnConfigStored[key]}
onclick={() => ($learnConfigStored[key] = undefined)}></button
>
</td>
</tr>
{/each}
</tbody>
</table>
</details>
<style lang="scss">
@use "sass:math";
input {
background: none;
font: inherit;
color: inherit;
border: none;
width: 5ch;
text-align: right;
}
div {
min-width: 20ch;
padding: 1ch;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.stats {
display: flex;
gap: 3em;
}
sup {
font-weight: normal;
font-size: 0.8em;
&.new-word {
color: var(--md-sys-color-primary);
}
&.weak {
color: var(--md-sys-color-error);
}
}
@for $i from 1 through 10 {
tr.decay:nth-child(#{$i}) {
opacity: 1 - math.div($i, 10);
}
}
</style>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { share } from "$lib/share";
import tippy from "tippy.js";
import { mount, setContext, unmount } from "svelte";
import Layout from "$lib/components/layout/Layout.svelte";
import { charaFileToUriComponent } from "$lib/share/share-url";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import { writable, derived } from "svelte/store";
import { layout } from "$lib/undo-redo";
import Action from "$lib/components/Action.svelte";
import { serialPort } from "$lib/serial/connection";
let hasStarted = $state(false);
setContext<VisualLayoutConfig>("visual-layout-config", {
scale: 50,
inactiveScale: 0.5,
inactiveOpacity: 0.4,
strokeWidth: 1,
margin: 5,
fontSize: 9,
iconFontSize: 14,
});
const actions = derived(layout, (layout) => {
const result = new Set<number>();
for (const layer of layout) {
for (const key of layer) {
result.add(key.action);
}
}
return [...result];
});
const currentAction = writable(0);
const expected = derived(
[layout, currentAction],
([layout, currentAction]) => {
const result: Array<{ layer: number; key: number }> = [];
for (let layer = 0; layer <= layout.length; layer++) {
if (layout[layer] === undefined) {
continue;
}
for (let key = 0; key <= layout[layer].length; key++) {
if (layout[layer][key]?.action === currentAction) {
result.push({ layer, key });
}
}
}
return result;
},
);
const highlight = derived(
expected,
(expected) => new Set(expected.map(({ key }) => key)),
);
const highlightAction = derived(
currentAction,
(currentAction) => new Set([currentAction]),
);
const currentLayer = writable(0);
setContext("highlight", highlight);
setContext("highlight-action", highlightAction);
setContext("active-layer", currentLayer);
async function next() {
console.log("Next");
const nextAction = $actions[Math.floor(Math.random() * $actions.length)];
if (nextAction !== undefined) {
currentAction.set(nextAction);
currentLayer.set($expected[0]?.layer ?? 0);
const key = await $serialPort?.queryKey();
if ($expected.some(({ key: expectedKey }) => expectedKey === key)) {
console.log("Correct", key);
} else {
console.log("Incorrect", key);
}
next();
}
}
$effect(() => {
if ($serialPort && $layout[0]?.[0] && !hasStarted) {
hasStarted = true;
next();
}
});
</script>
<section>
<div class="challenge">
<Action display="inline-keys" action={$currentAction}></Action>
</div>
<Layout />
</section>
<style lang="scss">
.challenge {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100px;
font-size: 24px;
}
.input {
width: 100%;
height: 100px;
border: 1px solid black;
}
section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,422 @@
<script lang="ts">
import { page } from "$app/stores";
import { SvelteMap } from "svelte/reactivity";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { shuffleInPlace } from "$lib/util/shuffle";
import TrackWpm from "$lib/charrecorder/TrackWpm.svelte";
import { fly } from "svelte/transition";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import ChordHud from "$lib/charrecorder/ChordHud.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
import { onMount } from "svelte";
import TrackText from "$lib/charrecorder/TrackText.svelte";
import { browser } from "$app/environment";
function initialThresholds(): [slow: number, fast: number][] {
try {
return JSON.parse(localStorage.getItem("mastery-thresholds") ?? "");
} catch {
return [
[1500, 1050],
[3000, 2500],
[5000, 3500],
[6000, 5000],
];
}
}
let masteryThresholds: [slow: number, fast: number][] =
$state(initialThresholds());
let inputSentence = $derived(
(browser && $page.url.searchParams.get("sentence")) || "Hello World",
);
let devTools = $derived(
browser && $page.url.searchParams.get("dev") === "true",
);
let sentenceWords = $derived(inputSentence.split(" "));
let currentWord = $state("");
let wordStats = new SvelteMap<string, number[]>();
let wordMastery = new SvelteMap<string, number>();
let text = $state("");
let level = $state(0);
let lastWPM = $state(0);
let bestWPM = $state(0);
let wpm = $state(0);
let chords: InferredChord[] = $state([]);
let recorder = $state(new ReplayRecorder());
let cooldown = $state(false);
onMount(() => {
selectNextWord();
});
$effect(() => {
if (lastWPM > bestWPM) {
bestWPM = lastWPM;
}
});
$effect(() => {
localStorage.setItem(
"mastery-thresholds",
JSON.stringify(masteryThresholds),
);
});
let words = $derived.by(() => {
const words = inputSentence.trim().split(" ");
switch (level) {
case 0: {
shuffleInPlace(words);
return words;
}
case 1: {
const pairs = [];
for (let i = 0; i < words.length - 1; i++) {
pairs.push(`${words[i]} ${words[i + 1]}`);
}
shuffleInPlace(pairs);
return pairs;
}
case 2: {
const trios = [];
for (let i = 0; i < words.length - 2; i++) {
trios.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
}
shuffleInPlace(trios);
return trios;
}
default: {
return [inputSentence];
}
}
});
$effect(() => {
for (const [word, speeds] of wordStats.entries()) {
const level = word.split(" ").length - 1;
const masteryThreshold =
masteryThresholds[level] ?? masteryThresholds.at(-1)!;
const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
wordMastery.set(
word,
1 -
Math.min(
1,
Math.max(
0,
(averageSpeed - masteryThreshold[1]) /
(masteryThreshold[0] - masteryThreshold[1]),
),
),
);
}
});
let progress = $derived.by(() => {
return words.length > 0
? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
words.length
: 0;
});
$effect(() => {
if (progress === 1 && level < masteryThresholds.length - 1) {
level++;
}
});
function selectNextWord() {
const unmasteredWords = words
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
.filter(([, it]) => it !== 1);
unmasteredWords.sort(([, a], [, b]) => a - b);
let nextWord = unmasteredWords[0]?.[0] ?? words[0] ?? "ERROR";
for (const [word] of unmasteredWords) {
if (word === currentWord || Math.random() > 0.5) continue;
nextWord = word;
break;
}
text = "";
currentWord = nextWord;
recorder = new ReplayRecorder(nextWord);
}
function checkInput() {
if (recorder.player.stepper.challenge.length === 0) return;
const replay = recorder.finish(false);
const elapsed = replay.finish - replay.start!;
if (elapsed < masteryThresholds[level]![0]) {
lastWPM = wpm;
const prevStats = wordStats.get(currentWord) ?? [];
prevStats.push(elapsed);
wordStats.set(currentWord, prevStats.slice(-10));
}
cooldown = true;
setTimeout(() => {
selectNextWord();
cooldown = false;
});
}
$effect(() => {
if (!cooldown && text && text === currentWord) checkInput();
});
function onkey(event: KeyboardEvent) {
recorder.next(event);
}
</script>
<div>
<h1>Sentence Trainer</h1>
<div class="levels">
{#each masteryThresholds as _, i}
<button
class:active={level === i}
class:mastered={i < level || progress === 1}
class="threshold"
onclick={() => {
level = i;
selectNextWord();
}}
>
Level {i + 1}
</button>
{/each}
{#each masteryThresholds as _, i}
<div
class="progress"
style:--progress="{-100 *
(1 - (level === i ? progress : i < level ? 1 : 0))}%"
class:active={level === i}
></div>
{/each}
</div>
<div class="sentence">
{#each sentenceWords as _, i}
{#if i !== sentenceWords.length - 1}
{@const word = sentenceWords.slice(i, i + 2).join(" ")}
{@const mastery = wordMastery.get(word) ?? 0}
<div
class="arch"
class:mastered={mastery === 1}
style:opacity={mastery}
style:grid-row={(i % 2) + 1}
style:grid-column="{i + 1} / span 2"
style:border-bottom="none"
></div>
{/if}
{/each}
{#each sentenceWords as word, i}
{@const mastery = wordMastery.get(word)}
<div
class="word"
class:mastered={mastery === 1}
style:opacity={mastery ?? 0}
style:grid-row={3}
style:grid-column={i + 1}
>
{word}
</div>
{/each}
{#each sentenceWords as _, i}
{#if i < sentenceWords.length - 2}
{@const word = sentenceWords.slice(i, i + 3).join(" ")}
{@const mastery = wordMastery.get(word) ?? 0}
<div
class="arch"
class:mastered={mastery === 1}
style:opacity={mastery}
style:grid-row={(i % 3) + 4}
style:grid-column="{i + 1} / span 3"
style:border-top="none"
></div>
{/if}
{/each}
</div>
<ChordHud {chords} />
<div class="container">
<div
class="input-section"
onkeydown={onkey}
onkeyup={onkey}
tabindex="0"
role="textbox"
>
{#if level === masteryThresholds.length - 1 && progress === 1}
<div class="finish" in:fly={{ y: -50, duration: 500 }}>
You have mastered this sentence!
</div>
{:else}
{#key recorder}
<div
class="input"
out:fly={{ y: 50, duration: 200 }}
in:fly={{ y: -50, duration: 500 }}
>
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
<TrackText bind:text />
<TrackWpm bind:wpm />
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
{/if}
</div>
</div>
{#if devTools}
<div>Dev Tools</div>
<table>
<tbody>
{#each masteryThresholds as _, i}
<tr>
<th>L{i + 1}</th>
<td><input bind:value={masteryThresholds[i]![0]} /></td>
<td><input bind:value={masteryThresholds[i]![1]} /></td>
</tr>
{/each}
</tbody>
</table>
<table>
<tbody>
{#each wordStats.entries() as [word, stats]}
{@const mastery = wordMastery.get(word) ?? 0}
<tr>
<th>{word}</th>
<td
style:color="var(--md-sys-color-{mastery === 1
? 'primary'
: 'tertiary'})">{Math.round(mastery * 100)}%</td
>
{#each stats as stat}
<td>{stat}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style lang="scss">
.levels {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
button {
margin: 0;
font-size: 1rem;
}
}
.finish {
font-weight: bold;
grid-row: 1;
grid-column: 1;
color: var(--md-sys-color-primary);
text-align: center;
font-size: 1.5rem;
}
.sentence {
display: grid;
width: min-content;
gap: 4px 1ch;
grid-template-rows: repeat(4, auto);
margin-block: 1rem;
.word,
.arch {
transition: opacity 0.2s ease;
&.mastered {
color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
}
.arch {
border: 2px solid var(--md-sys-color-outline);
height: 8px;
}
}
.progress {
position: relative;
height: 1rem;
width: auto;
background: var(--md-sys-color-outline-variant);
border: none;
overflow: hidden;
grid-row: 2;
&::after {
content: "";
display: block;
height: 100%;
width: 100%;
background: var(--md-sys-color-primary);
transform: translateX(var(--progress));
transition: transform 0.2s;
}
}
.threshold {
width: auto;
justify-self: center;
opacity: 0.5;
transition: opacity 0.2s;
&.mastered,
&.active {
opacity: 1;
}
&.mastered {
color: var(--md-sys-color-primary);
}
}
.input-section {
display: grid;
cursor: text;
:global(.cursor) {
opacity: 0;
}
}
.input {
display: flex;
grid-row: 1;
grid-column: 1;
font-size: 1.5rem;
padding: 1rem;
max-width: 16cm;
outline: 2px dashed transparent;
border-radius: 0.25rem;
margin-block: 1rem;
transition:
outline 0.2s ease,
border-radius 0.2s ease;
}
.input-section:focus-within {
outline: none;
.input {
outline-color: var(--md-sys-color-primary);
border-radius: 1rem;
}
:global(.cursor) {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
let {
wordStats,
level,
masteryThresholds,
}: { wordStats: object; level: number; masteryThresholds: number[] } =
$props();
// Function to calculate average time for a word
function calculateAverageTime(times: number[]): number {
if (times.length === 0) return Number.NaN;
const totalTime = times.reduce((a, b) => a + b, 0);
return totalTime / times.length;
}
// Function to calculate WPM for each timing
function calculateWPM(elapsedTime: number, wordLength: number): number {
const minutesElapsed = elapsedTime / 60000;
return Math.floor(wordLength / minutesElapsed);
}
// Function to get the best WPM for a word
function getBestWPM(times: number[], wordLength: number): number {
if (times.length === 0) return Number.NaN;
const wpms = times.map((time) => calculateWPM(time, wordLength));
return Math.max(...wpms);
}
function getLastWPM(times: number[], wordLength: number) {
const lastTime = times[times.length - 1];
return lastTime === undefined
? Number.NaN
: calculateWPM(lastTime, wordLength);
}
</script>
<div class="stats-table">
<h2>Stats for Level {level}</h2>
<table>
<thead>
<tr>
<th>Word</th>
<th>Last WPM</th>
<!-- Individualized -->
<th>Best WPM</th>
<!-- Individualized -->
<th>Attempts</th>
<th>Average Time</th>
</tr>
</thead>
<tbody>
{#each Object.entries(wordStats) as [word, times]}
<tr>
<td>{word}</td>
<td>{getLastWPM(times, word.split(" ").length)}</td>
<!-- Individualized -->
<td>{getBestWPM(times, word.split(" ").length)}</td>
<!-- Individualized -->
<td>{times.length}</td>
<td>{calculateAverageTime(times)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.stats-table {
margin-top: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th,
td {
padding: 10px;
border: 1px solid #ccc;
text-align: left;
}
th {
background-color: #f4f4f4;
font-weight: bold;
}
</style>