Add ability to view source for datasets

This commit is contained in:
Dominic Ferrando
2026-07-10 14:11:42 -04:00
parent 6d4d5f83b7
commit 0d9f8d0d01
8 changed files with 333 additions and 14 deletions
+4 -1
View File
@@ -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}