import { getAnalyticsDayKey } from './analytics-shared.js' /** @type {Record} 집계 단위별 차트 페이지 크기 */ export const ANALYTICS_CHART_PAGE_SIZES = { daily: 7, weekly: 8, monthly: 12, yearly: 8 } /** @type {Record} 집계 단위별 차트 전체 버킷 수(페이지 이동용) */ 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} 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} 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} rows - 전체 추이 행 * @param {string} period - 집계 단위 * @param {number} pageOffset - 0이면 최신 구간, 값이 클수록 과거 * @returns {{ rows: Array, 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} 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} 유입 스택 막대 색상 */ 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 } }