Add initial portal frontend for configuration managemetn

This commit is contained in:
Dominic Ferrando
2026-07-08 13:25:07 -04:00
parent cec0d17312
commit 2804f2cbf9
2 changed files with 442 additions and 277 deletions
@@ -1,8 +1,10 @@
<script lang="ts">
import {
Standards,
StandardsConfigSchema,
type StandardsConfig,
StandardsParamsSchema,
StandardsDataSchema,
type StandardsParams,
type StandardsData,
} from "@blade-and-brawn/calculator";
import {
Activity,
@@ -16,320 +18,476 @@
import { Value } from "@sinclair/typebox/value";
import { onMount } from "svelte";
type ConfigSummary = { id: string; name: string; datasetId: string };
type DatasetSummary = { id: string; name: string };
type Editor = {
configId: string | null;
name: string;
datasetId: string;
params: StandardsParams;
data: StandardsData;
};
type Snapshot = { name: string; datasetId: string; params: StandardsParams };
const DRAFT_KEY = "calculator-draft";
let activeTab = $state("standards");
let input = $state({
activity: Activity.BenchPress,
metrics: {
gender: Gender.Male,
age: null as number | null,
weight: null as number | null, // lb
},
standardsConfig: null as StandardsConfig | null,
let activity = $state(Activity.BenchPress);
let metrics = $state({
gender: Gender.Male,
age: null as number | null,
weight: null as number | null, // lb
});
let configs = $state<ConfigSummary[]>([]);
let datasets = $state<DatasetSummary[]>([]);
let liveConfigId = $state<string | null>(null);
const selected = $derived({
activity: input.activity,
metrics: {
gender: input.metrics.gender,
age: input.metrics.age ?? 0,
weight: lbToKg(input.metrics.weight ?? 0),
} as Metrics,
standardsConfig: input.standardsConfig,
});
let editor = $state<Editor | null>(null);
let savedSnapshot = $state<Snapshot | null>(null);
let loadError = $state<string | null>(null);
let saveError = $state<string | null>(null);
let saving = $state(false);
let settingLive = $state(false);
const allStandards = $derived(
selected.standardsConfig
? new Standards($state.snapshot(selected.standardsConfig))
editor
? new Standards($state.snapshot({ data: editor.data, params: editor.params }))
: null,
);
let standardsConfigLoadError = $state<string | null>(null);
const isDirty = $derived.by(() => {
if (!editor || !savedSnapshot) return false;
const current: Snapshot = {
name: editor.name,
datasetId: editor.datasetId,
params: editor.params,
};
return JSON.stringify(current) !== JSON.stringify(savedSnapshot);
});
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");
}
function saveDraft() {
if (editor) localStorage.setItem(DRAFT_KEY, JSON.stringify(editor));
}
async function refreshLists() {
const [configsRes, datasetsRes] = await Promise.all([
api.standards.configs.get(),
api.standards.datasets.get(),
]);
if (configsRes.error) throw configsRes.error;
if (datasetsRes.error) throw datasetsRes.error;
configs = configsRes.data;
datasets = datasetsRes.data;
}
async function loadConfig(id: string, opt: { skipDirtyCheck?: boolean } = {}) {
if (!opt.skipDirtyCheck && isDirty && !confirm("Discard unsaved changes?")) return;
saveError = null;
const res = await api.standards.configs({ id }).get();
if (res.error) {
saveError = errorMessage(res.error);
return;
}
Value.Assert(StandardsParamsSchema, res.data.params);
Value.Assert(StandardsDataSchema, res.data.data);
editor = {
configId: res.data.id,
name: res.data.name,
datasetId: res.data.datasetId,
params: res.data.params,
data: res.data.data,
};
savedSnapshot = {
name: res.data.name,
datasetId: res.data.datasetId,
params: structuredClone(res.data.params),
};
saveDraft();
}
async function onDatasetChange(datasetId: string) {
if (!editor) return;
saveError = null;
const res = await api.standards.datasets({ id: datasetId }).get();
if (res.error) {
saveError = errorMessage(res.error);
return;
}
Value.Assert(StandardsDataSchema, res.data.data);
editor.datasetId = datasetId;
editor.data = res.data.data;
}
async function persist(mode: "update" | "create") {
if (!editor || !editor.name) return;
saveError = null;
saving = true;
try {
if (mode === "update" && editor.configId) {
const res = await api.standards.configs({ id: editor.configId }).put({
name: editor.name,
datasetId: editor.datasetId,
params: editor.params,
});
if (res.error) throw res.error;
} else {
const res = await api.standards.configs.post({
name: editor.name,
datasetId: editor.datasetId,
params: editor.params,
});
if (res.error) throw res.error;
editor.configId = res.data.id;
}
savedSnapshot = {
name: editor.name,
datasetId: editor.datasetId,
params: $state.snapshot(editor.params),
};
saveDraft();
await refreshLists();
} catch (err) {
saveError = errorMessage(err);
} finally {
saving = false;
}
}
function save() {
return persist(editor?.configId ? "update" : "create");
}
function saveAsNew() {
return persist("create");
}
async function discard() {
if (!editor?.configId) return;
await loadConfig(editor.configId, { skipDirtyCheck: true });
}
async function setLive() {
if (!editor?.configId || isDirty) return;
saveError = null;
settingLive = true;
try {
const res = await api.calculator.standards.config.switch.post({
standardsConfigId: editor.configId,
});
if (res.error) throw res.error;
liveConfigId = editor.configId;
} catch (err) {
saveError = errorMessage(err);
} finally {
settingLive = false;
}
}
onMount(async () => {
try {
const savedInputStringified = localStorage.getItem("input");
if (savedInputStringified) {
input = JSON.parse(savedInputStringified);
await refreshLists();
const liveRes = await api.calculator.standards.config.get();
if (liveRes.error) throw liveRes.error;
Value.Assert(StandardsParamsSchema, liveRes.data.params);
Value.Assert(StandardsDataSchema, liveRes.data.data);
liveConfigId = liveRes.data.id;
const draftStringified = localStorage.getItem(DRAFT_KEY);
if (draftStringified) {
const draft = JSON.parse(draftStringified) as Editor;
editor = draft;
if (draft.configId) {
const configRes = await api.standards.configs({ id: draft.configId }).get();
if (!configRes.error) {
savedSnapshot = {
name: configRes.data.name,
datasetId: configRes.data.datasetId,
params: configRes.data.params,
};
}
}
} else {
const res = await api.calculator.standards.config.get();
if (res.error) throw res.error;
Value.Assert(StandardsConfigSchema, res.data);
input.standardsConfig = res.data;
await loadConfig(liveRes.data.id, { skipDirtyCheck: true });
}
} catch (err) {
console.error("failed to load standards config", err);
const value = (err as { value?: { error?: string } })?.value;
standardsConfigLoadError =
value?.error ??
(err instanceof Error ? err.message : "Unknown error");
loadError = errorMessage(err);
}
});
$effect(() => {
localStorage.setItem("input", JSON.stringify(input));
if (editor) saveDraft();
});
</script>
{#if !(input.standardsConfig && allStandards)}
{#if !(editor && allStandards)}
<div class="w-full flex justify-center pt-10">
{#if standardsConfigLoadError}
{#if loadError}
<div role="alert" class="alert alert-error max-w-lg">
<span>
Failed to load the standards configuration: {standardsConfigLoadError}
</span>
<span>Failed to load the calculator: {loadError}</span>
</div>
{:else}
<span class="loading loading-spinner loading-lg"></span>
{/if}
</div>
{:else}
{@const standardsConfig = input.standardsConfig}
<section class="w-full flex justify-between gap-2 pt-10 items-center">
<fieldset class="fieldset bg-base-200 p-6 max-w-lg">
<legend class="fieldset-legend text-lg font-semibold">
Data
</legend>
{@const currentEditor = editor}
<div
class="w-full -mt-10 -mx-10 px-6 py-2 bg-base-200 border-b border-base-300 flex items-center gap-2 flex-wrap text-sm"
>
<select
class="select select-sm"
value={currentEditor.configId ?? ""}
onchange={(e) => loadConfig(e.currentTarget.value)}
>
{#each configs as config}
<option value={config.id}>
{config.name || "(untitled)"}{config.id === liveConfigId ? " (live)" : ""}
</option>
{/each}
</select>
<div class="flex gap-4">
<fieldset class="fieldset bg-base-300 p-3 max-w-xl">
<legend class="fieldset-legend text-sm font-semibold">
General
</legend>
<details class="dropdown">
<summary
class="btn btn-ghost btn-sm gap-1 font-normal opacity-70 list-none [&::-webkit-details-marker]:hidden"
>
Edit ▾
</summary>
<div class="dropdown-content z-10 menu bg-base-100 rounded-box shadow p-4 gap-3 w-64 mt-2">
<label class="w-full">
<span class="label mb-1">Name</span>
<input
class="input input-sm w-full"
bind:value={currentEditor.name}
placeholder="Config name"
/>
</label>
<label class="w-full">
<span class="label mb-1">Dataset</span>
<select
class="select select-sm w-full"
value={currentEditor.datasetId}
onchange={(e) => onDatasetChange(e.currentTarget.value)}
>
{#each datasets as dataset}
<option value={dataset.id}>{dataset.name}</option>
{/each}
</select>
</label>
</div>
</details>
<div class="grid grid-cols-1 gap-4">
{#if isDirty}
<span class="badge badge-warning badge-sm">Unsaved</span>
{:else if currentEditor.configId === liveConfigId}
<span class="badge badge-success badge-sm">Live</span>
{/if}
<div class="flex-1"></div>
<button class="btn btn-ghost btn-sm" disabled={!isDirty} onclick={discard}>
Discard
</button>
<button
class="btn btn-outline btn-sm"
disabled={settingLive || isDirty || currentEditor.configId === liveConfigId}
onclick={setLive}
>
{settingLive ? "Setting live..." : "Set as live"}
</button>
<button class="btn btn-outline btn-sm" disabled={saving || !currentEditor.name} onclick={saveAsNew}>
Save as new
</button>
<button class="btn btn-primary btn-sm" 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 flex-col gap-4 mt-6">
<div class="flex justify-between gap-2 items-center flex-wrap">
<fieldset class="fieldset bg-base-200 p-6 max-w-lg">
<legend class="fieldset-legend text-lg font-semibold">Data</legend>
<div class="flex gap-4">
<fieldset class="fieldset bg-base-300 p-3 max-w-xl">
<legend class="fieldset-legend text-sm font-semibold">General</legend>
<div class="grid grid-cols-1 gap-4">
<label>
<span class="label mb-1">Activity</span>
<select class="select w-full" name="activities" bind:value={activity}>
{#each Object.values(Activity) as a}
<option value={a}>
{allStandards.byActivity(a).getMetadata().name}
</option>
{/each}
</select>
</label>
<label>
<span class="label mb-1">Max level</span>
<input
class="input w-full"
type="number"
min="1"
max="100"
placeholder="5"
bind:value={currentEditor.params.global.maxLevel}
/>
</label>
</div>
</fieldset>
<fieldset class="fieldset bg-base-300 p-3 max-w-3xs">
<legend class="fieldset-legend text-sm font-semibold">Data Generation</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="label">
<input
type="checkbox"
bind:checked={currentEditor.params.activity[activity].enableGeneration}
class="toggle toggle-lg m-auto"
/>
</label>
<label>
<span class="label mb-1">Weight significance</span>
<input
class="input w-full"
type="number"
min="-1"
max="1"
step=".05"
placeholder=".1"
bind:value={currentEditor.params.activity[activity].weightModifier}
disabled={!currentEditor.params.activity[activity].enableGeneration}
/>
</label>
<label>
<span class="label mb-1">Difficulty modifier</span>
<input
class="input w-full"
type="number"
min="-1"
max="1"
step=".05"
bind:value={currentEditor.params.activity[activity].difficultyModifier}
disabled={!currentEditor.params.activity[activity].enableGeneration}
/>
</label>
<label>
<span class="label mb-1">Age significance</span>
<input
class="input w-full"
type="number"
min="-1"
max="1"
step=".05"
bind:value={currentEditor.params.activity[activity].ageModifier}
disabled={!currentEditor.params.activity[activity].enableGeneration}
/>
</label>
<label>
<span class="label mb-1">Peak age</span>
<input
class="input w-full"
type="number"
min="1"
max="100"
step="1"
bind:value={currentEditor.params.activity[activity].peakAge}
disabled={!currentEditor.params.activity[activity].enableGeneration}
/>
</label>
<fieldset class="fieldset bg-base-200 p-3 max-w-xl">
<legend class="fieldset-legend text-sm font-semibold">Stretch</legend>
<label>
<span class="label mb-1">Lower</span>
<input
class="input w-full"
type="number"
min="0"
max="10"
step="1"
bind:value={currentEditor.params.activity[activity].stretch.lower}
disabled={!currentEditor.params.activity[activity].enableGeneration}
/>
</label>
<label>
<span class="label mb-1">Upper</span>
<input
class="input w-full"
type="number"
min="0"
max="10"
step="1"
bind:value={currentEditor.params.activity[activity].stretch.upper}
disabled={!currentEditor.params.activity[activity].enableGeneration}
/>
</label>
</fieldset>
</div>
</fieldset>
</div>
</fieldset>
{#if activeTab === "standards"}
<fieldset class="fieldset bg-base-200 p-6 max-w-lg">
<legend class="fieldset-legend text-lg font-semibold">Metrics</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label>
<span class="label mb-1">Activity</span>
<select
class="select w-full"
name="activities"
bind:value={input.activity}
>
{#each Object.values(Activity) as activity}
<option value={activity}>
{allStandards
.byActivity(activity)
.getMetadata().name}
</option>
<span class="label mb-1">Gender</span>
<select class="select w-full" name="genders" bind:value={metrics.gender}>
{#each Object.values(Gender) as gender}
<option value={gender}>{gender}</option>
{/each}
</select>
</label>
<label>
<span class="label mb-1">Max level</span>
<span class="label mb-1">Age</span>
<input
class="input w-full"
type="number"
min="1"
min="0"
max="100"
placeholder="5"
bind:value={
standardsConfig.params.global.maxLevel
}
placeholder="18"
bind:value={metrics.age}
/>
</label>
<label>
<span class="label mb-1">Weight (lb)</span>
<input
class="input w-full"
type="number"
min="0"
max="600"
placeholder="170"
bind:value={metrics.weight}
/>
</label>
</div>
</fieldset>
<fieldset class="fieldset bg-base-300 p-3 max-w-3xs">
<legend class="fieldset-legend text-sm font-semibold">
Data Generation
</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="label">
<input
type="checkbox"
bind:checked={
standardsConfig.params.activity[
selected.activity
].enableGeneration
}
class="toggle toggle-lg m-auto"
/>
</label>
<label>
<span class="label mb-1">Weight significance</span>
<input
class="input w-full"
type="number"
min="-1"
max="1"
step=".05"
placeholder=".1"
bind:value={
standardsConfig.params.activity[
selected.activity
].weightModifier
}
disabled={!standardsConfig.params.activity[
selected.activity
].enableGeneration}
/>
</label>
<!-- <label> -->
<!-- <span class="label mb-1">Weight skew</span> -->
<!-- <input -->
<!-- class="input w-full" -->
<!-- type="number" -->
<!-- min="-1" -->
<!-- max="1" -->
<!-- step=".05" -->
<!-- bind:value={ -->
<!-- inputSelected.cfg.activity[selected.activity] -->
<!-- .weightSkew -->
<!-- } -->
<!-- disabled={!selected.params.activity[selected.activity] -->
<!-- .enableGeneration} -->
<!-- /> -->
<!-- </label> -->
<label>
<span class="label mb-1">Difficulty modifier</span>
<input
class="input w-full"
type="number"
min="-1"
max="1"
step=".05"
bind:value={
standardsConfig.params.activity[
selected.activity
].difficultyModifier
}
disabled={!standardsConfig.params.activity[
selected.activity
].enableGeneration}
/>
</label>
<label>
<span class="label mb-1">Age significance</span>
<input
class="input w-full"
type="number"
min="-1"
max="1"
step=".05"
bind:value={
standardsConfig.params.activity[
selected.activity
].ageModifier
}
disabled={!standardsConfig.params.activity[
selected.activity
].enableGeneration}
/>
</label>
<label>
<span class="label mb-1">Peak age</span>
<input
class="input w-full"
type="number"
min="1"
max="100"
step="1"
bind:value={
standardsConfig.params.activity[
selected.activity
].peakAge
}
disabled={!standardsConfig.params.activity[
selected.activity
].enableGeneration}
/>
</label>
<fieldset class="fieldset bg-base-200 p-3 max-w-xl">
<legend
class="fieldset-legend text-sm font-semibold"
>
Stretch
</legend>
<label>
<span class="label mb-1">Lower</span>
<input
class="input w-full"
type="number"
min="0"
max="10"
step="1"
bind:value={
standardsConfig.params.activity[
selected.activity
].stretch.lower
}
disabled={!standardsConfig.params.activity[
selected.activity
].enableGeneration}
/>
</label>
<label>
<span class="label mb-1">Upper</span>
<input
class="input w-full"
type="number"
min="0"
max="10"
step="1"
bind:value={
standardsConfig.params.activity[
selected.activity
].stretch.upper
}
disabled={!standardsConfig.params.activity[
selected.activity
].enableGeneration}
/>
</label>
</fieldset>
</div>
</fieldset>
</div>
</fieldset>
{#if activeTab === "standards"}
<fieldset class="fieldset bg-base-200 p-6 max-w-lg">
<legend class="fieldset-legend text-lg font-semibold">
Metrics
</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label>
<span class="label mb-1">Gender</span>
<select
class="select w-full"
name="genders"
bind:value={input.metrics.gender}
>
{#each Object.values(Gender) as gender}
<option value={gender}>{gender}</option>
{/each}
</select>
</label>
<label>
<span class="label mb-1">Age</span>
<input
class="input w-full"
type="number"
min="0"
max="100"
placeholder="18"
bind:value={input.metrics.age}
/>
</label>
<label>
<span class="label mb-1">Weight (lb)</span>
<input
class="input w-full"
type="number"
min="0"
max="600"
placeholder="170"
bind:value={input.metrics.weight}
/>
</label>
</div>
</fieldset>
{/if}
{/if}
</div>
</section>
<div class="divider"></div>
@@ -356,7 +514,11 @@
{#if activeTab === "standards"}
<StandardsTable
selected={{ ...selected, standardsConfig }}
selected={{
activity,
metrics: selectedMetrics,
standardsConfig: { data: currentEditor.data, params: currentEditor.params },
}}
{allStandards}
/>
{/if}