3 Commits
3 changed files with 356 additions and 179 deletions
+11
View File
@@ -23,6 +23,7 @@ import { EventsService, EventStatusSchema } from "./services/events";
import { AccountsService, type AccountRole } from "./services/accounts";
import { Value } from "@sinclair/typebox/value";
import { AssessmentsService } from "./services/assessments";
import { Not } from "@sinclair/typebox";
// CONSTANTS
// -----------------------
@@ -509,6 +510,16 @@ export const app = new Elysia()
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),
})
})
)
)
+28 -9
View File
@@ -2,10 +2,10 @@ import { Activity, type ActivityPerformance, type Player } from "@blade-and-braw
import { db } from "../database/db";
export class AssessmentsService {
async create(player: Player, activityPerformances: ActivityPerformance[], accountId?: string) {
const performanceFor = (activity: Activity) =>
activityPerformances.find((p) => p.activity === activity)?.performance ?? null;
private static performanceFor = (activityPerformances: ActivityPerformance[], activity: Activity) =>
activityPerformances.find((p) => p.activity === activity)?.performance ?? null;
async create(player: Player, activityPerformances: ActivityPerformance[], accountId?: string) {
return await db.insertInto("assessments")
.values({
account_id: accountId ?? null,
@@ -13,12 +13,12 @@ export class AssessmentsService {
age: player.metrics.age,
weight: player.metrics.weight,
gender: player.metrics.gender,
perf_back_squat: performanceFor(Activity.BackSquat),
perf_deadlift: performanceFor(Activity.Deadlift),
perf_bench_press: performanceFor(Activity.BenchPress),
perf_broad_jump: performanceFor(Activity.BroadJump),
perf_run: performanceFor(Activity.Run),
perf_cone_drill: performanceFor(Activity.ConeDrill),
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),
})
.returning("id")
.executeTakeFirstOrThrow();
@@ -40,6 +40,25 @@ export class AssessmentsService {
.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> {
const result = await db.deleteFrom("assessments")
.where("id", "=", id)
+317 -170
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { beforeNavigate } from "$app/navigation";
import {
LevelCalculator,
type LevelCalculatorOutput,
@@ -26,12 +27,32 @@
levels?: LevelCalculatorOutput;
player: Player;
activityPerformances: ActivityPerformance[];
/** JSON snapshot of {player, activityPerformances} as of the last successful save/load. "" means never saved. */
savedSnapshot: string;
/** whether the metrics/performance editing panel is open — collapsed by default, since levels are the primary thing being compared */
expanded?: boolean;
saving?: boolean;
saveError?: string;
deleting?: boolean;
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 {
allStandards: Standards;
}
@@ -40,6 +61,9 @@
const levelCalculator = $derived(new LevelCalculator(allStandards));
// toggle + name + overall + one column per attribute + actions
const TABLE_COLUMNS = 4 + Object.keys(Attribute).length;
let calculations = $state<CalcData[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
@@ -54,21 +78,25 @@
} as const satisfies Record<Activity, keyof AssessmentRow>;
function assessmentToCalc(assessment: AssessmentRow): CalcData {
const player: Player = {
name: assessment.name,
metrics: {
age: assessment.age,
weight: assessment.weight,
gender: assessment.gender as Gender,
},
};
const activityPerformances = Object.values(Activity).map((a) => ({
activity: a,
performance: (assessment[PERF_COLUMN[a]] as number | null) ?? 0,
}));
return {
key: crypto.randomUUID(),
id: assessment.id,
player: {
name: assessment.name,
metrics: {
age: assessment.age,
weight: assessment.weight,
gender: assessment.gender as Gender,
},
},
activityPerformances: Object.values(Activity).map((a) => ({
activity: a,
performance: (assessment[PERF_COLUMN[a]] as number | null) ?? 0,
})),
player,
activityPerformances,
savedSnapshot: snapshotOf(player, activityPerformances),
};
}
@@ -98,12 +126,26 @@
calculation.saving = true;
calculation.saveError = undefined;
try {
const res = await api.assessments.me.post({
player: calculation.player,
activityPerformances: calculation.activityPerformances,
});
if (res.error) throw res.error;
calculation.id = res.data?.id;
if (calculation.id) {
const res = await api
.assessments({ id: calculation.id })
.put({
player: calculation.player,
activityPerformances: calculation.activityPerformances,
});
if (res.error) throw res.error;
} else {
const res = await api.assessments.me.post({
player: calculation.player,
activityPerformances: calculation.activityPerformances,
});
if (res.error) throw res.error;
calculation.id = res.data?.id;
}
calculation.savedSnapshot = snapshotOf(
calculation.player,
calculation.activityPerformances,
);
} catch (err) {
calculation.saveError = errorMessage(err);
} finally {
@@ -132,6 +174,24 @@
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(() => {
for (const calculation of calculations) {
calculation.levels = levelCalculator.calculate(
@@ -144,6 +204,8 @@
const createCalculation = function (name: string): CalcData {
return {
key: crypto.randomUUID(),
savedSnapshot: "", // never saved — always dirty until the first save
expanded: true, // a brand new player needs its details filled in right away
player: {
name: name,
metrics: {
@@ -177,7 +239,7 @@
const formatSeconds = (ms: number) => (ms / 1000).toFixed(2) + " seconds";
const performanceOptionsFromActivity = function (activity: Activity) {
const options: { name: string; value: string }[] = [];
const options: { name: string; value: number }[] = [];
switch (activity) {
case Activity.BackSquat:
case Activity.Deadlift:
@@ -185,7 +247,7 @@
for (let i = 0; i < 600; ++i) {
options.push({
name: String(i) + " lb",
value: String(lbToKg(i)),
value: lbToKg(i),
});
}
break;
@@ -193,7 +255,7 @@
case Activity.Run: {
const MAX_MIN = 30;
for (let ms = 0; ms <= MAX_MIN * 60_000; ms += 1_000) {
options.push({ name: formatMs(ms), value: String(ms) });
options.push({ name: formatMs(ms), value: ms });
}
break;
}
@@ -204,7 +266,7 @@
const inches = halfStep / 2;
options.push({
name: inchesToFeetInches(inches),
value: (inches * 2.54).toFixed(1),
value: Number((inches * 2.54).toFixed(1)),
});
}
break;
@@ -216,7 +278,7 @@
for (let ms = MIN_MS; ms <= MAX_MS; ms += 10) {
options.push({
name: formatSeconds(ms),
value: String(ms),
value: ms,
});
}
break;
@@ -252,156 +314,241 @@
</div>
{/if}
<section
class="w-full px-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
>
{#each calculations as calculation, index (calculation.key)}
<div
class="card card-compact bg-base-200 p-4 shadow-lg max-w-sm w-full"
>
<div class="card-body gap-4 p-4">
<input
class="input input-bordered input-sm w-full"
type="text"
bind:value={calculation.player.name}
placeholder="Player name"
/>
<ul
class="bg-base-100 rounded-box shadow-xs divide-y divide-base-300"
>
<li class="p-3">
<div class="flex items-center justify-between">
<div class="font-semibold text-sm">OVERALL</div>
<div class="badge badge-neutral badge-xs">
{calculation?.levels?.player || "N/A"}
</div>
</div>
</li>
{#each Object.values(Attribute) as attribute (attribute)}
<li class="p-3">
<div class="flex items-center justify-between">
<div class="opacity-80 text-sm">
{attribute}
</div>
<div class="badge badge-ghost badge-xs">
{calculation?.levels?.attributes?.[
attribute
] || "N/A"}
</div>
</div>
</li>
{/each}
</ul>
<fieldset
class="fieldset bg-base-200/60 rounded-lg"
onchange={() =>
(calculation.levels = levelCalculator.calculate(
calculation.player,
calculation.activityPerformances,
))}
>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<label class="form-control">
<span class="label mb-1 text-xs">Gender</span>
<select
class="select select-bordered select-sm w-full"
bind:value={calculation.player.metrics.gender}
>
{#each Object.values(Gender) as gender (gender)}
<option value={gender}>{gender}</option>
{/each}
</select>
</label>
<label class="form-control">
<span class="label mb-1 text-xs">Age</span>
<div class="w-full overflow-x-auto">
<table class="table">
<thead>
<tr>
<th class="w-8"></th>
<th>Name</th>
<th>Overall</th>
{#each Object.values(Attribute) as attribute (attribute)}
<th>{attribute}</th>
{/each}
<th class="w-40"></th>
</tr>
</thead>
<tbody>
{#each calculations as calculation, index (calculation.key)}
<tr class="hover:bg-base-300">
<td>
<button
onclick={() =>
(calculation.expanded = !calculation.expanded)}
class="btn btn-ghost btn-xs"
aria-label={calculation.expanded
? "Collapse details"
: "Expand details"}
>
{calculation.expanded ? "▾" : "▸"}
</button>
</td>
<td>
<div class="flex items-center gap-2">
<input
class="input input-bordered input-sm w-full"
type="number"
min="1"
max="100"
step="1"
bind:value={calculation.player.metrics.age}
class="input input-bordered input-sm w-full min-w-32"
type="text"
bind:value={calculation.player.name}
placeholder="Player name"
/>
</label>
<label class="form-control">
<span class="label mb-1 text-xs">Weight</span>
<select
class="select select-bordered select-sm w-full"
bind:value={calculation.player.metrics.weight}
{#if isDirty(calculation)}
<div class="badge badge-warning badge-xs shrink-0">
Unsaved
</div>
{/if}
</div>
</td>
<td>
<div class="badge badge-neutral font-semibold">
{calculation?.levels?.player ?? "N/A"}
</div>
</td>
{#each Object.values(Attribute) as attribute (attribute)}
<td>
<div class="badge badge-ghost">
{calculation?.levels?.attributes?.[
attribute
] ?? "N/A"}
</div>
</td>
{/each}
<td>
<div class="flex gap-2 justify-end">
<button
onclick={() => saveCalculation(calculation)}
disabled={calculation.saving ||
!isDirty(calculation)}
class="btn btn-primary btn-xs"
>
{#each range(400) as weight (weight)}
<option value={lbToKg(weight)}
>{weight}</option
>
{/each}
</select>
</label>
</div>
</fieldset>
<fieldset
class="fieldset bg-base-200/60 rounded-lg"
onchange={() =>
(calculation.levels = levelCalculator.calculate(
calculation.player,
calculation.activityPerformances,
))}
>
<div class="grid grid-cols-2 gap-3">
{#each calculation.activityPerformances as activityPerformance (activityPerformance.activity)}
<label class="form-control space-y-1">
<span class="label mb-1 text-xs">
{activityPerformance.activity}
</span>
<select
class="select select-bordered select-sm w-full"
bind:value={activityPerformance.performance}
{calculation.saving ? "Saving..." : "Save"}
</button>
<button
onclick={() =>
confirm(
`Remove ${calculation.player.name}?`,
) && deleteCalculation(calculation, index)}
disabled={calculation.deleting}
class="btn btn-error btn-xs"
>
{calculation.deleting ? "..." : "Delete"}
</button>
</div>
</td>
</tr>
{#if calculation.expanded}
<tr>
<td colspan={TABLE_COLUMNS}>
<div
class="bg-base-200/60 rounded-lg p-4 flex flex-col gap-4"
>
<fieldset
class="fieldset"
onchange={() =>
(calculation.levels =
levelCalculator.calculate(
calculation.player,
calculation.activityPerformances,
))}
>
{#each performanceOptionsFromActivity(activityPerformance.activity) as option (option.value)}
<option value={option.value}
>{option.name}</option
>
{/each}
</select>
</label>
{/each}
</div>
</fieldset>
<legend
class="fieldset-legend text-xs font-semibold opacity-70"
>
Metrics
</legend>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<label class="form-control">
<span class="label mb-1 text-xs"
>Gender</span
>
<select
class="select select-bordered select-sm w-full"
bind:value={
calculation.player.metrics
.gender
}
>
{#each Object.values(Gender) as gender (gender)}
<option value={gender}
>{gender}</option
>
{/each}
</select>
</label>
{#if calculation.saveError}
<div role="alert" class="alert alert-error alert-sm">
<span class="text-xs">{calculation.saveError}</span>
</div>
<label class="form-control">
<span class="label mb-1 text-xs"
>Age</span
>
<input
class="input input-bordered input-sm w-full"
type="number"
min="1"
max="100"
step="1"
bind:value={
calculation.player.metrics
.age
}
/>
</label>
<label class="form-control">
<span class="label mb-1 text-xs"
>Weight</span
>
<select
class="select select-bordered select-sm w-full"
bind:value={
calculation.player.metrics
.weight
}
>
{#each range(400) as weight (weight)}
<option
value={lbToKg(weight)}
>{weight}</option
>
{/each}
</select>
</label>
</div>
</fieldset>
<fieldset
class="fieldset"
onchange={() =>
(calculation.levels =
levelCalculator.calculate(
calculation.player,
calculation.activityPerformances,
))}
>
<legend
class="fieldset-legend text-xs font-semibold opacity-70"
>
Activity performances
</legend>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
{#each calculation.activityPerformances as activityPerformance (activityPerformance.activity)}
<label
class="form-control space-y-1"
>
<span
class="label mb-1 text-xs"
>
{activityPerformance.activity}
</span>
<select
class="select select-bordered select-sm w-full"
bind:value={
activityPerformance.performance
}
>
{#each performanceOptionsFromActivity(activityPerformance.activity) as option (option.value)}
<option
value={option.value}
>{option.name}</option
>
{/each}
</select>
</label>
{/each}
</div>
</fieldset>
{#if calculation.saveError}
<div
role="alert"
class="alert alert-error alert-sm"
>
<span class="text-xs"
>{calculation.saveError}</span
>
</div>
{/if}
{#if calculation.deleteError}
<div
role="alert"
class="alert alert-error alert-sm"
>
<span class="text-xs"
>{calculation.deleteError}</span
>
</div>
{/if}
</div>
</td>
</tr>
{/if}
{#if calculation.deleteError}
<div role="alert" class="alert alert-error alert-sm">
<span class="text-xs">{calculation.deleteError}</span>
</div>
{/if}
</div>
<div class="flex gap-2">
<button
onclick={() => saveCalculation(calculation)}
disabled={calculation.saving}
class="btn btn-primary btn-sm flex-1"
>
{calculation.saving ? "Saving..." : "Save"}
</button>
<button
onclick={() =>
confirm(`Remove ${calculation.player.name}?`) &&
deleteCalculation(calculation, index)}
disabled={calculation.deleting}
class="btn btn-error btn-sm w-1/4"
>
{calculation.deleting ? "..." : "Delete"}
</button>
</div>
</div>
{/each}
</section>
{:else}
<tr>
<td
colspan={TABLE_COLUMNS}
class="text-center opacity-60"
>
{loading ? "Loading..." : "No players yet"}
</td>
</tr>
{/each}
</tbody>
</table>
</div>