Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/app/core/reports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ use std::collections::BTreeMap;
use std::fmt::{Debug, Display};

use crate::app::DuckDBConn;
use crate::utils::duckdb::{repeat_vars, ParamVec};
use anyhow::{bail, Result};
use crate::utils::duckdb::{ParamVec, repeat_vars};
use anyhow::{Result, bail};
use chrono::{DateTime, Utc};
use duckdb::params_from_iter;
use schemars::JsonSchema;
Expand Down Expand Up @@ -66,6 +66,7 @@ pub enum Dimension {
UtmCampaign,
UtmContent,
UtmTerm,
ScreenSize,
}

#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord)]
Expand Down Expand Up @@ -191,6 +192,7 @@ fn filter_sql(filters: &[DimensionFilter]) -> Result<(String, ParamVec<'_>)> {
Dimension::UtmCampaign => format!("utm_campaign {filter_value}"),
Dimension::UtmContent => format!("utm_content {filter_value}"),
Dimension::UtmTerm => format!("utm_term {filter_value}"),
Dimension::ScreenSize => format!("screen_size {filter_value}"),
})
})
.collect::<Result<Vec<String>>>()?;
Expand Down Expand Up @@ -481,6 +483,7 @@ pub fn dimension_report(
Dimension::UtmCampaign => ("utm_campaign", "utm_campaign", None),
Dimension::UtmContent => ("utm_content", "utm_content", None),
Dimension::UtmTerm => ("utm_term", "utm_term", None),
Dimension::ScreenSize => ("screen_size", "screen_size", None),
};
let filters_sql = match (filters_sql.is_empty(), dimension_scope_sql) {
(true, Some(scope)) => format!("and ({scope})"),
Expand Down
2 changes: 2 additions & 0 deletions src/app/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub struct Event {
pub utm_campaign: Option<String>,
pub utm_content: Option<String>,
pub utm_term: Option<String>,
pub screen_size: Option<String>,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -100,6 +101,7 @@ macro_rules! event_params {
$event.utm_term,
None::<std::time::Duration>,
None::<std::time::Duration>,
$event.screen_size,
]
};
}
Expand Down
1 change: 1 addition & 0 deletions src/migrations/events/V5__screen_size.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table events add column screen_size text;
1 change: 1 addition & 0 deletions src/utils/seed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ pub fn random_events(
utm_medium: Some(random_el(UTM_MEDIUMS, 0.6).to_string()),
utm_source: Some(random_el(UTM_SOURCES, 0.6).to_string()),
utm_term: Some(random_el(UTM_TERMS, 0.6).to_string()),
screen_size: None,
})
})
}
Expand Down
5 changes: 5 additions & 0 deletions src/web/routes/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ async fn project_detailed_handler(
let city = city.filter(|city| !city.is_empty());
data.push(DimensionTableRow { dimension_value: key, value, display_name: city, icon: country });
}
Dimension::ScreenSize => {
let display_name =
key.chars().next().map(|c| c.to_uppercase().collect::<String>() + &key[c.len_utf8()..]);
data.push(DimensionTableRow { dimension_value: key, value, display_name, icon: None });
}
_ => {
data.push(DimensionTableRow { dimension_value: key, value, display_name: None, icon: None });
}
Expand Down
47 changes: 47 additions & 0 deletions src/web/routes/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ struct EventRequest {
url: String,
referrer: Option<String>,
utm: Option<Utm>,
screen_width: Option<u32>,
}

#[derive(serde::Deserialize, JsonSchema)]
Expand All @@ -42,6 +43,15 @@ struct Utm {
term: Option<String>,
}

fn screen_size_bucket(width: u32) -> &'static str {
match width {
0..=767 => "mobile",
768..=1023 => "tablet",
1024..=2559 => "desktop",
_ => "ultrawide",
}
}

static EXISTING_ENTITIES: LazyLock<quick_cache::sync::Cache<String, ()>> =
LazyLock::new(|| quick_cache::sync::Cache::new(512));

Expand Down Expand Up @@ -131,8 +141,45 @@ fn process_event(
utm_medium: event.utm.as_ref().and_then(|u| u.medium.clone()),
utm_source: event.utm.as_ref().and_then(|u| u.source.clone()),
utm_term: event.utm.as_ref().and_then(|u| u.term.clone()),
screen_size: event.screen_width.map(|w| screen_size_bucket(w).to_string()),
};

events.send(event)?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::screen_size_bucket;

#[test]
fn test_screen_size_bucket_mobile() {
assert_eq!(screen_size_bucket(0), "mobile");
assert_eq!(screen_size_bucket(375), "mobile");
assert_eq!(screen_size_bucket(430), "mobile");
assert_eq!(screen_size_bucket(767), "mobile");
}

#[test]
fn test_screen_size_bucket_tablet() {
assert_eq!(screen_size_bucket(768), "tablet");
assert_eq!(screen_size_bucket(810), "tablet");
assert_eq!(screen_size_bucket(1023), "tablet");
}

