Calendar

Repeat Rule

A recurrence rule that is immediately translated into a sentence and into a list of upcoming dates, with the assembled RRULE string shown underneath.

  • calendar
  • recurrence
  • rrule
  • form

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/calendar-026?lang=en

Повтор события

Каждые 2 недели по дням: вторник, четверг

  • вт, 14 апр.
  • чт, 16 апр.
  • вт, 28 апр.

RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH

Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "calendar-026" (Repeat Rule) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/calendar-026.json

Registry item: https://vibeui.ru/r/calendar-026.json
Installs to: components/vibeui/calendar-026.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A recurrence rule that is immediately translated into a sentence and into a list of upcoming dates, with the assembled RRULE string shown underneath.

Recurrence setup for an event: interval, unit (days, weeks, months) and weekdays. The form does not leave a person alone with the abstraction — below it sits a sentence (“every 2 weeks on: Tuesday, Thursday”) and the next three dates, which is where an interval mistake shows. At the bottom, the assembled RRULE string ready to travel to a calendar. Zero dependencies, one file, its own palette.

## 3. How to use it
import { Calendar026 } from "@/components/vibeui/calendar-026"

<Calendar026 defaultFrequency="weekly" defaultInterval={2} occurrences={3} />

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-calendar-026-* palette — do not swap it for your theme tokens (bg-accent, text-muted-foreground and the like)
- the trio “form — sentence — upcoming dates”: a recurrence rule can only be verified against dates, the form does not explain itself
- the pure ruleOf function called from the handlers: assembling the string from state would make onChange report the previous value
- the day-by-day walk with a week-index check for weekly — shifting by 7×interval drops the second and third selected day
- the sentence with aria-live="polite": a rule change is otherwise never announced
- pluralisation through pluralize: “every 2 weeks”, not “every 2 week”
- showing the weekday buttons only for weekly: for a monthly rule they are meaningless and confusing

## 6. You may change
- label — the heading above the form
- startDate — the date of the first event; repeats are counted from it
- defaultFrequency — the recurrence unit at first render
- defaultInterval and defaultWeekdays — the initial interval and weekdays
- occurrences — how many upcoming dates to show
- locale — the locale of weekday names; onChange — the RRULE handler; accent — the colour of an enabled day

## 7. Rules
- The RRULE here is simplified: no UNTIL, COUNT or BYSETPOS — add them if the calendar on the other side expects them.
- A monthly rule on the 31st slides in a short month: JavaScript rolls the date into the next month by itself, and that needs handling.
- Do not show the sentence without the dates: “every two weeks” sounds correct even when the start date is wrong.
- Do not count repeats from Date.now(): the start arrives as a prop, otherwise server and client renders diverge.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/calendar-026.json
https://vibeui.ru/r/calendar-026.json

Компонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-calendar-026-*. Клиентский: частота, интервал и дни недели живут в useState. Строка правила собирается чистой функцией ruleOf, поэтому onChange всегда получает актуальное значение, а не состояние прошлого рендера. Ближайшие даты считаются в useMemo тремя ветками: для daily и monthly — сдвигом даты, для weekly — перебором дней с проверкой номера недели от старта, поэтому «каждые 2 недели» действительно пропускает неделю. Фраза словами строится отдельно и склоняется через pluralize. Названия дней берутся из Intl.DateTimeFormat, кнопки дней показываются только для weekly.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useId, useMemo, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Calendar026Frequency = "daily" | "weekly" | "monthly"

export type Calendar026Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children" | "onChange"
> & {
  label?: string
  startDate?: string
  defaultFrequency?: Calendar026Frequency
  defaultInterval?: number
  /** Дни недели правила, где 0 — понедельник. */
  defaultWeekdays?: number[]
  occurrences?: number
  locale?: string
  onChange?: (rule: string) => void
  accent?: string
}

