Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
/**
* Конвертация между локальным временем браузера (поля форм) и UTC ISO,
* который использует backend (в БД и API — только UTC,
* конвертация в локальную зону — на клиенте).
*/
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
/** IANA-таймзона браузера пользователя — для поля `timezone` правила повторения. */
export function browserTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
/** Локальные дата ("YYYY-MM-DD") и время ("HH:MM") из момента (для прелоада полей формы). */
export function toLocalDateTimeParts(iso: string): { date: string; time: string } {
const d = new Date(iso)
return {
date: `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`,
time: `${pad2(d.getHours())}:${pad2(d.getMinutes())}`,
}
}
/** Собрать UTC ISO-момент из локальной даты ("YYYY-MM-DD") и времени ("HH:MM"). */
export function localDateTimeToIso(date: string, time: string): string {
return new Date(`${date}T${time}`).toISOString()
}
/** Отформатировать UTC ISO-момент в локальную дату+время для карточек («21 июля, 15:00»). */
export function formatLocalDateTime(iso: string): string {
const d = new Date(iso)
const dateLabel = d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
const timeLabel = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return `${dateLabel}, ${timeLabel}`
}
/** Отформатировать UTC ISO-момент как локальное время «14:02» (для сообщений чата). */
export function formatLocalTime(iso: string): string {
return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
}
/** Отформатировать UTC ISO-момент как «вт, 22 июля, 11:00» (для «Ближайшее»). */
export function formatNextOccurrence(iso: string): string {
const d = new Date(iso)
const weekday = d.toLocaleDateString('ru-RU', { weekday: 'short' }).replace('.', '')
const dateLabel = d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
const timeLabel = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return `${weekday}, ${dateLabel}, ${timeLabel}`
}

View File

