Add /v1/activity/daily endpoint returning per-day event counts via generate_series + LEFT JOIN. Frontend renders an SVG contribution graph with circles colored by quantile-based thresholds. Clicking a day navigates to /activity/YYYY-MM-DD showing that day's events. New /activity/:timespan route parses single dates (YYYY-MM-DD) and ranges (YYYY-MM-DD..YYYY-MM-DD) from the URL to initialize the activity timeline filter. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
156 lines
4.5 KiB
TypeScript
156 lines
4.5 KiB
TypeScript
// Wire types mirror the moments-entities types serialised by the API.
|
|
// Hand-maintained for now; if drift becomes a problem, generate them
|
|
// from the Rust crate via ts-rs or specta.
|
|
|
|
export type Source = 'github' | 'gitea' | 'hg' | 'bugzilla';
|
|
|
|
export type TitleSegment =
|
|
| { kind: 'text'; text: string }
|
|
| { kind: 'link'; text: string; url: string };
|
|
|
|
export interface CommitSummary {
|
|
sha: string;
|
|
short_sha: string;
|
|
message: string;
|
|
url: string;
|
|
author: string | null;
|
|
}
|
|
|
|
export type TimelineBody =
|
|
| { kind: 'markdown'; text: string }
|
|
| { kind: 'commits'; commits: CommitSummary[] }
|
|
| { kind: 'links'; items: TitleSegment[] };
|
|
|
|
export type TimelineIcon =
|
|
| 'git-push'
|
|
| 'git-commit'
|
|
| 'git-merge'
|
|
| 'git-fork'
|
|
| 'git-branch-create'
|
|
| 'git-branch-delete'
|
|
| 'pull-request'
|
|
| 'issue'
|
|
| 'comment'
|
|
| 'star'
|
|
| 'release'
|
|
| 'bug'
|
|
| 'generic';
|
|
|
|
export interface TimelineItem {
|
|
id: string;
|
|
source: Source;
|
|
action: string;
|
|
occurred_at: string;
|
|
icon: TimelineIcon;
|
|
title: TitleSegment[];
|
|
subtitle: TitleSegment[] | null;
|
|
body: TimelineBody | null;
|
|
}
|
|
|
|
export interface SourceSummary {
|
|
source: Source;
|
|
count: number;
|
|
earliest: string | null;
|
|
latest: string | null;
|
|
}
|
|
|
|
export interface ProjectSummary {
|
|
repo: string;
|
|
source: Source;
|
|
host: string;
|
|
commit_count: number;
|
|
issue_count: number;
|
|
pr_count: number;
|
|
first_activity: string | null;
|
|
last_activity: string | null;
|
|
}
|
|
|
|
export interface DailyCount {
|
|
date: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface EventQuery {
|
|
from?: Date;
|
|
to?: Date;
|
|
sources?: Source[];
|
|
repo?: string;
|
|
limit?: number;
|
|
}
|
|
|
|
const API_BASE = '/api/v1';
|
|
|
|
/** Decode base64 content as UTF-8 (atob only handles Latin-1). */
|
|
function decodeBase64Utf8(b64: string): string {
|
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
return new TextDecoder().decode(bytes);
|
|
}
|
|
|
|
export async function fetchEvents(q: EventQuery): Promise<TimelineItem[]> {
|
|
const params = new URLSearchParams();
|
|
if (q.from) params.set('from', q.from.toISOString());
|
|
if (q.to) params.set('to', q.to.toISOString());
|
|
if (q.sources && q.sources.length > 0) {
|
|
params.set('source', q.sources.join(','));
|
|
}
|
|
if (q.repo) params.set('repo', q.repo);
|
|
if (q.limit) params.set('limit', String(q.limit));
|
|
|
|
const resp = await fetch(`${API_BASE}/events?${params}`);
|
|
if (!resp.ok) throw new Error(`events: HTTP ${resp.status}`);
|
|
return resp.json();
|
|
}
|
|
|
|
export async function fetchSources(): Promise<SourceSummary[]> {
|
|
const resp = await fetch(`${API_BASE}/sources`);
|
|
if (!resp.ok) throw new Error(`sources: HTTP ${resp.status}`);
|
|
return resp.json();
|
|
}
|
|
|
|
export async function fetchDailyCounts(from: string, to: string): Promise<DailyCount[]> {
|
|
const resp = await fetch(`${API_BASE}/activity/daily?from=${from}&to=${to}`);
|
|
if (!resp.ok) throw new Error(`daily-counts: HTTP ${resp.status}`);
|
|
return resp.json();
|
|
}
|
|
|
|
export async function fetchProjects(): Promise<ProjectSummary[]> {
|
|
const resp = await fetch(`${API_BASE}/projects`);
|
|
if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`);
|
|
return resp.json();
|
|
}
|
|
|
|
/** Fetch repo README as raw markdown via the forge proxy. */
|
|
export async function fetchReadme(source: Source, host: string, repo: string): Promise<string | null> {
|
|
if (source === 'github') {
|
|
const resp = await fetch(`${API_BASE}/forge/github/repos/${repo}/readme`);
|
|
if (!resp.ok) return null;
|
|
const data = await resp.json();
|
|
if (data.encoding === 'base64' && data.content) {
|
|
return decodeBase64Utf8(data.content);
|
|
}
|
|
return data.content ?? null;
|
|
}
|
|
if (source === 'gitea') {
|
|
for (const name of ['README.md', 'readme.md', 'Readme.md']) {
|
|
const resp = await fetch(`${API_BASE}/forge/gitea/repos/${repo}/contents/${name}?host=${encodeURIComponent(host)}`);
|
|
if (!resp.ok) continue;
|
|
const data = await resp.json();
|
|
if (data.encoding === 'base64' && data.content) {
|
|
return decodeBase64Utf8(data.content);
|
|
}
|
|
if (data.content) return data.content;
|
|
}
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Fetch repo languages as { language: bytes } map via the forge proxy. */
|
|
export async function fetchLanguages(source: Source, host: string, repo: string): Promise<Record<string, number> | null> {
|
|
if (source !== 'github' && source !== 'gitea') return null;
|
|
const hostParam = source === 'gitea' ? `?host=${encodeURIComponent(host)}` : '';
|
|
const resp = await fetch(`${API_BASE}/forge/${source}/repos/${repo}/languages${hostParam}`);
|
|
if (!resp.ok) return null;
|
|
return resp.json();
|
|
}
|