Files
website/src/routes/projects/game-tracker/osu/+page.server.ts
Stan Runge 54924b39f0
Some checks failed
Test and deploy staging / staging (push) Failing after 43s
feat: upgrade to SvelteKit 3 and adapter-bun
Run the sveltekit-3 migration and finish its manual tasks:

- Move kit config into the sveltekit() Vite plugin; drop svelte.config.js
- Replace $lib with #lib subpath imports
- Declare env vars in src/env.ts and import them from $app/env/{private,public}
- Consolidate param matchers into src/params.ts
- Use error(status, message), Response.json, refreshAll and goto reset/shallow
- Fall back to a full page load when goto rejects a login return path

Switch from adapter-node to adapter-bun: the build script runs Vite under
Bun (the adapter requires it) and the image starts with `bun ./build`.
Runtime env names (PORT, BODY_SIZE_LIMIT, ADDRESS_HEADER, ...) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:17:30 +00:00

134 lines
4.8 KiB
TypeScript
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.

import { and, desc, eq } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
import { db } from '#lib/server/db/index.js';
import { osuEntries } from '#lib/server/db/schema/index.js';
import { allowedOsuUrl, boundedInteger, validDate } from '#lib/game-tracker/journal.js';
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;