Files
website/src/routes/projects/game-tracker/osu/+page.svelte

603 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount } from 'svelte';
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import * as AlertDialog from '#lib/components/ui/alert-dialog/index.js';
import * as Tabs from '#lib/components/ui/tabs/index.js';
import * as Table from '#lib/components/ui/table/index.js';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
import { Badge } from '#lib/components/ui/badge/index.js';
import GameLogo from '#lib/game-tracker/GameLogo.svelte';
import ProgressMeter from '#lib/game-tracker/ProgressMeter.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
type Entry = (typeof data.entries)[number];
let tab = $state('goals');
let open = $state(false);
let deleteOpen = $state(false);
let editing = $state<Entry | null>(null);
let kind = $state<'score' | 'goal' | 'training'>('goal');
let saving = $state(false);
let search = $state('');
let modeFilter = $state('all');
let skillFilter = $state('stamina');
let minDuration = $state(30);
const skills = ['stamina', 'speed', 'aim', 'reading', 'rhythm', 'finger-control', 'consistency'];
const modeName: Record<string, string> = {
osu: 'osu!',
taiko: 'osu!taiko',
fruits: 'osu!catch',
mania: 'osu!mania'
};
const scoped = $derived(
data.entries.filter(
(e) =>
(modeFilter === 'all' || e.mode === modeFilter) &&
`${e.title} ${e.notes} ${e.mods}`.toLowerCase().includes(search.toLowerCase())
)
);
const scores = $derived(scoped.filter((e) => e.kind === 'score'));
const goals = $derived(scoped.filter((e) => e.kind === 'goal'));
const sessions = $derived(scoped.filter((e) => e.kind === 'training'));
const allGoals = $derived(data.entries.filter((e) => e.kind === 'goal'));
const eligible = $derived(
sessions.filter((s) => s.skill === skillFilter && (s.duration ?? 0) >= minDuration)
);
const best = $derived(eligible.reduce((n, e) => Math.max(n, (e.bpm ?? 0) * Number(e.rate)), 0));
const chart = $derived(
[...eligible].sort((a, b) => a.playedOn.localeCompare(b.playedOn) || a.id - b.id).slice(-30)
);
const maxBpm = $derived(Math.max(1, ...chart.map((e) => (e.bpm ?? 0) * Number(e.rate))));
const minBpm = $derived(
Math.max(0, Math.min(...chart.map((e) => (e.bpm ?? 0) * Number(e.rate))) - 20)
);
function create(next: typeof kind, entry: Entry | null = null) {
kind = next;
editing = entry;
open = true;
}
function bpm(entry: Entry) {
return Math.round((entry.bpm ?? 0) * Number(entry.rate) * 10) / 10;
}
</script>
<div class="mx-auto max-w-6xl px-3 py-4 sm:px-5">
<a href={resolve('/trackers/games')} class="text-sm text-muted-foreground hover:text-foreground"
>← game tracker</a
>
<div class="mt-3 flex flex-wrap items-center gap-3">
<GameLogo slug="osu" name="osu!" class="h-12 w-12 rounded-full bg-pink-500/10 text-pink-500" />
<div class="flex-1">
<p class="text-xs font-semibold tracking-[.2em] text-pink-500 uppercase">One more try</p>
<h1 class="mt-1 text-2xl font-semibold tracking-tight">osu! training</h1>
<p class="mt-1 text-xs text-muted-foreground">
Chase the FC. Build your stamina. Remember the scores that mattered.
</p>
</div>
<Button
disabled={!hydrated || !data.user}
onclick={() => create(tab === 'scores' ? 'score' : tab === 'training' ? 'training' : 'goal')}
>+ {tab === 'scores'
? 'Record a score'
: tab === 'training'
? 'Log practice'
: 'Set a goal'}</Button
>
</div>
{#if !data.user}<p class="mt-6">
<a class="underline" href={resolve('login?returnTo=/projects/game-tracker/osu')}>Sign in</a> to
start your training journal.
</p>{/if}
{#if form?.message && !open && !deleteOpen}<p class="mt-4 text-destructive" role="alert">
{form.message}
</p>{/if}
<div class="my-3 grid grid-cols-3 gap-2">
<div class="rounded-xl border px-3 py-2">
<p class="text-xl font-semibold tabular-nums">
{data.entries.filter((e) => e.kind === 'score').length}
</p>
<p class="mt-1 text-xs text-muted-foreground">memorable scores</p>
</div>
<div class="rounded-xl border px-3 py-2">
<p class="text-xl font-semibold tabular-nums">
{data.entries.filter((e) => e.kind === 'goal' && e.achieved).length}<span
class="text-base text-muted-foreground"
>
/ {data.entries.filter((e) => e.kind === 'goal').length}</span
>
</p>
<p class="mt-1 text-xs text-muted-foreground">goals achieved</p>
</div>
<div class="rounded-xl border px-3 py-2">
<p class="text-xl font-semibold tabular-nums">
{Math.round(
data.entries
.filter((e) => e.kind === 'training')
.reduce((n, e) => n + (e.duration ?? 0), 0) / 60
)}
</p>
<p class="mt-1 text-xs text-muted-foreground">minutes of logged practice</p>
</div>
</div>
<div class="mb-3 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 sm:grid-cols-4">
<ProgressMeter
label="All map goals"
progress={{ value: allGoals.filter((g) => g.achieved).length, total: allGoals.length }}
color="pink"
compact
/>
{#each [{ kind: 'pass', label: 'Pass goals' }, { kind: 'fc', label: 'Full combo goals' }, { kind: 'accuracy', label: 'Accuracy goals' }] as group (group.kind)}
<ProgressMeter
label={group.label}
progress={{
value: allGoals.filter((g) => g.goal === group.kind && g.achieved).length,
total: allGoals.filter((g) => g.goal === group.kind).length
}}
color="pink"
compact
/>
{/each}
</div>
<div class="mb-5 flex gap-3">
<Input
type="search"
bind:value={search}
aria-label="Search osu journal"
placeholder="Search maps, scores or notes…"
/><NativeSelect aria-label="Game mode" bind:value={modeFilter}
><option value="all">All modes</option
>{#each Object.entries(modeName) as [value, label] (value)}<option {value}>{label}</option
>{/each}</NativeSelect
>
</div>
<Tabs.Root bind:value={tab}
><Tabs.List
><Tabs.Trigger disabled={!hydrated} value="goals">Map goals</Tabs.Trigger><Tabs.Trigger
disabled={!hydrated}
value="scores">Score showcase</Tabs.Trigger
><Tabs.Trigger disabled={!hydrated} value="training">Training log</Tabs.Trigger></Tabs.List
>
<Tabs.Content value="goals" class="mt-5"
><div class="grid gap-4 sm:grid-cols-2">
{#each goals as goal (goal.id)}<article class="rounded-xl border bg-card p-5">
<div class="flex items-center justify-between">
<Badge variant={goal.achieved ? 'secondary' : 'outline'}
>{goal.achieved ? '✓ achieved' : goal.goal.toUpperCase()}</Badge
><span class="text-xs text-muted-foreground">{modeName[goal.mode]} · {goal.mods}</span
>
</div>
<h2 class="mt-3 text-lg font-semibold">{goal.title}</h2>
{#if goal.notes}<p class="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">
{goal.notes}
</p>{/if}
<div class="mt-4 flex items-center gap-3">
{#if goal.beatmapUrl}<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a href={goal.beatmapUrl} target="_blank" rel="noreferrer" class="text-sm underline"
>Open beatmap ↗</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}<Button
size="sm"
variant="ghost"
onclick={() => create('goal', goal)}>Edit</Button
>
<form method="POST" action="?/toggleGoal" use:enhance class="ml-auto">
<input type="hidden" name="id" value={goal.id} /><input
type="hidden"
name="achieved"
value={goal.achieved ? '' : 'on'}
/><Button type="submit" variant={goal.achieved ? 'outline' : 'default'} size="sm"
>{goal.achieved ? 'Reopen' : 'Achieved!'}</Button
>
</form>
</div>
</article>{:else}<div
class="col-span-full rounded-xl border border-dashed p-12 text-center"
>
<p class="text-xl font-semibold">What’s your next breakthrough?</p>
<p class="mt-2 text-sm text-muted-foreground">
A first pass, a clean FC, 99% accuracy, or a challenge of your own.
</p>
<Button
class="mt-5"
variant="outline"
disabled={!hydrated || !data.user}
onclick={() => create('goal')}>Set a goal</Button
>
</div>{/each}
</div></Tabs.Content
>
<Tabs.Content value="scores" class="mt-5"
><div class="grid gap-4 sm:grid-cols-2">
{#each scores as score (score.id)}<article class="rounded-xl border bg-card p-5">
<div class="flex items-center justify-between">
<Badge variant="secondary">{score.mods}</Badge><span
class="text-xs text-muted-foreground"
>{score.playedOn} · {modeName[score.mode]}</span
>
</div>
<h2 class="mt-3 text-lg font-semibold">{score.title}</h2>
<div class="mt-4 flex flex-wrap gap-5">
{#if score.accuracy}<p class="text-2xl font-semibold">
{score.accuracy}<span class="text-sm text-muted-foreground">%</span>
</p>{/if}{#if score.pp}<p class="text-2xl font-semibold text-pink-500">
{score.pp}<span class="text-sm"> pp</span>
</p>{/if}{#if score.combo !== null}<p class="text-2xl font-semibold">
{score.combo}<span class="text-sm text-muted-foreground">x</span>
</p>{/if}
</div>
<p class="mt-2 text-sm text-muted-foreground">
{score.misses !== null ? `${score.misses} misses · ` : ''}{score.rate}× rate
</p>
{#if score.notes}<p class="mt-3 whitespace-pre-wrap text-sm text-muted-foreground">
{score.notes}
</p>{/if}
<div class="mt-4 flex gap-4">
{#if score.scoreUrl}<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a href={score.scoreUrl} target="_blank" rel="noreferrer" class="text-sm underline"
>View score ↗</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}{#if score.beatmapUrl}<!-- Catalog or validated external URL. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={score.beatmapUrl}
target="_blank"
rel="noreferrer"
class="text-sm underline">Beatmap ↗</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}<Button
class="ml-auto"
size="sm"
variant="ghost"
onclick={() => create('score', score)}>Edit</Button
>
</div>
</article>{:else}<div
class="col-span-full rounded-xl border border-dashed p-12 text-center"
>
<p class="text-xl font-semibold">Some scores deserve a place on the wall.</p>
<p class="mt-2 text-sm text-muted-foreground">
Keep the play, your accuracy, mods, pp, and why it mattered.
</p>
<Button
class="mt-5"
variant="outline"
disabled={!hydrated || !data.user}
onclick={() => create('score')}>Record a score</Button
>
</div>{/each}
</div></Tabs.Content
>
<Tabs.Content value="training" class="mt-5">
<section class="rounded-xl border p-5">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="text-xs font-semibold tracking-widest text-muted-foreground uppercase">
Sustained pace
</p>
<p class="mt-2 text-3xl font-semibold">
{best || '—'} <span class="text-base text-muted-foreground">effective BPM</span>
</p>
</div>
<div class="flex gap-3">
<label class="text-xs"
>Skill<NativeSelect bind:value={skillFilter} class="mt-1"
>{#each skills as skill (skill)}<option value={skill}>{skill}</option
>{/each}</NativeSelect
></label
><label class="text-xs"
>Minimum seconds<Input
type="number"
min={1}
max={86400}
bind:value={minDuration}
class="mt-1 w-28"
/></label
>
</div>
</div>
<p class="mt-3 text-xs text-muted-foreground">
Best recorded pace for the selected mode and skill, sustained for at least {minDuration} seconds.
Effective BPM = map BPM × playback rate.
</p>
{#if chart.length > 1}<svg
viewBox="0 0 640 150"
role="img"
aria-label={`Last ${chart.length} qualifying ${skillFilter} sessions, ${minBpm} to ${maxBpm} effective BPM`}
class="mt-5 h-40 w-full overflow-visible"
><line x1="10" x2="630" y1="135" y2="135" stroke="currentColor" opacity=".2" /><polyline
points={chart
.map(
(e, i) =>
`${10 + (i / (chart.length - 1)) * 620},${130 - ((bpm(e) - minBpm) / (maxBpm - minBpm || 1)) * 110}`
)
.join(' ')}
fill="none"
stroke="#ec4899"
stroke-width="2"
/>{#each chart as e, i (e.id)}<circle
cx={10 + (i / (chart.length - 1)) * 620}
cy={130 - ((bpm(e) - minBpm) / (maxBpm - minBpm || 1)) * 110}
r="4"
fill="#ec4899"
><title>{e.playedOn} · {bpm(e)} BPM · {e.duration}s · {e.title}</title></circle
>{/each}</svg
>{:else}<p class="mt-5 rounded-lg bg-muted/40 p-4 text-sm text-muted-foreground">
Log two qualifying sessions to see your pace over time.
</p>{/if}
</section>
<div class="mt-5 overflow-x-auto">
<Table.Root
><Table.Header
><Table.Row
><Table.Head>Session</Table.Head><Table.Head>Skill</Table.Head><Table.Head
>BPM</Table.Head
><Table.Head>Duration</Table.Head><Table.Head>Accuracy</Table.Head><Table.Head
></Table.Head></Table.Row
></Table.Header
><Table.Body
>{#each sessions as session (session.id)}<Table.Row
><Table.Cell
><span class="font-medium">{session.title}</span><span
class="block text-xs text-muted-foreground"
>{session.playedOn} · {modeName[session.mode]}</span
></Table.Cell
><Table.Cell>{session.skill}</Table.Cell><Table.Cell
>{bpm(session)}<span class="block text-xs text-muted-foreground"
>{session.bpm} × {session.rate}</span
></Table.Cell
><Table.Cell>{session.duration}s</Table.Cell><Table.Cell
>{session.accuracy ? `${session.accuracy}%` : '—'}</Table.Cell
><Table.Cell
><Button size="sm" variant="ghost" onclick={() => create('training', session)}
>Edit</Button
></Table.Cell
></Table.Row
>{:else}<Table.Row
><Table.Cell colspan={6} class="py-10 text-center text-muted-foreground"
>Log a practice session to start building your training history.</Table.Cell
></Table.Row
>{/each}</Table.Body
></Table.Root
>
</div>
</Tabs.Content>
</Tabs.Root>
</div>
<Dialog.Root bind:open
><Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-2xl"
><Dialog.Header
><Dialog.Title
>{editing ? 'Edit' : kind === 'score' ? 'Record' : kind === 'goal' ? 'Set' : 'Log'}
{kind === 'training' ? 'practice session' : kind}</Dialog.Title
><Dialog.Description
>{kind === 'goal'
? 'Give yourself something worth chasing.'
: kind === 'score'
? 'Save the numbers and the story behind this play.'
: 'Track the pace you can sustain and what you want to work on next.'}</Dialog.Description
></Dialog.Header
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
{#key `${kind}-${editing?.id ?? 'new'}`}
<form
method="POST"
action="?/save"
class="space-y-4"
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
await update({ reset: false });
saving = false;
if (result.type === 'success') open = false;
};
}}
>
<input type="hidden" name="kind" value={kind} />{#if editing}<input
type="hidden"
name="id"
value={editing.id}
/>{/if}
<label class="block"
>{kind === 'training' ? 'Session name' : 'Map / achievement'}<Input
name="title"
required
maxlength={250}
value={editing?.title ?? ''}
placeholder={kind === 'training'
? '180 BPM stream practice'
: 'Artist — Title [Difficulty]'}
/></label
>
<div class="grid grid-cols-2 gap-4">
<label
>Mode<NativeSelect name="mode" value={editing?.mode ?? 'osu'} class="w-full"
>{#each Object.entries(modeName) as [value, label] (value)}<option {value}
>{label}</option
>{/each}</NativeSelect
></label
><label
>Date<Input
type="date"
name="playedOn"
required
value={editing?.playedOn ?? new Date().toISOString().slice(0, 10)}
/></label
>
</div>
<label class="block"
>Beatmap link (optional)<Input
type="url"
name="beatmapUrl"
placeholder="https://osu.ppy.sh/beatmaps/…"
value={editing?.beatmapUrl ?? ''}
/></label
>
{#if kind === 'score'}<label class="block"
>Score link (optional)<Input
type="url"
name="scoreUrl"
placeholder="https://osu.ppy.sh/scores/…"
value={editing?.scoreUrl ?? ''}
/></label
>{/if}
{#if kind === 'goal'}<div class="flex items-end gap-4">
<label class="flex-1"
>Goal<NativeSelect class="w-full" name="goal" value={editing?.goal ?? 'pass'}
><option value="pass">Pass</option><option value="fc">Full combo</option><option
value="accuracy">Accuracy target</option
><option value="rank">Rank / pp target</option><option value="custom"
>Personal challenge</option
></NativeSelect
></label
><label class="mb-2 flex items-center gap-2"
><Checkbox
name="achieved"
value="on"
checked={editing?.achieved ?? false}
/>Achieved</label
>
</div>{/if}
{#if kind === 'training'}<label class="block"
>Skill<NativeSelect name="skill" value={editing?.skill ?? 'stamina'} class="w-full"
>{#each skills as skill (skill)}<option value={skill}>{skill}</option
>{/each}</NativeSelect
></label
>{/if}
<div class="grid grid-cols-2 gap-4">
<label
>Mods<Input
name="mods"
value={editing?.mods ?? 'NM'}
maxlength={80}
placeholder="NM, HD, DT…"
/></label
><label
>Playback rate<Input
type="number"
name="rate"
min={0.1}
max={4}
step="0.01"
required
value={Number(editing?.rate ?? 1)}
/></label
><label
>Map BPM{kind !== 'training' ? ' (optional)' : ''}<Input
type="number"
name="bpm"
min={1}
max={2000}
required={kind === 'training'}
value={editing?.bpm ?? undefined}
/></label
><label
>Duration (seconds)<Input
type="number"
name="duration"
min={1}
max={86400}
required={kind === 'training'}
value={editing?.duration ?? undefined}
/></label
><label
>Accuracy %<Input
type="number"
name="accuracy"
min={0}
max={100}
step="0.01"
value={editing?.accuracy ? Number(editing.accuracy) : undefined}
/></label
>{#if kind !== 'training'}<label
>pp<Input
type="number"
name="pp"
min={0}
max={100000}
step="0.01"
value={editing?.pp ? Number(editing.pp) : undefined}
/></label
><label
>Max combo<Input
type="number"
name="combo"
min={0}
max={1000000}
value={editing?.combo ?? undefined}
/></label
><label
>Misses<Input
type="number"
name="misses"
min={0}
max={1000000}
value={editing?.misses ?? undefined}
/></label
>{/if}
</div>
<p class="text-xs text-muted-foreground">
Enter the actual playback rate, including DT, HT or custom rate changes. BPM is the map’s
original tempo.
</p>
<label class="block"
>Notes / target<Textarea
name="notes"
maxlength={5000}
value={editing?.notes ?? ''}
placeholder="What felt good? What needs work? What is the next target?"
/></label
>
<Dialog.Footer
><Button variant="outline" onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
disabled={!data.user || saving}>{saving ? 'Saving…' : 'Save entry'}</Button
></Dialog.Footer
>
</form>{/key}
{#if editing}<Button
variant="ghost"
class="justify-self-start text-destructive"
onclick={() => (deleteOpen = true)}>Delete entry</Button
>{/if}
</Dialog.Content></Dialog.Root
>
<AlertDialog.Root bind:open={deleteOpen}
><AlertDialog.Content
><AlertDialog.Header
><AlertDialog.Title>Delete this entry?</AlertDialog.Title><AlertDialog.Description
>This removes “{editing?.title}” from your journal.</AlertDialog.Description
></AlertDialog.Header
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
<form
method="POST"
action="?/delete"
use:enhance={() =>
async ({ result, update }) => {
await update();
if (result.type === 'success') {
deleteOpen = false;
open = false;
}
}}
>
<input type="hidden" name="id" value={editing?.id ?? ''} /><AlertDialog.Footer
><AlertDialog.Cancel>Keep entry</AlertDialog.Cancel><Button
type="submit"
variant="destructive">Delete entry</Button
></AlertDialog.Footer
>
</form></AlertDialog.Content
></AlertDialog.Root
>