diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.test.ts b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.test.ts new file mode 100644 index 000000000..42dd068c3 --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.test.ts @@ -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 { + 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', + ]) + }) +}) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.tsx index 8e02ad7ce..d27f56be7 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.tsx @@ -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 = ({ onViewRun, onCancelRun, @@ -54,28 +73,23 @@ export const ScheduledTaskResults: FC = ({ 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 (
@@ -85,62 +99,107 @@ export const ScheduledTaskResults: FC = ({ } return ( -
- {sortedRuns.map((run) => ( - - )} - {run.status === 'failed' && ( - - )} -
- + + +
+ {group.runs.map((run) => { + const preview = getRunPreview(run) + + return ( +
+
{getStatusIcon(run.status)}
+ +
+ {run.status === 'running' && ( + + )} + {run.status === 'failed' && ( + + )} + +
+
+ ) + })} +
+
+ ))}
) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/scheduledTaskResultsUtils.ts b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/scheduledTaskResultsUtils.ts new file mode 100644 index 000000000..99c44af0f --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/scheduledTaskResultsUtils.ts @@ -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() + + 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)) +}