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
1 change: 1 addition & 0 deletions frontend/src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
120 changes: 120 additions & 0 deletions frontend/src/components/common/job-picker.tsx
Original file line number Diff line number Diff line change
@@ -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<JobType, 'predict' | 'backtest'>
/** 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 (
<div className="space-y-3">
<Select
value={dropdownValue}
onValueChange={onSelect}
disabled={isLoading || jobs.length === 0}
>
<SelectTrigger className="max-w-md">
<SelectValue
placeholder={
isLoading
? 'Loading jobs…'
: jobs.length === 0
? `No completed ${jobType} jobs yet`
: 'Pick a job…'
}
/>
</SelectTrigger>
<SelectContent>
{jobs.map((job) => (
<SelectItem key={job.job_id} value={job.job_id}>
{jobLabel(job)}
</SelectItem>
))}
</SelectContent>
</Select>

<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
or paste an ID:
</span>
<Input
placeholder="Enter job ID (e.g., abc12345...)"
value={manualId}
onChange={(e) => setManualId(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleManualLoad()
}}
className="max-w-xs"
/>
<Button onClick={handleManualLoad} disabled={!manualId.trim()}>
<Search className="h-4 w-4 mr-2" />
Load
</Button>
</div>
</div>
)
}
43 changes: 11 additions & 32 deletions frontend/src/pages/visualize/backtest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -31,53 +30,33 @@ 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

return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Backtest Results</h1>

{/* Search by Job ID */}
{/* Job picker */}
<Card>
<CardHeader>
<CardTitle>Load Backtest</CardTitle>
<CardDescription>
Enter a completed backtest job ID to visualize the results
Pick a completed backtest job to visualize the results
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Enter job ID (e.g., abc12345...)"
value={jobId}
onChange={(e) => setJobId(e.target.value)}
onKeyDown={handleKeyDown}
className="max-w-md"
/>
<Button onClick={handleSearch} disabled={!jobId.trim()}>
<Search className="h-4 w-4 mr-2" />
Load
</Button>
</div>
<JobPicker
jobType="backtest"
selectedJobId={searchJobId}
onSelect={setSearchJobId}
autoSelectLatest
/>
</CardContent>
</Card>

Expand Down Expand Up @@ -214,7 +193,7 @@ export default function BacktestPage() {
{!searchJobId && !isLoading && (
<EmptyState
title="No backtest loaded"
description="Enter a backtest job ID above to visualize the cross-validation results."
description="Pick a backtest job above to visualize the cross-validation results."
icon={<LineChart className="h-12 w-12" />}
/>
)}
Expand Down
43 changes: 11 additions & 32 deletions frontend/src/pages/visualize/forecast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -37,28 +23,21 @@ export default function ForecastPage() {
<div className="space-y-6">
<h1 className="text-3xl font-bold">Forecast Visualization</h1>

{/* Search by Job ID */}
{/* Job picker */}
<Card>
<CardHeader>
<CardTitle>Load Forecast</CardTitle>
<CardDescription>
Enter a completed prediction job ID to visualize the forecast
Pick a completed prediction job to visualize the forecast
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Enter job ID (e.g., abc12345...)"
value={jobId}
onChange={(e) => setJobId(e.target.value)}
onKeyDown={handleKeyDown}
className="max-w-md"
/>
<Button onClick={handleSearch} disabled={!jobId.trim()}>
<Search className="h-4 w-4 mr-2" />
Load
</Button>
</div>
<JobPicker
jobType="predict"
selectedJobId={searchJobId}
onSelect={setSearchJobId}
autoSelectLatest
/>
</CardContent>
</Card>

Expand Down Expand Up @@ -139,7 +118,7 @@ export default function ForecastPage() {
{!searchJobId && !isLoading && (
<EmptyState
title="No forecast loaded"
description="Enter a prediction job ID above to visualize the forecast results."
description="Pick a prediction job above to visualize the forecast results."
icon={<BarChart3 className="h-12 w-12" />}
/>
)}
Expand Down