e30e75b5d4
Code Quality / Oxlint + Oxfmt (push) Waiting to run
Code Quality / Template Sync (push) Waiting to run
Code Quality / Build Changed Packages (push) Waiting to run
Code Quality / Test Changed Packages (push) Waiting to run
Deploy Expo Example / Deploy Production (push) Waiting to run
Deploy Ink Example / Deploy Production (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.12) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Waiting to run
Deploy Shadcn Registry / Deploy Production (push) Waiting to run
Template Metrics / LOC + Bundle Size (push) Waiting to run
Changesets / Create Version PR (push) Has been cancelled
819 lines
22 KiB
TypeScript
819 lines
22 KiB
TypeScript
import {
|
|
type GitHubContributor,
|
|
getCommitActivityStats,
|
|
getCommitCoAuthors,
|
|
getCommitsSince,
|
|
getContributors,
|
|
getReleases,
|
|
getStargazersPage,
|
|
getUser,
|
|
getUserById,
|
|
} from "./github";
|
|
import { getDownloadsRange } from "./npm";
|
|
|
|
export type PackageInfo = {
|
|
name: string;
|
|
description: string;
|
|
category: PackageCategory;
|
|
deprecated?: boolean;
|
|
};
|
|
|
|
export type PackageCategory =
|
|
| "core"
|
|
| "tooling"
|
|
| "cloud"
|
|
| "frameworks"
|
|
| "protocols"
|
|
| "platforms"
|
|
| "ui"
|
|
| "effects"
|
|
| "mcp"
|
|
| "observability"
|
|
| "deprecated";
|
|
|
|
export const PACKAGE_CATEGORIES: Record<
|
|
PackageCategory,
|
|
{ label: string; description: string }
|
|
> = {
|
|
core: {
|
|
label: "Core",
|
|
description: "Runtime primitives every distribution shares.",
|
|
},
|
|
tooling: {
|
|
label: "Tooling & CLI",
|
|
description: "Scaffolding and build tooling.",
|
|
},
|
|
cloud: {
|
|
label: "Cloud & streaming",
|
|
description: "Hosted persistence and stream transports.",
|
|
},
|
|
frameworks: {
|
|
label: "Framework adapters",
|
|
description: "Drop-in bindings for popular AI SDKs.",
|
|
},
|
|
protocols: {
|
|
label: "Protocol adapters",
|
|
description: "Open agent protocols, ready to wire up.",
|
|
},
|
|
platforms: {
|
|
label: "Platform bindings",
|
|
description: "Run anywhere React runs.",
|
|
},
|
|
ui: {
|
|
label: "UI & rendering",
|
|
description: "Markdown, rich composers, and devtools.",
|
|
},
|
|
effects: {
|
|
label: "Effects & primitives",
|
|
description: "Standalone packages we built along the way.",
|
|
},
|
|
mcp: {
|
|
label: "MCP & agents",
|
|
description: "Tooling for MCP hosts and agent runtimes.",
|
|
},
|
|
observability: {
|
|
label: "Observability",
|
|
description: "Trace, debug, and measure assistants.",
|
|
},
|
|
deprecated: {
|
|
label: "Deprecated",
|
|
description: "No longer maintained. Kept on npm for existing installs.",
|
|
},
|
|
};
|
|
|
|
export const PACKAGES: PackageInfo[] = [
|
|
{
|
|
name: "@assistant-ui/react",
|
|
description: "TypeScript/React library for AI chat.",
|
|
category: "core",
|
|
},
|
|
{
|
|
name: "@assistant-ui/core",
|
|
description: "Framework-agnostic core runtime.",
|
|
category: "core",
|
|
},
|
|
{
|
|
name: "@assistant-ui/store",
|
|
description: "Tap-based state management.",
|
|
category: "core",
|
|
},
|
|
{
|
|
name: "@assistant-ui/tap",
|
|
description: "Zero-dependency reactive primitives.",
|
|
category: "core",
|
|
},
|
|
{
|
|
name: "assistant-ui",
|
|
description: "CLI for assistant-ui.",
|
|
category: "tooling",
|
|
},
|
|
{
|
|
name: "create-assistant-ui",
|
|
description: "Scaffold an assistant-ui app in one command.",
|
|
category: "tooling",
|
|
},
|
|
{
|
|
name: "@assistant-ui/x-buildutils",
|
|
description: "Shared build utilities for the monorepo.",
|
|
category: "tooling",
|
|
},
|
|
{
|
|
name: "@assistant-ui/next",
|
|
description: "Next.js plugin for the generative UI compiler.",
|
|
category: "tooling",
|
|
},
|
|
{
|
|
name: "@assistant-ui/vite",
|
|
description: "Vite plugin for the generative UI compiler.",
|
|
category: "tooling",
|
|
},
|
|
{
|
|
name: "@assistant-ui/x-generative-compiler",
|
|
description: 'Framework-agnostic "use generative" compiler.',
|
|
category: "tooling",
|
|
},
|
|
{
|
|
name: "assistant-cloud",
|
|
description: "Hosted backend for assistant-ui.",
|
|
category: "cloud",
|
|
},
|
|
{
|
|
name: "assistant-stream",
|
|
description: "Streaming utilities for AI assistants.",
|
|
category: "cloud",
|
|
},
|
|
{
|
|
name: "@assistant-ui/cloud-ai-sdk",
|
|
description: "AI SDK hooks with assistant-cloud persistence.",
|
|
category: "cloud",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-ai-sdk",
|
|
description: "Vercel AI SDK adapter.",
|
|
category: "frameworks",
|
|
},
|
|
{
|
|
name: "@assistant-ui/eve",
|
|
description: "Eve runtime adapter.",
|
|
category: "frameworks",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-langgraph",
|
|
description: "LangGraph adapter.",
|
|
category: "frameworks",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-langchain",
|
|
description: "LangChain useStream adapter.",
|
|
category: "frameworks",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-google-adk",
|
|
description: "Google ADK adapter.",
|
|
category: "frameworks",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-opencode",
|
|
description: "OpenCode runtime adapter.",
|
|
category: "frameworks",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-a2a",
|
|
description: "A2A v1.0 agent-to-agent protocol adapter.",
|
|
category: "protocols",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-ag-ui",
|
|
description: "AG-UI protocol adapter.",
|
|
category: "protocols",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-data-stream",
|
|
description: "Generic data stream adapter.",
|
|
category: "protocols",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-native",
|
|
description: "React Native bindings.",
|
|
category: "platforms",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-ink",
|
|
description: "Terminal UI bindings via Ink.",
|
|
category: "platforms",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-markdown",
|
|
description: "Streaming-aware markdown renderer.",
|
|
category: "ui",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-streamdown",
|
|
description: "Streamdown-based markdown rendering.",
|
|
category: "ui",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-syntax-highlighter",
|
|
description: "Syntax highlighting for assistant-ui.",
|
|
category: "ui",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-ink-markdown",
|
|
description: "Markdown for the terminal distribution.",
|
|
category: "ui",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-lexical",
|
|
description: "Lexical composer with @-mention support.",
|
|
category: "ui",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-hook-form",
|
|
description: "React Hook Form integration.",
|
|
category: "ui",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-devtools",
|
|
description: "Inspect runtime state in the browser.",
|
|
category: "ui",
|
|
},
|
|
{
|
|
name: "tw-glass",
|
|
description: "Tailwind v4 plugin for glass refraction effects.",
|
|
category: "effects",
|
|
},
|
|
{
|
|
name: "tw-shimmer",
|
|
description: "Tailwind v4 plugin for shimmer effects.",
|
|
category: "effects",
|
|
},
|
|
{
|
|
name: "heat-graph",
|
|
description: "Headless React components for activity heatmaps.",
|
|
category: "effects",
|
|
},
|
|
{
|
|
name: "safe-content-frame",
|
|
description: "Secure iframe rendering for untrusted content.",
|
|
category: "effects",
|
|
},
|
|
{
|
|
name: "@assistant-ui/mcp-docs-server",
|
|
description: "MCP server exposing assistant-ui docs.",
|
|
category: "mcp",
|
|
},
|
|
{
|
|
name: "@assistant-ui/agent-launcher",
|
|
description: "Launch Claude Code with bundled plugins.",
|
|
category: "mcp",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-mcp",
|
|
description: "MCP server configuration and connection primitives.",
|
|
category: "mcp",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-o11y",
|
|
description: "Observability primitives for assistants.",
|
|
category: "observability",
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-edge",
|
|
description: "Legacy edge runtime, superseded by the AI SDK adapter.",
|
|
category: "deprecated",
|
|
deprecated: true,
|
|
},
|
|
{
|
|
name: "@assistant-ui/styles",
|
|
description: "Prebuilt styles for non-Tailwind users.",
|
|
category: "deprecated",
|
|
deprecated: true,
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-trieve",
|
|
description: "Trieve search integration.",
|
|
category: "deprecated",
|
|
deprecated: true,
|
|
},
|
|
{
|
|
name: "@assistant-ui/react-playground",
|
|
description: "Standalone playground runtime.",
|
|
category: "deprecated",
|
|
deprecated: true,
|
|
},
|
|
];
|
|
|
|
export type PackageDownloads = {
|
|
weekly: number;
|
|
series: number[];
|
|
monthly: number;
|
|
prevMonthly: number;
|
|
};
|
|
|
|
export type NpmDownloads = {
|
|
totalWeekly: number;
|
|
perPackage: Record<string, PackageDownloads>;
|
|
};
|
|
|
|
export type TimelinePoint = {
|
|
date: string;
|
|
value: number;
|
|
};
|
|
|
|
export type Contributor = {
|
|
login: string;
|
|
avatarUrl: string;
|
|
htmlUrl: string;
|
|
contributions: number;
|
|
};
|
|
|
|
export type ActivityPoint = {
|
|
date: string;
|
|
count: number;
|
|
};
|
|
|
|
export async function fetchCommitActivity(
|
|
revalidate?: number,
|
|
): Promise<ActivityPoint[]> {
|
|
const stats = await getCommitActivityStats(revalidate);
|
|
if (stats && stats.length > 0) {
|
|
const points: ActivityPoint[] = [];
|
|
for (const week of stats) {
|
|
for (let i = 0; i < 7; i++) {
|
|
const ms = (week.week + i * 86_400) * 1000;
|
|
const d = new Date(ms);
|
|
const y = d.getUTCFullYear();
|
|
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
points.push({ date: `${y}-${m}-${day}`, count: week.days[i] ?? 0 });
|
|
}
|
|
}
|
|
return points;
|
|
}
|
|
|
|
const since = new Date();
|
|
since.setUTCDate(since.getUTCDate() - 365);
|
|
const items = await getCommitsSince(
|
|
since.toISOString(),
|
|
undefined,
|
|
revalidate,
|
|
);
|
|
|
|
const counts = new Map<string, number>();
|
|
for (const c of items) {
|
|
const iso = c.commit?.author?.date ?? c.commit?.committer?.date;
|
|
if (!iso) continue;
|
|
const day = iso.slice(0, 10);
|
|
counts.set(day, (counts.get(day) ?? 0) + 1);
|
|
}
|
|
return Array.from(counts.entries()).map(([date, count]) => ({ date, count }));
|
|
}
|
|
|
|
export async function fetchReleaseActivity(
|
|
revalidate?: number,
|
|
): Promise<ActivityPoint[]> {
|
|
const cutoff = Date.now() - 365 * 86_400_000;
|
|
const releases = await getReleases(undefined, revalidate);
|
|
|
|
const counts = new Map<string, number>();
|
|
for (const r of releases) {
|
|
const iso = r.published_at ?? r.created_at;
|
|
if (new Date(iso).getTime() < cutoff) continue;
|
|
const day = iso.slice(0, 10);
|
|
counts.set(day, (counts.get(day) ?? 0) + 1);
|
|
}
|
|
return Array.from(counts.entries()).map(([date, count]) => ({ date, count }));
|
|
}
|
|
|
|
const isBot = (login: string, type?: string) =>
|
|
type === "Bot" || /\[bot\]$/i.test(login);
|
|
|
|
const toContributor = (c: GitHubContributor): Contributor => ({
|
|
login: c.login,
|
|
avatarUrl: c.avatar_url,
|
|
htmlUrl: c.html_url,
|
|
contributions: c.contributions,
|
|
});
|
|
|
|
export async function fetchContributors(
|
|
revalidate?: number,
|
|
): Promise<Contributor[] | null> {
|
|
const raw = await getContributors(undefined, revalidate);
|
|
if (raw === null) return null;
|
|
return raw.filter((c) => !isBot(c.login, c.type)).map(toContributor);
|
|
}
|
|
|
|
/* Claude has no GitHub account, so it never resolves as a "Bot"; it is matched by the co-author email every model variant shares. */
|
|
const CLAUDE_CO_AUTHOR_EMAIL = "noreply@anthropic.com";
|
|
|
|
export async function fetchBotCoAuthors(
|
|
revalidate?: number,
|
|
): Promise<Contributor[]> {
|
|
const coAuthors = await getCommitCoAuthors(revalidate);
|
|
if (coAuthors === null) return [];
|
|
|
|
let claudeCount = 0;
|
|
const accounts = new Map<
|
|
string,
|
|
{ id: number | null; login: string | null; count: number }
|
|
>();
|
|
for (const author of coAuthors) {
|
|
if (author.email === CLAUDE_CO_AUTHOR_EMAIL) {
|
|
claudeCount += author.count;
|
|
continue;
|
|
}
|
|
if (author.id == null && author.login == null) continue;
|
|
const key =
|
|
author.id != null
|
|
? `id:${author.id}`
|
|
: `login:${author.login!.toLowerCase()}`;
|
|
const entry = accounts.get(key);
|
|
if (entry) entry.count += author.count;
|
|
else
|
|
accounts.set(key, {
|
|
id: author.id,
|
|
login: author.login,
|
|
count: author.count,
|
|
});
|
|
}
|
|
|
|
const resolved = await Promise.all(
|
|
Array.from(accounts.values()).map(async ({ id, login, count }) => {
|
|
const user =
|
|
id != null
|
|
? await getUserById(id, revalidate)
|
|
: await getUser(login!, revalidate);
|
|
if (!user || user.type !== "Bot") return null;
|
|
return {
|
|
login: user.login,
|
|
avatarUrl: user.avatarUrl,
|
|
htmlUrl: user.htmlUrl,
|
|
contributions: count,
|
|
} satisfies Contributor;
|
|
}),
|
|
);
|
|
|
|
// distinct app accounts can resolve to the same login (e.g. Copilot reviewer + swe-agent), so merge by login.
|
|
const byLogin = new Map<string, Contributor>();
|
|
for (const bot of resolved) {
|
|
if (!bot) continue;
|
|
const entry = byLogin.get(bot.login);
|
|
if (entry) entry.contributions += bot.contributions;
|
|
else byLogin.set(bot.login, { ...bot });
|
|
}
|
|
const result = Array.from(byLogin.values());
|
|
|
|
if (claudeCount > 0) {
|
|
const anthropic = await getUser("anthropics", revalidate);
|
|
result.push({
|
|
login: "Claude",
|
|
avatarUrl: anthropic?.avatarUrl ?? "/icons/anthropic.svg",
|
|
htmlUrl: "https://claude.com",
|
|
contributions: claudeCount,
|
|
});
|
|
}
|
|
|
|
return result.sort((a, b) => b.contributions - a.contributions);
|
|
}
|
|
|
|
const EMPTY_DOWNLOADS: PackageDownloads = {
|
|
weekly: 0,
|
|
series: [],
|
|
monthly: 0,
|
|
prevMonthly: 0,
|
|
};
|
|
|
|
const sum = (arr: number[]) => arr.reduce((acc, n) => acc + n, 0);
|
|
|
|
async function fetchPackageDownloadRange(
|
|
name: string,
|
|
revalidate?: number,
|
|
): Promise<PackageDownloads> {
|
|
const today = new Date();
|
|
const end = today.toISOString().slice(0, 10);
|
|
const start = new Date(today);
|
|
start.setUTCDate(today.getUTCDate() - 60);
|
|
const startStr = start.toISOString().slice(0, 10);
|
|
|
|
const downloads = await getDownloadsRange(name, startStr, end, revalidate);
|
|
const all = downloads.map((d) => d.downloads);
|
|
if (all.length === 0) return EMPTY_DOWNLOADS;
|
|
|
|
const last60 = all.slice(-60);
|
|
const last30 = last60.slice(-30);
|
|
const prior30 = last60.slice(-60, -30);
|
|
const last7 = last60.slice(-7);
|
|
|
|
return {
|
|
weekly: sum(last7),
|
|
series: last30,
|
|
monthly: sum(last30),
|
|
prevMonthly: sum(prior30),
|
|
};
|
|
}
|
|
|
|
export async function fetchNpmDownloads(
|
|
revalidate?: number,
|
|
): Promise<NpmDownloads> {
|
|
const entries = await Promise.all(
|
|
PACKAGES.filter((pkg) => !pkg.deprecated).map(
|
|
async (pkg) =>
|
|
[
|
|
pkg.name,
|
|
await fetchPackageDownloadRange(pkg.name, revalidate),
|
|
] as const,
|
|
),
|
|
);
|
|
const perPackage: Record<string, PackageDownloads> = {};
|
|
let totalWeekly = 0;
|
|
for (const [name, downloads] of entries) {
|
|
perPackage[name] = downloads;
|
|
totalWeekly += downloads.weekly;
|
|
}
|
|
return { totalWeekly, perPackage };
|
|
}
|
|
|
|
function currentMonthKey(): string {
|
|
return new Date().toISOString().slice(0, 7);
|
|
}
|
|
|
|
export const TIMELINE_PACKAGES = [
|
|
"@assistant-ui/react",
|
|
"@assistant-ui/react-ai-sdk",
|
|
"@assistant-ui/react-markdown",
|
|
"@assistant-ui/react-langgraph",
|
|
"assistant-stream",
|
|
] as const;
|
|
|
|
export type TimelineSeries = {
|
|
series: {
|
|
key: string;
|
|
pkg: string;
|
|
label: string;
|
|
chartIndex: number;
|
|
}[];
|
|
data: { date: string; [key: string]: number | string }[];
|
|
projectedMonth?: string;
|
|
};
|
|
|
|
export async function fetchTimelineSeries(
|
|
packages: readonly string[],
|
|
revalidate?: number,
|
|
): Promise<TimelineSeries> {
|
|
const fetched = await Promise.all(
|
|
packages.map(async (pkg, idx) => ({
|
|
key: `s${idx}`,
|
|
pkg,
|
|
label: pkg.replace(/^@assistant-ui\//, "").replace(/^assistant-/, ""),
|
|
chartIndex: (idx % 5) + 1,
|
|
points: await fetchDownloadsTimeline(pkg, revalidate),
|
|
})),
|
|
);
|
|
|
|
const monthsSet = new Set<string>();
|
|
for (const { points } of fetched) {
|
|
for (const p of points) monthsSet.add(p.date);
|
|
}
|
|
const months = Array.from(monthsSet).sort();
|
|
|
|
const cutoff = currentMonthKey();
|
|
const data = months.map((date) => {
|
|
const isProjected = date === cutoff;
|
|
const row: { date: string; [key: string]: number | string } = { date };
|
|
for (const { key, points } of fetched) {
|
|
const point = points.find((p) => p.date === date);
|
|
const value = point?.value ?? 0;
|
|
if (!isProjected) row[key] = value;
|
|
}
|
|
return row;
|
|
});
|
|
|
|
const projectedIndex = data.findIndex((r) => r.date === cutoff);
|
|
if (projectedIndex >= 1) {
|
|
for (let idx = projectedIndex - 1; idx <= projectedIndex; idx++) {
|
|
const row = data[idx]!;
|
|
const rowDate = row.date as string;
|
|
for (const { key, points } of fetched) {
|
|
const val = points.find((p) => p.date === rowDate)?.value ?? 0;
|
|
row[`${key}_proj`] = val;
|
|
}
|
|
}
|
|
}
|
|
|
|
const series = fetched.map(({ key, pkg, label, chartIndex }) => ({
|
|
key,
|
|
pkg,
|
|
label,
|
|
chartIndex,
|
|
}));
|
|
|
|
return {
|
|
series,
|
|
data,
|
|
...(projectedIndex >= 0 ? { projectedMonth: cutoff } : {}),
|
|
};
|
|
}
|
|
|
|
export async function fetchDownloadsTimeline(
|
|
name: string,
|
|
revalidate?: number,
|
|
): Promise<TimelinePoint[]> {
|
|
// npm's last-year endpoint stops at the last complete day, so it omits the
|
|
// in-flight month entirely; an explicit range through today keeps it.
|
|
const today = new Date();
|
|
const end = today.toISOString().slice(0, 10);
|
|
const start = new Date(
|
|
Date.UTC(today.getUTCFullYear() - 1, today.getUTCMonth(), 1),
|
|
)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
const downloads = await getDownloadsRange(name, start, end, revalidate);
|
|
if (!downloads.length) return [];
|
|
const cutoff = currentMonthKey();
|
|
type MonthBucket = {
|
|
sum: number;
|
|
dailies: { day: string; downloads: number }[];
|
|
};
|
|
const byMonth = new Map<string, MonthBucket>();
|
|
for (const point of downloads) {
|
|
const month = point.day.slice(0, 7);
|
|
const entry = byMonth.get(month) ?? { sum: 0, dailies: [] };
|
|
entry.sum += point.downloads;
|
|
entry.dailies.push({ day: point.day, downloads: point.downloads });
|
|
byMonth.set(month, entry);
|
|
}
|
|
const sorted = Array.from(byMonth.entries()).sort(([a], [b]) =>
|
|
a.localeCompare(b),
|
|
);
|
|
const fullMonths = sorted.filter(([k]) => k !== cutoff);
|
|
const lastFullMonth = fullMonths[fullMonths.length - 1];
|
|
const priorFullMonth = fullMonths[fullMonths.length - 2];
|
|
|
|
return sorted.map(([date, bucket]) => {
|
|
if (date !== cutoff || bucket.dailies.length === 0) {
|
|
return { date, value: bucket.sum };
|
|
}
|
|
return {
|
|
date,
|
|
value: projectInflightMonth(
|
|
date,
|
|
bucket,
|
|
lastFullMonth?.[1].sum,
|
|
priorFullMonth?.[1].sum,
|
|
),
|
|
};
|
|
});
|
|
}
|
|
|
|
function projectInflightMonth(
|
|
date: string,
|
|
bucket: { sum: number; dailies: { day: string; downloads: number }[] },
|
|
lastFullMonthSum: number | undefined,
|
|
priorFullMonthSum: number | undefined,
|
|
): number {
|
|
// npm aggregation lags 1-2 days; trailing days under-report and would drag the daily average down.
|
|
const TRAILING_LAG_DAYS = 2;
|
|
const dailies = [...bucket.dailies].sort((a, b) =>
|
|
a.day.localeCompare(b.day),
|
|
);
|
|
const stable = dailies.slice(
|
|
0,
|
|
Math.max(1, dailies.length - TRAILING_LAG_DAYS),
|
|
);
|
|
const stableSum = stable.reduce((s, d) => s + d.downloads, 0);
|
|
const stableDays = stable.length;
|
|
|
|
const totalDays = new Date(
|
|
Number(date.slice(0, 4)),
|
|
Number(date.slice(5, 7)),
|
|
0,
|
|
).getDate();
|
|
|
|
const linear = (stableSum / stableDays) * totalDays;
|
|
|
|
if (!lastFullMonthSum || !priorFullMonthSum || priorFullMonthSum === 0) {
|
|
return Math.round(linear);
|
|
}
|
|
|
|
// cap growth so an outlier month doesn't produce a runaway projection.
|
|
const growth = Math.max(
|
|
-0.3,
|
|
Math.min(1.0, lastFullMonthSum / priorFullMonthSum - 1),
|
|
);
|
|
const trend = lastFullMonthSum * (1 + growth);
|
|
// early in the month trust the trend (little data); late, trust observed.
|
|
const elapsedFraction = stableDays / totalDays;
|
|
const blended = elapsedFraction * linear + (1 - elapsedFraction) * trend;
|
|
|
|
return Math.round(blended);
|
|
}
|
|
|
|
export async function fetchStarHistory(
|
|
totalStars: number,
|
|
revalidate?: number,
|
|
): Promise<TimelinePoint[]> {
|
|
try {
|
|
const first = await getStargazersPage(1, revalidate);
|
|
if (first.data.length === 0) return [];
|
|
|
|
const lastPage = first.lastPage ?? Math.max(1, Math.ceil(totalStars / 100));
|
|
|
|
const samples = new Set<number>([1, lastPage]);
|
|
const target = 12;
|
|
if (lastPage > 2) {
|
|
const step = (lastPage - 1) / (target - 1);
|
|
for (let i = 1; i < target - 1; i++) {
|
|
samples.add(Math.max(2, Math.round(1 + i * step)));
|
|
}
|
|
}
|
|
const pages = Array.from(samples)
|
|
.filter((p) => p >= 1 && p <= lastPage)
|
|
.sort((a, b) => a - b);
|
|
|
|
const fetched = await Promise.all(
|
|
pages.map(async (page) => {
|
|
if (page === 1) return { page, data: first.data };
|
|
const result = await getStargazersPage(page, revalidate);
|
|
return { page, data: result.data };
|
|
}),
|
|
);
|
|
|
|
const anchors: TimelinePoint[] = fetched
|
|
.filter(({ data }) => data.length > 0)
|
|
.map(({ page, data }) => ({
|
|
date: data[0]!.starred_at,
|
|
value: (page - 1) * 100 + 1,
|
|
}))
|
|
.sort((a, b) => a.date.localeCompare(b.date))
|
|
.filter((p, i, arr) => i === 0 || p.value > arr[i - 1]!.value);
|
|
|
|
// page sampling stops at the first star on the last page; append (now, totalStars) so the chart reaches today.
|
|
const now = new Date().toISOString();
|
|
const lastAnchor = anchors[anchors.length - 1];
|
|
if (
|
|
!lastAnchor ||
|
|
(totalStars > lastAnchor.value && now > lastAnchor.date)
|
|
) {
|
|
anchors.push({ date: now, value: totalStars });
|
|
}
|
|
|
|
if (anchors.length < 2) return anchors;
|
|
|
|
return resampleMonthly(anchors);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// page-based sampling clusters unevenly in time; resample on a monthly grid so the chart shows real acceleration, not straight segments.
|
|
function resampleMonthly(anchors: TimelinePoint[]): TimelinePoint[] {
|
|
const startMs = new Date(anchors[0]!.date).getTime();
|
|
const endAnchor = anchors[anchors.length - 1]!;
|
|
const endMs = new Date(endAnchor.date).getTime();
|
|
|
|
const startDate = new Date(startMs);
|
|
const cursor = new Date(
|
|
Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth() + 1, 1),
|
|
);
|
|
|
|
const out: TimelinePoint[] = [
|
|
{ date: anchors[0]!.date, value: anchors[0]!.value },
|
|
];
|
|
|
|
let i = 0;
|
|
while (cursor.getTime() <= endMs) {
|
|
const t = cursor.getTime();
|
|
while (
|
|
i < anchors.length - 1 &&
|
|
new Date(anchors[i + 1]!.date).getTime() < t
|
|
) {
|
|
i++;
|
|
}
|
|
const a = anchors[i]!;
|
|
const b = anchors[Math.min(i + 1, anchors.length - 1)]!;
|
|
const aT = new Date(a.date).getTime();
|
|
const bT = new Date(b.date).getTime();
|
|
const ratio =
|
|
bT === aT ? 0 : Math.min(1, Math.max(0, (t - aT) / (bT - aT)));
|
|
const value = Math.round(a.value + (b.value - a.value) * ratio);
|
|
out.push({ date: cursor.toISOString(), value });
|
|
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
|
}
|
|
|
|
// tail point so the line reaches today instead of the latest crossed month boundary.
|
|
const tailExisting = out[out.length - 1]!;
|
|
if (
|
|
new Date(endAnchor.date).getTime() > new Date(tailExisting.date).getTime()
|
|
) {
|
|
out.push(endAnchor);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
export function daysSince(isoDate: string): number {
|
|
const start = new Date(isoDate).getTime();
|
|
const now = Date.now();
|
|
return Math.max(0, Math.floor((now - start) / 86_400_000));
|
|
}
|