관리자 대시보드 통계 표시 정규화

This commit is contained in:
2026-07-08 14:48:11 +09:00
parent 14c7245f06
commit e27ae1ea72
10 changed files with 282 additions and 121 deletions

View File

@@ -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
}
})
}