Compare commits
7
Commits
01e9ac8403
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99b78b38e8 | ||
|
|
3f74e22729 | ||
|
|
8a847cf965 | ||
|
|
564a18cfcb | ||
|
|
ce363d3b51 | ||
|
|
e9908aaa98 | ||
|
|
951f9286aa |
+19
-4
@@ -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
|
||||||
// -----------------------
|
// -----------------------
|
||||||
@@ -482,7 +483,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 +491,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
|
||||||
@@ -505,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),
|
||||||
|
})
|
||||||
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -39,4 +39,31 @@ export class AssessmentsService {
|
|||||||
.$if(opt.offset !== undefined, (qb) => qb.offset(opt.offset!))
|
.$if(opt.offset !== undefined, (qb) => qb.offset(opt.offset!))
|
||||||
.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> {
|
||||||
|
const result = await db.deleteFrom("assessments")
|
||||||
|
.where("id", "=", id)
|
||||||
|
.where("account_id", "=", accountId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return result.numDeletedRows > 0n;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { beforeNavigate } from "$app/navigation";
|
||||||
import {
|
import {
|
||||||
LevelCalculator,
|
LevelCalculator,
|
||||||
type LevelCalculatorOutput,
|
type LevelCalculatorOutput,
|
||||||
@@ -13,11 +15,42 @@
|
|||||||
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[];
|
||||||
|
/** 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 {
|
interface Props {
|
||||||
@@ -28,7 +61,136 @@
|
|||||||
|
|
||||||
const levelCalculator = $derived(new LevelCalculator(allStandards));
|
const levelCalculator = $derived(new LevelCalculator(allStandards));
|
||||||
|
|
||||||
let calculations = $state([] as CalcData[]);
|
// 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);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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,
|
||||||
|
activityPerformances,
|
||||||
|
savedSnapshot: snapshotOf(player, activityPerformances),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -39,19 +201,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$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(),
|
||||||
|
savedSnapshot: "", // never saved — always dirty until the first save
|
||||||
|
expanded: true, // a brand new player needs its details filled in right away
|
||||||
player: {
|
player: {
|
||||||
name: name,
|
name: name,
|
||||||
metrics: {
|
metrics: {
|
||||||
@@ -67,21 +221,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);
|
||||||
@@ -100,7 +239,7 @@
|
|||||||
const formatSeconds = (ms: number) => (ms / 1000).toFixed(2) + " seconds";
|
const formatSeconds = (ms: number) => (ms / 1000).toFixed(2) + " seconds";
|
||||||
|
|
||||||
const performanceOptionsFromActivity = function (activity: Activity) {
|
const performanceOptionsFromActivity = function (activity: Activity) {
|
||||||
const options: { name: string; value: string }[] = [];
|
const options: { name: string; value: number }[] = [];
|
||||||
switch (activity) {
|
switch (activity) {
|
||||||
case Activity.BackSquat:
|
case Activity.BackSquat:
|
||||||
case Activity.Deadlift:
|
case Activity.Deadlift:
|
||||||
@@ -108,7 +247,7 @@
|
|||||||
for (let i = 0; i < 600; ++i) {
|
for (let i = 0; i < 600; ++i) {
|
||||||
options.push({
|
options.push({
|
||||||
name: String(i) + " lb",
|
name: String(i) + " lb",
|
||||||
value: String(lbToKg(i)),
|
value: lbToKg(i),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -116,7 +255,7 @@
|
|||||||
case Activity.Run: {
|
case Activity.Run: {
|
||||||
const MAX_MIN = 30;
|
const MAX_MIN = 30;
|
||||||
for (let ms = 0; ms <= MAX_MIN * 60_000; ms += 1_000) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -127,7 +266,7 @@
|
|||||||
const inches = halfStep / 2;
|
const inches = halfStep / 2;
|
||||||
options.push({
|
options.push({
|
||||||
name: inchesToFeetInches(inches),
|
name: inchesToFeetInches(inches),
|
||||||
value: (inches * 2.54).toFixed(1),
|
value: Number((inches * 2.54).toFixed(1)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -139,7 +278,7 @@
|
|||||||
for (let ms = MIN_MS; ms <= MAX_MS; ms += 10) {
|
for (let ms = MIN_MS; ms <= MAX_MS; ms += 10) {
|
||||||
options.push({
|
options.push({
|
||||||
name: formatSeconds(ms),
|
name: formatSeconds(ms),
|
||||||
value: String(ms),
|
value: ms,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -149,7 +288,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,97 +305,166 @@
|
|||||||
)}
|
)}
|
||||||
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>
|
||||||
|
|
||||||
<section
|
{#if loadError}
|
||||||
class="w-full px-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
<div role="alert" class="alert alert-error w-full mb-4">
|
||||||
|
<span>{loadError}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<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"}
|
||||||
>
|
>
|
||||||
{#each calculations as calculation, index}
|
{calculation.expanded ? "▾" : "▸"}
|
||||||
<div
|
</button>
|
||||||
class="card card-compact bg-base-200 p-4 shadow-lg max-w-sm w-full"
|
</td>
|
||||||
>
|
<td>
|
||||||
<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 min-w-32"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={calculation.player.name}
|
bind:value={calculation.player.name}
|
||||||
placeholder="Player name"
|
placeholder="Player name"
|
||||||
/>
|
/>
|
||||||
|
{#if isDirty(calculation)}
|
||||||
<ul
|
<div class="badge badge-warning badge-xs shrink-0">
|
||||||
class="bg-base-100 rounded-box shadow-xs divide-y divide-base-300"
|
Unsaved
|
||||||
>
|
|
||||||
<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>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</td>
|
||||||
|
<td>
|
||||||
{#each Object.values(Attribute) as attribute}
|
<div class="badge badge-neutral font-semibold">
|
||||||
<li class="p-3">
|
{calculation?.levels?.player ?? "N/A"}
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="opacity-80 text-sm">
|
|
||||||
{attribute}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-ghost badge-xs">
|
</td>
|
||||||
|
{#each Object.values(Attribute) as attribute (attribute)}
|
||||||
|
<td>
|
||||||
|
<div class="badge badge-ghost">
|
||||||
{calculation?.levels?.attributes?.[
|
{calculation?.levels?.attributes?.[
|
||||||
attribute
|
attribute
|
||||||
] || "N/A"}
|
] ?? "N/A"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</li>
|
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
<td>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onclick={() => saveCalculation(calculation)}
|
||||||
|
disabled={calculation.saving ||
|
||||||
|
!isDirty(calculation)}
|
||||||
|
class="btn btn-primary btn-xs"
|
||||||
|
>
|
||||||
|
{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
|
<fieldset
|
||||||
class="fieldset bg-base-200/60 rounded-lg"
|
class="fieldset"
|
||||||
onchange={() =>
|
onchange={() =>
|
||||||
(calculation.levels = levelCalculator.calculate(
|
(calculation.levels =
|
||||||
|
levelCalculator.calculate(
|
||||||
calculation.player,
|
calculation.player,
|
||||||
calculation.activityPerformances,
|
calculation.activityPerformances,
|
||||||
))}
|
))}
|
||||||
>
|
>
|
||||||
|
<legend
|
||||||
|
class="fieldset-legend text-xs font-semibold opacity-70"
|
||||||
|
>
|
||||||
|
Metrics
|
||||||
|
</legend>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<span class="label mb-1 text-xs">Gender</span>
|
<span class="label mb-1 text-xs"
|
||||||
|
>Gender</span
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
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 (gender)}
|
||||||
|
<option value={gender}
|
||||||
|
>{gender}</option
|
||||||
>
|
>
|
||||||
{#each Object.values(Gender) as gender}
|
|
||||||
<option value={gender}>{gender}</option>
|
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<span class="label mb-1 text-xs">Age</span>
|
<span class="label mb-1 text-xs"
|
||||||
|
>Age</span
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
class="input input-bordered input-sm w-full"
|
class="input input-bordered input-sm w-full"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="100"
|
max="100"
|
||||||
step="1"
|
step="1"
|
||||||
bind:value={calculation.player.metrics.age}
|
bind:value={
|
||||||
|
calculation.player.metrics
|
||||||
|
.age
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<span class="label mb-1 text-xs">Weight</span>
|
<span class="label mb-1 text-xs"
|
||||||
|
>Weight</span
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
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
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -258,25 +474,38 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset
|
<fieldset
|
||||||
class="fieldset bg-base-200/60 rounded-lg"
|
class="fieldset"
|
||||||
onchange={() =>
|
onchange={() =>
|
||||||
(calculation.levels = levelCalculator.calculate(
|
(calculation.levels =
|
||||||
|
levelCalculator.calculate(
|
||||||
calculation.player,
|
calculation.player,
|
||||||
calculation.activityPerformances,
|
calculation.activityPerformances,
|
||||||
))}
|
))}
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<legend
|
||||||
{#each calculation.activityPerformances as activityPerformance}
|
class="fieldset-legend text-xs font-semibold opacity-70"
|
||||||
<label class="form-control space-y-1">
|
>
|
||||||
<span class="label mb-1 text-xs">
|
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}
|
{activityPerformance.activity}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
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
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -285,13 +514,41 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
|
||||||
<button
|
{#if calculation.saveError}
|
||||||
onclick={() =>
|
<div
|
||||||
confirm(`Delete ${calculation.player.name}?`) &&
|
role="alert"
|
||||||
calculations.splice(index, 1)}
|
class="alert alert-error alert-sm"
|
||||||
class="btn btn-error btn-sm w-1/4">Delete</button
|
>
|
||||||
|
<span class="text-xs"
|
||||||
|
>{calculation.saveError}</span
|
||||||
>
|
>
|
||||||
</div>
|
</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}
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colspan={TABLE_COLUMNS}
|
||||||
|
class="text-center opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? "Loading..." : "No players yet"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|||||||
+4
-4
@@ -36,14 +36,14 @@ target="${1:-migrate}"
|
|||||||
|
|
||||||
case "$target" in
|
case "$target" in
|
||||||
migrate)
|
migrate)
|
||||||
DATABASE_URL="$tunneled_url" bun run --filter @blade-and-brawn/api db:migrate:latest
|
(cd apps/api && NODE_ENV=production DATABASE_URL="$tunneled_url" bun run --env-file=../../.env.production src/database/migrate.ts latest)
|
||||||
;;
|
;;
|
||||||
seed)
|
seed)
|
||||||
DATABASE_URL="$tunneled_url" bun run --filter @blade-and-brawn/api db:seed
|
(cd apps/api && NODE_ENV=production DATABASE_URL="$tunneled_url" bun run --env-file=../../.env.production src/database/seed.ts)
|
||||||
;;
|
;;
|
||||||
both)
|
both)
|
||||||
DATABASE_URL="$tunneled_url" bun run --filter @blade-and-brawn/api db:migrate:latest
|
(cd apps/api && NODE_ENV=production DATABASE_URL="$tunneled_url" bun run --env-file=../../.env.production src/database/migrate.ts latest)
|
||||||
DATABASE_URL="$tunneled_url" bun run --filter @blade-and-brawn/api db:seed
|
(cd apps/api && NODE_ENV=production DATABASE_URL="$tunneled_url" bun run --env-file=../../.env.production src/database/seed.ts)
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: $0 [migrate|seed|both]" >&2
|
echo "Usage: $0 [migrate|seed|both]" >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user