feat(ui): add avg-by-hour panel to dashboard stats
Complements the existing avg-by-weekday chart with its orthogonal partner: which hour of the day the user typically commits. The api buckets events by EXTRACT(hour FROM occurred_at AT TIME ZONE $tz) so the chart matches the clock the user sees rather than UTC; the UI passes the browser's resolved IANA timezone. Renders as 24 mini-bars below the weekday chart with labels every 4 hours and per-bar tooltips showing the average events/day at that hour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ use chrono::{DateTime, Datelike, NaiveDate, Utc};
|
||||
use clap::Parser;
|
||||
use moments_core::{EventReader, reshape};
|
||||
use moments_data::PgStore;
|
||||
use moments_entities::{DailyCount, EventQuery, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
|
||||
use moments_entities::{DailyCount, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
|
||||
use serde::Deserialize;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use tracing::info;
|
||||
@@ -57,6 +57,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/v1/sources", get(list_sources))
|
||||
.route("/v1/projects", get(list_projects))
|
||||
.route("/v1/activity/daily", get(daily_counts))
|
||||
.route("/v1/activity/hourly", get(hourly_avgs))
|
||||
.route("/v1/languages/daily", get(language_daily_counts))
|
||||
.route("/v1/languages/repos", get(repo_languages))
|
||||
.route("/v1/forge/{source}/{*rest}", get(forge_proxy))
|
||||
@@ -169,6 +170,38 @@ async fn language_daily_counts(
|
||||
Ok(Json(counts))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HourlyAvgsParams {
|
||||
from: Option<NaiveDate>,
|
||||
to: Option<NaiveDate>,
|
||||
/// IANA timezone name (e.g. "Europe/Helsinki"). Defaults to UTC.
|
||||
/// Hour buckets are computed in this zone so the chart matches the
|
||||
/// clock the user sees.
|
||||
tz: Option<String>,
|
||||
}
|
||||
|
||||
async fn hourly_avgs(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<HourlyAvgsParams>,
|
||||
) -> Result<Json<Vec<HourlyAvg>>, ApiError> {
|
||||
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
|
||||
let from = params.from.unwrap_or_else(|| to - chrono::Duration::days(365));
|
||||
let tz = params.tz.as_deref().unwrap_or("UTC");
|
||||
// Validate the tz string before handing it to postgres — a bad name
|
||||
// here would surface as an opaque 500 from the DB. chrono-tz would do
|
||||
// it for free but we don't depend on it; instead reject obvious shell
|
||||
// injection vectors (the value is bound, not interpolated, so this is
|
||||
// belt-and-braces).
|
||||
if tz.len() > 64 || tz.chars().any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-'))) {
|
||||
return Err(ApiError {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: "invalid tz".into(),
|
||||
});
|
||||
}
|
||||
let avgs = state.store.hourly_avgs(from, to, tz, /* include_private */ true).await.map_err(internal)?;
|
||||
Ok(Json(avgs))
|
||||
}
|
||||
|
||||
async fn repo_languages(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<RepoLanguage>>, ApiError> {
|
||||
|
||||
Reference in New Issue
Block a user