Первоначальная версия VidConf
This commit is contained in:
50
frontend/src/lib/localTime.ts
Normal file
50
frontend/src/lib/localTime.ts
Normal 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}`
|
||||
}
|
||||
81
frontend/src/lib/parseJoinQuery.ts
Normal file
81
frontend/src/lib/parseJoinQuery.ts
Normal 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
|
||||
}
|
||||
17
frontend/src/lib/pluralize.ts
Normal file
17
frontend/src/lib/pluralize.ts
Normal 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 'конференций'
|
||||
}
|
||||
144
frontend/src/lib/recurrenceFormat.ts
Normal file
144
frontend/src/lib/recurrenceFormat.ts
Normal 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)}`
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user