Merge develop into master
All checks were successful
Deploy production / deploy (push) Successful in 9m21s
All checks were successful
Deploy production / deploy (push) Successful in 9m21s
This commit is contained in:
commit
587bccd0e1
@ -361,7 +361,6 @@ def filter_events_for_user(
|
||||
return queryset.filter(
|
||||
Q(calendar_id__in=calendar_ids)
|
||||
| Q(id__in=participant_event_ids)
|
||||
| Q(calendar__binding__type=CalendarBinding.BindingType.DEPARTMENT)
|
||||
| Q(
|
||||
calendar__binding__type=CalendarBinding.BindingType.DEFAULT,
|
||||
calendar__binding__access_scope=CalendarBinding.AccessScope.ALL,
|
||||
|
||||
@ -605,6 +605,60 @@ class TestRecurringEvents:
|
||||
'Admin event',
|
||||
'Other event',
|
||||
}
|
||||
|
||||
def test_admin_accessible_scope_excludes_unrelated_department_occurrences(
|
||||
self, api_client, admin_user, user1
|
||||
):
|
||||
"""Календарь отдела не становится доступным только из-за его типа."""
|
||||
department_calendar = Calendar.objects.create(
|
||||
name='Sales',
|
||||
slug='department-sales',
|
||||
)
|
||||
CalendarBinding.objects.create(
|
||||
calendar=department_calendar,
|
||||
type=CalendarBinding.BindingType.DEPARTMENT,
|
||||
)
|
||||
rule = Rule.objects.create(
|
||||
name='Department daily',
|
||||
description='Department recurrence',
|
||||
frequency='DAILY',
|
||||
params='',
|
||||
)
|
||||
start = timezone.now().replace(microsecond=0, second=0)
|
||||
range_end = start + timedelta(days=2)
|
||||
Event.objects.create(
|
||||
calendar=department_calendar,
|
||||
title='Sales recurring event',
|
||||
start=start,
|
||||
end=start + timedelta(hours=1),
|
||||
creator=user1,
|
||||
rule=rule,
|
||||
end_recurring_period=range_end,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=admin_user)
|
||||
accessible_occurrences = api_client.get(
|
||||
'/api/v1/schedule/events/occurrences/',
|
||||
{
|
||||
'start': start.isoformat(),
|
||||
'end': range_end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert accessible_occurrences.status_code == status.HTTP_200_OK
|
||||
assert accessible_occurrences.data == []
|
||||
|
||||
all_occurrences = api_client.get(
|
||||
'/api/v1/schedule/events/occurrences/',
|
||||
{
|
||||
'scope': 'all',
|
||||
'start': start.isoformat(),
|
||||
'end': range_end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert all_occurrences.status_code == status.HTTP_200_OK
|
||||
assert {event['title'] for event in all_occurrences.data} == {
|
||||
'Sales recurring event',
|
||||
}
|
||||
|
||||
def test_create_rule(self, api_client, user1):
|
||||
"""Создание правила повторения."""
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Calendar,
|
||||
CalendarDays,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Clock3,
|
||||
Edit2,
|
||||
FileText,
|
||||
Kanban,
|
||||
Link2,
|
||||
Loader2,
|
||||
Repeat2,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@ -21,6 +22,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { displayUserName } from "@/lib/shared";
|
||||
import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns";
|
||||
import { getCalendarEventDisplayRange } from "@/lib/calendar/eventRange";
|
||||
import type { TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
|
||||
|
||||
type EventRuleData = {
|
||||
@ -38,6 +40,7 @@ type EventDetails = Record<string, unknown> & {
|
||||
start?: string | Date | null;
|
||||
end?: string | Date | null;
|
||||
calendar?: number | null;
|
||||
calendar_name?: string;
|
||||
color_event?: string | null;
|
||||
can_edit?: boolean;
|
||||
can_delete?: boolean;
|
||||
@ -108,6 +111,13 @@ const WEEKDAY_LABELS: Record<number, string> = {
|
||||
6: "ВС",
|
||||
};
|
||||
|
||||
const PARTICIPANT_ROLE_LABELS: Record<string, string> = {
|
||||
owner: "Владелец",
|
||||
editor: "Редактор",
|
||||
viewer: "Просмотр",
|
||||
attendee: "Участник",
|
||||
};
|
||||
|
||||
// Нормализация byweekday в массив чисел
|
||||
function normalizeByweekday(byweekday: unknown): number[] {
|
||||
if (!byweekday) return [];
|
||||
@ -204,22 +214,23 @@ export function ViewEventDetailsModal({
|
||||
|
||||
if (!isOpen || !event) return null;
|
||||
|
||||
const formatDateTime = (dateStr?: string | Date | null) => {
|
||||
if (!dateStr) return "";
|
||||
const date = new Date(dateStr);
|
||||
return format(date, "d MMMM yyyy, HH:mm", { locale: ru });
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string | Date | null) => {
|
||||
if (!dateStr) return "";
|
||||
const date = new Date(dateStr);
|
||||
return format(date, "d MMMM yyyy", { locale: ru });
|
||||
const label = format(date, "d MMMM yyyy", { locale: ru });
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
};
|
||||
|
||||
const formattedStart = formatDateTime(event.start);
|
||||
const formattedEnd = formatDateTime(event.end);
|
||||
const capitalizedStart = formattedStart.charAt(0).toUpperCase() + formattedStart.slice(1);
|
||||
const capitalizedEnd = formattedEnd.charAt(0).toUpperCase() + formattedEnd.slice(1);
|
||||
const formatTime = (dateStr?: string | Date | null) => {
|
||||
if (!dateStr) return "";
|
||||
return format(new Date(dateStr), "HH:mm", { locale: ru });
|
||||
};
|
||||
|
||||
const displayRange = getCalendarEventDisplayRange(event);
|
||||
const formattedStartDate = formatDate(event.start);
|
||||
const formattedEndDate = formatDate(event.end);
|
||||
const formattedStartTime = formatTime(event.start);
|
||||
const formattedEndTime = formatTime(event.end);
|
||||
const canEdit = event.can_edit !== false;
|
||||
const canDelete = event.can_delete !== false;
|
||||
const ruleFrequency = typeof event.rule_data?.frequency === "string"
|
||||
@ -361,132 +372,167 @@ export function ViewEventDetailsModal({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={event.title}
|
||||
size="sm"
|
||||
size="md"
|
||||
closeOnClickOutside
|
||||
footer={canEdit || canDelete || event.id ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.id ? (
|
||||
<button
|
||||
onClick={() => void openTaskLinkModal()}
|
||||
className="app-action-secondary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
>
|
||||
<Link2 size={16} />
|
||||
Связать с задачей
|
||||
</button>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="app-action-primary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
) : null}
|
||||
<div className="app-divider flex flex-col-reverse gap-2 border-t pb-4 pt-4 sm:flex-row sm:items-center sm:justify-between sm:pb-6">
|
||||
{canDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="app-action-danger rounded-lg px-4 py-2.5 text-sm font-medium"
|
||||
className="app-action-danger inline-flex items-center justify-center gap-2 rounded-xl px-3.5 py-2.5 text-sm font-medium"
|
||||
title="Удалить событие"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span className="sm:hidden">Удалить</span>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex flex-col-reverse gap-2 sm:ml-auto sm:flex-row">
|
||||
{event.id ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openTaskLinkModal()}
|
||||
className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
|
||||
>
|
||||
<Link2 size={16} />
|
||||
Связать задачу
|
||||
</button>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Event meta */}
|
||||
<div className="flex items-center gap-2">
|
||||
{event.rule && (
|
||||
<span className="app-badge app-badge-accent shrink-0 rounded px-1.5 py-0.5 text-xs">
|
||||
⟲
|
||||
</span>
|
||||
)}
|
||||
<div className="app-text-muted flex items-center gap-1.5 text-xs">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: resolveEventColor(event.color_event) }}
|
||||
/>
|
||||
<span className="truncate">Календарь #{event.calendar}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Time */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock size={18} className="app-text-muted mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="mb-1 text-sm font-medium text-[var(--foreground)]">Время</p>
|
||||
<p className="app-text-muted text-xs">
|
||||
<span className="font-medium">Начало:</span> {capitalizedStart}
|
||||
</p>
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
<span className="font-medium">Конец:</span> {capitalizedEnd}
|
||||
</p>
|
||||
<div className="space-y-4 pb-1">
|
||||
{/* Основная информация */}
|
||||
<section className="app-surface-muted relative overflow-hidden rounded-2xl border border-[var(--border-subtle)]">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 w-1"
|
||||
style={{ backgroundColor: resolveEventColor(event.color_event) }}
|
||||
/>
|
||||
<div className="space-y-4 p-4 pl-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="app-badge app-badge-interactive gap-1.5 px-2.5 py-1 text-xs font-medium">
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: resolveEventColor(event.color_event) }}
|
||||
/>
|
||||
{event.calendar_name || `Календарь #${event.calendar}`}
|
||||
</span>
|
||||
{event.rule ? (
|
||||
<span className="app-badge app-badge-accent app-badge-interactive gap-1.5 px-2.5 py-1 text-xs font-medium">
|
||||
<Repeat2 size={13} />
|
||||
Повторяется
|
||||
</span>
|
||||
) : null}
|
||||
{displayRange?.isMultiDay ? (
|
||||
<span className="app-badge app-badge-accent app-badge-interactive px-2.5 py-1 text-xs font-medium">
|
||||
{displayRange.dayCountLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-primary)] p-3">
|
||||
<div className="app-text-muted flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide">
|
||||
<CalendarDays size={14} />
|
||||
Начало
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground)]">{formattedStartDate || "Без даты"}</p>
|
||||
{formattedStartTime ? <p className="app-text-muted mt-0.5 text-xs">{formattedStartTime}</p> : null}
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-primary)] p-3">
|
||||
<div className="app-text-muted flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide">
|
||||
<Clock3 size={14} />
|
||||
Завершение
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground)]">{formattedEndDate || "Не указано"}</p>
|
||||
{formattedEndTime ? <p className="app-text-muted mt-0.5 text-xs">{formattedEndTime}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayRange ? (
|
||||
<p className="app-text-muted flex items-center gap-2 text-xs">
|
||||
<Clock3 size={14} className="shrink-0" />
|
||||
<span>{displayRange.timeLabel}</span>
|
||||
{displayRange.isMultiDay ? <span>· {displayRange.dateLabel}</span> : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recurring Info */}
|
||||
{event.rule && event.rule_data && (
|
||||
<div className="app-selected space-y-2 rounded-lg p-4">
|
||||
<div className="app-accent-text flex items-center gap-2 text-sm font-medium">
|
||||
<Calendar size={16} />
|
||||
<span>Повторяющееся событие</span>
|
||||
</div>
|
||||
<div className="app-accent-text space-y-1 text-sm">
|
||||
<div>
|
||||
Частота: <span className="font-medium">{ruleFrequencyLabel}</span>
|
||||
<section className="rounded-2xl border border-[color:color-mix(in_srgb,var(--accent-primary)_22%,var(--border-subtle))] bg-[color:color-mix(in_srgb,var(--accent-primary)_7%,var(--surface-primary))] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="app-selected flex h-9 w-9 shrink-0 items-center justify-center rounded-xl">
|
||||
<Repeat2 size={17} className="app-accent-text" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Повторение</p>
|
||||
<p className="app-text-muted mt-0.5 text-xs">{ruleFrequencyLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
{Boolean(ruleParams?.byweekday) && (
|
||||
<div>
|
||||
Дни недели: <span className="font-medium">
|
||||
<span className="app-badge app-badge-interactive px-2.5 py-1">
|
||||
Дни: <span className="ml-1 font-medium text-[var(--foreground)]">
|
||||
{normalizeByweekday(ruleParams?.byweekday).map((day: number) => WEEKDAY_LABELS[day]).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
{Boolean(ruleParams?.count) && (
|
||||
<div>
|
||||
Повторений: <span className="font-medium">{String(ruleParams?.count)}</span>
|
||||
</div>
|
||||
<span className="app-badge app-badge-interactive px-2.5 py-1">
|
||||
Повторений: <span className="ml-1 font-medium text-[var(--foreground)]">{String(ruleParams?.count)}</span>
|
||||
</span>
|
||||
)}
|
||||
{event.end_recurring_period && (
|
||||
<div>
|
||||
До: <span className="font-medium">{formatDate(event.end_recurring_period)}</span>
|
||||
</div>
|
||||
<span className="app-badge app-badge-interactive px-2.5 py-1">
|
||||
До: <span className="ml-1 font-medium text-[var(--foreground)]">{formatDate(event.end_recurring_period)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.description && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<FileText size={18} className="app-text-muted mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="mb-1 text-sm font-medium text-[var(--foreground)]">Описание</p>
|
||||
<p className="app-text-wrap app-text-muted whitespace-pre-wrap text-xs">
|
||||
<section className="app-surface-muted rounded-2xl border border-[var(--border-subtle)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<FileText size={16} className="app-text-muted shrink-0" />
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">Описание</p>
|
||||
</div>
|
||||
<p className="app-text-wrap app-text-muted whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{event.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{event.id ? (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Link2 size={18} className="app-text-muted mt-0.5 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<section className="app-surface-muted rounded-2xl border border-[var(--border-subtle)] p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">
|
||||
<Link2 size={16} className="app-text-muted shrink-0" />
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">
|
||||
Связанные объекты
|
||||
</p>
|
||||
<span className="app-pill-count rounded-full px-2 py-0.5 text-[10px] font-bold">
|
||||
<span className="app-badge px-2 py-0.5 text-[10px] font-bold">
|
||||
{linkedTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openTaskLinkModal()}
|
||||
className="app-action-ghost shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
|
||||
className="app-action-secondary shrink-0 rounded-lg px-2.5 py-1.5 text-xs font-medium"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
@ -561,22 +607,22 @@ export function ViewEventDetailsModal({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Participants */}
|
||||
{showParticipants && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Users size={18} className="app-text-muted mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="mb-1.5 text-sm font-medium text-[var(--foreground)]">
|
||||
<section className="app-surface-muted rounded-2xl border border-[var(--border-subtle)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Users size={16} className="app-text-muted shrink-0" />
|
||||
<p className="text-sm font-semibold text-[var(--foreground)]">
|
||||
Участники {participants.length > 0 && `(${participants.length})`}
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
{loadingParticipants ? (
|
||||
<p className="app-text-muted text-xs">Загрузка...</p>
|
||||
) : participants.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{participants.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
@ -592,7 +638,7 @@ export function ViewEventDetailsModal({
|
||||
{participant.user_name}
|
||||
</div>
|
||||
<div className="app-text-muted">
|
||||
{participant.distinction || "attendee"}
|
||||
{PARTICIPANT_ROLE_LABELS[participant.distinction || "attendee"] || participant.distinction}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -601,8 +647,7 @@ export function ViewEventDetailsModal({
|
||||
) : (
|
||||
<p className="app-text-muted text-xs">Нет участников</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Bell, ChevronDown, ChevronLeft, ChevronRight, Pencil, ShieldCheck, Trash2, Users } from "lucide-react";
|
||||
import { Bell, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Clock3, Pencil, ShieldCheck, Trash2, Users } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useCalendar } from "@/contexts/CalendarContext";
|
||||
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
|
||||
@ -19,6 +19,7 @@ import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-col
|
||||
import {
|
||||
calendarEventOverlapsRange,
|
||||
formatLocalDateKey,
|
||||
getCalendarEventDisplayRange,
|
||||
getCalendarEventDateKeys,
|
||||
} from "@/lib/calendar/eventRange";
|
||||
import {
|
||||
@ -109,6 +110,11 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
[calendars, selectedCalendar],
|
||||
);
|
||||
|
||||
const calendarNamesById = useMemo(
|
||||
() => new Map(calendars.map((calendar) => [calendar.id, calendar.name])),
|
||||
[calendars],
|
||||
);
|
||||
|
||||
const participantsModalAvailable = canOpenParticipantsModal(selectedCalendar);
|
||||
const departmentCalendar = isDepartmentCalendar(selectedCalendar);
|
||||
|
||||
@ -670,19 +676,10 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{visibleEvents.map((event) => {
|
||||
const start = event.start ? new Date(event.start) : null;
|
||||
const eventId = getLinkedCalendarEventId(event);
|
||||
const linkedTasks = eventId ? linkedTasksByEventId[eventId] || [] : [];
|
||||
const dateLabel =
|
||||
start && !Number.isNaN(start.getTime())
|
||||
? start.toLocaleString("ru-RU", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "Без даты";
|
||||
const displayRange = getCalendarEventDisplayRange(event);
|
||||
const calendarName = calendarNamesById.get(event.calendar);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -706,7 +703,31 @@ export const CalendarCard = memo(function CalendarCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="app-text-muted mt-0.5 text-[11px]">{dateLabel}</p>
|
||||
{displayRange ? (
|
||||
<div className="app-text-muted mt-1 space-y-0.5 text-[11px]">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<CalendarDays size={12} className="shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">{displayRange.dateLabel}</span>
|
||||
{displayRange.isMultiDay ? (
|
||||
<span className="app-badge app-badge-accent app-badge-interactive shrink-0 px-1.5 py-0.5 text-[10px] font-medium leading-none">
|
||||
{displayRange.dayCountLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Clock3 size={12} className="shrink-0" aria-hidden="true" />
|
||||
<span className="shrink-0">{displayRange.timeLabel}</span>
|
||||
{selectedCalendarId === null && calendarName ? (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="min-w-0 truncate">{calendarName}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-text-muted mt-0.5 text-[11px]">Без даты</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{linkedTasks.length > 0 ? (
|
||||
|
||||
@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
calendarEventOverlapsRange,
|
||||
getCalendarEventDateKeys,
|
||||
getCalendarEventDisplayRange,
|
||||
} from "./eventRange.ts";
|
||||
|
||||
test("event crossing the week start is included in the current week", () => {
|
||||
@ -73,3 +74,57 @@ test("calendar day markers include a day when the event continues after midnight
|
||||
["2026-08-31", "2026-09-01"],
|
||||
);
|
||||
});
|
||||
|
||||
test("calendar event display range marks events spanning several days", () => {
|
||||
const range = getCalendarEventDisplayRange({
|
||||
start: "2026-08-28T10:00:00",
|
||||
end: "2026-08-30T18:00:00",
|
||||
});
|
||||
|
||||
assert.ok(range);
|
||||
assert.equal(range.isMultiDay, true);
|
||||
assert.equal(range.dayCount, 3);
|
||||
assert.equal(range.dayCountLabel, "3 дня");
|
||||
assert.match(range.dateLabel, /28/);
|
||||
assert.match(range.dateLabel, /30/);
|
||||
assert.match(range.timeLabel, /10:00/);
|
||||
assert.match(range.timeLabel, /18:00/);
|
||||
});
|
||||
|
||||
test("midnight end does not add an empty calendar day", () => {
|
||||
const range = getCalendarEventDisplayRange({
|
||||
start: "2026-08-28T00:00:00",
|
||||
end: "2026-08-30T00:00:00",
|
||||
});
|
||||
|
||||
assert.ok(range);
|
||||
assert.equal(range.isMultiDay, true);
|
||||
assert.equal(range.dayCount, 2);
|
||||
assert.equal(range.dayCountLabel, "2 дня");
|
||||
assert.equal(range.timeLabel, "Весь день");
|
||||
});
|
||||
|
||||
test("single-day event stays compact", () => {
|
||||
const range = getCalendarEventDisplayRange({
|
||||
start: "2026-08-28T10:00:00",
|
||||
end: "2026-08-28T11:30:00",
|
||||
});
|
||||
|
||||
assert.ok(range);
|
||||
assert.equal(range.isMultiDay, false);
|
||||
assert.equal(range.dayCount, 1);
|
||||
assert.equal(range.dayCountLabel, "1 день");
|
||||
assert.match(range.timeLabel, /10:00/);
|
||||
assert.match(range.timeLabel, /11:30/);
|
||||
});
|
||||
|
||||
test("full calendar day is labeled as all-day", () => {
|
||||
const range = getCalendarEventDisplayRange({
|
||||
start: "2026-09-01T00:00:00",
|
||||
end: "2026-09-01T23:59:00",
|
||||
});
|
||||
|
||||
assert.ok(range);
|
||||
assert.equal(range.isMultiDay, false);
|
||||
assert.equal(range.timeLabel, "Весь день");
|
||||
});
|
||||
|
||||
@ -3,6 +3,14 @@ export type CalendarEventInterval = {
|
||||
end?: string | Date | null;
|
||||
};
|
||||
|
||||
export type CalendarEventDisplayRange = {
|
||||
dateLabel: string;
|
||||
timeLabel: string;
|
||||
dayCount: number;
|
||||
dayCountLabel: string;
|
||||
isMultiDay: boolean;
|
||||
};
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
export const formatLocalDateKey = (date: Date): string =>
|
||||
@ -14,6 +22,70 @@ const parseEventDate = (value: string | Date | null | undefined): Date | null =>
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
|
||||
const formatShortDate = (date: Date, includeWeekday = false) =>
|
||||
date.toLocaleDateString("ru-RU", {
|
||||
...(includeWeekday ? { weekday: "short" as const } : {}),
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
const formatTime = (date: Date) =>
|
||||
date.toLocaleTimeString("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const formatDayCount = (count: number) => {
|
||||
const mod100 = count % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return `${count} дней`;
|
||||
const mod10 = count % 10;
|
||||
if (mod10 === 1) return `${count} день`;
|
||||
if (mod10 >= 2 && mod10 <= 4) return `${count} дня`;
|
||||
return `${count} дней`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Компактное, но полное представление периода события для карточек.
|
||||
* Для границы окончания в 00:00 показывается последний фактически занятый день.
|
||||
*/
|
||||
export function getCalendarEventDisplayRange(
|
||||
event: CalendarEventInterval,
|
||||
): CalendarEventDisplayRange | null {
|
||||
const start = parseEventDate(event.start);
|
||||
if (!start) return null;
|
||||
|
||||
const parsedEnd = parseEventDate(event.end);
|
||||
const end = parsedEnd && parsedEnd > start ? parsedEnd : start;
|
||||
const dateKeys = getCalendarEventDateKeys(event);
|
||||
const dayCount = Math.max(1, dateKeys.length);
|
||||
const isMultiDay = dayCount > 1;
|
||||
|
||||
let lastOccupiedDay = end;
|
||||
if (parsedEnd && parsedEnd > start) {
|
||||
lastOccupiedDay = new Date(parsedEnd.getTime() - 1);
|
||||
}
|
||||
|
||||
const dateLabel = isMultiDay
|
||||
? `${formatShortDate(start)} — ${formatShortDate(lastOccupiedDay)}`
|
||||
: formatShortDate(start, true);
|
||||
const startsAtMidnight = start.getHours() === 0 && start.getMinutes() === 0;
|
||||
const endsAtMidnight = end.getHours() === 0 && end.getMinutes() === 0;
|
||||
const endsAtDayEnd = end.getHours() === 23 && end.getMinutes() === 59;
|
||||
const timeLabel = startsAtMidnight && (endsAtMidnight || endsAtDayEnd)
|
||||
? "Весь день"
|
||||
: end > start
|
||||
? `${formatTime(start)}–${formatTime(end)}`
|
||||
: formatTime(start);
|
||||
|
||||
return {
|
||||
dateLabel,
|
||||
timeLabel,
|
||||
dayCount,
|
||||
dayCountLabel: formatDayCount(dayCount),
|
||||
isMultiDay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет пересечение события с полуоткрытым диапазоном [start, end).
|
||||
* Для событий без корректного окончания используется дата начала как момент времени.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user