Add a verification interface for review
This commit is contained in:
@@ -25,6 +25,11 @@
|
||||
path: "/activity",
|
||||
links: [{ name: "Events", path: "/events" }],
|
||||
},
|
||||
{
|
||||
name: "Verification",
|
||||
path: "/verification",
|
||||
links: [{ name: "Requests", path: "/requests" }],
|
||||
},
|
||||
];
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { api } from "$lib/api";
|
||||
import { Activity } from "@blade-and-brawn/domain";
|
||||
import type { VerificationStatus } from "@blade-and-brawn/api";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
type VerificationRow = NonNullable<
|
||||
Awaited<ReturnType<typeof api.verifications.get>>["data"]
|
||||
>[number];
|
||||
|
||||
const LIMIT = 25;
|
||||
|
||||
const STATUS_BADGE: Record<VerificationStatus, string> = {
|
||||
request: "badge-info",
|
||||
in_review: "badge-warning",
|
||||
action_requested: "badge-error",
|
||||
completed: "badge-success",
|
||||
};
|
||||
|
||||
const URL_COLUMN = {
|
||||
[Activity.BackSquat]: "url_back_squat",
|
||||
[Activity.Deadlift]: "url_deadlift",
|
||||
[Activity.BenchPress]: "url_bench_press",
|
||||
[Activity.Run]: "url_run",
|
||||
[Activity.BroadJump]: "url_broad_jump",
|
||||
[Activity.ConeDrill]: "url_cone_drill",
|
||||
} as const satisfies Record<Activity, keyof VerificationRow>;
|
||||
|
||||
function videoCount(row: VerificationRow): number {
|
||||
return Object.values(Activity).filter((a) => row[URL_COLUMN[a]]).length;
|
||||
}
|
||||
|
||||
function formatDate(value: string | Date | null): string {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
const value = (err as { value?: { error?: string } })?.value;
|
||||
return (
|
||||
value?.error ??
|
||||
(err instanceof Error ? err.message : "Unknown error")
|
||||
);
|
||||
}
|
||||
|
||||
let requests = $state<VerificationRow[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let offset = $state(0);
|
||||
let status = $state<VerificationStatus | "">("request");
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const res = await api.verifications.get({
|
||||
query: { status: status || undefined, limit: LIMIT, offset },
|
||||
});
|
||||
if (res.error) throw res.error;
|
||||
requests = res.data as VerificationRow[];
|
||||
} catch (err) {
|
||||
loadError = errorMessage(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(value: string) {
|
||||
status = value as VerificationStatus | "";
|
||||
offset = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
offset = Math.max(0, offset - LIMIT);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
offset += LIMIT;
|
||||
refresh();
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
</script>
|
||||
|
||||
<div class="w-full flex items-center gap-2 flex-wrap mt-10 mb-5">
|
||||
<select
|
||||
class="select select-sm w-44"
|
||||
value={status}
|
||||
onchange={(e) => setStatus(e.currentTarget.value)}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="request">Request</option>
|
||||
<option value="action_requested">Action requested</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
onclick={refresh}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<button
|
||||
class="btn btn-neutral btn-sm"
|
||||
onclick={prevPage}
|
||||
disabled={offset === 0 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-neutral btn-sm"
|
||||
onclick={nextPage}
|
||||
disabled={requests.length < LIMIT || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<div role="alert" class="alert alert-error w-full mb-4">
|
||||
<span>{loadError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Status</th>
|
||||
<th>Videos</th>
|
||||
<th>Requested</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each requests as row (row.id)}
|
||||
<tr class="hover:bg-base-300">
|
||||
<td>{row.name}</td>
|
||||
<td>
|
||||
<div class="badge {STATUS_BADGE[row.status]}">
|
||||
{row.status}
|
||||
</div>
|
||||
</td>
|
||||
<td>{videoCount(row)} / {Object.values(Activity).length}</td>
|
||||
<td class="whitespace-nowrap">{formatDate(row.created_at)}</td>
|
||||
<td>
|
||||
<a
|
||||
class="btn btn-primary btn-xs"
|
||||
href="/verification/requests/{row.id}"
|
||||
>
|
||||
Review
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center opacity-60">
|
||||
{loading ? "Loading..." : "No verification requests"}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,446 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { api } from "$lib/api";
|
||||
import { Activity, cmToIn, kgToLb, msToTime } from "@blade-and-brawn/domain";
|
||||
import type { ActivityVerifications } from "@blade-and-brawn/domain";
|
||||
import type { VerificationStatus } from "@blade-and-brawn/api";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
async function loadVerification(id: string) {
|
||||
return await api.verifications({ id }).get();
|
||||
}
|
||||
|
||||
type VerificationDetail = NonNullable<
|
||||
Awaited<ReturnType<typeof loadVerification>>["data"]
|
||||
>;
|
||||
type VerfChoice = "unset" | "pass" | "fail";
|
||||
|
||||
const ACTIVITIES = Object.values(Activity);
|
||||
|
||||
const STATUS_BADGE: Record<VerificationStatus, string> = {
|
||||
request: "badge-info",
|
||||
in_review: "badge-warning",
|
||||
action_requested: "badge-error",
|
||||
completed: "badge-success",
|
||||
};
|
||||
|
||||
const URL_COLUMN = {
|
||||
[Activity.BackSquat]: "url_back_squat",
|
||||
[Activity.Deadlift]: "url_deadlift",
|
||||
[Activity.BenchPress]: "url_bench_press",
|
||||
[Activity.Run]: "url_run",
|
||||
[Activity.BroadJump]: "url_broad_jump",
|
||||
[Activity.ConeDrill]: "url_cone_drill",
|
||||
} as const satisfies Record<Activity, keyof VerificationDetail>;
|
||||
|
||||
const VERF_COLUMN = {
|
||||
[Activity.BackSquat]: "verf_back_squat",
|
||||
[Activity.Deadlift]: "verf_deadlift",
|
||||
[Activity.BenchPress]: "verf_bench_press",
|
||||
[Activity.Run]: "verf_run",
|
||||
[Activity.BroadJump]: "verf_broad_jump",
|
||||
[Activity.ConeDrill]: "verf_cone_drill",
|
||||
} as const satisfies Record<Activity, keyof VerificationDetail>;
|
||||
|
||||
const PERF_COLUMN = {
|
||||
[Activity.BackSquat]: "perf_back_squat",
|
||||
[Activity.Deadlift]: "perf_deadlift",
|
||||
[Activity.BenchPress]: "perf_bench_press",
|
||||
[Activity.Run]: "perf_run",
|
||||
[Activity.BroadJump]: "perf_broad_jump",
|
||||
[Activity.ConeDrill]: "perf_cone_drill",
|
||||
} as const satisfies Record<Activity, keyof VerificationDetail>;
|
||||
|
||||
function formatPerformance(activity: Activity, value: number | null): string {
|
||||
if (value === null) return "—";
|
||||
switch (activity) {
|
||||
case Activity.BackSquat:
|
||||
case Activity.Deadlift:
|
||||
case Activity.BenchPress:
|
||||
return `${Math.round(kgToLb(value))} lb`;
|
||||
case Activity.Run:
|
||||
case Activity.ConeDrill:
|
||||
return msToTime(value, true);
|
||||
case Activity.BroadJump: {
|
||||
const inches = cmToIn(value);
|
||||
const ft = Math.floor(inches / 12);
|
||||
const rem = Math.round((inches - ft * 12) * 2) / 2;
|
||||
return `${ft}' ${rem}"`;
|
||||
}
|
||||
default:
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
function verfChoiceFrom(value: boolean | null): VerfChoice {
|
||||
if (value === true) return "pass";
|
||||
if (value === false) return "fail";
|
||||
return "unset";
|
||||
}
|
||||
|
||||
function toActivityVerifications(
|
||||
choices: Record<Activity, VerfChoice>,
|
||||
): ActivityVerifications {
|
||||
const result: ActivityVerifications = {};
|
||||
for (const activity of ACTIVITIES) {
|
||||
if (choices[activity] === "pass") result[activity] = true;
|
||||
else if (choices[activity] === "fail") result[activity] = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
const value = (err as { value?: { error?: string } })?.value;
|
||||
return (
|
||||
value?.error ??
|
||||
(err instanceof Error ? err.message : "Unknown error")
|
||||
);
|
||||
}
|
||||
|
||||
const id = page.params.id;
|
||||
|
||||
let verification = $state<VerificationDetail | null>(null);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let stepIndex = $state(0);
|
||||
let notes = $state("");
|
||||
let verf = $state<Record<Activity, VerfChoice>>(
|
||||
Object.fromEntries(ACTIVITIES.map((a) => [a, "unset"])) as Record<
|
||||
Activity,
|
||||
VerfChoice
|
||||
>,
|
||||
);
|
||||
let submitting = $state<"action" | "complete" | undefined>();
|
||||
let submitError = $state<string | null>(null);
|
||||
|
||||
const SUMMARY_STEP = ACTIVITIES.length;
|
||||
const isSummaryStep = $derived(stepIndex === SUMMARY_STEP);
|
||||
const reviewedCount = $derived(
|
||||
ACTIVITIES.filter((a) => verf[a] !== "unset").length,
|
||||
);
|
||||
const allReviewed = $derived(reviewedCount === ACTIVITIES.length);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const res = await loadVerification(id);
|
||||
if (res.error) throw res.error;
|
||||
if (!res.data) throw new Error("Verification not found");
|
||||
verification = res.data;
|
||||
notes = verification.reviewer_notes ?? "";
|
||||
verf = Object.fromEntries(
|
||||
ACTIVITIES.map((a) => [
|
||||
a,
|
||||
verfChoiceFrom(verification![VERF_COLUMN[a]]),
|
||||
]),
|
||||
) as Record<Activity, VerfChoice>;
|
||||
} catch (err) {
|
||||
loadError = errorMessage(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
stepIndex = Math.max(0, stepIndex - 1);
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
stepIndex = Math.min(SUMMARY_STEP, stepIndex + 1);
|
||||
}
|
||||
|
||||
function setVerf(activity: Activity, choice: VerfChoice) {
|
||||
verf[activity] = choice;
|
||||
}
|
||||
|
||||
async function requestAction() {
|
||||
submitting = "action";
|
||||
submitError = null;
|
||||
try {
|
||||
const res = await api
|
||||
.verifications({ id })
|
||||
["request-action"].post({
|
||||
reviewerNotes: notes || undefined,
|
||||
activityVerifications: toActivityVerifications(verf),
|
||||
});
|
||||
if (res.error) throw res.error;
|
||||
await goto("/verification/requests");
|
||||
} catch (err) {
|
||||
submitError = errorMessage(err);
|
||||
} finally {
|
||||
submitting = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
submitting = "complete";
|
||||
submitError = null;
|
||||
try {
|
||||
const res = await api.verifications({ id }).complete.post({
|
||||
activityVerifications: toActivityVerifications(verf),
|
||||
});
|
||||
if (res.error) throw res.error;
|
||||
await goto("/verification/requests");
|
||||
} catch (err) {
|
||||
submitError = errorMessage(err);
|
||||
} finally {
|
||||
submitting = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="w-full mt-10 mb-5">
|
||||
<a href="/verification/requests" class="link link-hover text-sm opacity-70"
|
||||
>← Back to requests</a
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="w-full flex justify-center pt-10">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<div role="alert" class="alert alert-error w-full mb-4">
|
||||
<span>{loadError}</span>
|
||||
</div>
|
||||
{:else if verification}
|
||||
{@const v = verification}
|
||||
<div class="w-full flex items-center gap-3 flex-wrap mb-6">
|
||||
<h1 class="text-xl font-semibold">{v.name}</h1>
|
||||
<div class="badge {STATUS_BADGE[v.status]}">{v.status}</div>
|
||||
<span class="text-sm opacity-70"
|
||||
>{v.gender} · age {v.age} · {Math.round(kgToLb(v.weight))} lb</span
|
||||
>
|
||||
<div class="flex-1"></div>
|
||||
<span class="text-sm opacity-70"
|
||||
>{reviewedCount} / {ACTIVITIES.length} reviewed</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-border w-full">
|
||||
{#each ACTIVITIES as activity, i (activity)}
|
||||
<button
|
||||
role="tab"
|
||||
class="tab gap-2"
|
||||
class:tab-active={stepIndex === i}
|
||||
onclick={() => (stepIndex = i)}
|
||||
>
|
||||
{activity}
|
||||
{#if verf[activity] === "pass"}
|
||||
<span class="text-success">✓</span>
|
||||
{:else if verf[activity] === "fail"}
|
||||
<span class="text-error">✕</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
role="tab"
|
||||
class="tab"
|
||||
class:tab-active={isSummaryStep}
|
||||
onclick={() => (stepIndex = SUMMARY_STEP)}
|
||||
>
|
||||
Summary
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !isSummaryStep}
|
||||
{@const activity = ACTIVITIES[stepIndex]}
|
||||
<div class="w-full flex flex-col gap-4 bg-base-200/60 rounded-lg p-6 mt-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<h2 class="text-lg font-semibold">{activity}</h2>
|
||||
<span class="text-sm opacity-70"
|
||||
>Claimed: {formatPerformance(
|
||||
activity,
|
||||
v[PERF_COLUMN[activity]],
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if v[URL_COLUMN[activity]]}
|
||||
{@const videoUrl = v[URL_COLUMN[activity]]}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
class="w-full max-h-[480px] rounded-lg bg-black"
|
||||
src={videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
></video>
|
||||
<a
|
||||
class="link link-primary text-xs self-start"
|
||||
href={videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open video in new tab
|
||||
</a>
|
||||
{:else}
|
||||
<div
|
||||
class="w-full aspect-video rounded-lg bg-base-300 flex items-center justify-center opacity-60"
|
||||
>
|
||||
No video uploaded
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 justify-center mt-2">
|
||||
<button
|
||||
class="btn btn-sm {verf[activity] === 'pass'
|
||||
? 'btn-success'
|
||||
: 'btn-outline btn-success'}"
|
||||
onclick={() => setVerf(activity, "pass")}
|
||||
>
|
||||
Pass
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm {verf[activity] === 'fail'
|
||||
? 'btn-error'
|
||||
: 'btn-outline btn-error'}"
|
||||
onclick={() => setVerf(activity, "fail")}
|
||||
>
|
||||
Fail
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
disabled={verf[activity] === "unset"}
|
||||
onclick={() => setVerf(activity, "unset")}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between mt-2">
|
||||
<button
|
||||
class="btn btn-neutral btn-sm"
|
||||
onclick={goPrev}
|
||||
disabled={stepIndex === 0}
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<button class="btn btn-neutral btn-sm" onclick={goNext}>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full flex flex-col gap-4 bg-base-200/60 rounded-lg p-6 mt-4">
|
||||
<h2 class="text-lg font-semibold">Review summary</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Activity</th>
|
||||
<th>Claimed</th>
|
||||
<th>Video</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ACTIVITIES as activity, i (activity)}
|
||||
<tr>
|
||||
<td>
|
||||
<button
|
||||
class="link link-hover"
|
||||
onclick={() => (stepIndex = i)}
|
||||
>
|
||||
{activity}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{formatPerformance(
|
||||
activity,
|
||||
v[PERF_COLUMN[activity]],
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{#if v[URL_COLUMN[activity]]}
|
||||
<a
|
||||
class="link link-primary"
|
||||
href={v[URL_COLUMN[activity]]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
{:else}
|
||||
<span class="opacity-50">None</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if verf[activity] === "pass"}
|
||||
<span class="badge badge-success"
|
||||
>Pass</span
|
||||
>
|
||||
{:else if verf[activity] === "fail"}
|
||||
<span class="badge badge-error"
|
||||
>Fail</span
|
||||
>
|
||||
{:else}
|
||||
<span class="badge badge-ghost"
|
||||
>Unreviewed</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label mb-1 text-xs">Reviewer notes</span>
|
||||
<textarea
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="3"
|
||||
placeholder="What needs to be fixed?"
|
||||
bind:value={notes}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
{#if submitError}
|
||||
<div role="alert" class="alert alert-error">
|
||||
<span>{submitError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<button
|
||||
class="btn btn-neutral btn-sm"
|
||||
onclick={goPrev}
|
||||
disabled={stepIndex === 0}
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !allReviewed}
|
||||
<span class="text-xs text-warning"
|
||||
>Mark every activity Pass or Fail to complete</span
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
disabled={!!submitting}
|
||||
onclick={requestAction}
|
||||
>
|
||||
{submitting === "action"
|
||||
? "Submitting..."
|
||||
: "Request action"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
disabled={!!submitting || !allReviewed}
|
||||
onclick={complete}
|
||||
>
|
||||
{submitting === "complete"
|
||||
? "Submitting..."
|
||||
: "Complete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
Reference in New Issue
Block a user