From ffe5ee67864f12f365c0dca298cf48423dbb65a4 Mon Sep 17 00:00:00 2001 From: Gabor Szabo Date: Mon, 18 May 2026 11:45:20 +0200 Subject: [PATCH] feat(ui): job picker dropdown on forecast and backtest pages (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The visualization pages only accepted a job ID typed into a text box, so users had to already know the ID. Add a JobPicker component: a dropdown of completed jobs of the relevant type (predict / backtest), newest first, with each option labelled by short id, model and timestamp. - New shared component src/components/common/job-picker.tsx, used by both forecast.tsx and backtest.tsx. - The manual job-ID input stays alongside the dropdown for pasting an ID. - The most recent completed job auto-loads on mount so a chart shows immediately without interaction. No backend change — GET /jobs?job_type=&status=completed already exists. Verified in a browser on both pages. --- frontend/src/components/common/index.ts | 1 + frontend/src/components/common/job-picker.tsx | 120 ++++++++++++++++++ frontend/src/pages/visualize/backtest.tsx | 43 ++----- frontend/src/pages/visualize/forecast.tsx | 43 ++----- 4 files changed, 143 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/common/job-picker.tsx diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 726e12a3..c74b7e8a 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -2,3 +2,4 @@ export * from './status-badge' export * from './date-range-picker' export * from './error-display' export * from './loading-state' +export * from './job-picker' diff --git a/frontend/src/components/common/job-picker.tsx b/frontend/src/components/common/job-picker.tsx new file mode 100644 index 00000000..ed677c87 --- /dev/null +++ b/frontend/src/components/common/job-picker.tsx @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useState } from 'react' +import { format } from 'date-fns' +import { Search } from 'lucide-react' +import { useJobs } from '@/hooks/use-jobs' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import type { Job, JobType } from '@/types/api' + +interface JobPickerProps { + /** Job type to list — 'predict' for forecasts, 'backtest' for backtests. */ + jobType: Extract + /** Currently loaded job ID (empty string when nothing is loaded). */ + selectedJobId: string + /** Called with a job ID when the user picks one or enters one manually. */ + onSelect: (jobId: string) => void + /** Auto-select the most recent completed job once the list first loads. */ + autoSelectLatest?: boolean +} + +/** Compact label for a job option: short id, model (when known), and timestamp. */ +function jobLabel(job: Job): string { + const shortId = job.job_id.slice(0, 8) + const when = format(new Date(job.created_at), 'MMM d, HH:mm') + const model = typeof job.params.model_type === 'string' ? job.params.model_type : null + return model ? `${shortId} · ${model} · ${when}` : `${shortId} · ${when}` +} + +/** + * Job selector for the visualization pages: a dropdown of completed jobs of a + * given type, plus a manual job-ID entry box for pasting an ID from elsewhere. + */ +export function JobPicker({ + jobType, + selectedJobId, + onSelect, + autoSelectLatest = false, +}: JobPickerProps) { + const [manualId, setManualId] = useState('') + + const { data, isLoading } = useJobs({ + page: 1, + pageSize: 50, + jobType, + status: 'completed', + }) + // Memoised so the auto-select effect below has a stable dependency. + const jobs = useMemo(() => data?.jobs ?? [], [data]) + + // Auto-select the most recent completed job once, when the list first + // arrives and nothing has been selected yet (jobs come newest-first). + useEffect(() => { + if (autoSelectLatest && !selectedJobId && jobs.length > 0) { + onSelect(jobs[0].job_id) + } + }, [autoSelectLatest, selectedJobId, jobs, onSelect]) + + const handleManualLoad = () => { + const trimmed = manualId.trim() + if (trimmed) onSelect(trimmed) + } + + // Only bind the dropdown to selectedJobId when it refers to a listed job, so + // a manually-pasted (and possibly unlisted) ID doesn't break the trigger. + const dropdownValue = jobs.some((j) => j.job_id === selectedJobId) ? selectedJobId : '' + + return ( +
+ + +
+ + or paste an ID: + + setManualId(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleManualLoad() + }} + className="max-w-xs" + /> + +
+
+ ) +} diff --git a/frontend/src/pages/visualize/backtest.tsx b/frontend/src/pages/visualize/backtest.tsx index c82d3174..9a8b36d4 100644 --- a/frontend/src/pages/visualize/backtest.tsx +++ b/frontend/src/pages/visualize/backtest.tsx @@ -2,12 +2,11 @@ import { useState } from 'react' import { useJob } from '@/hooks/use-jobs' import { BacktestFoldsChart, MetricsSummary } from '@/components/charts/backtest-folds-chart' import { EmptyState } from '@/components/common/error-display' +import { JobPicker } from '@/components/common/job-picker' import { LoadingState } from '@/components/common/loading-state' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Search, LineChart } from 'lucide-react' +import { LineChart } from 'lucide-react' interface BacktestResult { aggregated_metrics: { @@ -31,24 +30,11 @@ interface BacktestResult { } export default function BacktestPage() { - const [jobId, setJobId] = useState('') const [searchJobId, setSearchJobId] = useState('') const [selectedMetric, setSelectedMetric] = useState<'mae' | 'smape' | 'wape' | 'bias'>('mae') const { data: job, isLoading, error } = useJob(searchJobId, !!searchJobId) - const handleSearch = () => { - if (jobId.trim()) { - setSearchJobId(jobId.trim()) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch() - } - } - // Extract backtest result from job const backtestResult = job?.result as BacktestResult | undefined @@ -56,28 +42,21 @@ export default function BacktestPage() {

Backtest Results

- {/* Search by Job ID */} + {/* Job picker */} Load Backtest - Enter a completed backtest job ID to visualize the results + Pick a completed backtest job to visualize the results -
- setJobId(e.target.value)} - onKeyDown={handleKeyDown} - className="max-w-md" - /> - -
+
@@ -214,7 +193,7 @@ export default function BacktestPage() { {!searchJobId && !isLoading && ( } /> )} diff --git a/frontend/src/pages/visualize/forecast.tsx b/frontend/src/pages/visualize/forecast.tsx index 490da276..c2081fe2 100644 --- a/frontend/src/pages/visualize/forecast.tsx +++ b/frontend/src/pages/visualize/forecast.tsx @@ -2,30 +2,16 @@ import { useState } from 'react' import { useJob } from '@/hooks/use-jobs' import { TimeSeriesChart } from '@/components/charts/time-series-chart' import { EmptyState } from '@/components/common/error-display' +import { JobPicker } from '@/components/common/job-picker' import { LoadingState } from '@/components/common/loading-state' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { Search, BarChart3 } from 'lucide-react' +import { BarChart3 } from 'lucide-react' export default function ForecastPage() { - const [jobId, setJobId] = useState('') const [searchJobId, setSearchJobId] = useState('') const { data: job, isLoading, error } = useJob(searchJobId, !!searchJobId) - const handleSearch = () => { - if (jobId.trim()) { - setSearchJobId(jobId.trim()) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch() - } - } - // Extract forecast data from job result. // A completed `predict` job stores result.forecasts (each point: date + forecast). const forecastData = job?.result?.forecasts as Array<{ @@ -37,28 +23,21 @@ export default function ForecastPage() {

Forecast Visualization

- {/* Search by Job ID */} + {/* Job picker */} Load Forecast - Enter a completed prediction job ID to visualize the forecast + Pick a completed prediction job to visualize the forecast -
- setJobId(e.target.value)} - onKeyDown={handleKeyDown} - className="max-w-md" - /> - -
+
@@ -139,7 +118,7 @@ export default function ForecastPage() { {!searchJobId && !isLoading && ( } /> )}