Event Calendar

Upcoming Feed

Лента ближайших событий, сгруппированная по дням: заголовок дня липкий, «сегодня» и «завтра» названы словами, а не датой.

  • calendar
  • events
  • agenda
  • list

Превью

1440pxHost theme

Использовать с AI

  1. 1. Скопируйте ссылку.
  2. 2. Напишите агенту своими словами и вставьте её в предложение.
  3. 3. Агент откроет ссылку и поставит компонент из registry.

размести это в шапке: https://vibeui.ru/c/eventcalendar-004

Ближайшее

7 дней вперёд

Сегодня17 марта · 3

  1. 30 минПланёрка командыЗал 2
  2. 1 чОбед с подрядчикомКафе внизу
  3. 45 минРазбор метрикMeet

Завтра18 марта · 2

  1. 1 ч 30 минДизайн-ревью каталогаЗал 1
  2. срокСдать сметудедлайн

пятница20 марта · 2

  1. 1 чБассейн
  2. 2 чВыезд к клиентуПр. Мира, 12

понедельник23 марта · 1

  1. 30 минРетро спринтаЗал 2
Показать полную инструкцию

То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.

# Установка и размещение «eventcalendar-004» (Upcoming Feed) из VibeUI

## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/eventcalendar-004.json

Registry item: https://vibeui.ru/r/eventcalendar-004.json
Ставится в: components/vibeui/eventcalendar-004.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.

Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.

## 2. Что это
Лента ближайших событий, сгруппированная по дням: заголовок дня липкий, «сегодня» и «завтра» названы словами, а не датой.

Ближайшие события лентой: группировка по дням, липкие заголовки, длительность словами. Ноль зависимостей, один файл, клиентского JS нет.

## 3. Как использовать
import { Eventcalendar004 } from "@/components/vibeui/eventcalendar-004"

<Eventcalendar004
  today="2026-03-17"
  days={7}
  events={[{ at: "2026-03-18T11:00", minutes: 90, title: "Ревью" }]}
/>

Полный список пропсов — в установленном файле.

## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.

Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)

## 5. Сохранить как установлено
- локальную палитру --vibeui-eventcalendar-004-* и не подменять её токенами вашей темы
- липкий заголовок дня: при прокрутке иначе непонятно, к какому дню относится строка
- подписи «Сегодня» и «Завтра» словами — в эти два дня дату никто не читает
- моноширинные цифры времени в отдельной колонке: разной ширины цифры ломают вертикаль
- стиль левой границы как признак типа события, а не только её цвет
- собственную светлую подложку карточки: тёмный текст на тёмной витрине пропадает

## 6. Можно менять
- массив events и точку отсчёта через today
- глубину окна через days
- заголовок через heading и язык подписей через locale
- акцент через accent и максимальную высоту ленты

## 7. Правила
- Дни без событий не показываются: лента отвечает на вопрос «что дальше», а не «как выглядит неделя».
- Время берётся из строки as-is, без часовых поясов — переводите данные заранее.
- Слова «сегодня» и «завтра» зашиты по-русски: для другого языка их придётся вынести в пропсы.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.

## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.

Для разработчика

npx shadcn@latest add https://vibeui.ru/r/eventcalendar-004.json
https://vibeui.ru/r/eventcalendar-004.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра --vibeui-eventcalendar-004-*. Серверный: хуков нет. События фильтруются по окну days от today, сортируются по строке ISO и группируются в один проход. Заголовок дня липкий внутри прокручиваемой ленты с ограниченной высотой, поэтому карточка в сайдбаре не растёт вместе с числом встреч. Время стоит отдельной колонкой с моноширинными цифрами, тип события различается стилем левой границы, дедлайн дополнительно помечен словом.

Исходник компонента

Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.

import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Eventcalendar004Event = {
  /** Начало в виде «ГГГГ-ММ-ДДTЧЧ:ММ». */
  at: string
  /** Длительность в минутах. */
  minutes?: number
  title: string
  place?: string
  tone?: "work" | "personal" | "deadline"
}

export type Eventcalendar004Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children" | "title"
> & {
  today?: string
  events?: Eventcalendar004Event[]
  days?: number
  heading?: string
  locale?: string
  accent?: string
}

