Calendar

ISO Week Grid

Месячная сетка с колонкой номеров недель по ISO 8601: выбирается не день, а строка целиком, и подпись снизу переводит «неделя 16» в конкретные даты.

  • calendar
  • week
  • iso-8601
  • planning

Превью

1440pxHost theme

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

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

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

апрель 2026 г.

Номера недель по ISO 8601

недпнвтсрчтптсбвс
303112345
6789101112
13141516171819
20212223242526
27282930123

Неделя 16: 13 апр. 19 апр.

Показать полную инструкцию

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

# Установка и размещение «calendar-021» (ISO Week Grid) из VibeUI

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

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

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

## 2. Что это
Месячная сетка с колонкой номеров недель по ISO 8601: выбирается не день, а строка целиком, и подпись снизу переводит «неделя 16» в конкретные даты.

Календарь месяца, в котором единица выбора — неделя, а не день. Слева колонка номеров недель по ISO 8601, каждый номер — кнопка, выбирающая всю строку; подпись снизу показывает границы выбранной недели датами. Нужен там, где планируют производство, отгрузки и спринты: там говорят «на 16-й неделе», а не «с 13 по 19 апреля». Ноль зависимостей, один файл, своя палитра.

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

<Calendar021 month="2026-04" defaultWeek={16} />

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

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

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

## 5. Сохранить как установлено
- локальную палитру --vibeui-calendar-021-* и не подменять её токенами вашей темы (bg-card, text-muted-foreground и подобными)
- функцию isoWeek целиком: номер недели по ISO определяется её четвергом, наивное деление дней на семь даёт другой ответ в начале и конце года
- расчёт в UTC внутри isoWeek — в локальном времени ночь перевода часов даёт 23- и 25-часовые сутки и сбивает счёт
- таблицу с <th scope="row"> для номера недели: связь номера и семи ячеек строки объявляется разметкой, а не aria-описаниями
- aria-label у кнопки недели с границами дат — иначе скринридер прочитает голое число
- старт недели с понедельника ((getDay()+6)%7): ISO-неделя начинается в понедельник, воскресный старт сделает номера неверными
- подпись снизу с aria-live="polite": выбор недели меняет только цвет строки, без объявления это изменение не слышно

## 6. Можно менять
- month — показываемый месяц строкой YYYY-MM
- locale — локаль подписей месяца и дней недели
- today — опорная дата для обводки «сегодня»
- defaultWeek — номер недели, выбранной при первом рендере
- onSelect — обработчик, получает номер недели и обе границы в формате YYYY-MM-DD
- accent — цвет выделения; всё остальное красится от него

## 7. Правила
- Не считайте номер недели как Math.ceil(день/7): у ISO своя разметка года, и 1 января часто относится к последней неделе предыдущего года.
- В США и Израиле неделя начинается с воскресенья — там нужен не этот компонент, а сетка с другим стартом и без ISO-номеров.
- Не превращайте номер недели в подпись без кнопки: смысл варианта в том, что выбирается интервал, а не день.
- onSelect отдаёт даты строкой без часового пояса — не прогоняйте их через new Date(...).toISOString(), это сдвинет день.

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

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

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

Компонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-calendar-021-*. Клиентский: номер выбранной недели живёт в useState. Разметка — обычная <table>, где заголовок строки <th scope="row"> содержит кнопку с номером недели, поэтому связь «неделя → дни» читается скринридером без ARIA-костылей. Номер считает isoWeek: дата сдвигается на четверг своей недели и сравнивается с началом года в UTC, поэтому переход на летнее время и январские недели-перебежчики не ломают счёт. Сетка строится от понедельника ((getDay()+6)%7), пустые ведущие недели отбрасываются фильтром. Подсветка выбранной строки повешена на tr[data-picked="true"], скругления краёв — на td:first-of-type и td:last-child.

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

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

"use client"

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

export type Calendar021Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children" | "onSelect"
> & {
  /** Месяц строкой YYYY-MM: сервер и клиент считают сетку одинаково. */
  month?: string
  today?: string
  locale?: string
  defaultWeek?: number
  onSelect?: (week: number, start: string, end: string) => void
  accent?: string
}

