Skip to content

Commit c2ab4fc

Browse files
feat: add screen width dimension (#35)
Co-authored-by: Henry <mail@henrygressmann.de>
1 parent 258e118 commit c2ab4fc

File tree

15 files changed

+319
-5
lines changed

15 files changed

+319
-5
lines changed

src/app/core/reports.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use std::collections::BTreeMap;
22
use std::fmt::{Debug, Display};
33

44
use crate::app::DuckDBConn;
5-
use crate::utils::duckdb::{repeat_vars, ParamVec};
6-
use anyhow::{bail, Result};
5+
use crate::utils::duckdb::{ParamVec, repeat_vars};
6+
use anyhow::{Result, bail};
77
use chrono::{DateTime, Utc};
88
use duckdb::params_from_iter;
99
use schemars::JsonSchema;
@@ -66,6 +66,7 @@ pub enum Dimension {
6666
UtmCampaign,
6767
UtmContent,
6868
UtmTerm,
69+
ScreenSize,
6970
}
7071

7172
#[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord)]
@@ -191,6 +192,7 @@ fn filter_sql(filters: &[DimensionFilter]) -> Result<(String, ParamVec<'_>)> {
191192
Dimension::UtmCampaign => format!("utm_campaign {filter_value}"),
192193
Dimension::UtmContent => format!("utm_content {filter_value}"),
193194
Dimension::UtmTerm => format!("utm_term {filter_value}"),
195+
Dimension::ScreenSize => format!("screen_size {filter_value}"),
194196
})
195197
})
196198
.collect::<Result<Vec<String>>>()?;
@@ -481,6 +483,7 @@ pub fn dimension_report(
481483
Dimension::UtmCampaign => ("utm_campaign", "utm_campaign", None),
482484
Dimension::UtmContent => ("utm_content", "utm_content", None),
483485
Dimension::UtmTerm => ("utm_term", "utm_term", None),
486+
Dimension::ScreenSize => ("screen_size", "screen_size", None),
484487
};
485488
let filters_sql = match (filters_sql.is_empty(), dimension_scope_sql) {
486489
(true, Some(scope)) => format!("and ({scope})"),

src/app/models.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub struct Event {
2323
pub utm_campaign: Option<String>,
2424
pub utm_content: Option<String>,
2525
pub utm_term: Option<String>,
26+
pub screen_size: Option<String>,
2627
}
2728

2829
#[derive(Debug, Clone)]
@@ -100,6 +101,7 @@ macro_rules! event_params {
100101
$event.utm_term,
101102
None::<std::time::Duration>,
102103
None::<std::time::Duration>,
104+
$event.screen_size,
103105
]
104106
};
105107
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
alter table events add column screen_size text;

src/utils/seed.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ pub fn random_events(
125125
utm_medium: Some(random_el(UTM_MEDIUMS, 0.6).to_string()),
126126
utm_source: Some(random_el(UTM_SOURCES, 0.6).to_string()),
127127
utm_term: Some(random_el(UTM_TERMS, 0.6).to_string()),
128+
screen_size: None,
128129
})
129130
})
130131
}

src/web/routes/dashboard.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,11 @@ async fn project_detailed_handler(
227227
let city = city.filter(|city| !city.is_empty());
228228
data.push(DimensionTableRow { dimension_value: key, value, display_name: city, icon: country });
229229
}
230+
Dimension::ScreenSize => {
231+
let display_name =
232+
key.chars().next().map(|c| c.to_uppercase().collect::<String>() + &key[c.len_utf8()..]);
233+
data.push(DimensionTableRow { dimension_value: key, value, display_name, icon: None });
234+
}
230235
_ => {
231236
data.push(DimensionTableRow { dimension_value: key, value, display_name: None, icon: None });
232237
}

src/web/routes/event.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ struct EventRequest {
3131
url: String,
3232
referrer: Option<String>,
3333
utm: Option<Utm>,
34+
screen_width: Option<u32>,
3435
}
3536

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

