From a07c0ca3e443bdf312299339112fb87077f261b5 Mon Sep 17 00:00:00 2001 From: Alexander Bruy Date: Sun, 29 Mar 2026 16:18:55 +0100 Subject: [PATCH 1/5] add screen width to the tracking script --- tracker/script.min.js | 2 +- tracker/script.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tracker/script.min.js b/tracker/script.min.js index f577d3a..7a8015c 100644 --- a/tracker/script.min.js +++ b/tracker/script.min.js @@ -1 +1 @@ -let i=null,a=null,l=null,s=null;const u=typeof window>"u";typeof document<"u"&&(i=document.querySelector(`script[src^="${import.meta.url}"]`),a=i?.getAttribute("data-api")||i&&`${new URL(i.src).origin}/api/event`,l=i?.getAttribute("data-entity")||null,s=document.referrer);const d=t=>console.info(`[liwan]: ${t}`),c=t=>d(`Ignoring event: ${t}`),g=t=>{throw new Error(`Failed to send event: ${t}`)};async function p(t="pageview",e){const o=e?.endpoint||a;if(!o)return g("endpoint is required");if(localStorage?.getItem("disable-liwan"))return c("localStorage flag");if(/^localhost$|^127(?:\.\d+){0,2}\.\d+$|^\[::1?\]$/.test(location.hostname)||location.protocol==="file:")return c("localhost");const n=new URLSearchParams(location.search),r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:t,entity_id:e?.entity||l,referrer:e?.referrer||s,url:e?.url||location.origin+location.pathname,utm:{campaign:n.get("utm_campaign"),content:n.get("utm_content"),medium:n.get("utm_medium"),source:n.get("utm_source"),term:n.get("utm_term")}})});r.ok||(d(`Failed to send event: ${r.statusText}`),g(r.statusText))}const m=()=>{window.__liwan_loaded=!0;let t;const e=()=>{t!==location.pathname&&(t=location.pathname,p("pageview"))};window.navigation?window.navigation.addEventListener("navigate",()=>e()):(window.history.pushState=new Proxy(window.history.pushState,{apply:(o,n,r)=>{Reflect.apply(o,n,r),e()}}),window.addEventListener("popstate",e),document.addEventListener("astro:page-load",()=>e())),e()};!u&&!window.__liwan_loaded&&i&&m();export{p as event}; +let i=null,a=null,l=null,s=null;const d=typeof window>"u";typeof document<"u"&&(i=document.querySelector(`script[src^="${import.meta.url}"]`),a=i?.getAttribute("data-api")||i&&`${new URL(i.src).origin}/api/event`,l=i?.getAttribute("data-entity")||null,s=document.referrer);const c=e=>console.info(`[liwan]: ${e}`),g=e=>c(`Ignoring event: ${e}`),u=e=>{throw new Error(`Failed to send event: ${e}`)};async function p(e="pageview",t){const o=t?.endpoint||a;if(!o)return u("endpoint is required");if(localStorage?.getItem("disable-liwan"))return g("localStorage flag");if(/^localhost$|^127(?:\.\d+){0,2}\.\d+$|^\[::1?\]$/.test(location.hostname)||location.protocol==="file:")return g("localhost");const n=new URLSearchParams(location.search),r=await fetch(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:e,entity_id:t?.entity||l,referrer:t?.referrer||s,url:t?.url||location.origin+location.pathname,utm:{campaign:n.get("utm_campaign"),content:n.get("utm_content"),medium:n.get("utm_medium"),source:n.get("utm_source"),term:n.get("utm_term")},screen_width:d?void 0:window.screen?.width})});r.ok||(c(`Failed to send event: ${r.statusText}`),u(r.statusText))}const m=()=>{window.__liwan_loaded=!0;let e;const t=()=>{e!==location.pathname&&(e=location.pathname,p("pageview"))};window.navigation?window.navigation.addEventListener("navigate",()=>t()):(window.history.pushState=new Proxy(window.history.pushState,{apply:(o,n,r)=>{Reflect.apply(o,n,r),t()}}),window.addEventListener("popstate",t),document.addEventListener("astro:page-load",()=>t())),t()};!d&&!window.__liwan_loaded&&i&&m();export{p as event}; diff --git a/tracker/script.ts b/tracker/script.ts index 175080e..8787992 100644 --- a/tracker/script.ts +++ b/tracker/script.ts @@ -11,6 +11,7 @@ type Payload = { url: string; referrer?: string; utm?: { campaign?: string; content?: string; medium?: string; source?: string; term?: string }; + screen_width?: number; }; export type EventOptions = { @@ -107,6 +108,7 @@ export async function event(name: string = "pageview", options?: EventOptions): source: utm.get("utm_source"), term: utm.get("utm_term"), }, + screen_width: !noWindow ? window.screen?.width : undefined, }), }); From 2cd317e1b8ec60ce5a81afd006bb683747a9c81a Mon Sep 17 00:00:00 2001 From: Alexander Bruy Date: Sun, 29 Mar 2026 16:20:45 +0100 Subject: [PATCH 2/5] add screen size field to the events database --- src/migrations/events/V5__screen_size.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/migrations/events/V5__screen_size.sql diff --git a/src/migrations/events/V5__screen_size.sql b/src/migrations/events/V5__screen_size.sql new file mode 100644 index 0000000..c2123c9 --- /dev/null +++ b/src/migrations/events/V5__screen_size.sql @@ -0,0 +1 @@ +alter table events add column screen_size text; From 6be570f9e5986174d83cc1bcb1a96991bc5923d9 Mon Sep 17 00:00:00 2001 From: Alexander Bruy Date: Mon, 30 Mar 2026 15:04:28 +0100 Subject: [PATCH 3/5] add screen size handling to the backend --- src/app/core/reports.rs | 7 ++++-- src/app/models.rs | 2 ++ src/utils/seed.rs | 1 + src/web/routes/dashboard.rs | 5 ++++ src/web/routes/event.rs | 47 +++++++++++++++++++++++++++++++++++++ web/src/api/dashboard.ts | 2 +- 6 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/app/core/reports.rs b/src/app/core/reports.rs index db98b1d..2c1d248 100644 --- a/src/app/core/reports.rs +++ b/src/app/core/reports.rs @@ -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; @@ -66,6 +66,7 @@ pub enum Dimension { UtmCampaign, UtmContent, UtmTerm, + ScreenSize, } #[derive(Serialize, Deserialize, JsonSchema, Debug, Clone, Copy, Hash, Eq, PartialEq, PartialOrd, Ord)] @@ -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::>>()?; @@ -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})"), diff --git a/src/app/models.rs b/src/app/models.rs index 6d376fc..b481be0 100644 --- a/src/app/models.rs +++ b/src/app/models.rs @@ -23,6 +23,7 @@ pub struct Event { pub utm_campaign: Option, pub utm_content: Option, pub utm_term: Option, + pub screen_size: Option, } #[derive(Debug, Clone)] @@ -100,6 +101,7 @@ macro_rules! event_params { $event.utm_term, None::, None::, + $event.screen_size, ] }; } diff --git a/src/utils/seed.rs b/src/utils/seed.rs index 1a490d1..bb4148e 100644 --- a/src/utils/seed.rs +++ b/src/utils/seed.rs @@ -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, }) }) } diff --git a/src/web/routes/dashboard.rs b/src/web/routes/dashboard.rs index dfc75f1..23aacca 100644 --- a/src/web/routes/dashboard.rs +++ b/src/web/routes/dashboard.rs @@ -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::() + &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 }); } diff --git a/src/web/routes/event.rs b/src/web/routes/event.rs index 78a943a..a8bf379 100644 --- a/src/web/routes/event.rs +++ b/src/web/routes/event.rs @@ -31,6 +31,7 @@ struct EventRequest { url: String, referrer: Option, utm: Option, + screen_width: Option, } #[derive(serde::Deserialize, JsonSchema)] @@ -42,6 +43,15 @@ struct Utm { term: Option, } +fn screen_size_bucket(width: u32) -> &'static str { + match width { + 0..=767 => "mobile", + 768..=1023 => "tablet", + 1024..=2559 => "desktop", + _ => "ultrawide", + } +} + static EXISTING_ENTITIES: LazyLock> = LazyLock::new(|| quick_cache::sync::Cache::new(512)); @@ -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"); + } +} diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts index 22599e9..a174147 100644 --- a/web/src/api/dashboard.ts +++ b/web/src/api/dashboard.ts @@ -1 +1 @@ -export default {"openapi":"3.1.0","info":{"title":"Liwan API","version":""},"paths":{"/api/dashboard/users":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersResponse"}}}}}}},"/api/dashboard/user/{username}":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}},"delete":{}},"/api/dashboard/user/{username}/password":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePasswordRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/user":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/project/{project_id}":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}}}},"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}},"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}},"delete":{}},"/api/dashboard/projects":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}}}}}},"/api/dashboard/entities":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntitiesResponse"}}}}}}},"/api/dashboard/entity":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEntityRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityResponse"}}}},"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/entity/{entity_id}":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEntityRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}},"delete":{}},"/api/dashboard/auth/me":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeResponse"}}}}}}},"/api/dashboard/auth/setup":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/auth/login":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/auth/logout":{"post":{}},"/api/dashboard/config":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigResponse"}}}}}}},"/api/dashboard/project/{project_id}/earliest":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarliestResponse"}}}}}}},"/api/dashboard/project/{project_id}/graph":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphResponse"}}}},"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/project/{project_id}/stats":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsResponse"}}}},"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/project/{project_id}/dimension":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionResponse"}}}},"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}}},"components":{"schemas":{"ConfigResponse":{"type":"object","properties":{"disableFavicons":{"type":"boolean"}},"required":["disableFavicons"]},"CreateEntityRequest":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"type":"array","items":{"type":"string"}}},"required":["id","displayName","projects"]},"CreateProjectRequest":{"type":"object","properties":{"displayName":{"type":"string"},"entities":{"type":"array","items":{"type":"string"}},"public":{"type":"boolean"},"secret":{"type":["string","null"]}},"required":["displayName","public","entities"]},"CreateUserRequest":{"type":"object","properties":{"password":{"type":"string"},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","password","role"]},"DateRange":{"type":"object","properties":{"end":{"type":"string","format":"date-time"},"start":{"type":"string","format":"date-time"}},"required":["start","end"]},"Dimension":{"type":"string","enum":["url","url_entry","url_exit","fqdn","path","referrer","platform","browser","mobile","country","city","utm_source","utm_medium","utm_campaign","utm_content","utm_term"]},"DimensionFilter":{"type":"object","properties":{"dimension":{"description":"The dimension to filter by","allOf":[{"$ref":"#/components/schemas/Dimension"}]},"filterType":{"description":"The type of filter to apply\nNote that some filters may not be applicable to all dimensions","allOf":[{"$ref":"#/components/schemas/FilterType"}]},"inversed":{"description":"Whether to invert the filter (e.g. not equal, not contains)\nDefaults to false","type":["boolean","null"]},"strict":{"description":"Whether to filter by the strict value (case-sensitive, exact match)","type":["boolean","null"]},"value":{"description":"The value to filter by\nFor `FilterType::IsNull` this should be `None`","type":["string","null"]}},"required":["dimension","filterType"]},"DimensionRequest":{"type":"object","properties":{"dimension":{"$ref":"#/components/schemas/Dimension"},"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","metric","dimension"]},"DimensionResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DimensionTableRow"}}},"required":["data"]},"DimensionTableRow":{"type":"object","properties":{"dimensionValue":{"type":"string"},"displayName":{"type":["string","null"]},"icon":{"type":["string","null"]},"value":{"type":"number","format":"double"}},"required":["dimensionValue","value"]},"EarliestResponse":{"type":"object","properties":{"earliest":{"type":["string","null"],"format":"date-time"}}},"EntitiesResponse":{"type":"object","properties":{"entities":{"type":"array","items":{"$ref":"#/components/schemas/EntityResponse"}}},"required":["entities"]},"EntityProject":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","public"]},"EntityResponse":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"type":"array","items":{"$ref":"#/components/schemas/EntityProject"}}},"required":["id","displayName","projects"]},"EventRequest":{"type":"object","properties":{"entity_id":{"type":"string"},"name":{"type":"string"},"referrer":{"type":["string","null"]},"url":{"type":"string"},"utm":{"anyOf":[{"$ref":"#/components/schemas/Utm"},{"type":"null"}]}},"required":["entity_id","name","url"]},"FilterType":{"type":"string","enum":["is_null","equal","contains","starts_with","ends_with","is_true","is_false"]},"GraphRequest":{"type":"object","properties":{"dataPoints":{"type":"integer","format":"uint32","minimum":0},"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","dataPoints","metric"]},"GraphResponse":{"type":"object","properties":{"data":{"type":"array","items":{"type":"number","format":"double"}}},"required":["data"]},"LoginRequest":{"type":"object","properties":{"password":{"type":"string"},"username":{"type":"string"}},"required":["username","password"]},"MeResponse":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role"]},"Metric":{"type":"string","enum":["views","unique_visitors","bounce_rate","avg_time_on_site"]},"ProjectEntity":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"}},"required":["id","displayName"]},"ProjectResponse":{"type":"object","properties":{"displayName":{"type":"string"},"entities":{"type":"array","items":{"$ref":"#/components/schemas/ProjectEntity"}},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","entities","public"]},"ProjectsResponse":{"type":"object","properties":{"projects":{"type":"array","items":{"$ref":"#/components/schemas/ProjectResponse"}}},"required":["projects"]},"ReportStats":{"type":"object","properties":{"avgTimeOnSite":{"type":"number","format":"double"},"bounceRate":{"type":"number","format":"double"},"totalViews":{"type":"integer","format":"uint64","minimum":0},"uniqueVisitors":{"type":"integer","format":"uint64","minimum":0}},"required":["totalViews","uniqueVisitors","bounceRate","avgTimeOnSite"]},"SetupRequest":{"type":"object","properties":{"password":{"type":"string"},"token":{"type":"string"},"username":{"type":"string"}},"required":["token","username","password"]},"StatsRequest":{"type":"object","properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters"]},"StatsResponse":{"type":"object","properties":{"currentVisitors":{"type":"integer","format":"uint64","minimum":0},"stats":{"$ref":"#/components/schemas/ReportStats"},"statsPrev":{"$ref":"#/components/schemas/ReportStats"}},"required":["currentVisitors","stats","statsPrev"]},"UpdateEntityRequest":{"type":"object","properties":{"displayName":{"type":["string","null"]},"projects":{"type":["array","null"],"items":{"type":"string"}}}},"UpdatePasswordRequest":{"type":"object","properties":{"password":{"type":"string"}},"required":["password"]},"UpdateProjectInfo":{"type":"object","properties":{"displayName":{"type":"string"},"public":{"type":"boolean"},"secret":{"type":["string","null"]}},"required":["displayName","public"]},"UpdateProjectRequest":{"type":"object","properties":{"entities":{"type":["array","null"],"items":{"type":"string"}},"project":{"anyOf":[{"$ref":"#/components/schemas/UpdateProjectInfo"},{"type":"null"}]}}},"UpdateUserRequest":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"string"}},"role":{"$ref":"#/components/schemas/UserRole"}},"required":["role","projects"]},"UserResponse":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"string"}},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role","projects"]},"UserRole":{"type":"string","enum":["admin","user"]},"UsersResponse":{"type":"object","properties":{"users":{"type":"array","items":{"$ref":"#/components/schemas/UserResponse"}}},"required":["users"]},"Utm":{"type":"object","properties":{"campaign":{"type":["string","null"]},"content":{"type":["string","null"]},"medium":{"type":["string","null"]},"source":{"type":["string","null"]},"term":{"type":["string","null"]}}}}}} as const; +export default {"openapi":"3.1.0","info":{"title":"Liwan API","version":""},"paths":{"/api/dashboard/users":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersResponse"}}}}}}},"/api/dashboard/user/{username}":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}},"delete":{}},"/api/dashboard/user/{username}/password":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePasswordRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/user":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/project/{project_id}":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}}}},"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}},"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}},"delete":{}},"/api/dashboard/projects":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}}}}}},"/api/dashboard/entities":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntitiesResponse"}}}}}}},"/api/dashboard/entity":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEntityRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityResponse"}}}},"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/entity/{entity_id}":{"put":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEntityRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}},"delete":{}},"/api/dashboard/auth/me":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeResponse"}}}}}}},"/api/dashboard/auth/setup":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/auth/login":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/auth/logout":{"post":{}},"/api/dashboard/config":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfigResponse"}}}}}}},"/api/dashboard/project/{project_id}/earliest":{"get":{"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarliestResponse"}}}}}}},"/api/dashboard/project/{project_id}/graph":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphResponse"}}}},"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/project/{project_id}/stats":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsResponse"}}}},"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/dashboard/project/{project_id}/dimension":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionResponse"}}}},"400":{"description":"Failed to parse the request body as JSON","content":{"text/plain":{"schema":{"type":"string"}}}},"415":{"description":"Expected request with `Content-Type: application/json`","content":{"text/plain":{"schema":{"type":"string"}}}},"422":{"description":"Failed to deserialize the JSON body into the target type","content":{"text/plain":{"schema":{"type":"string"}}}}}}}},"components":{"schemas":{"ConfigResponse":{"type":"object","properties":{"disableFavicons":{"type":"boolean"}},"required":["disableFavicons"]},"CreateEntityRequest":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"type":"array","items":{"type":"string"}}},"required":["id","displayName","projects"]},"CreateProjectRequest":{"type":"object","properties":{"displayName":{"type":"string"},"entities":{"type":"array","items":{"type":"string"}},"public":{"type":"boolean"},"secret":{"type":["string","null"]}},"required":["displayName","public","entities"]},"CreateUserRequest":{"type":"object","properties":{"password":{"type":"string"},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","password","role"]},"DateRange":{"type":"object","properties":{"end":{"type":"string","format":"date-time"},"start":{"type":"string","format":"date-time"}},"required":["start","end"]},"Dimension":{"type":"string","enum":["url","url_entry","url_exit","fqdn","path","referrer","platform","browser","mobile","country","city","utm_source","utm_medium","utm_campaign","utm_content","utm_term","screen_size"]},"DimensionFilter":{"type":"object","properties":{"dimension":{"description":"The dimension to filter by","allOf":[{"$ref":"#/components/schemas/Dimension"}]},"filterType":{"description":"The type of filter to apply\nNote that some filters may not be applicable to all dimensions","allOf":[{"$ref":"#/components/schemas/FilterType"}]},"inversed":{"description":"Whether to invert the filter (e.g. not equal, not contains)\nDefaults to false","type":["boolean","null"]},"strict":{"description":"Whether to filter by the strict value (case-sensitive, exact match)","type":["boolean","null"]},"value":{"description":"The value to filter by\nFor `FilterType::IsNull` this should be `None`","type":["string","null"]}},"required":["dimension","filterType"]},"DimensionRequest":{"type":"object","properties":{"dimension":{"$ref":"#/components/schemas/Dimension"},"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","metric","dimension"]},"DimensionResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DimensionTableRow"}}},"required":["data"]},"DimensionTableRow":{"type":"object","properties":{"dimensionValue":{"type":"string"},"displayName":{"type":["string","null"]},"icon":{"type":["string","null"]},"value":{"type":"number","format":"double"}},"required":["dimensionValue","value"]},"EarliestResponse":{"type":"object","properties":{"earliest":{"type":["string","null"],"format":"date-time"}}},"EntitiesResponse":{"type":"object","properties":{"entities":{"type":"array","items":{"$ref":"#/components/schemas/EntityResponse"}}},"required":["entities"]},"EntityProject":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","public"]},"EntityResponse":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"type":"array","items":{"$ref":"#/components/schemas/EntityProject"}}},"required":["id","displayName","projects"]},"EventRequest":{"type":"object","properties":{"entity_id":{"type":"string"},"name":{"type":"string"},"referrer":{"type":["string","null"]},"screen_width":{"type":["integer","null"],"format":"uint32","minimum":0},"url":{"type":"string"},"utm":{"anyOf":[{"$ref":"#/components/schemas/Utm"},{"type":"null"}]}},"required":["entity_id","name","url"]},"FilterType":{"type":"string","enum":["is_null","equal","contains","starts_with","ends_with","is_true","is_false"]},"GraphRequest":{"type":"object","properties":{"dataPoints":{"type":"integer","format":"uint32","minimum":0},"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","dataPoints","metric"]},"GraphResponse":{"type":"object","properties":{"data":{"type":"array","items":{"type":"number","format":"double"}}},"required":["data"]},"LoginRequest":{"type":"object","properties":{"password":{"type":"string"},"username":{"type":"string"}},"required":["username","password"]},"MeResponse":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role"]},"Metric":{"type":"string","enum":["views","unique_visitors","bounce_rate","avg_time_on_site"]},"ProjectEntity":{"type":"object","properties":{"displayName":{"type":"string"},"id":{"type":"string"}},"required":["id","displayName"]},"ProjectResponse":{"type":"object","properties":{"displayName":{"type":"string"},"entities":{"type":"array","items":{"$ref":"#/components/schemas/ProjectEntity"}},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","entities","public"]},"ProjectsResponse":{"type":"object","properties":{"projects":{"type":"array","items":{"$ref":"#/components/schemas/ProjectResponse"}}},"required":["projects"]},"ReportStats":{"type":"object","properties":{"avgTimeOnSite":{"type":"number","format":"double"},"bounceRate":{"type":"number","format":"double"},"totalViews":{"type":"integer","format":"uint64","minimum":0},"uniqueVisitors":{"type":"integer","format":"uint64","minimum":0}},"required":["totalViews","uniqueVisitors","bounceRate","avgTimeOnSite"]},"SetupRequest":{"type":"object","properties":{"password":{"type":"string"},"token":{"type":"string"},"username":{"type":"string"}},"required":["token","username","password"]},"StatsRequest":{"type":"object","properties":{"filters":{"type":"array","items":{"$ref":"#/components/schemas/DimensionFilter"}},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters"]},"StatsResponse":{"type":"object","properties":{"currentVisitors":{"type":"integer","format":"uint64","minimum":0},"stats":{"$ref":"#/components/schemas/ReportStats"},"statsPrev":{"$ref":"#/components/schemas/ReportStats"}},"required":["currentVisitors","stats","statsPrev"]},"UpdateEntityRequest":{"type":"object","properties":{"displayName":{"type":["string","null"]},"projects":{"type":["array","null"],"items":{"type":"string"}}}},"UpdatePasswordRequest":{"type":"object","properties":{"password":{"type":"string"}},"required":["password"]},"UpdateProjectInfo":{"type":"object","properties":{"displayName":{"type":"string"},"public":{"type":"boolean"},"secret":{"type":["string","null"]}},"required":["displayName","public"]},"UpdateProjectRequest":{"type":"object","properties":{"entities":{"type":["array","null"],"items":{"type":"string"}},"project":{"anyOf":[{"$ref":"#/components/schemas/UpdateProjectInfo"},{"type":"null"}]}}},"UpdateUserRequest":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"string"}},"role":{"$ref":"#/components/schemas/UserRole"}},"required":["role","projects"]},"UserResponse":{"type":"object","properties":{"projects":{"type":"array","items":{"type":"string"}},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role","projects"]},"UserRole":{"type":"string","enum":["admin","user"]},"UsersResponse":{"type":"object","properties":{"users":{"type":"array","items":{"$ref":"#/components/schemas/UserResponse"}}},"required":["users"]},"Utm":{"type":"object","properties":{"campaign":{"type":["string","null"]},"content":{"type":["string","null"]},"medium":{"type":["string","null"]},"source":{"type":["string","null"]},"term":{"type":["string","null"]}}}}}} as const; From e03a8ac4afa6d4b266bd45edcffaeb8d6f97dee7 Mon Sep 17 00:00:00 2001 From: Alexander Bruy Date: Mon, 30 Mar 2026 16:13:40 +0100 Subject: [PATCH 4/5] add tests --- tests/dashboard.rs | 2 + tests/event.rs | 242 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) diff --git a/tests/dashboard.rs b/tests/dashboard.rs index 9a02a42..417301e 100644 --- a/tests/dashboard.rs +++ b/tests/dashboard.rs @@ -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() { diff --git a/tests/event.rs b/tests/event.rs index 950d18c..0eae951 100644 --- a/tests/event.rs +++ b/tests/event.rs @@ -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(()) +} From 35c6a2f18e07d6a5527156d7c1b71358a8e99a3e Mon Sep 17 00:00:00 2001 From: Alexander Bruy Date: Mon, 30 Mar 2026 16:20:00 +0100 Subject: [PATCH 5/5] add screen size to the frontend dashboard --- web/src/api/constants.ts | 1 + web/src/api/types.ts | 1 + web/src/components/dimensions/index.tsx | 8 +++++++- web/src/components/project.tsx | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/web/src/api/constants.ts b/web/src/api/constants.ts index 1c55782..8dfe238 100644 --- a/web/src/api/constants.ts +++ b/web/src/api/constants.ts @@ -24,6 +24,7 @@ export const dimensionNames: Record = { utm_medium: "UTM Medium", utm_source: "UTM Source", utm_term: "UTM Term", + screen_size: "Screen Size", }; export const filterNames: Record = { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 89b9324..f6a5d6e 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -25,6 +25,7 @@ export const dimensions = [ "utm_medium", "utm_source", "utm_term", + "screen_size", ] as const satisfies Dimension[]; export const filterTypes = [ diff --git a/web/src/components/dimensions/index.tsx b/web/src/components/dimensions/index.tsx index 785238b..45d4209 100644 --- a/web/src/components/dimensions/index.tsx +++ b/web/src/components/dimensions/index.tsx @@ -1,5 +1,5 @@ import { Tabs } from "@base-ui/react/tabs"; -import { LinkIcon, PinIcon, SquareArrowOutUpRightIcon } from "lucide-react"; +import { LinkIcon, MonitorIcon, PinIcon, SquareArrowOutUpRightIcon } from "lucide-react"; import styles from "./dimensions.module.css"; import { type Dimension, type DimensionTableRow, dimensionNames, metricNames, useDimension } from "../../api"; @@ -298,6 +298,12 @@ const dimensionLabels: Record{value.dimensionValue} ), + screen_size: (value, onSelect) => ( + <> + + {value.dimensionValue || "Unknown"} + + ), }; const isValidFqdn = (fqdn: string) => { diff --git a/web/src/components/project.tsx b/web/src/components/project.tsx index ace5ad4..ec37414 100644 --- a/web/src/components/project.tsx +++ b/web/src/components/project.tsx @@ -138,6 +138,7 @@ export const Project = () => { onSelectDimRow(v, "mobile")} /> + onSelectDimRow(v, "screen_size")} />