Files
moments/ui/src/api/client.ts
rob thijssen 27ce16e630 feat(ui): contribution graph with daily activity heatmap
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>
2026-05-05 17:05:28 +03:00

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