import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowsClockwiseIcon } from '@phosphor-icons/react'
import {
Alert,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
LoadingBlock,
QueryErrorState,
} from '@/ui'
import { useWebhookStats } from '@/api/queries'
import type { WebhookStatsBucket } from '@/api/client'
import { cn } from '@/lib/utils'
/**
* Time-series histogram delivery'и подписки. Stacked bar chart на чистом SVG
* (без recharts/chart-js dependency'и — bundle stays slim).
*
*
Layout:
*
* ┌────────────────────────────────────────────────────────┐
* │ Header: title + bucketBy toggle (hour/day) + refresh │
* ├────────────────────────────────────────────────────────┤
* │ Legend: pending · retrying · delivered · dlq │
* ├────────────────────────────────────────────────────────┤
* │ Chart area: │
* │ stacked bars per bucket, y-axis = count │
* │ x-axis ticks: каждый 4-й bucket (hour) / day name │
* │ hover tooltip → counts breakdown │
* └────────────────────────────────────────────────────────┘
*
*
* Empty state: «Нет данных за выбранный период» — когда все buckets total=0.
* Visible-only: фильтруем chart area до bucket'ов, где total > 0, чтобы
* sparse data не растягивала ось. X-axis labels всё равно показывают полный
* диапазон чтобы юзер видел временное окно.
*/
export function WebhookStatsHistogram({
subscriptionId,
}: {
subscriptionId: string
}) {
const { t } = useTranslation()
const [bucketBy, setBucketBy] = useState<'hour' | 'day'>('hour')
const stats = useWebhookStats(subscriptionId, bucketBy)
if (stats.isLoading) {
return (
{t('webhooks.histogram.title')}
)
}
if (stats.isError) {
return (
{t('webhooks.histogram.title')}
stats.refetch()} />
)
}
const data = stats.data
if (!data) return null
return (
{t('webhooks.histogram.title')}
setBucketBy('hour')}
className={cn(
'px-2.5 py-1 transition-colors',
bucketBy === 'hour'
? 'bg-accent text-on-accent'
: 'bg-surface text-ink-2 hover:bg-surface-2',
)}
>
{t('webhooks.histogram.bucket.hour', { defaultValue: '24ч' })}
setBucketBy('day')}
className={cn(
'px-2.5 py-1 transition-colors border-l border-line',
bucketBy === 'day'
? 'bg-accent text-on-accent'
: 'bg-surface text-ink-2 hover:bg-surface-2',
)}
>
{t('webhooks.histogram.bucket.day', { defaultValue: '7д' })}
stats.refetch()}
disabled={stats.isFetching}
aria-label={t('common.refresh')}
title={t('common.refresh')}
>
)
}
const STATUS_TOKENS = {
delivered: { color: 'fill-jade', labelKey: 'webhooks.status.delivered' },
retrying: { color: 'fill-amber', labelKey: 'webhooks.status.retrying' },
pending: { color: 'fill-mute/60', labelKey: 'webhooks.status.pending' },
dlq: { color: 'fill-mars', labelKey: 'webhooks.status.dlq' },
} as const
type StatusKey = keyof typeof STATUS_TOKENS
const STACK_ORDER: StatusKey[] = ['delivered', 'retrying', 'pending', 'dlq']
const CHART_HEIGHT = 180
const CHART_PADDING = { top: 8, right: 8, bottom: 28, left: 32 }
function HistogramChart({
buckets,
bucketBy,
}: {
buckets: WebhookStatsBucket[]
bucketBy: 'hour' | 'day'
}) {
const { t, i18n } = useTranslation()
const [hoveredIdx, setHoveredIdx] = useState(null)
const maxTotal = useMemo(
() => Math.max(1, ...buckets.map((b) => b.total)),
[buckets],
)
// Если всё пусто — показываем empty state, не пустой chart с одним пикселем.
if (buckets.every((b) => b.total === 0)) {
return (
{t('webhooks.histogram.empty', {
defaultValue: 'Нет доставок за выбранный период.',
})}
)
}
const innerHeight = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom
const bucketCount = buckets.length
// ViewBox единиц: используем pixel-like coords чтобы text/тики читались.
// SVG растягивается через preserveAspectRatio (none) на full container width.
const VB_W = 800
const VB_H = CHART_HEIGHT
const innerW = VB_W - CHART_PADDING.left - CHART_PADDING.right
const barW = innerW / bucketCount
const barGap = Math.max(1, Math.min(2, barW * 0.15))
const actualBarW = Math.max(1, barW - barGap)
const tickStep = bucketBy === 'hour'
? Math.max(1, Math.floor(bucketCount / 6)) // 4 ticks per 24h
: 1 // каждый день для 7d view
return (
{/* Y-axis ticks (0, mid, max) */}
{[0, 0.5, 1].map((frac) => {
const y = CHART_PADDING.top + innerHeight * (1 - frac)
const val = Math.round(maxTotal * frac)
return (
{val}
)
})}
{/* Stacked bars */}
{buckets.map((bucket, idx) => {
const x = CHART_PADDING.left + idx * barW + barGap / 2
let yCursor = CHART_PADDING.top + innerHeight
const isHovered = hoveredIdx === idx
return (
setHoveredIdx(idx)}
onMouseLeave={() => setHoveredIdx(null)}
className="cursor-pointer"
>
{/* Невидимая hover-zone — full column. Чтобы hover работал
* даже на zero bucket'ах. */}
{STACK_ORDER.map((status) => {
const value = bucket[status]
if (!value) return null
const segH = (value / maxTotal) * innerHeight
yCursor -= segH
return (
)
})}
)
})}
{/* X-axis ticks */}
{buckets.map((bucket, idx) => {
if (idx % tickStep !== 0 && idx !== buckets.length - 1) return null
const x = CHART_PADDING.left + idx * barW + barW / 2
return (
{formatTick(bucket.ts, bucketBy, i18n.language)}
)
})}
{/* Tooltip overlay */}
{hoveredIdx !== null && (
)}
)
}
function Legend() {
const { t } = useTranslation()
return (
{STACK_ORDER.map((status) => (
{t(STATUS_TOKENS[status].labelKey)}
))}
)
}
function BucketTooltip({
bucket,
bucketBy,
locale,
position,
}: {
bucket: WebhookStatsBucket
bucketBy: 'hour' | 'day'
locale: string
position: 'left' | 'right'
}) {
const { t } = useTranslation()
return (
{formatTooltip(bucket.ts, bucketBy, locale)}
{STACK_ORDER.map((status) => {
const value = bucket[status]
if (!value) return null
return (
{t(STATUS_TOKENS[status].labelKey)}
{value}
)
})}
{t('webhooks.histogram.total', { defaultValue: 'итого' })}
{bucket.total}
)
}
const formatTick = (iso: string, bucketBy: 'hour' | 'day', locale: string): string => {
const d = new Date(iso)
if (bucketBy === 'hour') {
return d.toLocaleTimeString(locale === 'ru' ? 'ru-RU' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
})
}
return d.toLocaleDateString(locale === 'ru' ? 'ru-RU' : 'en-US', {
day: '2-digit',
month: '2-digit',
})
}
const formatTooltip = (iso: string, bucketBy: 'hour' | 'day', locale: string): string => {
const d = new Date(iso)
if (bucketBy === 'hour') {
return d.toLocaleString(locale === 'ru' ? 'ru-RU' : 'en-US', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
return d.toLocaleDateString(locale === 'ru' ? 'ru-RU' : 'en-US', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
}