통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
524 lines
15 KiB
JavaScript
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 '이번주'
|
|
}
|
|
|
|
if (period === 'monthly') {
|
|
return '이번달'
|
|
}
|
|
|
|
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 }
|
|
}
|