Overhaul portal calculator frontend

This commit is contained in:
Dominic Ferrando
2026-07-08 15:45:03 -04:00
parent 2804f2cbf9
commit c474c16a1d
3 changed files with 341 additions and 228 deletions
@@ -0,0 +1,45 @@
<script lang="ts">
interface Props {
label: string;
value: number;
onchange: (value: number) => void;
scale?: number;
min?: number;
max?: number;
step?: number;
placeholder?: string;
disabled?: boolean;
}
let {
label,
value,
onchange,
scale = 100,
min,
max,
step,
placeholder,
disabled,
}: Props = $props();
function onInput(e: Event & { currentTarget: HTMLInputElement }) {
const pct = e.currentTarget.valueAsNumber;
if (!Number.isNaN(pct)) onchange(pct / scale);
}
</script>
<label class="flex flex-col gap-1">
<span class="label text-xs">{label}</span>
<input
class="input input-sm w-20"
type="number"
{min}
{max}
{step}
{placeholder}
value={Math.round(value * scale)}
oninput={onInput}
{disabled}
/>
</label>
@@ -31,9 +31,15 @@
const rawValue = standard.levels[lvl];
const unit = allStandards.byActivity(activity).getMetadata().unit;
switch (unit) {
case "ms":
const seconds = (rawValue / 1000).toFixed(1);
return `${seconds}`;
case "ms": {
if (activity === Activity.Run) {
const totalSeconds = Math.round(rawValue / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
return (rawValue / 1000).toFixed(1);
}
case "cm": {
const totalInches = cmToIn(rawValue);
const feet = Math.floor(totalInches / 12);
@@ -51,19 +57,9 @@
<section class="w-full">
<div>
<div class="flex items-center">
<p class="text-xs label">
{#if selected.standardsConfig.params.activity[selected.activity].enableGeneration && allStandards
.byActivity(selected.activity)
.getMetadata().generators.length}
(Generated data: {allStandards
.byActivity(selected.activity)
.getMetadata()
.generators.map((g) => g.metric)
.join(", ")})
{:else}
(Generated data: NONE)
{/if}
</p>
<h2 class="text-xl font-semibold">
{allStandards.byActivity(selected.activity).getMetadata().name}
</h2>
</div>
</div>
@@ -14,11 +14,12 @@
} from "@blade-and-brawn/domain";
import StandardsTable from "$lib/components/StandardsTable.svelte";
import PlayersTable from "$lib/components/PlayersTable.svelte";
import PercentInput from "$lib/components/PercentInput.svelte";
import { api } from "$lib/api";
import { Value } from "@sinclair/typebox/value";
import { onMount } from "svelte";
type ConfigSummary = { id: string; name: string; datasetId: string };
type ConfigSummary = { id: string; name: string };
type DatasetSummary = { id: string; name: string };
type Editor = {
configId: string | null;
@@ -27,7 +28,11 @@
params: StandardsParams;
data: StandardsData;
};
type Snapshot = { name: string; datasetId: string; params: StandardsParams };
type Snapshot = {
name: string;
datasetId: string;
params: StandardsParams;
};
const DRAFT_KEY = "calculator-draft";
@@ -52,7 +57,9 @@
const allStandards = $derived(
editor
? new Standards($state.snapshot({ data: editor.data, params: editor.params }))
? new Standards(
$state.snapshot({ data: editor.data, params: editor.params }),
)
: null,
);
@@ -74,11 +81,19 @@
function errorMessage(err: unknown): string {
const value = (err as { value?: { error?: string } })?.value;
return value?.error ?? (err instanceof Error ? err.message : "Unknown error");
return (
value?.error ??
(err instanceof Error ? err.message : "Unknown error")
);
}
function saveDraft() {
if (editor) localStorage.setItem(DRAFT_KEY, JSON.stringify(editor));
if (!editor) return;
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(editor));
} catch (err) {
console.error("failed to save draft", err);
}
}
async function refreshLists() {
@@ -92,8 +107,16 @@
datasets = datasetsRes.data;
}
async function loadConfig(id: string, opt: { skipDirtyCheck?: boolean } = {}) {
if (!opt.skipDirtyCheck && isDirty && !confirm("Discard unsaved changes?")) return;
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();
@@ -121,6 +144,7 @@
async function onDatasetChange(datasetId: string) {
if (!editor) return;
const target = editor;
saveError = null;
const res = await api.standards.datasets({ id: datasetId }).get();
if (res.error) {
@@ -128,37 +152,43 @@
return;
}
Value.Assert(StandardsDataSchema, res.data.data);
// the user may have switched to a different config while this was in flight
if (editor !== target) return;
editor.datasetId = datasetId;
editor.data = res.data.data;
}
async function persist(mode: "update" | "create") {
if (!editor || !editor.name) return;
const target = editor;
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 (mode === "update" && target.configId) {
const res = await api.standards.configs({ id: target.configId }).put({
name: target.name,
datasetId: target.datasetId,
params: target.params,
});
if (res.error) throw res.error;
} else {
const res = await api.standards.configs.post({
name: editor.name,
datasetId: editor.datasetId,
params: editor.params,
name: target.name,
datasetId: target.datasetId,
params: target.params,
});
if (res.error) throw res.error;
editor.configId = res.data.id;
// only stamp the new id if the user hasn't switched away in the meantime
if (editor === target) editor.configId = res.data.id;
}
if (editor === target) {
savedSnapshot = {
name: editor.name,
datasetId: editor.datasetId,
params: $state.snapshot(editor.params),
name: target.name,
datasetId: target.datasetId,
params: $state.snapshot(target.params),
};
saveDraft();
}
await refreshLists();
} catch (err) {
saveError = errorMessage(err);
@@ -182,14 +212,15 @@
async function setLive() {
if (!editor?.configId || isDirty) return;
const configId = editor.configId;
saveError = null;
settingLive = true;
try {
const res = await api.calculator.standards.config.switch.post({
standardsConfigId: editor.configId,
standardsConfigId: configId,
});
if (res.error) throw res.error;
liveConfigId = editor.configId;
liveConfigId = configId;
} catch (err) {
saveError = errorMessage(err);
} finally {
@@ -197,31 +228,48 @@
}
}
onMount(async () => {
try {
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;
async function restoreDraft(): Promise<boolean> {
const draftStringified = localStorage.getItem(DRAFT_KEY);
if (draftStringified) {
if (!draftStringified) return false;
try {
const draft = JSON.parse(draftStringified) as Editor;
editor = draft;
Value.Assert(StandardsParamsSchema, draft.params);
Value.Assert(StandardsDataSchema, draft.data);
if (draft.configId) {
const configRes = await api.standards.configs({ id: draft.configId }).get();
if (!configRes.error) {
if (configRes.error) throw configRes.error;
savedSnapshot = {
name: configRes.data.name,
datasetId: configRes.data.datasetId,
params: configRes.data.params,
};
}
editor = draft;
return true;
} catch {
// stale/incompatible/dangling draft (schema drift, deleted config, corrupted JSON) - drop it
localStorage.removeItem(DRAFT_KEY);
saveError =
"Your saved draft could not be restored, so the live config was loaded instead.";
return false;
}
} else {
}
onMount(async () => {
try {
const [, liveRes] = await Promise.all([
refreshLists(),
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;
if (!(await restoreDraft())) {
await loadConfig(liveRes.data.id, { skipDirtyCheck: true });
}
} catch (err) {
@@ -246,9 +294,12 @@
</div>
{:else}
{@const currentEditor = editor}
{@const activityParams = currentEditor.params.activity[activity]}
{@const activityMetadata = allStandards.byActivity(activity).getMetadata()}
<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"
>
<span class="label">Configuration</span>
<select
class="select select-sm"
value={currentEditor.configId ?? ""}
@@ -256,20 +307,30 @@
>
{#each configs as config}
<option value={config.id}>
{config.name || "(untitled)"}{config.id === liveConfigId ? " (live)" : ""}
{config.name || "(untitled)"}{config.id === liveConfigId
? " (live)"
: ""}
</option>
{/each}
</select>
{#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}
<details class="dropdown">
<summary
class="btn btn-ghost btn-sm gap-1 font-normal opacity-70 list-none [&::-webkit-details-marker]:hidden"
>
Edit
More
</summary>
<div class="dropdown-content z-10 menu bg-base-100 rounded-box shadow p-4 gap-3 w-64 mt-2">
<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>
<span class="label mb-1">Configuration name</span>
<input
class="input input-sm w-full"
bind:value={currentEditor.name}
@@ -291,60 +352,56 @@
</div>
</details>
{#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}>
<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}
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}>
<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}>
<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>
<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>
<section class="w-full flex gap-4 flex-wrap mt-6">
<fieldset class="fieldset bg-base-200 p-4">
<legend class="fieldset-legend text-lg font-semibold"
>Global Parameters</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>
<label class="flex flex-col gap-1">
<span class="label text-xs">Max level</span>
<input
class="input w-full"
class="input input-sm w-36"
type="number"
min="1"
max="100"
@@ -352,142 +409,116 @@
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>
<fieldset class="fieldset bg-base-200 p-4">
<legend class="fieldset-legend text-lg font-semibold"
>{activityMetadata.name} Parameters</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">Gender</span>
<select class="select w-full" name="genders" bind:value={metrics.gender}>
{#each Object.values(Gender) as gender}
<option value={gender}>{gender}</option>
<label class="flex flex-col gap-1 mb-3">
<span class="label text-xs">Activity</span>
<select
class="select select-sm w-36"
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">Age</span>
<input
class="input w-full"
type="number"
min="0"
max="100"
placeholder="18"
bind:value={metrics.age}
/>
</label>
<div class="flex gap-3 flex-wrap">
<fieldset class="fieldset bg-base-300 p-2">
<legend class="fieldset-legend text-sm font-semibold"
>Modifiers</legend
>
<label>
<span class="label mb-1">Weight (lb)</span>
<PercentInput
label="Difficulty (%)"
min={-100}
max={100}
step={5}
value={activityParams.difficultyModifier}
onchange={(v) => (activityParams.difficultyModifier = v)}
/>
</fieldset>
<fieldset class="fieldset bg-base-300 p-2">
<legend class="fieldset-legend text-sm font-semibold"
>Generation</legend
>
<div class="flex gap-3">
<label class="flex flex-col gap-1">
<span class="label text-xs">Enabled</span>
<input
class="input w-full"
type="number"
min="0"
max="600"
placeholder="170"
bind:value={metrics.weight}
type="checkbox"
bind:checked={activityParams.enableGeneration}
class="toggle toggle-sm self-start mt-1.5"
/>
</label>
<PercentInput
label="Stretch lower (%)"
scale={10}
min={0}
max={100}
step={10}
value={activityParams.stretch.lower}
onchange={(v) => (activityParams.stretch.lower = v)}
disabled={!activityParams.enableGeneration}
/>
<PercentInput
label="Stretch upper (%)"
scale={10}
min={0}
max={100}
step={10}
value={activityParams.stretch.upper}
onchange={(v) => (activityParams.stretch.upper = v)}
disabled={!activityParams.enableGeneration}
/>
{#if activityMetadata.generators.some((g) => g.metric === "weight")}
<PercentInput
label="Weight sig. (%)"
min={-100}
max={100}
step={5}
value={activityParams.weightModifier}
onchange={(v) => (activityParams.weightModifier = v)}
disabled={!activityParams.enableGeneration}
/>
{/if}
{#if activityMetadata.generators.some((g) => g.metric === "age")}
<PercentInput
label="Age sig. (%)"
min={-100}
max={100}
step={5}
value={activityParams.ageModifier}
onchange={(v) => (activityParams.ageModifier = v)}
disabled={!activityParams.enableGeneration}
/>
<label class="flex flex-col gap-1">
<span class="label text-xs">Peak age</span>
<input
class="input input-sm w-20"
type="number"
min="1"
max="100"
step="1"
bind:value={activityParams.peakAge}
disabled={!activityParams.enableGeneration}
/>
</label>
</div>
</fieldset>
{/if}
</div>
</fieldset>
</div>
</fieldset>
</section>
<div class="divider"></div>
@@ -513,11 +544,52 @@
</div>
{#if activeTab === "standards"}
<div class="flex items-center gap-4 text-sm opacity-70 mt-6">
<span>Filter:</span>
<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: currentEditor.params },
standardsConfig: {
data: currentEditor.data,
params: currentEditor.params,
},
}}
{allStandards}
/>