diff --git a/src/app/core/reports.rs b/src/app/core/reports.rs index 20cf526..d60553c 100644 --- a/src/app/core/reports.rs +++ b/src/app/core/reports.rs @@ -211,12 +211,31 @@ fn metric_sql(metric: Metric) -> String { Metric::AvgTimeOnSite => { // avg time_until_next_event where time_until_next_event <= 1800 and time_until_next_event is not null "--sql - avg(sd.time_until_next_event) filter (where sd.time_until_next_event is not null and sd.time_until_next_event <= 1800)" + coalesce(avg(sd.time_until_next_event) filter (where sd.time_until_next_event is not null and sd.time_until_next_event <= 1800), 0)" } } .to_owned() } +pub fn earliest_timestamp(conn: &DuckDBConn, entities: &[String]) -> Result> { + if entities.is_empty() { + return Ok(None); + } + + let vars = repeat_vars(entities.len()); + let query = format!( + "--sql + select min(created_at) from events + where entity_id in ({vars}); + " + ); + + let mut stmt = conn.prepare_cached(&query)?; + let rows = stmt.query_map(params_from_iter(entities), |row| row.get(0))?; + let earliest_timestamp = rows.collect::>, duckdb::Error>>()?; + Ok(earliest_timestamp[0]) +} + pub fn online_users(conn: &DuckDBConn, entities: &[String]) -> Result { if entities.is_empty() { return Ok(0); diff --git a/src/web/routes/dashboard.rs b/src/web/routes/dashboard.rs index abab188..8c30cf1 100644 --- a/src/web/routes/dashboard.rs +++ b/src/web/routes/dashboard.rs @@ -66,6 +66,25 @@ pub struct DashboardAPI; #[OpenApi] impl DashboardAPI { + #[oai(path = "/project/:project_id/earliest", method = "get")] + async fn project_earliest_handler( + &self, + Path(project_id): Path, + Data(app): Data<&Liwan>, + user: Option, + ) -> ApiResult>> { + let project = app.projects.get(&project_id).http_status(StatusCode::NOT_FOUND)?; + + if !can_access_project(&project, user.as_ref()) { + http_bail!(StatusCode::NOT_FOUND, "Project not found") + } + + let conn = app.events_conn().http_status(StatusCode::INTERNAL_SERVER_ERROR)?; + let entities = app.projects.entity_ids(&project.id).http_status(StatusCode::INTERNAL_SERVER_ERROR)?; + let earliest = reports::earliest_timestamp(&conn, &entities).http_status(StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(earliest)) + } + #[oai(path = "/project/:project_id/graph", method = "post")] async fn project_graph_handler( &self, diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts index 8c05864..73190e1 100644 --- a/web/src/api/dashboard.ts +++ b/web/src/api/dashboard.ts @@ -1 +1 @@ -export default {"components":{"schemas":{"CreateEntityRequest":{"properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"items":{"type":"string"},"type":"array"}},"required":["id","displayName","projects"],"type":"object"},"CreateProjectRequest":{"properties":{"displayName":{"type":"string"},"entities":{"items":{"type":"string"},"type":"array"},"public":{"type":"boolean"},"secret":{"type":"string"}},"required":["displayName","public","entities"],"type":"object"},"CreateUserRequest":{"properties":{"password":{"type":"string"},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","password","role"],"type":"object"},"DateRange":{"properties":{"end":{"format":"date-time","type":"string"},"start":{"format":"date-time","type":"string"}},"required":["start","end"],"type":"object"},"Dimension":{"enum":["url","fqdn","path","referrer","platform","browser","mobile","country","city","utm_source","utm_medium","utm_campaign","utm_content","utm_term"],"type":"string"},"DimensionFilter":{"properties":{"dimension":{"allOf":[{"$ref":"#/components/schemas/Dimension"},{"description":"The dimension to filter by"}]},"filterType":{"allOf":[{"$ref":"#/components/schemas/FilterType"},{"description":"The type of filter to apply\nNote that some filters may not be applicable to all dimensions"}]},"inversed":{"description":"Whether to invert the filter (e.g. not equal, not contains)\nDefaults to false","type":"boolean"},"strict":{"description":"Whether to filter by the strict value (case-sensitive, exact match)","type":"boolean"},"value":{"description":"The value to filter by\nFor `FilterType::IsNull` this should be `None`","type":"string"}},"required":["dimension","filterType"],"type":"object"},"DimensionRequest":{"properties":{"dimension":{"$ref":"#/components/schemas/Dimension"},"filters":{"items":{"$ref":"#/components/schemas/DimensionFilter"},"type":"array"},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","metric","dimension"],"type":"object"},"DimensionResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/DimensionTableRow"},"type":"array"}},"required":["data"],"type":"object"},"DimensionTableRow":{"properties":{"dimensionValue":{"type":"string"},"displayName":{"type":"string"},"icon":{"type":"string"},"value":{"format":"double","type":"number"}},"required":["dimensionValue","value"],"type":"object"},"EntitiesResponse":{"properties":{"entities":{"items":{"$ref":"#/components/schemas/EntityResponse"},"type":"array"}},"required":["entities"],"type":"object"},"EntityProject":{"properties":{"displayName":{"type":"string"},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","public"],"type":"object"},"EntityResponse":{"properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"items":{"$ref":"#/components/schemas/EntityProject"},"type":"array"}},"required":["id","displayName","projects"],"type":"object"},"FilterType":{"enum":["is_null","equal","contains","starts_with","ends_with","is_true","is_false"],"type":"string"},"GraphRequest":{"properties":{"dataPoints":{"format":"uint32","type":"integer"},"filters":{"items":{"$ref":"#/components/schemas/DimensionFilter"},"type":"array"},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","dataPoints","metric"],"type":"object"},"GraphResponse":{"properties":{"data":{"items":{"format":"double","type":"number"},"type":"array"}},"required":["data"],"type":"object"},"LoginRequest":{"properties":{"password":{"type":"string"},"username":{"type":"string"}},"required":["username","password"],"type":"object"},"MeResponse":{"properties":{"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role"],"type":"object"},"Metric":{"enum":["views","unique_visitors","bounce_rate","avg_time_on_site"],"type":"string"},"ProjectEntity":{"properties":{"displayName":{"type":"string"},"id":{"type":"string"}},"required":["id","displayName"],"type":"object"},"ProjectResponse":{"properties":{"displayName":{"type":"string"},"entities":{"items":{"$ref":"#/components/schemas/ProjectEntity"},"type":"array"},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","entities","public"],"type":"object"},"ProjectsResponse":{"properties":{"projects":{"items":{"$ref":"#/components/schemas/ProjectResponse"},"type":"array"}},"required":["projects"],"type":"object"},"ReportStats":{"properties":{"avgTimeOnSite":{"format":"double","type":"number"},"bounceRate":{"format":"double","type":"number"},"totalViews":{"format":"uint64","type":"integer"},"uniqueVisitors":{"format":"uint64","type":"integer"}},"required":["totalViews","uniqueVisitors","bounceRate","avgTimeOnSite"],"type":"object"},"SetupRequest":{"properties":{"password":{"type":"string"},"token":{"type":"string"},"username":{"type":"string"}},"required":["token","username","password"],"type":"object"},"StatsRequest":{"properties":{"filters":{"items":{"$ref":"#/components/schemas/DimensionFilter"},"type":"array"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters"],"type":"object"},"StatsResponse":{"properties":{"currentVisitors":{"format":"uint64","type":"integer"},"stats":{"$ref":"#/components/schemas/ReportStats"},"statsPrev":{"$ref":"#/components/schemas/ReportStats"}},"required":["currentVisitors","stats","statsPrev"],"type":"object"},"StatusResponse":{"properties":{"message":{"type":"string"},"status":{"type":"string"}},"required":["status"],"type":"object"},"UpdateEntityRequest":{"properties":{"displayName":{"type":"string"},"projects":{"items":{"type":"string"},"type":"array"}},"type":"object"},"UpdatePasswordRequest":{"properties":{"password":{"type":"string"}},"required":["password"],"type":"object"},"UpdateProjectInfo":{"properties":{"displayName":{"type":"string"},"public":{"type":"boolean"},"secret":{"type":"string"}},"required":["displayName","public"],"type":"object"},"UpdateProjectRequest":{"properties":{"entities":{"items":{"type":"string"},"type":"array"},"project":{"$ref":"#/components/schemas/UpdateProjectInfo"}},"type":"object"},"UpdateUserRequest":{"properties":{"projects":{"items":{"type":"string"},"type":"array"},"role":{"$ref":"#/components/schemas/UserRole"}},"required":["role","projects"],"type":"object"},"UserResponse":{"properties":{"projects":{"items":{"type":"string"},"type":"array"},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role","projects"],"type":"object"},"UserRole":{"enum":["admin","user"],"type":"string"},"UsersResponse":{"properties":{"users":{"items":{"$ref":"#/components/schemas/UserResponse"},"type":"array"}},"required":["users"],"type":"object"}}},"info":{"title":"liwan dashboard api","version":"1.0"},"openapi":"3.0.0","paths":{"/api/dashboard/auth/login":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/auth/logout":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/auth/me":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeResponse"}}},"description":""}}}},"/api/dashboard/auth/setup":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/entities":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntitiesResponse"}}},"description":""}}}},"/api/dashboard/entity":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEntityRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityResponse"}}},"description":""}}}},"/api/dashboard/entity/{entity_id}":{"delete":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"entity_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}},"put":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"entity_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEntityRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/project/{project_id}":{"delete":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}},"get":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":""}}},"post":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}},"put":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/project/{project_id}/dimension":{"post":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionResponse"}}},"description":""}}}},"/api/dashboard/project/{project_id}/graph":{"post":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphResponse"}}},"description":""}}}},"/api/dashboard/project/{project_id}/stats":{"post":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsResponse"}}},"description":""}}}},"/api/dashboard/projects":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}},"description":""}}}},"/api/dashboard/user":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/user/{username}":{"delete":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"username","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}},"put":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"username","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/user/{username}/password":{"put":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"username","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePasswordRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/users":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersResponse"}}},"description":""}}}}},"tags":[]} as const; +export default {"components":{"schemas":{"CreateEntityRequest":{"properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"items":{"type":"string"},"type":"array"}},"required":["id","displayName","projects"],"type":"object"},"CreateProjectRequest":{"properties":{"displayName":{"type":"string"},"entities":{"items":{"type":"string"},"type":"array"},"public":{"type":"boolean"},"secret":{"type":"string"}},"required":["displayName","public","entities"],"type":"object"},"CreateUserRequest":{"properties":{"password":{"type":"string"},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","password","role"],"type":"object"},"DateRange":{"properties":{"end":{"format":"date-time","type":"string"},"start":{"format":"date-time","type":"string"}},"required":["start","end"],"type":"object"},"Dimension":{"enum":["url","fqdn","path","referrer","platform","browser","mobile","country","city","utm_source","utm_medium","utm_campaign","utm_content","utm_term"],"type":"string"},"DimensionFilter":{"properties":{"dimension":{"allOf":[{"$ref":"#/components/schemas/Dimension"},{"description":"The dimension to filter by"}]},"filterType":{"allOf":[{"$ref":"#/components/schemas/FilterType"},{"description":"The type of filter to apply\nNote that some filters may not be applicable to all dimensions"}]},"inversed":{"description":"Whether to invert the filter (e.g. not equal, not contains)\nDefaults to false","type":"boolean"},"strict":{"description":"Whether to filter by the strict value (case-sensitive, exact match)","type":"boolean"},"value":{"description":"The value to filter by\nFor `FilterType::IsNull` this should be `None`","type":"string"}},"required":["dimension","filterType"],"type":"object"},"DimensionRequest":{"properties":{"dimension":{"$ref":"#/components/schemas/Dimension"},"filters":{"items":{"$ref":"#/components/schemas/DimensionFilter"},"type":"array"},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","metric","dimension"],"type":"object"},"DimensionResponse":{"properties":{"data":{"items":{"$ref":"#/components/schemas/DimensionTableRow"},"type":"array"}},"required":["data"],"type":"object"},"DimensionTableRow":{"properties":{"dimensionValue":{"type":"string"},"displayName":{"type":"string"},"icon":{"type":"string"},"value":{"format":"double","type":"number"}},"required":["dimensionValue","value"],"type":"object"},"EntitiesResponse":{"properties":{"entities":{"items":{"$ref":"#/components/schemas/EntityResponse"},"type":"array"}},"required":["entities"],"type":"object"},"EntityProject":{"properties":{"displayName":{"type":"string"},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","public"],"type":"object"},"EntityResponse":{"properties":{"displayName":{"type":"string"},"id":{"type":"string"},"projects":{"items":{"$ref":"#/components/schemas/EntityProject"},"type":"array"}},"required":["id","displayName","projects"],"type":"object"},"FilterType":{"enum":["is_null","equal","contains","starts_with","ends_with","is_true","is_false"],"type":"string"},"GraphRequest":{"properties":{"dataPoints":{"format":"uint32","type":"integer"},"filters":{"items":{"$ref":"#/components/schemas/DimensionFilter"},"type":"array"},"metric":{"$ref":"#/components/schemas/Metric"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters","dataPoints","metric"],"type":"object"},"GraphResponse":{"properties":{"data":{"items":{"format":"double","type":"number"},"type":"array"}},"required":["data"],"type":"object"},"LoginRequest":{"properties":{"password":{"type":"string"},"username":{"type":"string"}},"required":["username","password"],"type":"object"},"MeResponse":{"properties":{"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role"],"type":"object"},"Metric":{"enum":["views","unique_visitors","bounce_rate","avg_time_on_site"],"type":"string"},"ProjectEntity":{"properties":{"displayName":{"type":"string"},"id":{"type":"string"}},"required":["id","displayName"],"type":"object"},"ProjectResponse":{"properties":{"displayName":{"type":"string"},"entities":{"items":{"$ref":"#/components/schemas/ProjectEntity"},"type":"array"},"id":{"type":"string"},"public":{"type":"boolean"}},"required":["id","displayName","entities","public"],"type":"object"},"ProjectsResponse":{"properties":{"projects":{"items":{"$ref":"#/components/schemas/ProjectResponse"},"type":"array"}},"required":["projects"],"type":"object"},"ReportStats":{"properties":{"avgTimeOnSite":{"format":"double","type":"number"},"bounceRate":{"format":"double","type":"number"},"totalViews":{"format":"uint64","type":"integer"},"uniqueVisitors":{"format":"uint64","type":"integer"}},"required":["totalViews","uniqueVisitors","bounceRate","avgTimeOnSite"],"type":"object"},"SetupRequest":{"properties":{"password":{"type":"string"},"token":{"type":"string"},"username":{"type":"string"}},"required":["token","username","password"],"type":"object"},"StatsRequest":{"properties":{"filters":{"items":{"$ref":"#/components/schemas/DimensionFilter"},"type":"array"},"range":{"$ref":"#/components/schemas/DateRange"}},"required":["range","filters"],"type":"object"},"StatsResponse":{"properties":{"currentVisitors":{"format":"uint64","type":"integer"},"stats":{"$ref":"#/components/schemas/ReportStats"},"statsPrev":{"$ref":"#/components/schemas/ReportStats"}},"required":["currentVisitors","stats","statsPrev"],"type":"object"},"StatusResponse":{"properties":{"message":{"type":"string"},"status":{"type":"string"}},"required":["status"],"type":"object"},"UpdateEntityRequest":{"properties":{"displayName":{"type":"string"},"projects":{"items":{"type":"string"},"type":"array"}},"type":"object"},"UpdatePasswordRequest":{"properties":{"password":{"type":"string"}},"required":["password"],"type":"object"},"UpdateProjectInfo":{"properties":{"displayName":{"type":"string"},"public":{"type":"boolean"},"secret":{"type":"string"}},"required":["displayName","public"],"type":"object"},"UpdateProjectRequest":{"properties":{"entities":{"items":{"type":"string"},"type":"array"},"project":{"$ref":"#/components/schemas/UpdateProjectInfo"}},"type":"object"},"UpdateUserRequest":{"properties":{"projects":{"items":{"type":"string"},"type":"array"},"role":{"$ref":"#/components/schemas/UserRole"}},"required":["role","projects"],"type":"object"},"UserResponse":{"properties":{"projects":{"items":{"type":"string"},"type":"array"},"role":{"$ref":"#/components/schemas/UserRole"},"username":{"type":"string"}},"required":["username","role","projects"],"type":"object"},"UserRole":{"enum":["admin","user"],"type":"string"},"UsersResponse":{"properties":{"users":{"items":{"$ref":"#/components/schemas/UserResponse"},"type":"array"}},"required":["users"],"type":"object"}}},"info":{"title":"liwan dashboard api","version":"1.0"},"openapi":"3.0.0","paths":{"/api/dashboard/auth/login":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/auth/logout":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/auth/me":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeResponse"}}},"description":""}}}},"/api/dashboard/auth/setup":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/entities":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntitiesResponse"}}},"description":""}}}},"/api/dashboard/entity":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEntityRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityResponse"}}},"description":""}}}},"/api/dashboard/entity/{entity_id}":{"delete":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"entity_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}},"put":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"entity_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEntityRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/project/{project_id}":{"delete":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}},"get":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":""}}},"post":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}},"put":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/project/{project_id}/dimension":{"post":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DimensionResponse"}}},"description":""}}}},"/api/dashboard/project/{project_id}/earliest":{"get":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"format":"date-time","type":"string"}}},"description":""}}}},"/api/dashboard/project/{project_id}/graph":{"post":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphResponse"}}},"description":""}}}},"/api/dashboard/project/{project_id}/stats":{"post":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsResponse"}}},"description":""}}}},"/api/dashboard/projects":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectsResponse"}}},"description":""}}}},"/api/dashboard/user":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/user/{username}":{"delete":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"username","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}},"put":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"username","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/user/{username}/password":{"put":{"parameters":[{"deprecated":false,"explode":true,"in":"path","name":"username","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePasswordRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}},"description":""}}}},"/api/dashboard/users":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsersResponse"}}},"description":""}}}}},"tags":[]} as const; diff --git a/web/src/api/ranges.ts b/web/src/api/ranges.ts index 48170c9..c835387 100644 --- a/web/src/api/ranges.ts +++ b/web/src/api/ranges.ts @@ -7,6 +7,7 @@ import { addYears, differenceInDays, differenceInHours, + differenceInMonths, differenceInSeconds, differenceInYears, endOfDay, @@ -37,11 +38,11 @@ type DateRangeValue = { start: Date; end: Date }; export class DateRange { #value: RangeName | { start: Date; end: Date }; - label: string; + variant?: string; constructor(value: RangeName | { start: Date; end: Date }) { this.#value = value; - this.label = ""; + if (typeof value === "string") this.variant = value; } get value(): DateRangeValue { @@ -52,10 +53,11 @@ export class DateRange { } isCustom(): boolean { - return typeof this.#value !== "string"; + return typeof this.#value !== "string" && !this.variant; } format(): string { + if (this.variant === "allTime") return "All Time"; if (typeof this.#value === "string") return wellKnownRanges[this.#value]; return formatDateRange(this.#value.start, this.#value.end); } @@ -66,15 +68,19 @@ export class DateRange { serialize(): string { if (typeof this.#value === "string") return this.#value; - return `${Number(this.#value.start)}:${Number(this.#value.end)}`; + return `${Number(this.#value.start)}:${Number(this.#value.end)}:${this.variant}`; } static deserialize(range: string): DateRange { if (!range.includes(":")) { return new DateRange(range as RangeName); } - const [start, end] = range.split(":").map((v) => new Date(Number(v))); - return new DateRange({ start, end }); + const [start, end, variant] = range.split(":"); + const dr = new DateRange({ start: new Date(Number(start)), end: new Date(Number(end)) }); + if (variant) { + dr.variant = variant; + } + return dr; } endsToday(): boolean { @@ -118,6 +124,7 @@ export class DateRange { } previous() { + if (this.variant === "allTime") return this; if (this.#value === "today") return new DateRange("yesterday"); if ( @@ -150,6 +157,18 @@ export class DateRange { return new DateRange({ start, end }); } + if (differenceInMonths(this.value.end, this.value.start) === 12) { + // if (isSameDay(this.value.start, startOfMonth(this.value.start))) { + // const start = startOfMonth(subYears(this.value.start, 1)); + // const end = endOfMonth(subYears(this.value.end, 1)); + // return new DateRange({ start, end }); + // } + + const start = subYears(this.value.start, 1); + const end = subYears(this.value.end, 1); + return new DateRange({ start, end }); + } + if (differenceInHours(this.value.end, this.value.start) < 23) { const start = subSeconds(this.value.start, differenceInSeconds(this.value.end, this.value.start)); const end = subSeconds(this.value.end, differenceInSeconds(this.value.end, this.value.start)); @@ -198,6 +217,18 @@ export class DateRange { return new DateRange({ start, end }); } + if (differenceInMonths(this.value.end, this.value.start) === 12) { + // if (isSameDay(this.value.start, startOfMonth(this.value.start))) { + // const start = startOfMonth(addYears(this.value.start, 1)); + // const end = endOfMonth(addYears(this.value.end, 1)); + // return new DateRange({ start, end }); + // } + + const start = addYears(this.value.start, 1); + const end = addYears(this.value.end, 1); + return new DateRange({ start, end }); + } + if (differenceInHours(this.value.end, this.value.start) < 23) { const start = addSeconds(this.value.start, differenceInSeconds(this.value.end, this.value.start)); const end = addSeconds(this.value.end, differenceInSeconds(this.value.end, this.value.start)); @@ -247,8 +278,8 @@ export const ranges: Record { range: { start: Date; end: Date } last7Days: () => ({ range: lastXDays(7) }), last30Days: () => ({ range: lastXDays(30) }), last12Months: () => { + const start = startOfMonth(subYears(new Date(), 1)); const end = endOfMonth(new Date()); - const start = subMonths(end, 11); return { range: { start, end } }; }, weekToDate: () => { diff --git a/web/src/components/project.tsx b/web/src/components/project.tsx index 7fc83b5..3a3468b 100644 --- a/web/src/components/project.tsx +++ b/web/src/components/project.tsx @@ -93,7 +93,7 @@ export const Project = () => {
- setRangeString(range.serialize())} range={range} /> + setRangeString(range.serialize())} range={range} projectId={project.id} />
diff --git a/web/src/components/project/range.module.css b/web/src/components/project/range.module.css index 2947abe..2e3fc5d 100644 --- a/web/src/components/project/range.module.css +++ b/web/src/components/project/range.module.css @@ -15,6 +15,18 @@ color: var(--pico-h1-background-color); } } + + ul { + li { + padding: 0; + + button { + box-sizing: border-box; + width: 100%; + padding: 0.4rem var(--pico-form-element-spacing-horizontal); + } + } + } } details.selectRange { diff --git a/web/src/components/project/range.tsx b/web/src/components/project/range.tsx index e993702..308c425 100644 --- a/web/src/components/project/range.tsx +++ b/web/src/components/project/range.tsx @@ -7,8 +7,14 @@ import { cls } from "../../utils"; import { Dialog } from "../dialog"; import { DatePickerRange } from "../daterange"; import { DateRange, wellKnownRanges, type RangeName } from "../../api/ranges"; +import { api, useQuery } from "../../api"; +import { endOfDay, startOfDay } from "date-fns"; -export const SelectRange = ({ onSelect, range }: { onSelect: (range: DateRange) => void; range: DateRange }) => { +export const SelectRange = ({ + onSelect, + range, + projectId, +}: { onSelect: (range: DateRange) => void; range: DateRange; projectId?: string }) => { const detailsRef = useRef(null); const handleSelect = (range: DateRange) => () => { @@ -16,6 +22,23 @@ export const SelectRange = ({ onSelect, range }: { onSelect: (range: DateRange) onSelect(range); }; + const allTime = useQuery({ + queryKey: ["allTime", projectId], + enabled: !!projectId, + staleTime: 7 * 24 * 60 * 60 * 1000, + queryFn: () => + api["/api/dashboard/project/{project_id}/earliest"].get({ params: { project_id: projectId || "" } }).json(), + }); + + const selectAllTime = async () => { + if (!projectId) return; + if (!allTime.data) return; + const from = new Date(allTime.data); + const range = new DateRange({ start: startOfDay(from), end: endOfDay(new Date()) }); + range.variant = "allTime"; + onSelect(range); + }; + return (
))} + {projectId && allTime.data && ( +
  • + +
  • + )}
  • { export const Projects = () => { const { data, isLoading, isError } = useQuery({ queryKey: ["projects"], - queryFn: () => api["/api/dashboard/projects"].get().json(), }); diff --git a/web/src/components/worldmap.tsx b/web/src/components/worldmap.tsx index 6aeb380..dc14c40 100644 --- a/web/src/components/worldmap.tsx +++ b/web/src/components/worldmap.tsx @@ -84,7 +84,7 @@ export const WorldMap = ({

    {metricNames[metric]}

    - {currentGeo.name} {formatMetricVal(countries.get(currentGeo.iso) ?? 0)} + {currentGeo.name} {formatMetricVal(countries.get(currentGeo.iso) ?? 0, metric)}

    )} diff --git a/web/src/global.css b/web/src/global.css index 2deda2d..481b74c 100644 --- a/web/src/global.css +++ b/web/src/global.css @@ -22,6 +22,7 @@ Ubuntu, Cantarell, Helvetica, Arial, "Helvetica Neue", sans-serif, var(--pico-font-family-emoji); --pico-font-family-sans-serif: var(--pico-font-family); + font-variant-numeric: tabular-nums; } :root[data-theme="dark"] {