#[test]
fn test_screen_size_bucket_desktop() {
assert_eq!(screen_size_bucket(1024), "desktop");
assert_eq!(screen_size_bucket(1280), "desktop");
assert_eq!(screen_size_bucket(1920), "desktop");
assert_eq!(screen_size_bucket(2559), "desktop");
}

#[test]
fn test_screen_size_bucket_ultrawide() {
assert_eq!(screen_size_bucket(2560), "ultrawide");
assert_eq!(screen_size_bucket(3440), "ultrawide");
assert_eq!(screen_size_bucket(3840), "ultrawide");
assert_eq!(screen_size_bucket(7680), "ultrawide");
}
}
2 changes: 2 additions & 0 deletions tests/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ async fn test_dashboard() -> Result<()> {
json!({"dimension":"url","filters":[{"dimension":"fqdn","filterType":"equal","value":"example.org"},{"dimension":"url","filterType":"equal","value":"example.org/contact"},{"dimension":"referrer","filterType":"equal","value":"liwan.dev"},{"dimension":"country","filterType":"equal","value":"AU"},{"dimension":"city","filterType":"equal","value":"Sydney"},{"dimension":"platform","filterType":"equal","value":"iOS"},{"dimension":"browser","filterType":"equal","value":"Safari"},{"dimension":"mobile","filterType":"is_true"}],"metric":"views","range":{"start": start_date ,"end": end_date}}),
json!({"dimension":"city","filters":[{"dimension":"fqdn","filterType":"equal","value":"example.org"},{"dimension":"url","filterType":"equal","value":"example.org/contact"},{"dimension":"referrer","filterType":"equal","value":"liwan.dev"},{"dimension":"country","filterType":"equal","value":"AU"},{"dimension":"city","filterType":"equal","value":"Sydney"},{"dimension":"platform","filterType":"equal","value":"iOS"},{"dimension":"browser","filterType":"equal","value":"Safari"},{"dimension":"mobile","filterType":"is_true"}],"metric":"views","range":{"start": start_date ,"end": end_date}}),
json!({"dimension":"browser","filters":[{"dimension":"fqdn","filterType":"equal","value":"example.org"},{"dimension":"url","filterType":"equal","value":"example.org/contact"},{"dimension":"referrer","filterType":"equal","value":"liwan.dev"},{"dimension":"country","filterType":"equal","value":"AU"},{"dimension":"city","filterType":"equal","value":"Sydney"},{"dimension":"platform","filterType":"equal","value":"iOS"},{"dimension":"browser","filterType":"equal","value":"Safari"},{"dimension":"mobile","filterType":"is_true"}],"metric":"views","range":{"start": start_date ,"end": end_date}}),
json!({"dimension":"screen_size","filters":[],"metric":"views","range":{"start": start_date ,"end": end_date}}),
json!({"dimension":"url","filters":[{"dimension":"screen_size","filterType":"equal","value":"mobile"}],"metric":"views","range":{"start": start_date ,"end": end_date}}),
];

