refactor(api): merge review-queue into /api/activities, rename resolve to assign
Removes the unused GET /api/activities list/detail endpoints, moves the review-queue list and resolve/unlock/unassign actions under /api/activities, renames resolve to assign end-to-end, and drops the now-unused store.ReviewQueue helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,9 @@
|
||||
import type {
|
||||
Activity,
|
||||
ActivityListItem,
|
||||
ActivitiesPage,
|
||||
AuthResponse,
|
||||
KindAssignment,
|
||||
Profile,
|
||||
ProgressionMetric,
|
||||
ProgressionPoint,
|
||||
ReviewQueuePage,
|
||||
SessionInfo,
|
||||
SyncRun,
|
||||
SyncStatus,
|
||||
@@ -37,7 +34,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
// is not something to show a user; every caller's catch-and-showError
|
||||
// already displays whatever this throws, so the friendly text only
|
||||
// needs to be written once, here.
|
||||
throw new NetworkError("Can't reach the server — check your connection.");
|
||||
throw new NetworkError("Can't reach the server.");
|
||||
}
|
||||
if (res.status === 401 && path !== "/api/session/me") {
|
||||
// An established session expired mid-use (not the initial "am I logged
|
||||
@@ -97,18 +94,6 @@ export const api = {
|
||||
syncRuns: () => request<SyncRun[]>("/api/sync/runs"),
|
||||
syncStatus: () => request<SyncStatus>("/api/sync/status"),
|
||||
|
||||
// Activities
|
||||
listActivities: (params?: { from?: string; to?: string; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.from) q.set("from", params.from);
|
||||
if (params?.to) q.set("to", params.to);
|
||||
if (params?.limit) q.set("limit", String(params.limit));
|
||||
const qs = q.toString();
|
||||
return request<ActivityListItem[]>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
getActivity: (id: number) =>
|
||||
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
|
||||
|
||||
// Training types -- fixed taxonomy, no create/delete
|
||||
listWorkoutKinds: (includeInactive = false) =>
|
||||
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
|
||||
@@ -141,34 +126,34 @@ export const api = {
|
||||
// navigation -- this does not touch the session cookie (see Profile.tsx).
|
||||
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
|
||||
|
||||
// Review queue -- cursor-paginated: pass the previous page's next_cursor
|
||||
// Activities -- cursor-paginated: pass the previous page's next_cursor
|
||||
// as `before` to fetch the next one. Fetching every activity's laps and
|
||||
// per-second samples up front gets expensive once there are many, so the
|
||||
// frontend loads it incrementally (infinite scroll) instead of all at once
|
||||
// -- kindId/unclassified filter server-side so a filtered view stays
|
||||
// paginated too, instead of having to load the whole matching backlog.
|
||||
reviewQueue: (params?: { limit?: number; before?: string; kindId?: number; unclassified?: boolean }) => {
|
||||
listActivities: (params?: { limit?: number; before?: string; kindId?: number; unclassified?: boolean }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.limit) q.set("limit", String(params.limit));
|
||||
if (params?.before) q.set("before", params.before);
|
||||
if (params?.kindId != null) q.set("kind_id", String(params.kindId));
|
||||
if (params?.unclassified) q.set("unclassified", "true");
|
||||
const qs = q.toString();
|
||||
return request<ReviewQueuePage>(`/api/review-queue/${qs ? `?${qs}` : ""}`);
|
||||
return request<ActivitiesPage>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
resolveReview: (activityId: number, workoutKindId: number) =>
|
||||
request<{ status: string }>(`/api/review-queue/${activityId}/resolve`, {
|
||||
assignActivity: (activityId: number, workoutKindId: number) =>
|
||||
request<{ status: string }>(`/api/activities/${activityId}/assign`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workout_kind_id: workoutKindId }),
|
||||
}),
|
||||
// Reverts a manual assignment back to rule_engine sourcing (same kind),
|
||||
// so a later reclassify pass is free to change it again.
|
||||
unlockReview: (activityId: number) =>
|
||||
request<{ status: string }>(`/api/review-queue/${activityId}/unlock`, { method: "POST" }),
|
||||
unlockActivity: (activityId: number) =>
|
||||
request<{ status: string }>(`/api/activities/${activityId}/unlock`, { method: "POST" }),
|
||||
// Manually clears an activity's kind back to Unclassified (locks that
|
||||
// decision, same as resolveReview locks a specific kind).
|
||||
unassignReview: (activityId: number) =>
|
||||
request<{ status: string }>(`/api/review-queue/${activityId}/unassign`, { method: "POST" }),
|
||||
// decision, same as assignActivity locks a specific kind).
|
||||
unassignActivity: (activityId: number) =>
|
||||
request<{ status: string }>(`/api/activities/${activityId}/unassign`, { method: "POST" }),
|
||||
|
||||
// Progression
|
||||
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {
|
||||
|
||||
@@ -4,12 +4,12 @@ import { showError } from "../banner";
|
||||
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
|
||||
import { formatMinutesSeconds } from "../components/PaceField";
|
||||
import { RawDataModal } from "../components/RawDataModal";
|
||||
import type { Profile, ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||
import type { Profile, ActivityItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||
|
||||
const UNCLASSIFIED = "__unclassified__";
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function candidates(item: ReviewQueueItem): ScoredKind[] {
|
||||
function candidates(item: ActivityItem): ScoredKind[] {
|
||||
try {
|
||||
return JSON.parse(item.CandidateKindsJSON) as ScoredKind[];
|
||||
} catch {
|
||||
@@ -47,8 +47,8 @@ function formatActivityDateTime(startTimeUTC: string): { date: string; time: str
|
||||
function kindColor(name: string | undefined): string {
|
||||
if (!name) return "#6b7280"; // grey -- unclassified
|
||||
if (name.startsWith("Easy") || name.startsWith("Long")) return "#3b82f6"; // blue
|
||||
if (name.includes("Threshold")) return "#22c55e"; // green
|
||||
if (name.startsWith("Tempo")) return "#eab308"; // yellow
|
||||
if (name.startsWith("Tempo")) return "#22c55e"; // green
|
||||
if (name.includes("Threshold")) return "#eab308"; // yellow
|
||||
if (name.startsWith("Interval")) return "#f97316"; // orange
|
||||
if (name.startsWith("MAS")) return "#ef4444"; // red
|
||||
if (name.startsWith("Race")) return "#a855f7"; // purple
|
||||
@@ -70,7 +70,7 @@ function ClassifyControl({
|
||||
onUnassign,
|
||||
onUnlock,
|
||||
}: {
|
||||
item: ReviewQueueItem;
|
||||
item: ActivityItem;
|
||||
kinds: WorkoutKind[];
|
||||
busy: boolean;
|
||||
onAssign: (activityId: number, kindId: number) => void;
|
||||
@@ -150,14 +150,14 @@ function filterParams(filter: string): { kindId?: number; unclassified?: boolean
|
||||
return {};
|
||||
}
|
||||
|
||||
function matchesFilter(item: ReviewQueueItem, filter: string): boolean {
|
||||
function matchesFilter(item: ActivityItem, filter: string): boolean {
|
||||
if (filter === "") return true;
|
||||
if (filter === UNCLASSIFIED) return item.WorkoutKindID == null;
|
||||
return item.WorkoutKindID === Number(filter);
|
||||
}
|
||||
|
||||
export function Activities() {
|
||||
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
||||
const [items, setItems] = useState<ActivityItem[]>([]);
|
||||
const [grandTotal, setGrandTotal] = useState(0); // unfiltered count, for the "All" pill
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
@@ -166,11 +166,11 @@ export function Activities() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [filterKindId, setFilterKindId] = useState<string>("");
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||
const [rawDataItem, setRawDataItem] = useState<ReviewQueueItem | null>(null);
|
||||
const [rawDataItem, setRawDataItem] = useState<ActivityItem | 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/.
|
||||
// scroll) instead of all at once -- see the paginated GET /api/activities/.
|
||||
const allLoaded = !initialLoading && nextCursor === null;
|
||||
// A single in-flight request is shared by every concurrent caller (the
|
||||
// scroll observer, a filter switch, ...): each gets the same promise back
|
||||
@@ -201,7 +201,7 @@ export function Activities() {
|
||||
setLoadingMore(true);
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const page = await api.reviewQueue({
|
||||
const page = await api.listActivities({
|
||||
limit: PAGE_SIZE,
|
||||
before: nextCursorRef.current ?? undefined,
|
||||
...filterParams(filterRef.current),
|
||||
@@ -231,7 +231,7 @@ export function Activities() {
|
||||
setItems([]);
|
||||
setInitialLoading(true);
|
||||
api
|
||||
.reviewQueue({ limit: PAGE_SIZE, ...filterParams(filter) })
|
||||
.listActivities({ limit: PAGE_SIZE, ...filterParams(filter) })
|
||||
.then((page) => {
|
||||
if (gen !== generationRef.current) return;
|
||||
nextCursorRef.current = page.next_cursor;
|
||||
@@ -271,10 +271,10 @@ export function Activities() {
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, items.length]);
|
||||
|
||||
async function resolve(activityId: number, kindId: number) {
|
||||
async function assign(activityId: number, kindId: number) {
|
||||
setResolvingId(activityId);
|
||||
try {
|
||||
await api.resolveReview(activityId, kindId);
|
||||
await api.assignActivity(activityId, kindId);
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((i) =>
|
||||
@@ -298,7 +298,7 @@ export function Activities() {
|
||||
async function unassign(activityId: number) {
|
||||
setResolvingId(activityId);
|
||||
try {
|
||||
await api.unassignReview(activityId);
|
||||
await api.unassignActivity(activityId);
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((i) =>
|
||||
@@ -306,7 +306,7 @@ export function Activities() {
|
||||
? { ...i, WorkoutKindID: null, AssignmentSource: "manual" as const, Status: "needs_review" as const }
|
||||
: i,
|
||||
)
|
||||
// Same as resolve() -- an active kind filter no longer matches an
|
||||
// Same as assign() -- an active kind filter no longer matches an
|
||||
// activity just cleared back to Unclassified, so drop it.
|
||||
.filter((i) => matchesFilter(i, filterKindId)),
|
||||
);
|
||||
@@ -320,7 +320,7 @@ export function Activities() {
|
||||
async function unlock(activityId: number) {
|
||||
setResolvingId(activityId);
|
||||
try {
|
||||
await api.unlockReview(activityId);
|
||||
await api.unlockActivity(activityId);
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((i) => (i.ActivityID === activityId ? { ...i, AssignmentSource: "rule_engine" as const } : i))
|
||||
@@ -391,7 +391,7 @@ export function Activities() {
|
||||
item={item}
|
||||
kinds={kinds}
|
||||
busy={resolvingId === item.ActivityID}
|
||||
onAssign={resolve}
|
||||
onAssign={assign}
|
||||
onUnassign={unassign}
|
||||
onUnlock={unlock}
|
||||
/>
|
||||
|
||||
@@ -67,14 +67,6 @@ export interface Sample {
|
||||
ElevationM: number | null;
|
||||
}
|
||||
|
||||
export interface ActivityListItem extends Activity {
|
||||
workout_kind_id: number | null;
|
||||
workout_kind_name: string | null;
|
||||
assignment_source: "rule_engine" | "manual" | null;
|
||||
assignment_status: "assigned" | "needs_review" | null;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface WorkoutKind {
|
||||
ID: number;
|
||||
Name: string;
|
||||
@@ -153,14 +145,14 @@ export interface KindAssignment {
|
||||
CreatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReviewQueueItem extends KindAssignment {
|
||||
export interface ActivityItem extends KindAssignment {
|
||||
activity: Activity;
|
||||
laps: Lap[];
|
||||
samples: Sample[];
|
||||
}
|
||||
|
||||
export interface ReviewQueuePage {
|
||||
items: ReviewQueueItem[];
|
||||
export interface ActivitiesPage {
|
||||
items: ActivityItem[];
|
||||
next_cursor: string | null;
|
||||
total: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user