3 Commits
2 changed files with 263 additions and 169 deletions
+259 -165
View File
@@ -29,6 +29,8 @@
activityPerformances: ActivityPerformance[]; activityPerformances: ActivityPerformance[];
/** JSON snapshot of {player, activityPerformances} as of the last successful save/load. "" means never saved. */ /** JSON snapshot of {player, activityPerformances} as of the last successful save/load. "" means never saved. */
savedSnapshot: string; 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; saving?: boolean;
saveError?: string; saveError?: string;
deleting?: boolean; deleting?: boolean;
@@ -59,6 +61,9 @@
const levelCalculator = $derived(new LevelCalculator(allStandards)); 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 calculations = $state<CalcData[]>([]);
let loading = $state(true); let loading = $state(true);
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
@@ -121,12 +126,22 @@
calculation.saving = true; calculation.saving = true;
calculation.saveError = undefined; calculation.saveError = undefined;
try { try {
const res = await api.assessments.me.post({ if (calculation.id) {
player: calculation.player, const res = await api
activityPerformances: calculation.activityPerformances, .assessments({ id: calculation.id })
}); .put({
if (res.error) throw res.error; player: calculation.player,
calculation.id = res.data?.id; 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.savedSnapshot = snapshotOf(
calculation.player, calculation.player,
calculation.activityPerformances, calculation.activityPerformances,
@@ -190,6 +205,7 @@
return { return {
key: crypto.randomUUID(), key: crypto.randomUUID(),
savedSnapshot: "", // never saved — always dirty until the first save 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: {
@@ -223,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:
@@ -231,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;
@@ -239,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;
} }
@@ -250,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;
@@ -262,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;
@@ -298,163 +314,241 @@
</div> </div>
{/if} {/if}
<section <div class="w-full overflow-x-auto">
class="w-full px-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" <table class="table">
> <thead>
{#each calculations as calculation, index (calculation.key)} <tr>
<div <th class="w-8"></th>
class="card card-compact bg-base-200 p-4 shadow-lg max-w-sm w-full" <th>Name</th>
> <th>Overall</th>
<div class="card-body gap-4 p-4"> {#each Object.values(Attribute) as attribute (attribute)}
<div class="flex items-center gap-2"> <th>{attribute}</th>
<input {/each}
class="input input-bordered input-sm w-full" <th class="w-40"></th>
type="text" </tr>
bind:value={calculation.player.name} </thead>
placeholder="Player name" <tbody>
/> {#each calculations as calculation, index (calculation.key)}
{#if isDirty(calculation)} <tr class="hover:bg-base-300">
<div class="badge badge-warning badge-sm shrink-0"> <td>
Unsaved <button
</div> onclick={() =>
{/if} (calculation.expanded = !calculation.expanded)}
</div> class="btn btn-ghost btn-xs"
aria-label={calculation.expanded
<ul ? "Collapse details"
class="bg-base-100 rounded-box shadow-xs divide-y divide-base-300" : "Expand details"}
> >
<li class="p-3"> {calculation.expanded ? "▾" : "▸"}
<div class="flex items-center justify-between"> </button>
<div class="font-semibold text-sm">OVERALL</div> </td>
<div class="badge badge-neutral badge-xs"> <td>
{calculation?.levels?.player || "N/A"} <div class="flex items-center gap-2">
</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>
<input <input
class="input input-bordered input-sm w-full" class="input input-bordered input-sm w-full min-w-32"
type="number" type="text"
min="1" bind:value={calculation.player.name}
max="100" placeholder="Player name"
step="1"
bind:value={calculation.player.metrics.age}
/> />
</label> {#if isDirty(calculation)}
<div class="badge badge-warning badge-xs shrink-0">
<label class="form-control"> Unsaved
<span class="label mb-1 text-xs">Weight</span> </div>
<select {/if}
class="select select-bordered select-sm w-full" </div>
bind:value={calculation.player.metrics.weight} </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)} {calculation.saving ? "Saving..." : "Save"}
<option value={lbToKg(weight)} </button>
>{weight}</option <button
> onclick={() =>
{/each} confirm(
</select> `Remove ${calculation.player.name}?`,
</label> ) && deleteCalculation(calculation, index)}
</div> disabled={calculation.deleting}
</fieldset> class="btn btn-error btn-xs"
>
<fieldset {calculation.deleting ? "..." : "Delete"}
class="fieldset bg-base-200/60 rounded-lg" </button>
onchange={() => </div>
(calculation.levels = levelCalculator.calculate( </td>
calculation.player, </tr>
calculation.activityPerformances, {#if calculation.expanded}
))} <tr>
> <td colspan={TABLE_COLUMNS}>
<div class="grid grid-cols-2 gap-3"> <div
{#each calculation.activityPerformances as activityPerformance (activityPerformance.activity)} class="bg-base-200/60 rounded-lg p-4 flex flex-col gap-4"
<label class="form-control space-y-1"> >
<span class="label mb-1 text-xs"> <fieldset
{activityPerformance.activity} class="fieldset"
</span> onchange={() =>
<select (calculation.levels =
class="select select-bordered select-sm w-full" levelCalculator.calculate(
bind:value={activityPerformance.performance} calculation.player,
calculation.activityPerformances,
))}
> >
{#each performanceOptionsFromActivity(activityPerformance.activity) as option (option.value)} <legend
<option value={option.value} class="fieldset-legend text-xs font-semibold opacity-70"
>{option.name}</option >
> Metrics
{/each} </legend>
</select> <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
</label> <label class="form-control">
{/each} <span class="label mb-1 text-xs"
</div> >Gender</span
</fieldset> >
<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} <label class="form-control">
<div role="alert" class="alert alert-error alert-sm"> <span class="label mb-1 text-xs"
<span class="text-xs">{calculation.saveError}</span> >Age</span
</div> >
<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}
{#if calculation.deleteError} {:else}
<div role="alert" class="alert alert-error alert-sm"> <tr>
<span class="text-xs">{calculation.deleteError}</span> <td
</div> colspan={TABLE_COLUMNS}
{/if} class="text-center opacity-60"
</div> >
<div class="flex gap-2"> {loading ? "Loading..." : "No players yet"}
<button </td>
onclick={() => saveCalculation(calculation)} </tr>
disabled={calculation.saving || !isDirty(calculation)} {/each}
class="btn btn-primary btn-sm flex-1" </tbody>
> </table>
{calculation.saving ? "Saving..." : "Save"} </div>
</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>
+4 -4
View File
@@ -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