Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions PanTS-Demo/src/helpers/recentUploads.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
62 changes: 62 additions & 0 deletions PanTS-Demo/src/helpers/recentUploads.ts
Original file line number Diff line number Diff line change
@@ -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";
46 changes: 46 additions & 0 deletions PanTS-Demo/src/helpers/search.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
45 changes: 45 additions & 0 deletions PanTS-Demo/src/helpers/search.ts
Original file line number Diff line number Diff line change
@@ -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;
};
39 changes: 8 additions & 31 deletions PanTS-Demo/src/routes/Homepage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 }[] = [
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
65 changes: 8 additions & 57 deletions PanTS-Demo/src/routes/UploadPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@
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<any> => {

Check failure on line 15 in PanTS-Demo/src/routes/UploadPage.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 22)

Unexpected any. Specify a different type

Check failure on line 15 in PanTS-Demo/src/routes/UploadPage.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 20)

Unexpected any. Specify a different type
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return res.json();
Expand All @@ -16,63 +24,6 @@
);
};

/* ── 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<HTMLInputElement | null>(null);
Expand Down Expand Up @@ -121,7 +72,7 @@
return;
}
setSelectedFiles(prev => [...prev, ...filteredFiles]);
}, []);

Check warning on line 75 in PanTS-Demo/src/routes/UploadPage.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 22)

React Hook useCallback has a missing dependency: 'allowedExtensions'. Either include it or remove the dependency array

Check warning on line 75 in PanTS-Demo/src/routes/UploadPage.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 20)

React Hook useCallback has a missing dependency: 'allowedExtensions'. Either include it or remove the dependency array

const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
Expand Down
Loading