관리자 대시보드 통계 표시 정규화
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.10.64에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.10.66에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -263,7 +263,7 @@ docker compose --env-file .env.production up -d --build
|
||||
- `site_analytics_daily`, `post_analytics_daily`: 사이트 전체 방문자와 게시물별 조회수의 누적 원본이므로 자동 삭제하지 않는다.
|
||||
- `analytics_traffic_daily`: 유입원·디바이스·키워드 축약 집계 원본이므로 자동 삭제하지 않는다.
|
||||
- `analytics_daily_visitors`: 일별 중복 방문 제거용 해시만 담으므로 32일 초과 행은 통계 수집·관리자 조회 흐름에서 주기적으로 삭제한다.
|
||||
- `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다. `ip_address`는 현재 접속자 화면에서 `session_hash` 앞자리 SID와 함께 보조 식별 정보로 표시한다.
|
||||
- `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다. `ip_address`는 현재 접속자 화면에서 익명 방문자 묶음과 보조 식별 정보로 표시한다.
|
||||
- 관리자 대시보드 차트는 최대 365일 범위를 조회하며, 차트 범위를 넘는 집계도 누적 통계 원본으로 보관한다.
|
||||
- 관리자 통계 집계 시작일은 `ANALYTICS_COLLECTED_SINCE` 환경 변수로 설정한다. 기본값은 `2026-06-01`이다.
|
||||
- 관리자 세션으로 공개 글·페이지를 확인하는 요청은 통계 수집에서 제외된다. 운영자가 직접 방문자처럼 검증해야 할 때는 관리자 로그아웃 또는 별도 브라우저 프로필을 사용한다.
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
## 2026-07-08 v1.10.66 — 현재 접속자는 세션보다 사람에 가깝게 묶어 보여준다
|
||||
|
||||
실시간 세션 ID는 같은 방문자라도 탭이나 브라우저 저장소 단위가 달라지면 서로 다른 값으로 보일 수 있다. 관리자 화면에서 SID를 그대로 노출하면 사람 식별 보조 정보처럼 보이지만, 실제로는 같은 사람이 여러 페이지를 열었을 때 여러 명처럼 보이는 혼란을 만든다.
|
||||
|
||||
따라서 현재 접속자 화면에서는 SID를 제거하고 IP만 보조 정보로 표시한다. 집계는 로그인 사용자는 회원 ID 기준, 익명 방문자는 IP 기준으로 묶어 한 사람이 여러 페이지를 열어도 1명처럼 보이게 하고, 같은 묶음 안의 활성 페이지 수는 `열린 페이지 N개`로 표시한다. IP 기반 묶음은 공유망의 여러 사용자를 완벽히 구분하지 못하지만, 운영 화면에서는 같은 사람의 여러 탭을 여러 방문자로 오해하는 문제를 줄이는 쪽을 우선한다.
|
||||
|
||||
## 2026-07-08 v1.10.65 — 관리자 통계 표시는 해석 가능한 범위로 정규화한다
|
||||
|
||||
읽음 이벤트와 스크롤 heartbeat는 페이지뷰와 별도 요청으로 수집되므로 네트워크 타이밍, 탭 이동, 과거 집계 방식에 따라 원시 카운터가 `읽음 > 조회`, `75% 스크롤 > 50% 스크롤`처럼 해석하기 어려운 조합이 될 수 있다. 운영 화면에서 이런 원시값을 그대로 보여 주면 실제 방문 흐름보다 집계 오류처럼 먼저 인식된다.
|
||||
|
||||
따라서 원본 통계 행은 보존하되 관리자 조회 응답에서는 `읽음 <= 조회`, `100% 스크롤 <= 75% 스크롤 <= 50% 스크롤 <= 조회`로 정규화해 표시한다. 수집 단계의 세션 세분화는 별도 개선 과제로 남겨, 표시 안정성과 원인 분석 가능성을 함께 유지한다.
|
||||
|
||||
## 2026-07-08 v1.10.62 — 대시보드 자동 갱신은 사용자의 선택을 덮어쓰지 않는다
|
||||
|
||||
대시보드는 30초마다 통계를 다시 조회하지만, 조회 결과가 바뀔 때마다 기본 날짜 선택 로직을 다시 실행하면 사용자가 과거 날짜를 보고 있는 중에도 오늘 통계로 되돌아간다. 차트 막대 클릭은 사용자의 명시 선택이므로 자동 갱신보다 우선해야 한다. 따라서 기본 선택은 초기 진입과 기간 탭 변경 때만 수행하고, 자동 갱신은 현재 선택 범위를 유지한 채 값만 새로 받아오도록 정리한다.
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·디바이스, IP·SID 포함 접속자 목록) |
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·디바이스, IP·열린 페이지 수 포함 접속자 목록) |
|
||||
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, 관리자 전용 상단 고정 배지·정렬·행 메뉴 토글, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(고정 전용 API·추천 전용 API·삭제) |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·첫 저장 준비 오버레이·첫 생성 중 추가 입력 PUT 반영·이탈 직전 플러시, 저장 토스트 |
|
||||
|
||||
@@ -554,15 +554,15 @@ components/content/
|
||||
- 검색 유입은 네이버·다음·구글·줌·빙을 우선 분류하고, SNS 유입은 카카오톡·페이스북·인스타그램·트위터/X·유튜브를 우선 분류한다. 검색 키워드는 referrer query에 남아 있는 경우만 표시한다.
|
||||
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송.
|
||||
- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다.
|
||||
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간·IP·짧은 세션 ID)을 조회한다. 세션 ID는 클라이언트 세션 원문이 아니라 서버 `session_hash` 앞부분만 표시해 같은 IP 안의 방문자를 구분하는 보조 정보로 사용한다.
|
||||
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간·IP·열린 페이지 수)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시한다.
|
||||
- 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 선택지는 일간·주간·월간·년간이며 기본값은 일간이다. 집계는 `ANALYTICS_COLLECTED_SINCE` 환경 변수(기본 `2026-06-01`) 이후 데이터만 합산하고 그 이전 구간은 0으로 표시한다. **방문자수** 막대는 검색(빨강)·SNS(청록)·기타(회색) 유입 비율을 스택으로 표시하며, hover 시 해당 막대만 색상을 강조한다. 안내 문구는 `통계는 2026년 6월 1일부터 집계한 데이터를 표시합니다.` 형식이다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 막대 클릭 시 하단 **선택한 기간 상세** 패널을 해당 날짜 또는 묶음 구간 기준으로 갱신한다. 표(table)나 외부 차트 라이브러리는 사용하지 않는다.
|
||||
- 관리자 대시보드 **선택한 기간 상세**는 기본값으로 오늘 통계를 표시하고, 차트 막대 클릭 시 선택 범위의 페이지뷰·방문자·평균 체류·50% 스크롤·검색/SNS/직접 포함 유입원·디바이스/OS·인기 게시물·인기 페이지를 표시한다. 자동 갱신 중에는 사용자가 선택한 날짜·기간을 유지하고, 상세 재조회 중에도 기존 내용을 유지해 화면 높이가 줄어들지 않게 한다. 인기 게시물·인기 페이지는 데스크톱에서 왼쪽 75% 영역에 세로 목록으로 표시하고, 유입·디바이스는 오른쪽 25% 영역에 세로 배치한다. 인기 콘텐츠 메타는 `V 조회`, `R 읽음/방문자`, `M 최근 30일 조회`, `C 작성일`, `S 50/75/100% 스크롤` 형식의 한 줄 압축 표기를 사용한다. `V/R/M` 알파벳만 서로 다른 색상으로 강조하고 숫자는 보조 텍스트 색상으로 표시한다. 구분자 `|`는 연한 회색으로 표시하며, `S`의 50/75/100 스크롤 값은 단계가 깊어질수록 진하게 표시한다. 유입 프로그래스 바는 보조 지표로 보이도록 카드 폭의 절반만 사용한다. 검색엔진이 대부분 검색어를 전달하지 않는 현실을 반영해 대시보드의 유입 키워드 영역은 표시하지 않는다.
|
||||
- 관리자 대시보드 **선택한 기간 상세**는 기본값으로 오늘 통계를 표시하고, 차트 막대 클릭 시 선택 범위의 페이지뷰·방문자·평균 체류·50% 스크롤·검색/SNS/직접 포함 유입원·디바이스/OS·인기 게시물·인기 페이지를 표시한다. 자동 갱신 중에는 사용자가 선택한 날짜·기간을 유지하고, 상세 재조회 중에도 기존 내용을 유지해 화면 높이가 줄어들지 않게 한다. 인기 게시물·인기 페이지는 데스크톱에서 왼쪽 75% 영역에 세로 목록으로 표시하고, 유입·디바이스는 오른쪽 25% 영역에 세로 배치한다. 인기 콘텐츠 메타는 `V 조회`, `R 읽음/방문자`, `M 최근 30일 조회`, `C 작성일`, `S 50/75/100% 스크롤` 형식의 한 줄 압축 표기를 사용한다. `V/R/M` 알파벳만 서로 다른 색상으로 강조하고 숫자는 보조 텍스트 색상으로 표시한다. 구분자 `|`는 연한 회색으로 표시하며, `S`의 50/75/100 스크롤 값은 단계가 깊어질수록 진하게 표시한다. 관리자 통계 응답은 표시 안정성을 위해 `읽음 <= 조회`, `100% 스크롤 <= 75% 스크롤 <= 50% 스크롤 <= 조회` 범위로 정규화한다. 유입 프로그래스 바는 보조 지표로 보이도록 카드 폭의 절반만 사용한다. 검색엔진이 대부분 검색어를 전달하지 않는 현실을 반영해 대시보드의 유입 키워드 영역은 표시하지 않는다.
|
||||
- 관리자 글 목록은 게시물별 `post_analytics_daily.views` 누적 합계를 별도 조회수 열에 `1,000`처럼 숫자만 표시하고, 같은 셀 아래 작은 회색 통계 버튼으로 게시물별 통계 모달을 연다. 통계 버튼은 hover 시에만 파란색으로 표시한다.
|
||||
- 관리자 게시물별 통계 모달과 상세 화면은 누적 조회수, 조회수/방문자 추이, 읽음·평균 체류·스크롤 지표, 게시물별 유입원·디바이스·유입 키워드를 표시한다. 추이는 일간(최근 14일), 주간(최근 12주, 일요일~토요일), 월간(최근 12개월) 단위로 전환한다. 게시물별 유입은 `059_post_analytics_traffic_daily.sql` 적용 이후 새로 수집되는 페이지뷰부터 집계된다.
|
||||
- 관리자 대시보드 추이 차트는 일일 7개, 주간 최대 8개, 월간 12개, 년간 최대 8개 버킷을 조회한다. 게시물별 통계와 기타 인기/유입 목록 API는 기존 최대 365일 조회 제한을 유지한다.
|
||||
- `site_analytics_daily`, `post_analytics_daily`, `analytics_traffic_daily`는 사이트 전체 방문자와 게시물별 조회수, 유입 통계 누적 원본이므로 자동 삭제하지 않는다.
|
||||
- `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다.
|
||||
- `analytics_active_sessions`는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다. IP는 실시간 접속자 식별 보조용으로만 저장하고, 방문자 통계 고유성은 기존 해시 기반 집계를 유지한다.
|
||||
- `analytics_active_sessions`는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다. IP는 실시간 익명 접속자 묶음 표시용으로만 저장하고, 방문자 통계 고유성은 기존 해시 기반 집계를 유지한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -630,7 +630,7 @@ components/content/
|
||||
- `GET /admin/api/analytics/summary?period=weekly&days=56` - 통계 요약(오늘 및 최근 7/30/180/365일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, `collectedSince`, 선택 집계 단위 `trends`). `period`는 `daily`, `weekly`, `monthly`, `yearly`를 사용하며 대시보드는 일일·주간·월간·년간으로 전환한다.
|
||||
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간)
|
||||
- `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간)
|
||||
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타, IP, 세션 해시 포함)
|
||||
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타, IP, 열린 페이지 수 포함)
|
||||
- `GET /admin/api/analytics/traffic?days=30` - 기간 내 유입원·디바이스·유입 키워드 통계
|
||||
- `GET /admin/api/analytics/range-detail?startDay=YYYY-MM-DD&endDay=YYYY-MM-DD&limit=5` - 통계 추이 막대 상세(요약·유입·디바이스·인기 게시물·인기 페이지). 인기 콘텐츠는 조회·읽음/방문자·최근 30일 조회·작성일·스크롤 도달 지표를 포함한다. 최대 31일 범위로 제한한다.
|
||||
- `GET /admin/api/analytics/posts/:id?period=daily&days=14` - 게시물별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 게시물별 유입·디바이스·키워드)
|
||||
|
||||
@@ -16,5 +16,8 @@
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
- 통계 heartbeat/read 수집을 콘텐츠별 세션 기준으로 세분화 검토.
|
||||
- 현재 관리자 응답은 `읽음 <= 조회`, `100% <= 75% <= 50% <= 조회`로 표시값을 정규화함.
|
||||
- 원시 집계 단계에서도 같은 탭의 여러 게시물 이동이 스크롤 구간 계산에 섞이지 않도록 별도 콘텐츠 세션 저장 구조 검토.
|
||||
|
||||
## 배포
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.10.66
|
||||
|
||||
- 현재 접속자 목록에서 SID 표시 제거.
|
||||
- 현재 접속자 집계를 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 보이도록 수정.
|
||||
- 같은 접속자 묶음 안에 여러 페이지가 있으면 `열린 페이지 N개`로 표시하도록 수정.
|
||||
|
||||
## v1.10.65
|
||||
|
||||
- 관리자 통계 인기 콘텐츠·개별 통계 응답에서 읽음 수가 조회수를 넘지 않도록 정규화.
|
||||
- 관리자 통계 스크롤 50/75/100 도달 수가 조회수 범위와 단계 순서를 넘지 않도록 정규화.
|
||||
- 관리자 통계 추이·선택 기간 상세의 50% 스크롤 도달 요약도 게시물 조회수 범위 안으로 정규화.
|
||||
|
||||
## v1.10.64
|
||||
|
||||
- 관리자 대시보드 인기 콘텐츠 메타에서 `V/R/M` 알파벳만 색상 강조하고 숫자는 보조 텍스트 색상으로 표시하도록 수정.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.64",
|
||||
"version": "1.10.66",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.64",
|
||||
"version": "1.10.66",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.64",
|
||||
"version": "1.10.66",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -417,15 +417,6 @@ const formatTopPageMeta = (row) => {
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 세션 해시를 짧게 표시한다.
|
||||
* @param {string} value - 세션 해시
|
||||
* @returns {string} 짧은 세션 ID
|
||||
*/
|
||||
const formatSessionId = (value) => {
|
||||
return String(value || '').slice(0, 8) || '-'
|
||||
}
|
||||
|
||||
let refreshTimer = null
|
||||
|
||||
onMounted(() => {
|
||||
@@ -861,7 +852,7 @@ watch(trendRows, () => {
|
||||
현재 접속자
|
||||
</h2>
|
||||
<p class="admin-dashboard__live-count text-xs text-muted">
|
||||
{{ realtime.sessions.length }}명 표시
|
||||
{{ realtime.summary.onlineNow }}명 표시
|
||||
</p>
|
||||
</div>
|
||||
<ul
|
||||
@@ -870,7 +861,7 @@ watch(trendRows, () => {
|
||||
>
|
||||
<li
|
||||
v-for="session in realtime.sessions"
|
||||
:key="session.sessionHash"
|
||||
:key="session.activeKey"
|
||||
class="admin-dashboard__live-item flex items-center gap-3 py-3"
|
||||
>
|
||||
<img
|
||||
@@ -898,7 +889,10 @@ watch(trendRows, () => {
|
||||
접속 유지 {{ formatEngagedDuration(session.durationSeconds) }}
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] leading-snug text-muted">
|
||||
IP {{ session.ipAddress || '-' }} · SID {{ formatSessionId(session.sessionHash) }}
|
||||
IP {{ session.ipAddress || '-' }}
|
||||
<span v-if="Number(session.pageCount || 0) > 1">
|
||||
· 열린 페이지 {{ session.pageCount }}개
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -53,6 +53,27 @@ const getAnalyticsCollectedSinceDay = () => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 화면에 표시할 콘텐츠 참여 지표를 조회수 기준으로 정규화한다.
|
||||
* @param {{ views?: number, reads?: number, scroll50?: number, scroll75?: number, scroll100?: number }} values - 원시 지표
|
||||
* @returns {{ views: number, reads: number, scroll50: number, scroll75: number, scroll100: number }} 정규화 지표
|
||||
*/
|
||||
const normalizeContentEngagementStats = (values = {}) => {
|
||||
const views = Math.max(Number(values.views || 0), 0)
|
||||
const reads = Math.min(Math.max(Number(values.reads || 0), 0), views)
|
||||
const scroll50 = Math.min(Math.max(Number(values.scroll50 || 0), 0), views)
|
||||
const scroll75 = Math.min(Math.max(Number(values.scroll75 || 0), 0), scroll50)
|
||||
const scroll100 = Math.min(Math.max(Number(values.scroll100 || 0), 0), scroll75)
|
||||
|
||||
return {
|
||||
views,
|
||||
reads,
|
||||
scroll50,
|
||||
scroll75,
|
||||
scroll100
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일별 사이트 통계 행을 보장한다.
|
||||
* @param {import('postgres').Sql} sql - DB 클라이언트
|
||||
@@ -999,15 +1020,20 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
`
|
||||
|
||||
const scrollRows = await sql`
|
||||
SELECT COALESCE(SUM(scroll_50), 0)::int AS scroll_50
|
||||
SELECT
|
||||
COALESCE(SUM(views), 0)::int AS views,
|
||||
COALESCE(SUM(scroll_50), 0)::int AS scroll_50
|
||||
FROM post_analytics_daily
|
||||
WHERE day >= ${rangeStartDay}::date
|
||||
`
|
||||
|
||||
const onlineRows = await sql`
|
||||
SELECT
|
||||
COUNT(*)::int AS online_now,
|
||||
COUNT(*) FILTER (WHERE user_id IS NOT NULL)::int AS logged_in_now
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN user_id IS NOT NULL THEN 'user:' || user_id::text
|
||||
ELSE 'ip:' || COALESCE(NULLIF(ip_address, ''), session_hash)
|
||||
END)::int AS online_now,
|
||||
COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL)::int AS logged_in_now
|
||||
FROM analytics_active_sessions
|
||||
WHERE last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
|
||||
`
|
||||
@@ -1028,6 +1054,7 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
post_daily AS (
|
||||
SELECT
|
||||
day,
|
||||
COALESCE(SUM(views), 0)::int AS views,
|
||||
COALESCE(SUM(scroll_50), 0)::int AS scroll_50
|
||||
FROM post_analytics_daily
|
||||
WHERE day BETWEEN ${trendStartDay}::date AND ${today}::date
|
||||
@@ -1042,6 +1069,7 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
THEN ROUND(site_analytics_daily.total_engaged_seconds::numeric / site_analytics_daily.engaged_views)::int
|
||||
ELSE 0
|
||||
END AS avg_engaged_seconds,
|
||||
COALESCE(post_daily.views, 0)::int AS post_views,
|
||||
COALESCE(post_daily.scroll_50, 0)::int AS scroll_50
|
||||
FROM buckets
|
||||
LEFT JOIN site_analytics_daily ON site_analytics_daily.day = buckets.start_day
|
||||
@@ -1098,6 +1126,12 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
))::int
|
||||
ELSE 0
|
||||
END AS avg_engaged_seconds,
|
||||
COALESCE((
|
||||
SELECT SUM(views)
|
||||
FROM post_analytics_daily
|
||||
WHERE day BETWEEN buckets.start_day AND buckets.end_day
|
||||
AND day >= ${collectedSince}::date
|
||||
), 0)::int AS post_views,
|
||||
COALESCE((
|
||||
SELECT SUM(scroll_50)
|
||||
FROM post_analytics_daily
|
||||
@@ -1144,6 +1178,12 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
))::int
|
||||
ELSE 0
|
||||
END AS avg_engaged_seconds,
|
||||
COALESCE((
|
||||
SELECT SUM(views)
|
||||
FROM post_analytics_daily
|
||||
WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 month' - interval '1 day')::date, ${today}::date)
|
||||
AND day >= ${collectedSince}::date
|
||||
), 0)::int AS post_views,
|
||||
COALESCE((
|
||||
SELECT SUM(scroll_50)
|
||||
FROM post_analytics_daily
|
||||
@@ -1193,6 +1233,12 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
))::int
|
||||
ELSE 0
|
||||
END AS avg_engaged_seconds,
|
||||
COALESCE((
|
||||
SELECT SUM(views)
|
||||
FROM post_analytics_daily
|
||||
WHERE day BETWEEN buckets.start_day AND LEAST((buckets.start_day + interval '1 year' - interval '1 day')::date, ${today}::date)
|
||||
AND day >= ${collectedSince}::date
|
||||
), 0)::int AS post_views,
|
||||
COALESCE((
|
||||
SELECT SUM(scroll_50)
|
||||
FROM post_analytics_daily
|
||||
@@ -1268,7 +1314,10 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
avgEngagedSeconds: engagedViews > 0
|
||||
? Math.round(totalEngagedSeconds / engagedViews)
|
||||
: 0,
|
||||
scroll50Reach: Number(scrollRows[0]?.scroll_50 || 0),
|
||||
scroll50Reach: Math.min(
|
||||
Number(scrollRows[0]?.scroll_50 || 0),
|
||||
Number(scrollRows[0]?.views || 0)
|
||||
),
|
||||
collectedSince,
|
||||
trends: trendRows.map((row) => {
|
||||
const startDay = row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : ''
|
||||
@@ -1277,25 +1326,30 @@ export const getAnalyticsSummary = async (options = {}) => {
|
||||
? aggregateTrafficBreakdownForRange(startDay, endDay)
|
||||
: { trafficSearch: 0, trafficSns: 0, trafficOther: 0 }
|
||||
|
||||
const scroll50Reach = Math.min(
|
||||
Number(row.scroll_50 || 0),
|
||||
Number(row.post_views || 0)
|
||||
)
|
||||
|
||||
return {
|
||||
day: startDay,
|
||||
startDay,
|
||||
endDay,
|
||||
label: startDay && endDay
|
||||
? formatAnalyticsTrendLabel(
|
||||
period,
|
||||
startDay,
|
||||
endDay,
|
||||
today
|
||||
)
|
||||
: '',
|
||||
visitors: Number(row.visitors || 0),
|
||||
trafficSearch: trafficBreakdown.trafficSearch,
|
||||
trafficSns: trafficBreakdown.trafficSns,
|
||||
trafficOther: trafficBreakdown.trafficOther,
|
||||
avgEngagedSeconds: Number(row.avg_engaged_seconds || 0),
|
||||
scroll50Reach: Number(row.scroll_50 || 0)
|
||||
}
|
||||
day: startDay,
|
||||
startDay,
|
||||
endDay,
|
||||
label: startDay && endDay
|
||||
? formatAnalyticsTrendLabel(
|
||||
period,
|
||||
startDay,
|
||||
endDay,
|
||||
today
|
||||
)
|
||||
: '',
|
||||
visitors: Number(row.visitors || 0),
|
||||
trafficSearch: trafficBreakdown.trafficSearch,
|
||||
trafficSns: trafficBreakdown.trafficSns,
|
||||
trafficOther: trafficBreakdown.trafficOther,
|
||||
avgEngagedSeconds: Number(row.avg_engaged_seconds || 0),
|
||||
scroll50Reach
|
||||
}
|
||||
}),
|
||||
period,
|
||||
days
|
||||
@@ -1322,8 +1376,12 @@ export const getAnalyticsRealtimeSummary = async () => {
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
COUNT(*)::int AS online_now,
|
||||
COUNT(*) FILTER (WHERE user_id IS NOT NULL)::int AS logged_in_now
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN user_id IS NOT NULL THEN 'user:' || user_id::text
|
||||
ELSE 'ip:' || COALESCE(NULLIF(ip_address, ''), session_hash)
|
||||
END)::int AS online_now,
|
||||
COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL)::int AS logged_in_now,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(ip_address, ''), session_hash)) FILTER (WHERE user_id IS NULL)::int AS anonymous_now
|
||||
FROM analytics_active_sessions
|
||||
WHERE last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
|
||||
`
|
||||
@@ -1334,7 +1392,7 @@ export const getAnalyticsRealtimeSummary = async () => {
|
||||
return {
|
||||
onlineNow,
|
||||
loggedInNow,
|
||||
anonymousNow: Math.max(onlineNow - loggedInNow, 0)
|
||||
anonymousNow: Number(rows[0]?.anonymous_now || 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1346,6 +1404,7 @@ export const getAnalyticsRealtimeSummary = async () => {
|
||||
export const getAnalyticsActiveSessions = async (options = {}) => {
|
||||
const sql = getPostgresClient()
|
||||
const limit = Math.min(Math.max(Number(options.limit) || 20, 1), 50)
|
||||
const rawLimit = Math.min(limit * 8, 200)
|
||||
|
||||
if (!sql) {
|
||||
return []
|
||||
@@ -1375,29 +1434,53 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
|
||||
LEFT JOIN users ON users.id = analytics_active_sessions.user_id
|
||||
WHERE analytics_active_sessions.last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
|
||||
ORDER BY analytics_active_sessions.last_seen_at DESC
|
||||
LIMIT ${limit}
|
||||
LIMIT ${rawLimit}
|
||||
`
|
||||
|
||||
return rows.map((row) => ({
|
||||
sessionHash: row.session_hash,
|
||||
path: row.path,
|
||||
postSlug: row.post_slug || '',
|
||||
pageSlug: row.page_slug || '',
|
||||
ipAddress: row.ip_address || '',
|
||||
postTitle: row.post_title || '',
|
||||
pageTitle: row.page_title || '',
|
||||
durationSeconds: Number(row.duration_seconds || 0),
|
||||
maxScrollRatio: Number(row.max_scroll_ratio || 0),
|
||||
lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at).toISOString() : null,
|
||||
isLoggedIn: Boolean(row.user_id),
|
||||
user: row.user_id
|
||||
? {
|
||||
id: row.user_id,
|
||||
username: row.username,
|
||||
avatarUrl: row.avatar_url || ''
|
||||
}
|
||||
: null
|
||||
}))
|
||||
/** @type {Map<string, Object>} */
|
||||
const activeVisitors = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
const activeKey = row.user_id
|
||||
? `user:${row.user_id}`
|
||||
: `ip:${row.ip_address || row.session_hash}`
|
||||
const existingVisitor = activeVisitors.get(activeKey)
|
||||
const durationSeconds = Number(row.duration_seconds || 0)
|
||||
|
||||
if (existingVisitor) {
|
||||
existingVisitor.pageCount += 1
|
||||
existingVisitor.durationSeconds = Math.max(existingVisitor.durationSeconds, durationSeconds)
|
||||
existingVisitor.maxScrollRatio = Math.max(
|
||||
existingVisitor.maxScrollRatio,
|
||||
Number(row.max_scroll_ratio || 0)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
activeVisitors.set(activeKey, {
|
||||
activeKey,
|
||||
path: row.path,
|
||||
postSlug: row.post_slug || '',
|
||||
pageSlug: row.page_slug || '',
|
||||
ipAddress: row.ip_address || '',
|
||||
postTitle: row.post_title || '',
|
||||
pageTitle: row.page_title || '',
|
||||
pageCount: 1,
|
||||
durationSeconds,
|
||||
maxScrollRatio: Number(row.max_scroll_ratio || 0),
|
||||
lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at).toISOString() : null,
|
||||
isLoggedIn: Boolean(row.user_id),
|
||||
user: row.user_id
|
||||
? {
|
||||
id: row.user_id,
|
||||
username: row.username,
|
||||
avatarUrl: row.avatar_url || ''
|
||||
}
|
||||
: null
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(activeVisitors.values()).slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1531,7 +1614,9 @@ export const getAnalyticsRangeDetail = async (options = {}) => {
|
||||
`
|
||||
|
||||
const scrollRows = await sql`
|
||||
SELECT COALESCE(SUM(post_analytics_daily.scroll_50), 0)::int AS scroll_50
|
||||
SELECT
|
||||
COALESCE(SUM(post_analytics_daily.views), 0)::int AS views,
|
||||
COALESCE(SUM(post_analytics_daily.scroll_50), 0)::int AS scroll_50
|
||||
FROM post_analytics_daily
|
||||
WHERE post_analytics_daily.day BETWEEN ${startDay}::date AND ${endDay}::date
|
||||
`
|
||||
@@ -1634,7 +1719,10 @@ export const getAnalyticsRangeDetail = async (options = {}) => {
|
||||
avgEngagedSeconds: engagedViews > 0
|
||||
? Math.round(totalEngagedSeconds / engagedViews)
|
||||
: 0,
|
||||
scroll50Reach: Number(scrollRows[0]?.scroll_50 || 0)
|
||||
scroll50Reach: Math.min(
|
||||
Number(scrollRows[0]?.scroll_50 || 0),
|
||||
Number(scrollRows[0]?.views || 0)
|
||||
)
|
||||
},
|
||||
sources: sourceRows.map((row) => ({
|
||||
sourceGroup: row.source_group,
|
||||
@@ -1654,31 +1742,50 @@ export const getAnalyticsRangeDetail = async (options = {}) => {
|
||||
pageViews: Number(row.page_views || 0),
|
||||
visitors: Number(row.visitors || 0)
|
||||
})),
|
||||
topPosts: postRows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : null,
|
||||
views: Number(row.views || 0),
|
||||
monthlyViews: Number(row.monthly_views || 0),
|
||||
reads: Number(row.reads || 0),
|
||||
visitors: Number(row.visitors || 0),
|
||||
scroll50: Number(row.scroll_50 || 0),
|
||||
scroll75: Number(row.scroll_75 || 0),
|
||||
scroll100: Number(row.scroll_100 || 0)
|
||||
})),
|
||||
topPages: pageRows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : null,
|
||||
views: Number(row.views || 0),
|
||||
monthlyViews: Number(row.monthly_views || 0),
|
||||
visitors: Number(row.visitors || 0),
|
||||
scroll50: Number(row.scroll_50 || 0),
|
||||
scroll75: Number(row.scroll_75 || 0),
|
||||
scroll100: Number(row.scroll_100 || 0)
|
||||
}))
|
||||
topPosts: postRows.map((row) => {
|
||||
const engagementStats = normalizeContentEngagementStats({
|
||||
views: row.views,
|
||||
reads: row.reads,
|
||||
scroll50: row.scroll_50,
|
||||
scroll75: row.scroll_75,
|
||||
scroll100: row.scroll_100
|
||||
})
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : null,
|
||||
views: engagementStats.views,
|
||||
monthlyViews: Number(row.monthly_views || 0),
|
||||
reads: engagementStats.reads,
|
||||
visitors: Number(row.visitors || 0),
|
||||
scroll50: engagementStats.scroll50,
|
||||
scroll75: engagementStats.scroll75,
|
||||
scroll100: engagementStats.scroll100
|
||||
}
|
||||
}),
|
||||
topPages: pageRows.map((row) => {
|
||||
const engagementStats = normalizeContentEngagementStats({
|
||||
views: row.views,
|
||||
scroll50: row.scroll_50,
|
||||
scroll75: row.scroll_75,
|
||||
scroll100: row.scroll_100
|
||||
})
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : null,
|
||||
views: engagementStats.views,
|
||||
monthlyViews: Number(row.monthly_views || 0),
|
||||
visitors: Number(row.visitors || 0),
|
||||
scroll50: engagementStats.scroll50,
|
||||
scroll75: engagementStats.scroll75,
|
||||
scroll100: engagementStats.scroll100
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1887,17 +1994,24 @@ export const getPostAnalyticsDetail = async (options = {}) => {
|
||||
const mapSummaryRow = (row) => {
|
||||
const engagedViews = Number(row?.engaged_views || 0)
|
||||
const totalEngagedSeconds = Number(row?.total_engaged_seconds || 0)
|
||||
const engagementStats = normalizeContentEngagementStats({
|
||||
views: row?.views,
|
||||
reads: row?.reads,
|
||||
scroll50: row?.scroll_50,
|
||||
scroll75: row?.scroll_75,
|
||||
scroll100: row?.scroll_100
|
||||
})
|
||||
|
||||
return {
|
||||
views: Number(row?.views || 0),
|
||||
views: engagementStats.views,
|
||||
visitors: Number(row?.visitors || 0),
|
||||
reads: Number(row?.reads || 0),
|
||||
reads: engagementStats.reads,
|
||||
avgEngagedSeconds: engagedViews > 0
|
||||
? Math.round(totalEngagedSeconds / engagedViews)
|
||||
: 0,
|
||||
scroll50: Number(row?.scroll_50 || 0),
|
||||
scroll75: Number(row?.scroll_75 || 0),
|
||||
scroll100: Number(row?.scroll_100 || 0)
|
||||
scroll50: engagementStats.scroll50,
|
||||
scroll75: engagementStats.scroll75,
|
||||
scroll100: engagementStats.scroll100
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1908,14 +2022,21 @@ export const getPostAnalyticsDetail = async (options = {}) => {
|
||||
updatedAt: new Date().toISOString(),
|
||||
total: mapSummaryRow(totalRows[0]),
|
||||
period: mapSummaryRow(periodRows[0]),
|
||||
trends: trendRows.map((row) => ({
|
||||
day: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '',
|
||||
startDay: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '',
|
||||
endDay: row.end_day ? new Date(row.end_day).toISOString().slice(0, 10) : '',
|
||||
views: Number(row.views || 0),
|
||||
visitors: Number(row.visitors || 0),
|
||||
reads: Number(row.reads || 0)
|
||||
})),
|
||||
trends: trendRows.map((row) => {
|
||||
const engagementStats = normalizeContentEngagementStats({
|
||||
views: row.views,
|
||||
reads: row.reads
|
||||
})
|
||||
|
||||
return {
|
||||
day: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '',
|
||||
startDay: row.start_day ? new Date(row.start_day).toISOString().slice(0, 10) : '',
|
||||
endDay: row.end_day ? new Date(row.end_day).toISOString().slice(0, 10) : '',
|
||||
views: engagementStats.views,
|
||||
visitors: Number(row.visitors || 0),
|
||||
reads: engagementStats.reads
|
||||
}
|
||||
}),
|
||||
sources: sourceRows.map((row) => ({
|
||||
sourceGroup: row.source_group,
|
||||
sourceName: row.source_name,
|
||||
@@ -2087,16 +2208,22 @@ export const getPageAnalyticsDetail = async (options = {}) => {
|
||||
const mapPageSummaryRow = (row) => {
|
||||
const engagedViews = Number(row?.engaged_views || 0)
|
||||
const totalEngagedSeconds = Number(row?.total_engaged_seconds || 0)
|
||||
const engagementStats = normalizeContentEngagementStats({
|
||||
views: row?.views,
|
||||
scroll50: row?.scroll_50,
|
||||
scroll75: row?.scroll_75,
|
||||
scroll100: row?.scroll_100
|
||||
})
|
||||
|
||||
return {
|
||||
views: Number(row?.views || 0),
|
||||
views: engagementStats.views,
|
||||
visitors: Number(row?.visitors || 0),
|
||||
avgEngagedSeconds: engagedViews > 0
|
||||
? Math.round(totalEngagedSeconds / engagedViews)
|
||||
: 0,
|
||||
scroll50: Number(row?.scroll_50 || 0),
|
||||
scroll75: Number(row?.scroll_75 || 0),
|
||||
scroll100: Number(row?.scroll_100 || 0)
|
||||
scroll50: engagementStats.scroll50,
|
||||
scroll75: engagementStats.scroll75,
|
||||
scroll100: engagementStats.scroll100
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2164,22 +2291,29 @@ export const getAnalyticsTopPosts = async (options = {}) => {
|
||||
return rows.map((row) => {
|
||||
const engagedViews = Number(row.engaged_views || 0)
|
||||
const totalEngagedSeconds = Number(row.total_engaged_seconds || 0)
|
||||
const engagementStats = normalizeContentEngagementStats({
|
||||
views: row.views,
|
||||
reads: row.reads,
|
||||
scroll50: row.scroll_50,
|
||||
scroll75: row.scroll_75,
|
||||
scroll100: row.scroll_100
|
||||
})
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : null,
|
||||
views: Number(row.views || 0),
|
||||
views: engagementStats.views,
|
||||
monthlyViews: Number(row.monthly_views || 0),
|
||||
reads: Number(row.reads || 0),
|
||||
reads: engagementStats.reads,
|
||||
visitors: Number(row.visitors || 0),
|
||||
avgEngagedSeconds: engagedViews > 0
|
||||
? Math.round(totalEngagedSeconds / engagedViews)
|
||||
: 0,
|
||||
scroll50: Number(row.scroll_50 || 0),
|
||||
scroll75: Number(row.scroll_75 || 0),
|
||||
scroll100: Number(row.scroll_100 || 0)
|
||||
scroll50: engagementStats.scroll50,
|
||||
scroll75: engagementStats.scroll75,
|
||||
scroll100: engagementStats.scroll100
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2225,19 +2359,25 @@ export const getAnalyticsTopPages = async (options = {}) => {
|
||||
return rows.map((row) => {
|
||||
const engagedViews = Number(row.engaged_views || 0)
|
||||
const totalEngagedSeconds = Number(row.total_engaged_seconds || 0)
|
||||
const engagementStats = normalizeContentEngagementStats({
|
||||
views: row.views,
|
||||
scroll50: row.scroll_50,
|
||||
scroll75: row.scroll_75,
|
||||
scroll100: row.scroll_100
|
||||
})
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
slug: row.slug,
|
||||
views: Number(row.views || 0),
|
||||
views: engagementStats.views,
|
||||
visitors: Number(row.visitors || 0),
|
||||
avgEngagedSeconds: engagedViews > 0
|
||||
? Math.round(totalEngagedSeconds / engagedViews)
|
||||
: 0,
|
||||
scroll50: Number(row.scroll_50 || 0),
|
||||
scroll75: Number(row.scroll_75 || 0),
|
||||
scroll100: Number(row.scroll_100 || 0)
|
||||
scroll50: engagementStats.scroll50,
|
||||
scroll75: engagementStats.scroll75,
|
||||
scroll100: engagementStats.scroll100
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user