// Идея компонента: правило повтора нельзя проверить по форме — его проверяют
// по фразе и по ближайшим датам. Поэтому форма собирает RRULE, а под ней
// живут две вещи: человеческая формулировка и список следующих повторов.
// Ошибку «каждые 2 недели» вместо «каждую неделю» видно сразу по датам.
const STYLES = `
:where([data-vibeui-block="calendar-026"]){
--vibeui-calendar-026-bg:oklch(1 0 0);
--vibeui-calendar-026-fg:oklch(0.23 0.014 300);
--vibeui-calendar-026-muted:oklch(0.56 0.014 300);
--vibeui-calendar-026-border:oklch(0.91 0.008 300);
--vibeui-calendar-026-field:oklch(0.985 0.004 300);
--vibeui-calendar-026-soft:oklch(0.96 0.02 300);
--vibeui-calendar-026-accent:oklch(0.5 0.14 300);
--vibeui-calendar-026-radius:0.625rem;
--vibeui-calendar-026-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--vibeui-calendar-026-mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
}
[data-vibeui-block="calendar-026"]{
display:flex;flex-direction:column;gap:0.7rem;
width:100%;max-width:24rem;box-sizing:border-box;padding:1rem;
background:var(--vibeui-calendar-026-bg);
border:1px solid var(--vibeui-calendar-026-border);
border-radius:calc(var(--vibeui-calendar-026-radius) + 0.3rem);
color:var(--vibeui-calendar-026-fg);
font-family:var(--vibeui-calendar-026-font);
}
[data-vibeui-block="calendar-026"] [data-part="title"]{
margin:0;font-size:0.95rem;font-weight:700;letter-spacing:-0.01em;
}
[data-vibeui-block="calendar-026"] [data-part="row"]{
display:grid;grid-template-columns:4.5rem 1fr;gap:0.4rem;
}
[data-vibeui-block="calendar-026"] [data-part="cell"]{display:flex;flex-direction:column;gap:0.2rem;min-width:0}
[data-vibeui-block="calendar-026"] [data-part="cell"] label{
font-size:0.7rem;font-weight:600;letter-spacing:0.03em;text-transform:uppercase;
color:var(--vibeui-calendar-026-muted);
}
[data-vibeui-block="calendar-026"] input,
[data-vibeui-block="calendar-026"] select{
box-sizing:border-box;width:100%;height:2.35rem;padding:0 0.5rem;
border:1px solid var(--vibeui-calendar-026-border);
border-radius:var(--vibeui-calendar-026-radius);
background:var(--vibeui-calendar-026-field);
color:inherit;font:inherit;font-size:0.875rem;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-026"] input:focus-visible,
[data-vibeui-block="calendar-026"] select:focus-visible{
outline:2px solid var(--vibeui-calendar-026-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="calendar-026"] [data-part="days"]{display:flex;gap:0.25rem;flex-wrap:wrap}
[data-vibeui-block="calendar-026"] [data-part="day"]{
appearance:none;cursor:pointer;font:inherit;
width:2rem;height:2rem;border-radius:50%;
border:1px solid var(--vibeui-calendar-026-border);
background:var(--vibeui-calendar-026-field);
color:var(--vibeui-calendar-026-muted);
font-size:0.72rem;font-weight:700;text-transform:capitalize;
transition:background-color .16s ease,color .16s ease;
}
[data-vibeui-block="calendar-026"] [data-part="day"][aria-pressed="true"]{
background:var(--vibeui-calendar-026-accent);border-color:transparent;
color:var(--vibeui-calendar-026-bg);
}
[data-vibeui-block="calendar-026"] [data-part="day"]:focus-visible{
outline:2px solid var(--vibeui-calendar-026-accent);outline-offset:2px;
}
[data-vibeui-block="calendar-026"] [data-part="phrase"]{
margin:0;padding:0.55rem 0.7rem;border-radius:var(--vibeui-calendar-026-radius);
background:var(--vibeui-calendar-026-soft);
font-size:0.875rem;font-weight:600;line-height:1.35;
}
[data-vibeui-block="calendar-026"] [data-part="next"]{
margin:0;padding:0;list-style:none;display:flex;flex-wrap:wrap;gap:0.3rem;
}
[data-vibeui-block="calendar-026"] [data-part="next"] li{
padding:0.2rem 0.5rem;border-radius:999px;
border:1px dashed var(--vibeui-calendar-026-border);
font-size:0.75rem;color:var(--vibeui-calendar-026-muted);
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-026"] [data-part="rrule"]{
margin:0;padding-top:0.6rem;border-top:1px solid var(--vibeui-calendar-026-border);
font-family:var(--vibeui-calendar-026-mono);font-size:0.72rem;
color:var(--vibeui-calendar-026-muted);word-break:break-all;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-026"] *{animation:none!important;transition:none!important}}
`

const BYDAY = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]

/** Строка RRULE из состояния формы: её же отдаёт onChange. */
function ruleOf(
  frequency: Calendar026Frequency,
  interval: number,
  weekdays: number[],
  monthDay: number,
) {
  const parts = [`FREQ=${frequency.toUpperCase()}`, `INTERVAL=${interval}`]

  if (frequency === "weekly" && weekdays.length) {
    parts.push(`BYDAY=${weekdays.map((day) => BYDAY[day]).join(",")}`)
  }

  if (frequency === "monthly") {
    parts.push(`BYMONTHDAY=${monthDay}`)
  }

  return parts.join(";")
}

function pluralize(count: number, forms: [string, string, string]) {
  const tens = count % 100
  const ones = count % 10

  if (tens > 10 && tens < 20) return forms[2]
  if (ones === 1) return forms[0]
  if (ones > 1 && ones < 5) return forms[1]

  return forms[2]
}