@@ -0,0 +1,81 @@
/**
* Разбор пользовательского ввода на экране подключения к конференции
* (JoinPage, поле «Ссылка или номер») в значение, которое можно передать
* backend'у как `q` в `GET /conferences/resolve?q=` — это либо slug конференции,
* либо её номер (цифры, возможно с пробелами).
*
* Поддерживаемые форматы входной строки:
* - голый slug: `oEjfD0s-XJg`;
* - голый номер, в т.ч. с пробелами: `8842 104`;
* - полная ссылка с любым origin и путём приглашения `/j/{slug}`:
* `https://vidconf.ru/j/oEjfD0s-XJg`, `vidconf.ru/j/oEjfD0s-XJg`;
* - полная ссылка на саму комнату `/room/{slug}` (её видно в адресной строке
* уже во время звонка и её тоже логично уметь вставить обратно):
* `http://localhost:5173/room/aCFxP2REfhw`;
* - с «хвостом» query/fragment после slug: `.../j/oEjfD0s-XJg?ref=email#x`.
*
* Тест-раннер (vitest/jest) в проекте не настроен (см. package.json) — вместо
* юнит-тестов ниже приведены примеры поведения в комментариях; при появлении
* тест-раннера их можно почти дословно перенести в `*.test.ts`.
*
* @example
* parseJoinQuery('8842 104') // '8842 104' (номер с пробелом — как есть)
* parseJoinQuery('oEjfD0s-XJg') // 'oEjfD0s-XJg' (голый slug — как есть)
* parseJoinQuery('vidconf.ru/j/oEjfD0s-XJg') // 'oEjfD0s-XJg'
* parseJoinQuery('https://vidconf.ru/j/oEjfD0s-XJg?ref=email') // 'oEjfD0s-XJg'
* parseJoinQuery('http://localhost:5173/room/aCFxP2REfhw') // 'aCFxP2REfhw'
* parseJoinQuery(' https://vidconf.ru/j/x-y#section ') // 'x-y'
*/
/** Путь приглашения (`/j/`) или прямая ссылка на комнату (`/room/`) — оба ведут к одному и тому же slug. */
const JOIN_PATH_SEGMENT = /\/(?:j|room)\/([^/?#]+)/i
/** Похоже на домен (`vidconf.ru/...`) без явной схемы `http(s)://`. */
const BARE_DOMAIN_LIKE = /^[\w-]+(?:\.[a-z]{2,})+(?::\d+)?(\/|$)/i
/** Явная схема URL (`http://`, `https://`, в теории и другие). */
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i
function safeDecode(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
/** Отрезает query (`?...`) и fragment (`#...`) от конца строки, если они есть. */
function stripQueryAndFragment(value: string): string {
const idx = value.search(/[?#]/)
return idx === -1 ? value : value.slice(0, idx)
}
/**
* Извлекает slug или номер конференции из произвольного пользовательского
* ввода (ссылка любого вида или номер). Если распознать путь `/j/`/`/room/`
* не удалось — возвращает исходную строку без схемы/домена (если они были)
* либо как есть, чтобы `resolveConference` сам решил, валидна она или нет
* (публичный эндпоинт и так отвечает единообразным 404, не палит различие
* между «неверный формат» и «не найдено»).
*/
export function parseJoinQuery(raw: string): string {
const trimmed = raw.trim()
if (!trimmed) return ''
const segmentMatch = trimmed.match(JOIN_PATH_SEGMENT)
if (segmentMatch) {
return safeDecode(segmentMatch[1])
}
const looksLikeUrl = URL_SCHEME.test(trimmed) || BARE_DOMAIN_LIKE.test(trimmed)
if (looksLikeUrl) {
const withoutScheme = trimmed.replace(URL_SCHEME, '')
const slashIndex = withoutScheme.indexOf('/')
if (slashIndex !== -1) {
const rest = stripQueryAndFragment(withoutScheme.slice(slashIndex + 1))
if (rest) return safeDecode(rest)
}
}
return stripQueryAndFragment(trimmed).trim() || trimmed
}

View File

@@ -0,0 +1,17 @@
/** Склонение русского слова «участник» по числу. */
export function pluralizeParticipants(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return 'участник'
if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) return 'участника'
return 'участников'
}
/** Склонение русского слова «конференция» по числу. */
export function pluralizeConferences(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return 'конференция'
if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) return 'конференции'
return 'конференций'
}

View File

@@ -0,0 +1,144 @@
/**
* Форматирование и лёгкий клиентский предпросмотр правила повторения
* конференции (`ConferenceRecurrence`). Источник истины по развёртке —
* backend (`backend/services/recurrence.py::expand_occurrences`); функции
* ниже — только для UI-подсказок в форме создания/редактирования
* («ближайшие вхождения») и подписей в карточках календаря/«Моих
* конференций» — точность до секунды/переход через полночь не гарантируется.
*/
import type { ConferenceRecurrence, RecurrenceType } from '@/api/conferences'
/** Короткие подписи дней недели для чипов (0 — понедельник .. 6 — воскресенье). */
export const WEEKDAY_SHORT = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', 'СБ', 'ВС'] as const
const WEEKDAY_DATIVE_PLURAL = [
'понедельникам',
'вторникам',
'средам',
'четвергам',
'пятницам',
'субботам',
'воскресеньям',
] as const
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
/** Прибавить минуты к времени вида "HH:MM" (без учёта перехода через полночь на новый день). */
export function addMinutesToTime(hhmm: string, minutes: number): string {
const [h, m] = hhmm.split(':').map(Number)
const total = ((h * 60 + m + minutes) % (24 * 60) + 24 * 60) % (24 * 60)
return `${pad2(Math.floor(total / 60))}:${pad2(total % 60)}`
}
/** Соединить список строк по-русски («а» / «а и б» / «а, б и в»). */
function joinRu(items: string[]): string {
if (items.length === 0) return ''
if (items.length === 1) return items[0]
return `${items.slice(0, -1).join(', ')} и ${items[items.length - 1]}`
}
/** Человекочитаемое описание правила повторения + диапазон времени вхождения. */
export function formatRecurrenceSummary(recurrence: ConferenceRecurrence): string {
const range = `${recurrence.time_local}${addMinutesToTime(recurrence.time_local, recurrence.duration_minutes)}`
switch (recurrence.type) {
case 'weekly': {
const days = [...(recurrence.weekdays ?? [])].sort((a, b) => a - b).map((w) => WEEKDAY_DATIVE_PLURAL[w])
return `По ${joinRu(days)}, ${range}`
}
case 'biweekly': {
const days = [...(recurrence.weekdays ?? [])].sort((a, b) => a - b).map((w) => WEEKDAY_DATIVE_PLURAL[w])
return `Раз в 2 недели, по ${joinRu(days)}, ${range}`
}
case 'monthly':
return `Раз в месяц, ${recurrence.day_of_month}-го числа, ${range}`
case 'every_n_days':
return `Каждые ${recurrence.interval_days} дней, ${range}`
default:
return range
}
}
/** Разобрать дату вида "YYYY-MM-DD" в локальную полночь (без смещения часового пояса). */
function parseDateOnly(value: string): Date {
const [y, m, d] = value.split('-').map(Number)
return new Date(y, m - 1, d)
}
function startOfWeek(d: Date): Date {
const jsWeekday = (d.getDay() + 6) % 7 // 0 — понедельник
const out = new Date(d)
out.setDate(out.getDate() - jsWeekday)
out.setHours(0, 0, 0, 0)
return out
}
function matchesCalendarDate(
type: RecurrenceType,
weekdays: number[],
dayOfMonth: number | undefined,
intervalDays: number | undefined,
anchor: Date,
candidate: Date,
): boolean {
const ourWeekday = (candidate.getDay() + 6) % 7
if (type === 'weekly') return weekdays.includes(ourWeekday)
if (type === 'biweekly') {
if (!weekdays.includes(ourWeekday)) return false
const weeksDiff = Math.round((startOfWeek(candidate).getTime() - startOfWeek(anchor).getTime()) / (7 * 86_400_000))
return ((weeksDiff % 2) + 2) % 2 === 0
}
if (type === 'monthly') {
const lastDay = new Date(candidate.getFullYear(), candidate.getMonth() + 1, 0).getDate()
const clamped = Math.min(dayOfMonth ?? 1, lastDay)
return candidate.getDate() === clamped
}
if (type === 'every_n_days') {
if (candidate < anchor) return false
const daysDiff = Math.round((candidate.getTime() - anchor.getTime()) / 86_400_000)
return daysDiff % Math.max(1, intervalDays ?? 1) === 0
}
return false
}
/**
* Приблизительный клиентский предпросмотр ближайших `count` дат вхождения —
* только для подсказки в форме (см. заголовок файла).
*/
export function previewNextDates(
recurrence: Pick<ConferenceRecurrence, 'type' | 'weekdays' | 'day_of_month' | 'interval_days' | 'anchor_date'>,
count = 2,
): Date[] {
const anchor = parseDateOnly(recurrence.anchor_date)
const today = new Date()
today.setHours(0, 0, 0, 0)
const start = anchor > today ? anchor : today
const result: Date[] = []
const cursor = new Date(start)
for (let i = 0; i < 3660 && result.length < count; i++) {
if (
matchesCalendarDate(
recurrence.type,
recurrence.weekdays ?? [],
recurrence.day_of_month,
recurrence.interval_days,
anchor,
cursor,
)
) {
result.push(new Date(cursor))
}
cursor.setDate(cursor.getDate() + 1)
}
return result
}
const DATE_HINT_FORMAT: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long' }
/** Отформатировать список дат для подсказки вида «ближайшие: вторник 21 июля, четверг 23 июля». */
export function formatPreviewHint(dates: Date[]): string {
if (dates.length === 0) return ''
const labels = dates.map((d) => d.toLocaleDateString('ru-RU', DATE_HINT_FORMAT))
return `Ближайшие: ${joinRu(labels)}`
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}