feat: stream calendar events over websocket

This commit is contained in:
lizerk 2026-08-14 23:44:28 +03:00
parent aaa1da663c
commit b7e20c8956
11 changed files with 1012 additions and 129 deletions

View File

@ -1,7 +1,7 @@
asyncapi: 2.6.0
info:
title: EUSRR Realtime WebSocket API
version: 1.1.0
version: 1.2.0
description: |
Отдельная AsyncAPI-спецификация для WebSocket-контура EUSRR.
@ -32,7 +32,7 @@ channels:
- typing indicators
- реакции и голосования
- procurement updates
- календарные invalidation-события
- подписки на диапазоны календаря и calendar snapshots
publish:
operationId: clientAction
summary: Команды от клиента к серверу
@ -49,6 +49,8 @@ channels:
- $ref: '#/components/messages/StopTypingAction'
- $ref: '#/components/messages/VotePollAction'
- $ref: '#/components/messages/MarkReadAction'
- $ref: '#/components/messages/CalendarSubscribeAction'
- $ref: '#/components/messages/CalendarUnsubscribeAction'
subscribe:
operationId: serverEvent
summary: События от сервера клиенту
@ -76,6 +78,8 @@ channels:
- $ref: '#/components/messages/ProcurementUpdateEvent'
- $ref: '#/components/messages/TaskBoardUpdateEvent'
- $ref: '#/components/messages/CalendarUpdateEvent'
- $ref: '#/components/messages/CalendarSnapshotEvent'
- $ref: '#/components/messages/CalendarErrorEvent'
components:
schemas:
ChatId:
@ -134,6 +138,44 @@ components:
- verb
- unread
additionalProperties: true
CalendarEventPayload:
type: object
required: [id, title, start, end, calendar, is_recurring]
properties:
id:
oneOf:
- type: integer
- type: string
title:
type: string
description:
type: string
start:
type: string
format: date-time
end:
type: string
format: date-time
calendar:
type: integer
minimum: 1
event_id:
type: integer
minimum: 1
rule:
type: integer
minimum: 1
nullable: true
color_event:
type: string
nullable: true
can_edit:
type: boolean
can_delete:
type: boolean
is_recurring:
type: boolean
additionalProperties: true
messages:
OpenChatAction:
name: OpenChatAction
@ -283,6 +325,48 @@ components:
note:
type: string
example: Deprecated, используйте HTTP mark-read endpoint
CalendarSubscribeAction:
name: CalendarSubscribeAction
title: Подписаться на диапазон календаря
payload:
type: object
required: [action, subscription_id, start, end]
properties:
action:
type: string
enum: [calendar_subscribe]
subscription_id:
type: string
minLength: 1
maxLength: 80
start:
type: string
format: date-time
end:
type: string
format: date-time
calendar_id:
type: integer
minimum: 1
nullable: true
scope:
type: string
enum: [accessible, all]
default: accessible
CalendarUnsubscribeAction:
name: CalendarUnsubscribeAction
title: Отписаться от диапазона календаря
payload:
type: object
required: [action, subscription_id]
properties:
action:
type: string
enum: [calendar_unsubscribe]
subscription_id:
type: string
minLength: 1
maxLength: 80
PingEvent:
name: PingEvent
payload:
@ -632,3 +716,55 @@ components:
nullable: true
is_recurring:
type: boolean
CalendarSnapshotEvent:
name: CalendarSnapshotEvent
payload:
type: object
required: [type, reason, data]
properties:
type:
type: string
enum: [calendar_snapshot]
reason:
type: string
enum: [subscribed, updated]
data:
type: object
required: [subscription_id, start, end, scope, events]
properties:
subscription_id:
type: string
start:
type: string
format: date-time
end:
type: string
format: date-time
calendar_id:
type: integer
minimum: 1
nullable: true
scope:
type: string
enum: [accessible, all]
events:
type: array
items:
$ref: '#/components/schemas/CalendarEventPayload'
CalendarErrorEvent:
name: CalendarErrorEvent
payload:
type: object
required: [type, code, detail]
properties:
type:
type: string
enum: [calendar_error]
subscription_id:
type: string
nullable: true
code:
type: string
enum: [invalid_subscription, subscription_limit, snapshot_failed]
detail:
type: string

View File

