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;