diff --git a/crates/moments-api/src/main.rs b/crates/moments-api/src/main.rs index 0951a82..d8cf72b 100644 --- a/crates/moments-api/src/main.rs +++ b/crates/moments-api/src/main.rs @@ -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, + to: Option, + /// 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, +} + +async fn hourly_avgs( + State(state): State, + Query(params): Query, +) -> Result>, 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, ) -> Result>, ApiError> { diff --git a/crates/moments-core/src/lib.rs b/crates/moments-core/src/lib.rs index 1c8b4ba..fd93188 100644 --- a/crates/moments-core/src/lib.rs +++ b/crates/moments-core/src/lib.rs @@ -6,7 +6,7 @@ pub use sources::{EventSource, PollerState, PollerStateStore, SourceError, run_p use async_trait::async_trait; use chrono::NaiveDate; -use moments_entities::{DailyCount, Event, EventQuery, LanguageDailyCount, ProjectSummary, RepoLanguage, SourceSummary}; +use moments_entities::{DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, SourceSummary}; #[derive(Debug, thiserror::Error)] pub enum StoreError { @@ -22,6 +22,7 @@ pub trait EventReader: Send + Sync { async fn list_projects(&self) -> Result, StoreError>; async fn daily_counts(&self, from: NaiveDate, to: NaiveDate, include_private: bool) -> Result, StoreError>; async fn language_daily_counts(&self, from: NaiveDate, to: NaiveDate, include_private: bool) -> Result, StoreError>; + async fn hourly_avgs(&self, from: NaiveDate, to: NaiveDate, tz: &str, include_private: bool) -> Result, StoreError>; async fn repo_languages(&self) -> Result, StoreError>; } diff --git a/crates/moments-data/src/lib.rs b/crates/moments-data/src/lib.rs index d503f88..293f700 100644 --- a/crates/moments-data/src/lib.rs +++ b/crates/moments-data/src/lib.rs @@ -9,7 +9,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError}; use chrono::NaiveDate; -use moments_entities::{DailyCount, Event, EventQuery, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary}; +use moments_entities::{DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary}; use sqlx::Row; use sqlx::postgres::{PgPool, PgPoolOptions}; use std::str::FromStr; @@ -291,6 +291,55 @@ impl EventReader for PgStore { .collect() } + async fn hourly_avgs( + &self, + from: NaiveDate, + to: NaiveDate, + tz: &str, + include_private: bool, + ) -> Result, StoreError> { + // GREATEST guards against from > to (returns NaN-via-div-by-zero + // otherwise). EXTRACT(hour FROM tz-shifted timestamp) buckets each + // event into the user's local hour rather than UTC, so the chart + // matches the labels they'd see on a clock. + let rows = sqlx::query( + r#" + WITH params AS ( + SELECT GREATEST(($2::date - $1::date + 1), 1)::float8 AS day_count + ), + bucketed AS ( + SELECT EXTRACT(hour FROM (occurred_at AT TIME ZONE $3))::int AS hour + FROM events + WHERE occurred_at >= ($1::date::timestamp AT TIME ZONE 'UTC') + AND occurred_at < (($2::date + 1)::timestamp AT TIME ZONE 'UTC') + AND ($4::bool OR public = true) + ) + SELECT g.h::int AS hour, + (COUNT(b.hour)::float8 / (SELECT day_count FROM params)) AS avg + FROM generate_series(0, 23) AS g(h) + LEFT JOIN bucketed b ON b.hour = g.h + GROUP BY g.h + ORDER BY g.h + "#, + ) + .bind(from) + .bind(to) + .bind(tz) + .bind(include_private) + .fetch_all(&self.pool) + .await + .map_err(map_err)?; + + rows.into_iter() + .map(|r| { + Ok(HourlyAvg { + hour: r.try_get("hour").map_err(map_err)?, + avg: r.try_get("avg").map_err(map_err)?, + }) + }) + .collect() + } + async fn repo_languages(&self) -> Result, StoreError> { let rows = sqlx::query( r#" diff --git a/crates/moments-entities/src/lib.rs b/crates/moments-entities/src/lib.rs index 7f832cf..771ed64 100644 --- a/crates/moments-entities/src/lib.rs +++ b/crates/moments-entities/src/lib.rs @@ -91,6 +91,14 @@ pub struct DailyCount { pub count: i64, } +/// Average events per day at a given hour of the day, computed in a +/// caller-supplied IANA timezone. 24 entries (0..=23). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HourlyAvg { + pub hour: i32, + pub avg: f64, +} + /// Per-repo activity rollup for the dashboard. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectSummary { diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index bcdb04d..fc4331d 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -70,6 +70,11 @@ export interface DailyCount { count: number; } +export interface HourlyAvg { + hour: number; + avg: number; +} + export interface LanguageDailyCount { date: string; language: string; @@ -120,6 +125,13 @@ export async function fetchDailyCounts(from: string, to: string): Promise { + const qs = new URLSearchParams({ from, to, tz }); + const resp = await fetch(`${API_BASE}/activity/hourly?${qs}`); + if (!resp.ok) throw new Error(`hourly-avgs: HTTP ${resp.status}`); + return resp.json(); +} + export async function fetchLanguageDailyCounts(from: string, to: string): Promise { const resp = await fetch(`${API_BASE}/languages/daily?from=${from}&to=${to}`); if (!resp.ok) throw new Error(`language-daily-counts: HTTP ${resp.status}`); diff --git a/ui/src/components/ContributionStats.tsx b/ui/src/components/ContributionStats.tsx index 6b910e2..42ce0c2 100644 --- a/ui/src/components/ContributionStats.tsx +++ b/ui/src/components/ContributionStats.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { fetchDailyCounts, fetchSources } from '../api/client'; +import { fetchDailyCounts, fetchHourlyAvgs, fetchSources } from '../api/client'; export function ContributionStats() { const sourcesQ = useQuery({ @@ -31,6 +31,21 @@ export function ContributionStats() { staleTime: 10 * 60_000, }); + // Bucket hour-of-day in the user's local timezone so the chart matches + // the clock they see. Browser may report e.g. "Europe/Helsinki"; fall + // back to UTC if the resolver returns something the server won't + // accept (it validates the string before binding). + const tz = useMemo( + () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + [], + ); + const hourlyQ = useQuery({ + queryKey: ['hourly-avgs-alltime', fromStr, toStr, tz], + queryFn: () => fetchHourlyAvgs(fromStr, toStr, tz), + enabled: !!earliest, + staleTime: 10 * 60_000, + }); + const stats = useMemo(() => { const counts = dailyQ.data ?? []; if (counts.length === 0) return null; @@ -96,6 +111,17 @@ export function ContributionStats() { return { currentStreak, longestStreak, busiest, dayAvgs, maxAvg, activeDays }; }, [dailyQ.data]); + const hourly = useMemo(() => { + const data = hourlyQ.data ?? []; + if (data.length === 0) return null; + const byHour = new Array(24).fill(0); + for (const { hour, avg } of data) { + if (hour >= 0 && hour < 24) byHour[hour] = avg; + } + const max = Math.max(...byHour); + return { hours: byHour, max }; + }, [hourlyQ.data]); + if (!stats) return null; return ( @@ -139,6 +165,31 @@ export function ContributionStats() { ))} + {hourly && ( +
+ avg by hour ({tz}) +
+ {hourly.hours.map((avg, h) => ( +
+
+
0 ? `${(avg / hourly.max) * 100}%` : '0%', + borderRadius: 2, + backgroundColor: '#39d353', + opacity: 0.7, + }} + title={`${h.toString().padStart(2, '0')}:00 — ${avg.toFixed(2)}/day`} + /> +
+ + {h % 4 === 0 ? h.toString().padStart(2, '0') : ''} + +
+ ))} +
+
+ )}
);