46+
fn screen_size_bucket(width: u32) -> &'static str {
47+
match width {
48+
0..=767 => "mobile",
49+
768..=1023 => "tablet",
50+
1024..=2559 => "desktop",
51+
_ => "ultrawide",
52+
}
53+
}
54+
4555
static EXISTING_ENTITIES: LazyLock<quick_cache::sync::Cache<String, ()>> =
4656
LazyLock::new(|| quick_cache::sync::Cache::new(512));
4757

@@ -131,8 +141,45 @@ fn process_event(
131141
utm_medium: event.utm.as_ref().and_then(|u| u.medium.clone()),
132142
utm_source: event.utm.as_ref().and_then(|u| u.source.clone()),
133143
utm_term: event.utm.as_ref().and_then(|u| u.term.clone()),
144+
screen_size: event.screen_width.map(|w| screen_size_bucket(w).to_string()),
134145
};
135146

136147
events.send(event)?;
137148
Ok(())
138149
}
150+
151+
#[cfg(test)]
152+
mod tests {
153+
use super::screen_size_bucket;
154+
155+
#[test]
156+
fn test_screen_size_bucket_mobile() {
157+
assert_eq!(screen_size_bucket(0), "mobile");
158+
assert_eq!(screen_size_bucket(375), "mobile");
159+
assert_eq!(screen_size_bucket(430), "mobile");
160+
assert_eq!(screen_size_bucket(767), "mobile");
161+
}
162+
163+
#[test]
164+
fn test_screen_size_bucket_tablet() {
165+
assert_eq!(screen_size_bucket(768), "tablet");
166+
assert_eq!(screen_size_bucket(810), "tablet");
167+
assert_eq!(screen_size_bucket(1023), "tablet");
168+
}
169+
170+
#[test]
171+
fn test_screen_size_bucket_desktop() {
172+
assert_eq!(screen_size_bucket(1024), "desktop");
173+
assert_eq!(screen_size_bucket(1280), "desktop");
174+
assert_eq!(screen_size_bucket(1920), "desktop");
175+
assert_eq!(screen_size_bucket(2559), "desktop");
176+
}
177+
178+
#[test]
179+
fn test_screen_size_bucket_ultrawide() {
180+
assert_eq!(screen_size_bucket(2560), "ultrawide");
181+
assert_eq!(screen_size_bucket(3440), "ultrawide");
182+
assert_eq!(screen_size_bucket(3840), "ultrawide");
183+
assert_eq!(screen_size_bucket(7680), "ultrawide");
184+
}
185+
}

tests/dashboard.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ async fn test_dashboard() -> Result<()> {
3535
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}}),
3636
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}}),
3737
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}}),
38+
json!({"dimension":"screen_size","filters":[],"metric":"views","range":{"start": start_date ,"end": end_date}}),
39+
json!({"dimension":"url","filters":[{"dimension":"screen_size","filterType":"equal","value":"mobile"}],"metric":"views","range":{"start": start_date ,"end": end_date}}),
3840
];
3941

