Improve unsaved state in players tab

This commit is contained in:
Dominic Ferrando
2026-08-22 12:08:25 -04:00
parent e9908aaa98
commit ce363d3b51
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { beforeNavigate } from "$app/navigation";
import {
LevelCalculator,
type LevelCalculatorOutput,
@@ -26,12 +27,30 @@
levels?: LevelCalculatorOutput;
player: Player;
activityPerformances: ActivityPerformance[];
/** JSON snapshot of {player, activityPerformances} as of the last successful save/load. "" means never saved. */
savedSnapshot: string;
saving?: boolean;
saveError?: string;
deleting?: boolean;
deleteError?: string;
}
function snapshotOf(
player: Player,
activityPerformances: ActivityPerformance[],
): string {
return JSON.stringify({ player, activityPerformances });
}
function isDirty(calculation: CalcData): boolean {
return (
snapshotOf(
calculation.player,
calculation.activityPerformances,
) !== calculation.savedSnapshot
);
}
interface Props {
allStandards: Standards;
}
@@ -54,21 +73,25 @@
} as const satisfies Record<Activity, keyof AssessmentRow>;
function assessmentToCalc(assessment: AssessmentRow): CalcData {
return {
key: crypto.randomUUID(),
id: assessment.id,
player: {
const player: Player = {
name: assessment.name,
metrics: {
age: assessment.age,
weight: assessment.weight,
gender: assessment.gender as Gender,
},
},
activityPerformances: Object.values(Activity).map((a) => ({
};
const activityPerformances = Object.values(Activity).map((a) => ({
activity: a,
performance: (assessment[PERF_COLUMN[a]] as number | null) ?? 0,
})),
}));
return {
key: crypto.randomUUID(),
id: assessment.id,
player,
activityPerformances,
savedSnapshot: snapshotOf(player, activityPerformances),
};
}
@@ -104,6 +127,10 @@
});
if (res.error) throw res.error;
calculation.id = res.data?.id;
calculation.savedSnapshot = snapshotOf(
calculation.player,
calculation.activityPerformances,
);
} catch (err) {
calculation.saveError = errorMessage(err);
} finally {
@@ -132,6 +159,24 @@
onMount(loadAssessments);
beforeNavigate((navigation) => {
if (!calculations.some(isDirty)) return;
if (navigation.type === "leave") {
// triggers the browser's native "leave site?" confirmation
navigation.cancel();
return;
}
if (
!confirm(
"You have unsaved player changes that will be lost. Leave anyway?",
)
) {
navigation.cancel();
}
});
$effect(() => {
for (const calculation of calculations) {
calculation.levels = levelCalculator.calculate(
@@ -144,6 +189,7 @@
const createCalculation = function (name: string): CalcData {
return {
key: crypto.randomUUID(),
savedSnapshot: "", // never saved — always dirty until the first save
player: {
name: name,
metrics: {
@@ -260,12 +306,19 @@
class="card card-compact bg-base-200 p-4 shadow-lg max-w-sm w-full"
>
<div class="card-body gap-4 p-4">
<div class="flex items-center gap-2">
<input
class="input input-bordered input-sm w-full"
type="text"
bind:value={calculation.player.name}
placeholder="Player name"
/>
{#if isDirty(calculation)}
<div class="badge badge-warning badge-sm shrink-0">
Unsaved
</div>
{/if}
</div>
<ul
class="bg-base-100 rounded-box shadow-xs divide-y divide-base-300"
@@ -387,7 +440,7 @@
<div class="flex gap-2">
<button
onclick={() => saveCalculation(calculation)}
disabled={calculation.saving}
disabled={calculation.saving || !isDirty(calculation)}
class="btn btn-primary btn-sm flex-1"
>
{calculation.saving ? "Saving..." : "Save"}