{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cron-expression",
  "title": "Cron Expression",
  "description": "Turns a cron expression into a sentence anyone can read, and lists the next times it fires. Reach for it wherever a schedule is shown rather than edited: a scheduled-jobs table in an admin panel, the summary line under a cron input, backup and report-delivery settings, sync and webhook retry schedules, a CI/CD or deploy cadence, a GitHub Actions / Vercel Cron / Cloudflare Workers Triggers schedule rendered in your own dashboard, or the live preview beside a cron builder. Common asks it answers: \"cron to human readable react\", \"explain a cron expression\", \"crontab parser component\", \"describe cron in plain English\", \"next run time from a cron expression\", \"cron preview shadcn\", \"validate a cron expression in a form\", \"what does 0 9 * * 1-5 mean\". It handles the parts a hand-rolled parser gets wrong. The day-of-month and day-of-week fields are ORed when neither is a literal star and ANDed when either one is — so 0 0 13 * 5 runs on the 13th OR on every Friday, not only on Friday the 13th, and 0 0 13 * 0-6 runs every single day even though 0-6 covers the same seven days a star does. That rule is cron's oldest trap, it keys off syntax rather than coverage, and the component both applies it and says so on screen when it is in play. Sunday is both 0 and 7, and the fold happens after a range is expanded, so 5-7 means Friday, Saturday and Sunday instead of collapsing into a backwards range. Ranges, lists, steps, */n, a-b/n, the n/step shorthand, three-letter month and weekday aliases in any position, and the @daily / @hourly / @weekly / @monthly / @yearly / @midnight nicknames all parse; @reboot is reported as having no calendar schedule rather than being invented one; a six- or seven-field expression is named as Quartz/Spring syntax rather than dismissed as invalid, and L, W and # are named as Quartz extensions. Next runs are computed in UTC — the zone GitHub Actions, Vercel Cron and Cloudflare Triggers all schedule in — by stepping whichever field fails rather than a minute at a time, so an expression that only matches on February 29 costs a few thousand comparisons instead of two million, and one that can never match (February 30) ends empty instead of hanging. Run times are formatted without Intl, because a locale-dependent string renders differently on the server and in the browser and turns into a hydration mismatch; pass formatRun to localise it yourself. Nothing reads the clock, so the same props always produce the same markup. An invalid expression is reported inline, in words as well as in colour, with the field and token that failed — conveying state by colour alone fails WCAG 1.4.1 — and the parse helpers (parseCron, describeCron, nextCronRuns) are exported so the same expression can be validated in a form before it is saved. No dependencies and no hooks, so it renders inside a React server component with no 'use client' of its own and ships no client JavaScript. Official shadcn/ui has nothing for scheduling: calendar is a date picker built on react-day-picker, and progress is a bar with no notion of recurrence.",
  "files": [
    {
      "path": "registry/ui/cron-expression.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface CronField {\n  /** Sorted, de-duplicated list of the values this field matches. */\n  values: number[]\n  /**\n   * The field was written starting with `*` (or the Quartz `?`). This is syntax, not coverage,\n   * and it is load-bearing rather than cosmetic: the day-of-month / day-of-week rule keys off the\n   * literal star, so `*` and `0-6` behave differently in the day-of-week field even though the\n   * two cover the same seven days. See `dayMatches` below.\n   */\n  star: boolean\n  /** Lowest legal value for this field (0 for minutes, 1 for day-of-month, …). */\n  min: number\n  /** Highest legal value for this field. Day-of-week is 0–6 here; an input of 7 folds to 0. */\n  max: number\n}\n\nexport interface CronSchedule {\n  minute: CronField\n  hour: CronField\n  dayOfMonth: CronField\n  month: CronField\n  dayOfWeek: CronField\n}\n\nexport type CronParseResult =\n  | {\n      ok: true\n      schedule: CronSchedule\n      /** The five-field form, with any `@macro` expanded and whitespace collapsed. */\n      normalized: string\n    }\n  | { ok: false; error: string }\n\ninterface FieldSpec {\n  label: string\n  min: number\n  max: number\n  /** Highest value accepted on input, when it differs from `max` (day-of-week accepts 7). */\n  inputMax?: number\n  /** Three-letter aliases, in value order starting at `min`. */\n  names?: readonly string[]\n  /** Quartz writes `?` for \"no specific value\" in the two day fields. */\n  allowQuestion?: boolean\n}\n\nconst MONTH_ALIASES = [\n  \"JAN\",\n  \"FEB\",\n  \"MAR\",\n  \"APR\",\n  \"MAY\",\n  \"JUN\",\n  \"JUL\",\n  \"AUG\",\n  \"SEP\",\n  \"OCT\",\n  \"NOV\",\n  \"DEC\",\n] as const\n\nconst DAY_ALIASES = [\"SUN\", \"MON\", \"TUE\", \"WED\", \"THU\", \"FRI\", \"SAT\"] as const\n\nconst MONTH_NAMES = [\n  \"January\",\n  \"February\",\n  \"March\",\n  \"April\",\n  \"May\",\n  \"June\",\n  \"July\",\n  \"August\",\n  \"September\",\n  \"October\",\n  \"November\",\n  \"December\",\n]\n\nconst DAY_NAMES = [\n  \"Sunday\",\n  \"Monday\",\n  \"Tuesday\",\n  \"Wednesday\",\n  \"Thursday\",\n  \"Friday\",\n  \"Saturday\",\n]\n\nconst DAY_ABBR = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"]\n\nconst SPECS: readonly FieldSpec[] = [\n  { label: \"minute\", min: 0, max: 59 },\n  { label: \"hour\", min: 0, max: 23 },\n  { label: \"day-of-month\", min: 1, max: 31, allowQuestion: true },\n  { label: \"month\", min: 1, max: 12, names: MONTH_ALIASES },\n  {\n    label: \"day-of-week\",\n    min: 0,\n    max: 6,\n    inputMax: 7,\n    names: DAY_ALIASES,\n    allowQuestion: true,\n  },\n]\n\n/**\n * The nicknames every cron implementation understands. `@reboot` is deliberately absent: it is a\n * start-up trigger, not a calendar rule, and reporting it as a parse error is more useful than\n * inventing a schedule for it.\n */\nconst MACROS: Record<string, string> = {\n  \"@yearly\": \"0 0 1 1 *\",\n  \"@annually\": \"0 0 1 1 *\",\n  \"@monthly\": \"0 0 1 * *\",\n  \"@weekly\": \"0 0 * * 0\",\n  \"@daily\": \"0 0 * * *\",\n  \"@midnight\": \"0 0 * * *\",\n  \"@hourly\": \"0 * * * *\",\n}\n\n/** `L`, `W` and `#` are Quartz extensions. `JUL` must not trip this, hence the letter guards. */\nconst QUARTZ_ONLY = /(^|[^A-Z])[LW](?![A-Z])|#/i\n\nconst MS_PER_MINUTE = 60_000\n\n/** How far ahead `nextCronRuns` will look before giving up. */\nconst HORIZON_YEARS = 8\n\n/**\n * Above this many distinct wall-clock times, the sentence stops listing them and describes the\n * two fields separately instead — \"At 09:00 and 17:00\" is clearer than any field-by-field\n * phrasing, but the same treatment of `0 9-17 * * *` would spell out nine times in a row.\n */\nconst MAX_CLOCK_TIMES = 8\n\nconst pad2 = (n: number) => String(n).padStart(2, \"0\")\n\nfunction ordinal(n: number) {\n  const teens = n % 100\n  if (teens >= 11 && teens <= 13) return `${n}th`\n  switch (n % 10) {\n    case 1:\n      return `${n}st`\n    case 2:\n      return `${n}nd`\n    case 3:\n      return `${n}rd`\n    default:\n      return `${n}th`\n  }\n}\n\nfunction listPhrase(parts: string[]) {\n  if (parts.length <= 1) return parts[0] ?? \"\"\n  return `${parts.slice(0, -1).join(\", \")} and ${parts[parts.length - 1]}`\n}\n\n/** One value of one field: a number, or a three-letter alias. Returns null when it is neither. */\nfunction parseValue(token: string, spec: FieldSpec): number | null {\n  const upper = token.toUpperCase()\n  if (spec.names) {\n    const index = spec.names.indexOf(upper)\n    if (index >= 0) return spec.min + index\n  }\n  if (!/^\\d{1,2}$/.test(token)) return null\n  const value = Number(token)\n  if (value < spec.min || value > (spec.inputMax ?? spec.max)) return null\n  return value\n}\n\n/**\n * Sunday is both 0 and 7. Folding happens here, after a range has been expanded, rather than at\n * the point each endpoint is read: `5-7` has to mean Friday, Saturday, Sunday, and folding the\n * endpoint first would turn it into the backwards range `5-0`.\n */\nconst fold = (value: number, spec: FieldSpec) => (value > spec.max ? spec.min : value)\n\nfunction parseTerm(term: string, spec: FieldSpec): number[] | string {\n  if (term === \"\") return `${spec.label} has an empty entry`\n\n  const slash = term.split(\"/\")\n  if (slash.length > 2) return `${spec.label} \"${term}\" has more than one step`\n  const [rangeText, stepText] = slash\n\n  let step = 1\n  if (stepText !== undefined) {\n    if (!/^\\d{1,4}$/.test(stepText))\n      return `${spec.label} step \"${stepText}\" is not a whole number`\n    step = Number(stepText)\n    if (step < 1) return `${spec.label} step must be 1 or more`\n  }\n\n  let from: number\n  let to: number\n  if (rangeText === \"*\" || (rangeText === \"?\" && spec.allowQuestion)) {\n    from = spec.min\n    // `spec.max`, not `inputMax`: the extra day-of-week value 7 folds onto 0, which a range\n    // starting at 0 already contains, so it cannot add a day here at any step. It is only\n    // reachable — and only load-bearing — in the explicit branch below, where `5/1` has to\n    // reach Sunday.\n    to = spec.max\n  } else {\n    const dash = rangeText.split(\"-\")\n    if (dash.length > 2) return `${spec.label} \"${rangeText}\" has more than one range`\n    const start = parseValue(dash[0], spec)\n    if (start === null) return `\"${dash[0]}\" is not a valid ${spec.label}`\n    from = start\n    if (dash.length === 1) {\n      // `5/15` is the widespread shorthand for \"from 5, then every 15th\" — it only means a lone\n      // value when no step was given.\n      to = stepText === undefined ? start : (spec.inputMax ?? spec.max)\n    } else {\n      const end = parseValue(dash[1], spec)\n      if (end === null) return `\"${dash[1]}\" is not a valid ${spec.label}`\n      if (end < start) return `${spec.label} range \"${rangeText}\" runs backwards`\n      to = end\n    }\n  }\n\n  const values: number[] = []\n  for (let value = from; value <= to; value += step) values.push(fold(value, spec))\n  return values\n}\n\nfunction parseField(text: string, spec: FieldSpec): CronField | string {\n  const values = new Set<number>()\n  for (const term of text.split(\",\")) {\n    const parsed = parseTerm(term, spec)\n    if (typeof parsed === \"string\") {\n      return QUARTZ_ONLY.test(text)\n        ? `${parsed} — L, W and # are Quartz extensions that five-field cron does not have`\n        : parsed\n    }\n    for (const value of parsed) values.add(value)\n  }\n  return {\n    values: [...values].sort((a, b) => a - b),\n    // Vixie cron sets its star flag from the first character of the field, which is why `*/2`\n    // counts as a star and `0-59` does not. Matching that exactly is what keeps `dayMatches`\n    // faithful for expressions like `0 0 13 * 0-6`.\n    star: text.startsWith(\"*\") || (spec.allowQuestion === true && text.startsWith(\"?\")),\n    min: spec.min,\n    max: spec.max,\n  }\n}\n\n/**\n * Parse a five-field cron expression (or an `@macro`) into the set of values each field matches.\n * Errors are returned rather than thrown, so an expression typed into a form can be reported\n * inline while the user is still editing it.\n */\nexport function parseCron(expression: string): CronParseResult {\n  const trimmed = String(expression ?? \"\").trim()\n  if (trimmed === \"\") return { ok: false, error: \"The expression is empty\" }\n\n  if (trimmed.startsWith(\"@\")) {\n    const macro = trimmed.toLowerCase()\n    if (macro === \"@reboot\")\n      return {\n        ok: false,\n        error: \"@reboot runs once at start-up and has no calendar schedule\",\n      }\n    const expanded = MACROS[macro]\n    if (!expanded)\n      return {\n        ok: false,\n        error: `Unknown nickname \"${trimmed}\" — the known ones are ${Object.keys(MACROS).join(\", \")}`,\n      }\n    return parseCron(expanded)\n  }\n\n  const parts = trimmed.split(/\\s+/)\n  if (parts.length !== SPECS.length) {\n    const extra =\n      parts.length === 6 || parts.length === 7\n        ? \" — six- and seven-field expressions carry a seconds (and year) field, which is Quartz/Spring syntax rather than crontab syntax\"\n        : \"\"\n    return {\n      ok: false,\n      error: `Expected 5 fields (minute hour day-of-month month day-of-week) but found ${parts.length}${extra}`,\n    }\n  }\n\n  const fields: CronField[] = []\n  for (let i = 0; i < SPECS.length; i++) {\n    const field = parseField(parts[i], SPECS[i])\n    if (typeof field === \"string\") return { ok: false, error: field }\n    fields.push(field)\n  }\n\n  return {\n    ok: true,\n    schedule: {\n      minute: fields[0],\n      hour: fields[1],\n      dayOfMonth: fields[2],\n      month: fields[3],\n      dayOfWeek: fields[4],\n    },\n    normalized: parts.join(\" \"),\n  }\n}\n\ntype Shape =\n  | { kind: \"all\" }\n  | { kind: \"one\"; value: number }\n  | { kind: \"range\"; from: number; to: number }\n  | { kind: \"step\"; step: number; from: number; to: number }\n  | { kind: \"list\"; values: number[] }\n\n/** The gap between values when it is the same all the way along, otherwise null. */\nfunction uniformStep(field: CronField): number | null {\n  const values = field.values\n  if (values.length < 2) return null\n  const step = values[1] - values[0]\n  for (let i = 2; i < values.length; i++) {\n    if (values[i] - values[i - 1] !== step) return null\n  }\n  return step\n}\n\n/**\n * Below this many values, \"every nth\" is a worse description than the list it stands for. Any two\n * values have a uniform gap, so without the floor `1,15` describes as \"every 14th day of the\n * month from the 1st through the 15th\" — true, and useless.\n */\nconst MIN_STEP_VALUES = 3\n\n/**\n * Recover the shape of a field from its values, so the sentence can say \"every 15 minutes\"\n * instead of reading out four numbers. Coverage, not syntax: `0-59` describes as \"all\".\n *\n * `allowStep` is off for the two fields whose values have names. `1,3,5` in day-of-week is a\n * uniform step, but \"every 2nd day of the week from Monday through Friday\" invites exactly the\n * misreading the sentence exists to prevent, and there are only ever seven names to list.\n */\nfunction shapeOf(field: CronField, allowStep = true): Shape {\n  const values = field.values\n  if (values.length === field.max - field.min + 1) return { kind: \"all\" }\n  if (values.length === 1) return { kind: \"one\", value: values[0] }\n  const step = uniformStep(field)\n  if (step === null) return { kind: \"list\", values }\n  const from = values[0]\n  const to = values[values.length - 1]\n  if (step === 1) return { kind: \"range\", from, to }\n  if (!allowStep || values.length < MIN_STEP_VALUES) return { kind: \"list\", values }\n  return { kind: \"step\", step, from, to }\n}\n\ninterface Nouns {\n  all: string\n  one: string\n  plural: string\n  unit: string\n  /** Repeated before the far end of a range: \"the 1st through the 7th\", but \"09 through 17\". */\n  article?: string\n}\n\nfunction spanPhrase(shape: Shape, format: (value: number) => string, nouns: Nouns): string {\n  const article = nouns.article ?? \"\"\n  switch (shape.kind) {\n    case \"all\":\n      return nouns.all\n    case \"one\":\n      return `${nouns.one} ${format(shape.value)}`\n    case \"range\":\n      return `${nouns.plural} ${format(shape.from)} through ${article}${format(shape.to)}`\n    case \"step\":\n      return `every ${ordinal(shape.step)} ${nouns.unit} from ${article}${format(shape.from)} through ${article}${format(shape.to)}`\n    case \"list\":\n      return `${nouns.plural} ${listPhrase(shape.values.map(format))}`\n  }\n}\n\nfunction timePhrase(schedule: CronSchedule): string {\n  const minute = shapeOf(schedule.minute)\n  const hour = shapeOf(schedule.hour)\n\n  // A handful of wall-clock times is what most expressions are, and it is what people read\n  // fastest. Only fall back to describing the two fields separately when the cross product would\n  // turn into a wall of numbers.\n  if (\n    minute.kind !== \"all\" &&\n    hour.kind !== \"all\" &&\n    schedule.hour.values.length * schedule.minute.values.length <= MAX_CLOCK_TIMES\n  ) {\n    const times: string[] = []\n    // Both value lists are sorted ascending, and hours are the outer loop, so the times come out\n    // in chronological order without a further sort.\n    for (const h of schedule.hour.values) {\n      for (const m of schedule.minute.values) times.push(`${pad2(h)}:${pad2(m)}`)\n    }\n    return `At ${listPhrase(times)}`\n  }\n\n  // `*/n` — a step that starts at the bottom of the range and runs off the end of it. Read from\n  // the values rather than from the shape, because `*/30` is only two values and the shape layer\n  // deliberately calls that a list.\n  const minuteStep = uniformStep(schedule.minute)\n  const wholeHourStep =\n    minute.kind !== \"all\" &&\n    minuteStep !== null &&\n    minuteStep > 1 &&\n    schedule.minute.values[0] === schedule.minute.min &&\n    schedule.minute.values[schedule.minute.values.length - 1] + minuteStep >\n      schedule.minute.max\n\n  const minuteClause =\n    minute.kind === \"all\"\n      ? \"Every minute\"\n      : wholeHourStep\n        ? `Every ${minuteStep} minutes`\n        : `At ${spanPhrase(minute, String, {\n            all: \"every minute\",\n            one: \"minute\",\n            plural: \"minutes\",\n            unit: \"minute\",\n          })}`\n\n  // \"Every minute during hours 09 through 17\" but \"At minute 5 past hours 09 through 17\": the\n  // two minute clauses are different parts of speech and take different prepositions.\n  const continuous = minute.kind === \"all\" || wholeHourStep\n  if (hour.kind === \"all\") return continuous ? minuteClause : `${minuteClause} past every hour`\n\n  const hours = spanPhrase(hour, pad2, {\n    all: \"every hour\",\n    one: \"hour\",\n    plural: \"hours\",\n    unit: \"hour\",\n  })\n  return continuous ? `${minuteClause} during ${hours}` : `${minuteClause} past ${hours}`\n}\n\nfunction dayPhrase(schedule: CronSchedule): string {\n  const dom = shapeOf(schedule.dayOfMonth)\n  const dow = shapeOf(schedule.dayOfWeek, false)\n  const domText = spanPhrase(dom, ordinal, {\n    all: \"every day of the month\",\n    one: \"the\",\n    plural: \"the\",\n    unit: \"day of the month\",\n    article: \"the \",\n  })\n  const dowText = spanPhrase(dow, (value) => DAY_NAMES[value], {\n    all: \"every day of the week\",\n    one: \"\",\n    plural: \"\",\n    unit: \"day of the week\",\n  }).trim()\n\n  // Cron's oldest trap, and it is syntactic: with a star in neither day field the two are ORed;\n  // with a star in either, they are ANDed. Both sides are named even when one covers every day,\n  // because \"on the 13th or on every day of the week\" is how a reader sees that this schedule is\n  // in fact daily — collapsing it to \"on the 13th\" would state the opposite of what runs.\n  if (usesDayOrRule(schedule)) return `, on ${domText} or on ${dowText}`\n\n  // Coverage decides whether a field is worth mentioning, and it is a separate question from the\n  // star: `*/10` carries the star flag but still restricts the month to four days.\n  if (dom.kind === \"all\" && dow.kind === \"all\") return \"\"\n  if (dom.kind === \"all\") return `, on ${dowText}`\n  if (dow.kind === \"all\") return `, on ${domText}`\n  return `, on ${domText} that also fall on ${dowText}`\n}\n\n/** True when both day fields are set and cron will therefore run on whichever of them matches. */\nexport function usesDayOrRule(schedule: CronSchedule) {\n  return !schedule.dayOfMonth.star && !schedule.dayOfWeek.star\n}\n\n/** Turn a parsed schedule into one English sentence. Timezone-free on purpose: the fields\n *  themselves carry no zone — only an actual run time does. */\nexport function describeCron(schedule: CronSchedule): string {\n  const month = shapeOf(schedule.month, false)\n  const monthText =\n    month.kind === \"all\"\n      ? \"\"\n      : `, in ${spanPhrase(month, (value) => MONTH_NAMES[value - 1], {\n          all: \"every month\",\n          one: \"\",\n          plural: \"\",\n          unit: \"month\",\n        }).trim()}`\n  return `${timePhrase(schedule)}${dayPhrase(schedule)}${monthText}.`\n}\n\ninterface Matcher {\n  minute: Set<number>\n  hour: Set<number>\n  dayOfMonth: Set<number>\n  month: Set<number>\n  dayOfWeek: Set<number>\n  orDays: boolean\n}\n\nconst toMatcher = (schedule: CronSchedule): Matcher => ({\n  minute: new Set(schedule.minute.values),\n  hour: new Set(schedule.hour.values),\n  dayOfMonth: new Set(schedule.dayOfMonth.values),\n  month: new Set(schedule.month.values),\n  dayOfWeek: new Set(schedule.dayOfWeek.values),\n  orDays: usesDayOrRule(schedule),\n})\n\nfunction dayMatches(matcher: Matcher, date: Date) {\n  const byDate = matcher.dayOfMonth.has(date.getUTCDate())\n  const byWeek = matcher.dayOfWeek.has(date.getUTCDay())\n  return matcher.orDays ? byDate || byWeek : byDate && byWeek\n}\n\nfunction toDate(value: Date | number | string): Date | null {\n  // One constructor covers all three: since ES2015 `new Date(aDate)` copies the instant, so\n  // there is nothing for an `instanceof` branch to decide.\n  const date = new Date(value)\n  return Number.isNaN(date.getTime()) ? null : date\n}\n\n/**\n * The next `count` instants the schedule fires, strictly after `from`, in UTC.\n *\n * Everything is computed in UTC, which is both the honest answer and the common one: GitHub\n * Actions, Vercel Cron and Cloudflare Triggers all schedule in UTC. A local-time answer would\n * need the zone's whole DST history to be right, and a schedule that reads 02:30 daily fires\n * twice on one day of the year and not at all on another.\n *\n * The search steps by the coarsest field that fails rather than minute by minute, so an\n * expression that only matches once every four years costs a few thousand comparisons instead of\n * two million. It gives up after `HORIZON_YEARS`, which is how an impossible date — February 30,\n * or February 29 in a month field that excludes leap years — returns fewer runs than asked for\n * rather than looping.\n */\nexport function nextCronRuns(\n  schedule: CronSchedule,\n  from: Date | number | string,\n  count = 3\n): Date[] {\n  const start = toDate(from)\n  const wanted = Math.floor(count)\n  if (!start) return []\n\n  const matcher = toMatcher(schedule)\n  const runs: Date[] = []\n  // Cron fires on minute boundaries, and a run at the very instant of `from` has already\n  // happened, so the search opens at the next whole minute.\n  let cursor = Math.floor(start.getTime() / MS_PER_MINUTE) * MS_PER_MINUTE + MS_PER_MINUTE\n  const deadline = Date.UTC(\n    start.getUTCFullYear() + HORIZON_YEARS,\n    start.getUTCMonth(),\n    start.getUTCDate(),\n    start.getUTCHours(),\n    start.getUTCMinutes()\n  )\n\n  while (runs.length < wanted && cursor <= deadline) {\n    const date = new Date(cursor)\n    const year = date.getUTCFullYear()\n    const monthIndex = date.getUTCMonth()\n    const day = date.getUTCDate()\n    const hour = date.getUTCHours()\n    const minute = date.getUTCMinutes()\n\n    // Each branch moves the cursor strictly forward to the start of the next candidate unit, so\n    // the loop always terminates; Date.UTC normalises the overflow at every level for us.\n    if (!matcher.month.has(monthIndex + 1)) {\n      cursor = Date.UTC(year, monthIndex + 1, 1)\n      continue\n    }\n    if (!dayMatches(matcher, date)) {\n      cursor = Date.UTC(year, monthIndex, day + 1)\n      continue\n    }\n    if (!matcher.hour.has(hour)) {\n      cursor = Date.UTC(year, monthIndex, day, hour + 1)\n      continue\n    }\n    if (!matcher.minute.has(minute)) {\n      cursor = Date.UTC(year, monthIndex, day, hour, minute + 1)\n      continue\n    }\n    runs.push(date)\n    cursor += MS_PER_MINUTE\n  }\n  return runs\n}\n\n/**\n * Fixed format rather than Intl: a locale-dependent string is a hydration mismatch waiting to\n * happen, because the server's default locale is not the visitor's. Callers who want a localised\n * run list can pass `formatRun`.\n */\nfunction formatUtc(date: Date) {\n  return `${DAY_ABBR[date.getUTCDay()]} ${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())} UTC`\n}\n\nexport interface CronExpressionProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** The expression: five fields, or a nickname such as `@daily`. */\n  value: string\n  /**\n   * Reference instant for the run list. Omit it and no runs are listed — nothing here reads the\n   * clock, so the same props always render the same markup. Pass a stable value (a timestamp\n   * fetched with the data, not `new Date()` inside a client component) to keep it that way.\n   */\n  from?: Date | number | string\n  /** How many upcoming runs to list when `from` is given (default 3). */\n  runs?: number\n  /** Show the expression itself above the sentence (default true). */\n  showExpression?: boolean\n  /** Heading above the run list (default \"Next runs (UTC)\"). */\n  runsLabel?: string\n  /** Format one run. The default is locale-independent: \"Wed 2026-08-12 09:00 UTC\". */\n  formatRun?: (date: Date) => string\n}\n\nexport const CronExpression = React.forwardRef<HTMLDivElement, CronExpressionProps>(\n  (\n    {\n      value,\n      from,\n      runs = 3,\n      showExpression = true,\n      runsLabel = \"Next runs (UTC)\",\n      formatRun = formatUtc,\n      className,\n      ...props\n    },\n    ref\n  ) => {\n    const parsed = parseCron(value)\n    const raw = String(value ?? \"\")\n      .trim()\n      .replace(/\\s+/g, \" \")\n    const expanded = parsed.ok && parsed.normalized !== raw ? parsed.normalized : null\n    const reference = from === undefined ? null : toDate(from)\n    const upcoming =\n      parsed.ok && reference ? nextCronRuns(parsed.schedule, reference, runs) : []\n    const wanted = Math.floor(runs)\n\n    return (\n      <div\n        ref={ref}\n        data-invalid={parsed.ok ? undefined : \"true\"}\n        className={cn(\"space-y-1.5 text-sm\", className)}\n        {...props}\n      >\n        {showExpression ? (\n          <p className=\"font-mono text-xs\">\n            <span className=\"sr-only\">Cron expression: </span>\n            <code className=\"rounded border bg-muted px-1.5 py-0.5 text-foreground\">\n              {raw || \"(empty)\"}\n            </code>\n            {expanded ? (\n              <span className=\"ml-1.5 text-muted-foreground\">= {expanded}</span>\n            ) : null}\n          </p>\n        ) : null}\n\n        {parsed.ok ? (\n          <p className=\"text-foreground\">{describeCron(parsed.schedule)}</p>\n        ) : (\n          // The word \"Invalid\" carries the state, not the colour: a red sentence and a black one\n          // are the same sentence to anyone who cannot tell them apart (WCAG 1.4.1).\n          <p className=\"text-destructive\">\n            <span className=\"font-medium\">Invalid cron expression:</span> {parsed.error}.\n          </p>\n        )}\n\n        {parsed.ok && usesDayOrRule(parsed.schedule) ? (\n          <p className=\"text-xs text-muted-foreground\">\n            Both day fields are set, so cron runs on whichever one matches — not only on days\n            that satisfy both.\n          </p>\n        ) : null}\n\n        {/* `runs={0}` means \"do not list runs\", which is not the same claim as \"there are none\". */}\n        {parsed.ok && from !== undefined && wanted > 0 ? (\n          <div className=\"pt-0.5\">\n            <p className=\"text-xs font-medium text-muted-foreground\">{runsLabel}</p>\n            {!reference ? (\n              <p className=\"mt-1 text-xs text-muted-foreground\">\n                Cannot list runs: the reference time is not a valid date.\n              </p>\n            ) : upcoming.length === 0 ? (\n              <p className=\"mt-1 text-xs text-muted-foreground\">\n                No run in the next {HORIZON_YEARS} years.\n              </p>\n            ) : (\n              <>\n                <ol className=\"mt-1 space-y-0.5 font-mono text-xs text-foreground\">\n                  {upcoming.map((run) => (\n                    <li key={run.getTime()}>\n                      <time dateTime={run.toISOString()}>{formatRun(run)}</time>\n                    </li>\n                  ))}\n                </ol>\n                {upcoming.length < wanted ? (\n                  <p className=\"mt-1 text-xs text-muted-foreground\">\n                    No further run in the next {HORIZON_YEARS} years.\n                  </p>\n                ) : null}\n              </>\n            )}\n          </div>\n        ) : null}\n      </div>\n    )\n  }\n)\nCronExpression.displayName = \"CronExpression\"\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}