4042
for request in stats_requests.iter() {

tests/event.rs

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,245 @@ async fn test_event() -> Result<()> {
2626

2727
Ok(())
2828
}
29+
30+
#[tokio::test]
31+
async fn test_event_screen_size() -> Result<()> {
32+
let app = common::app();
33+
let (tx, rx) = common::events();
34+
let client = common::TestClient::new(app.clone(), tx);
35+
app.entities.create(&Entity { display_name: "Entity 1".to_string(), id: "entity-1".to_string() }, &[])?;
36+
37+
let ua = vec![("user-agent".to_string(), "Mozilla/5.0 (test)".to_string())];
38+
39+
// Mobile: 375px
40+
let res = client
41+
.post_with_headers(
42+
"/api/event",
43+
json!({
44+
"entity_id": "entity-1", "name": "pageview",
45+
"url": "https://example.com/", "screen_width": 375
46+
}),
47+
ua.clone(),
48+
)
49+
.await;
50+
res.assert_status_success();
51+
let event = rx.recv().unwrap();
52+
assert_eq!(event.screen_size.as_deref(), Some("mobile"));
53+
54+
// Tablet: 810px
55+
let res = client
56+
.post_with_headers(
57+
"/api/event",
58+
json!({
59+
"entity_id": "entity-1", "name": "pageview",
60+
"url": "https://example.com/", "screen_width": 810
61+
}),
62+
ua.clone(),
63+
)
64+
.await;
65+
res.assert_status_success();
66+
let event = rx.recv().unwrap();
67+
assert_eq!(event.screen_size.as_deref(), Some("tablet"));
68+
69+
// Desktop: 1920px
70+
let res = client
71+
.post_with_headers(
72+
"/api/event",
73+
json!({
74+
"entity_id": "entity-1", "name": "pageview",
75+
"url": "https://example.com/", "screen_width": 1920
76+
}),
77+
ua.clone(),
78+
)
79+
.await;
80+
res.assert_status_success();
81+
let event = rx.recv().unwrap();
82+
assert_eq!(event.screen_size.as_deref(), Some("desktop"));
83+
84+
// Ultrawide: 3840px
85+
let res = client
86+
.post_with_headers(
87+
"/api/event",
88+
json!({
89+
"entity_id": "entity-1", "name": "pageview",
90+
"url": "https://example.com/", "screen_width": 3840
91+
}),
92+
ua.clone(),
93+
)
94+
.await;
95+
res.assert_status_success();
96+
let event = rx.recv().unwrap();
97+
assert_eq!(event.screen_size.as_deref(), Some("ultrawide"));
98+
99+
// Wuithout screen_width
100+
let res = client
101+
.post_with_headers(
102+
"/api/event",
103+
json!({
104+
"entity_id": "entity-1", "name": "pageview",
105+
"url": "https://example.com/"
106+
}),
107+
ua.clone(),
108+
)
109+
.await;
110+
res.assert_status_success();
111+
let event = rx.recv().unwrap();
112+
assert_eq!(event.screen_size, None);
113+
114+
Ok(())
115+
}
116+
117+
#[tokio::test]
118+
async fn test_screen_size_dimension_api() -> Result<()> {
119+
use chrono::Utc;
120+
use liwan::app::models::Event;
121+
122+
let app = common::app();
123+
let (tx, _rx) = common::events();
124+
let client = common::TestClient::new(app.clone(), tx);
125+
126+
app.seed_database(0)?;
127+
128+
let events_to_insert = vec![
129+
Event {
130+
entity_id: "entity-1".to_string(),
131+
visitor_id: "visitor-1".to_string(),
132+
event: "pageview".to_string(),
133+
created_at: Utc::now(),
134+
fqdn: Some("example.com".to_string()),
135+
path: Some("/".to_string()),
136+
referrer: None,
137+
platform: None,
138+
browser: None,
139+
mobile: Some(false),
140+
country: None,
141+
city: None,
142+
utm_source: None,
143+
utm_medium: None,
144+
utm_campaign: None,
145+
utm_content: None,
146+
utm_term: None,
147+
screen_size: Some("mobile".to_string()),
148+
},
149+
Event {
150+
entity_id: "entity-1".to_string(),
151+
visitor_id: "visitor-2".to_string(),
152+
event: "pageview".to_string(),
153+
created_at: Utc::now(),
154+
fqdn: Some("example.com".to_string()),
155+
path: Some("/".to_string()),
156+
referrer: None,
157+
platform: None,
158+
browser: None,
159+
mobile: Some(true),
160+
country: None,
161+
city: None,
162+
utm_source: None,
163+
utm_medium: None,
164+
utm_campaign: None,
165+
utm_content: None,
166+
utm_term: None,
167+
screen_size: Some("mobile".to_string()),
168+
},
169+
Event {
170+
entity_id: "entity-1".to_string(),
171+
visitor_id: "visitor-2".to_string(),
172+
event: "pageview".to_string(),
173+
created_at: Utc::now(),
174+
fqdn: Some("example.com".to_string()),
175+
path: Some("/".to_string()),
176+
referrer: None,
177+
platform: None,
178+
browser: None,
179+
mobile: None,
180+
country: None,
181+
city: None,
182+
utm_source: None,
183+
utm_medium: None,
184+
utm_campaign: None,
185+
utm_content: None,
186+
utm_term: None,
187+
screen_size: Some("tablet".to_string()),
188+
},
189+
Event {
190+
entity_id: "entity-1".to_string(),
191+
visitor_id: "visitor-3".to_string(),
192+
event: "pageview".to_string(),
193+
created_at: Utc::now(),
194+
fqdn: Some("example.com".to_string()),
195+
path: Some("/".to_string()),
196+
referrer: None,
197+
platform: None,
198+
browser: None,
199+
mobile: None,
200+
country: None,
201+
city: None,
202+
utm_source: None,
203+
utm_medium: None,
204+
utm_campaign: None,
205+
utm_content: None,
206+
utm_term: None,
207+
screen_size: Some("desktop".to_string()),
208+
},
209+
Event {
210+
entity_id: "entity-1".to_string(),
211+
visitor_id: "visitor-4".to_string(),
212+
event: "pageview".to_string(),
213+
created_at: Utc::now(),
214+
fqdn: Some("example.com".to_string()),
215+
path: Some("/".to_string()),
216+
referrer: None,
217+
platform: None,
218+
browser: None,
219+
mobile: None,
220+
country: None,
221+
city: None,
222+
utm_source: None,
223+
utm_medium: None,
224+
utm_campaign: None,
225+
utm_content: None,
226+
utm_term: None,
227+
screen_size: Some("ultrawide".to_string()),
228+
},
229+
];
230+
app.events.append(events_to_insert.into_iter())?;
231+
232+
let start = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
233+
let end = Utc::now().to_rfc3339();
234+
235+
let res = client
236+
.post(
237+
"/api/dashboard/project/public-project/dimension",
238+
json!({
239+
"dimension": "screen_size",
240+
"filters": [],
241+
"metric": "views",
242+
"range": { "start": start, "end": end }
243+
}),
244+
)
245+
.await;
246+
res.assert_status_success();
247+
248+
let body: serde_json::Value = res.json();
249+
let rows = body["data"].as_array().expect("data should be an array");
250+
251+
let find = |bucket: &str| rows.iter().find(|r| r["dimensionValue"].as_str() == Some(bucket));
252+
253+
let mobile_row = find("mobile").expect("mobile bucket should be present");
254+
assert_eq!(mobile_row["displayName"].as_str(), Some("Mobile"));
255+
assert_eq!(mobile_row["value"].as_f64(), Some(2.0));
256+
257+
let tablet_row = find("tablet").expect("tablet bucket should be present");
258+
assert_eq!(tablet_row["displayName"].as_str(), Some("Tablet"));
259+
assert_eq!(tablet_row["value"].as_f64(), Some(1.0));
260+
261+
let desktop_row = find("desktop").expect("desktop bucket should be present");
262+
assert_eq!(desktop_row["displayName"].as_str(), Some("Desktop"));
263+
assert_eq!(desktop_row["value"].as_f64(), Some(1.0));
264+
265+
let ultrawide_row = find("ultrawide").expect("ultrawide bucket should be present");
266+
assert_eq!(ultrawide_row["displayName"].as_str(), Some("Ultrawide"));
267+
assert_eq!(ultrawide_row["value"].as_f64(), Some(1.0));
268+
269+
Ok(())
270+
}

0 commit comments

Comments
 (0)