diff --git a/docs/deploy.md b/docs/deploy.md index 0fa56f8..24d5b51 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -204,6 +204,13 @@ docker compose --env-file .env.production up -d --build - 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다. - 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다. +### 통계 데이터 보관 정책 + +- `site_analytics_daily`, `post_analytics_daily`: 사이트 전체 방문자와 게시물별 조회수의 누적 원본이므로 자동 삭제하지 않는다. +- `analytics_daily_visitors`: 일별 중복 방문 제거용 해시만 담으므로 32일 초과 행은 통계 수집·관리자 조회 흐름에서 주기적으로 삭제한다. +- `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다. +- 관리자 대시보드 차트는 최대 365일 범위를 조회하며, 차트 범위를 넘는 집계도 누적 통계 원본으로 보관한다. + ### 개발/운영 DB 분리 검증 절차 검증 전제는 실제 비밀번호나 전체 `DATABASE_URL`을 화면 공유, 문서, 커밋 메시지에 노출하지 않는 것이다. 확인할 때는 호스트, 포트, DB 이름, 파일명만 대조한다. diff --git a/docs/history.md b/docs/history.md index ab702d4..e0ae594 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-20 v1.3.5 + +### 관리자 로그인·대시보드 차트·통계 보관 후속 + +v1.3.4 통계 확장 이후 운영에서 로그인 쿠키·클라이언트 번들·차트 조회 오류가 겹쳐 후속 정리가 필요했다. 세션 쿠키는 공통 유틸로 묶고, 통계 상수는 `analytics-shared`로 분리했다. 대시보드는 기간별 `trends` 차트와 접속자 목록 가독성을 맞췄다. 저장 용량은 일별 집계는 누적 원본으로 두고 방문자 해시만 32일 초과 시 정리한다. + ## 2026-05-15 v1.1.18 ### 에디터 미디어 UX·발행일·수정일 표시 설정 diff --git a/docs/map.md b/docs/map.md index 3e99562..06b77d1 100644 --- a/docs/map.md +++ b/docs/map.md @@ -26,7 +26,8 @@ | lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) | | lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` | | lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 | -| lib/analytics.js | 통계 추적 경로 필터·봇 UA·일별 visitor hash | +| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) | +| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) | ## Nuxt 모듈 @@ -115,7 +116,7 @@ | 파일 | 화면 | |------|------| -| pages/admin/index.vue | 대시보드(실시간 접속자·체류·스크롤·인기 게시물 참여 지표) | +| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물 참여 지표) | | pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) | | pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) | | pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 | @@ -242,7 +243,7 @@ | server/repositories/content-repository.js | 콘텐츠 조회 저장소 | | server/repositories/member-repository.js | 회원 조회/생성 저장소 | | server/repositories/comment-repository.js | 댓글 조회/생성 저장소 | -| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회 | +| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회·방문자 해시 보관 정리 | | server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 | | server/api/analytics/pageview.post.js | 공개 통계 수집 API | | server/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API | diff --git a/docs/spec.md b/docs/spec.md index 584a2d4..2e91f04 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -366,7 +366,11 @@ components/content/ - 봇 User-Agent는 서버에서 무시. - 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송. - `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다. -- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·경로·마지막 활동)을 조회한다. +- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간)을 조회한다. +- 관리자 차트는 최대 365일 범위를 조회한다. +- `site_analytics_daily`, `post_analytics_daily`는 사이트 전체 방문자와 게시물별 조회수 누적 원본이므로 자동 삭제하지 않는다. +- `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다. +- `analytics_active_sessions`는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다. --- @@ -429,7 +433,7 @@ components/content/ - `POST /admin/api/auth/login` - 로그인 - `POST /admin/api/auth/logout` - 로그아웃 - `GET /admin/api/auth/me` - 현재 관리자 세션 조회 -- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달) +- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별 `trends`). `days`는 대시보드에서 7/30/90/180/365로 전환한다. - `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·읽음·평균 체류·스크롤 구간) - `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함) - `GET /admin/api/posts` - 글 목록 @@ -614,8 +618,8 @@ components/content/ - DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다. - DB에 owner/admin 계정이 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다. - 최초 owner 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다. -- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다. -- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다. +- 관리자 로그인 성공 시 httpOnly 세션 쿠키(`sori_admin_session`)를 `/` 경로에 설정한다. Secure는 실제 HTTPS 요청(`x-forwarded-proto` 포함)일 때만 사용한다. +- `/admin/login` 로그인 제출 버튼은 제출 중일 때만 비활성화한다. 빈 값은 브라우저 `required`와 서버 검증으로 처리하며, 자동완성 값은 제출 직전 동기화한다. - 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. - 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다. - 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다. diff --git a/docs/update.md b/docs/update.md index 01baf1e..12c6a13 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v1.3.5 + +- 관리자 로그인: 자동완성 값 동기화·제출 중만 버튼 비활성. 운영 HTTP에서 Secure 쿠키 미저장으로 로그인 루프되던 문제 수정. `server/utils/session-cookie.js`로 path `/`·`x-forwarded-proto` Secure 통일. +- 통계: `lib/analytics-shared.js` 분리로 클라이언트 `node:crypto` 오류 수정. 통계 조회 시작일 JS 계산으로 `date >= integer` 오류 수정. +- 관리자 대시보드: 상단 요약 한 줄·7일~12개월 차트·접속자 목록(게시물 제목·유지시간). `trends` 일자별 0 채움. +- 통계 보관: 일별 집계 누적 보관, 방문자 해시 32일 초과 정리, 실시간 세션 90초 TTL. + ## v1.3.4 - 통계 확장: 체류시간·스크롤 구간(25/50/75/100%)·실시간 접속 세션. 마이그레이션 `031_analytics_engagement_and_realtime.sql`. diff --git a/lib/analytics-shared.js b/lib/analytics-shared.js new file mode 100644 index 0000000..b57403f --- /dev/null +++ b/lib/analytics-shared.js @@ -0,0 +1,142 @@ +/** @type {RegExp} 추적 제외 경로 */ +const EXCLUDED_PATH_PATTERN = /^\/(admin|signin|signup|forgot-password|settings)(\/|$)/ + +/** @type {RegExp} 봇 User-Agent 패턴 */ +const BOT_USER_AGENT_PATTERN = /bot|crawl|spider|slurp|preview|headless|lighthouse|bytespider|facebookexternalhit/i + +/** + * 오늘 날짜(UTC)를 YYYY-MM-DD로 반환한다. + * @returns {string} 날짜 문자열 + */ +export const getAnalyticsDayKey = () => { + const now = new Date() + return now.toISOString().slice(0, 10) +} + +/** + * 기준일에서 지정 일수만큼 이전 날짜를 YYYY-MM-DD로 반환한다. + * @param {string} dayKey - 기준일(YYYY-MM-DD) + * @param {number} daysBefore - 며칠 전(0이면 같은 날) + * @returns {string} 시작일 + */ +export const getAnalyticsDayBefore = (dayKey, daysBefore) => { + const offset = Math.max(Number(daysBefore) || 0, 0) + const base = new Date(`${dayKey}T00:00:00.000Z`) + base.setUTCDate(base.getUTCDate() - offset) + return base.toISOString().slice(0, 10) +} + +/** + * 통계 추적 대상 경로인지 확인한다. + * @param {string} path - 요청 경로 + * @returns {boolean} 추적 가능 여부 + */ +export const isTrackableAnalyticsPath = (path) => { + const normalized = (path || '').trim() + if (!normalized.startsWith('/')) { + return false + } + + if (EXCLUDED_PATH_PATTERN.test(normalized)) { + return false + } + + return true +} + +/** + * 봇 User-Agent 여부 + * @param {string} userAgent - User-Agent + * @returns {boolean} 봇 여부 + */ +export const isBotUserAgent = (userAgent) => { + const value = (userAgent || '').trim() + if (!value) { + return false + } + + return BOT_USER_AGENT_PATTERN.test(value) +} + +/** + * 게시물 slug 정규화 + * @param {string} slug - slug + * @returns {string} 정규화된 slug + */ +export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim() + +/** @type {number} heartbeat 체류시간 상한(초) */ +export const ANALYTICS_MAX_DURATION_SECONDS = 1800 + +/** @type {number} engaged_views 집계 최소 체류(초) */ +export const ANALYTICS_ENGAGED_MIN_SECONDS = 10 + +/** @type {number} 현재 접속자 판정 TTL(초) */ +export const ANALYTICS_ACTIVE_SESSION_TTL_SECONDS = 90 + +/** @type {number} 관리자 차트 최대 조회 기간(일) */ +export const ANALYTICS_CHART_MAX_DAYS = 365 + +/** @type {number} 일별 방문자 해시 보관 기간(일) */ +export const ANALYTICS_VISITOR_HASH_RETENTION_DAYS = 32 + +/** @type {number} 통계 정리 최소 실행 간격(ms) */ +export const ANALYTICS_RETENTION_PURGE_INTERVAL_MS = 6 * 60 * 60 * 1000 + +/** @type {number[]} 스크롤 구간 임계값 */ +export const ANALYTICS_SCROLL_THRESHOLDS = [0.25, 0.5, 0.75, 1] + +/** + * 체류시간(초)을 상한 내로 보정한다. + * @param {number} seconds - 체류시간 + * @returns {number} 보정된 초 + */ +export const clampAnalyticsDurationSeconds = (seconds) => { + const value = Number(seconds) + if (!Number.isFinite(value) || value < 0) { + return 0 + } + + return Math.min(Math.floor(value), ANALYTICS_MAX_DURATION_SECONDS) +} + +/** + * 스크롤 비율을 0~1로 보정한다. + * @param {number} ratio - 스크롤 비율 + * @returns {number} 보정된 비율 + */ +export const clampAnalyticsScrollRatio = (ratio) => { + const value = Number(ratio) + if (!Number.isFinite(value) || value < 0) { + return 0 + } + + return Math.min(value, 1) +} + +/** + * 새로 통과한 스크롤 구간 컬럼명 목록을 반환한다. + * @param {number} previousRatio - 이전 최대 스크롤 + * @param {number} nextRatio - 갱신된 최대 스크롤 + * @returns {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} 신규 구간 + */ +export const getNewScrollBucketColumns = (previousRatio, nextRatio) => { + const previous = clampAnalyticsScrollRatio(previousRatio) + const next = clampAnalyticsScrollRatio(nextRatio) + const columns = [] + + if (previous < 0.25 && next >= 0.25) { + columns.push('scroll_25') + } + if (previous < 0.5 && next >= 0.5) { + columns.push('scroll_50') + } + if (previous < 0.75 && next >= 0.75) { + columns.push('scroll_75') + } + if (previous < 1 && next >= 1) { + columns.push('scroll_100') + } + + return columns +} diff --git a/lib/analytics.js b/lib/analytics.js index a1657ce..f5bcc14 100644 --- a/lib/analytics.js +++ b/lib/analytics.js @@ -1,51 +1,22 @@ import { createHash } from 'node:crypto' -/** @type {RegExp} 추적 제외 경로 */ -const EXCLUDED_PATH_PATTERN = /^\/(admin|signin|signup|forgot-password|settings)(\/|$)/ - -/** @type {RegExp} 봇 User-Agent 패턴 */ -const BOT_USER_AGENT_PATTERN = /bot|crawl|spider|slurp|preview|headless|lighthouse|bytespider|facebookexternalhit/i - -/** - * 오늘 날짜(UTC)를 YYYY-MM-DD로 반환한다. - * @returns {string} 날짜 문자열 - */ -export const getAnalyticsDayKey = () => { - const now = new Date() - return now.toISOString().slice(0, 10) -} - -/** - * 통계 추적 대상 경로인지 확인한다. - * @param {string} path - 요청 경로 - * @returns {boolean} 추적 가능 여부 - */ -export const isTrackableAnalyticsPath = (path) => { - const normalized = (path || '').trim() - if (!normalized.startsWith('/')) { - return false - } - - if (EXCLUDED_PATH_PATTERN.test(normalized)) { - return false - } - - return true -} - -/** - * 봇 User-Agent 여부 - * @param {string} userAgent - User-Agent - * @returns {boolean} 봇 여부 - */ -export const isBotUserAgent = (userAgent) => { - const value = (userAgent || '').trim() - if (!value) { - return false - } - - return BOT_USER_AGENT_PATTERN.test(value) -} +export { + ANALYTICS_ACTIVE_SESSION_TTL_SECONDS, + ANALYTICS_CHART_MAX_DAYS, + ANALYTICS_ENGAGED_MIN_SECONDS, + ANALYTICS_MAX_DURATION_SECONDS, + ANALYTICS_RETENTION_PURGE_INTERVAL_MS, + ANALYTICS_SCROLL_THRESHOLDS, + ANALYTICS_VISITOR_HASH_RETENTION_DAYS, + clampAnalyticsDurationSeconds, + clampAnalyticsScrollRatio, + getAnalyticsDayBefore, + getAnalyticsDayKey, + getNewScrollBucketColumns, + isBotUserAgent, + isTrackableAnalyticsPath, + normalizePostSlugForAnalytics +} from './analytics-shared.js' /** * 일 단위 익명 방문자 해시를 생성한다. 원문 IP·UA는 저장하지 않는다. @@ -57,80 +28,6 @@ export const createDailyVisitorHash = ({ day, ip, userAgent, secret }) => { return createHash('sha256').update(payload).digest('hex') } -/** - * 게시물 slug 정규화 - * @param {string} slug - slug - * @returns {string} 정규화된 slug - */ -export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim() - -/** @type {number} heartbeat 체류시간 상한(초) */ -export const ANALYTICS_MAX_DURATION_SECONDS = 1800 - -/** @type {number} engaged_views 집계 최소 체류(초) */ -export const ANALYTICS_ENGAGED_MIN_SECONDS = 10 - -/** @type {number} 현재 접속자 판정 TTL(초) */ -export const ANALYTICS_ACTIVE_SESSION_TTL_SECONDS = 90 - -/** @type {number[]} 스크롤 구간 임계값 */ -export const ANALYTICS_SCROLL_THRESHOLDS = [0.25, 0.5, 0.75, 1] - -/** - * 체류시간(초)을 상한 내로 보정한다. - * @param {number} seconds - 체류시간 - * @returns {number} 보정된 초 - */ -export const clampAnalyticsDurationSeconds = (seconds) => { - const value = Number(seconds) - if (!Number.isFinite(value) || value < 0) { - return 0 - } - - return Math.min(Math.floor(value), ANALYTICS_MAX_DURATION_SECONDS) -} - -/** - * 스크롤 비율을 0~1로 보정한다. - * @param {number} ratio - 스크롤 비율 - * @returns {number} 보정된 비율 - */ -export const clampAnalyticsScrollRatio = (ratio) => { - const value = Number(ratio) - if (!Number.isFinite(value) || value < 0) { - return 0 - } - - return Math.min(value, 1) -} - -/** - * 새로 통과한 스크롤 구간 컬럼명 목록을 반환한다. - * @param {number} previousRatio - 이전 최대 스크롤 - * @param {number} nextRatio - 갱신된 최대 스크롤 - * @returns {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} 신규 구간 - */ -export const getNewScrollBucketColumns = (previousRatio, nextRatio) => { - const previous = clampAnalyticsScrollRatio(previousRatio) - const next = clampAnalyticsScrollRatio(nextRatio) - const columns = [] - - if (previous < 0.25 && next >= 0.25) { - columns.push('scroll_25') - } - if (previous < 0.5 && next >= 0.5) { - columns.push('scroll_50') - } - if (previous < 0.75 && next >= 0.75) { - columns.push('scroll_75') - } - if (previous < 1 && next >= 1) { - columns.push('scroll_100') - } - - return columns -} - /** * 실시간 세션 해시를 생성한다. * @param {{ clientSessionId: string, visitorHash: string, secret: string }} input - 해시 입력 diff --git a/package-lock.json b/package-lock.json index ba5537b..5764ef2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.2.4", + "version": "1.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.2.4", + "version": "1.3.4", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 5c2676f..91ff71b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.3.4", + "version": "1.3.5", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index 9acecbb..54fa31a 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -7,7 +7,20 @@ const { data: posts } = await useFetch('/admin/api/posts', { default: () => [] }) +const analyticsRangeOptions = [ + { label: '7일', days: 7 }, + { label: '30일', days: 30 }, + { label: '3개월', days: 90 }, + { label: '6개월', days: 180 }, + { label: '12개월', days: 365 } +] +const selectedAnalyticsDays = ref(30) +const analyticsQuery = computed(() => ({ + days: selectedAnalyticsDays.value +})) + const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', { + query: analyticsQuery, default: () => ({ todayVisitors: 0, visitorsLast7Days: 0, @@ -15,12 +28,18 @@ const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/adm onlineNow: 0, loggedInNow: 0, avgEngagedSeconds: 0, - scroll50Reach: 0 + scroll50Reach: 0, + trends: [] }) }) -const { data: topPosts } = await useFetch('/admin/api/analytics/posts', { - query: { days: 30, limit: 5 }, +const topPostsQuery = computed(() => ({ + days: selectedAnalyticsDays.value, + limit: 5 +})) + +const { data: topPosts, refresh: refreshTopPosts } = await useFetch('/admin/api/analytics/posts', { + query: topPostsQuery, default: () => [] }) @@ -38,6 +57,12 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/ const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length) const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length) +const analyticsRangeLabel = computed(() => { + return analyticsRangeOptions.find((option) => option.days === selectedAnalyticsDays.value)?.label || '30일' +}) +const trendRows = computed(() => analyticsSummary.value.trends || []) +const trendStartDay = computed(() => trendRows.value[0]?.day || '') +const trendEndDay = computed(() => trendRows.value[trendRows.value.length - 1]?.day || '') /** * 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다. @@ -56,24 +81,104 @@ const formatEngagedDuration = (seconds) => { } /** - * 마지막 활동 시각을 상대 표시로 변환한다. - * @param {string|null} iso - ISO 시각 - * @returns {string} 상대 시각 + * 선택 기간의 추세 합계를 반환한다. + * @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키 + * @returns {number} 합계 또는 평균 */ -const formatLastSeen = (iso) => { - if (!iso) { - return '-' +const getTrendSummaryValue = (key) => { + const rows = trendRows.value + if (key === 'avgEngagedSeconds') { + const nonZeroRows = rows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0) + if (!nonZeroRows.length) { + return 0 + } + + const total = nonZeroRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0) + return Math.round(total / nonZeroRows.length) } - const diffMs = Date.now() - new Date(iso).getTime() - const diffSec = Math.max(Math.floor(diffMs / 1000), 0) + return rows.reduce((sum, row) => sum + Number(row[key] || 0), 0) +} - if (diffSec < 60) { - return `${diffSec}초 전` +/** + * 추세 값 표시 문자열을 반환한다. + * @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키 + * @param {number} value - 값 + * @returns {string} 표시 문자열 + */ +const formatTrendValue = (key, value) => { + if (key === 'avgEngagedSeconds') { + return formatEngagedDuration(value) } - const diffMin = Math.floor(diffSec / 60) - return `${diffMin}분 전` + return `${Number(value || 0)}` +} + +const chartMetrics = computed(() => [ + { + key: 'visitors', + title: '방문자수', + label: `${getTrendSummaryValue('visitors')}명` + }, + { + key: 'avgEngagedSeconds', + title: '평균 체류시간', + label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds')) + }, + { + key: 'scroll50Reach', + title: '50% 스크롤 도달', + label: `${getTrendSummaryValue('scroll50Reach')}회` + } +]) + +/** + * 차트 막대 높이 비율을 반환한다. + * @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키 + * @param {Object} row - 추세 행 + * @returns {number} 높이 % + */ +const getTrendBarHeight = (key, row) => { + const rows = trendRows.value + const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0) + + if (maxValue <= 0) { + return 3 + } + + const value = Number(row[key] || 0) + return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3) +} + +/** + * 추세 시작·종료 날짜 라벨을 반환한다. + * @param {string} day - YYYY-MM-DD + * @returns {string} 날짜 라벨 + */ +const formatTrendDayLabel = (day) => { + if (!day) { + return '' + } + + const [, month, date] = day.split('-') + return `${month}.${date}` +} + +/** + * 현재 접속자가 보고 있는 화면명을 반환한다. + * @param {Object} session - 접속 세션 + * @returns {string} 화면명 + */ +const getSessionViewingTitle = (session) => { + if (session.postTitle) { + return session.postTitle + } + + if (session.path === '/') { + return '홈' + } + + return session.path || '알 수 없음' } let refreshTimer = null @@ -81,6 +186,7 @@ let refreshTimer = null onMounted(() => { refreshTimer = window.setInterval(() => { refreshSummary() + refreshTopPosts() refreshRealtime() }, 30000) }) @@ -90,6 +196,11 @@ onUnmounted(() => { window.clearInterval(refreshTimer) } }) + +watch(selectedAnalyticsDays, () => { + refreshSummary() + refreshTopPosts() +})