diff --git a/CLAUDE.md b/CLAUDE.md index c5781d9..81c4c2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,14 @@ moments-worker — ingestion daemon binary (runs migrations, connects as mom `activity/summary`, `languages/repos`); the count endpoints (`activity/daily`, `activity/hourly`, `sources`, `languages/daily`) pass `include_private = true`, so private work shows up as volume without leaking repo names or messages. + `activity/summary` bridges the two: alongside the named per-repo rows it emits + at most one row per period with `private = true` and a null `source`/`repo`, + counting that period's private-repo activity. Without it a period spent mostly + in private repos read as near-idle next to a busy contribution graph. It is + deliberately not split by forge — the per-period total is already derivable + from `activity/daily`, a per-forge breakdown would not be (note that a + `?source=` filter does narrow it, by design, so the numbers stay consistent + with the filter the caller asked for). - **Visibility reconciliation**: `public` is stamped at ingest from whatever the forge reported then, and every poller is incremental — so nothing would ever revisit a repo that later flipped public ↔ private. The github and gitea diff --git a/crates/moments-data/src/lib.rs b/crates/moments-data/src/lib.rs index cf725b9..15f5d95 100644 --- a/crates/moments-data/src/lib.rs +++ b/crates/moments-data/src/lib.rs @@ -327,21 +327,44 @@ impl EventReader for PgStore { // `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. + // + // Private activity is not dropped, it is collapsed: one extra row per + // period, unattributed, so a period spent mostly in private repos + // still reports its real size instead of reading as near-idle beside + // a busy contribution graph. With `include_private` the named branch + // takes everything and the aggregate is empty, so an authenticated + // view sees repos rather than a lump. 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, repo + WITH scoped AS ( + SELECT date_trunc($3, occurred_at AT TIME ZONE 'UTC')::date AS period, + source, repo, public 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 + AND repo IS NOT NULL AND repo != '' + ), + named AS ( + SELECT period, source, repo, COUNT(*)::bigint AS count, false AS private + FROM scoped + WHERE public OR $5 + GROUP BY period, source, repo + ), + aggregated AS ( + -- Deliberately not grouped by source: the per-period total is + -- already derivable from /activity/daily, which counts private + -- activity, but a per-forge split would be new information. + SELECT period, NULL::text AS source, NULL::text AS repo, + COUNT(*)::bigint AS count, true AS private + FROM scoped + WHERE NOT public AND NOT $5 + GROUP BY period + ) + SELECT * FROM named + UNION ALL + SELECT * FROM aggregated + ORDER BY period DESC, private, count DESC, repo "#, ) .bind(from) @@ -355,12 +378,17 @@ impl EventReader for PgStore { rows.into_iter() .map(|r| { - let source_str: String = r.try_get("source").map_err(map_err)?; + let source_str: Option = r.try_get("source").map_err(map_err)?; + let source = source_str + .map(|s| Source::from_str(&s)) + .transpose() + .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)?, + source, repo: r.try_get("repo").map_err(map_err)?, count: r.try_get("count").map_err(map_err)?, + private: r.try_get("private").map_err(map_err)?, }) }) .collect() diff --git a/crates/moments-entities/src/lib.rs b/crates/moments-entities/src/lib.rs index ba51304..26e010c 100644 --- a/crates/moments-entities/src/lib.rs +++ b/crates/moments-entities/src/lib.rs @@ -138,12 +138,21 @@ 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). +/// +/// Each period also carries at most one row with `private = true`, holding +/// the count of that period's activity in repos the timeline won't name. +/// Without it a week spent mostly in private repos reads as near-idle here +/// while the contribution graph above shows it as busy. `source` and `repo` +/// are `None` on that row: the per-period total is already derivable from +/// `/activity/daily` (which counts private activity), but a forge-level or +/// per-repo breakdown would not be. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RepoPeriodCount { pub period: chrono::NaiveDate, - pub source: Source, - pub repo: String, + pub source: Option, + pub repo: Option, pub count: i64, + pub private: bool, } /// Average events per day at a given hour of the day, computed in a diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 709dd34..b216130 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -76,9 +76,18 @@ 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; + /** null on the private aggregate row. */ + source: Source | null; + /** null on the private aggregate row. */ + repo: string | null; count: number; + /** + * True on the at-most-one row per period that aggregates activity in repos + * the timeline won't name, so a period spent mostly in private repos still + * reports its real size. Unattributed by design — the per-period total is + * already derivable from /activity/daily, a forge or repo split would not be. + */ + private: boolean; } export interface HourlyAvg { diff --git a/ui/src/components/SummaryEntry.tsx b/ui/src/components/SummaryEntry.tsx index 21dbed9..34444d8 100644 --- a/ui/src/components/SummaryEntry.tsx +++ b/ui/src/components/SummaryEntry.tsx @@ -1,12 +1,15 @@ import { Fragment } from 'react'; import { Link } from 'react-router-dom'; -import { Calendar3 } from 'react-bootstrap-icons'; +import { Calendar3, LockFill } from 'react-bootstrap-icons'; import { VerticalTimelineElement } from 'react-vertical-timeline-component'; -import type { RepoPeriodCount, SummaryBucket } from '../api/client'; +import type { RepoPeriodCount, Source, SummaryBucket } from '../api/client'; import { LanguageBar } from './LanguageBar'; import { forgeIcon } from '../lib/forge'; import { fmtDate } from '../lib/ranges'; +/** A summary row that names its repo — everything except the private lump. */ +type NamedPeriodCount = RepoPeriodCount & { source: Source; repo: string }; + interface Props { /** UTC start date of the period (YYYY-MM-DD). */ period: string; @@ -22,6 +25,12 @@ interface Props { * mix and how many changes landed in it, with a drill-down link to the * per-event view. */ export function SummaryEntry({ period, bucket, repos, langsByRepo, colorMap }: Props) { + // The private row is one lump covering an unknown number of repos, so it + // counts towards the period's changes but never towards its repo count. + // Narrowing here is what lets the rest of the render treat source/repo as + // present without asserting it. + const named = repos.filter((r): r is NamedPeriodCount => !r.private && !!r.repo && !!r.source); + const privateCount = repos.reduce((sum, r) => (r.private ? sum + r.count : sum), 0); const total = repos.reduce((sum, r) => sum + r.count, 0); const end = periodEnd(period, bucket); const timespan = bucket === 'day' ? period : `${period}..${end}`; @@ -33,8 +42,7 @@ export function SummaryEntry({ period, bucket, repos, langsByRepo, colorMap }: P icon={} >

- {total} {total === 1 ? 'change' : 'changes'} in {repos.length}{' '} - {repos.length === 1 ? 'repository' : 'repositories'} + {total} {total === 1 ? 'change' : 'changes'} in {summaryScope(named.length, privateCount)}

- {repos.map((r) => { + {named.map((r) => { const icon = forgeIcon(r.source, /* onLight */ true); const langs = langsByRepo.get(`${r.source}:${r.repo}`); return ( @@ -67,6 +75,19 @@ export function SummaryEntry({ period, bucket, repos, langsByRepo, colorMap }: P ); })} + {privateCount > 0 && ( + // No link and no language bar: a drill-down has nothing to show, + // and a language mix would narrow the lump back down to the repo. + + + + + + {privateCount} + + + )}
view activity → @@ -75,6 +96,20 @@ export function SummaryEntry({ period, bucket, repos, langsByRepo, colorMap }: P ); } +/** What the period's changes landed in, for the card headline. Private work + * is named but never counted as repositories — the aggregate covers an + * unknown number of them. + * + * `+` rather than `and`: the headline count is the sum of the rows below, and + * "8 changes in 1 repository and private work" misreads as the named repo + * holding all 8. A summand can't attach itself to "1 repository". */ +function summaryScope(namedRepos: number, privateCount: number): string { + const repos = `${namedRepos} ${namedRepos === 1 ? 'repository' : 'repositories'}`; + if (privateCount === 0) return repos; + if (namedRepos === 0) return 'private repositories'; + return `${repos} + private work`; +} + /** 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');