Календарь — встречи, напоминания и время, которое люди закрыли для себя
Простыми словами: один общий месяц на всю команду. Вы ставите встречи и созвоны, зовёте людей или целые отделы, видите, кто свободен, ещё до выбора времени, и закрываете себе часы, чтобы на них никого не назначали.
Всё дальнейшее держится на двух правилах, и оба — о том, чего людям не сообщают:
Вы видите событие, только если оно открыто команде или вы в нём участвуете.
Занятость сообщает, что человек занят, и никогда — чем.
Второе правило и есть причина, по которой в списке участников горит красная точка, а не название. Чтобы назначить встречу, нужно знать, что коллега занят в три; знать, что он у врача, — не нужно.
Календарь (/team/calendar)
Виды по месяцу/неделе/дню для каждого события, которое ты можешь увидеть (см. Кто что видит ниже) — создание, перемещение и изменение длительности перетаскиванием (точные жесты см. в Как... ниже). Шесть типов событий, правила видимости, повторы, напоминания и занятость из разделов ниже — всё это про этот же экран; этот заголовок существует, чтобы у ссылки Техническое описание из хелпера в шапке была своя цель, а не пересказ того, что идёт дальше.
Как...
Календарь
Добавить событие — кнопка + в правом верхнем углу; либо наведите на пустую ячейку и нажмите бледный +, который проступит в её углу; либо протяните по пустому времени — так сразу задаётся длительность.
Обычный клик по пустому месту намеренно не делает ничего. Раньше он открывал форму, а значит её открывал любой случайный клик — в том числе тот, которым вы закрываете открытую карточку. В + нужно попасть, поэтому случайно ничего не откроется. На сенсорном экране наведения нет — там пользуйтесь + в углу страницы.
Посмотреть, что это за событие — наведите на него, появится краткая справка; кликните — откроется карточка с кнопками Редактировать и Удалить. Форма открывается только по кнопке, поэтому «просто посмотреть» никогда не превращается в случайную правку.
Перенести — перетащите. За нижний край — сделать длиннее или короче. И то и другое сохраняется сразу, а если сервер откажет — событие вернётся на место с сообщением.
Закрыть себе время — кнопка с замком рядом с +. Прав для этого не нужно, и никто другой это время не отредактирует — см. Закрытое время.
Найти — строка поиска фильтрует уже загруженное по названию. Воронка — по типу, по человеку и по отделу.
Перейти к другой дате — нажмите на заголовок периода («Август 2026»). Откроется маленький календарь с выбором месяца и года: до ноября следующего года — два клика, а не двадцать нажатий стрелки. Стрелки рядом шагают на период, кнопка Сегодня возвращает на сегодня.
Ответить на приглашение — кликните событие и нажмите Иду, Возможно или Не иду прямо в карточке. Прав на редактирование события для этого не требуется, а уведомление ведёт прямо к нему.
Посмотреть, что ждёт вашего ответа — полоса под переключателем видов. Всё, на что вас позвали и вы ещё не ответили, с тремя кнопками ответа в каждой строке. Ответили — строка ушла. Когда ждать нечего, полосы нет.
Это ваши входящие, а не срез сетки. Полоса не зависит от того, что сейчас на экране — ни от вида, ни от выбранного месяца, ни от поиска, ни от фильтров. Клик по строке переносит календарь к событию и открывает его карточку, даже если сейчас эта дата не видна.
Убирает приглашение только ответ, а не открытие. Колокольчик продолжает считать приглашение, сколько бы раз вы его ни открыли — уберите его ответом отсюда, из карточки события или откуда угодно ещё. Все остальные уведомления уходят, как только их прочитали; приглашение — единственное, которое просит чего-то в ответ.
Уведомление о приглашении открывает Повестку, а не карточку события. Оно переносит календарь на дату события и подсвечивает его строку в полосе, намеренно не открывая карточку — та задала бы тот же вопрос второй раз. Отвечайте прямо в полосе; подробности события — один клик по той же строке. Остальные уведомления календаря (событие изменили, отменили, кто-то ответил, напоминание) по-прежнему открывают карточку — строки в полосе у них нет.
График одного человека
Посмотреть, чем человек занят — Управление командой → Сотрудники → откройте человека → График. Там ближайшие события и часы, которые он закрыл.
Назначить ему встречу — кнопка Назначить встречу на той же вкладке открывает форму с уже добавленным участником.
Шесть типов
У каждого свой цвет и свой значок. Цвет делает месяц читаемым с одного взгляда; значок называет тип точно и сохраняет различение для тех, кто два оттенка не разделяет.
| Тип | Для чего | Ведёт себя иначе |
|---|---|---|
| Созвон | разговор на расстоянии | нет |
| Встреча | люди в переговорке или назначенное обсуждение | нет |
| Напоминание | «сдать отчёт к шести» — точка во времени | да — см. ниже |
| Праздник | день, отмеченный для всех | нет |
| Другое | всё остальное | нет |
| Закрытое время | ваши собственные закрытые часы | да — см. Закрытое время |
Подсказка в строке заголовка (значок фигур, рядом с переключателем вида) перечисляет все шесть с их цветами.
Напоминание — момент, а не отрезок
При выборе Напоминания из формы исчезают время окончания и переключатель Весь день: у точки во времени нет ни того, ни другого. Отсюда следует:
- оно никогда не делает человека занятым — срок сдачи, блокирующий всем полдня, обессмыслил бы всю идею занятости;
- его нельзя растянуть в сетке времени — потянув за край, вы молча превратили бы точку в отрезок;
- на сетке оно рисуется меткой на своей линии, а не блоком.
Любой другой тип обязан длиться дольше нуля: форма не предложит конец, равный началу или раньше.
Кто что видит
Два уровня, задаются у каждого события:
- Вся команда — все, у кого есть
workspace:calendar:read, видят название и детали. - Только участники — все остальные не видят события вообще. Не серый прямоугольник, не анонимное «занято» — его просто нет в календаре, а запрос по адресу отвечает 404, а не «запрещено». Ответ «найдено, но смотреть нельзя» подтвердил бы, что событие существует, — а именно это для закрытого события и не должно утечь.
Единственное место, где чужое закрытое время всё же проявляется, — занятость, и только как интервал; об этом ниже.
Занятость и закрытие собственного времени
Пока вы выбираете участников
По мере выбора у каждого человека появляется отметка для того окна, которое сейчас в форме: свободен, занят или вероятно занят (приглашение, на которое он не ответил). Меняете время — отметки следуют за ним.
Отметка сообщает, но не запрещает. Назначить поверх занятого времени осознанно — нормально, а инструмент, который это запрещает, просто обходят. Если кто-то занят, под списком появляется строка с именами — и ничем больше.
Одобренное отсутствие делает человека занятым на весь день и видно в самом календаре как заштрихованная полоса на весь день: «Анна — отсутствует». Причину не показывает ни то, ни другое — ни отпуск, ни больничный. Нажмите на полосу, чтобы открыть Отсутствия.
Чтобы нарисовать эти отметки, платформа передаёт список интервалов: начало, конец и степень уверенности. Ни названия, ни типа, ни даже идентификатора события — идентификатор превратил бы занятость в способ перебрать чужие закрытые события.
Закрытое время
Кнопка с замком закрывает часы для себя — обед, дорога, спортзал. От всего остального в календаре оно отличается так:
- создать может кто угодно, без
workspace:calendar:manage: закрыть себе время — не административное действие; - никто другой не может его отредактировать или удалить — даже администратор. Это единственная сознательная дыра в общем правиле платформы «привилегированная роль может всё»;
- у него нет участников и нет отделов;
- оно может повторяться еженедельно, по выбранным дням;
- вы решаете, видно ли команде название или коллеги видят только, что время занято.
На сетке это самое тихое, что там есть: приглушённое и заштрихованное, потому что это контекст для чужого решения о времени, а не то, на что нужно смотреть.
Участники и отделы
Вы приглашаете людей. Приглашение отдела — это сокращение, которое добавляет его сотрудников и одновременно относит событие к этому отделу. Одно решение, два следствия, поэтому разойтись они не могут.
У каждого выбранного отдела под полем появляется блок с его людьми, все отмечены. Снимите галочку с тех, кто не нужен: в заголовке вместо «Все» станет «5 из 7», а галочка в заголовке возвращает всех. Уберёте отдел — его люди уйдут вместе с ним, но останутся те, кого вы добавили по имени, и те, кто входит в другой выбранный отдел.
Кто отмечен — вычисляется заново при каждом открытии события, по текущему составу отдела. Само участие хранится по человеку и записывается в момент приглашения: поэтому коллега, пришедший в отдел в следующем месяце, не появится молча во встрече прошлого, а ушедший из неё не исчезнет.
Фильтр по отделу находит события, отнесённые к нему. Событие, на которое просто позвали кого-то из маркетинга, событием маркетинга не является.
Ответы
Каждый приглашённый отвечает за себя: Иду, Возможно, Не иду. Карточка показывает счёт и раскрывается в список, кто что ответил, — чтобы организатор знал, кого именно тормошить. Те же отметки стоят рядом с именами в форме редактирования. Пока он не ответил, событие рисуется у него заштрихованным — неотвеченное приглашение занимает слот, но договорённостью ещё не является. Организатору сообщают каждый ответ; больше никому.
Повторяющиеся события
Повторяться может любое событие: каждый день, каждую неделю по выбранным дням, раз в две недели или раз в месяц по числу. У повтора всегда есть дата окончания.
Сохранение или удаление повторяющегося события сначала задаёт один вопрос — это вхождение или вся серия:
- это вхождение отделяет его от остальных. Перенесите, переименуйте или отмените — серия не затронута.
- вся серия меняет все вхождения, включая ранее отделённые, там где изменение к ним применимо.
Ежемесячный повтор 31-го числа пропускает месяцы, где такого числа нет, а не съезжает на 28-е: серия, которая молча сдвигается, хуже той, что пропускает.
Повторяющееся событие нельзя перетащить. Жест пришлось бы прервать тем же вопросом о серии, а прерывать перетаскивание вопросом нельзя — вместо этого форма предлагает оба варианта явно.
Напоминания
У события может быть до пяти напоминаний, из вариантов за 15 минут, за час и за сутки. Каждое приходит отдельно — они намеренно не сворачиваются в одно, потому что «через 15 минут» и «завтра» — разные сообщения.
Получают все участники события. Напоминание, срок которого уже прошёл в момент создания события, не отправляется — поэтому событие, поставленное за час до начала, не выстреливает сразу напоминанием «за сутки».
Перенос события переносит и его напоминания.
Итоги
У встречи может быть короткая запись о результате. Добавить и изменить её может любой участник, не только организатор, и только после того, как событие началось: итог того, что ещё не состоялось, — либо догадка, либо ошибка.
Виды
- Месяц — всегда шесть строк, чтобы сетка не прыгала при переходе между месяцами. День, в который не помещается всё, заканчивается ссылкой ещё N: она открывает этот день в повестке, а не растягивает ячейку.
- Неделя и День — почасовая сетка с линией текущего времени. Перетаскивание переносит, нижний край меняет длительность, протягивание по пустому месту создаёт.
- Повестка — ближайшие 30 дней списком, по дням.
Выбранный вид попадает в адрес страницы, поэтому переживает перезагрузку, и на календарь можно дать ссылку сразу нужным видом. Дата в адрес не попадает — иначе кнопка «Назад» в браузере отматывала бы по одному месяцу вместо того, чтобы уводить с календаря.
События на весь день стоят полосой над почасовой сеткой. Событие на весь день — это дата, а не промежуток в 24 часа: 5 августа остаётся 5 августа и для коллеги в другом часовом поясе.
Время и часовые пояса
Каждое событие хранит момент и пояс, в котором его создали, — не смещение: смещения устаревают из-за перехода на летнее время и из-за законов. На экране время показывается в поясе вашего браузера, и форма подписывает под полями времени, какой это пояс.
Уведомления
Вас пригласили на событие — когда вас добавили участником. Клик переводит календарь на дату этого события и открывает карточку прямо на нём — видно, куда оно встало, ещё до ответа. Кнопки ответа в карточке.
Событие, где вы участвуете, перенесено — изменилось время.
Событие, где вы участвуете, отменено — отмена тоже новость, поэтому отменённые события сохраняются, а не стираются.
Кто-то ответил на ваше приглашение — только организатору. Сообщать каждому приглашённому, кто идёт, значило бы превратить одну встречу в очень много уведомлений, и никто об этом не просил.
Добавлены итоги — всем, кто был на событии.
Ваше событие скоро начнётся — напоминания выше.
Приглашения, изменения, отмены и ответы настраиваются в группе Приглашения в календаре; напоминания — в Напоминаниях о событиях, отдельно, потому что отключение потока приглашений не должно заглушать напоминание о встрече, на которую вы уже согласились. См. Уведомления.
Одна строка на событие, а не одна на каждое случившееся с ним. Приглашение, перенос и отмена одного и того же события склеиваются, пока строка не прочитана, и в ней остаётся самое свежее — поэтому организатор, который пригласил вас и через минуту перенёс встречу, оставит вам «перенесено», а не две отдельные строки. См. склейку.
О собственных действиях вам не сообщают никогда.
Права
| Право | Что даёт |
|---|---|
workspace:calendar:read | видеть календарь, отвечать на свои приглашения, закрывать себе время |
workspace:calendar:manage | создавать, редактировать и отменять события, управлять их участниками |
Две вещи не требуют ничего: ответ на приглашение, которое вам прислали, и закрытие собственного времени. И одного :manage сознательно не может: тронуть чужое закрытое время.
Расписание одного человека (/team/members/[id]/schedule)
Вкладка Расписание на странице сотрудника (см. Кадры → Сотрудники) — те же события, что и в общем календаре выше, только для одного человека, плюс часы, которые он закрыл для себя. Назначить встречу на этой вкладке открывает ту же форму события, что и основной календарь, с уже добавленным этим человеком в качестве участника (см. Как... ниже).
Тестирование этого модуля
У /team/calendar не было никакого покрытия, ни юнит, ни живого, до 2026-08-12 — это раундовое усилие, как и /team/members, блок за блоком.
- Блок 1 — режимы отображения, синхронизация с URL, фильтры.
apps/portal/pages/team/calendar/index.test.ts(10 юнит-тестов) покрывает синхронизацию режима отображения с URL (?view=month|week|day|agenda, некорректное значение откатывается наmonth, а не падает), точное вычисление окна дат для каждого режима (agenda: 30 дней от якоря; week: с отступом до реального начала недели; day: ровно 1 день; month: с отступом на неделю до и две недели после месяца, чтобы все 6 строк сетки всегда что-то показывали, даже из соседних месяцев), фильтры по типу/участнику/отделу, каждый из которых запускает реальную серверную перезагрузку с правильными параметрами запроса, поиск, остающийся чисто клиентским поверх уже загруженных событий (без дополнительного запроса на каждое нажатие клавиши), «Только мои», переиспользующий существующий фильтр участника вместо второго параллельного флага (выбор другого человека в панели фильтров сам по себе выключает его обратно, поскольку нет отдельного значения, которое могло бы разойтись с ним),activeFilterCount, намеренно исключающий поиск из подсчёта, и строку заголовка для каждого режима.scripts/e2e/menu/calendar.mjs(7 проверок, новый файл) подтверждает, что все четыре кнопки режима реально обновляют URL (а не просто кликабельны), реальный цикл переключения «Только мои», и что поля фильтра «Тип»/«Также приглашены» рендерятся с реальными списками опций (то есть эндпоинты персонала/отделов, на которых они построены, реально загрузились).CalendarView(базовый рендерер сетки/недели/дня/повестки) реальный и самодостаточный, уже покрыт собственнымpackages/ui-kit/src/patterns/calendar/CalendarView.test.ts— здесь тоже монтируется по-настоящему, а не заглушкой.CalendarEventPopover/CalendarEventDialog/CalendarBlockDialog/CalendarInvitationsостаются заглушками в этом блоке — каждый свой диалог/компонент, вне рамок до блока, который его реально задействует. - Блок 2 — мутации событий (создание, drag-move, закрытие времени), тот же раунд. Ещё девять юнит-тестов (19 всего) покрывают
canDrag, зависящий отworkspace:calendar:manage, оптимистичное обновление вonMoveEventи его откат при неудачном PATCH,onCreateRange/openCreate/openBlock, заранее заполняющие нужный диалог правильной датой/диапазоном,expandDay(ссылка «+N ещё»), переносящую якорь и переключающую на режим повестки, иopenEvent, корректно читающий id серии из поляeventIdчипа, а не из собственного ключа рендера чипа — который для повторяющегося вхождения равен<eventId>#<index>, так что ошибка здесь привела бы к редактированию не той серии.calendar.mjsвырос до 10 проверок: создание реального события (заполняет заголовок, отправляет, подтверждает тост успеха, затем подтверждает, что событие реально находится через поиск после этого — а не просто что диалог закрылся) и закрытие собственного времени через диалог блокировки (подтверждает свой тост успеха). Сам drag-move намеренно не проверяется живьём — реальное перетаскивание указателем по математике сеткиCalendarViewне имеет установленного паттерна нигде в этом e2e-наборе и уже покрыт юнит-тестомonMoveEventплюс собственным покрытиемCalendarView; то же решение, что и пропуск живого «Дублировать»/«Передать владение» в других местах этой инициативы. Один живой нюанс: кнопка отправки диалога блокировки остаётся отключённой, пока форма не станет «грязной», поэтому проверка сначала заполняет поле «Метка» — сами дефолты (сегодня, 13:00– 14:00, видно команде) изменением не считаются. - Блок 3 — deep-linking из уведомлений + лента приглашений, тот же раунд. Это полностью закрывает
/team/calendar(все 3 блока, по одному раунду на каждый). Ещё семь юнит-тестов добавлены в тот же файл (26 всего теперь) и покрывают три ветки наблюдателя за?event=<id>— открывает карточку события и переключает на режим повестки, очищая параметр запроса, чтобы перезагрузка не открывала карточку снова; для приглашения, всё ещё ожидающего ответа, вместо этого подсвечивает соответствующую строку в ленте (карточка и лента, показывающие одни и те же три кнопки ответа в нескольких сантиметрах друг от друга, были реальным багом, о котором сообщили и который исправили 2026-08-09, согласно собственному комментарию в коде); а для неотвеченного приглашения, у которого нет строки в ленте, потому что оно уже прошло, откатывается к открытию карточки, поскольку указывать больше не на что — плюс тост об ошибке «событие не найдено, возможно, оно было отменено» для удалённого или более не видимого id,goToEvent/onInvitationAnswered(два собственных действия ленты — перейти к событию и снять подсветку + обновить и сетку, и инбокс после ответа), и три формыinvitationWhen(весь день, с указанием времени, и «мгновенное» с нулевой длительностью, где начало равно концу).calendar.mjsвырос до 14 проверок: deep-link?event=открывает реальное событие по id (захваченному из реального ответа на создание черезpage.waitForResponse, поскольку id больше нигде в интерфейсе не виден) и подтверждает и содержимое карточки, и обновление URL, плюс тост «пропало» для несуществующего id — оба однопользовательских пути — И саму подсветку в ленте приглашений, с использованием собственного второго выделенного аккаунта набора (e2e-test-staff2@p4p.internal) в отдельном браузерном контексте:e2e-test-adminприглашает STAFF2 на реальное событие, STAFF2 открывает?event=<id>, лента подсвечивает строку, ответ «Иду» убирает её. (Первый черновик этого файла ошибочно предположил, что второго аккаунта не существует, и пропустил эту живую проверку — исправлено в том же раунде, когда было замечено давнее использование этого аккаунта в других местах набора, например вrealtime-new-items-pill.mjs.) Также добавлено в этом раунде: общий вспомогательный хелпер очисткиdeleteEventByTitle(), ретроактивно применённый и к собственным проверкам блока 2 — созданию события и закрытию времени (ни одна из них раньше не убирала за собой) — теперь каждое реальное событие, которое создаёт этот файл во всех трёх блоках, удаляется к концу прогона, соответствуя собственной дисциплине очистки любого другого файлаmenu/*.mjs. В тот же день найден и исправлен реальный баг в самом этом хелпере очистки: локатор клика по чипу использовал подстроковое (exact: false) сопоставление текста, чей.first()может разрешиться в предка, чей текст — конкатенация нескольких чипов, а не один чип — клик тогда попадает в контейнер без обработчика клика и молча не срабатывает, оставляя событие позади без какой-либо ошибки. Найдено через 3 реально осиротевшие строки «E2E Calendar Event ...», оставленные более ранними прогонами в этом же раунде; исправлено наexact: trueс явным ожиданием видимости перед каждым кликом, а сироты убраны вручную. Проверка подсветки ленты приглашений потребовала похожего второго исправления: и утверждение «лента показывает строку», и «ответ убирает её» должны быть привязаны к собственному атрибуту строки ленты[data-invitation-id], а не к поиску текста заголовка по всей странице — dev БД общая, поэтому у STAFF2 реально могут быть ДРУГИЕ настоящие ожидающие приглашения в тот же момент, что и приглашение теста (слепой клик «первая кнопка Иду» ответил на чужое несвязанное приглашение вместо), и отдельно?event=переключает вид на повестку дня, где та же строка события в списке повестки несёт тот же текст заголовка, так что поиск по всей странице не может отличить «всё ещё в ленте» от «видимо где-то в календаре».
Вкладка «Расписание» на собственной карточке сотрудника (/team/members/[id]/schedule, см. Персонал) — представление той же самой системы для одного человека, и её собственный раунд (apps/portal/pages/team/members/[id]/schedule.test.ts) был первым, кто застабил useCalendarEvents/useEventTooltip/toCalendarItems/utils/calendarEvent.ts по-настоящему — этот раунд промотировал оба в общий apps/portal/test/setup.ts при их втором реальном переиспользовании.