oxios 1.16.0

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import type { TFunction } from 'i18next'
import { Clock3, Code2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import {
  cronToSimple,
  DEFAULT_SCHEDULE,
  type FrequencyMode,
  formatTime,
  HOUR_INTERVAL_OPTIONS,
  isValidCron,
  MINUTE_INTERVAL_OPTIONS,
  parseTime,
  type SimpleSchedule,
  simpleToCron,
} from '@/lib/cron-utils'
import { cn } from '@/lib/utils'

interface CronScheduleEditorProps {
  /** Current cron expression (5-field). */
  value: string
  /** Fired with the new cron expression whenever the GUI or raw input changes. */
  onChange: (cron: string) => void
  id?: string
}

const FREQUENCY_OPTIONS: { mode: FrequencyMode; labelKey: string }[] = [
  { mode: 'minutes', labelKey: 'cronJobs.freqMinutes' },
  { mode: 'hourly', labelKey: 'cronJobs.freqHourly' },
  { mode: 'daily', labelKey: 'cronJobs.freqDaily' },
  { mode: 'weekly', labelKey: 'cronJobs.freqWeekly' },
  { mode: 'monthly', labelKey: 'cronJobs.freqMonthly' },
]

const WEEKDAY_KEYS = [
  'common.sun',
  'common.mon',
  'common.tue',
  'common.wed',
  'common.thu',
  'common.fri',
  'common.sat',
] as const

function dayOfMonthOptions() {
  return Array.from({ length: 31 }, (_, i) => ({ label: String(i + 1), value: String(i + 1) }))
}

/** Locale-aware friendly time label (e.g. "오전 9:00" / "9:00 AM"). */
function formatTimeLabel(hour: number, minute: number, ko: boolean): string {
  const mm = minute.toString().padStart(2, '0')
  const isPM = hour >= 12
  const h12 = hour % 12 === 0 ? 12 : hour % 12
  return ko ? `${isPM ? '오후' : '오전'} ${h12}:${mm}` : `${h12}:${mm} ${isPM ? 'PM' : 'AM'}`
}

/**
 * Build a localized human-readable description directly from the structured
 * {@link SimpleSchedule}. Describes exactly the 5 shapes the GUI can emit, so
 * no cron-string round-trip is needed.
 */
function describeSchedule(s: SimpleSchedule, t: TFunction, ko: boolean): string {
  switch (s.mode) {
    case 'minutes':
      return t('cronJobs.descMinutes', { count: s.minuteInterval })
    case 'hourly':
      return t('cronJobs.descHourly', {
        count: s.hourInterval,
        minute: String(s.atMinute).padStart(2, '0'),
      })
    case 'daily':
      return t('cronJobs.descDaily', { time: formatTimeLabel(s.hour, s.minute, ko) })
    case 'weekly': {
      const days = [...s.weekdays]
        .sort((a, b) => a - b)
        .map((d) => t(WEEKDAY_KEYS[d] as string))
        .join(', ')
      return t('cronJobs.descWeekly', { days, time: formatTimeLabel(s.hour, s.minute, ko) })
    }
    case 'monthly':
      return t('cronJobs.descMonthly', {
        day: s.dayOfMonth,
        time: formatTimeLabel(s.hour, s.minute, ko),
      })
  }
}

/**
 * Human-friendly cron schedule editor.
 *
 * Two modes:
 *  - **Simple** (default): structured GUI that emits canonical cron. When an
 *    incoming expression matches a supported pattern it is parsed back into GUI
 *    state; otherwise the editor auto-switches to advanced mode so an existing
 *    job's schedule is never misrepresented.
 *  - **Advanced**: raw 5-field cron input for power users.
 *
 * The live preview (cron expression + locale-aware human description) updates on
 * every keystroke / control change in both modes. Descriptions are derived
 * directly from the structured schedule state — no external parsing library.
 */
export function CronScheduleEditor({ value, onChange, id }: CronScheduleEditorProps) {
  const { t, i18n } = useTranslation()
  const ko = i18n.resolvedLanguage?.startsWith('ko') ?? false

  const [schedule, setSchedule] = useState<SimpleSchedule>(
    () => cronToSimple(value) ?? DEFAULT_SCHEDULE,
  )
  const [advanced, setAdvanced] = useState<boolean>(
    () => value.trim() !== '' && cronToSimple(value) === null,
  )

  // Avoid re-parsing our own emitted value (idempotent anyway, but skips a setState).
  const lastEmitted = useRef(value)

  useEffect(() => {
    if (value === lastEmitted.current) return
    lastEmitted.current = value
    const parsed = cronToSimple(value)
    if (parsed) {
      setSchedule(parsed)
    } else {
      setAdvanced(true)
    }
  }, [value])

  // ── Simple mode ────────────────────────────────────────────────────────────
  const updateSchedule = (patch: Partial<SimpleSchedule>) => {
    const next = { ...schedule, ...patch }
    setSchedule(next)
    const cron = simpleToCron(next)
    lastEmitted.current = cron
    onChange(cron)
  }

  const toggleWeekday = (day: number) => {
    const set = new Set(schedule.weekdays)
    if (set.has(day)) {
      if (set.size <= 1) return // keep at least one day selected
      set.delete(day)
    } else {
      set.add(day)
    }
    updateSchedule({ weekdays: [...set] })
  }

  // ── Advanced mode ──────────────────────────────────────────────────────────
  const handleRawChange = (raw: string) => {
    lastEmitted.current = raw
    onChange(raw)
  }

  // When switching back to simple, re-emit the current GUI schedule so the
  // preview and stored value stay consistent even if advanced held an
  // unparseable expression.
  const handleToggleAdvanced = (next: boolean) => {
    setAdvanced(next)
    if (!next) {
      const cron = simpleToCron(schedule)
      lastEmitted.current = cron
      onChange(cron)
    }
  }

  // ── Preview ────────────────────────────────────────────────────────────────
  const trimmed = value.trim()
  const valid = isValidCron(trimmed)
  // In simple mode `schedule` always describes the value; in advanced mode,
  // still describe when the raw expression happens to parse.
  const parsedForDesc = advanced ? cronToSimple(trimmed) : schedule
  const description = parsedForDesc
    ? describeSchedule(parsedForDesc, t, ko)
    : valid
      ? t('cronJobs.customSchedule')
      : null

  return (
    <div className="space-y-3" data-testid={id ? `${id}-cron-editor` : undefined}>
      {/* Mode switch */}
      <div className="flex items-center justify-between">
        <Label className="text-muted-foreground">{t('cronJobs.repeat')}</Label>
        <Button
          type="button"
          variant="ghost"
          size="sm"
          onClick={() => handleToggleAdvanced(!advanced)}
          className="h-7 gap-1 text-xs text-muted-foreground"
        >
          {advanced ? <Clock3 className="h-3.5 w-3.5" /> : <Code2 className="h-3.5 w-3.5" />}
          {advanced ? t('cronJobs.simpleMode') : t('cronJobs.advancedMode')}
        </Button>
      </div>

      {advanced ? (
        <div className="space-y-1">
          <Input
            id={id}
            value={value}
            onChange={(e) => handleRawChange(e.target.value)}
            placeholder="0 */6 * * *"
            className="font-mono"
            aria-invalid={!valid}
          />
          {!valid && <p className="text-xs text-destructive">{t('cronJobs.invalidCron')}</p>}
        </div>
      ) : (
        <div className="space-y-3">
          <Select
            id={id ? `${id}-freq` : undefined}
            value={schedule.mode}
            onValueChange={(m) => updateSchedule({ mode: m as FrequencyMode })}
            options={FREQUENCY_OPTIONS.map((o) => ({ label: t(o.labelKey), value: o.mode }))}
          />

          {schedule.mode === 'minutes' && (
            <Row label={t('cronJobs.every')}>
              <Select
                value={String(schedule.minuteInterval)}
                onValueChange={(v) => updateSchedule({ minuteInterval: Number(v) })}
                options={MINUTE_INTERVAL_OPTIONS.map((n) => ({
                  label: t('cronJobs.everyNMinutes', { count: n }),
                  value: String(n),
                }))}
              />
            </Row>
          )}

          {schedule.mode === 'hourly' && (
            <>
              <Row label={t('cronJobs.every')}>
                <Select
                  value={String(schedule.hourInterval)}
                  onValueChange={(v) => updateSchedule({ hourInterval: Number(v) })}
                  options={HOUR_INTERVAL_OPTIONS.map((n) => ({
                    label: t('cronJobs.everyNHours', { count: n }),
                    value: String(n),
                  }))}
                />
              </Row>
              <Row label={t('cronJobs.atMinute')}>
                <Select
                  value={String(schedule.atMinute)}
                  onValueChange={(v) => updateSchedule({ atMinute: Number(v) })}
                  options={Array.from({ length: 12 }, (_, i) => i * 5).map((mm) => ({
                    label: String(mm),
                    value: String(mm),
                  }))}
                />
              </Row>
            </>
          )}

          {schedule.mode === 'daily' && (
            <Row label={t('cronJobs.atTime')}>
              <Input
                type="time"
                value={formatTime(schedule.hour, schedule.minute)}
                onChange={(e) => {
                  const [h, m] = parseTime(e.target.value)
                  updateSchedule({ hour: h, minute: m })
                }}
                className="max-w-[8rem]"
              />
            </Row>
          )}

          {schedule.mode === 'weekly' && (
            <>
              <Row label={t('cronJobs.onDays')}>
                <div className="flex flex-wrap gap-1">
                  {WEEKDAY_KEYS.map((key, idx) => {
                    const active = schedule.weekdays.includes(idx)
                    return (
                      <Button
                        key={key}
                        type="button"
                        variant={active ? 'default' : 'outline'}
                        size="sm"
                        onClick={() => toggleWeekday(idx)}
                        className={cn('h-8 w-9 p-0 text-xs')}
                        aria-pressed={active}
                      >
                        {t(key)}
                      </Button>
                    )
                  })}
                </div>
              </Row>
              <Row label={t('cronJobs.atTime')}>
                <Input
                  type="time"
                  value={formatTime(schedule.hour, schedule.minute)}
                  onChange={(e) => {
                    const [h, m] = parseTime(e.target.value)
                    updateSchedule({ hour: h, minute: m })
                  }}
                  className="max-w-[8rem]"
                />
              </Row>
            </>
          )}

          {schedule.mode === 'monthly' && (
            <>
              <Row label={t('cronJobs.onDay')}>
                <Select
                  value={String(schedule.dayOfMonth)}
                  onValueChange={(v) => updateSchedule({ dayOfMonth: Number(v) })}
                  options={dayOfMonthOptions()}
                />
              </Row>
              <Row label={t('cronJobs.atTime')}>
                <Input
                  type="time"
                  value={formatTime(schedule.hour, schedule.minute)}
                  onChange={(e) => {
                    const [h, m] = parseTime(e.target.value)
                    updateSchedule({ hour: h, minute: m })
                  }}
                  className="max-w-[8rem]"
                />
              </Row>
            </>
          )}
        </div>
      )}

      {/* Live preview */}
      <div className="flex flex-col gap-1 rounded-md border bg-muted/40 px-3 py-2">
        <div className="flex items-center gap-2">
          <code className="text-xs font-semibold">{trimmed || '—'}</code>
          {description && (
            <Badge variant="secondary" className="text-[10px]">
              {t('cronJobs.preview')}
            </Badge>
          )}
        </div>
        {description ? (
          <p className="text-xs text-muted-foreground">{description}</p>
        ) : (
          <p className="text-xs text-muted-foreground/60">{t('cronJobs.previewEmpty')}</p>
        )}
      </div>
    </div>
  )
}

function Row({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div className="flex items-center justify-between gap-3">
      <Label className="shrink-0 text-muted-foreground">{label}</Label>
      <div className="min-w-0 flex-1">{children}</div>
    </div>
  )
}