Files
website/src/lib/game-tracker/OsuGoals.svelte
2026-10-26 00:44:12 +00:00

499 lines
14 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 { enhance } from '$app/forms';
import Target from 'lucide-svelte/icons/target';
import Trophy from 'lucide-svelte/icons/trophy';
import CircleCheck from 'lucide-svelte/icons/circle-check';
import Star from 'lucide-svelte/icons/star';
import Clock from 'lucide-svelte/icons/clock';
import Music from 'lucide-svelte/icons/music';
import EyeOff from 'lucide-svelte/icons/eye-off';
import FastForward from 'lucide-svelte/icons/fast-forward';
import Waves from 'lucide-svelte/icons/waves';
import Gauge from 'lucide-svelte/icons/gauge';
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
import CircleHelp from 'lucide-svelte/icons/circle-help';
import ExternalLink from 'lucide-svelte/icons/external-link';
import { FOCUS_LABELS, formatLength } from '#lib/game-tracker/osu-goals.js';
import type { OsuGoalView } from '#lib/server/osu-goals/load.js';
// Maps I want to pass or FC (games.osu_goals, moved from the old list), with osu! API
// metadata and my best score. Refreshed hourly by the in-app poller.
let { goals, owner }: { goals: OsuGoalView[]; owner: boolean } = $props();
const matched = $derived(goals.filter((g) => g.matchStatus === 'matched'));
const open = $derived(goals.filter((g) => g.matchStatus !== 'matched'));
const done = $derived(goals.filter((g) => g.achievedAt).length);
const passedCount = $derived(matched.filter((g) => g.passed).length);
const fcCount = $derived(matched.filter((g) => g.fc).length);
const lastCheck = $derived(
goals
.map((g) => g.scoresAt)
.filter(Boolean)
.sort()
.at(-1)
);
// osu!'s own star rating colour spectrum.
const STOPS: [number, string][] = [
[0.1, '#4290fb'],
[1.25, '#4fc0ff'],
[2, '#4fffd5'],
[2.5, '#7cff4f'],
[3.3, '#f6f05c'],
[4.2, '#ff8068'],
[4.9, '#ff4e6f'],
[5.8, '#c645b8'],
[6.7, '#6563de'],
[7.7, '#18158e'],
[9, '#000000']
];
function starColour(s: number) {
if (s <= STOPS[0][0]) return STOPS[0][1];
for (let i = 1; i < STOPS.length; i++) {
const [x1, c1] = STOPS[i];
const [x0, c0] = STOPS[i - 1];
if (s <= x1) {
const t = (s - x0) / (x1 - x0);
const ch = (c: string, k: number) => parseInt(c.slice(1 + k * 2, 3 + k * 2), 16);
const mix = [0, 1, 2].map((k) => Math.round(ch(c0, k) + (ch(c1, k) - ch(c0, k)) * t));
return `rgb(${mix.join(',')})`;
}
}
return '#000';
}
const rankClass = (r: string | null) =>
!r
? ''
: r.startsWith('X') || r === 'SS' || r === 'SSH'
? 'rank-x'
: `rank-${r[0].toLowerCase()}`;
const rankLabel = (r: string | null) =>
r === 'X' ? 'SS' : r === 'XH' ? 'SS' : r === 'SH' ? 'S' : (r ?? '');
const silver = (r: string | null) => r === 'XH' || r === 'SH';
const goalText = (g: OsuGoalView) =>
g.goal === 'fc' ? 'FC' : g.goal === 'pass' ? 'pass' : 'pass / FC';
const nf = (n: number | null, d = 0) =>
n == null
? '–'
: n.toLocaleString('en-GB', { maximumFractionDigits: d, minimumFractionDigits: d });
const day = (iso: string | null) =>
iso
? new Date(iso).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})
: '';
const focusIcon: Record<string, typeof EyeOff> = {
hd: EyeOff,
dt: FastForward,
streams: Waves,
'fast-bpm': Gauge
};
</script>
<section id="goals" class="mb-10 scroll-mt-24">
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 class="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<span class="icon-tile"><Target size={18} aria-hidden="true" /></span>goals: to pass / FC
</h2>
<p class="mt-1 text-sm text-[var(--site-muted)]">
{goals.length} maps · {passedCount} passed · {fcCount} FC’d{#if done}
· {done} goal{done === 1 ? '' : 's'} done{/if}{#if lastCheck}
· scores checked {day(lastCheck)}{/if}
</p>
</div>
{#if owner}
<form method="POST" action="?/osuGoalsRefresh" use:enhance>
<button class="refresh" type="submit"
><RefreshCw size={14} aria-hidden="true" /> refresh scores</button
>
</form>
{/if}
</div>
<ul class="goals">
{#each matched as g (g.id)}
<li class={['goal', g.achievedAt && 'achieved']}>
<a
class="thumb"
href="https://osu.ppy.sh/beatmaps/{g.beatmapId}"
target="_blank"
rel="noopener noreferrer"
aria-label="{g.artist} – {g.title} on osu!"
>
{#if g.listUrl}<img src={g.listUrl} alt="" loading="lazy" width="64" height="64" />{/if}
</a>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<a
class="truncate font-medium hover:underline"
href="https://osu.ppy.sh/beatmaps/{g.beatmapId}"
target="_blank"
rel="noopener noreferrer">{g.artist} – {g.title}</a
>
<span class={['goal-badge', g.goal ?? 'unset', g.achievedAt && 'done']}
>{#if g.achievedAt}<CircleCheck size={12} aria-hidden="true" />{:else}<Trophy
size={12}
aria-hidden="true"
/>{/if}{goalText(g)}{#if g.mods.length}&nbsp;+{g.mods.join('')}{/if}</span
>
</div>
<div class="truncate text-sm text-[var(--site-muted)]">
[{g.version}] · {g.mapper}{#if g.notes}
· {g.notes}{/if}
</div>
<div class="meta mt-1.5">
{#if g.stars != null}
<span class="stars" style:--star={starColour(g.stars)}
><Star size={11} aria-hidden="true" />{nf(g.stars, 2)}</span
>
{/if}
{#if g.starsDt != null}<span class="dim" title="star rating with DT"
>DT {nf(g.starsDt, 2)}★</span
>{/if}
{#if g.starsHr != null}<span class="dim" title="star rating with HR"
>HR {nf(g.starsHr, 2)}★</span
>{/if}
<span
title={g.bpmMin != null && g.bpmMax != null && g.bpmMax - g.bpmMin >= 1
? `BPM ${nf(g.bpmMin)}–${nf(g.bpmMax)}`
: 'BPM'}><Music size={12} aria-hidden="true" />{nf(g.bpm)}</span
>
<span title="length"
><Clock size={12} aria-hidden="true" />{formatLength(g.lengthSeconds)}</span
>
<span class="dim">AR {nf(g.ar, 1)} · OD {nf(g.od, 1)} · CS {nf(g.cs, 1)}</span>
{#each g.focus as f (f)}
{@const Icon = focusIcon[f]}
<span class="focus focus-{f}"
><Icon size={11} aria-hidden="true" />{FOCUS_LABELS[f] ?? f}</span
>
{/each}
</div>
</div>
<div class="best">
{#if g.bestRank}
<a
class="flex items-center gap-2"
href="https://osu.ppy.sh/scores/{g.bestScoreId}"
target="_blank"
rel="noopener noreferrer"
title="my best score{g.bestAt ? `, ${day(g.bestAt)}` : ''}"
>
<span class={['rank', rankClass(g.bestRank), silver(g.bestRank) && 'silver']}
>{rankLabel(g.bestRank)}</span
>
<span class="text-right leading-tight">
<span class="block font-medium tabular-nums"
>{nf(g.bestAccuracy, 2)}%{#if g.bestMods.length}<span class="mods"
>+{g.bestMods.join('')}</span
>{/if}</span
>
<span class="block text-xs text-[var(--site-muted)] tabular-nums"
>{nf(g.bestPp)}pp · {nf(g.bestCombo)}/{nf(g.maxCombo)}x{#if g.bestFc}
· FC{:else if g.bestMisses}
· {g.bestMisses}❌{/if}</span
>
</span>
</a>
{:else}
<span class="text-sm text-[var(--site-muted)]">no pass yet</span>
{/if}
{#if owner}
<form method="POST" action="?/osuGoalSet" use:enhance class="mt-1">
<input type="hidden" name="id" value={g.id} />
<select
name="goal"
class="goal-select"
aria-label="goal for {g.title}"
value={g.goal ?? ''}
onchange={(e) => e.currentTarget.form?.requestSubmit()}
>
<option value="">pass / FC (not set)</option>
<option value="pass">pass</option>
<option value="fc">FC</option>
</select>
</form>
{/if}
</div>
</li>
{/each}
{#each open as g (g.id)}
<li class="goal unmatched">
<span class="thumb grid place-items-center text-[var(--site-muted)]"
><CircleHelp size={22} aria-hidden="true" /></span
>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium">{g.name}</span>
<span class="goal-badge unset"
><Trophy size={12} aria-hidden="true" />{goalText(
g
)}{#if g.mods.length}&nbsp;+{g.mods.join('')}{/if}</span
>
<span class="text-xs text-[var(--site-muted)]"
>{g.matchStatus === 'ambiguous' ? 'which difficulty?' : 'no map found'}</span
>
</div>
{#if g.matchNote}<p class="text-sm text-[var(--site-muted)]">{g.matchNote}</p>{/if}
{#if g.candidates.length}
<ul class="mt-1.5 flex flex-wrap gap-1.5">
{#each g.candidates as c (c.beatmapId)}
<li class="candidate">
<a
href="https://osu.ppy.sh/beatmaps/{c.beatmapId}"
target="_blank"
rel="noopener noreferrer"
>{c.label}{#if c.stars != null}
· {nf(c.stars, 2)}★{/if}{#if c.myPlays}
· {c.myPlays} plays{/if}<ExternalLink size={11} aria-hidden="true" /></a
>
{#if owner}
<form method="POST" action="?/osuGoalPick" use:enhance>
<input type="hidden" name="id" value={g.id} />
<input type="hidden" name="beatmapId" value={c.beatmapId} />
<button type="submit" class="pick">this one</button>
</form>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
</li>
{/each}
</ul>
<p class="mt-2 text-xs text-[var(--site-muted)]">
Map data and scores from the <a
class="underline"
href="https://osu.ppy.sh/docs/"
target="_blank"
rel="noopener noreferrer">osu! API</a
>.
</p>
</section>
<style>
.icon-tile {
display: inline-grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 0.5rem;
color: #fff;
background-image: linear-gradient(135deg, #ff66aa, #ff3d7f);
}
.refresh,
.pick {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent);
border-radius: 999px;
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
color: var(--site-muted);
transition:
color 160ms ease,
background-color 160ms ease;
}
.pick {
padding: 0.05rem 0.5rem;
font-size: 0.72rem;
}
.refresh:hover,
.pick:hover {
color: var(--foreground);
background: color-mix(in oklab, var(--foreground) 7%, transparent);
}
.goals {
display: grid;
gap: 0.5rem;
}
.goal {
display: grid;
grid-template-columns: 4rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
padding: 0.6rem 0.9rem 0.6rem 0.6rem;
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
border-radius: 0.9rem;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
transition:
background-color 160ms ease,
border-color 160ms ease;
}
.goal:hover {
background: color-mix(in oklab, var(--foreground) 5%, transparent);
border-color: color-mix(in oklab, var(--foreground) 18%, transparent);
}
.goal.achieved {
border-color: color-mix(in oklab, #22c55e 45%, transparent);
}
.goal.unmatched {
border-style: dashed;
}
.thumb {
width: 4rem;
height: 4rem;
border-radius: 0.6rem;
overflow: hidden;
background: color-mix(in oklab, var(--foreground) 6%, transparent);
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.75rem;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.meta > span {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.meta .dim {
color: var(--site-muted);
}
.stars {
padding: 0.05rem 0.45rem;
border-radius: 999px;
font-weight: 600;
color: #fff;
background: var(--star);
text-shadow: 0 1px 1px rgb(0 0 0 / 0.35);
}
.focus {
padding: 0.05rem 0.45rem;
border-radius: 999px;
font-weight: 500;
border: 1px solid currentColor;
}
.focus-hd {
color: #f59e0b;
}
.focus-dt {
color: #a855f7;
}
.focus-streams {
color: #06b6d4;
}
.focus-fast-bpm {
color: #f43f5e;
}
.goal-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.05rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid color-mix(in oklab, var(--foreground) 18%, transparent);
}
.goal-badge.fc {
color: #fff;
background: linear-gradient(135deg, #ff66aa, #ff3d7f);
border-color: transparent;
}
.goal-badge.pass {
color: #fff;
background: linear-gradient(135deg, #3b82f6, #6366f1);
border-color: transparent;
}
.goal-badge.unset {
color: var(--site-muted);
}
.goal-badge.done {
color: #fff;
background: linear-gradient(135deg, #22c55e, #10b981);
border-color: transparent;
}
.best {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 9rem;
}
.rank {
display: inline-grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
font-weight: 800;
font-size: 1rem;
color: #fff;
}
.rank-x,
.rank-s {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
}
.rank.silver {
background: linear-gradient(135deg, #e5e7eb, #9ca3af);
color: #111;
}
.rank-a {
background: linear-gradient(135deg, #22c55e, #16a34a);
}
.rank-b {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
.rank-c {
background: linear-gradient(135deg, #a855f7, #9333ea);
}
.rank-d {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
.mods {
margin-left: 0.3rem;
font-size: 0.75rem;
color: var(--site-muted);
}
.goal-select {
font-size: 0.72rem;
background: transparent;
color: var(--site-muted);
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
border-radius: 0.4rem;
padding: 0.05rem 0.3rem;
}
.candidate {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.3rem 0.15rem 0.6rem;
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
border-radius: 999px;
font-size: 0.78rem;
}
.candidate a {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
@media (max-width: 640px) {
.goal {
grid-template-columns: 3rem minmax(0, 1fr);
}
.thumb {
width: 3rem;
height: 3rem;
}
.best {
grid-column: 2;
align-items: flex-start;
}
}
</style>