Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
This commit is contained in:
133
src/routes/projects/game-tracker/osu/+page.server.ts
Normal file
133
src/routes/projects/game-tracker/osu/+page.server.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { osuEntries } from '$lib/server/db/schema';
|
||||
import { allowedOsuUrl, boundedInteger, validDate } from '$lib/game-tracker/journal';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => ({
|
||||
entries: locals.user
|
||||
? await db
|
||||
.select()
|
||||
.from(osuEntries)
|
||||
.where(eq(osuEntries.userId, locals.user.id))
|
||||
.orderBy(desc(osuEntries.playedOn), desc(osuEntries.id))
|
||||
: [],
|
||||
seo: {
|
||||
title: 'osu! training · Game Tracker',
|
||||
description:
|
||||
'A personal journal for memorable scores, beatmap goals and speed, stamina, aim and reading practice.'
|
||||
}
|
||||
});
|
||||
export const actions = {
|
||||
save: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save training.' });
|
||||
const data = await request.formData();
|
||||
const t = (k: string) => String(data.get(k) ?? '').trim();
|
||||
const id = t('id') ? boundedInteger(data.get('id'), 1, 2147483647) : null;
|
||||
const kind = t('kind');
|
||||
const mode = t('mode');
|
||||
const goal = t('goal') || 'pass';
|
||||
const skill = t('skill') || 'stamina';
|
||||
if (
|
||||
(t('id') && !id) ||
|
||||
!['score', 'goal', 'training'].includes(kind) ||
|
||||
!['osu', 'taiko', 'fruits', 'mania'].includes(mode) ||
|
||||
!['pass', 'fc', 'accuracy', 'rank', 'custom'].includes(goal) ||
|
||||
!['stamina', 'speed', 'aim', 'reading', 'rhythm', 'finger-control', 'consistency'].includes(
|
||||
skill
|
||||
) ||
|
||||
!t('title') ||
|
||||
t('title').length > 250 ||
|
||||
t('notes').length > 5000 ||
|
||||
t('mods').length > 80 ||
|
||||
!validDate(t('playedOn'))
|
||||
)
|
||||
return fail(400, { message: 'Check the title, date and training details.' });
|
||||
if (!allowedOsuUrl(t('beatmapUrl'), 'beatmap') || !allowedOsuUrl(t('scoreUrl'), 'score'))
|
||||
return fail(400, { message: 'Use an https://osu.ppy.sh beatmap or score link.' });
|
||||
const optionalInt = (key: string, min: number, max: number) =>
|
||||
t(key) ? boundedInteger(data.get(key), min, max) : null;
|
||||
const bpm = optionalInt('bpm', 1, 2000),
|
||||
duration = optionalInt('duration', 1, 86400),
|
||||
combo = optionalInt('combo', 0, 1000000),
|
||||
misses = optionalInt('misses', 0, 1000000);
|
||||
const decimal = (key: string, min: number, max: number) =>
|
||||
!t(key) ||
|
||||
(/^\d+(\.\d{1,4})?$/.test(t(key)) && Number(t(key)) >= min && Number(t(key)) <= max);
|
||||
if (
|
||||
(t('bpm') && bpm === null) ||
|
||||
(t('duration') && duration === null) ||
|
||||
(t('combo') && combo === null) ||
|
||||
(t('misses') && misses === null) ||
|
||||
!decimal('accuracy', 0, 100) ||
|
||||
!decimal('pp', 0, 100000) ||
|
||||
!t('rate') ||
|
||||
!decimal('rate', 0.1, 4) ||
|
||||
(kind === 'training' && (!bpm || !duration))
|
||||
)
|
||||
return fail(400, {
|
||||
message:
|
||||
'Training needs BPM and duration. Check numeric fields, accuracy (0–100) and rate (0.1–4).'
|
||||
});
|
||||
const values = {
|
||||
userId: locals.user.id,
|
||||
kind: kind as 'score' | 'goal' | 'training',
|
||||
mode,
|
||||
goal,
|
||||
skill,
|
||||
title: t('title'),
|
||||
notes: t('notes'),
|
||||
bpm,
|
||||
duration,
|
||||
combo,
|
||||
misses,
|
||||
accuracy: t('accuracy') || null,
|
||||
pp: t('pp') || null,
|
||||
rate: t('rate'),
|
||||
mods: t('mods') || 'NM',
|
||||
beatmapUrl: t('beatmapUrl') || null,
|
||||
scoreUrl: t('scoreUrl') || null,
|
||||
playedOn: t('playedOn'),
|
||||
achieved: data.get('achieved') === 'on'
|
||||
};
|
||||
if (id) {
|
||||
const updated = await db
|
||||
.update(osuEntries)
|
||||
.set(values)
|
||||
.where(and(eq(osuEntries.id, id), eq(osuEntries.userId, locals.user.id)))
|
||||
.returning({ id: osuEntries.id });
|
||||
if (!updated.length) return fail(404, { message: 'Entry not found.' });
|
||||
} else await db.insert(osuEntries).values(values);
|
||||
return { saved: true };
|
||||
},
|
||||
toggleGoal: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to update goals.' });
|
||||
const data = await request.formData();
|
||||
const id = boundedInteger(data.get('id'), 1, 2147483647);
|
||||
if (!id) return fail(400, { message: 'Invalid goal.' });
|
||||
const updated = await db
|
||||
.update(osuEntries)
|
||||
.set({ achieved: data.get('achieved') === 'on' })
|
||||
.where(
|
||||
and(
|
||||
eq(osuEntries.id, id),
|
||||
eq(osuEntries.userId, locals.user.id),
|
||||
eq(osuEntries.kind, 'goal')
|
||||
)
|
||||
)
|
||||
.returning({ id: osuEntries.id });
|
||||
return updated.length ? { saved: true } : fail(404, { message: 'Goal not found.' });
|
||||
},
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to remove entries.' });
|
||||
const data = await request.formData();
|
||||
const id = boundedInteger(data.get('id'), 1, 2147483647);
|
||||
if (!id) return fail(400, { message: 'Invalid entry.' });
|
||||
const removed = await db
|
||||
.delete(osuEntries)
|
||||
.where(and(eq(osuEntries.id, id), eq(osuEntries.userId, locals.user.id)))
|
||||
.returning({ id: osuEntries.id });
|
||||
return removed.length ? { saved: true } : fail(404, { message: 'Entry not found.' });
|
||||
}
|
||||
} satisfies Actions;
|
||||
603
src/routes/projects/game-tracker/osu/+page.svelte
Normal file
603
src/routes/projects/game-tracker/osu/+page.svelte
Normal file
@@ -0,0 +1,603 @@
|
||||
<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('/projects/game-tracker')}
|
||||
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
|
||||
>
|
||||
Reference in New Issue
Block a user