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_data::PgStore;
|
||||||
use moments_entities::{
|
use moments_entities::{
|
||||||
BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount,
|
BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount,
|
||||||
ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem,
|
ProjectSummary, RepoLanguage, RepoPeriodCount, Source, SourceSummary, SummaryBucket,
|
||||||
|
TimelineItem,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
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", get(list_blog_posts))
|
||||||
.route("/v1/blog/{slug}", get(get_blog_post))
|
.route("/v1/blog/{slug}", get(get_blog_post))
|
||||||
.route("/v1/activity/daily", get(daily_counts))
|
.route("/v1/activity/daily", get(daily_counts))
|
||||||
|
.route("/v1/activity/summary", get(activity_summary))
|
||||||
.route("/v1/activity/hourly", get(hourly_avgs))
|
.route("/v1/activity/hourly", get(hourly_avgs))
|
||||||
.route("/v1/languages/daily", get(language_daily_counts))
|
.route("/v1/languages/daily", get(language_daily_counts))
|
||||||
.route("/v1/languages/repos", get(repo_languages))
|
.route("/v1/languages/repos", get(repo_languages))
|
||||||
@@ -224,6 +226,47 @@ async fn daily_counts(
|
|||||||
Ok(Json(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(
|
async fn language_daily_counts(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<DailyCountsParams>,
|
Query(params): Query<DailyCountsParams>,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use async_trait::async_trait;
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use moments_entities::{
|
use moments_entities::{
|
||||||
DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage,
|
DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage,
|
||||||
SourceSummary,
|
RepoPeriodCount, Source, SourceSummary, SummaryBucket,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -38,6 +38,15 @@ pub trait EventReader: Send + Sync {
|
|||||||
to: NaiveDate,
|
to: NaiveDate,
|
||||||
include_private: bool,
|
include_private: bool,
|
||||||
) -> Result<Vec<LanguageDailyCount>, StoreError>;
|
) -> 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(
|
async fn hourly_avgs(
|
||||||
&self,
|
&self,
|
||||||
from: NaiveDate,
|
from: NaiveDate,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError};
|
use moments_core::{EventReader, EventWriter, PollerState, PollerStateStore, StoreError};
|
||||||
use moments_entities::{
|
use moments_entities::{
|
||||||
DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage,
|
DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage,
|
||||||
Source, SourceSummary,
|
RepoPeriodCount, Source, SourceSummary, SummaryBucket,
|
||||||
};
|
};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||||
@@ -317,6 +317,73 @@ impl EventReader for PgStore {
|
|||||||
.collect()
|
.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(
|
async fn hourly_avgs(
|
||||||
&self,
|
&self,
|
||||||
from: NaiveDate,
|
from: NaiveDate,
|
||||||
|
|||||||
@@ -95,6 +95,56 @@ pub struct DailyCount {
|
|||||||
pub count: i64,
|
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
|
/// Average events per day at a given hour of the day, computed in a
|
||||||
/// caller-supplied IANA timezone. 24 entries (0..=23).
|
/// caller-supplied IANA timezone. 24 entries (0..=23).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -71,6 +71,16 @@ export interface DailyCount {
|
|||||||
count: number;
|
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 {
|
export interface HourlyAvg {
|
||||||
hour: number;
|
hour: number;
|
||||||
avg: number;
|
avg: number;
|
||||||
@@ -129,6 +139,19 @@ export async function fetchDailyCounts(from: string, to: string): Promise<DailyC
|
|||||||
return resp.json();
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchActivitySummary(
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
bucket: SummaryBucket,
|
||||||
|
sources?: Source[],
|
||||||
|
): Promise<RepoPeriodCount[]> {
|
||||||
|
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<HourlyAvg[]> {
|
export async function fetchHourlyAvgs(from: string, to: string, tz: string): Promise<HourlyAvg[]> {
|
||||||
const qs = new URLSearchParams({ from, to, tz });
|
const qs = new URLSearchParams({ from, to, tz });
|
||||||
const resp = await fetch(`${API_BASE}/activity/hourly?${qs}`);
|
const resp = await fetch(`${API_BASE}/activity/hourly?${qs}`);
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface Props {
|
|||||||
limit: number;
|
limit: number;
|
||||||
onLimitChange: (n: number) => void;
|
onLimitChange: (n: number) => void;
|
||||||
summaries: SourceSummary[] | undefined;
|
summaries: SourceSummary[] | undefined;
|
||||||
|
/** Hide the activity-count slider (summary mode has no event cap). */
|
||||||
|
showLimit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Filters({
|
export function Filters({
|
||||||
@@ -28,6 +30,7 @@ export function Filters({
|
|||||||
limit,
|
limit,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
summaries,
|
summaries,
|
||||||
|
showLimit = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const summaryFor = (src: Source) => summaries?.find((s) => s.source === src);
|
const summaryFor = (src: Source) => summaries?.find((s) => s.source === src);
|
||||||
|
|
||||||
@@ -51,17 +54,19 @@ export function Filters({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Col>
|
</Col>
|
||||||
<Col md={6}>
|
{showLimit && (
|
||||||
<label style={{ fontSize: '70%' }}>
|
<Col md={6}>
|
||||||
number of activities to display: {limit}
|
<label style={{ fontSize: '70%' }}>
|
||||||
</label>
|
number of activities to display: {limit}
|
||||||
<Slider
|
</label>
|
||||||
value={limit}
|
<Slider
|
||||||
min={10}
|
value={limit}
|
||||||
max={1000}
|
min={10}
|
||||||
onChange={(v) => onLimitChange(Array.isArray(v) ? v[0] : v)}
|
max={1000}
|
||||||
/>
|
onChange={(v) => onLimitChange(Array.isArray(v) ? v[0] : v)}
|
||||||
</Col>
|
/>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<Row className="mb-3">
|
<Row className="mb-3">
|
||||||
<Col>
|
<Col>
|
||||||
|
|||||||
106
ui/src/components/SummaryEntry.tsx
Normal file
106
ui/src/components/SummaryEntry.tsx
Normal file
@@ -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 (
|
||||||
|
<VerticalTimelineElement
|
||||||
|
date={periodLabel(period, bucket)}
|
||||||
|
iconStyle={{ background: '#546e7a', color: '#fff' }}
|
||||||
|
icon={<Calendar3 />}
|
||||||
|
>
|
||||||
|
<h4 className="vertical-timeline-element-title">
|
||||||
|
{total} {total === 1 ? 'change' : 'changes'} in {repos.length}{' '}
|
||||||
|
{repos.length === 1 ? 'repository' : 'repositories'}
|
||||||
|
</h4>
|
||||||
|
<ul style={{ listStyle: 'none', paddingLeft: 0, marginBottom: '0.5rem' }}>
|
||||||
|
{repos.map((r) => (
|
||||||
|
<li
|
||||||
|
key={`${r.source}:${r.repo}`}
|
||||||
|
className="d-flex justify-content-between"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Link to={`/project/${r.source}/${r.repo}`}>{r.repo}</Link>{' '}
|
||||||
|
<small className="text-muted">{r.source}</small>
|
||||||
|
</span>
|
||||||
|
<span>{r.count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Link to={`/activity/${timespan}`} style={{ fontSize: '85%' }}>
|
||||||
|
view activity →
|
||||||
|
</Link>
|
||||||
|
</VerticalTimelineElement>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,36 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 Col from 'react-bootstrap/Col';
|
||||||
import Row from 'react-bootstrap/Row';
|
import Row from 'react-bootstrap/Row';
|
||||||
import { VerticalTimeline } from 'react-vertical-timeline-component';
|
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 { Filters } from '../components/Filters';
|
||||||
|
import { SummaryEntry } from '../components/SummaryEntry';
|
||||||
import { TimelineEntry } from '../components/TimelineEntry';
|
import { TimelineEntry } from '../components/TimelineEntry';
|
||||||
import { defaultActivityRange, endOfTodayMs, fmtDate } from '../lib/ranges';
|
import { defaultActivityRange, endOfTodayMs, fmtDate } from '../lib/ranges';
|
||||||
|
|
||||||
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
|
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
|
||||||
const RANGE_MAX = endOfTodayMs();
|
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 {
|
function parseDate(s: string): number {
|
||||||
// Accept YYYY-MM-DD or full ISO datetime
|
// Accept YYYY-MM-DD or full ISO datetime
|
||||||
const t = new Date(s.includes('T') ? s : s + 'T00:00:00Z').getTime();
|
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() {
|
export function TimelineHome() {
|
||||||
const { timespan } = useParams();
|
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<Record<Source, boolean>>({
|
const [enabledSources, setEnabledSources] = useState<Record<Source, boolean>>({
|
||||||
github: true,
|
github: true,
|
||||||
@@ -56,6 +77,15 @@ export function TimelineHome() {
|
|||||||
return [from, to];
|
return [from, to];
|
||||||
});
|
});
|
||||||
const [limit, setLimit] = useState<number>(100);
|
const [limit, setLimit] = useState<number>(100);
|
||||||
|
const [bucket, setBucket] = useState<SummaryBucket>('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({
|
const sourcesQ = useQuery({
|
||||||
queryKey: ['sources'],
|
queryKey: ['sources'],
|
||||||
@@ -84,14 +114,35 @@ export function TimelineHome() {
|
|||||||
limit,
|
limit,
|
||||||
}),
|
}),
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
|
enabled: !summaryMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const events = eventsQ.data ?? [];
|
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<string, RepoPeriodCount[]>();
|
||||||
|
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({
|
const dailyQ = useQuery({
|
||||||
queryKey: ['daily-counts', fromStr, toStr],
|
queryKey: ['daily-counts', fromStr, toStr],
|
||||||
queryFn: () => fetchDailyCounts(fromStr, toStr),
|
queryFn: () => fetchDailyCounts(fromStr, toStr),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
|
enabled: !summaryMode,
|
||||||
});
|
});
|
||||||
const totalCount = useMemo(
|
const totalCount = useMemo(
|
||||||
() => (dailyQ.data ?? []).reduce((sum, d) => sum + d.count, 0),
|
() => (dailyQ.data ?? []).reduce((sum, d) => sum + d.count, 0),
|
||||||
@@ -99,6 +150,8 @@ export function TimelineHome() {
|
|||||||
);
|
);
|
||||||
const privateCount = totalCount - events.length;
|
const privateCount = totalCount - events.length;
|
||||||
|
|
||||||
|
const statusQ = summaryMode ? summaryQ : eventsQ;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Filters
|
<Filters
|
||||||
@@ -113,21 +166,51 @@ export function TimelineHome() {
|
|||||||
limit={limit}
|
limit={limit}
|
||||||
onLimitChange={setLimit}
|
onLimitChange={setLimit}
|
||||||
summaries={sourcesQ.data}
|
summaries={sourcesQ.data}
|
||||||
|
showLimit={!summaryMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{summaryMode && (
|
||||||
|
<Row className="mb-3">
|
||||||
|
<Col className="text-center">
|
||||||
|
<ButtonGroup size="sm">
|
||||||
|
{BUCKETS.map((b) => (
|
||||||
|
<Button
|
||||||
|
key={b.value}
|
||||||
|
variant={bucket === b.value ? 'secondary' : 'outline-secondary'}
|
||||||
|
onClick={() => setBucket(b.value)}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</ButtonGroup>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
<Col>
|
<Col>
|
||||||
<p className="text-center" style={{ fontSize: '85%' }}>
|
<p className="text-center" style={{ fontSize: '85%' }}>
|
||||||
{eventsQ.isLoading
|
{statusQ.isLoading
|
||||||
? 'loading…'
|
? 'loading…'
|
||||||
: eventsQ.isError
|
: statusQ.isError
|
||||||
? `error: ${(eventsQ.error as Error).message}`
|
? `error: ${(statusQ.error as Error).message}`
|
||||||
: `showing ${events.length} public ${events.length === 1 ? 'activity' : 'activities'}${privateCount > 0 ? `, ${privateCount} private` : ''}`}
|
: 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` : ''}`}
|
||||||
</p>
|
</p>
|
||||||
<VerticalTimeline>
|
<VerticalTimeline>
|
||||||
{events.map((item) => (
|
{summaryMode
|
||||||
<TimelineEntry key={item.id} item={item} />
|
? periods.map(([period, repos]) => (
|
||||||
))}
|
<SummaryEntry
|
||||||
|
key={period}
|
||||||
|
period={period}
|
||||||
|
bucket={bucket}
|
||||||
|
repos={repos}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: events.map((item) => (
|
||||||
|
<TimelineEntry key={item.id} item={item} />
|
||||||
|
))}
|
||||||
</VerticalTimeline>
|
</VerticalTimeline>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import type { QueryClient } from '@tanstack/react-query';
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
fetchActivitySummary,
|
||||||
fetchBlogPost,
|
fetchBlogPost,
|
||||||
fetchBlogPosts,
|
fetchBlogPosts,
|
||||||
fetchDailyCounts,
|
fetchDailyCounts,
|
||||||
@@ -34,7 +35,6 @@ import {
|
|||||||
// Sources the timeline shows by default, in the same insertion order as
|
// Sources the timeline shows by default, in the same insertion order as
|
||||||
// TimelineHome's `enabledSources` (the array order is part of the query key).
|
// TimelineHome's `enabledSources` (the array order is part of the query key).
|
||||||
const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla', 'blog'];
|
const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla', 'blog'];
|
||||||
const TIMELINE_LIMIT = 100;
|
|
||||||
const PROJECT_EVENT_LIMIT = 500;
|
const PROJECT_EVENT_LIMIT = 500;
|
||||||
|
|
||||||
async function prefetchDash(qc: QueryClient): Promise<void> {
|
async function prefetchDash(qc: QueryClient): Promise<void> {
|
||||||
@@ -72,25 +72,17 @@ async function prefetchDash(qc: QueryClient): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function prefetchActivity(qc: QueryClient): Promise<void> {
|
async function prefetchActivity(qc: QueryClient): Promise<void> {
|
||||||
// Only the default `/activity` view is prerendered. Timespan-parameterised
|
// Only the default `/activity` view is prerendered — the per-period summary
|
||||||
// routes (`/activity/:timespan`) are unbounded, so they SPA-fall-back to the
|
// with the default daily bucket. Timespan-parameterised routes
|
||||||
|
// (`/activity/:timespan`) are unbounded, so they SPA-fall-back to the
|
||||||
// client and refetch.
|
// client and refetch.
|
||||||
const range = defaultActivityRange();
|
const range = defaultActivityRange();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources }),
|
qc.prefetchQuery({ queryKey: ['sources'], queryFn: fetchSources }),
|
||||||
qc.prefetchQuery({
|
qc.prefetchQuery({
|
||||||
queryKey: ['events', range.fromStr, range.toStr, ALL_SOURCES, TIMELINE_LIMIT],
|
queryKey: ['activity-summary', range.fromStr, range.toStr, 'day', ALL_SOURCES],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchEvents({
|
fetchActivitySummary(range.fromStr, range.toStr, 'day', ALL_SOURCES),
|
||||||
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),
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user