// Идея компонента: ближайшие события удобнее читать лентой, а не сеткой —
// в сетке половина клеток пустая. День становится липким заголовком, под ним
// идут события по возрастанию времени, и сколько бы их ни было, строка не
// ломается: время стоит отдельной колонкой с моноширинными цифрами.
// «Сегодня» и «Завтра» подписаны словами — дату в эти два дня никто не читает.
const STYLES = `
:where([data-vibeui-block="eventcalendar-004"]){
--vibeui-eventcalendar-004-bg:oklch(1 0 0);
--vibeui-eventcalendar-004-fg:oklch(0.23 0.014 265);
--vibeui-eventcalendar-004-muted:oklch(0.6 0.014 265);
--vibeui-eventcalendar-004-border:oklch(0.91 0.006 265);
--vibeui-eventcalendar-004-line:oklch(0.96 0.004 265);
--vibeui-eventcalendar-004-accent:oklch(0.55 0.16 262);
--vibeui-eventcalendar-004-personal:oklch(0.6 0.13 165);
--vibeui-eventcalendar-004-deadline:oklch(0.58 0.19 25);
--vibeui-eventcalendar-004-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="eventcalendar-004"]{
width:100%;max-width:30rem;box-sizing:border-box;
padding:1rem 1rem 0.5rem;
background:var(--vibeui-eventcalendar-004-bg);
border:1px solid var(--vibeui-eventcalendar-004-border);border-radius:1rem;
color:var(--vibeui-eventcalendar-004-fg);
font-family:var(--vibeui-eventcalendar-004-font);
}
[data-vibeui-block="eventcalendar-004"] *{box-sizing:border-box}
[data-vibeui-block="eventcalendar-004"] ol,
[data-vibeui-block="eventcalendar-004"] ul{margin:0;padding:0;list-style:none}
[data-vibeui-block="eventcalendar-004"] [data-part="head"]{
display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;
gap:0.5rem;margin:0 0 0.5rem;
}
[data-vibeui-block="eventcalendar-004"] [data-part="heading"]{
margin:0;font-size:0.9375rem;font-weight:700;letter-spacing:-0.01em;
}
[data-vibeui-block="eventcalendar-004"] [data-part="hint"]{
margin:0;font-size:0.75rem;color:var(--vibeui-eventcalendar-004-muted);
}
/* Лента прокручивается сама: карточка в сайдбаре не должна расти вместе
   с числом встреч. */
[data-vibeui-block="eventcalendar-004"] [data-part="feed"]{
max-height:22rem;overflow-y:auto;
}
[data-vibeui-block="eventcalendar-004"] [data-part="feed"]:focus-visible{
outline:2px solid var(--vibeui-eventcalendar-004-accent);outline-offset:2px;
border-radius:0.5rem;
}
/* Заголовок дня липкий: при прокрутке видно, к какому дню относится строка. */
[data-vibeui-block="eventcalendar-004"] [data-part="dayhead"]{
position:sticky;top:0;z-index:1;
display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;
margin:0;padding:0.375rem 0;
background:var(--vibeui-eventcalendar-004-bg);
border-bottom:1px solid var(--vibeui-eventcalendar-004-border);
font-size:0.75rem;font-weight:700;
}
/* Заглавная только первая буква: capitalize поднимает и «марта» в «19 марта». */
[data-vibeui-block="eventcalendar-004"] [data-part="dayhead"]::first-letter{text-transform:uppercase}
[data-vibeui-block="eventcalendar-004"] [data-part="dayhead"] span{
font-weight:500;text-transform:none;font-variant-numeric:tabular-nums;
color:var(--vibeui-eventcalendar-004-muted);
}
[data-vibeui-block="eventcalendar-004"] [data-part="dayhead"][data-today="true"]{
color:var(--vibeui-eventcalendar-004-accent);
}
[data-vibeui-block="eventcalendar-004"] [data-part="row"]{
display:grid;grid-template-columns:3.25rem minmax(0,1fr);
gap:0.625rem;padding:0.5rem 0;
border-bottom:1px solid var(--vibeui-eventcalendar-004-line);
}
[data-vibeui-block="eventcalendar-004"] [data-part="when"]{
display:flex;flex-direction:column;gap:0.0625rem;
padding-left:0.5rem;
border-left:3px solid var(--vibeui-eventcalendar-004-accent);
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="eventcalendar-004"] [data-part="when"] time{
font-size:0.8125rem;font-weight:650;line-height:1.1;
}
[data-vibeui-block="eventcalendar-004"] [data-part="when"] small{
font-size:0.5625rem;color:var(--vibeui-eventcalendar-004-muted);
}
[data-vibeui-block="eventcalendar-004"] [data-tone="personal"] [data-part="when"]{
border-left-color:var(--vibeui-eventcalendar-004-personal);
}
[data-vibeui-block="eventcalendar-004"] [data-tone="deadline"] [data-part="when"]{
border-left-color:var(--vibeui-eventcalendar-004-deadline);border-left-style:double;
border-left-width:4px;
}
[data-vibeui-block="eventcalendar-004"] [data-part="what"]{
display:flex;flex-direction:column;gap:0.125rem;min-width:0;
}
[data-vibeui-block="eventcalendar-004"] [data-part="what"] b{
font-size:0.8125rem;font-weight:600;line-height:1.3;
}
[data-vibeui-block="eventcalendar-004"] [data-part="what"] span{
font-size:0.6875rem;color:var(--vibeui-eventcalendar-004-muted);
}
[data-vibeui-block="eventcalendar-004"] [data-part="badge"]{
align-self:flex-start;margin-top:0.125rem;
padding:0.0625rem 0.3125rem;border-radius:0.25rem;
border:1px solid var(--vibeui-eventcalendar-004-deadline);
color:var(--vibeui-eventcalendar-004-deadline);
font-size:0.5625rem;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;
}
[data-vibeui-block="eventcalendar-004"] [data-part="empty"]{
padding:0.75rem 0;font-size:0.75rem;color:var(--vibeui-eventcalendar-004-muted);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="eventcalendar-004"] *{animation:none!important;transition:none!important}}
`

