2 Commits
3 changed files with 171 additions and 49 deletions
+8 -4
View File
@@ -482,7 +482,7 @@ export const app = new Elysia()
.group("/assessments", (app) => app .group("/assessments", (app) => app
.guard({ auth: true }, (app) => app .guard({ auth: true }, (app) => app
.post("/me", async ({ body: { player, activityPerformances }, accountId }) => { .post("/me", async ({ body: { player, activityPerformances }, accountId }) => {
await s.Assessments.create(player, activityPerformances, accountId); return await s.Assessments.create(player, activityPerformances, accountId);
}, { }, {
body: t.Object({ body: t.Object({
player: PlayerSchema, player: PlayerSchema,
@@ -490,9 +490,13 @@ export const app = new Elysia()
}) })
}) })
.get("/me", async ({ accountId }) => { .get("/me", async ({ accountId }) => {
const assessments = await s.Assessments.list({ filter: { accountId } }); return await s.Assessments.list({ filter: { accountId } });
if (!assessments) throw new NotFoundError("Assessments not found"); })
return assessments; .delete("/me/:id", async ({ params: { id }, accountId }) => {
const deleted = await s.Assessments.delete(id, accountId);
if (!deleted) throw new NotFoundError("Assessment not found");
}, {
params: t.Object({ id: t.String() })
}) })
) )
.guard({ authAdmin: true }, (app) => app .guard({ authAdmin: true }, (app) => app
+8
View File
@@ -39,4 +39,12 @@ export class AssessmentsService {
.$if(opt.offset !== undefined, (qb) => qb.offset(opt.offset!)) .$if(opt.offset !== undefined, (qb) => qb.offset(opt.offset!))
.execute(); .execute();
} }
async delete(id: string, accountId: string): Promise<boolean> {
const result = await db.deleteFrom("assessments")
.where("id", "=", id)
.where("account_id", "=", accountId)
.executeTakeFirst();
return result.numDeletedRows > 0n;
}
} }
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { import {
LevelCalculator, LevelCalculator,
type LevelCalculatorOutput, type LevelCalculatorOutput,
@@ -13,11 +14,22 @@
type ActivityPerformance, type ActivityPerformance,
type Player, type Player,
} from "@blade-and-brawn/domain"; } from "@blade-and-brawn/domain";
import { api } from "$lib/api";
type AssessmentRow = NonNullable<
Awaited<ReturnType<typeof api.assessments.me.get>>["data"]
>[number];
interface CalcData { interface CalcData {
key: string;
id?: string;
levels?: LevelCalculatorOutput; levels?: LevelCalculatorOutput;
player: Player; player: Player;
activityPerformances: ActivityPerformance[]; activityPerformances: ActivityPerformance[];
saving?: boolean;
saveError?: string;
deleting?: boolean;
deleteError?: string;
} }
interface Props { interface Props {
@@ -28,7 +40,97 @@
const levelCalculator = $derived(new LevelCalculator(allStandards)); const levelCalculator = $derived(new LevelCalculator(allStandards));
let calculations = $state([] as CalcData[]); let calculations = $state<CalcData[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
const PERF_COLUMN = {
[Activity.BackSquat]: "perf_back_squat",
[Activity.Deadlift]: "perf_deadlift",
[Activity.BenchPress]: "perf_bench_press",
[Activity.Run]: "perf_run",
[Activity.BroadJump]: "perf_broad_jump",
[Activity.ConeDrill]: "perf_cone_drill",
} as const satisfies Record<Activity, keyof AssessmentRow>;
function assessmentToCalc(assessment: AssessmentRow): CalcData {
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,
})),
};
}
function errorMessage(err: unknown): string {
const value = (err as { value?: { error?: string } })?.value;
return (
value?.error ??
(err instanceof Error ? err.message : "Unknown error")
);
}
async function loadAssessments() {
loading = true;
loadError = null;
try {
const res = await api.assessments.me.get();
if (res.error) throw res.error;
calculations = (res.data ?? []).map(assessmentToCalc);
} catch (err) {
loadError = errorMessage(err);
} finally {
loading = false;
}
}
async function saveCalculation(calculation: CalcData) {
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;
} catch (err) {
calculation.saveError = errorMessage(err);
} finally {
calculation.saving = false;
}
}
async function deleteCalculation(calculation: CalcData, index: number) {
if (!calculation.id) {
calculations.splice(index, 1);
return;
}
calculation.deleting = true;
calculation.deleteError = undefined;
try {
const res = await api.assessments.me({ id: calculation.id }).delete();
if (res.error) throw res.error;
calculations.splice(index, 1);
} catch (err) {
calculation.deleteError = errorMessage(err);
} finally {
calculation.deleting = false;
}
}
onMount(loadAssessments);
$effect(() => { $effect(() => {
for (const calculation of calculations) { for (const calculation of calculations) {
@@ -39,19 +141,9 @@
} }
}); });
$effect(() => { const createCalculation = function (name: string): CalcData {
const savedCalcs = localStorage.getItem("calculations");
if (savedCalcs) calculations = JSON.parse(savedCalcs);
});
$effect(() => {
localStorage.setItem("calculations", JSON.stringify(calculations));
});
$inspect(calculations[0]);
const createCalculation = function (name: string) {
return { return {
key: crypto.randomUUID(),
player: { player: {
name: name, name: name,
metrics: { metrics: {
@@ -67,21 +159,6 @@
}; };
}; };
function downloadObject(obj: unknown, filename = "data.json") {
const json = JSON.stringify(obj, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const formatMs = (ms: number) => { const formatMs = (ms: number) => {
const total = Math.floor(ms / 1000); const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60); const m = Math.floor(total / 60);
@@ -149,7 +226,15 @@
}; };
</script> </script>
<div class="mt-5 mb-5 flex w-full"> <div class="mt-5 mb-5 flex w-full items-center">
<button
onclick={loadAssessments}
class="btn btn-secondary btn-sm"
disabled={loading}
>
{loading ? "Refreshing..." : "Refresh"}
</button>
<div class="ml-auto"> <div class="ml-auto">
<button <button
onclick={() => onclick={() =>
@@ -158,17 +243,19 @@
)} )}
class="btn btn-primary">New</button class="btn btn-primary">New</button
> >
<button
onclick={() => downloadObject(calculations)}
class="btn btn-secondary">Export</button
>
</div> </div>
</div> </div>
{#if loadError}
<div role="alert" class="alert alert-error w-full mb-4">
<span>{loadError}</span>
</div>
{/if}
<section <section
class="w-full px-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" class="w-full px-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
> >
{#each calculations as calculation, index} {#each calculations as calculation, index (calculation.key)}
<div <div
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"
> >
@@ -192,7 +279,7 @@
</div> </div>
</li> </li>
{#each Object.values(Attribute) as attribute} {#each Object.values(Attribute) as attribute (attribute)}
<li class="p-3"> <li class="p-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="opacity-80 text-sm"> <div class="opacity-80 text-sm">
@@ -223,7 +310,7 @@
class="select select-bordered select-sm w-full" class="select select-bordered select-sm w-full"
bind:value={calculation.player.metrics.gender} bind:value={calculation.player.metrics.gender}
> >
{#each Object.values(Gender) as gender} {#each Object.values(Gender) as gender (gender)}
<option value={gender}>{gender}</option> <option value={gender}>{gender}</option>
{/each} {/each}
</select> </select>
@@ -247,7 +334,7 @@
class="select select-bordered select-sm w-full" class="select select-bordered select-sm w-full"
bind:value={calculation.player.metrics.weight} bind:value={calculation.player.metrics.weight}
> >
{#each range(400) as weight} {#each range(400) as weight (weight)}
<option value={lbToKg(weight)} <option value={lbToKg(weight)}
>{weight}</option >{weight}</option
> >
@@ -266,7 +353,7 @@
))} ))}
> >
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
{#each calculation.activityPerformances as activityPerformance} {#each calculation.activityPerformances as activityPerformance (activityPerformance.activity)}
<label class="form-control space-y-1"> <label class="form-control space-y-1">
<span class="label mb-1 text-xs"> <span class="label mb-1 text-xs">
{activityPerformance.activity} {activityPerformance.activity}
@@ -275,7 +362,7 @@
class="select select-bordered select-sm w-full" class="select select-bordered select-sm w-full"
bind:value={activityPerformance.performance} bind:value={activityPerformance.performance}
> >
{#each performanceOptionsFromActivity(activityPerformance.activity) as option} {#each performanceOptionsFromActivity(activityPerformance.activity) as option (option.value)}
<option value={option.value} <option value={option.value}
>{option.name}</option >{option.name}</option
> >
@@ -285,13 +372,36 @@
{/each} {/each}
</div> </div>
</fieldset> </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>
<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>
<button
onclick={() =>
confirm(`Delete ${calculation.player.name}?`) &&
calculations.splice(index, 1)}
class="btn btn-error btn-sm w-1/4">Delete</button
>
</div> </div>
{/each} {/each}
</section> </section>