Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах. Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки, ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера. Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check` для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и `prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет вывод и красит CI на файлах, которых никто не трогал. `.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с настройками csharpier и Prettier намеренно — оба его читают. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
106 lines
3.9 KiB
TypeScript
106 lines
3.9 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { qk } from '@/shared/api/query-keys'
|
|
import { useTranslation } from 'react-i18next'
|
|
import type { EntryTraceDto } from '@/shared/api/types'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
|
import { getEntryTrace } from '../api'
|
|
import { formatChannelTime } from '../lib/format'
|
|
|
|
/**
|
|
* «Почему это здесь» (см. 6.5): цепочка происхождения записи. Трейс пишется в момент генерации —
|
|
* восстановить его потом нельзя, поэтому у старых записей часть строк будет пустой.
|
|
*/
|
|
export function EntryTraceDialog({
|
|
entryId,
|
|
utcOffsetMinutes,
|
|
onClose,
|
|
}: Readonly<{
|
|
entryId: string
|
|
utcOffsetMinutes: number
|
|
onClose: () => void
|
|
}>) {
|
|
const { t } = useTranslation()
|
|
const { data } = useQuery({
|
|
queryKey: qk.entries.trace(entryId),
|
|
queryFn: () => getEntryTrace(entryId),
|
|
})
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{data
|
|
? `${data.showName ?? '—'} · ${formatChannelTime(data.startsAtUtc, utcOffsetMinutes)}`
|
|
: t('common.loading')}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{data && (
|
|
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1.5 text-sm">
|
|
<Row label={t('admin.channels.traceLayer')}>{layerSummary(data, t)}</Row>
|
|
<Row label={t('admin.channels.traceSlot')}>{slotSummary(data, t)}</Row>
|
|
<Row label={t('admin.channels.traceGroup')}>{groupSummary(data)}</Row>
|
|
<Row label={t('admin.channels.traceCollection')}>{data.collectionName}</Row>
|
|
<Row label={t('admin.channels.traceStrategy')}>{strategySummary(data, t)}</Row>
|
|
<Row label={t('admin.channels.traceJunction')}>{data.junctionName}</Row>
|
|
</dl>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
type Translate = ReturnType<typeof useTranslation>['t']
|
|
|
|
/** Склейка непустых частей строки трейса; пусто — значит строка не заполнена (покажем «—»). */
|
|
const joinParts = (parts: (string | null | undefined)[]) =>
|
|
parts.filter(Boolean).join(' · ') || null
|
|
|
|
function layerSummary(data: EntryTraceDto, t: Translate) {
|
|
if (!data.layerName) return null
|
|
const priority =
|
|
data.layerPriority !== null ? ` (${t('admin.channels.priority')} ${data.layerPriority})` : ''
|
|
return `${data.layerName}${priority}`
|
|
}
|
|
|
|
function slotSummary(data: EntryTraceDto, t: Translate) {
|
|
if (!data.slotTitle) return null
|
|
return joinParts([
|
|
data.slotTitle,
|
|
data.slotWeekday === null
|
|
? t('admin.channels.everyDay')
|
|
: t(`admin.channels.weekdays.${data.slotWeekday}`),
|
|
data.slotTargetStart?.slice(0, 5),
|
|
data.slotDurationMinutes ? `${data.slotDurationMinutes} мин` : null,
|
|
data.driftMinutes !== 0 ? t('admin.channels.traceDrift', { minutes: data.driftMinutes }) : null,
|
|
data.snapped ? t('admin.channels.traceSnapped') : null,
|
|
])
|
|
}
|
|
|
|
function groupSummary(data: EntryTraceDto) {
|
|
if (!data.groupName) return null
|
|
const count = data.groupItemCount !== null ? ` (${data.groupItemCount})` : ''
|
|
return `${data.groupName}${count}`
|
|
}
|
|
|
|
function strategySummary(data: EntryTraceDto, t: Translate) {
|
|
if (!data.strategy) return null
|
|
return joinParts([
|
|
t(`admin.channels.strategies.${data.strategy}`),
|
|
data.cooldownDays ? t('admin.channels.traceCooldown', { days: data.cooldownDays }) : null,
|
|
data.candidatesAfterCooldown !== null
|
|
? t('admin.channels.traceCandidates', { count: data.candidatesAfterCooldown })
|
|
: null,
|
|
])
|
|
}
|
|
|
|
function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) {
|
|
return (
|
|
<>
|
|
<dt className="text-muted-foreground">{label}</dt>
|
|
<dd>{children || '—'}</dd>
|
|
</>
|
|
)
|
|
}
|