oxios 1.23.1

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { Pencil, Power, PowerOff, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import {
  chartColor,
  computeJobFireData,
  computeTicks,
  formatTickLabel,
  LABEL_WIDTH,
  msToPx,
  PIXELS_PER_HOUR,
  TIME_RANGES,
  type TimeRange,
} from '@/lib/cron-timeline'
import { cn } from '@/lib/utils'
import type { CronJob } from '@/types'

interface HoveredMarker {
  rect: DOMRect
  date: Date
  jobName: string
  isPrimary: boolean
}

/**
 * Gantt-style timeline view for cron jobs.
 *
 * Shows each job as a row with fire-time markers projected across a
 * selectable time window (24h / 48h / 7d). The `next_run` from the
 * backend is always shown as a highlighted primary marker; additional
 * projected fire times visualize the schedule pattern.
 */
export function CronTimelineView({
  jobs,
  onEdit,
  onToggle,
  onDelete,
}: {
  jobs: CronJob[]
  onEdit?: (job: CronJob) => void
  onToggle?: (job: CronJob) => void
  onDelete?: (job: CronJob) => void
}) {
  const { t } = useTranslation()
  const [range, setRange] = useState<TimeRange>('24h')
  const [hovered, setHovered] = useState<HoveredMarker | null>(null)

  const rangeHours = TIME_RANGES.find((r) => r.value === range)!.hours
  const nowMs = Date.now()
  const trackWidth = rangeHours * PIXELS_PER_HOUR

  const fireData = useMemo(() => computeJobFireData(jobs, rangeHours), [jobs, rangeHours])
  const ticks = computeTicks(nowMs, rangeHours, range)
  const totalFires = useMemo(
    () => Array.from(fireData.values()).reduce((sum, d) => sum + d.fireTimes.length, 0),
    [fireData],
  )

  if (jobs.length === 0) {
    return (
      <div className="rounded-xl border border-dashed p-12 text-center">
        <p className="text-sm text-muted-foreground">{t('cronJobs.timeline.emptyHint')}</p>
      </div>
    )
  }

  return (
    <div className="space-y-3">
      {/* Controls */}
      <div className="flex flex-wrap items-center justify-between gap-3">
        <div className="inline-flex gap-0.5 rounded-lg border bg-muted/50 p-0.5">
          {TIME_RANGES.map((r) => (
            <button
              key={r.value}
              type="button"
              onClick={() => setRange(r.value)}
              className={cn(
                'rounded-md px-3 py-1 text-xs font-medium transition-colors',
                range === r.value
                  ? 'bg-background text-foreground shadow-sm'
                  : 'text-muted-foreground hover:text-foreground',
              )}
            >
              {t(`cronJobs.timeline.range${r.value}`)}
            </button>
          ))}
        </div>
        <p className="text-xs text-muted-foreground">
          {t('cronJobs.timeline.jobCount', { count: jobs.length })} ·{' '}
          {t('cronJobs.timeline.fireCount', { count: totalFires })}
        </p>
      </div>

      {/* Timeline */}
      <div className="max-h-[70vh] overflow-auto rounded-xl border bg-card shadow-sm">
        <div style={{ width: LABEL_WIDTH + trackWidth, minWidth: '100%' }}>
          {/* Time axis header */}
          <div className="sticky top-0 z-30 flex border-b bg-muted/30 backdrop-blur-sm">
            <div
              className="sticky left-0 z-30 shrink-0 border-r bg-muted/30 px-3 py-1.5"
              style={{ width: LABEL_WIDTH }}
            >
              <span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
                {t('cronJobs.timeline.now')}
              </span>
            </div>
            <div className="relative h-7" style={{ width: trackWidth }}>
              {ticks.map((tick) => (
                <div
                  key={tick.getTime()}
                  className="absolute top-0 h-full"
                  style={{ left: msToPx(tick.getTime() - nowMs) }}
                >
                  <span className="absolute top-1 -translate-x-1/2 whitespace-nowrap text-[10px] text-muted-foreground">
                    {formatTickLabel(tick, range)}
                  </span>
                </div>
              ))}
            </div>
          </div>

          {/* Job rows */}
          {jobs.map((job, i) => {
            const data = fireData.get(job.id)
            const color = chartColor(i)
            const hasFires = data && data.fireTimes.length > 0

            return (
              <div key={job.id} className="flex border-b last:border-b-0">
                {/* Sticky label */}
                <div
                  className="sticky left-0 z-20 shrink-0 border-r bg-card px-3 py-2"
                  style={{ width: LABEL_WIDTH }}
                >
                  <div className="flex items-center gap-1.5">
                    <span
                      className={cn(
                        'truncate text-sm font-medium',
                        !job.enabled && 'text-muted-foreground',
                      )}
                    >
                      {job.name}
                    </span>
                  </div>
                  <div className="mt-0.5 flex items-center gap-1">
                    <code className="rounded bg-muted px-1 py-0.5 text-[10px]">{job.schedule}</code>
                    {!job.enabled && (
                      <Badge variant="secondary" className="h-4 px-1 text-[9px]">
                        {t('cronJobs.timeline.disabled')}
                      </Badge>
                    )}
                    {data?.parseFailed && (
                      <span className="text-[9px] text-muted-foreground/50">
                        {t('cronJobs.timeline.parseFailed')}
                      </span>
                    )}
                  </div>
                  {(onEdit || onToggle || onDelete) && (
                    <div className="mt-1 flex gap-0.5">
                      {onEdit && (
                        <button
                          type="button"
                          onClick={() => onEdit(job)}
                          aria-label={t('common.edit')}
                          className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                        >
                          <Pencil className="h-3 w-3" />
                        </button>
                      )}
                      {onToggle && (
                        <button
                          type="button"
                          onClick={() => onToggle(job)}
                          aria-label={
                            job.enabled ? t('cronJobs.disableJob') : t('cronJobs.enableJob')
                          }
                          className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                        >
                          {job.enabled ? (
                            <PowerOff className="h-3 w-3" />
                          ) : (
                            <Power className="h-3 w-3" />
                          )}
                        </button>
                      )}
                      {onDelete && (
                        <button
                          type="button"
                          onClick={() => onDelete(job)}
                          aria-label={t('cronJobs.deleteJob')}
                          className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                        >
                          <Trash2 className="h-3 w-3" />
                        </button>
                      )}
                    </div>
                  )}
                </div>

                {/* Track */}
                <div className="relative h-12" style={{ width: trackWidth }}>
                  {/* Gridlines */}
                  {ticks.map((tick) => (
                    <div
                      key={tick.getTime()}
                      className="absolute top-0 h-full w-px bg-border/30"
                      style={{ left: msToPx(tick.getTime() - nowMs) }}
                    />
                  ))}

                  {hasFires && data.isDense && (
                    <DenseBar
                      data={data}
                      color={color}
                      dimmed={!job.enabled}
                      nowMs={nowMs}
                      jobName={job.name}
                      onHover={setHovered}
                      t={t}
                    />
                  )}

                  {hasFires &&
                    !data.isDense &&
                    data.fireTimes.map((ft) => {
                      const pos = msToPx(Math.max(0, ft.date.getTime() - nowMs))
                      return (
                        <div
                          key={ft.date.getTime()}
                          className="absolute top-1/2 -translate-y-1/2 cursor-pointer rounded-full transition-transform hover:z-20 hover:scale-[1.8]"
                          style={{
                            left: pos,
                            width: ft.isPrimary ? 6 : 3,
                            height: ft.isPrimary ? 22 : 14,
                            marginLeft: ft.isPrimary ? -3 : -1.5,
                            backgroundColor: color,
                            opacity: job.enabled ? 1 : 0.3,
                            zIndex: ft.isPrimary ? 5 : 1,
                            outline: ft.isPrimary
                              ? `2px solid color-mix(in oklch, ${color} 30%, transparent)`
                              : undefined,
                            outlineOffset: '1px',
                          }}
                          onMouseEnter={(e) =>
                            setHovered({
                              rect: e.currentTarget.getBoundingClientRect(),
                              date: ft.date,
                              jobName: job.name,
                              isPrimary: ft.isPrimary,
                            })
                          }
                          onMouseLeave={() => setHovered(null)}
                          tabIndex={ft.isPrimary ? 0 : undefined}
                          role={ft.isPrimary ? 'img' : undefined}
                          aria-hidden={ft.isPrimary ? undefined : true}
                          aria-label={
                            ft.isPrimary
                              ? `${job.name}: ${t('cronJobs.timeline.nextRun')} ${ft.date.toLocaleString()}`
                              : undefined
                          }
                          onFocus={
                            ft.isPrimary
                              ? (e) =>
                                  setHovered({
                                    rect: e.currentTarget.getBoundingClientRect(),
                                    date: ft.date,
                                    jobName: job.name,
                                    isPrimary: true,
                                  })
                              : undefined
                          }
                          onBlur={ft.isPrimary ? () => setHovered(null) : undefined}
                        />
                      )
                    })}
                </div>
              </div>
            )
          })}
        </div>
      </div>

      {/* Floating tooltip */}
      {hovered && (
        <div
          className="pointer-events-none fixed z-50 rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-lg"
          style={{
            left: hovered.rect.left + hovered.rect.width / 2,
            top: hovered.rect.top - 6,
            transform: 'translate(-50%, -100%)',
          }}
        >
          <span className="font-medium">{hovered.jobName}</span>
          <span className="mx-1 opacity-50">·</span>
          {hovered.date.toLocaleString(undefined, {
            month: 'short',
            day: 'numeric',
            hour: '2-digit',
            minute: '2-digit',
            hour12: false,
          })}
          {hovered.isPrimary && (
            <span className="ml-1.5 rounded bg-background/20 px-1 text-[10px]">
              {t('cronJobs.timeline.nextRun')}
            </span>
          )}
        </div>
      )}
    </div>
  )
}

/** Dense continuous bar for high-frequency jobs (>25 fires in window). */
function DenseBar({
  data,
  color,
  dimmed,
  nowMs,
  jobName,
  onHover,
  t,
}: {
  data: { fireTimes: { date: Date; isPrimary: boolean }[] }
  color: string
  dimmed: boolean
  nowMs: number
  jobName: string
  onHover: (h: HoveredMarker | null) => void
  t: (key: string, opts?: Record<string, unknown>) => string
}) {
  const first = data.fireTimes[0]
  const last = data.fireTimes.at(-1)
  if (!first || !last) return null
  const leftPx = msToPx(Math.max(0, first.date.getTime() - nowMs))
  const rightPx = msToPx(last.date.getTime() - nowMs)
  const barWidth = Math.max(6, rightPx - leftPx)

  return (
    <>
      <div
        className="absolute top-1/2 h-2.5 -translate-y-1/2 cursor-pointer rounded-full"
        style={{
          left: leftPx,
          width: barWidth,
          backgroundColor: color,
          opacity: dimmed ? 0.15 : 0.5,
        }}
        onMouseEnter={(e) =>
          onHover({
            rect: e.currentTarget.getBoundingClientRect(),
            date: first.date,
            jobName,
            isPrimary: false,
          })
        }
        onMouseLeave={() => onHover(null)}
      />
      <span
        className="absolute top-1/2 -translate-y-1/2 whitespace-nowrap text-[10px] font-medium text-muted-foreground"
        style={{ left: rightPx + 8 }}
      >
        {t('cronJobs.timeline.fireCount', { count: data.fireTimes.length })}
      </span>
    </>
  )
}