From 12e9d4097fb53b50413edcd5fd8e4e4e18593554 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Sat, 15 Aug 2026 19:57:54 +0300 Subject: [PATCH] feat: count private-repo work in the activity summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary cards only ever counted public activity, so a period spent mostly or entirely in private repos read as near-idle — or vanished altogether — while the contribution graph directly above it showed that period as busy. The two views disagreed with no explanation on the page. `activity/summary` now emits, alongside the named per-repo rows, at most one row per period with `private = true` and a null source/repo, holding that period's private-repo change count. `source` and `repo` on `RepoPeriodCount` become nullable to carry it. The card counts it towards the period's changes but never towards its repository count (the lump covers an unknown number of repos), renders it last, unlinked, and without a language bar — a language mix would narrow the lump back down to the repo it was hiding. Not split by forge: the per-period total is already derivable from `activity/daily`, which counts private activity, so publishing it adds no information that isn't on the contribution graph already. A per-forge breakdown would be new. `?source=` does narrow the aggregate, which makes per-forge counts recoverable by diffing two requests — chosen knowingly, because a summary that contradicts the filter it was given is worse than a forge attribution on an unattributed count. `include_private` keeps its meaning: with it set, the named branch takes everything and the aggregate is empty, so a future authenticated view sees repos rather than a lump. Verified against postgres 16 with seeded public/private history: the summary now reconciles with `activity/daily` per period (3 public + 7 private = the graph's 10), a private-only period appears at all where it previously did not, no aggregate row is emitted for a period with nothing private, and the row sorts last within its period. Prerendered `/activity` against that data renders "8 changes in 1 repository + private work" with a muted `private 6` row, "4 changes in private repositories" for the private-only day, and contains zero occurrences of the private repo's name in either the markup or the dehydrated query cache. Closes https://git.lair.cafe/grenade/moments/issues/7 --- CLAUDE.md | 8 +++++ crates/moments-data/src/lib.rs | 50 +++++++++++++++++++++++------- crates/moments-entities/src/lib.rs | 13 ++++++-- ui/src/api/client.ts | 13 ++++++-- ui/src/components/SummaryEntry.tsx | 45 ++++++++++++++++++++++++--- 5 files changed, 109 insertions(+), 20 deletions(-) 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');