diff --git a/crates/moments-api/src/main.rs b/crates/moments-api/src/main.rs index 2ede704..f68a4ca 100644 --- a/crates/moments-api/src/main.rs +++ b/crates/moments-api/src/main.rs @@ -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, + to: Option, + /// Bucket granularity: day (default), week, month or year. + bucket: Option, + /// Comma-separated list, e.g. `source=github,gitea`. + source: Option, +} + +/// 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, + 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 bucket = params + .bucket + .as_deref() + .unwrap_or("day") + .parse::() + .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, Query(params): Query, diff --git a/crates/moments-core/src/lib.rs b/crates/moments-core/src/lib.rs index 1809efd..691af0c 100644 --- a/crates/moments-core/src/lib.rs +++ b/crates/moments-core/src/lib.rs @@ -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, 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, StoreError>; async fn hourly_avgs( &self, from: NaiveDate, diff --git a/crates/moments-data/src/lib.rs b/crates/moments-data/src/lib.rs index 88a61cc..364b58a 100644 --- a/crates/moments-data/src/lib.rs +++ b/crates/moments-data/src/lib.rs @@ -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, StoreError> { + let sources: Option> = + 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, diff --git a/crates/moments-entities/src/lib.rs b/crates/moments-entities/src/lib.rs index c48f540..b72aa99 100644 --- a/crates/moments-entities/src/lib.rs +++ b/crates/moments-entities/src/lib.rs @@ -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 { + 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)] diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index c03b5ca..709dd34 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -71,6 +71,16 @@ export interface DailyCount { count: number; } +export type SummaryBucket = 'day' | 'week' | 'month' | 'year'; + +export interface RepoPeriodCount { + /** UTC start date of the bucket (day, ISO week Monday, first of month/year). */ + period: string; + source: Source; + repo: string; + count: number; +} + export interface HourlyAvg { hour: number; avg: number; @@ -129,6 +139,19 @@ export async function fetchDailyCounts(from: string, to: string): Promise { + const params = new URLSearchParams({ from, to, bucket }); + if (sources && sources.length > 0) params.set('source', sources.join(',')); + const resp = await fetch(`${API_BASE}/activity/summary?${params}`); + if (!resp.ok) throw new Error(`activity-summary: HTTP ${resp.status}`); + return resp.json(); +} + export async function fetchHourlyAvgs(from: string, to: string, tz: string): Promise { const qs = new URLSearchParams({ from, to, tz }); const resp = await fetch(`${API_BASE}/activity/hourly?${qs}`); diff --git a/ui/src/components/Filters.tsx b/ui/src/components/Filters.tsx index 217e204..b42890b 100644 --- a/ui/src/components/Filters.tsx +++ b/ui/src/components/Filters.tsx @@ -16,6 +16,8 @@ interface Props { limit: number; onLimitChange: (n: number) => void; summaries: SourceSummary[] | undefined; + /** Hide the activity-count slider (summary mode has no event cap). */ + showLimit?: boolean; } export function Filters({ @@ -28,6 +30,7 @@ export function Filters({ limit, onLimitChange, summaries, + showLimit = true, }: Props) { const summaryFor = (src: Source) => summaries?.find((s) => s.source === src); @@ -51,17 +54,19 @@ export function Filters({ ); })} - - - onLimitChange(Array.isArray(v) ? v[0] : v)} - /> - + {showLimit && ( + + + onLimitChange(Array.isArray(v) ? v[0] : v)} + /> + + )} diff --git a/ui/src/components/SummaryEntry.tsx b/ui/src/components/SummaryEntry.tsx new file mode 100644 index 0000000..4c5cb89 --- /dev/null +++ b/ui/src/components/SummaryEntry.tsx @@ -0,0 +1,106 @@ +import { Link } from 'react-router-dom'; +import { Calendar3 } from 'react-bootstrap-icons'; +import { VerticalTimelineElement } from 'react-vertical-timeline-component'; +import type { RepoPeriodCount, SummaryBucket } from '../api/client'; +import { fmtDate } from '../lib/ranges'; + +interface Props { + /** UTC start date of the period (YYYY-MM-DD). */ + period: string; + bucket: SummaryBucket; + /** Rows for this period, already sorted by count descending. */ + repos: RepoPeriodCount[]; +} + +/** One timeline card summarising a period: each repo touched and how many + * changes landed in it, with a drill-down link to the per-event view. */ +export function SummaryEntry({ period, bucket, repos }: Props) { + const total = repos.reduce((sum, r) => sum + r.count, 0); + const end = periodEnd(period, bucket); + const timespan = bucket === 'day' ? period : `${period}..${end}`; + + return ( + } + > +