const DAY = 86400000

const DEFAULT_EVENTS: Eventcalendar004Event[] = [
  {
    at: "2026-03-17T09:30",
    minutes: 30,
    title: "Планёрка команды",
    place: "Зал 2",
  },
  {
    at: "2026-03-17T12:00",
    minutes: 60,
    title: "Обед с подрядчиком",
    place: "Кафе внизу",
    tone: "personal",
  },
  {
    at: "2026-03-17T16:00",
    minutes: 45,
    title: "Разбор метрик",
    place: "Meet",
  },
  {
    at: "2026-03-18T11:00",
    minutes: 90,
    title: "Дизайн-ревью каталога",
    place: "Зал 1",
  },
  {
    at: "2026-03-18T18:00",
    title: "Сдать смету",
    tone: "deadline",
  },
  {
    at: "2026-03-20T08:00",
    minutes: 60,
    title: "Бассейн",
    tone: "personal",
  },
  {
    at: "2026-03-20T14:00",
    minutes: 120,
    title: "Выезд к клиенту",
    place: "Пр. Мира, 12",
  },
  {
    at: "2026-03-23T10:00",
    minutes: 30,
    title: "Ретро спринта",
    place: "Зал 2",
  },
]

function duration(value: number) {
  const hours = Math.floor(value / 60)
  const rest = value % 60

  if (hours === 0) {
    return `${rest} мин`
  }

  return rest === 0 ? `${hours} ч` : `${hours} ч ${rest} мин`
}

/**
 * Лента ближайших событий, сгруппированная по дням: заголовок дня липкий,
 * «сегодня» и «завтра» названы словами. Один файл, ноль зависимостей.
 */
export function Eventcalendar004({
  today = "2026-03-17",
  events = DEFAULT_EVENTS,
  days = 7,
  heading = "Ближайшее",
  locale = "ru-RU",
  accent,
  className,
  style,
  ...props
}: Eventcalendar004Props) {
  const from = new Date(`${today}T00:00:00Z`).getTime()
  const to = from + days * DAY

  const upcoming = events
    .filter((event) => {
      const stamp = new Date(`${event.at}:00Z`).getTime()
      return stamp >= from && stamp < to
    })
    .sort((left, right) => left.at.localeCompare(right.at))

  const groups: { key: string; offset: number; items: typeof upcoming }[] = []

  for (const event of upcoming) {
    const key = event.at.slice(0, 10)
    const last = groups.at(-1)

    if (last?.key === key) {
      last.items.push(event)
      continue
    }

    groups.push({
      key,
      offset: Math.round((new Date(`${key}T00:00:00Z`).getTime() - from) / DAY),
      items: [event],
    })
  }

  const dayName = (key: string, offset: number) => {
    if (offset === 0) {
      return "Сегодня"
    }

    if (offset === 1) {
      return "Завтра"
    }

    return new Intl.DateTimeFormat(locale, {
      weekday: "long",
      timeZone: "UTC",
    }).format(new Date(`${key}T00:00:00Z`))
  }

  const dayDate = (key: string) =>
    new Intl.DateTimeFormat(locale, {
      day: "numeric",
      month: "long",
      timeZone: "UTC",
    }).format(new Date(`${key}T00:00:00Z`))

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

  return (
    <>
      <style href="vibeui-eventcalendar-004" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="eventcalendar-004"
        aria-label={heading}
        className={className}
        style={palette}
      >
        <header data-part="head">
          <h3 data-part="heading">{heading}</h3>
          <p data-part="hint">{days} дней вперёд</p>
        </header>

        <div
          data-part="feed"
          tabIndex={0}
          role="group"
          aria-label={`${heading}: список событий`}
        >
          {groups.length === 0 ? (
            <p data-part="empty">На ближайшие дни ничего не запланировано.</p>
          ) : null}

          {groups.map((group) => (
            <section key={group.key} aria-label={dayDate(group.key)}>
              <h4 data-part="dayhead" data-today={String(group.offset === 0)}>
                {dayName(group.key, group.offset)}
                <span>
                  {dayDate(group.key)} · {group.items.length}
                </span>
              </h4>

              <ol>
                {group.items.map((event) => (
                  <li
                    key={event.at + event.title}
                    data-part="row"
                    data-tone={event.tone ?? "work"}
                  >
                    <span data-part="when">
                      <time dateTime={event.at}>{event.at.slice(11, 16)}</time>
                      {event.minutes ? (
                        <small>{duration(event.minutes)}</small>
                      ) : (
                        <small>срок</small>
                      )}
                    </span>

                    <span data-part="what">
                      <b>{event.title}</b>
                      {event.place ? <span>{event.place}</span> : null}
                      {event.tone === "deadline" ? (
                        <span data-part="badge">дедлайн</span>
                      ) : null}
                    </span>
                  </li>
                ))}
              </ol>
            </section>
          ))}
        </div>
      </section>
    </>
  )
}