/**
 * Правило повтора: форма, фраза словами, ближайшие даты и строка RRULE.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Calendar026({
  label = "Повтор события",
  startDate = "2026-04-14",
  defaultFrequency = "weekly",
  defaultInterval = 2,
  defaultWeekdays = [1, 3],
  occurrences = 3,
  locale = "ru-RU",
  onChange,
  accent,
  className,
  style,
  ...props
}: Calendar026Props) {
  const id = useId()
  const [frequency, setFrequency] =
    useState<Calendar026Frequency>(defaultFrequency)
  const [interval, setInterval] = useState(defaultInterval)
  const [weekdays, setWeekdays] = useState(defaultWeekdays)

  const names = useMemo(() => {
    const short = new Intl.DateTimeFormat(locale, { weekday: "short" })
    const long = new Intl.DateTimeFormat(locale, { weekday: "long" })

    return Array.from({ length: 7 }, (_, index) => ({
      short: short.format(new Date(2024, 0, 1 + index)),
      long: long.format(new Date(2024, 0, 1 + index)),
    }))
  }, [locale])

  const start = new Date(`${startDate}T00:00:00`)

  const dates = useMemo(() => {
    const result: Date[] = []
    const cursor = new Date(start)

    if (frequency === "monthly") {
      for (let step = 0; step < occurrences; step += 1) {
        const date = new Date(start)
        date.setMonth(start.getMonth() + step * interval)
        result.push(date)
      }

      return result
    }

    if (frequency === "daily") {
      for (let step = 0; step < occurrences; step += 1) {
        const date = new Date(start)
        date.setDate(start.getDate() + step * interval)
        result.push(date)
      }

      return result
    }

    const wanted = weekdays.length ? weekdays : [(start.getDay() + 6) % 7]

    for (let step = 0; step < 400 && result.length < occurrences; step += 1) {
      const weekday = (cursor.getDay() + 6) % 7
      const weekIndex = Math.floor(
        (cursor.getTime() -
          start.getTime() +
          ((start.getDay() + 6) % 7) * 86400000) /
          (7 * 86400000),
      )

      if (wanted.includes(weekday) && weekIndex % interval === 0) {
        result.push(new Date(cursor))
      }

      cursor.setDate(cursor.getDate() + 1)
    }

    return result
  }, [frequency, interval, weekdays, occurrences, startDate])

  const rule = ruleOf(frequency, interval, weekdays, start.getDate())

  const phrase = useMemo(() => {
    if (frequency === "daily") {
      return interval === 1
        ? "Каждый день"
        : `Каждые ${interval} ${pluralize(interval, ["день", "дня", "дней"])}`
    }

    if (frequency === "monthly") {
      const head =
        interval === 1
          ? "Каждый месяц"
          : `Каждые ${interval} ${pluralize(interval, ["месяц", "месяца", "месяцев"])}`

      return `${head}, ${start.getDate()}-го числа`
    }

    const head =
      interval === 1
        ? "Каждую неделю"
        : `Каждые ${interval} ${pluralize(interval, ["неделю", "недели", "недель"])}`

    if (!weekdays.length) {
      return `${head} — день недели не выбран`
    }

    const list = weekdays.map((day) => names[day].long.toLowerCase()).join(", ")

    return `${head} по дням: ${list}`
  }, [frequency, interval, weekdays, names, startDate])

  const toggle = (index: number) => {
    const next = weekdays.includes(index)
      ? weekdays.filter((day) => day !== index)
      : [...weekdays, index].sort((left, right) => left - right)

    setWeekdays(next)
    onChange?.(ruleOf(frequency, interval, next, start.getDate()))
  }

  const stamp = new Intl.DateTimeFormat(locale, {
    day: "numeric",
    month: "short",
    weekday: "short",
  })

  const palette = {
    ...(accent ? { "--vibeui-calendar-026-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-calendar-026" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="calendar-026"
        className={className}
        style={palette}
      >
        <h3 data-part="title">{label}</h3>
        <div data-part="row">
          <div data-part="cell">
            <label htmlFor={`${id}-interval`}>Каждые</label>
            <input
              id={`${id}-interval`}
              inputMode="numeric"
              value={interval}
              onChange={(event) => {
                const next = Math.min(
                  30,
                  Math.max(
                    1,
                    Number(event.target.value.replace(/\D/g, "")) || 1,
                  ),
                )
                setInterval(next)
                onChange?.(ruleOf(frequency, next, weekdays, start.getDate()))
              }}
            />
          </div>
          <div data-part="cell">
            <label htmlFor={`${id}-freq`}>Единица</label>
            <select
              id={`${id}-freq`}
              value={frequency}
              onChange={(event) => {
                const next = event.target.value as Calendar026Frequency
                setFrequency(next)
                onChange?.(ruleOf(next, interval, weekdays, start.getDate()))
              }}
            >
              <option value="daily">дней</option>
              <option value="weekly">недель</option>
              <option value="monthly">месяцев</option>
            </select>
          </div>
        </div>
        {frequency === "weekly" ? (
          <div data-part="days" role="group" aria-label="Дни недели">
            {names.map((name, index) => (
              <button
                key={name.long}
                type="button"
                data-part="day"
                aria-pressed={weekdays.includes(index)}
                aria-label={name.long}
                onClick={() => toggle(index)}
              >
                <span aria-hidden="true">{name.short.slice(0, 2)}</span>
              </button>
            ))}
          </div>
        ) : null}
        <p data-part="phrase" aria-live="polite">
          {phrase}
        </p>
        <ul data-part="next" aria-label="Ближайшие повторы">
          {dates.map((date) => (
            <li key={date.getTime()}>{stamp.format(date)}</li>
          ))}
        </ul>
        <p data-part="rrule">RRULE:{rule}</p>
      </section>
    </>
  )
}