osu! goals: games.osu_goals (maps to pass/FC from the old list) with osu! API v2 metadata (stars NM/DT/HR/HD, BPM, length, AR/OD/CS/HP, combo, objects, tags, community tags, streams from .osu), my best scores and pass/FC via hourly in-app refresh; Goals section on /trackers/games/osu; retired list URL 301s there
All checks were successful
Deploy production / deploy (push) Successful in 1m15s

This commit is contained in:
2026-10-26 00:35:42 +00:00
parent 45841d6f6c
commit 9f8d54d9e3
14 changed files with 41357 additions and 5 deletions

View File

@@ -0,0 +1,494 @@
<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="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>