Files
sori.studio/lib/analytics-chart-ui.js
zenn 901463e5f3 관리자 통계 UI 개선 v1.9.7
기간 필터 hover 스타일 충돌을 수정하고, 통계 추이 기본값을 일간으로 변경한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:34:22 +09:00

524 lines
15 KiB
JavaScript

import { getAnalyticsDayKey } from './analytics-shared.js'
/** @type {Record<string, number>} 집계 단위별 차트 페이지 크기 */
export const ANALYTICS_CHART_PAGE_SIZES = {
daily: 7,
weekly: 8,
monthly: 12,
yearly: 8
}
/** @type {Record<string, number>} 집계 단위별 차트 전체 버킷 수(페이지 이동용) */
export const ANALYTICS_CHART_TOTAL_BUCKETS = {
daily: 28,
weekly: 24,
monthly: 24,
yearly: 16
}
/**
* 집계 단위별 차트 페이지 크기를 반환한다.
* @param {string} period - 집계 단위
* @returns {number} 페이지 크기
*/
export const getAnalyticsChartPageSize = (period) => {
return ANALYTICS_CHART_PAGE_SIZES[period] || 8
}
/**
* 집계 단위별 차트 전체 버킷 수를 반환한다.
* @param {string} period - 집계 단위
* @returns {number} 전체 버킷 수
*/
export const getAnalyticsChartTotalBuckets = (period) => {
return ANALYTICS_CHART_TOTAL_BUCKETS[period] || 16
}
/**
* 통계 추이 행의 종료일을 반환한다.
* @param {Object} row - 추이 행
* @returns {string} 종료일
*/
const getAnalyticsTrendEndDay = (row) => {
return String(row?.endDay || row?.day || '').trim()
}
/**
* 통계 추이 행의 시작일을 반환한다.
* @param {Object} row - 추이 행
* @returns {string} 시작일
*/
const getAnalyticsTrendStartDay = (row) => {
return String(row?.startDay || row?.day || '').trim()
}
/**
* 날짜 키를 Date 객체로 변환한다.
* @param {string} dayKey - YYYY-MM-DD
* @returns {Date | null} Date 객체
*/
const toAnalyticsDate = (dayKey) => {
const value = String(dayKey || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null
}
const date = new Date(`${value}T00:00:00.000Z`)
return Number.isNaN(date.getTime()) ? null : date
}
/**
* Date 객체를 통계 날짜 키로 변환한다.
* @param {Date} date - Date 객체
* @returns {string} YYYY-MM-DD
*/
const formatAnalyticsDateKey = (date) => {
return date.toISOString().slice(0, 10)
}
/**
* 두 날짜 키 중 더 늦은 날짜를 반환한다.
* @param {string} leftDay - YYYY-MM-DD
* @param {string} rightDay - YYYY-MM-DD
* @returns {string} 더 늦은 날짜 키
*/
const getLaterAnalyticsDayKey = (leftDay, rightDay) => {
const left = String(leftDay || '').trim()
const right = String(rightDay || '').trim()
if (!left) {
return right
}
if (!right) {
return left
}
return left > right ? left : right
}
/**
* 집계 단위에 맞춘 표시용 시작일을 반환한다.
* @param {string} period - 집계 단위
* @param {string} collectedSince - 최초 집계일(YYYY-MM-DD)
* @returns {string} 정렬된 시작일
*/
export const getAnalyticsPeriodAlignedStartDay = (period, collectedSince) => {
const date = toAnalyticsDate(collectedSince)
if (!date) {
return ''
}
if (period === 'monthly') {
return formatAnalyticsDateKey(new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)))
}
if (period === 'yearly') {
return formatAnalyticsDateKey(new Date(Date.UTC(date.getUTCFullYear(), 0, 1)))
}
if (period === 'weekly') {
const weekStart = new Date(date)
weekStart.setUTCDate(date.getUTCDate() - date.getUTCDay())
return formatAnalyticsDateKey(weekStart)
}
return collectedSince
}
/**
* 추이 조회 시작일과 집계 시작일 중 유효한 시작일을 반환한다.
* @param {string} period - 집계 단위
* @param {string} requestedStartDay - 요청 시작일
* @param {string} collectedSince - 최초 집계일
* @returns {string} 유효 시작일
*/
export const getAnalyticsEffectiveTrendStartDay = (period, requestedStartDay, collectedSince) => {
const alignedStartDay = getAnalyticsPeriodAlignedStartDay(period, collectedSince)
if (!alignedStartDay) {
return requestedStartDay
}
return getLaterAnalyticsDayKey(requestedStartDay, alignedStartDay)
}
/**
* 집계 시작일을 한국어 안내 문구로 변환한다.
* @param {string} dayKey - YYYY-MM-DD
* @returns {string} 안내 문구용 날짜
*/
export const formatAnalyticsCollectedSinceLabel = (dayKey) => {
const date = toAnalyticsDate(dayKey)
if (!date) {
return ''
}
return `${date.getUTCFullYear()}년 ${date.getUTCMonth() + 1}월 ${date.getUTCDate()}일`
}
/**
* 통계 추이 안내 문구를 반환한다.
* @param {string} period - 집계 단위
* @param {string} collectedSince - 최초 집계일
* @returns {string} 안내 문구
*/
export const getAnalyticsTrendDescription = (period, collectedSince) => {
if (!collectedSince) {
return '아직 집계된 통계 데이터가 없습니다.'
}
const label = formatAnalyticsCollectedSinceLabel(collectedSince)
if (!label) {
return '아직 집계된 통계 데이터가 없습니다.'
}
return `통계는 ${label}부터 집계한 데이터를 표시합니다.`
}
/**
* 현재 진행 중인 집계 구간인지 확인한다.
* @param {string} period - 집계 단위
* @param {Object} row - 추이 행
* @param {string} [todayKey] - 오늘 날짜 키
* @returns {boolean} 현재 구간 여부
*/
export const isAnalyticsCurrentTrendRow = (period, row, todayKey = getAnalyticsDayKey()) => {
const startDay = getAnalyticsTrendStartDay(row)
const endDay = getAnalyticsTrendEndDay(row)
if (!startDay || !endDay || endDay !== todayKey) {
return false
}
if (period === 'daily') {
return startDay === todayKey
}
return period === 'weekly' || period === 'monthly' || period === 'yearly'
}
/**
* 현재 구간의 상대 라벨을 반환한다.
* @param {string} period - 집계 단위
* @returns {string} 상대 라벨
*/
export const getAnalyticsCurrentPeriodLabel = (period) => {
if (period === 'daily') {
return '오늘'
}
if (period === 'weekly') {
return '이번\u00A0주'
}
if (period === 'monthly') {
return '이번\u00A0달'
}
if (period === 'yearly') {
return '올해'
}
return ''
}
/**
* 통계 추이 툴팁·상세용 라벨을 반환한다.
* @param {string} period - 집계 단위
* @param {Object} row - 추이 행
* @param {string} [todayKey] - 오늘 날짜 키
* @returns {string} 라벨
*/
export const getAnalyticsTrendDetailLabel = (period, row, todayKey = getAnalyticsDayKey()) => {
if (!row) {
return ''
}
if (row.label) {
return row.label
}
if (isAnalyticsCurrentTrendRow(period, row, todayKey)) {
return getAnalyticsCurrentPeriodLabel(period)
}
const startDay = getAnalyticsTrendStartDay(row)
const endDay = getAnalyticsTrendEndDay(row)
const startDate = toAnalyticsDate(startDay)
const endDate = toAnalyticsDate(endDay)
if (!startDate || !endDate) {
return ''
}
if (period === 'yearly') {
return `${startDate.getUTCFullYear()}년`
}
if (period === 'monthly') {
return `${startDate.getUTCFullYear()}.${String(startDate.getUTCMonth() + 1).padStart(2, '0')}`
}
if (period === 'weekly') {
const startLabel = `${startDate.getUTCMonth() + 1}.${String(startDate.getUTCDate()).padStart(2, '0')}`
const endLabel = `${endDate.getUTCMonth() + 1}.${String(endDate.getUTCDate()).padStart(2, '0')}`
return startLabel === endLabel ? startLabel : `${startLabel}-${endLabel}`
}
return `${String(startDate.getUTCMonth() + 1).padStart(2, '0')}.${String(startDate.getUTCDate()).padStart(2, '0')}`
}
/**
* 차트 축 기본 라벨을 반환한다.
* @param {string} period - 집계 단위
* @param {Object} row - 추이 행
* @param {boolean} isLast - 마지막 막대 여부
* @param {string} [todayKey] - 오늘 날짜 키
* @returns {string} 축 라벨
*/
export const getAnalyticsTrendAxisPrimaryLabel = (period, row, isLast, todayKey = getAnalyticsDayKey()) => {
if (isLast && isAnalyticsCurrentTrendRow(period, row, todayKey)) {
return getAnalyticsCurrentPeriodLabel(period)
}
const startDate = toAnalyticsDate(getAnalyticsTrendStartDay(row))
if (!startDate) {
return ''
}
if (period === 'daily' || period === 'weekly') {
return String(startDate.getUTCDate())
}
if (period === 'monthly') {
return String(startDate.getUTCMonth() + 1)
}
if (period === 'yearly') {
return String(startDate.getUTCFullYear())
}
return ''
}
/**
* 차트 축 보조 라벨(월·년)을 반환한다.
* @param {string} period - 집계 단위
* @param {Object} row - 추이 행
* @param {Array<Object>} rows - 표시 중인 추이 행
* @param {number} index - 행 인덱스
* @returns {string} 보조 라벨
*/
export const getAnalyticsTrendAxisSecondaryLabel = (period, row, rows, index) => {
const startDate = toAnalyticsDate(getAnalyticsTrendStartDay(row))
if (!startDate) {
return ''
}
const previousRow = index > 0 ? rows[index - 1] : null
const previousDate = previousRow ? toAnalyticsDate(getAnalyticsTrendStartDay(previousRow)) : null
if (period === 'daily' || period === 'weekly') {
if (!previousDate || startDate.getUTCMonth() !== previousDate.getUTCMonth()) {
return `${startDate.getUTCMonth() + 1}월`
}
return ''
}
if (period === 'monthly' || period === 'yearly') {
if (!previousDate || startDate.getUTCFullYear() !== previousDate.getUTCFullYear()) {
return `${startDate.getUTCFullYear()}년`
}
}
return ''
}
/**
* 이전 구간 대비 증감을 반환한다.
* @param {Array<Object>} rows - 전체 추이 행
* @param {number} index - 현재 행 인덱스
* @param {string} metricKey - 지표 키
* @returns {number | null} 증감값
*/
export const getAnalyticsTrendDelta = (rows, index, metricKey) => {
if (!Array.isArray(rows) || index <= 0 || index >= rows.length) {
return null
}
const current = Number(rows[index]?.[metricKey] || 0)
const previous = Number(rows[index - 1]?.[metricKey] || 0)
return current - previous
}
/**
* 통계 추이 행을 페이지 단위로 잘라 반환한다.
* @param {Array<Object>} rows - 전체 추이 행
* @param {string} period - 집계 단위
* @param {number} pageOffset - 0이면 최신 구간, 값이 클수록 과거
* @returns {{ rows: Array<Object>, canGoPrev: boolean, canGoNext: boolean, pageOffset: number, pageSize: number }}
*/
export const paginateAnalyticsTrendRows = (rows, period, pageOffset = 0) => {
const list = Array.isArray(rows) ? rows : []
const pageSize = getAnalyticsChartPageSize(period)
const safeOffset = Math.max(Number(pageOffset) || 0, 0)
const endIndex = Math.max(list.length - safeOffset, 0)
const startIndex = Math.max(endIndex - pageSize, 0)
return {
rows: list.slice(startIndex, endIndex),
canGoPrev: startIndex > 0,
canGoNext: safeOffset > 0,
pageOffset: safeOffset,
pageSize
}
}
/**
* 차트 헤더에 표시할 기간 라벨을 반환한다.
* @param {string} period - 집계 단위
* @param {Array<Object>} visibleRows - 표시 중인 추이 행
* @param {string} [todayKey] - 오늘 날짜 키
* @returns {string} 기간 라벨
*/
export const formatAnalyticsChartHeaderLabel = (period, visibleRows, todayKey = getAnalyticsDayKey()) => {
if (!visibleRows?.length) {
return ''
}
const firstRow = visibleRows[0]
const lastRow = visibleRows[visibleRows.length - 1]
if (visibleRows.length === 1 && isAnalyticsCurrentTrendRow(period, lastRow, todayKey) && period === 'daily') {
const date = toAnalyticsDate(getAnalyticsTrendStartDay(lastRow))
if (!date) {
return ''
}
const weekdays = ['일', '월', '화', '수', '목', '금', '토']
return `${date.getUTCFullYear()}.${String(date.getUTCMonth() + 1).padStart(2, '0')}.${String(date.getUTCDate()).padStart(2, '0')} ${weekdays[date.getUTCDay()]}`
}
const startDate = toAnalyticsDate(getAnalyticsTrendStartDay(firstRow))
const endDate = toAnalyticsDate(getAnalyticsTrendEndDay(lastRow))
if (!startDate || !endDate) {
return ''
}
const formatDate = (date) => {
return `${date.getUTCFullYear()}.${String(date.getUTCMonth() + 1).padStart(2, '0')}.${String(date.getUTCDate()).padStart(2, '0')}`
}
if (period === 'monthly' && visibleRows.length === 1) {
return `${startDate.getUTCFullYear()}.${String(startDate.getUTCMonth() + 1).padStart(2, '0')}`
}
if (formatDate(startDate) === formatDate(endDate)) {
return formatDate(startDate)
}
return `${formatDate(startDate)} ~ ${formatDate(endDate)}`
}
/**
* 집계 단위 표시명을 반환한다.
* @param {string} period - 집계 단위
* @returns {string} 표시명
*/
export const getAnalyticsPeriodLabel = (period) => {
if (period === 'daily') {
return '일간'
}
if (period === 'weekly') {
return '주간'
}
if (period === 'monthly') {
return '월간'
}
if (period === 'yearly') {
return '년간'
}
return ''
}
/** @type {Record<string, { active: string, muted: string }>} 유입 스택 막대 색상 */
export const ANALYTICS_TRAFFIC_STACK_COLORS = {
search: { active: '#e5484d', muted: '#f3b6b8' },
sns: { active: '#12a594', muted: '#a8e8df' },
other: { active: '#8b8d98', muted: '#d5d7de' }
}
/** @type {{ active: string, muted: string }} 단일 막대 색상 */
export const ANALYTICS_SINGLE_BAR_COLORS = {
active: '#15171a',
muted: '#d5d7de'
}
/**
* 유입 스택 막대 색상을 반환한다.
* @param {'search' | 'sns' | 'other'} key - 유입 구분
* @param {boolean} emphasized - 강조 여부
* @returns {string} 색상
*/
export const getAnalyticsTrafficStackColor = (key, emphasized) => {
const colors = ANALYTICS_TRAFFIC_STACK_COLORS[key] || ANALYTICS_TRAFFIC_STACK_COLORS.other
return emphasized ? colors.active : colors.muted
}
/**
* 추이 행의 유입 스택 구간을 반환한다.
* @param {Object} row - 추이 행
* @returns {{ total: number, segments: Array<{ key: 'search' | 'sns' | 'other', value: number }> }}
*/
export const getAnalyticsTrafficStackSegments = (row) => {
const total = Number(row?.visitors || 0)
const search = Number(row?.trafficSearch || 0)
const sns = Number(row?.trafficSns || 0)
const other = Number(row?.trafficOther || 0)
const trafficSum = search + sns + other
if (total <= 0) {
return { total: 0, segments: [] }
}
if (trafficSum <= 0) {
return {
total,
segments: [{ key: 'other', value: total }]
}
}
const scale = total / trafficSum
const scaledSearch = Math.round(search * scale)
const scaledSns = Math.round(sns * scale)
let scaledOther = total - scaledSearch - scaledSns
if (scaledOther < 0) {
scaledOther = 0
}
/** @type {Array<{ key: 'search' | 'sns' | 'other', value: number }>} */
const segments = []
if (scaledSearch > 0) {
segments.push({ key: 'search', value: scaledSearch })
}
if (scaledSns > 0) {
segments.push({ key: 'sns', value: scaledSns })
}
if (scaledOther > 0) {
segments.push({ key: 'other', value: scaledOther })
}
if (!segments.length) {
segments.push({ key: 'other', value: total })
}
return { total, segments }
}