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

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:
2026-08-02 11:04:15 +03:00
parent 9905cef1d2
commit 957770257c
9 changed files with 415 additions and 37 deletions

View File

@@ -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<DailyC
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[]> {
const qs = new URLSearchParams({ from, to, tz });
const resp = await fetch(`${API_BASE}/activity/hourly?${qs}`);

View File

@@ -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({
);
})}
</Col>
<Col md={6}>
<label style={{ fontSize: '70%' }}>
number of activities to display: {limit}
</label>
<Slider
value={limit}
min={10}
max={1000}
onChange={(v) => onLimitChange(Array.isArray(v) ? v[0] : v)}
/>
</Col>
{showLimit && (
<Col md={6}>
<label style={{ fontSize: '70%' }}>
number of activities to display: {limit}
</label>
<Slider
value={limit}
min={10}
max={1000}
onChange={(v) => onLimitChange(Array.isArray(v) ? v[0] : v)}
/>
</Col>
)}
</Row>
<Row className="mb-3">
<Col>

View 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());
}
}

View File

@@ -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<Record<Source, boolean>>({
github: true,
@@ -56,6 +77,15 @@ export function TimelineHome() {
return [from, to];
});
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({
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<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({
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 (
<>
<Filters
@@ -113,21 +166,51 @@ export function TimelineHome() {
limit={limit}
onLimitChange={setLimit}
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>
<Col>
<p className="text-center" style={{ fontSize: '85%' }}>
{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` : ''}`}
</p>
<VerticalTimeline>
{events.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
{summaryMode
? periods.map(([period, repos]) => (
<SummaryEntry
key={period}
period={period}
bucket={bucket}
repos={repos}
/>
))
: events.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
</VerticalTimeline>
</Col>
</Row>

View File

@@ -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<void> {
@@ -72,25 +72,17 @@ async function prefetchDash(qc: QueryClient): Promise<void> {
}
async function prefetchActivity(qc: QueryClient): Promise<void> {
// 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),
}),
]);
}