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