feat: ship blog platform admin and deploy stack
This commit is contained in:
@@ -9,10 +9,13 @@ use sea_orm::{
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::models::_entities::query_events;
|
||||
use crate::models::_entities::{content_events, posts, query_events};
|
||||
|
||||
const EVENT_TYPE_SEARCH: &str = "search";
|
||||
const EVENT_TYPE_AI_QUESTION: &str = "ai_question";
|
||||
pub const CONTENT_EVENT_PAGE_VIEW: &str = "page_view";
|
||||
pub const CONTENT_EVENT_READ_PROGRESS: &str = "read_progress";
|
||||
pub const CONTENT_EVENT_READ_COMPLETE: &str = "read_complete";
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct QueryEventRequestContext {
|
||||
@@ -34,6 +37,25 @@ pub struct QueryEventDraft {
|
||||
pub latency_ms: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ContentEventRequestContext {
|
||||
pub path: Option<String>,
|
||||
pub referrer: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ContentEventDraft {
|
||||
pub event_type: String,
|
||||
pub path: String,
|
||||
pub post_slug: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub request_context: ContentEventRequestContext,
|
||||
pub duration_ms: Option<i32>,
|
||||
pub progress_percent: Option<i32>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AnalyticsOverview {
|
||||
pub total_searches: u64,
|
||||
@@ -48,6 +70,17 @@ pub struct AnalyticsOverview {
|
||||
pub avg_ai_latency_ms_last_7d: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ContentAnalyticsOverview {
|
||||
pub total_page_views: u64,
|
||||
pub page_views_last_24h: u64,
|
||||
pub page_views_last_7d: u64,
|
||||
pub total_read_completes: u64,
|
||||
pub read_completes_last_7d: u64,
|
||||
pub avg_read_progress_last_7d: f64,
|
||||
pub avg_read_duration_ms_last_7d: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AnalyticsTopQuery {
|
||||
pub query: String,
|
||||
@@ -75,6 +108,22 @@ pub struct AnalyticsProviderBucket {
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AnalyticsReferrerBucket {
|
||||
pub referrer: String,
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AnalyticsPopularPost {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub page_views: u64,
|
||||
pub read_completes: u64,
|
||||
pub avg_progress_percent: f64,
|
||||
pub avg_duration_ms: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AnalyticsDailyBucket {
|
||||
pub date: String,
|
||||
@@ -85,13 +134,39 @@ pub struct AnalyticsDailyBucket {
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdminAnalyticsResponse {
|
||||
pub overview: AnalyticsOverview,
|
||||
pub content_overview: ContentAnalyticsOverview,
|
||||
pub top_search_terms: Vec<AnalyticsTopQuery>,
|
||||
pub top_ai_questions: Vec<AnalyticsTopQuery>,
|
||||
pub recent_events: Vec<AnalyticsRecentEvent>,
|
||||
pub providers_last_7d: Vec<AnalyticsProviderBucket>,
|
||||
pub top_referrers: Vec<AnalyticsReferrerBucket>,
|
||||
pub popular_posts: Vec<AnalyticsPopularPost>,
|
||||
pub daily_activity: Vec<AnalyticsDailyBucket>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PublicContentHighlights {
|
||||
pub overview: ContentAnalyticsOverview,
|
||||
pub popular_posts: Vec<AnalyticsPopularPost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PublicContentWindowOverview {
|
||||
pub page_views: u64,
|
||||
pub read_completes: u64,
|
||||
pub avg_read_progress: f64,
|
||||
pub avg_read_duration_ms: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PublicContentWindowHighlights {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub days: i32,
|
||||
pub overview: PublicContentWindowOverview,
|
||||
pub popular_posts: Vec<AnalyticsPopularPost>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct QueryAggregate {
|
||||
query: String,
|
||||
@@ -122,6 +197,18 @@ fn format_timestamp(value: DateTime<Utc>) -> String {
|
||||
value.format("%Y-%m-%d %H:%M").to_string()
|
||||
}
|
||||
|
||||
fn normalize_referrer_source(value: Option<String>) -> String {
|
||||
let Some(value) = trim_to_option(value) else {
|
||||
return "direct".to_string();
|
||||
};
|
||||
|
||||
reqwest::Url::parse(&value)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(ToString::to_string))
|
||||
.filter(|item| !item.trim().is_empty())
|
||||
.unwrap_or(value)
|
||||
}
|
||||
|
||||
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
|
||||
headers
|
||||
.get(key)
|
||||
@@ -134,6 +221,10 @@ fn clamp_latency(latency_ms: i64) -> i32 {
|
||||
latency_ms.clamp(0, i64::from(i32::MAX)) as i32
|
||||
}
|
||||
|
||||
fn clamp_percentage(value: i32) -> i32 {
|
||||
value.clamp(0, 100)
|
||||
}
|
||||
|
||||
fn build_query_aggregates(
|
||||
events: &[query_events::Model],
|
||||
wanted_type: &str,
|
||||
@@ -199,6 +290,17 @@ pub fn request_context_from_headers(path: &str, headers: &HeaderMap) -> QueryEve
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content_request_context_from_headers(
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> ContentEventRequestContext {
|
||||
ContentEventRequestContext {
|
||||
path: trim_to_option(Some(path.to_string())),
|
||||
referrer: header_value(headers, "referer"),
|
||||
user_agent: header_value(headers, "user-agent"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn record_event(ctx: &AppContext, draft: QueryEventDraft) {
|
||||
let query_text = draft.query_text.trim().to_string();
|
||||
if query_text.is_empty() {
|
||||
@@ -226,6 +328,38 @@ pub async fn record_event(ctx: &AppContext, draft: QueryEventDraft) {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn record_content_event(ctx: &AppContext, draft: ContentEventDraft) {
|
||||
let path = draft.path.trim().to_string();
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let event_type = draft.event_type.trim().to_ascii_lowercase();
|
||||
if !matches!(
|
||||
event_type.as_str(),
|
||||
CONTENT_EVENT_PAGE_VIEW | CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active_model = content_events::ActiveModel {
|
||||
event_type: Set(event_type),
|
||||
path: Set(path),
|
||||
post_slug: Set(trim_to_option(draft.post_slug)),
|
||||
session_id: Set(trim_to_option(draft.session_id)),
|
||||
referrer: Set(trim_to_option(draft.request_context.referrer)),
|
||||
user_agent: Set(trim_to_option(draft.request_context.user_agent)),
|
||||
duration_ms: Set(draft.duration_ms.map(|value| value.max(0))),
|
||||
progress_percent: Set(draft.progress_percent.map(clamp_percentage)),
|
||||
metadata: Set(draft.metadata),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Err(error) = active_model.insert(&ctx.db).await {
|
||||
tracing::warn!("failed to record content analytics event: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn record_search_event(
|
||||
ctx: &AppContext,
|
||||
query_text: &str,
|
||||
@@ -309,12 +443,25 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
||||
.filter(query_events::Column::CreatedAt.gte(since_24h))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
let total_page_views = content_events::Entity::find()
|
||||
.filter(content_events::Column::EventType.eq(CONTENT_EVENT_PAGE_VIEW))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
let total_read_completes = content_events::Entity::find()
|
||||
.filter(content_events::Column::EventType.eq(CONTENT_EVENT_READ_COMPLETE))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let last_7d_events = query_events::Entity::find()
|
||||
.filter(query_events::Column::CreatedAt.gte(since_7d))
|
||||
.order_by_desc(query_events::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let last_7d_content_events = content_events::Entity::find()
|
||||
.filter(content_events::Column::CreatedAt.gte(since_7d))
|
||||
.order_by_desc(content_events::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let searches_last_7d = last_7d_events
|
||||
.iter()
|
||||
@@ -336,6 +483,14 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
||||
let mut counted_search_results = 0_u64;
|
||||
let mut total_ai_latency = 0.0_f64;
|
||||
let mut counted_ai_latency = 0_u64;
|
||||
let mut referrer_breakdown: HashMap<String, u64> = HashMap::new();
|
||||
let mut total_read_progress = 0.0_f64;
|
||||
let mut counted_read_progress = 0_u64;
|
||||
let mut total_read_duration = 0.0_f64;
|
||||
let mut counted_read_duration = 0_u64;
|
||||
let mut page_views_last_24h = 0_u64;
|
||||
let mut page_views_last_7d = 0_u64;
|
||||
let mut read_completes_last_7d = 0_u64;
|
||||
|
||||
for offset in 0..7 {
|
||||
let date = (now - Duration::days(offset)).date_naive();
|
||||
@@ -372,6 +527,104 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
||||
}
|
||||
}
|
||||
|
||||
let post_titles = posts::Entity::find()
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|post| {
|
||||
(
|
||||
post.slug,
|
||||
post.title.unwrap_or_else(|| "Untitled post".to_string()),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let mut post_breakdown: HashMap<String, (u64, u64, f64, u64, f64, u64)> = HashMap::new();
|
||||
|
||||
for event in &last_7d_content_events {
|
||||
let created_at = DateTime::<Utc>::from(event.created_at);
|
||||
|
||||
if event.event_type == CONTENT_EVENT_PAGE_VIEW {
|
||||
page_views_last_7d += 1;
|
||||
if created_at >= since_24h {
|
||||
page_views_last_24h += 1;
|
||||
}
|
||||
|
||||
let referrer = normalize_referrer_source(event.referrer.clone());
|
||||
*referrer_breakdown.entry(referrer).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
read_completes_last_7d += 1;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event.event_type.as_str(),
|
||||
CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE
|
||||
) {
|
||||
let progress = event.progress_percent.unwrap_or({
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
if progress > 0 {
|
||||
total_read_progress += f64::from(progress);
|
||||
counted_read_progress += 1;
|
||||
}
|
||||
|
||||
if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) {
|
||||
total_read_duration += f64::from(duration_ms);
|
||||
counted_read_duration += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(post_slug) = event
|
||||
.post_slug
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let entry = post_breakdown
|
||||
.entry(post_slug)
|
||||
.or_insert((0, 0, 0.0, 0, 0.0, 0));
|
||||
|
||||
if event.event_type == CONTENT_EVENT_PAGE_VIEW {
|
||||
entry.0 += 1;
|
||||
}
|
||||
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
entry.1 += 1;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event.event_type.as_str(),
|
||||
CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE
|
||||
) {
|
||||
let progress = event.progress_percent.unwrap_or({
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
if progress > 0 {
|
||||
entry.2 += f64::from(progress);
|
||||
entry.3 += 1;
|
||||
}
|
||||
|
||||
if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) {
|
||||
entry.4 += f64::from(duration_ms);
|
||||
entry.5 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut providers_last_7d = provider_breakdown
|
||||
.into_iter()
|
||||
.map(|(provider, count)| AnalyticsProviderBucket { provider, count })
|
||||
@@ -384,6 +637,50 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
||||
});
|
||||
providers_last_7d.truncate(6);
|
||||
|
||||
let mut top_referrers = referrer_breakdown
|
||||
.into_iter()
|
||||
.map(|(referrer, count)| AnalyticsReferrerBucket { referrer, count })
|
||||
.collect::<Vec<_>>();
|
||||
top_referrers.sort_by(|left, right| {
|
||||
right
|
||||
.count
|
||||
.cmp(&left.count)
|
||||
.then_with(|| left.referrer.cmp(&right.referrer))
|
||||
});
|
||||
top_referrers.truncate(8);
|
||||
|
||||
let mut popular_posts = post_breakdown
|
||||
.into_iter()
|
||||
.map(
|
||||
|(slug, (page_views, read_completes, total_progress, progress_count, total_duration, duration_count))| {
|
||||
AnalyticsPopularPost {
|
||||
title: post_titles
|
||||
.get(&slug)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| slug.clone()),
|
||||
slug,
|
||||
page_views,
|
||||
read_completes,
|
||||
avg_progress_percent: if progress_count > 0 {
|
||||
total_progress / progress_count as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
avg_duration_ms: (duration_count > 0)
|
||||
.then(|| total_duration / duration_count as f64),
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
popular_posts.sort_by(|left, right| {
|
||||
right
|
||||
.page_views
|
||||
.cmp(&left.page_views)
|
||||
.then_with(|| right.read_completes.cmp(&left.read_completes))
|
||||
.then_with(|| left.slug.cmp(&right.slug))
|
||||
});
|
||||
popular_posts.truncate(10);
|
||||
|
||||
let mut daily_activity = daily_map
|
||||
.into_iter()
|
||||
.map(|(date, (searches, ai_questions))| AnalyticsDailyBucket {
|
||||
@@ -432,10 +729,448 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
|
||||
avg_ai_latency_ms_last_7d: (counted_ai_latency > 0)
|
||||
.then(|| total_ai_latency / counted_ai_latency as f64),
|
||||
},
|
||||
content_overview: ContentAnalyticsOverview {
|
||||
total_page_views,
|
||||
page_views_last_24h,
|
||||
page_views_last_7d,
|
||||
total_read_completes,
|
||||
read_completes_last_7d,
|
||||
avg_read_progress_last_7d: if counted_read_progress > 0 {
|
||||
total_read_progress / counted_read_progress as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
avg_read_duration_ms_last_7d: (counted_read_duration > 0)
|
||||
.then(|| total_read_duration / counted_read_duration as f64),
|
||||
},
|
||||
top_search_terms,
|
||||
top_ai_questions,
|
||||
recent_events,
|
||||
providers_last_7d,
|
||||
top_referrers,
|
||||
popular_posts,
|
||||
daily_activity,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn build_public_content_highlights(
|
||||
ctx: &AppContext,
|
||||
public_posts: &[posts::Model],
|
||||
) -> Result<PublicContentHighlights> {
|
||||
if public_posts.is_empty() {
|
||||
return Ok(PublicContentHighlights {
|
||||
overview: ContentAnalyticsOverview {
|
||||
total_page_views: 0,
|
||||
page_views_last_24h: 0,
|
||||
page_views_last_7d: 0,
|
||||
total_read_completes: 0,
|
||||
read_completes_last_7d: 0,
|
||||
avg_read_progress_last_7d: 0.0,
|
||||
avg_read_duration_ms_last_7d: None,
|
||||
},
|
||||
popular_posts: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let since_24h = now - Duration::hours(24);
|
||||
let since_7d = now - Duration::days(7);
|
||||
let public_slugs = public_posts
|
||||
.iter()
|
||||
.map(|post| post.slug.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let post_titles = public_posts
|
||||
.iter()
|
||||
.map(|post| {
|
||||
(
|
||||
post.slug.clone(),
|
||||
trim_to_option(post.title.clone()).unwrap_or_else(|| post.slug.clone()),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let total_page_views = content_events::Entity::find()
|
||||
.filter(content_events::Column::EventType.eq(CONTENT_EVENT_PAGE_VIEW))
|
||||
.filter(content_events::Column::PostSlug.is_in(public_slugs.clone()))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
let total_read_completes = content_events::Entity::find()
|
||||
.filter(content_events::Column::EventType.eq(CONTENT_EVENT_READ_COMPLETE))
|
||||
.filter(content_events::Column::PostSlug.is_in(public_slugs.clone()))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let last_7d_content_events = content_events::Entity::find()
|
||||
.filter(content_events::Column::CreatedAt.gte(since_7d))
|
||||
.filter(content_events::Column::PostSlug.is_in(public_slugs))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let mut page_views_last_24h = 0_u64;
|
||||
let mut page_views_last_7d = 0_u64;
|
||||
let mut read_completes_last_7d = 0_u64;
|
||||
let mut total_read_progress = 0.0_f64;
|
||||
let mut counted_read_progress = 0_u64;
|
||||
let mut total_read_duration = 0.0_f64;
|
||||
let mut counted_read_duration = 0_u64;
|
||||
let mut post_breakdown = HashMap::<String, (u64, u64, f64, u64, f64, u64)>::new();
|
||||
|
||||
for event in &last_7d_content_events {
|
||||
let created_at = DateTime::<Utc>::from(event.created_at);
|
||||
let Some(post_slug) = event
|
||||
.post_slug
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if event.event_type == CONTENT_EVENT_PAGE_VIEW {
|
||||
page_views_last_7d += 1;
|
||||
if created_at >= since_24h {
|
||||
page_views_last_24h += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
read_completes_last_7d += 1;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event.event_type.as_str(),
|
||||
CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE
|
||||
) {
|
||||
let progress = event.progress_percent.unwrap_or({
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
if progress > 0 {
|
||||
total_read_progress += f64::from(progress);
|
||||
counted_read_progress += 1;
|
||||
}
|
||||
|
||||
if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) {
|
||||
total_read_duration += f64::from(duration_ms);
|
||||
counted_read_duration += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let entry = post_breakdown
|
||||
.entry(post_slug)
|
||||
.or_insert((0, 0, 0.0, 0, 0.0, 0));
|
||||
|
||||
if event.event_type == CONTENT_EVENT_PAGE_VIEW {
|
||||
entry.0 += 1;
|
||||
}
|
||||
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
entry.1 += 1;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event.event_type.as_str(),
|
||||
CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE
|
||||
) {
|
||||
let progress = event.progress_percent.unwrap_or({
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
if progress > 0 {
|
||||
entry.2 += f64::from(progress);
|
||||
entry.3 += 1;
|
||||
}
|
||||
|
||||
if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) {
|
||||
entry.4 += f64::from(duration_ms);
|
||||
entry.5 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut popular_posts = post_breakdown
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
slug,
|
||||
(
|
||||
page_views,
|
||||
read_completes,
|
||||
total_progress,
|
||||
progress_count,
|
||||
total_duration,
|
||||
duration_count,
|
||||
),
|
||||
)| AnalyticsPopularPost {
|
||||
title: post_titles
|
||||
.get(&slug)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| slug.clone()),
|
||||
slug,
|
||||
page_views,
|
||||
read_completes,
|
||||
avg_progress_percent: if progress_count > 0 {
|
||||
total_progress / progress_count as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
avg_duration_ms: (duration_count > 0).then(|| total_duration / duration_count as f64),
|
||||
},
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
popular_posts.sort_by(|left, right| {
|
||||
right
|
||||
.page_views
|
||||
.cmp(&left.page_views)
|
||||
.then_with(|| right.read_completes.cmp(&left.read_completes))
|
||||
.then_with(|| left.slug.cmp(&right.slug))
|
||||
});
|
||||
popular_posts.truncate(6);
|
||||
|
||||
Ok(PublicContentHighlights {
|
||||
overview: ContentAnalyticsOverview {
|
||||
total_page_views,
|
||||
page_views_last_24h,
|
||||
page_views_last_7d,
|
||||
total_read_completes,
|
||||
read_completes_last_7d,
|
||||
avg_read_progress_last_7d: if counted_read_progress > 0 {
|
||||
total_read_progress / counted_read_progress as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
avg_read_duration_ms_last_7d: (counted_read_duration > 0)
|
||||
.then(|| total_read_duration / counted_read_duration as f64),
|
||||
},
|
||||
popular_posts,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn build_public_content_windows(
|
||||
ctx: &AppContext,
|
||||
public_posts: &[posts::Model],
|
||||
) -> Result<Vec<PublicContentWindowHighlights>> {
|
||||
if public_posts.is_empty() {
|
||||
return Ok(vec![
|
||||
build_empty_public_content_window("24h", "24h", 1),
|
||||
build_empty_public_content_window("7d", "7d", 7),
|
||||
build_empty_public_content_window("30d", "30d", 30),
|
||||
]);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let since_30d = now - Duration::days(30);
|
||||
let public_slugs = public_posts
|
||||
.iter()
|
||||
.map(|post| post.slug.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let post_titles = public_posts
|
||||
.iter()
|
||||
.map(|post| {
|
||||
(
|
||||
post.slug.clone(),
|
||||
trim_to_option(post.title.clone()).unwrap_or_else(|| post.slug.clone()),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let events = content_events::Entity::find()
|
||||
.filter(content_events::Column::CreatedAt.gte(since_30d))
|
||||
.filter(content_events::Column::PostSlug.is_in(public_slugs))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
Ok(vec![
|
||||
summarize_public_content_window(&events, &post_titles, now - Duration::hours(24), "24h", "24h", 1),
|
||||
summarize_public_content_window(&events, &post_titles, now - Duration::days(7), "7d", "7d", 7),
|
||||
summarize_public_content_window(&events, &post_titles, since_30d, "30d", "30d", 30),
|
||||
])
|
||||
}
|
||||
|
||||
fn build_empty_public_content_window(
|
||||
key: &str,
|
||||
label: &str,
|
||||
days: i32,
|
||||
) -> PublicContentWindowHighlights {
|
||||
PublicContentWindowHighlights {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
days,
|
||||
overview: PublicContentWindowOverview {
|
||||
page_views: 0,
|
||||
read_completes: 0,
|
||||
avg_read_progress: 0.0,
|
||||
avg_read_duration_ms: None,
|
||||
},
|
||||
popular_posts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_public_content_window(
|
||||
events: &[content_events::Model],
|
||||
post_titles: &HashMap<String, String>,
|
||||
since: DateTime<Utc>,
|
||||
key: &str,
|
||||
label: &str,
|
||||
days: i32,
|
||||
) -> PublicContentWindowHighlights {
|
||||
let mut page_views = 0_u64;
|
||||
let mut read_completes = 0_u64;
|
||||
let mut total_read_progress = 0.0_f64;
|
||||
let mut counted_read_progress = 0_u64;
|
||||
let mut total_read_duration = 0.0_f64;
|
||||
let mut counted_read_duration = 0_u64;
|
||||
let mut post_breakdown = HashMap::<String, (u64, u64, f64, u64, f64, u64)>::new();
|
||||
|
||||
for event in events {
|
||||
let created_at = DateTime::<Utc>::from(event.created_at);
|
||||
if created_at < since {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(post_slug) = event
|
||||
.post_slug
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if event.event_type == CONTENT_EVENT_PAGE_VIEW {
|
||||
page_views += 1;
|
||||
}
|
||||
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
read_completes += 1;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event.event_type.as_str(),
|
||||
CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE
|
||||
) {
|
||||
let progress = event.progress_percent.unwrap_or({
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
if progress > 0 {
|
||||
total_read_progress += f64::from(progress);
|
||||
counted_read_progress += 1;
|
||||
}
|
||||
|
||||
if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) {
|
||||
total_read_duration += f64::from(duration_ms);
|
||||
counted_read_duration += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let entry = post_breakdown
|
||||
.entry(post_slug)
|
||||
.or_insert((0, 0, 0.0, 0, 0.0, 0));
|
||||
|
||||
if event.event_type == CONTENT_EVENT_PAGE_VIEW {
|
||||
entry.0 += 1;
|
||||
}
|
||||
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
entry.1 += 1;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event.event_type.as_str(),
|
||||
CONTENT_EVENT_READ_PROGRESS | CONTENT_EVENT_READ_COMPLETE
|
||||
) {
|
||||
let progress = event.progress_percent.unwrap_or({
|
||||
if event.event_type == CONTENT_EVENT_READ_COMPLETE {
|
||||
100
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
if progress > 0 {
|
||||
entry.2 += f64::from(progress);
|
||||
entry.3 += 1;
|
||||
}
|
||||
|
||||
if let Some(duration_ms) = event.duration_ms.filter(|value| *value >= 0) {
|
||||
entry.4 += f64::from(duration_ms);
|
||||
entry.5 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut popular_posts = post_breakdown
|
||||
.into_iter()
|
||||
.map(
|
||||
|(
|
||||
slug,
|
||||
(
|
||||
item_page_views,
|
||||
item_read_completes,
|
||||
total_progress,
|
||||
progress_count,
|
||||
total_duration,
|
||||
duration_count,
|
||||
),
|
||||
)| AnalyticsPopularPost {
|
||||
title: post_titles
|
||||
.get(&slug)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| slug.clone()),
|
||||
slug,
|
||||
page_views: item_page_views,
|
||||
read_completes: item_read_completes,
|
||||
avg_progress_percent: if progress_count > 0 {
|
||||
total_progress / progress_count as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
avg_duration_ms: (duration_count > 0).then(|| total_duration / duration_count as f64),
|
||||
},
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
popular_posts.sort_by(|left, right| {
|
||||
right
|
||||
.page_views
|
||||
.cmp(&left.page_views)
|
||||
.then_with(|| right.read_completes.cmp(&left.read_completes))
|
||||
.then_with(|| {
|
||||
right
|
||||
.avg_progress_percent
|
||||
.partial_cmp(&left.avg_progress_percent)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.then_with(|| left.slug.cmp(&right.slug))
|
||||
});
|
||||
popular_posts.truncate(6);
|
||||
|
||||
PublicContentWindowHighlights {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
days,
|
||||
overview: PublicContentWindowOverview {
|
||||
page_views,
|
||||
read_completes,
|
||||
avg_read_progress: if counted_read_progress > 0 {
|
||||
total_read_progress / counted_read_progress as f64
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
avg_read_duration_ms: (counted_read_duration > 0)
|
||||
.then(|| total_read_duration / counted_read_duration as f64),
|
||||
},
|
||||
popular_posts,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user