diff --git a/PanTS-Demo/src/helpers/recentUploads.test.ts b/PanTS-Demo/src/helpers/recentUploads.test.ts new file mode 100644 index 00000000..01a039a9 --- /dev/null +++ b/PanTS-Demo/src/helpers/recentUploads.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + addRecentUpload, + formatRelativeTime, + loadRecentUploads, + MAX_RECENT_UPLOADS, + recentStatusColor, + updateRecentUploadStatus, + type RecentUpload, +} from "./recentUploads"; + +const makeEntry = (overrides: Partial = {}): RecentUpload => ({ + sessionId: "s1", + label: "ct.nii.gz", + model: "SuPreM", + status: "Processing", + timestamp: Date.now(), + ...overrides, +}); + +beforeEach(() => { + localStorage.clear(); +}); + +describe("loadRecentUploads", () => { + it("returns an empty array when nothing is stored", () => { + expect(loadRecentUploads()).toEqual([]); + }); + + it("returns an empty array when storage holds malformed JSON", () => { + localStorage.setItem("recentUploads", "{not json"); + expect(loadRecentUploads()).toEqual([]); + }); +}); + +describe("addRecentUpload", () => { + it("prepends the newest entry", () => { + addRecentUpload(makeEntry({ sessionId: "a" })); + const list = addRecentUpload(makeEntry({ sessionId: "b" })); + expect(list.map((u) => u.sessionId)).toEqual(["b", "a"]); + }); + + it("de-duplicates by sessionId (re-adding moves it to the front)", () => { + addRecentUpload(makeEntry({ sessionId: "a" })); + addRecentUpload(makeEntry({ sessionId: "b" })); + const list = addRecentUpload(makeEntry({ sessionId: "a", label: "updated" })); + expect(list.map((u) => u.sessionId)).toEqual(["a", "b"]); + expect(list.filter((u) => u.sessionId === "a")).toHaveLength(1); + expect(list[0].label).toBe("updated"); + }); + + it(`caps the list at ${MAX_RECENT_UPLOADS} entries`, () => { + for (let i = 0; i < MAX_RECENT_UPLOADS + 4; i++) { + addRecentUpload(makeEntry({ sessionId: `s${i}` })); + } + expect(loadRecentUploads()).toHaveLength(MAX_RECENT_UPLOADS); + }); +}); + +describe("updateRecentUploadStatus", () => { + it("updates only the matching session's status", () => { + addRecentUpload(makeEntry({ sessionId: "a" })); + addRecentUpload(makeEntry({ sessionId: "b" })); + const list = updateRecentUploadStatus("a", "Completed"); + expect(list.find((u) => u.sessionId === "a")?.status).toBe("Completed"); + expect(list.find((u) => u.sessionId === "b")?.status).toBe("Processing"); + }); +}); + +describe("formatRelativeTime", () => { + it("formats recent, minutes, hours, and days", () => { + const now = Date.now(); + expect(formatRelativeTime(now)).toBe("Just now"); + expect(formatRelativeTime(now - 5 * 60_000)).toBe("5 mins ago"); + expect(formatRelativeTime(now - 1 * 60_000)).toBe("1 min ago"); + expect(formatRelativeTime(now - 3 * 60 * 60_000)).toBe("3 hours ago"); + expect(formatRelativeTime(now - 25 * 60 * 60_000)).toBe("Yesterday"); + expect(formatRelativeTime(now - 4 * 24 * 60 * 60_000)).toBe("4 days ago"); + }); +}); + +describe("recentStatusColor", () => { + it("maps each status to its color", () => { + expect(recentStatusColor("Failed")).toBe("#ef4444"); + expect(recentStatusColor("Processing")).toBe("#6a6a6a"); + expect(recentStatusColor("Completed")).toBe("#8f8f8f"); + }); +}); diff --git a/PanTS-Demo/src/helpers/recentUploads.ts b/PanTS-Demo/src/helpers/recentUploads.ts new file mode 100644 index 00000000..37e4bb34 --- /dev/null +++ b/PanTS-Demo/src/helpers/recentUploads.ts @@ -0,0 +1,62 @@ +// Recent uploads persisted in the user's localStorage (mirrors JHU's recentIds +// pattern). Extracted from UploadPage so the logic can be unit-tested. + +export type RecentUploadStatus = "Processing" | "Completed" | "Failed"; + +export type RecentUpload = { + sessionId: string; + label: string; + model: string; + status: RecentUploadStatus; + timestamp: number; + isReconstruction?: boolean; +}; + +export const RECENT_UPLOADS_KEY = "recentUploads"; +export const MAX_RECENT_UPLOADS = 8; + +export const loadRecentUploads = (): RecentUpload[] => { + try { + const arr = JSON.parse(localStorage.getItem(RECENT_UPLOADS_KEY) || "[]"); + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } +}; + +export const persistRecentUploads = (list: RecentUpload[]) => { + try { + localStorage.setItem(RECENT_UPLOADS_KEY, JSON.stringify(list.slice(0, MAX_RECENT_UPLOADS))); + } catch (e) { + console.warn("saveRecentUploads failed", e); + } +}; + +export const addRecentUpload = (entry: RecentUpload): RecentUpload[] => { + const list = [entry, ...loadRecentUploads().filter((u) => u.sessionId !== entry.sessionId)]; + const trimmed = list.slice(0, MAX_RECENT_UPLOADS); + persistRecentUploads(trimmed); + return trimmed; +}; + +export const updateRecentUploadStatus = ( + sessionId: string, + status: RecentUploadStatus +): RecentUpload[] => { + const list = loadRecentUploads().map((u) => (u.sessionId === sessionId ? { ...u, status } : u)); + persistRecentUploads(list); + return list; +}; + +export const formatRelativeTime = (ts: number): string => { + const mins = Math.floor((Date.now() - ts) / 60000); + if (mins < 1) return "Just now"; + if (mins < 60) return `${mins} min${mins === 1 ? "" : "s"} ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.floor(hours / 24); + return days === 1 ? "Yesterday" : `${days} days ago`; +}; + +export const recentStatusColor = (status: RecentUploadStatus): string => + status === "Failed" ? "#ef4444" : status === "Processing" ? "#6a6a6a" : "#8f8f8f"; diff --git a/PanTS-Demo/src/helpers/search.test.ts b/PanTS-Demo/src/helpers/search.test.ts new file mode 100644 index 00000000..0630ce12 --- /dev/null +++ b/PanTS-Demo/src/helpers/search.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { buildSearchParams, itemToId, type SearchFilters } from "./search"; + +describe("itemToId", () => { + it("parses the numeric id out of a PanTS case id", () => { + expect(itemToId({ case_id: "PanTS_00008854" })).toBe(8854); + expect(itemToId({ case_id: "PanTS_00000001" })).toBe(1); + }); + + it("falls back across id fields and handles numbers", () => { + expect(itemToId({ "PanTS ID": "PanTS_00000900" })).toBe(900); + expect(itemToId({ id: 42 })).toBe(42); + }); + + it("returns 0 when no usable id is present", () => { + expect(itemToId({})).toBe(0); + expect(itemToId({ case_id: "no-digits-here" })).toBe(0); + }); +}); + +describe("buildSearchParams", () => { + const base: SearchFilters = { tumor: "any", sex: [], age: [] }; + + it("omits the tumor param for 'any' and maps tumor/no_tumor to 1/0", () => { + expect(buildSearchParams(base).has("tumor")).toBe(false); + expect(buildSearchParams({ ...base, tumor: "tumor" }).get("tumor")).toBe("1"); + expect(buildSearchParams({ ...base, tumor: "no_tumor" }).get("tumor")).toBe("0"); + }); + + it("appends sex[] and age_bin[] for each selected value", () => { + const params = buildSearchParams({ + tumor: "any", + sex: ["M", "F"], + age: ["0-9", "90-99"], + }); + expect(params.getAll("sex[]")).toEqual(["M", "F"]); + expect(params.getAll("age_bin[]")).toEqual(["0-9", "90-99"]); + }); + + it("adds sort_by and per_page only when provided", () => { + expect(buildSearchParams(base).has("sort_by")).toBe(false); + const params = buildSearchParams(base, { sortBy: "quality", perPage: 12 }); + expect(params.get("sort_by")).toBe("quality"); + expect(params.get("per_page")).toBe("12"); + }); +}); diff --git a/PanTS-Demo/src/helpers/search.ts b/PanTS-Demo/src/helpers/search.ts new file mode 100644 index 00000000..9ab1578c --- /dev/null +++ b/PanTS-Demo/src/helpers/search.ts @@ -0,0 +1,45 @@ +// Helpers for the dashboard's advanced search against the backend /api/search. +// Extracted from Homepage so the query-building and id-parsing can be unit-tested. + +export type TumorFilter = "any" | "tumor" | "no_tumor"; + +export type SearchFilters = { + tumor: TumorFilter; + sex: string[]; // M / F / UNKNOWN + age: string[]; // "0-9" … "90-99" / "UNKNOWN" +}; + +// Minimal shape of an item returned by /api/search and /api/random. +export type SearchItem = { + case_id?: string | number; + "PanTS ID"?: string | number; + id?: string | number; + tumor?: number | null; + sex?: string | null; + age?: number | string | null; +}; + +// Parse the numeric case id out of any of the id-ish fields, e.g. +// "PanTS_00008854" -> 8854. Returns 0 when nothing usable is present. +export const itemToId = (it: SearchItem): number => { + const raw = String(it.case_id ?? it["PanTS ID"] ?? it.id ?? ""); + const m = raw.match(/\d+/); + return m ? Number(m[0]) : 0; +}; + +// Build the /api/search query string from the active filters. Mirrors the +// backend's expected params: sex[]/age_bin[] (multi), tumor (1/0, omitted for +// "any"), plus optional sort_by / per_page. +export const buildSearchParams = ( + filters: SearchFilters, + opts: { sortBy?: string; perPage?: number } = {} +): URLSearchParams => { + const params = new URLSearchParams(); + filters.sex.forEach((v) => params.append("sex[]", v)); + if (filters.tumor === "tumor") params.set("tumor", "1"); + else if (filters.tumor === "no_tumor") params.set("tumor", "0"); + filters.age.forEach((v) => params.append("age_bin[]", v)); + if (opts.sortBy) params.set("sort_by", opts.sortBy); + if (opts.perPage) params.set("per_page", String(opts.perPage)); + return params; +}; diff --git a/PanTS-Demo/src/routes/Homepage.tsx b/PanTS-Demo/src/routes/Homepage.tsx index 1acfe189..8c38c7f7 100644 --- a/PanTS-Demo/src/routes/Homepage.tsx +++ b/PanTS-Demo/src/routes/Homepage.tsx @@ -13,6 +13,13 @@ import { useNavigate } from "react-router-dom"; import Header from "../components/Header"; import Preview from "../components/Preview"; import { API_BASE } from "../helpers/constants"; +import { + buildSearchParams, + itemToId, + type SearchFilters as Filters, + type SearchItem, + type TumorFilter, +} from "../helpers/search"; import type { PreviewType } from "../types"; // Number of cards shown on the dashboard (and skeleton placeholders). @@ -25,14 +32,6 @@ const STATS = [ { label: "Organ Classes", value: "25", icon: IconAtom }, ]; -type TumorFilter = "any" | "tumor" | "no_tumor"; - -type Filters = { - tumor: TumorFilter; - sex: string[]; - age: string[]; -}; - const DEFAULT_FILTERS: Filters = { tumor: "any", sex: [], age: [] }; const TUMOR_OPTIONS: { value: TumorFilter; label: string }[] = [ @@ -62,22 +61,6 @@ const AGE_OPTIONS = [ { value: "UNKNOWN", label: "Unknown" }, ]; -// Minimal shape of an item returned by /api/search and /api/random. -type SearchItem = { - case_id?: string | number; - "PanTS ID"?: string | number; - id?: string | number; - tumor?: number | null; - sex?: string | null; - age?: number | string | null; -}; - -const itemToId = (it: SearchItem): number => { - const raw = String(it.case_id ?? it["PanTS ID"] ?? it.id ?? ""); - const m = raw.match(/\d+/); - return m ? Number(m[0]) : 0; -}; - const pillStyle = (active: boolean): React.CSSProperties => ({ padding: "7px 16px", borderRadius: "8px", @@ -210,13 +193,7 @@ export default function Homepage() { setLoading(true); setPreviewMetadata({}); try { - const params = new URLSearchParams(); - filters.sex.forEach((v) => params.append("sex[]", v)); - if (filters.tumor === "tumor") params.set("tumor", "1"); - else if (filters.tumor === "no_tumor") params.set("tumor", "0"); - filters.age.forEach((v) => params.append("age_bin[]", v)); - params.set("sort_by", "quality"); - params.set("per_page", "12"); + const params = buildSearchParams(filters, { sortBy: "quality", perPage: 12 }); const res = await fetch(`${API_BASE}/api/search?${params.toString()}`); const data = await res.json(); setResultCount(data.total ?? 0); diff --git a/PanTS-Demo/src/routes/UploadPage.tsx b/PanTS-Demo/src/routes/UploadPage.tsx index 873d217b..53c7ce1d 100644 --- a/PanTS-Demo/src/routes/UploadPage.tsx +++ b/PanTS-Demo/src/routes/UploadPage.tsx @@ -2,6 +2,14 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import './UploadPage.css'; import { API_BASE } from '../helpers/constants'; +import { + addRecentUpload, + formatRelativeTime, + loadRecentUploads, + recentStatusColor, + updateRecentUploadStatus, + type RecentUpload, +} from '../helpers/recentUploads'; import Header from '../components/Header'; const parseApiResponse = async (res: Response): Promise => { @@ -16,63 +24,6 @@ const parseApiResponse = async (res: Response): Promise => { ); }; -/* ── Recent uploads (persisted in the user's localStorage, like JHU's recentIds) ── */ -type RecentUploadStatus = "Processing" | "Completed" | "Failed"; -type RecentUpload = { - sessionId: string; - label: string; - model: string; - status: RecentUploadStatus; - timestamp: number; - isReconstruction?: boolean; -}; - -const RECENT_UPLOADS_KEY = "recentUploads"; -const MAX_RECENT_UPLOADS = 8; - -const loadRecentUploads = (): RecentUpload[] => { - try { - const arr = JSON.parse(localStorage.getItem(RECENT_UPLOADS_KEY) || "[]"); - return Array.isArray(arr) ? arr : []; - } catch { - return []; - } -}; - -const persistRecentUploads = (list: RecentUpload[]) => { - try { - localStorage.setItem(RECENT_UPLOADS_KEY, JSON.stringify(list.slice(0, MAX_RECENT_UPLOADS))); - } catch (e) { - console.warn("saveRecentUploads failed", e); - } -}; - -const addRecentUpload = (entry: RecentUpload): RecentUpload[] => { - const list = [entry, ...loadRecentUploads().filter((u) => u.sessionId !== entry.sessionId)]; - const trimmed = list.slice(0, MAX_RECENT_UPLOADS); - persistRecentUploads(trimmed); - return trimmed; -}; - -const updateRecentUploadStatus = (sessionId: string, status: RecentUploadStatus): RecentUpload[] => { - const list = loadRecentUploads().map((u) => (u.sessionId === sessionId ? { ...u, status } : u)); - persistRecentUploads(list); - return list; -}; - -const formatRelativeTime = (ts: number): string => { - const mins = Math.floor((Date.now() - ts) / 60000); - if (mins < 1) return "Just now"; - if (mins < 60) return `${mins} min${mins === 1 ? "" : "s"} ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; - const days = Math.floor(hours / 24); - return days === 1 ? "Yesterday" : `${days} days ago`; -}; - -const recentStatusColor = (status: RecentUploadStatus): string => - status === "Failed" ? "#ef4444" : status === "Processing" ? "#6a6a6a" : "#8f8f8f"; - const UploadPage: React.FC = () => { const navigate = useNavigate(); const fileInputRef = useRef(null);