@ -21,6 +21,12 @@ from django.utils import timezone
from communications.consumers import ChatConsumerMixin
from communications.services import ChatNotificationReadService
from scheduling.realtime import (
MAX_CALENDAR_SUBSCRIPTIONS,
CalendarSubscriptionError,
build_calendar_snapshot,
parse_calendar_subscription,
)
logger = logging.getLogger(__name__)
@ -45,6 +51,9 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
self.subscribed_chats = set() # Чаты, на которые подписан
self.user = None
self.chat_notification_read_service = ChatNotificationReadService()
self.calendar_subscriptions = {}
self.calendar_refresh_task = None
self.pending_calendar_refresh_ids = set()
@database_sync_to_async
def _mark_chat_notifications_as_read(self, chat_id):
@ -106,6 +115,16 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
except asyncio.CancelledError:
pass
if self.calendar_refresh_task:
self.calendar_refresh_task.cancel()
try:
await self.calendar_refresh_task
except asyncio.CancelledError:
pass
self.calendar_refresh_task = None
self.calendar_subscriptions.clear()
self.pending_calendar_refresh_ids.clear()
# Сбрасываем статус "набирает текст" если был активный чат
if self.active_chat_id and self.user:
chat = await self._get_chat(self.active_chat_id)
@ -205,6 +224,116 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
elif action == "vote_poll":
await self._handle_vote_poll(content)
# Подписки календаря на диапазоны событий
elif action == "calendar_subscribe":
await self._handle_calendar_subscribe(content)
elif action == "calendar_unsubscribe":
await self._handle_calendar_unsubscribe(content)
@database_sync_to_async
def _build_calendar_snapshot(self, subscription):
return build_calendar_snapshot(user=self.user, subscription=subscription)
async def _send_calendar_snapshot(self, subscription_id, *, reason):
subscription = self.calendar_subscriptions.get(subscription_id)
if subscription is None:
return
try:
snapshot = await self._build_calendar_snapshot(subscription)
except Exception as error:
logger.exception(
"Calendar WebSocket snapshot failed",
extra={
"event": "calendar.websocket_snapshot_failed",
"user_id": self.user.id,
"subscription_id": subscription_id,
"error_type": type(error).__name__,
},
)
await self.send_json(
{
"type": "calendar_error",
"subscription_id": subscription_id,
"code": "snapshot_failed",
"detail": "Не удалось загрузить события календаря.",
}
)
return
# Подписка могла измениться, пока выполнялся запрос к БД.
if self.calendar_subscriptions.get(subscription_id) != subscription:
return
await self.send_json(
{
"type": "calendar_snapshot",
"reason": reason,
"data": snapshot,
}
)
async def _handle_calendar_subscribe(self, content):
try:
subscription = parse_calendar_subscription(content, user=self.user)
except CalendarSubscriptionError as error:
await self.send_json(
{
"type": "calendar_error",
"subscription_id": content.get("subscription_id"),
"code": "invalid_subscription",
"detail": str(error),
}
)
return
if (
subscription.subscription_id not in self.calendar_subscriptions
and len(self.calendar_subscriptions) >= MAX_CALENDAR_SUBSCRIPTIONS
):
await self.send_json(
{
"type": "calendar_error",
"subscription_id": subscription.subscription_id,
"code": "subscription_limit",
"detail": "Превышен лимит подписок календаря.",
}
)
return
self.calendar_subscriptions[subscription.subscription_id] = subscription
await self._send_calendar_snapshot(
subscription.subscription_id,
reason="subscribed",
)
async def _handle_calendar_unsubscribe(self, content):
subscription_id = str(content.get("subscription_id") or "").strip()
if subscription_id:
self.calendar_subscriptions.pop(subscription_id, None)
async def _refresh_calendar_subscriptions(self):
try:
while self.pending_calendar_refresh_ids:
# Несколько сигналов одной транзакции объединяются в один snapshot.
await asyncio.sleep(0.05)
calendar_ids = set(self.pending_calendar_refresh_ids)
self.pending_calendar_refresh_ids.clear()
subscriptions = list(self.calendar_subscriptions.values())
for subscription in subscriptions:
if (
subscription.calendar_id is not None
and subscription.calendar_id not in calendar_ids
):
continue
await self._send_calendar_snapshot(
subscription.subscription_id,
reason="updated",
)
except asyncio.CancelledError:
pass
finally:
self.calendar_refresh_task = None
@staticmethod
def _extract_notification_chat_id(notification):
"""Достаёт chat_id из payload уведомления, если он есть."""
@ -339,11 +468,24 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
)
async def calendar_update(self, event):
"""Инвалидация календарей и событий в подключённых клиентах."""
"""Передать метаданные изменения и обновить активные snapshots."""
data = event.get("data", {})
await self.send_json(
{
"type": "calendar_update",
"event": event.get("event", "event_updated"),
"data": event.get("data", {}),
"data": data,
}
)
if not self.calendar_subscriptions:
return
try:
calendar_id = int(data.get("calendar_id"))
except (TypeError, ValueError):
return
self.pending_calendar_refresh_ids.add(calendar_id)
if not self.calendar_refresh_task:
self.calendar_refresh_task = asyncio.create_task(
self._refresh_calendar_subscriptions()
)

View File

