feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

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