Add ability to view source for datasets
This commit is contained in:
@@ -66,7 +66,7 @@
|
||||
},
|
||||
"ConeDrill": {
|
||||
"enableGeneration": true,
|
||||
"weightModifier": -0.1,
|
||||
"weightModifier": 0.1,
|
||||
"weightSkew": 0,
|
||||
"ageModifier": -0.25,
|
||||
"difficultyModifier": 0.15,
|
||||
@@ -84,7 +84,8 @@
|
||||
"attribute": "Strength",
|
||||
"generators": [],
|
||||
"unit": "kg",
|
||||
"name": "Squat"
|
||||
"name": "Squat",
|
||||
"source": "http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf"
|
||||
},
|
||||
"standards": [
|
||||
{
|
||||
@@ -1662,7 +1663,8 @@
|
||||
"attribute": "Strength",
|
||||
"generators": [],
|
||||
"unit": "kg",
|
||||
"name": "Deadlift"
|
||||
"name": "Deadlift",
|
||||
"source": "http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf"
|
||||
},
|
||||
"standards": [
|
||||
{
|
||||
@@ -3240,7 +3242,8 @@
|
||||
"attribute": "Strength",
|
||||
"generators": [],
|
||||
"unit": "kg",
|
||||
"name": "Bench Press"
|
||||
"name": "Bench Press",
|
||||
"source": "http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf"
|
||||
},
|
||||
"standards": [
|
||||
{
|
||||
@@ -4822,7 +4825,8 @@
|
||||
}
|
||||
],
|
||||
"unit": "ms",
|
||||
"name": "Mile Run"
|
||||
"name": "Mile Run",
|
||||
"source": "https://runninglevel.com/running-times/1-mile-times"
|
||||
},
|
||||
"standards": [
|
||||
{
|
||||
@@ -5315,7 +5319,8 @@
|
||||
}
|
||||
],
|
||||
"unit": "cm",
|
||||
"name": "Broad Jump"
|
||||
"name": "Broad Jump",
|
||||
"source": "https://nrpt.co.uk/training/tests/power/broad.htm"
|
||||
},
|
||||
"standards": [
|
||||
{
|
||||
@@ -5360,7 +5365,8 @@
|
||||
}
|
||||
],
|
||||
"unit": "ms",
|
||||
"name": "3 Cone Drill"
|
||||
"name": "3 Cone Drill",
|
||||
"source": "https://nflsavant.com/combine.php"
|
||||
},
|
||||
"standards": [
|
||||
{
|
||||
|
||||
@@ -168,6 +168,12 @@ export const app = new Elysia()
|
||||
}, {
|
||||
params: t.Object({ id: t.String() })
|
||||
})
|
||||
.patch("/datasets/:id", async ({ params: { id }, body: { name } }) => {
|
||||
await s.Standards.Datasets.update(id, name);
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({ name: t.String() })
|
||||
})
|
||||
)
|
||||
|
||||
// COMMERCE
|
||||
|
||||
@@ -91,4 +91,12 @@ class StandardsDatasetService {
|
||||
Value.Assert(StandardsDataSchema, result.data);
|
||||
return { id: result.id, name: result.name, data: result.data };
|
||||
}
|
||||
|
||||
async update(id: string, name: string) {
|
||||
const result = await db.updateTable("standards_datasets")
|
||||
.set({ name })
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
if (result[0]?.numUpdatedRows === 0n) throw new Error(`No dataset with id "${id}"`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
|
||||
const { selected, allStandards }: Props = $props();
|
||||
|
||||
const activityMetadata = $derived(
|
||||
allStandards.byActivity(selected.activity).getMetadata(),
|
||||
);
|
||||
|
||||
const getLvlValue = (
|
||||
activity: Activity,
|
||||
standard: Standard,
|
||||
@@ -57,10 +61,16 @@
|
||||
<section class="w-full">
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<h2 class="text-xl font-semibold">
|
||||
{allStandards.byActivity(selected.activity).getMetadata().name}
|
||||
</h2>
|
||||
<h2 class="text-xl font-semibold">{activityMetadata.name}</h2>
|
||||
</div>
|
||||
<p class="mt-1 text-xs opacity-60">
|
||||
<a
|
||||
href={activityMetadata.source}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link link-hover">View source</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !selected.metrics.age}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
{
|
||||
name: "Calculator",
|
||||
path: "/calculator",
|
||||
links: [{ name: "Configurations", path: "/configurations" }],
|
||||
links: [
|
||||
{ name: "Configurations", path: "/configurations" },
|
||||
{ name: "Datasets", path: "/datasets" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Commerce",
|
||||
|
||||
@@ -167,8 +167,6 @@
|
||||
if (!editor) return;
|
||||
const name = nameOverride ?? editor.name;
|
||||
if (!name) return;
|
||||
if (mode === "update" && editor.configId && editor.configId === liveConfigId)
|
||||
return;
|
||||
const target = editor;
|
||||
saveError = null;
|
||||
saving = true;
|
||||
@@ -398,7 +396,7 @@
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
hidden={isLive && !isDirty}
|
||||
disabled={saving || !isDirty || isLive || deleting}
|
||||
disabled={saving || !isDirty || deleting}
|
||||
onclick={save}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Standards,
|
||||
StandardsDataSchema,
|
||||
type StandardsParams,
|
||||
type StandardsData,
|
||||
} from "@blade-and-brawn/calculator";
|
||||
import {
|
||||
Activity,
|
||||
Gender,
|
||||
lbToKg,
|
||||
type Metrics,
|
||||
} from "@blade-and-brawn/domain";
|
||||
import StandardsTable from "$lib/components/StandardsTable.svelte";
|
||||
import { api } from "$lib/api";
|
||||
import { Value } from "@sinclair/typebox/value";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
type DatasetSummary = { id: string; name: string };
|
||||
type Editor = { datasetId: string; name: string; data: StandardsData };
|
||||
|
||||
const NEUTRAL_ACTIVITY_PARAMS = {
|
||||
weightModifier: 0,
|
||||
weightSkew: 0,
|
||||
ageModifier: 0,
|
||||
enableGeneration: false,
|
||||
difficultyModifier: 0,
|
||||
peakAge: 0,
|
||||
stretch: { upper: 0, lower: 0 },
|
||||
};
|
||||
|
||||
let activity = $state(Activity.BenchPress);
|
||||
let metrics = $state({
|
||||
gender: Gender.Male,
|
||||
age: null as number | null,
|
||||
weight: null as number | null, // lb
|
||||
});
|
||||
let datasets = $state<DatasetSummary[]>([]);
|
||||
|
||||
let editor = $state<Editor | null>(null);
|
||||
let savedName = $state<string | null>(null);
|
||||
|
||||
let loadError = $state<string | null>(null);
|
||||
let saveError = $state<string | null>(null);
|
||||
let saving = $state(false);
|
||||
|
||||
const isDirty = $derived(editor !== null && editor.name !== savedName);
|
||||
|
||||
const neutralParams = $derived.by((): StandardsParams => {
|
||||
let maxLevel = 0;
|
||||
if (editor) {
|
||||
for (const standard of editor.data[activity]?.standards ?? []) {
|
||||
for (const key of Object.keys(standard.levels)) {
|
||||
maxLevel = Math.max(maxLevel, Number(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
global: { maxLevel },
|
||||
activity: Object.fromEntries(
|
||||
Object.values(Activity).map((a) => [
|
||||
a,
|
||||
{
|
||||
...NEUTRAL_ACTIVITY_PARAMS,
|
||||
stretch: { ...NEUTRAL_ACTIVITY_PARAMS.stretch },
|
||||
},
|
||||
]),
|
||||
) as StandardsParams["activity"],
|
||||
};
|
||||
});
|
||||
|
||||
const allStandards = $derived(
|
||||
editor
|
||||
? new Standards(
|
||||
$state.snapshot({ data: editor.data, params: neutralParams }),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
const selectedMetrics = $derived({
|
||||
gender: metrics.gender,
|
||||
age: metrics.age ?? 0,
|
||||
weight: lbToKg(metrics.weight ?? 0),
|
||||
} as Metrics);
|
||||
|
||||
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 refreshList() {
|
||||
const res = await api.standards.datasets.get();
|
||||
if (res.error) throw res.error;
|
||||
datasets = res.data;
|
||||
}
|
||||
|
||||
async function loadDataset(
|
||||
id: string,
|
||||
opt: { skipDirtyCheck?: boolean } = {},
|
||||
) {
|
||||
if (
|
||||
!opt.skipDirtyCheck &&
|
||||
isDirty &&
|
||||
!confirm("Discard unsaved changes?")
|
||||
)
|
||||
return;
|
||||
|
||||
saveError = null;
|
||||
const res = await api.standards.datasets({ id }).get();
|
||||
if (res.error) {
|
||||
saveError = errorMessage(res.error);
|
||||
return;
|
||||
}
|
||||
Value.Assert(StandardsDataSchema, res.data.data);
|
||||
|
||||
editor = { datasetId: res.data.id, name: res.data.name, data: res.data.data };
|
||||
savedName = res.data.name;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editor || !editor.name || !isDirty) return;
|
||||
const target = editor;
|
||||
saveError = null;
|
||||
saving = true;
|
||||
try {
|
||||
const res = await api.standards.datasets({ id: target.datasetId }).patch({
|
||||
name: target.name,
|
||||
});
|
||||
if (res.error) throw res.error;
|
||||
if (editor === target) savedName = target.name;
|
||||
await refreshList();
|
||||
} catch (err) {
|
||||
saveError = errorMessage(err);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function discard() {
|
||||
if (!editor) return;
|
||||
await loadDataset(editor.datasetId, { skipDirtyCheck: true });
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await refreshList();
|
||||
const first = datasets[0];
|
||||
if (first) await loadDataset(first.id, { skipDirtyCheck: true });
|
||||
} catch (err) {
|
||||
loadError = errorMessage(err);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !(editor && allStandards)}
|
||||
<div class="w-full flex justify-center pt-10">
|
||||
{#if loadError}
|
||||
<div role="alert" class="alert alert-error max-w-lg">
|
||||
<span>Failed to load datasets: {loadError}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{@const currentEditor = editor}
|
||||
<div
|
||||
class="w-full px-6 py-2 bg-base-200 border-b border-base-300 flex items-center gap-2 flex-wrap text-sm"
|
||||
>
|
||||
<span class="label">Dataset: </span>
|
||||
<select
|
||||
class="select select-sm w-36"
|
||||
value={currentEditor.datasetId}
|
||||
onchange={async (e) => {
|
||||
const select = e.currentTarget;
|
||||
await loadDataset(select.value);
|
||||
select.value = editor?.datasetId ?? "";
|
||||
}}
|
||||
>
|
||||
{#each datasets as dataset}
|
||||
<option value={dataset.id}>{dataset.name || "(untitled)"}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if isDirty}
|
||||
<span class="badge badge-warning badge-sm">Unsaved</span>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
hidden={!isDirty}
|
||||
onclick={discard}
|
||||
>
|
||||
Discard changes
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
hidden={!isDirty}
|
||||
disabled={saving || !isDirty}
|
||||
onclick={save}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if saveError}
|
||||
<div role="alert" class="alert alert-error w-full mt-4">
|
||||
<span>{saveError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="w-full flex gap-4 flex-wrap mt-6">
|
||||
<fieldset class="fieldset bg-base-200 p-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="label text-xs">Name</span>
|
||||
<input
|
||||
class="input input-sm w-36"
|
||||
bind:value={currentEditor.name}
|
||||
placeholder="Dataset name"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="label text-xs">Activity</span>
|
||||
<select class="select select-sm w-36" bind:value={activity}>
|
||||
{#each Object.values(Activity) as a}
|
||||
<option value={a}>
|
||||
{allStandards.byActivity(a).getMetadata().name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm opacity-70">
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="label">Gender</span>
|
||||
<select
|
||||
class="select select-sm w-28"
|
||||
name="genders"
|
||||
bind:value={metrics.gender}
|
||||
>
|
||||
{#each Object.values(Gender) as gender}
|
||||
<option value={gender}>{gender}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="label">Age</span>
|
||||
<input
|
||||
class="input input-sm w-20"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="18"
|
||||
bind:value={metrics.age}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="label">Weight (lb)</span>
|
||||
<input
|
||||
class="input input-sm w-20"
|
||||
type="number"
|
||||
min="0"
|
||||
max="600"
|
||||
placeholder="170"
|
||||
bind:value={metrics.weight}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<StandardsTable
|
||||
selected={{
|
||||
activity,
|
||||
metrics: selectedMetrics,
|
||||
standardsConfig: { data: currentEditor.data, params: neutralParams },
|
||||
}}
|
||||
{allStandards}
|
||||
/>
|
||||
{/if}
|
||||
@@ -43,6 +43,7 @@ export const StandardsDataSchema = t.Record(
|
||||
generators: t.Array(GeneratorSchema),
|
||||
unit: t.Union([t.Literal("ms"), t.Literal("cm"), t.Literal("kg")]),
|
||||
name: t.String(),
|
||||
source: t.String()
|
||||
}),
|
||||
standards: t.Array(StandardSchema),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user