// Идея компонента: в производстве, логистике и отчётности живут не даты,
// а номера недель по ISO 8601. Поэтому здесь неделя — это строка таблицы
// с заголовком-кнопкой: выбирают не день, а весь семидневный интервал,
// и подпись снизу переводит «неделя 15» в понятные человеку числа.
const STYLES = `
:where([data-vibeui-block="calendar-021"]){
--vibeui-calendar-021-bg:oklch(1 0 0);
--vibeui-calendar-021-fg:oklch(0.24 0.014 255);
--vibeui-calendar-021-muted:oklch(0.63 0.014 255);
--vibeui-calendar-021-faint:oklch(0.8 0.012 255);
--vibeui-calendar-021-border:oklch(0.91 0.006 255);
--vibeui-calendar-021-soft:oklch(0.965 0.006 255);
--vibeui-calendar-021-accent:oklch(0.52 0.14 255);
--vibeui-calendar-021-accentsoft:oklch(0.94 0.04 255);
--vibeui-calendar-021-radius:0.75rem;
--vibeui-calendar-021-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="calendar-021"]{
display:flex;flex-direction:column;gap:0.75rem;
width:100%;max-width:23rem;box-sizing:border-box;padding:1rem;
background:var(--vibeui-calendar-021-bg);
border:1px solid var(--vibeui-calendar-021-border);
border-radius:calc(var(--vibeui-calendar-021-radius) + 0.25rem);
color:var(--vibeui-calendar-021-fg);
font-family:var(--vibeui-calendar-021-font);
}
[data-vibeui-block="calendar-021"] [data-part="title"]{
margin:0;font-size:0.95rem;font-weight:700;letter-spacing:-0.01em;text-transform:capitalize;
}
[data-vibeui-block="calendar-021"] [data-part="hint"]{
margin:0.15rem 0 0;font-size:0.75rem;color:var(--vibeui-calendar-021-muted);
}
[data-vibeui-block="calendar-021"] table{
width:100%;border-collapse:collapse;table-layout:fixed;
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-021"] th,
[data-vibeui-block="calendar-021"] td{padding:0;text-align:center}
[data-vibeui-block="calendar-021"] thead th{
padding-bottom:0.35rem;font-size:0.7rem;font-weight:600;
color:var(--vibeui-calendar-021-muted);text-transform:uppercase;letter-spacing:0.04em;
}
[data-vibeui-block="calendar-021"] [data-part="weekcol"]{width:2.4rem}
[data-vibeui-block="calendar-021"] [data-part="weekbtn"]{
appearance:none;cursor:pointer;font:inherit;
width:2.1rem;height:2.1rem;border-radius:0.5rem;
border:1px dashed var(--vibeui-calendar-021-border);
background:var(--vibeui-calendar-021-soft);
color:var(--vibeui-calendar-021-muted);
font-size:0.75rem;font-weight:700;
transition:background-color .16s ease,color .16s ease,border-color .16s ease;
}
[data-vibeui-block="calendar-021"] [data-part="weekbtn"]:hover{
color:var(--vibeui-calendar-021-fg);border-color:var(--vibeui-calendar-021-accent);
}
[data-vibeui-block="calendar-021"] [data-part="weekbtn"]:focus-visible{
outline:2px solid var(--vibeui-calendar-021-accent);outline-offset:2px;
}
[data-vibeui-block="calendar-021"] [data-part="weekbtn"][aria-pressed="true"]{
background:var(--vibeui-calendar-021-accent);border-color:transparent;
color:var(--vibeui-calendar-021-bg);
}
[data-vibeui-block="calendar-021"] [data-part="day"]{
display:flex;align-items:center;justify-content:center;
height:2.1rem;font-size:0.8125rem;
}
[data-vibeui-block="calendar-021"] tbody tr[data-picked="true"] [data-part="day"]{
background:var(--vibeui-calendar-021-accentsoft);
}
[data-vibeui-block="calendar-021"] tbody tr[data-picked="true"] td:first-of-type [data-part="day"]{
border-radius:0.5rem 0 0 0.5rem;
}
[data-vibeui-block="calendar-021"] tbody tr[data-picked="true"] td:last-child [data-part="day"]{
border-radius:0 0.5rem 0.5rem 0;
}
[data-vibeui-block="calendar-021"] [data-part="day"][data-outside="true"]{color:var(--vibeui-calendar-021-faint)}
[data-vibeui-block="calendar-021"] [data-part="day"][data-today="true"]{
font-weight:700;box-shadow:inset 0 0 0 1px var(--vibeui-calendar-021-accent);border-radius:0.5rem;
}
[data-vibeui-block="calendar-021"] [data-part="foot"]{
margin:0;padding-top:0.6rem;border-top:1px solid var(--vibeui-calendar-021-border);
font-size:0.8125rem;color:var(--vibeui-calendar-021-muted);
}
[data-vibeui-block="calendar-021"] [data-part="foot"] b{color:var(--vibeui-calendar-021-fg)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-021"] *{animation:none!important;transition:none!important}}
`

