Add ability to view source for datasets
This commit is contained in:
@@ -66,7 +66,7 @@
|
|||||||
},
|
},
|
||||||
"ConeDrill": {
|
"ConeDrill": {
|
||||||
"enableGeneration": true,
|
"enableGeneration": true,
|
||||||
"weightModifier": -0.1,
|
"weightModifier": 0.1,
|
||||||
"weightSkew": 0,
|
"weightSkew": 0,
|
||||||
"ageModifier": -0.25,
|
"ageModifier": -0.25,
|
||||||
"difficultyModifier": 0.15,
|
"difficultyModifier": 0.15,
|
||||||
@@ -84,7 +84,8 @@
|
|||||||
"attribute": "Strength",
|
"attribute": "Strength",
|
||||||
"generators": [],
|
"generators": [],
|
||||||
"unit": "kg",
|
"unit": "kg",
|
||||||
"name": "Squat"
|
"name": "Squat",
|
||||||
|
"source": "http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf"
|
||||||
},
|
},
|
||||||
"standards": [
|
"standards": [
|
||||||
{
|
{
|
||||||
@@ -1662,7 +1663,8 @@
|
|||||||
"attribute": "Strength",
|
"attribute": "Strength",
|
||||||
"generators": [],
|
"generators": [],
|
||||||
"unit": "kg",
|
"unit": "kg",
|
||||||
"name": "Deadlift"
|
"name": "Deadlift",
|
||||||
|
"source": "http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf"
|
||||||
},
|
},
|
||||||
"standards": [
|
"standards": [
|
||||||
{
|
{
|
||||||
@@ -3240,7 +3242,8 @@
|
|||||||
"attribute": "Strength",
|
"attribute": "Strength",
|
||||||
"generators": [],
|
"generators": [],
|
||||||
"unit": "kg",
|
"unit": "kg",
|
||||||
"name": "Bench Press"
|
"name": "Bench Press",
|
||||||
|
"source": "http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf"
|
||||||
},
|
},
|
||||||
"standards": [
|
"standards": [
|
||||||
{
|
{
|
||||||
@@ -4822,7 +4825,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"unit": "ms",
|
"unit": "ms",
|
||||||
"name": "Mile Run"
|
"name": "Mile Run",
|
||||||
|
"source": "https://runninglevel.com/running-times/1-mile-times"
|
||||||
},
|
},
|
||||||
"standards": [
|
"standards": [
|
||||||
{
|
{
|
||||||
@@ -5315,7 +5319,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"unit": "cm",
|
"unit": "cm",
|
||||||
"name": "Broad Jump"
|
"name": "Broad Jump",
|
||||||
|
"source": "https://nrpt.co.uk/training/tests/power/broad.htm"
|
||||||
},
|
},
|
||||||
"standards": [
|
"standards": [
|
||||||
{
|
{
|
||||||
@@ -5360,7 +5365,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"unit": "ms",
|
"unit": "ms",
|
||||||
"name": "3 Cone Drill"
|
"name": "3 Cone Drill",
|
||||||
|
"source": "https://nflsavant.com/combine.php"
|
||||||
},
|
},
|
||||||
"standards": [
|
"standards": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ export const app = new Elysia()
|
|||||||
}, {
|
}, {
|
||||||
params: t.Object({ id: t.String() })
|
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
|
// COMMERCE
|
||||||
|
|||||||
@@ -91,4 +91,12 @@ class StandardsDatasetService {
|
|||||||
Value.Assert(StandardsDataSchema, result.data);
|
Value.Assert(StandardsDataSchema, result.data);
|
||||||
return { id: result.id, name: result.name, data: 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 { selected, allStandards }: Props = $props();
|
||||||
|
|
||||||
|
const activityMetadata = $derived(
|
||||||
|
allStandards.byActivity(selected.activity).getMetadata(),
|
||||||
|
);
|
||||||
|
|
||||||
const getLvlValue = (
|
const getLvlValue = (
|
||||||
activity: Activity,
|
activity: Activity,
|
||||||
standard: Standard,
|
standard: Standard,
|
||||||
@@ -57,10 +61,16 @@
|
|||||||
<section class="w-full">
|
<section class="w-full">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<h2 class="text-xl font-semibold">
|
<h2 class="text-xl font-semibold">{activityMetadata.name}</h2>
|
||||||
{allStandards.byActivity(selected.activity).getMetadata().name}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{#if !selected.metrics.age}
|
{#if !selected.metrics.age}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
{
|
{
|
||||||
name: "Calculator",
|
name: "Calculator",
|
||||||
path: "/calculator",
|
path: "/calculator",
|
||||||
links: [{ name: "Configurations", path: "/configurations" }],
|
links: [
|
||||||
|
{ name: "Configurations", path: "/configurations" },
|
||||||
|
{ name: "Datasets", path: "/datasets" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Commerce",
|
name: "Commerce",
|
||||||
|
|||||||
@@ -167,8 +167,6 @@
|
|||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const name = nameOverride ?? editor.name;
|
const name = nameOverride ?? editor.name;
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
if (mode === "update" && editor.configId && editor.configId === liveConfigId)
|
|
||||||
return;
|
|
||||||
const target = editor;
|
const target = editor;
|
||||||
saveError = null;
|
saveError = null;
|
||||||
saving = true;
|
saving = true;
|
||||||
@@ -398,7 +396,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
hidden={isLive && !isDirty}
|
hidden={isLive && !isDirty}
|
||||||
disabled={saving || !isDirty || isLive || deleting}
|
disabled={saving || !isDirty || deleting}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : "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),
|
generators: t.Array(GeneratorSchema),
|
||||||
unit: t.Union([t.Literal("ms"), t.Literal("cm"), t.Literal("kg")]),
|
unit: t.Union([t.Literal("ms"), t.Literal("cm"), t.Literal("kg")]),
|
||||||
name: t.String(),
|
name: t.String(),
|
||||||
|
source: t.String()
|
||||||
}),
|
}),
|
||||||
standards: t.Array(StandardSchema),
|
standards: t.Array(StandardSchema),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user