feat: group scheduled task results (bosmain-obt)

This commit is contained in:
jade
2026-05-08 18:25:41 -07:00
committed by Nikhil Sonti
parent b89ea201fa
commit d405f59aeb
3 changed files with 329 additions and 79 deletions

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from 'bun:test'
import type {
ScheduledJob,
ScheduledJobRun,
} from '@/lib/schedules/scheduleTypes'
import { groupScheduledTaskRuns } from './scheduledTaskResultsUtils'
function makeJob(input: Pick<ScheduledJob, 'id' | 'name'>): ScheduledJob {
return {
...input,
query: `Query for ${input.name}`,
scheduleType: 'daily',
scheduleTime: '09:00',
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
}
function makeRun(input: {
id: string
jobId: string
startedAt: string
status?: ScheduledJobRun['status']
}): ScheduledJobRun {
return {
id: input.id,
jobId: input.jobId,
startedAt: input.startedAt,
status: input.status ?? 'completed',
result: `Result for ${input.id}`,
}
}
describe('groupScheduledTaskRuns', () => {
it('groups runs by scheduled task and sorts groups by latest run', () => {
const groups = groupScheduledTaskRuns({
jobs: [makeJob({ id: 'news', name: 'Morning News' })],
runs: [
makeRun({
id: 'news-old',
jobId: 'news',
startedAt: '2026-01-02T09:00:00.000Z',
}),
makeRun({
id: 'prices-new',
jobId: 'prices',
startedAt: '2026-01-03T14:00:00.000Z',
}),
makeRun({
id: 'news-new',
jobId: 'news',
startedAt: '2026-01-04T09:00:00.000Z',
}),
],
})
expect(groups.map((group) => group.id)).toEqual(['news', 'prices'])
expect(groups[0]).toMatchObject({
id: 'news',
name: 'Morning News',
resultCount: 2,
latestRun: { id: 'news-new' },
})
expect(groups[0]?.runs.map((run) => run.id)).toEqual([
'news-new',
'news-old',
])
})
it('keeps missing jobs visible under an unknown task label', () => {
const groups = groupScheduledTaskRuns({
jobs: [],
runs: [
makeRun({
id: 'orphan-run',
jobId: 'deleted-job',
startedAt: '2026-01-02T09:00:00.000Z',
}),
],
})
expect(groups).toHaveLength(1)
expect(groups[0]).toMatchObject({
id: 'deleted-job',
name: 'Unknown scheduled task',
resultCount: 1,
latestRun: { id: 'orphan-run' },
})
})
it('keeps running runs first without changing the latest-run header data', () => {
const groups = groupScheduledTaskRuns({
jobs: [makeJob({ id: 'news', name: 'Morning News' })],
runs: [
makeRun({
id: 'completed-new',
jobId: 'news',
startedAt: '2026-01-04T09:00:00.000Z',
status: 'completed',
}),
makeRun({
id: 'running-old',
jobId: 'news',
startedAt: '2026-01-03T09:00:00.000Z',
status: 'running',
}),
],
})
expect(groups[0]?.latestRun.id).toBe('completed-new')
expect(groups[0]?.runs.map((run) => run.id)).toEqual([
'running-old',
'completed-new',
])
})
})

View File

@@ -3,6 +3,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import {
Calendar,
CheckCircle2,
ChevronDown,
Clock,
Loader2,
RotateCcw,
@@ -10,23 +11,25 @@ import {
XCircle,
} from 'lucide-react'
import type { FC } from 'react'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
useScheduledJobRuns,
useScheduledJobs,
} from '@/lib/schedules/scheduleStorage'
import type {
ScheduledJob,
ScheduledJobRun,
} from '@/lib/schedules/scheduleTypes'
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
import {
groupScheduledTaskRuns,
type JobRunWithDetails,
} from './scheduledTaskResultsUtils'
dayjs.extend(relativeTime)
interface JobRunWithDetails extends ScheduledJobRun {
job: ScheduledJob | undefined
}
interface ScheduledTaskResultsProps {
onViewRun: (run: ScheduledJobRun) => void
onCancelRun: (runId: string) => void
@@ -46,6 +49,22 @@ const getStatusIcon = (status: JobRunWithDetails['status']) => {
const formatTimestamp = (dateString: string) => dayjs(dateString).fromNow()
const formatRunTimestamp = (dateString: string) => {
const date = dayjs(dateString)
if (date.isSame(dayjs(), 'day')) {
return `Today, ${date.format('h:mm A')}`
}
if (date.isSame(dayjs().subtract(1, 'day'), 'day')) {
return `Yesterday, ${date.format('h:mm A')}`
}
return date.format('MMM D, h:mm A')
}
const getRunPreview = (run: JobRunWithDetails) =>
run.finalResult ?? run.result ?? run.error
export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
onViewRun,
onCancelRun,
@@ -54,28 +73,23 @@ export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
const { jobRuns } = useScheduledJobRuns()
const { jobs } = useScheduledJobs()
const sortedRuns: JobRunWithDetails[] = useMemo(() => {
const enrichWithJob = (run: ScheduledJobRun): JobRunWithDetails => ({
...run,
job: jobs.find((j) => j.id === run.jobId),
})
const taskGroups = useMemo(
() => groupScheduledTaskRuns({ runs: jobRuns, jobs }),
[jobRuns, jobs],
)
const [expandedGroupId, setExpandedGroupId] = useState<
string | null | undefined
>(undefined)
const running = jobRuns
.filter((r) => r.status === 'running')
.map(enrichWithJob)
const visibleExpandedGroupId =
expandedGroupId === undefined
? (taskGroups[0]?.id ?? null)
: expandedGroupId !== null &&
!taskGroups.some((group) => group.id === expandedGroupId)
? (taskGroups[0]?.id ?? null)
: expandedGroupId
const completedOrFailed = jobRuns
.filter((r) => r.status === 'completed' || r.status === 'failed')
.sort(
(a, b) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
)
.map(enrichWithJob)
return [...running, ...completedOrFailed]
}, [jobRuns, jobs])
if (!sortedRuns.length) {
if (!taskGroups.length) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-muted-foreground">
<Calendar className="h-10 w-10 opacity-50" />
@@ -85,62 +99,107 @@ export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
}
return (
<div className="space-y-2">
{sortedRuns.map((run) => (
<Button
key={run.id}
variant="ghost"
onClick={() => onViewRun(run)}
className="h-auto w-full justify-start rounded-xl border border-border/50 bg-card p-4 text-left transition-all hover:border-border"
<div className="space-y-3">
{taskGroups.map((group) => (
<Collapsible
key={group.id}
open={visibleExpandedGroupId === group.id}
onOpenChange={(open) => setExpandedGroupId(open ? group.id : null)}
className="rounded-xl border border-border bg-card shadow-sm transition-all hover:border-border"
>
<div className="flex w-full items-start gap-3">
{getStatusIcon(run.status)}
<CollapsibleTrigger className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/40">
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 ${
visibleExpandedGroupId === group.id ? '' : '-rotate-90'
}`}
/>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
{getStatusIcon(group.latestRun.status)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="truncate font-medium text-foreground text-sm">
{run.job?.name}
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-foreground">
{group.name}
</span>
<span className="flex items-center gap-1 text-muted-foreground text-xs">
<Clock className="h-3 w-3" />
{formatTimestamp(run.startedAt)}
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">
{group.resultCount}{' '}
{group.resultCount === 1 ? 'result' : 'results'}
</span>
</div>
{run.result && (
<p className="line-clamp-2 text-ellipsis text-muted-foreground text-xs">
{run.result}
</p>
)}
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
<Clock className="h-3 w-3" />
<span>Latest {formatTimestamp(group.latestRun.startedAt)}</span>
</div>
</div>
{run.status === 'running' && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
onCancelRun(run.id)
}}
className="shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label="Cancel run"
>
<Square className="h-3.5 w-3.5" />
</Button>
)}
{run.status === 'failed' && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
onRetryRun(run.jobId)
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Retry run"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border border-t px-4 pt-3 pb-4">
<div className="space-y-2">
{group.runs.map((run) => {
const preview = getRunPreview(run)
return (
<div
key={run.id}
className="flex items-start gap-3 rounded-lg border border-border bg-background p-3"
>
<div className="pt-0.5">{getStatusIcon(run.status)}</div>
<button
type="button"
onClick={() => onViewRun(run)}
className="min-w-0 flex-1 text-left"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm">
{formatRunTimestamp(run.startedAt)}
</span>
<span className="text-muted-foreground text-xs">
{run.status}
</span>
</div>
{preview && (
<p className="mt-1 line-clamp-2 text-muted-foreground text-xs">
{preview}
</p>
)}
</button>
<div className="flex shrink-0 items-center gap-1">
{run.status === 'running' && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => onCancelRun(run.id)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label="Cancel run"
>
<Square className="h-3.5 w-3.5" />
</Button>
)}
{run.status === 'failed' && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => onRetryRun(run.jobId)}
className="text-muted-foreground hover:text-foreground"
aria-label="Retry run"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => onViewRun(run)}
className="text-muted-foreground hover:text-foreground"
>
View
</Button>
</div>
</div>
)
})}
</div>
</CollapsibleContent>
</Collapsible>
))}
</div>
)

View File

@@ -0,0 +1,74 @@
import type {
ScheduledJob,
ScheduledJobRun,
} from '@/lib/schedules/scheduleTypes'
export interface JobRunWithDetails extends ScheduledJobRun {
job: ScheduledJob | undefined
}
export interface ScheduledTaskRunGroup {
id: string
name: string
job: ScheduledJob | undefined
runs: JobRunWithDetails[]
latestRun: JobRunWithDetails
resultCount: number
}
interface GroupScheduledTaskRunsInput {
jobs: ScheduledJob[]
runs: ScheduledJobRun[]
}
const getStartedAtMs = (run: ScheduledJobRun) => {
const time = new Date(run.startedAt).getTime()
return Number.isNaN(time) ? 0 : time
}
const compareRunsByNewest = (a: ScheduledJobRun, b: ScheduledJobRun) =>
getStartedAtMs(b) - getStartedAtMs(a)
const compareRunsForDisplay = (a: ScheduledJobRun, b: ScheduledJobRun) => {
if (a.status === 'running' && b.status !== 'running') return -1
if (a.status !== 'running' && b.status === 'running') return 1
return compareRunsByNewest(a, b)
}
export function groupScheduledTaskRuns({
jobs,
runs,
}: GroupScheduledTaskRunsInput): ScheduledTaskRunGroup[] {
const jobsById = new Map(jobs.map((job) => [job.id, job]))
const groupsByJobId = new Map<string, JobRunWithDetails[]>()
for (const run of runs) {
const job = jobsById.get(run.jobId)
const enrichedRun: JobRunWithDetails = { ...run, job }
const existing = groupsByJobId.get(run.jobId)
if (existing) {
existing.push(enrichedRun)
} else {
groupsByJobId.set(run.jobId, [enrichedRun])
}
}
return [...groupsByJobId.entries()]
.map(([jobId, groupRuns]) => {
const sortedRuns = [...groupRuns].sort(compareRunsForDisplay)
const latestRun = [...groupRuns].sort(compareRunsByNewest)[0]
const job = jobsById.get(jobId)
return {
id: jobId,
name: job?.name ?? 'Unknown scheduled task',
job,
runs: sortedRuns,
latestRun,
resultCount: sortedRuns.length,
}
})
.filter((group): group is ScheduledTaskRunGroup => Boolean(group.latestRun))
.sort((a, b) => compareRunsByNewest(a.latestRun, b.latestRun))
}