feat: count private-repo work in the activity summary
All checks were successful
deploy / Build prerendered web (push) Successful in 4m32s
deploy / Deploy web to oolon (push) Successful in 21s
deploy / Build api + worker (static musl) (push) Successful in 5m32s
deploy / Deploy moments-worker to frootmig (push) Successful in 17s
deploy / Deploy moments-api to nikola (push) Successful in 19s
refresh / Rebuild prerendered web (push) Successful in 4m12s
refresh / Deploy refreshed web to oolon (push) Successful in 36s

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 #7
This commit is contained in:
2026-08-15 19:57:54 +03:00
parent 815bfa7deb
commit 12e9d4097f
5 changed files with 109 additions and 20 deletions

View File

@@ -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 {

View File

@@ -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={<Calendar3 />}
>
<h4 className="vertical-timeline-element-title">
{total} {total === 1 ? 'change' : 'changes'} in {repos.length}{' '}
{repos.length === 1 ? 'repository' : 'repositories'}
{total} {total === 1 ? 'change' : 'changes'} in {summaryScope(named.length, privateCount)}
</h4>
<div
style={{
@@ -45,7 +53,7 @@ export function SummaryEntry({ period, bucket, repos, langsByRepo, colorMap }: P
marginBottom: '0.5rem',
}}
>
{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
</Fragment>
);
})}
{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.
<Fragment key="private">
<span className="text-muted">
<LockFill className="forge-icon" aria-hidden="true" /> private
</span>
<span />
<span className="text-muted" style={{ textAlign: 'right' }}>
{privateCount}
</span>
</Fragment>
)}
</div>
<Link to={`/activity/${timespan}`} style={{ fontSize: '85%' }}>
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');