|
|
|
|
@@ -1,10 +1,11 @@
|
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
import { api } from "../api/client";
|
|
|
|
|
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
|
|
|
|
|
import { RawDataModal } from "../components/RawDataModal";
|
|
|
|
|
import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
|
|
|
|
|
|
|
|
|
const UNSORTED = "__unsorted__";
|
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
|
|
|
|
|
|
function candidates(item: ReviewQueueItem): ScoredKind[] {
|
|
|
|
|
try {
|
|
|
|
|
@@ -24,28 +25,106 @@ function formatPace(avgSpeedMps: number | null): string | null {
|
|
|
|
|
|
|
|
|
|
export function ReviewQueue() {
|
|
|
|
|
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
|
|
|
|
const [total, setTotal] = useState(0);
|
|
|
|
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
|
|
|
const [initialLoading, setInitialLoading] = useState(true);
|
|
|
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
|
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
|
|
|
|
const [filterKindId, setFilterKindId] = useState<string>("");
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
|
|
|
|
const [rawDataItem, setRawDataItem] = useState<ReviewQueueItem | null>(null);
|
|
|
|
|
|
|
|
|
|
// Fetching every activity's laps and per-second samples is expensive once
|
|
|
|
|
// there are many activities, so the list loads incrementally (infinite
|
|
|
|
|
// scroll) instead of all at once -- see the paginated GET /api/review-queue/.
|
|
|
|
|
const allLoaded = !initialLoading && nextCursor === null;
|
|
|
|
|
// A single in-flight request is shared by every concurrent caller (the
|
|
|
|
|
// scroll observer, a filter switch's loadAll, ...): each gets the same
|
|
|
|
|
// promise back and genuinely awaits its completion, rather than a boolean
|
|
|
|
|
// guard that would let a second caller's loop spin against an
|
|
|
|
|
// already-in-progress fetch with nothing to await.
|
|
|
|
|
const inFlightRef = useRef<Promise<void> | null>(null);
|
|
|
|
|
// The authoritative cursor for control flow, updated synchronously the
|
|
|
|
|
// instant a response arrives -- NOT derived from the nextCursor state in
|
|
|
|
|
// the render body. A ref written that way only picks up a new value once
|
|
|
|
|
// React actually re-renders, which isn't guaranteed to happen between two
|
|
|
|
|
// iterations of loadAll's tight loop; it was reading a stale cursor and
|
|
|
|
|
// re-fetching the same page twice. nextCursor (state) still exists
|
|
|
|
|
// separately, purely to drive rendering (e.g. allLoaded).
|
|
|
|
|
const nextCursorRef = useRef<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const loadMore = useCallback((): Promise<void> => {
|
|
|
|
|
if (inFlightRef.current) return inFlightRef.current;
|
|
|
|
|
if (nextCursorRef.current === null) return Promise.resolve();
|
|
|
|
|
setLoadingMore(true);
|
|
|
|
|
const promise = (async () => {
|
|
|
|
|
try {
|
|
|
|
|
const page = await api.reviewQueue({ limit: PAGE_SIZE, before: nextCursorRef.current ?? undefined });
|
|
|
|
|
nextCursorRef.current = page.next_cursor;
|
|
|
|
|
setItems((prev) => [...prev, ...page.items]);
|
|
|
|
|
setNextCursor(page.next_cursor);
|
|
|
|
|
setTotal(page.total);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setError(String(e));
|
|
|
|
|
} finally {
|
|
|
|
|
setLoadingMore(false);
|
|
|
|
|
inFlightRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
inFlightRef.current = promise;
|
|
|
|
|
return promise;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Loads every remaining page in sequence. Used when a specific-kind filter
|
|
|
|
|
// is selected: filtering only the items loaded so far would hide matches
|
|
|
|
|
// that simply haven't scrolled into view yet, so switching to a filter
|
|
|
|
|
// (other than "All") loads the whole backlog once, up front.
|
|
|
|
|
const loadAll = useCallback(async () => {
|
|
|
|
|
while (nextCursorRef.current !== null) {
|
|
|
|
|
await loadMore();
|
|
|
|
|
}
|
|
|
|
|
}, [loadMore]);
|
|
|
|
|
|
|
|
|
|
function reload() {
|
|
|
|
|
Promise.all([api.reviewQueue(), api.listWorkoutKinds()])
|
|
|
|
|
.then(([reviewItems, workoutKinds]) => {
|
|
|
|
|
setItems(reviewItems);
|
|
|
|
|
setItems([]);
|
|
|
|
|
setInitialLoading(true);
|
|
|
|
|
Promise.all([api.reviewQueue({ limit: PAGE_SIZE }), api.listWorkoutKinds()])
|
|
|
|
|
.then(([page, workoutKinds]) => {
|
|
|
|
|
nextCursorRef.current = page.next_cursor;
|
|
|
|
|
setItems(page.items);
|
|
|
|
|
setNextCursor(page.next_cursor);
|
|
|
|
|
setTotal(page.total);
|
|
|
|
|
setKinds(workoutKinds);
|
|
|
|
|
})
|
|
|
|
|
.catch((e) => setError(String(e)));
|
|
|
|
|
.catch((e) => setError(String(e)))
|
|
|
|
|
.finally(() => setInitialLoading(false));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useEffect(reload, []);
|
|
|
|
|
|
|
|
|
|
// Infinite scroll: load the next page once the sentinel at the bottom of
|
|
|
|
|
// the list becomes visible.
|
|
|
|
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const node = sentinelRef.current;
|
|
|
|
|
if (!node || filterKindId !== "") return;
|
|
|
|
|
const observer = new IntersectionObserver(
|
|
|
|
|
(entries) => {
|
|
|
|
|
if (entries[0]?.isIntersecting) loadMore();
|
|
|
|
|
},
|
|
|
|
|
{ rootMargin: "200px" },
|
|
|
|
|
);
|
|
|
|
|
observer.observe(node);
|
|
|
|
|
return () => observer.disconnect();
|
|
|
|
|
}, [loadMore, filterKindId, items.length]);
|
|
|
|
|
|
|
|
|
|
async function resolve(activityId: number, kindId: number) {
|
|
|
|
|
setResolvingId(activityId);
|
|
|
|
|
try {
|
|
|
|
|
await api.resolveReview(activityId, kindId);
|
|
|
|
|
setItems((prev) => prev.filter((i) => i.ActivityID !== activityId));
|
|
|
|
|
setTotal((prev) => Math.max(0, prev - 1));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setError(String(e));
|
|
|
|
|
} finally {
|
|
|
|
|
@@ -63,7 +142,9 @@ export function ReviewQueue() {
|
|
|
|
|
}, [items, filterKindId]);
|
|
|
|
|
|
|
|
|
|
function toggleFilter(id: string) {
|
|
|
|
|
setFilterKindId((prev) => (prev === id ? "" : id));
|
|
|
|
|
const next = filterKindId === id ? "" : id;
|
|
|
|
|
setFilterKindId(next);
|
|
|
|
|
if (next !== "") loadAll();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Race is assigned automatically from Garmin metadata (eventType.typeKey
|
|
|
|
|
@@ -75,14 +156,14 @@ export function ReviewQueue() {
|
|
|
|
|
<h2>Review Queue</h2>
|
|
|
|
|
{error && <p className="error">{error}</p>}
|
|
|
|
|
|
|
|
|
|
{items.length > 0 && (
|
|
|
|
|
{total > 0 && (
|
|
|
|
|
<div className="filter-pills">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`filter-pill${filterKindId === "" ? " active" : ""}`}
|
|
|
|
|
onClick={() => toggleFilter("")}
|
|
|
|
|
>
|
|
|
|
|
All ({items.length})
|
|
|
|
|
All ({total})
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
@@ -104,8 +185,12 @@ export function ReviewQueue() {
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{items.length === 0 ? (
|
|
|
|
|
{initialLoading ? (
|
|
|
|
|
<p className="empty-state">Loading…</p>
|
|
|
|
|
) : total === 0 ? (
|
|
|
|
|
<p className="empty-state">Nothing needs review right now.</p>
|
|
|
|
|
) : filterKindId !== "" && !allLoaded ? (
|
|
|
|
|
<p className="empty-state">Loading the rest of the queue to filter accurately…</p>
|
|
|
|
|
) : filteredItems.length === 0 ? (
|
|
|
|
|
<p className="empty-state">No runs match this filter.</p>
|
|
|
|
|
) : (
|
|
|
|
|
@@ -157,6 +242,12 @@ export function ReviewQueue() {
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{filterKindId === "" && !allLoaded && (
|
|
|
|
|
<div ref={sentinelRef} className="review-list-sentinel">
|
|
|
|
|
{loadingMore && <p className="empty-state">Loading more…</p>}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{rawDataItem && <RawDataModal data={rawDataItem} onClose={() => setRawDataItem(null)} />}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|