feat: summarise /activity by period with per-repo change counts
All checks were successful
deploy / Build api + worker (static musl) (push) Successful in 5m31s
deploy / Deploy moments-worker to frootmig (push) Successful in 20s
deploy / Deploy moments-api to nikola (push) Successful in 25s
deploy / Build prerendered web (push) Successful in 8m48s
deploy / Deploy web to oolon (push) Successful in 27s
All checks were successful
deploy / Build api + worker (static musl) (push) Successful in 5m31s
deploy / Deploy moments-worker to frootmig (push) Successful in 20s
deploy / Deploy moments-api to nikola (push) Successful in 25s
deploy / Build prerendered web (push) Successful in 8m48s
deploy / Deploy web to oolon (push) Successful in 27s
The bare /activity route (no timespan modifier) now shows one timeline card per period — each listing the repos touched and the number of changes in each — with a daily/weekly/monthly/yearly bucket selector, so the view answers "where did my time go" at a glance. Each card drills down to the existing per-event view via /activity/from..to, which is unchanged. Backed by a new GET /v1/activity/summary?from&to&bucket&source endpoint: public events only (private repo names never leak), bucketed with date_trunc on the UTC-shifted timestamp, reusing the same per-source repo extraction as /v1/projects. Blog events carry no repo and are excluded. The /activity prerender now bakes the daily summary instead of the event list. On the first deploy the live API won't have the endpoint yet when build-web prerenders; that route degrades to the client-side fallback and self-heals on the daily refresh re-bake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LY4nbfHC9qt726gVrdAh3X
This commit is contained in:
@@ -13,7 +13,8 @@ use moments_core::{EventReader, reshape};
|
||||
use moments_data::PgStore;
|
||||
use moments_entities::{
|
||||
BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount,
|
||||
ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem,
|
||||
ProjectSummary, RepoLanguage, RepoPeriodCount, Source, SourceSummary, SummaryBucket,
|
||||
TimelineItem,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
@@ -62,6 +63,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/v1/blog", get(list_blog_posts))
|
||||
.route("/v1/blog/{slug}", get(get_blog_post))
|
||||
.route("/v1/activity/daily", get(daily_counts))
|
||||
.route("/v1/activity/summary", get(activity_summary))
|
||||
.route("/v1/activity/hourly", get(hourly_avgs))
|
||||
.route("/v1/languages/daily", get(language_daily_counts))
|
||||
.route("/v1/languages/repos", get(repo_languages))
|
||||
@@ -224,6 +226,47 @@ async fn daily_counts(
|
||||
Ok(Json(counts))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SummaryParams {
|
||||
from: Option<NaiveDate>,
|
||||
to: Option<NaiveDate>,
|
||||
/// Bucket granularity: day (default), week, month or year.
|
||||
bucket: Option<String>,
|
||||
/// Comma-separated list, e.g. `source=github,gitea`.
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-repo event counts bucketed into periods, for the timeline summary
|
||||
/// view. Public events only — repo names of private activity never leak.
|
||||
async fn activity_summary(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SummaryParams>,
|
||||
) -> Result<Json<Vec<RepoPeriodCount>>, 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 bucket = params
|
||||
.bucket
|
||||
.as_deref()
|
||||
.unwrap_or("day")
|
||||
.parse::<SummaryBucket>()
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))?;
|
||||
let sources = params.source.as_deref().map(parse_sources).transpose()?;
|
||||
let counts = state
|
||||
.store
|
||||
.activity_summary(
|
||||
from,
|
||||
to,
|
||||
bucket,
|
||||
sources.as_deref(),
|
||||
/* include_private */ false,
|
||||
)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
Ok(Json(counts))
|
||||
}
|
||||
|
||||
async fn language_daily_counts(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<DailyCountsParams>,
|
||||
|
||||
@@ -8,7 +8,7 @@ use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use moments_entities::{
|
||||
DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage,
|
||||
SourceSummary,
|
||||
RepoPeriodCount, Source, SourceSummary, SummaryBucket,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -38,6 +38,15 @@ pub trait EventReader: Send + Sync {
|
||||
to: NaiveDate,
|
||||
include_private: bool,
|
||||
) -> Result<Vec<LanguageDailyCount>, StoreError>;
|
||||
/// Per-repo event counts bucketed into day/week/month/year periods.
|
||||
async fn activity_summary(
|
||||
&self,
|
||||
from: NaiveDate,
|
||||
to: NaiveDate,
|
||||
bucket: SummaryBucket,
|
||||
sources: Option<&[Source]>,
|
||||
include_private: bool,
|
||||
) -> Result<Vec<RepoPeriodCount>, StoreError>;
|
||||
async fn hourly_avgs(
|
||||
&self,
|
||||
from: NaiveDate,
|
||||
|
||||
@@ -12,7 +12,7 @@ use chrono::{DateTime, Utc};
|
||||
use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError};
|
||||
use moments_entities::{
|
||||
DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage,
|
||||
Source, SourceSummary,
|
||||
RepoPeriodCount, Source, SourceSummary, SummaryBucket,
|
||||
};
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||
@@ -317,6 +317,73 @@ impl EventReader for PgStore {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn activity_summary(
|
||||
&self,
|
||||
from: NaiveDate,
|
||||
to: NaiveDate,
|
||||
bucket: SummaryBucket,
|
||||
sources: Option<&[Source]>,
|
||||
include_private: bool,
|
||||
) -> Result<Vec<RepoPeriodCount>, StoreError> {
|
||||
let sources: Option<Vec<String>> =
|
||||
sources.map(|s| s.iter().map(|x| x.as_str().to_string()).collect());
|
||||
|
||||
// `bucket.as_str()` is one of day/week/month/year, bound as the
|
||||
// date_trunc field. Truncation happens on the UTC-shifted timestamp so
|
||||
// period boundaries match the UTC day-stamping used everywhere else.
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT date_trunc($3, occurred_at AT TIME ZONE 'UTC')::date AS period,
|
||||
source, repo, COUNT(*)::bigint AS count
|
||||
FROM (
|
||||
SELECT source, occurred_at,
|
||||
CASE source
|
||||
WHEN 'github' THEN COALESCE(
|
||||
payload->'repo'->>'name',
|
||||
payload->'repository'->>'full_name',
|
||||
payload->>'_repo'
|
||||
)
|
||||
WHEN 'gitea' THEN COALESCE(
|
||||
payload->'repo'->>'full_name',
|
||||
payload->'repo'->>'name'
|
||||
)
|
||||
WHEN 'hg' THEN payload->>'_repo'
|
||||
WHEN 'bugzilla' THEN payload->>'product'
|
||||
ELSE NULL
|
||||
END AS repo
|
||||
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::text[] IS NULL OR source = ANY($4))
|
||||
AND ($5::bool OR public = true)
|
||||
) sub
|
||||
WHERE repo IS NOT NULL AND repo != ''
|
||||
GROUP BY period, source, repo
|
||||
ORDER BY period DESC, count DESC, repo
|
||||
"#,
|
||||
)
|
||||
.bind(from)
|
||||
.bind(to)
|
||||
.bind(bucket.as_str())
|
||||
.bind(sources.as_deref())
|
||||
.bind(include_private)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
let source_str: String = r.try_get("source").map_err(map_err)?;
|
||||
Ok(RepoPeriodCount {
|
||||
period: r.try_get("period").map_err(map_err)?,
|
||||
source: Source::from_str(&source_str).map_err(map_err)?,
|
||||
repo: r.try_get("repo").map_err(map_err)?,
|
||||
count: r.try_get("count").map_err(map_err)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn hourly_avgs(
|
||||
&self,
|
||||
from: NaiveDate,
|
||||
|
||||
@@ -95,6 +95,56 @@ pub struct DailyCount {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// Bucketing granularity for `GET /v1/activity/summary`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SummaryBucket {
|
||||
Day,
|
||||
Week,
|
||||
Month,
|
||||
Year,
|
||||
}
|
||||
|
||||
impl SummaryBucket {
|
||||
/// Postgres `date_trunc` field name.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SummaryBucket::Day => "day",
|
||||
SummaryBucket::Week => "week",
|
||||
SummaryBucket::Month => "month",
|
||||
SummaryBucket::Year => "year",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for SummaryBucket {
|
||||
type Err = ParseBucketError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"day" => Ok(SummaryBucket::Day),
|
||||
"week" => Ok(SummaryBucket::Week),
|
||||
"month" => Ok(SummaryBucket::Month),
|
||||
"year" => Ok(SummaryBucket::Year),
|
||||
other => Err(ParseBucketError(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("unknown bucket: {0} (expected day, week, month or year)")]
|
||||
pub struct ParseBucketError(pub String);
|
||||
|
||||
/// Per-repo event count within one summary period. `period` is the UTC
|
||||
/// start date of the bucket (day, ISO week Monday, first of month/year).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RepoPeriodCount {
|
||||
pub period: chrono::NaiveDate,
|
||||
pub source: Source,
|
||||
pub repo: String,
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user