/**
 * Номер недели по ISO 8601: неделя принадлежит тому году, в котором лежит
 * её четверг. Считаем в UTC, чтобы переход на летнее время не сдвинул сутки.
 */
function isoWeek(date: Date) {
  const point = new Date(
    Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
  )
  const weekday = point.getUTCDay() || 7

  point.setUTCDate(point.getUTCDate() + 4 - weekday)

  const yearStart = new Date(Date.UTC(point.getUTCFullYear(), 0, 1))

  return Math.ceil(((point.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
}

function stamp(date: Date) {
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, "0")
  const day = String(date.getDate()).padStart(2, "0")

  return `${year}-${month}-${day}`
}

/**
 * Месячная сетка с колонкой номеров ISO-недель: выбирается неделя целиком.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Calendar021({
  month = "2026-04",
  today = "2026-04-15",
  locale = "ru-RU",
  defaultWeek = 16,
  onSelect,
  accent,
  className,
  style,
  ...props
}: Calendar021Props) {
  const [picked, setPicked] = useState(defaultWeek)

  const rows = useMemo(() => {
    const [year, index] = month.split("-").map(Number)
    const first = new Date(year, index - 1, 1)
    const offset = (first.getDay() + 6) % 7

    return Array.from({ length: 6 }, (_, week) =>
      Array.from(
        { length: 7 },
        (_, day) => new Date(year, index - 1, 1 - offset + week * 7 + day),
      ),
    )
      .filter((days) => days.some((date) => date.getMonth() === index - 1))
      .map((days) => ({ days, number: isoWeek(days[3]) }))
  }, [month])

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

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

  const heading = new Intl.DateTimeFormat(locale, {
    month: "long",
    year: "numeric",
  }).format(new Date(`${month}-01T00:00:00`))

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

  const current = rows.find((row) => row.number === picked)

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

  return (
    <>
      <style href="vibeui-calendar-021" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="calendar-021"
        className={className}
        style={palette}
      >
        <header>
          <h3 data-part="title">{heading}</h3>
          <p data-part="hint">Номера недель по ISO 8601</p>
        </header>
        <table>
          <thead>
            <tr>
              <th data-part="weekcol" scope="col">
                нед
              </th>
              {weekdays.map((name) => (
                <th key={name} scope="col">
                  {name}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.map((row) => (
              <tr key={row.number} data-picked={row.number === picked}>
                <th scope="row" data-part="weekcol">
                  <button
                    type="button"
                    data-part="weekbtn"
                    aria-pressed={row.number === picked}
                    aria-label={`Неделя ${row.number}, с ${span.format(row.days[0])} по ${span.format(row.days[6])}`}
                    onClick={() => {
                      setPicked(row.number)
                      onSelect?.(
                        row.number,
                        stamp(row.days[0]),
                        stamp(row.days[6]),
                      )
                    }}
                  >
                    {row.number}
                  </button>
                </th>
                {row.days.map((date) => (
                  <td key={date.getTime()}>
                    <span
                      data-part="day"
                      data-outside={
                        date.getMonth() !== Number(month.split("-")[1]) - 1
                      }
                      data-today={stamp(date) === today}
                    >
                      {date.getDate()}
                    </span>
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
        <p data-part="foot" aria-live="polite">
          {current ? (
            <>
              Неделя <b>{current.number}</b>: {span.format(current.days[0])} —{" "}
              {span.format(current.days[6])}
            </>
          ) : (
            "Неделя не выбрана"
          )}
        </p>
      </section>
    </>
  )
}