for request in stats_requests.iter() {
Expand Down
242 changes: 242 additions & 0 deletions tests/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,245 @@ async fn test_event() -> Result<()> {

Ok(())
}

#[tokio::test]
async fn test_event_screen_size() -> Result<()> {
let app = common::app();
let (tx, rx) = common::events();
let client = common::TestClient::new(app.clone(), tx);
app.entities.create(&Entity { display_name: "Entity 1".to_string(), id: "entity-1".to_string() }, &[])?;

let ua = vec![("user-agent".to_string(), "Mozilla/5.0 (test)".to_string())];

// Mobile: 375px
let res = client
.post_with_headers(
"/api/event",
json!({
"entity_id": "entity-1", "name": "pageview",
"url": "https://example.com/", "screen_width": 375
}),
ua.clone(),
)
.await;
res.assert_status_success();
let event = rx.recv().unwrap();
assert_eq!(event.screen_size.as_deref(), Some("mobile"));

// Tablet: 810px
let res = client
.post_with_headers(
"/api/event",
json!({
"entity_id": "entity-1", "name": "pageview",
"url": "https://example.com/", "screen_width": 810
}),
ua.clone(),
)
.await;
res.assert_status_success();
let event = rx.recv().unwrap();
assert_eq!(event.screen_size.as_deref(), Some("tablet"));

// Desktop: 1920px
let res = client
.post_with_headers(
"/api/event",
json!({
"entity_id": "entity-1", "name": "pageview",
"url": "https://example.com/", "screen_width": 1920
}),
ua.clone(),
)
.await;
res.assert_status_success();
let event = rx.recv().unwrap();
assert_eq!(event.screen_size.as_deref(), Some("desktop"));

// Ultrawide: 3840px
let res = client
.post_with_headers(
"/api/event",
json!({
"entity_id": "entity-1", "name": "pageview",
"url": "https://example.com/", "screen_width": 3840
}),
ua.clone(),
)
.await;
res.assert_status_success();
let event = rx.recv().unwrap();
assert_eq!(event.screen_size.as_deref(), Some("ultrawide"));

// Wuithout screen_width
let res = client
.post_with_headers(
"/api/event",
json!({
"entity_id": "entity-1", "name": "pageview",
"url": "https://example.com/"
}),
ua.clone(),
)
.await;
res.assert_status_success();
let event = rx.recv().unwrap();
assert_eq!(event.screen_size, None);

Ok(())
}

#[tokio::test]
async fn test_screen_size_dimension_api() -> Result<()> {
use chrono::Utc;
use liwan::app::models::Event;

let app = common::app();
let (tx, _rx) = common::events();
let client = common::TestClient::new(app.clone(), tx);

app.seed_database(0)?;

let events_to_insert = vec![
Event {
entity_id: "entity-1".to_string(),
visitor_id: "visitor-1".to_string(),
event: "pageview".to_string(),
created_at: Utc::now(),
fqdn: Some("example.com".to_string()),
path: Some("/".to_string()),
referrer: None,
platform: None,
browser: None,
mobile: Some(false),
country: None,
city: None,
utm_source: None,
utm_medium: None,
utm_campaign: None,
utm_content: None,
utm_term: None,
screen_size: Some("mobile".to_string()),
},
Event {
entity_id: "entity-1".to_string(),
visitor_id: "visitor-1".to_string(),
event: "pageview".to_string(),
created_at: Utc::now(),
fqdn: Some("example.com".to_string()),
path: Some("/".to_string()),
referrer: None,
platform: None,
browser: None,
mobile: Some(true),
country: None,
city: None,
utm_source: None,
utm_medium: None,
utm_campaign: None,
utm_content: None,
utm_term: None,
screen_size: Some("mobile".to_string()),
},
Event {
entity_id: "entity-1".to_string(),
visitor_id: "visitor-2".to_string(),
event: "pageview".to_string(),
created_at: Utc::now(),
fqdn: Some("example.com".to_string()),
path: Some("/".to_string()),
referrer: None,
platform: None,
browser: None,
mobile: None,
country: None,
city: None,
utm_source: None,
utm_medium: None,
utm_campaign: None,
utm_content: None,
utm_term: None,
screen_size: Some("tablet".to_string()),
},
Event {
entity_id: "entity-1".to_string(),
visitor_id: "visitor-3".to_string(),
event: "pageview".to_string(),
created_at: Utc::now(),
fqdn: Some("example.com".to_string()),
path: Some("/".to_string()),
referrer: None,
platform: None,
browser: None,
mobile: None,
country: None,
city: None,
utm_source: None,
utm_medium: None,
utm_campaign: None,
utm_content: None,
utm_term: None,
screen_size: Some("desktop".to_string()),
},
Event {
entity_id: "entity-1".to_string(),
visitor_id: "visitor-4".to_string(),
event: "pageview".to_string(),
created_at: Utc::now(),
fqdn: Some("example.com".to_string()),
path: Some("/".to_string()),
referrer: None,
platform: None,
browser: None,
mobile: None,
country: None,
city: None,
utm_source: None,
utm_medium: None,
utm_campaign: None,
utm_content: None,
utm_term: None,
screen_size: Some("ultrawide".to_string()),
},
];
app.events.append(events_to_insert.into_iter())?;

let start = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
let end = Utc::now().to_rfc3339();

let res = client
.post(
"/api/dashboard/project/public-project/dimension",
json!({
"dimension": "screen_size",
"filters": [],
"metric": "views",
"range": { "start": start, "end": end }
}),
)
.await;
res.assert_status_success();

let body: serde_json::Value = res.json();
let rows = body["data"].as_array().expect("data should be an array");

let find = |bucket: &str| rows.iter().find(|r| r["dimensionValue"].as_str() == Some(bucket));

let mobile_row = find("mobile").expect("mobile bucket should be present");
assert_eq!(mobile_row["displayName"].as_str(), Some("Mobile"));
assert_eq!(mobile_row["value"].as_f64(), Some(2.0));

let tablet_row = find("tablet").expect("tablet bucket should be present");
assert_eq!(tablet_row["displayName"].as_str(), Some("Tablet"));
assert_eq!(tablet_row["value"].as_f64(), Some(1.0));

let desktop_row = find("desktop").expect("desktop bucket should be present");
assert_eq!(desktop_row["displayName"].as_str(), Some("Desktop"));
assert_eq!(desktop_row["value"].as_f64(), Some(1.0));

let ultrawide_row = find("ultrawide").expect("ultrawide bucket should be present");
assert_eq!(ultrawide_row["displayName"].as_str(), Some("Ultrawide"));
assert_eq!(ultrawide_row["value"].as_f64(), Some(1.0));

Ok(())
}
Loading