+ {total} {total === 1 ? 'change' : 'changes'} in {repos.length}{' '} + {repos.length === 1 ? 'repository' : 'repositories'} +

+
    + {repos.map((r) => ( +
  • + + {r.repo}{' '} + {r.source} + + {r.count} +
  • + ))} +
+ + view activity → + +
+ ); +} + +/** Last day of the period (YYYY-MM-DD), for the drill-down timespan. */ +function periodEnd(period: string, bucket: SummaryBucket): string { + const d = new Date(period + 'T00:00:00Z'); + switch (bucket) { + case 'day': + return period; + case 'week': + d.setUTCDate(d.getUTCDate() + 6); + return fmtDate(d); + case 'month': + d.setUTCMonth(d.getUTCMonth() + 1); + d.setUTCDate(0); + return fmtDate(d); + case 'year': + return `${d.getUTCFullYear()}-12-31`; + } +} + +/** Human label for the card's date gutter. Formatted in UTC so the + * prerendered (Node) and client renders match byte-for-byte. */ +function periodLabel(period: string, bucket: SummaryBucket): string { + const d = new Date(period + 'T00:00:00Z'); + switch (bucket) { + case 'day': + return d + .toLocaleDateString('en-GB', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }) + .toLowerCase(); + case 'week': + return `week of ${d + .toLocaleDateString('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }) + .toLowerCase()}`; + case 'month': + return d + .toLocaleDateString('en-GB', { + year: 'numeric', + month: 'long', + timeZone: 'UTC', + }) + .toLowerCase(); + case 'year': + return String(d.getUTCFullYear()); + } +} diff --git a/ui/src/pages/TimelineHome.tsx b/ui/src/pages/TimelineHome.tsx index 3f9c898..ea3a014 100644 --- a/ui/src/pages/TimelineHome.tsx +++ b/ui/src/pages/TimelineHome.tsx @@ -1,18 +1,36 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; +import Button from 'react-bootstrap/Button'; +import ButtonGroup from 'react-bootstrap/ButtonGroup'; import Col from 'react-bootstrap/Col'; import Row from 'react-bootstrap/Row'; import { VerticalTimeline } from 'react-vertical-timeline-component'; -import { fetchDailyCounts, fetchEvents, fetchSources, type Source } from '../api/client'; +import { + fetchActivitySummary, + fetchDailyCounts, + fetchEvents, + fetchSources, + type RepoPeriodCount, + type Source, + type SummaryBucket, +} from '../api/client'; import { Filters } from '../components/Filters'; +import { SummaryEntry } from '../components/SummaryEntry'; import { TimelineEntry } from '../components/TimelineEntry'; import { defaultActivityRange, endOfTodayMs, fmtDate } from '../lib/ranges'; const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime(); const RANGE_MAX = endOfTodayMs(); +const BUCKETS: { value: SummaryBucket; label: string }[] = [ + { value: 'day', label: 'daily' }, + { value: 'week', label: 'weekly' }, + { value: 'month', label: 'monthly' }, + { value: 'year', label: 'yearly' }, +]; + function parseDate(s: string): number { // Accept YYYY-MM-DD or full ISO datetime const t = new Date(s.includes('T') ? s : s + 'T00:00:00Z').getTime(); @@ -41,6 +59,9 @@ function parseTimespan(timespan?: string): [number, number] | null { export function TimelineHome() { const { timespan } = useParams(); + // Without a date modifier in the route, show per-period summaries instead + // of individual events; drill-down links navigate to /activity/:timespan. + const summaryMode = !parseTimespan(timespan); const [enabledSources, setEnabledSources] = useState>({ github: true, @@ -56,6 +77,15 @@ export function TimelineHome() { return [from, to]; }); const [limit, setLimit] = useState(100); + const [bucket, setBucket] = useState('day'); + + // The summary and event views are the same component at the same route + // position, so React keeps state across navigation between them. Sync the + // slider to the route's timespan (e.g. a summary card's drill-down link). + useEffect(() => { + const parsed = parseTimespan(timespan); + if (parsed) setRangeValue(parsed); + }, [timespan]); const sourcesQ = useQuery({ queryKey: ['sources'], @@ -84,14 +114,35 @@ export function TimelineHome() { limit, }), refetchInterval: 60_000, + enabled: !summaryMode, }); const events = eventsQ.data ?? []; + const summaryQ = useQuery({ + queryKey: ['activity-summary', fromStr, toStr, bucket, activeSources], + queryFn: () => fetchActivitySummary(fromStr, toStr, bucket, activeSources), + refetchInterval: 60_000, + enabled: summaryMode, + }); + + // Group the flat rows into periods. The API orders by period desc then + // count desc, so Map insertion order is already the display order. + const periods = useMemo(() => { + const byPeriod = new Map(); + for (const row of summaryQ.data ?? []) { + const list = byPeriod.get(row.period); + if (list) list.push(row); + else byPeriod.set(row.period, [row]); + } + return [...byPeriod.entries()]; + }, [summaryQ.data]); + const dailyQ = useQuery({ queryKey: ['daily-counts', fromStr, toStr], queryFn: () => fetchDailyCounts(fromStr, toStr), staleTime: 5 * 60_000, + enabled: !summaryMode, }); const totalCount = useMemo( () => (dailyQ.data ?? []).reduce((sum, d) => sum + d.count, 0), @@ -99,6 +150,8 @@ export function TimelineHome() { ); const privateCount = totalCount - events.length; + const statusQ = summaryMode ? summaryQ : eventsQ; + return ( <> + {summaryMode && ( + + + + {BUCKETS.map((b) => ( + + ))} + + + + )} +

- {eventsQ.isLoading + {statusQ.isLoading ? 'loading…' - : eventsQ.isError - ? `error: ${(eventsQ.error as Error).message}` - : `showing ${events.length} public ${events.length === 1 ? 'activity' : 'activities'}${privateCount > 0 ? `, ${privateCount} private` : ''}`} + : statusQ.isError + ? `error: ${(statusQ.error as Error).message}` + : summaryMode + ? `showing ${periods.length} ${BUCKETS.find((b) => b.value === bucket)?.label} ${periods.length === 1 ? 'summary' : 'summaries'}` + : `showing ${events.length} public ${events.length === 1 ? 'activity' : 'activities'}${privateCount > 0 ? `, ${privateCount} private` : ''}`}

- {events.map((item) => ( - - ))} + {summaryMode + ? periods.map(([period, repos]) => ( + + )) + : events.map((item) => ( + + ))}
diff --git a/ui/src/prerender/prefetch.ts b/ui/src/prerender/prefetch.ts index 071c41a..6454e04 100644 --- a/ui/src/prerender/prefetch.ts +++ b/ui/src/prerender/prefetch.ts @@ -8,6 +8,7 @@ import type { QueryClient } from '@tanstack/react-query'; import { + fetchActivitySummary, fetchBlogPost, fetchBlogPosts, fetchDailyCounts, @@ -34,7 +35,6 @@ import { // Sources the timeline shows by default, in the same insertion order as // TimelineHome's `enabledSources` (the array order is part of the query key). const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla', 'blog']; -const TIMELINE_LIMIT = 100; const PROJECT_EVENT_LIMIT = 500; async function prefetchDash(qc: QueryClient): Promise { @@ -72,25 +72,17 @@ async function prefetchDash(qc: QueryClient): Promise { } async function prefetchActivity(qc: QueryClient): Promise { - // Only the default `/activity` view is prerendered. Timespan-parameterised - // routes (`/activity/:timespan`) are unbounded, so they SPA-fall-back to the + // Only the default `/activity` view is prerendered — the per-period summary + // with the default daily bucket. Timespan-parameterised routes + // (`/activity/:timespan`) are unbounded, so they SPA-fall-back to the // client and refetch. const range = defaultActivityRange(); await Promise.all([ qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources }), qc.prefetchQuery({ - queryKey: ['events', range.fromStr, range.toStr, ALL_SOURCES, TIMELINE_LIMIT], + queryKey: ['activity-summary', range.fromStr, range.toStr, 'day', ALL_SOURCES], queryFn: () => - fetchEvents({ - from: new Date(range.from), - to: new Date(range.to), - sources: ALL_SOURCES, - limit: TIMELINE_LIMIT, - }), - }), - qc.prefetchQuery({ - queryKey: ['daily-counts', range.fromStr, range.toStr], - queryFn: () => fetchDailyCounts(range.fromStr, range.toStr), + fetchActivitySummary(range.fromStr, range.toStr, 'day', ALL_SOURCES), }), ]); }