@ -1,15 +1,239 @@
import logging
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db import models, transaction
from django.db.models import Q
from django.utils import timezone
from django.utils.dateparse import parse_date, parse_datetime
from schedule.models import Calendar, CalendarRelation, Event, EventRelation
from schedule.periods import Period
from scheduling.services import is_department_calendar
logger = logging.getLogger(__name__)
MAX_CALENDAR_SUBSCRIPTIONS = 8
MAX_CALENDAR_RANGE_DAYS = 400
class CalendarSubscriptionError(ValueError):
"""Некорректные параметры WebSocket-подписки календаря."""
@dataclass(frozen=True)
class CalendarSubscription:
subscription_id: str
start: datetime
end: datetime
calendar_id: int | None
scope: str
def _parse_boundary(value, *, field: str) -> datetime:
raw_value = str(value or "").strip()
parsed = parse_datetime(raw_value)
if parsed is None:
parsed_date = parse_date(raw_value)
if parsed_date is not None:
parsed = datetime.combine(parsed_date, time.min)
if parsed is None:
raise CalendarSubscriptionError(
f"Поле {field} должно содержать дату в формате ISO 8601."
)
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed)
return parsed
def parse_calendar_subscription(content: dict, *, user) -> CalendarSubscription:
subscription_id = str(content.get("subscription_id") or "").strip()
if not subscription_id or len(subscription_id) > 80:
raise CalendarSubscriptionError("Некорректный subscription_id.")
start = _parse_boundary(content.get("start"), field="start")
end = _parse_boundary(content.get("end"), field="end")
if end <= start:
raise CalendarSubscriptionError("Конец диапазона должен быть позже начала.")
if end - start > timedelta(days=MAX_CALENDAR_RANGE_DAYS):
raise CalendarSubscriptionError(
f"Диапазон календаря не может превышать {MAX_CALENDAR_RANGE_DAYS} дней."
)
calendar_id = content.get("calendar_id")
if calendar_id in (None, ""):
calendar_id = None
else:
try:
calendar_id = int(calendar_id)
except (TypeError, ValueError) as error:
raise CalendarSubscriptionError("Некорректный calendar_id.") from error
if calendar_id <= 0:
raise CalendarSubscriptionError("Некорректный calendar_id.")
requested_scope = str(content.get("scope") or "accessible")
scope = (
"all"
if requested_scope == "all" and (user.is_staff or user.is_superuser)
else "accessible"
)
return CalendarSubscription(
subscription_id=subscription_id,
start=start,
end=end,
calendar_id=calendar_id,
scope=scope,
)
def _visible_event_queryset(*, user, subscription: CalendarSubscription):
queryset = Event.objects.select_related(
"calendar",
"calendar__binding",
"rule",
"creator",
)
if subscription.scope != "all":
User = get_user_model()
user_content_type = ContentType.objects.get_for_model(User)
calendar_ids = CalendarRelation.objects.filter(
content_type=user_content_type,
object_id=user.id,
).values_list("calendar_id", flat=True)
participant_event_ids = EventRelation.objects.filter(
content_type=user_content_type,
object_id=user.id,
).values_list("event_id", flat=True)
queryset = queryset.filter(
Q(calendar_id__in=calendar_ids) | Q(id__in=participant_event_ids)
)
if subscription.calendar_id is not None:
queryset = queryset.filter(calendar_id=subscription.calendar_id)
return queryset.distinct()
def _calendar_roles_for_user(user) -> dict[int, str]:
if user.is_staff or user.is_superuser:
return {}
User = get_user_model()
user_content_type = ContentType.objects.get_for_model(User)
return dict(
CalendarRelation.objects.filter(
content_type=user_content_type,
object_id=user.id,
).values_list("calendar_id", "distinction")
)
def _can_edit_event(user, event: Event, calendar_roles: dict[int, str]) -> bool:
if user.is_staff or user.is_superuser:
return True
role = calendar_roles.get(event.calendar_id)
if is_department_calendar(event.calendar):
return role in {"owner", "editor"} or event.creator_id == user.id
return role in {"owner", "editor"}
def _serialize_regular_event(
event: Event,
*,
user,
calendar_roles: dict[int, str],
) -> dict:
can_edit = _can_edit_event(user, event, calendar_roles)
return {
"id": event.id,
"title": event.title,
"description": event.description,
"start": event.start.isoformat(),
"end": event.end.isoformat(),
"calendar": event.calendar_id,
"calendar_name": event.calendar.name,
"color_event": event.color_event or None,
"rule": None,
"end_recurring_period": None,
"can_edit": can_edit,
"can_delete": can_edit,
"is_recurring": False,
}
def _serialize_occurrence(
occurrence,
*,
user,
calendar_roles: dict[int, str],
) -> dict:
event = occurrence.event
can_edit = _can_edit_event(user, event, calendar_roles)
return {
"id": f"{event.id}_{occurrence.start.isoformat()}",
"title": occurrence.title or event.title,
"description": event.description,
"start": occurrence.start.isoformat(),
"end": occurrence.end.isoformat(),
"calendar": event.calendar_id,
"color_event": event.color_event or None,
"event_id": event.id,
"rule": event.rule_id,
"end_recurring_period": (
event.end_recurring_period.isoformat()
if event.end_recurring_period
else None
),
"can_edit": can_edit,
"can_delete": can_edit,
"is_recurring": True,
}
def build_calendar_snapshot(*, user, subscription: CalendarSubscription) -> dict:
"""Собрать разрешённые пользователю события для WebSocket-подписки."""
visible_events = _visible_event_queryset(user=user, subscription=subscription)
calendar_roles = _calendar_roles_for_user(user)
regular_events = visible_events.filter(
rule__isnull=True,
start__lte=subscription.end,
end__gte=subscription.start,
).order_by("start", "id")
serialized_events = [
_serialize_regular_event(
event,
user=user,
calendar_roles=calendar_roles,
)
for event in regular_events
]
recurring_events = visible_events.filter(rule__isnull=False).order_by(
"start", "id"
)
period = Period(recurring_events, subscription.start, subscription.end)
serialized_events.extend(
_serialize_occurrence(
occurrence,
user=user,
calendar_roles=calendar_roles,
)
for occurrence in period.get_occurrences()
)
serialized_events.sort(key=lambda event: (event["start"], str(event["id"])))
return {
"subscription_id": subscription.subscription_id,
"start": subscription.start.isoformat(),
"end": subscription.end.isoformat(),
"calendar_id": subscription.calendar_id,
"scope": subscription.scope,
"events": serialized_events,
}
def get_calendar_recipient_ids(
calendar: Calendar | None,

View File

@ -2,12 +2,18 @@ from unittest.mock import patch
from datetime import timedelta
import pytest
from channels.db import database_sync_to_async
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from schedule.models import Calendar, CalendarRelation, Event, EventRelation
from schedule.models import Calendar, CalendarRelation, Event, EventRelation, Rule
from employees.models import Employee
from scheduling.realtime import get_calendar_recipient_ids, send_calendar_update
from scheduling.realtime import (
build_calendar_snapshot,
get_calendar_recipient_ids,
parse_calendar_subscription,
send_calendar_update,
)
pytestmark = pytest.mark.django_db
@ -113,6 +119,155 @@ def test_event_lifecycle_emits_calendar_invalidation(user):
assert send_update.call_args.kwargs["event_id"] == event_id
def test_calendar_snapshot_contains_visible_regular_event(user):
calendar = _calendar_with_owner(user)
event = Event.objects.create(
calendar=calendar,
title="WebSocket snapshot",
start=timezone.now(),
end=timezone.now() + timedelta(hours=1),
)
subscription = parse_calendar_subscription(
{
"subscription_id": "calendar-main",
"start": (timezone.now() - timedelta(days=1)).isoformat(),
"end": (timezone.now() + timedelta(days=1)).isoformat(),
"calendar_id": calendar.id,
"scope": "accessible",
},
user=user,
)
snapshot = build_calendar_snapshot(user=user, subscription=subscription)
assert snapshot["subscription_id"] == "calendar-main"
assert snapshot["calendar_id"] == calendar.id
assert snapshot["events"] == [
{
"id": event.id,
"title": "WebSocket snapshot",
"description": "",
"start": event.start.isoformat(),
"end": event.end.isoformat(),
"calendar": calendar.id,
"calendar_name": calendar.name,
"color_event": None,
"rule": None,
"end_recurring_period": None,
"can_edit": True,
"can_delete": True,
"is_recurring": False,
}
]
def test_calendar_snapshot_expands_recurring_events_and_enforces_access(user, user2):
calendar = _calendar_with_owner(user)
rule = Rule.objects.create(
name="Daily realtime",
description="Two occurrences",
frequency="DAILY",
params="count:2",
)
event = Event.objects.create(
calendar=calendar,
title="Повтор",
start=timezone.now(),
end=timezone.now() + timedelta(hours=1),
rule=rule,
)
content = {
"subscription_id": "calendar-recurring",
"start": (timezone.now() - timedelta(days=1)).isoformat(),
"end": (timezone.now() + timedelta(days=3)).isoformat(),
"scope": "accessible",
}
owner_subscription = parse_calendar_subscription(content, user=user)
owner_snapshot = build_calendar_snapshot(
user=user,
subscription=owner_subscription,
)
assert len(owner_snapshot["events"]) == 2
assert {item["event_id"] for item in owner_snapshot["events"]} == {event.id}
assert all(item["is_recurring"] for item in owner_snapshot["events"])
outsider_subscription = parse_calendar_subscription(content, user=user2)
outsider_snapshot = build_calendar_snapshot(
user=user2,
subscription=outsider_subscription,
)
assert outsider_snapshot["events"] == []
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
async def test_consumer_serves_calendar_snapshot(ws_communicator, user):
@database_sync_to_async
def create_event():
calendar = _calendar_with_owner(user)
event = Event.objects.create(
calendar=calendar,
title="Realtime range",
start=timezone.now(),
end=timezone.now() + timedelta(hours=1),
)
return calendar.id, event.id
calendar_id, event_id = await create_event()
communicator = await ws_communicator(user=user)
connected, _ = await communicator.connect()
assert connected
await communicator.send_json_to(
{
"action": "calendar_subscribe",
"subscription_id": "calendar-test",
"start": (timezone.now() - timedelta(days=1)).isoformat(),
"end": (timezone.now() + timedelta(days=1)).isoformat(),
"calendar_id": calendar_id,
"scope": "accessible",
}
)
response = await communicator.receive_json_from(timeout=5)
assert response["type"] == "calendar_snapshot"
assert response["reason"] == "subscribed"
assert response["data"]["subscription_id"] == "calendar-test"
assert [event["id"] for event in response["data"]["events"]] == [event_id]
@database_sync_to_async
def rename_event():
event = Event.objects.get(pk=event_id)
event.title = "Realtime range updated"
with patch("scheduling.realtime_signals.send_calendar_update"):
event.save(update_fields=["title"])
await rename_event()
from channels.layers import get_channel_layer
await get_channel_layer().group_send(
f"user_{user.id}",
{
"type": "calendar_update",
"event": "event_updated",
"data": {
"action": "updated",
"entity": "event",
"calendar_id": calendar_id,
"event_id": event_id,
},
},
)
update = await communicator.receive_json_from(timeout=5)
refreshed = await communicator.receive_json_from(timeout=5)
assert update["type"] == "calendar_update"
assert refreshed["type"] == "calendar_snapshot"
assert refreshed["reason"] == "updated"
assert refreshed["data"]["events"][0]["title"] == "Realtime range updated"
await communicator.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
async def test_consumer_forwards_calendar_update(ws_communicator, user):

View File

@ -75,6 +75,8 @@ JWT проходит `SessionAwareJWTAuthentication`: проверяется п
| `stop_typing` | — | Остановить typing |
| `vote_poll` | `poll_id`, `option_ids` | Изменить голос в опросе активного чата |
| `mark_read` | `chat_id` | Deprecated; используйте HTTP mark-read action |
| `calendar_subscribe` | `subscription_id`, `start`, `end` | Подписаться на snapshot событий диапазона; optional `calendar_id`, `scope` |
| `calendar_unsubscribe` | `subscription_id` | Закрыть подписку диапазона календаря |
Пример:
@ -93,9 +95,12 @@ JWT проходит `SessionAwareJWTAuthentication`: проверяется п
- typing: `typing_start`, `typing_stop`;
- polls/read receipts: `poll_update`, `marked_read`;
- notifications: `notification`, `unread_count`, `notification_read`, `notifications_read_all`;
- domain events: `procurement_update`, `task_board_update`, `calendar_update`.
- domain events: `procurement_update`, `task_board_update`, `calendar_update`;
- calendar stream: `calendar_snapshot`, `calendar_error`.
`calendar_update` сообщает о создании, изменении, удалении события или календаря и об изменении состава участников. Payload используется как invalidation signal: начальная загрузка, диапазоны и recurring occurrences остаются в REST, а frontend очищает calendar cache и пакетно обновляет видимый диапазон.
`calendar_subscribe` открывает независимую подписку на диапазон до 400 дней. Одно соединение поддерживает до восьми подписок, поэтому большой и боковой календари могут иметь разные диапазоны. Backend проверяет доступ пользователя, разворачивает recurring occurrences и отправляет `calendar_snapshot` сразу после подписки и после связанных изменений. `scope=all` применяется только для staff/superuser.
`calendar_update` остаётся компактным доменным событием для синхронизации списка календарей. Сами видимые события поступают в `calendar_snapshot`; REST-загрузка используется frontend только как fallback, если WebSocket недоступен. Создание, изменение, удаление, импорт и экспорт календаря остаются HTTP-командами.
Channel-layer handler names вроде `chat_message`, `notification_message` и `notification_count_update` — внутренний Python protocol. Frontend должен обрабатывать JSON `type`, отправленный consumer-ом, а не имена внутренних handlers.
@ -112,7 +117,7 @@ Channel-layer handler names вроде `chat_message`, `notification_message` и
Не создавайте WebSocket напрямую в page/component. Chat-specific hook подписывается на shared manager и не владеет отдельным соединением.
Realtime не заменяет REST как источник состояния. После reconnect или исчерпания попыток frontend выполняет HTTP fallback/refetch.
После reconnect frontend повторяет активные календарные подписки. Если snapshot не приходит за три секунды или исчерпаны попытки reconnect, выполняется HTTP fallback для текущего диапазона.
## Изменение протокола

View File

@ -7,6 +7,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { apiClient } from "@/lib/api";
import { useCalendar } from "@/contexts/CalendarContext";
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
import { Plus, ChevronDown, ChevronLeft, ChevronRight, Users, Pencil, Trash2 } from "lucide-react";
import { CalendarModal } from "@/components/CalendarModal";
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
@ -152,11 +153,9 @@ export function BigCalendar() {
calendarScope,
setCalendarScope,
canUseAllCalendarsMode,
eventsRevision,
reloadCalendars,
} = useCalendar();
const [events, setEvents] = useState<CalendarEventData[]>([]);
const [loading, setLoading] = useState(false);
const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0);
const [currentView, setCurrentView] = useState<View>("month");
const [currentDate, setCurrentDate] = useState(new Date());
@ -174,7 +173,6 @@ export function BigCalendar() {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
const [viewingEvent, setViewingEvent] = useState<CalendarEventDraft | null>(null);
const eventsRequestIdRef = useRef(0);
const linkedEventId = Number(searchParams.get("event") || "");
const linkedCalendarId = Number(searchParams.get("calendar") || "");
const linkedCalendarState = useMemo(
@ -193,6 +191,15 @@ export function BigCalendar() {
const participantsModalAvailable = canOpenParticipantsModal(selectedCalendar);
const departmentCalendar = isDepartmentCalendar(selectedCalendar);
const { events, loading } = useCalendarEvents({
monthDate: currentDate,
selectedCalendarId,
calendarScope,
canUseAllCalendarsMode,
eventsRefreshTrigger,
includeCurrentWeek: false,
rangePaddingDays: 7,
});
const clearEventParam = useCallback(() => {
if (!searchParams.get("event")) return;
@ -229,48 +236,6 @@ export function BigCalendar() {
}, []);
}, [events]);
// Загрузка событий
const loadEvents = useCallback(async () => {
const requestId = ++eventsRequestIdRef.current;
const requestedCalendarId = selectedCalendarId;
try {
setLoading(true);
const start = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
start.setDate(start.getDate() - 7);
const end = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 7);
const nextEvents = await calendarService.loadEvents(
start,
end,
requestedCalendarId,
calendarScope,
canUseAllCalendarsMode,
eventsRevision,
);
// Если параллельно ушёл более новый запрос, его ответ приоритетнее.
if (requestId !== eventsRequestIdRef.current) {
return;
}
setEvents(nextEvents);
} catch (err) {
if (requestId !== eventsRequestIdRef.current) {
return;
}
console.error("Ошибка загрузки событий:", err);
} finally {
if (requestId === eventsRequestIdRef.current) {
setLoading(false);
}
}
}, [calendarScope, canUseAllCalendarsMode, currentDate, eventsRevision, selectedCalendarId]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
useEffect(() => {
if (!linkedEventId || viewingEvent?.id === linkedEventId) return;
@ -405,12 +370,12 @@ export function BigCalendar() {
setSelectedCalendarId(null);
setShowCalendarActions(false);
await reloadCalendars();
loadEvents();
setEventsRefreshTrigger((value) => value + 1);
} catch (error) {
console.error("Не удалось удалить календарь:", error);
alert("Не удалось удалить календарь");
}
}, [departmentCalendar, loadEvents, reloadCalendars, selectedCalendar, setSelectedCalendarId]);
}, [departmentCalendar, reloadCalendars, selectedCalendar, setSelectedCalendarId]);
const handleExportCalendar = useCallback(async () => {
if (!selectedCalendar?.id) return;
@ -443,7 +408,7 @@ export function BigCalendar() {
const result = await calendarService.importFromICS(selectedCalendar.id, file);
alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`);
calendarService.clearCache();
loadEvents();
setEventsRefreshTrigger((value) => value + 1);
} catch (error: unknown) {
console.error("Не удалось импортировать календарь:", error);
alert(`Ошибка импорта: ${error instanceof Error ? error.message : "Не удалось импортировать календарь"}`);
@ -453,7 +418,7 @@ export function BigCalendar() {
}
setShowCalendarActions(false);
}
}, [loadEvents, selectedCalendar]);
}, [selectedCalendar]);
// Клик на событие из модала просмотра дня
const handleEventClickFromDay = useCallback(async (event: CalendarEventData) => {
@ -749,7 +714,7 @@ export function BigCalendar() {
event={editingEvent}
onSave={() => {
calendarService.clearCache();
loadEvents(); // ✅ Обновляем список событий
setEventsRefreshTrigger((value) => value + 1);
}}
showParticipants={true}
/>
@ -785,7 +750,7 @@ export function BigCalendar() {
await calendarService.deleteEvent(viewingEvent.id);
setShowEventDetailsModal(false);
setViewingEvent(null);
loadEvents();
setEventsRefreshTrigger((value) => value + 1);
} catch (err) {
console.error("Ошибка удаления события:", err);
alert("Не удалось удалить событие");

View File

@ -18,7 +18,6 @@ type CalendarContextType = {
canUseAllCalendarsMode: boolean;
loading: boolean;
error: string | null;
eventsRevision: number;
reloadCalendars: () => Promise<void>;
};
@ -31,7 +30,6 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
const [calendarScope, setCalendarScope] = useState<CalendarScope>("accessible");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [eventsRevision, setEventsRevision] = useState(0);
const initialLoadDone = useRef(false);
const realtimeSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shouldReloadCalendarsRef = useRef(false);
@ -90,14 +88,12 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
if (!data) return;
calendarService.clearCache();
if (data.entity === "calendar" || data.action === "membership_changed") {
shouldReloadCalendarsRef.current = true;
}
if (data.entity !== "calendar") return;
shouldReloadCalendarsRef.current = true;
if (realtimeSyncTimerRef.current) return;
realtimeSyncTimerRef.current = setTimeout(() => {
realtimeSyncTimerRef.current = null;
setEventsRevision((revision) => revision + 1);
if (shouldReloadCalendarsRef.current) {
shouldReloadCalendarsRef.current = false;
void loadCalendars();
@ -126,7 +122,6 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
canUseAllCalendarsMode,
loading,
error,
eventsRevision,
reloadCalendars: loadCalendars,
}}
>

View File

@ -3,9 +3,8 @@
* Обрабатывает кэширование, загрузку и ошибки
*/
import { useEffect, useState, useRef } from "react";
import { useEffect, useState } from "react";
import { calendarService, type CalendarEvent, startOfWeekMonday } from "@/services/calendarService";
import { useCalendar } from "@/contexts/CalendarContext";
interface UseCalendarEventsOptions {
monthDate: Date;
@ -14,6 +13,8 @@ interface UseCalendarEventsOptions {
canUseAllCalendarsMode: boolean;
eventsRefreshTrigger: number;
onEventsLoaded?: (events: CalendarEvent[]) => void;
includeCurrentWeek?: boolean;
rangePaddingDays?: number;
}
interface UseCalendarEventsResult {
@ -29,83 +30,62 @@ export function useCalendarEvents({
canUseAllCalendarsMode,
eventsRefreshTrigger,
onEventsLoaded,
includeCurrentWeek = true,
rangePaddingDays = 0,
}: UseCalendarEventsOptions): UseCalendarEventsResult {
const { eventsRevision } = useCalendar();
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestIdRef = useRef(0);
useEffect(() => {
let cancelled = false;
// Вычисляем диапазон дат для загрузки
const start = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const end = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1);
const weekStart = startOfWeekMonday(new Date());
const weekEndExclusive = new Date(weekStart);
weekEndExclusive.setDate(weekEndExclusive.getDate() + 7);
start.setDate(start.getDate() - rangePaddingDays);
end.setDate(end.getDate() + rangePaddingDays);
// Расширяем диапазон для включения текущей недели
const fetchStart = weekStart < start ? weekStart : start;
const fetchEnd = weekEndExclusive > end ? weekEndExclusive : end;
async function loadEvents() {
const requestId = ++requestIdRef.current;
try {
setLoading(true);
setError(null);
const loadedEvents = await calendarService.loadEvents(
fetchStart,
fetchEnd,
selectedCalendarId,
calendarScope,
canUseAllCalendarsMode,
`${eventsRefreshTrigger}:${eventsRevision}`
);
if (!cancelled) {
setEvents(loadedEvents);
onEventsLoaded?.(loadedEvents);
}
} catch (err) {
if (requestId !== requestIdRef.current) {
return;
}
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err || "");
const isNetworkLike = message === "NetworkError" || /fetch failed/i.test(message);
if (!isNetworkLike) {
console.error("Ошибка загрузки событий календаря:", err);
}
setError("Не удалось загрузить события");
setEvents([]);
}
} finally {
if (!cancelled && requestId === requestIdRef.current) {
setLoading(false);
}
}
let fetchStart = start;
let fetchEnd = end;
if (includeCurrentWeek) {
const weekStart = startOfWeekMonday(new Date());
const weekEndExclusive = new Date(weekStart);
weekEndExclusive.setDate(weekEndExclusive.getDate() + 7);
fetchStart = weekStart < start ? weekStart : start;
fetchEnd = weekEndExclusive > end ? weekEndExclusive : end;
}
loadEvents();
return () => {
cancelled = true;
};
return calendarService.subscribeEvents({
startDate: fetchStart,
endDate: fetchEnd,
calendarId: selectedCalendarId,
calendarScope,
canUseAllCalendarsMode,
refreshTrigger: eventsRefreshTrigger,
onEvents: (loadedEvents) => {
setEvents(loadedEvents);
onEventsLoaded?.(loadedEvents);
},
onLoading: (isLoading) => {
setLoading(isLoading);
if (isLoading) setError(null);
},
onError: (err) => {
const message = err instanceof Error ? err.message : String(err || "");
if (message !== "NetworkError" && !/fetch failed/i.test(message)) {
console.error("Ошибка загрузки событий календаря:", err);
}
setError("Не удалось загрузить события");
setEvents([]);
},
});
}, [
monthDate,
selectedCalendarId,
calendarScope,
canUseAllCalendarsMode,
eventsRefreshTrigger,
eventsRevision,
onEventsLoaded,
includeCurrentWeek,
rangePaddingDays,
]);
return { events, loading, error };

View File

@ -29,6 +29,10 @@ export function urlBase64ToUint8Array(base64String: string): Uint8Array {
*/
export function isPushSupported(): boolean {
if (typeof window === 'undefined') return false;
// В development Service Worker намеренно отключён и удаляется в pwa.ts.
// Не запускаем Web Push инициализацию, которая заведомо завершится ошибкой.
if (process.env.NODE_ENV !== 'production') return false;
if (!('serviceWorker' in navigator)) {
console.warn('[PushNotifications] Service Worker не поддерживается');

View File

@ -50,3 +50,125 @@ test("calendar service deduplicates identical event range requests", async () =>
calendarService.clearCache();
}
});
test("calendar service receives range snapshots through the shared WebSocket", async () => {
const originalWindow = globalThis.window;
const originalLocalStorage = globalThis.localStorage;
const originalWebSocket = globalThis.WebSocket;
const originalLoadEvents = calendarService.loadEvents;
class FakeWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances = [];
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.CONNECTING;
this.sent = [];
FakeWebSocket.instances.push(this);
}
open() {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.();
}
send(payload) {
this.sent.push(JSON.parse(payload));
}
receive(payload) {
this.onmessage?.({ data: JSON.stringify(payload) });
}
close() {
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.({ code: 1000, reason: "", wasClean: true });
}
}
globalThis.window = { addEventListener() {} };
globalThis.localStorage = {
getItem(key) {
return key === "access_token" ? "calendar-token" : null;
},
};
globalThis.WebSocket = FakeWebSocket;
const received = [];
const loading = [];
let unsubscribe = () => {};
try {
unsubscribe = calendarService.subscribeEvents({
startDate: new Date("2026-08-01T00:00:00Z"),
endDate: new Date("2026-09-01T00:00:00Z"),
calendarId: 7,
calendarScope: "accessible",
canUseAllCalendarsMode: false,
refreshTrigger: 0,
onEvents: (events) => received.push(events),
onLoading: (value) => loading.push(value),
});
assert.equal(FakeWebSocket.instances.length, 1);
const socket = FakeWebSocket.instances[0];
socket.open();
const subscription = socket.sent.find(
(payload) => payload.action === "calendar_subscribe",
);
assert.ok(subscription);
assert.equal(subscription.calendar_id, 7);
socket.receive({
type: "calendar_snapshot",
data: {
subscription_id: subscription.subscription_id,
events: [
{
id: 42,
title: "Планёрка",
description: "",
start: "2026-08-14T09:00:00+03:00",
end: "2026-08-14T10:00:00+03:00",
calendar: 7,
is_recurring: false,
},
],
},
});
assert.equal(received.length, 1);
assert.equal(received[0][0].id, 42);
assert.deepEqual(loading, [true, false]);
calendarService.loadEvents = async () => [
{
id: 43,
title: "HTTP fallback",
description: "",
start: "2026-08-15T09:00:00+03:00",
end: "2026-08-15T10:00:00+03:00",
calendar: 7,
is_recurring: false,
},
];
socket.receive({
type: "calendar_error",
subscription_id: subscription.subscription_id,
detail: "temporary snapshot failure",
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(received.length, 2);
assert.equal(received[1][0].id, 43);
} finally {
unsubscribe();
calendarService.loadEvents = originalLoadEvents;
globalThis.window = originalWindow;
globalThis.localStorage = originalLocalStorage;
globalThis.WebSocket = originalWebSocket;
}
});

View File

@ -4,6 +4,7 @@
*/
import { apiClient } from "@/lib/api";
import wsManager from "@/lib/websocketManager";
import type {
CalendarEvent as CalendarEventDetails,
CalendarListEvent,
@ -52,13 +53,41 @@ interface EventsCache {
timestamp: number;
}
export interface CalendarEventsSubscriptionOptions {
startDate: Date;
endDate: Date;
calendarId: number | null;
calendarScope: "accessible" | "all";
canUseAllCalendarsMode: boolean;
refreshTrigger: number | string;
onEvents: (events: CalendarEvent[]) => void;
onLoading?: (loading: boolean) => void;
onError?: (error: unknown) => void;
}
type CalendarSnapshotEvent = {
type: "calendar_snapshot";
data?: {
subscription_id?: string;
events?: CalendarEvent[];
};
};
type CalendarErrorEvent = {
type: "calendar_error";
subscription_id?: string;
detail?: string;
};
// Константы
const CACHE_TTL = 30000; // 30 секунд
const REALTIME_FALLBACK_DELAY_MS = 3000;
// Глобальный кэш для событий календаря
let eventsCache: EventsCache | null = null;
let eventsCacheGeneration = 0;
let eventsInFlight: { key: string; promise: Promise<CalendarEvent[]> } | null = null;
let realtimeSubscriptionSequence = 0;
// Утилиты для работы с датами
const pad = (v: number) => String(v).padStart(2, "0");
@ -105,8 +134,134 @@ function normalizeOccurrence(occurrence: CalendarOccurrence): CalendarEvent {
};
}
function normalizeRealtimeEvent(event: CalendarEvent): CalendarEvent {
if (!event.is_recurring) {
return {
...event,
color_event: event.color_event ?? null,
is_recurring: false,
};
}
return {
...event,
title: event.title.startsWith("⟲") ? event.title : `⟲ ${event.title}`,
color_event: event.color_event ?? null,
is_recurring: true,
};
}
// Сервис для работы с событиями календаря
class CalendarService {
/**
* Подписаться на диапазон событий через единое WebSocket-соединение.
* HTTP используется только как fallback, если snapshot не пришёл вовремя.
*/
subscribeEvents(options: CalendarEventsSubscriptionOptions): () => void {
const subscriptionId = `calendar-${++realtimeSubscriptionSequence}`;
let active = true;
let fallbackInFlight = false;
let snapshotVersion = 0;
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
const clearFallbackTimer = () => {
if (fallbackTimer) {
clearTimeout(fallbackTimer);
fallbackTimer = null;
}
};
const sendSubscription = () => {
wsManager.send({
action: "calendar_subscribe",
subscription_id: subscriptionId,
start: options.startDate.toISOString(),
end: options.endDate.toISOString(),
calendar_id: options.calendarId,
scope:
options.canUseAllCalendarsMode && options.calendarScope === "all"
? "all"
: "accessible",
});
};
const loadFallback = async () => {
if (!active || fallbackInFlight) return;
fallbackInFlight = true;
const requestedAtSnapshotVersion = snapshotVersion;
try {
const events = await this.loadEvents(
options.startDate,
options.endDate,
options.calendarId,
options.calendarScope,
options.canUseAllCalendarsMode,
`ws-fallback:${options.refreshTrigger}:${subscriptionId}`,
);
if (active && requestedAtSnapshotVersion === snapshotVersion) {
options.onEvents(events);
options.onLoading?.(false);
}
} catch (error) {
if (active && requestedAtSnapshotVersion === snapshotVersion) {
options.onError?.(error);
options.onLoading?.(false);
}
} finally {
fallbackInFlight = false;
}
};
options.onLoading?.(true);
const unsubscribeMessages = wsManager.subscribe((event) => {
if (event.type === "calendar_snapshot") {
const snapshot = event as CalendarSnapshotEvent;
if (snapshot.data?.subscription_id !== subscriptionId) return;
snapshotVersion += 1;
clearFallbackTimer();
const events = Array.isArray(snapshot.data.events)
? snapshot.data.events.map(normalizeRealtimeEvent)
: [];
options.onEvents(events);
options.onLoading?.(false);
return;
}
if (event.type === "calendar_error") {
const errorEvent = event as CalendarErrorEvent;
if (errorEvent.subscription_id !== subscriptionId) return;
clearFallbackTimer();
void loadFallback();
}
});
const unsubscribeStatus = wsManager.subscribeStatus((status) => {
if (!active) return;
if (status.isConnected) {
sendSubscription();
} else if (
!status.isConnecting
&& status.reconnectAttempts >= status.maxReconnectAttempts
) {
clearFallbackTimer();
void loadFallback();
}
});
fallbackTimer = setTimeout(() => {
void loadFallback();
}, REALTIME_FALLBACK_DELAY_MS);
return () => {
active = false;
clearFallbackTimer();
wsManager.send({
action: "calendar_unsubscribe",
subscription_id: subscriptionId,
});
unsubscribeMessages();
unsubscribeStatus();
};
}
/**
* Загружает события календаря с кэшированием
* @param startDate Начальная дата периода