2 Commits
3 changed files with 111 additions and 28 deletions
+11
View File
@@ -23,6 +23,7 @@ import { EventsService, EventStatusSchema } from "./services/events";
import { AccountsService, type AccountRole } from "./services/accounts"; import { AccountsService, type AccountRole } from "./services/accounts";
import { Value } from "@sinclair/typebox/value"; import { Value } from "@sinclair/typebox/value";
import { AssessmentsService } from "./services/assessments"; import { AssessmentsService } from "./services/assessments";
import { Not } from "@sinclair/typebox";
// CONSTANTS // CONSTANTS
// ----------------------- // -----------------------
@@ -509,6 +510,16 @@ export const app = new Elysia()
id: t.Optional(t.String()), id: t.Optional(t.String()),
}) })
}) })
.put("/:id", async ({ body: { player, activityPerformances }, params: { id } }) => {
const updated = await s.Assessments.update(id, player, activityPerformances);
if (!updated) throw new NotFoundError("Assessment not found");
}, {
params: t.Object({ id: t.String() }),
body: t.Object({
player: PlayerSchema,
activityPerformances: t.Array(ActivityPerformanceSchema),
})
})
) )
) )
+27 -8
View File
@@ -2,10 +2,10 @@ import { Activity, type ActivityPerformance, type Player } from "@blade-and-braw
import { db } from "../database/db"; import { db } from "../database/db";
export class AssessmentsService { export class AssessmentsService {
async create(player: Player, activityPerformances: ActivityPerformance[], accountId?: string) { private static performanceFor = (activityPerformances: ActivityPerformance[], activity: Activity) =>
const performanceFor = (activity: Activity) =>
activityPerformances.find((p) => p.activity === activity)?.performance ?? null; activityPerformances.find((p) => p.activity === activity)?.performance ?? null;
async create(player: Player, activityPerformances: ActivityPerformance[], accountId?: string) {
return await db.insertInto("assessments") return await db.insertInto("assessments")
.values({ .values({
account_id: accountId ?? null, account_id: accountId ?? null,
@@ -13,12 +13,12 @@ export class AssessmentsService {
age: player.metrics.age, age: player.metrics.age,
weight: player.metrics.weight, weight: player.metrics.weight,
gender: player.metrics.gender, gender: player.metrics.gender,
perf_back_squat: performanceFor(Activity.BackSquat), perf_back_squat: AssessmentsService.performanceFor(activityPerformances, Activity.BackSquat),
perf_deadlift: performanceFor(Activity.Deadlift), perf_deadlift: AssessmentsService.performanceFor(activityPerformances, Activity.Deadlift),
perf_bench_press: performanceFor(Activity.BenchPress), perf_bench_press: AssessmentsService.performanceFor(activityPerformances, Activity.BenchPress),
perf_broad_jump: performanceFor(Activity.BroadJump), perf_broad_jump: AssessmentsService.performanceFor(activityPerformances, Activity.BroadJump),
perf_run: performanceFor(Activity.Run), perf_run: AssessmentsService.performanceFor(activityPerformances, Activity.Run),
perf_cone_drill: performanceFor(Activity.ConeDrill), perf_cone_drill: AssessmentsService.performanceFor(activityPerformances, Activity.ConeDrill),
}) })
.returning("id") .returning("id")
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
@@ -40,6 +40,25 @@ export class AssessmentsService {
.execute(); .execute();
} }
async update(id: string, player: Player, activityPerformances: ActivityPerformance[]) {
const result = await db.updateTable("assessments")
.where("id", "=", id)
.set({
name: player.name ?? "Anonymous",
age: player.metrics.age,
weight: player.metrics.weight,
gender: player.metrics.gender,
perf_back_squat: AssessmentsService.performanceFor(activityPerformances, Activity.BackSquat),
perf_deadlift: AssessmentsService.performanceFor(activityPerformances, Activity.Deadlift),
perf_bench_press: AssessmentsService.performanceFor(activityPerformances, Activity.BenchPress),
perf_broad_jump: AssessmentsService.performanceFor(activityPerformances, Activity.BroadJump),
perf_run: AssessmentsService.performanceFor(activityPerformances, Activity.Run),
perf_cone_drill: AssessmentsService.performanceFor(activityPerformances, Activity.ConeDrill),
})
.executeTakeFirst();
return result.numUpdatedRows > 0n;
}
async delete(id: string, accountId: string): Promise<boolean> { async delete(id: string, accountId: string): Promise<boolean> {
const result = await db.deleteFrom("assessments") const result = await db.deleteFrom("assessments")
.where("id", "=", id) .where("id", "=", id)
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { beforeNavigate } from "$app/navigation";
import { import {
LevelCalculator, LevelCalculator,
type LevelCalculatorOutput, type LevelCalculatorOutput,
@@ -26,12 +27,30 @@
levels?: LevelCalculatorOutput; levels?: LevelCalculatorOutput;
player: Player; player: Player;
activityPerformances: ActivityPerformance[]; activityPerformances: ActivityPerformance[];
/** JSON snapshot of {player, activityPerformances} as of the last successful save/load. "" means never saved. */
savedSnapshot: string;
saving?: boolean; saving?: boolean;
saveError?: string; saveError?: string;
deleting?: boolean; deleting?: boolean;
deleteError?: string; deleteError?: string;
} }
function snapshotOf(
player: Player,
activityPerformances: ActivityPerformance[],
): string {
return JSON.stringify({ player, activityPerformances });
}
function isDirty(calculation: CalcData): boolean {
return (
snapshotOf(
calculation.player,
calculation.activityPerformances,
) !== calculation.savedSnapshot
);
}
interface Props { interface Props {
allStandards: Standards; allStandards: Standards;
} }
@@ -54,21 +73,25 @@
} as const satisfies Record<Activity, keyof AssessmentRow>; } as const satisfies Record<Activity, keyof AssessmentRow>;
function assessmentToCalc(assessment: AssessmentRow): CalcData { function assessmentToCalc(assessment: AssessmentRow): CalcData {
return { const player: Player = {
key: crypto.randomUUID(),
id: assessment.id,
player: {
name: assessment.name, name: assessment.name,
metrics: { metrics: {
age: assessment.age, age: assessment.age,
weight: assessment.weight, weight: assessment.weight,
gender: assessment.gender as Gender, gender: assessment.gender as Gender,
}, },
}, };
activityPerformances: Object.values(Activity).map((a) => ({ const activityPerformances = Object.values(Activity).map((a) => ({
activity: a, activity: a,
performance: (assessment[PERF_COLUMN[a]] as number | null) ?? 0, performance: (assessment[PERF_COLUMN[a]] as number | null) ?? 0,
})), }));
return {
key: crypto.randomUUID(),
id: assessment.id,
player,
activityPerformances,
savedSnapshot: snapshotOf(player, activityPerformances),
}; };
} }
@@ -104,6 +127,10 @@
}); });
if (res.error) throw res.error; if (res.error) throw res.error;
calculation.id = res.data?.id; calculation.id = res.data?.id;
calculation.savedSnapshot = snapshotOf(
calculation.player,
calculation.activityPerformances,
);
} catch (err) { } catch (err) {
calculation.saveError = errorMessage(err); calculation.saveError = errorMessage(err);
} finally { } finally {
@@ -132,6 +159,24 @@
onMount(loadAssessments); onMount(loadAssessments);
beforeNavigate((navigation) => {
if (!calculations.some(isDirty)) return;
if (navigation.type === "leave") {
// triggers the browser's native "leave site?" confirmation
navigation.cancel();
return;
}
if (
!confirm(
"You have unsaved player changes that will be lost. Leave anyway?",
)
) {
navigation.cancel();
}
});
$effect(() => { $effect(() => {
for (const calculation of calculations) { for (const calculation of calculations) {
calculation.levels = levelCalculator.calculate( calculation.levels = levelCalculator.calculate(
@@ -144,6 +189,7 @@
const createCalculation = function (name: string): CalcData { const createCalculation = function (name: string): CalcData {
return { return {
key: crypto.randomUUID(), key: crypto.randomUUID(),
savedSnapshot: "", // never saved — always dirty until the first save
player: { player: {
name: name, name: name,
metrics: { metrics: {
@@ -260,12 +306,19 @@
class="card card-compact bg-base-200 p-4 shadow-lg max-w-sm w-full" class="card card-compact bg-base-200 p-4 shadow-lg max-w-sm w-full"
> >
<div class="card-body gap-4 p-4"> <div class="card-body gap-4 p-4">
<div class="flex items-center gap-2">
<input <input
class="input input-bordered input-sm w-full" class="input input-bordered input-sm w-full"
type="text" type="text"
bind:value={calculation.player.name} bind:value={calculation.player.name}
placeholder="Player name" placeholder="Player name"
/> />
{#if isDirty(calculation)}
<div class="badge badge-warning badge-sm shrink-0">
Unsaved
</div>
{/if}
</div>
<ul <ul
class="bg-base-100 rounded-box shadow-xs divide-y divide-base-300" class="bg-base-100 rounded-box shadow-xs divide-y divide-base-300"
@@ -387,7 +440,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
onclick={() => saveCalculation(calculation)} onclick={() => saveCalculation(calculation)}
disabled={calculation.saving} disabled={calculation.saving || !isDirty(calculation)}
class="btn btn-primary btn-sm flex-1" class="btn btn-primary btn-sm flex-1"
> >
{calculation.saving ? "Saving..." : "Save"} {calculation.saving ? "Saving..." : "Save"}