Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions web/src/components/CatalogCharts.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<script>
import { formatCount } from "../lib/format.js";
import {
catalogTrafficTotals,
dailyCatalogPulse,
pulseBars,
quietStarsRank,
rankTraffic,
trafficRepos,
uniqueVisitorsByLanguage,
} from "../lib/trafficCharts.js";
import RankChart from "./RankChart.svelte";

export let repos = [];

$: tracked = trafficRepos(repos);
$: totals = catalogTrafficTotals(repos);
$: visitors = rankTraffic(repos, "view_uniques", 10);
$: cloners = rankTraffic(repos, "clone_uniques", 10);
$: pulse = pulseBars(dailyCatalogPulse(repos));
$: languages = uniqueVisitorsByLanguage(repos);
$: quiet = quietStarsRank(repos).map((row) => ({
...row,
value: Math.round(row.score * 100) / 100,
}));
</script>

{#if tracked.length === 0}
<div class="empty">
<strong>No traffic snapshots in this catalog yet.</strong>
Charts fill in after traffic jobs run for repositories you can push to.
</div>
{:else}
<div class="catalog-charts">
<div class="chart-totals">
<div class="traffic-stat">
<span>Unique visitors</span>
<b>{formatCount(totals.viewUniques)}</b>
<small>{formatCount(totals.tracked)} of {formatCount(totals.loaded)} repos</small>
</div>
<div class="traffic-stat">
<span>Unique cloners</span>
<b>{formatCount(totals.cloneUniques)}</b>
<small>Summed GitHub windows</small>
</div>
<div class="traffic-stat">
<span>Views</span>
<b>{formatCount(totals.views)}</b>
<small>Last 14 days</small>
</div>
<div class="traffic-stat">
<span>Clones</span>
<b>{formatCount(totals.clones)}</b>
<small>Last 14 days</small>
</div>
</div>

<RankChart title="Top unique visitors" headingId="chart-visitors" caption="GitHub unique visitors in the current window." rows={visitors} />
<RankChart title="Top unique cloners" headingId="chart-cloners" caption="GitHub unique cloners in the current window." rows={cloners} tone="clones" />

{#if pulse.length}
<section class="chart-panel chart-panel-wide" aria-labelledby="chart-pulse">
<header class="chart-head">
<h3 id="chart-pulse">Last 14 days</h3>
<p>Unique visitors and cloners summed across this catalog.</p>
</header>
<div class="pulse-chart" aria-hidden="true">
{#each pulse as day}
<div class="pulse-col">
<div class="pulse-pair">
<div class="traffic-bar" style={`height:${day.viewHeight}`}></div>
<div class="traffic-bar traffic-bar-clone" style={`height:${day.cloneHeight}`}></div>
</div>
<span>{day.label}</span>
</div>
{/each}
</div>
<p class="chart-legend">
<span class="chart-swatch"></span> Unique visitors
<span class="chart-swatch is-clone"></span> Unique cloners
</p>
</section>
{/if}

<RankChart title="Unique visitors by language" headingId="chart-languages" caption="Where unique visitors land in this catalog." rows={languages} nameKey="language" />
<RankChart title="Quiet stars" headingId="chart-quiet" caption="Unique visitors per GitHub star." rows={quiet} />
</div>
{/if}
60 changes: 60 additions & 0 deletions web/src/components/CatalogCharts.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { render, screen, within } from "@testing-library/svelte";
import { describe, expect, test } from "vitest";
import CatalogCharts from "./CatalogCharts.svelte";

const repos = [
{
name: "cais",
language: "Go",
stargazers_count: 2,
traffic: {
available: true,
views: 80,
view_uniques: 70,
clones: 5,
clone_uniques: 4,
views_by_day: [{ timestamp: "2026-08-27T00:00:00Z", count: 10, uniques: 8 }],
clones_by_day: [{ timestamp: "2026-08-27T00:00:00Z", count: 2, uniques: 1 }],
},
},
{
name: "atlas",
language: "Python",
stargazers_count: 40,
traffic: {
available: true,
views: 20,
view_uniques: 12,
clones: 90,
clone_uniques: 60,
},
},
{ name: "silent", language: "Rust", stargazers_count: 1 },
];

describe("CatalogCharts", () => {
test("explains when the catalog has no traffic snapshots", () => {
render(CatalogCharts, { props: { repos: [{ name: "silent" }] } });
expect(screen.getByText(/No traffic snapshots/)).toBeInTheDocument();
});

test("shows top unique visitors and unique cloners", () => {
render(CatalogCharts, { props: { repos } });
const visitors = screen.getByRole("region", { name: "Top unique visitors" });
expect(within(visitors).getByText("cais")).toBeInTheDocument();
expect(within(visitors).getByText("70")).toBeInTheDocument();
const cloners = screen.getByRole("region", { name: "Top unique cloners" });
expect(within(cloners).getByText("atlas")).toBeInTheDocument();
expect(within(cloners).getByText("60")).toBeInTheDocument();
});

test("shows catalog totals, language mix, pulse, and quiet stars", () => {
render(CatalogCharts, { props: { repos } });
expect(screen.getByText("82")).toBeInTheDocument();
expect(screen.getByText("64")).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Unique visitors by language" })).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Last 14 days" })).toBeInTheDocument();
const quiet = screen.getByRole("region", { name: "Quiet stars" });
expect(within(quiet).getByText("cais")).toBeInTheDocument();
});
});
32 changes: 32 additions & 0 deletions web/src/components/RankChart.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script>
import { formatCount } from "../lib/format.js";

export let title = "";
export let caption = "";
export let headingId = "chart-rank";
export let rows = [];
export let tone = "views";
export let nameKey = "name";
</script>

<section class="chart-panel" aria-labelledby={headingId}>
<header class="chart-head">
<h3 id={headingId}>{title}</h3>
{#if caption}<p>{caption}</p>{/if}
</header>
{#if rows.length}
<ol class="rank-list">
{#each rows as row}
<li class="rank-row">
<span class="rank-name">{row[nameKey]}</span>
<div class="rank-track" aria-hidden="true">
<i class="rank-fill" class:is-clone={tone === "clones"} style={`--bar:${row.share}%`}></i>
</div>
<b class="rank-value">{formatCount(row.value)}</b>
</li>
{/each}
</ol>
{:else}
<p class="chart-empty">Nothing with traffic in this slice.</p>
{/if}
</section>
10 changes: 6 additions & 4 deletions web/src/components/RepoCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@
<span class="card-counts">
<span class="stars" title={`${formatCount(repo.stargazers_count)} stars`}>★ {formatCount(repo.stargazers_count)}</span>
<span class="forks" title={`${formatCount(repo.forks_count)} forks`}>{@html FORK_ICON} {formatCount(repo.forks_count)}</span>
{#if traffic.available}
<span class="views" title={`${formatCount(traffic.views)} views · ${formatCount(traffic.view_uniques)} unique`}>↗ {formatCount(traffic.views)}</span>
<span class="clones-metric" title={`${formatCount(traffic.clones)} clones · ${formatCount(traffic.clone_uniques)} unique`}>↓ {formatCount(traffic.clones)}</span>
{/if}
</span>
</div>
{#if traffic.available}
<div class="card-traffic">
<span class="views" title={`${formatCount(traffic.views)} views · ${formatCount(traffic.view_uniques)} unique`}>↗ {formatCount(traffic.views)} <span class="metric-uniques">· {formatCount(traffic.view_uniques)} unique</span></span>
<span class="clones-metric" title={`${formatCount(traffic.clones)} clones · ${formatCount(traffic.clone_uniques)} unique`}>↓ {formatCount(traffic.clones)} <span class="metric-uniques">· {formatCount(traffic.clone_uniques)} unique</span></span>
</div>
{/if}
<h2><a href={repo.html_url} target="_blank" rel="noopener noreferrer">{repo.name}</a></h2>
{#if repo.fork || repo.archived || repo.private}
<div class="badges">
Expand Down
6 changes: 6 additions & 0 deletions web/src/components/RepoCard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ describe("RepoCard", () => {
expect(within(dialog).getByText("342")).toBeInTheDocument();
});

test("shows unique views and unique clones on the card", () => {
render(RepoCard, { props: { repo, index: 0 } });
expect(screen.getByTitle("17 views · 3 unique")).toHaveTextContent("3 unique");
expect(screen.getByTitle("342 clones · 104 unique")).toHaveTextContent("104 unique");
});

test("shows a Private badge on private repositories", () => {
render(RepoCard, { props: { repo: { ...repo, private: true, traffic: {} }, index: 0 } });
expect(screen.getByText("Private")).toBeInTheDocument();
Expand Down
122 changes: 122 additions & 0 deletions web/src/lib/trafficCharts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
export function trafficRepos(repos) {
return (repos || []).filter((repo) => repo.traffic?.available);
}

function withShare(rows, valueOf) {
const peak = Math.max(1, ...rows.map(valueOf));
return rows.map((row) => ({ ...row, share: Math.round((valueOf(row) / peak) * 100) }));
}

export function rankTraffic(repos, key, limit = 10) {
const rows = trafficRepos(repos)
.map((repo) => ({
name: repo.name,
language: repo.language || "Not specified",
stars: repo.stargazers_count || 0,
value: Number(repo.traffic?.[key]) || 0,
}))
.filter((row) => row.value > 0)
.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name))
.slice(0, limit);
return withShare(rows, (row) => row.value);
}

function sumField(repos, key) {
return trafficRepos(repos).reduce((total, repo) => total + (Number(repo.traffic?.[key]) || 0), 0);
}

export function catalogTrafficTotals(repos) {
return {
tracked: trafficRepos(repos).length,
loaded: (repos || []).length,
views: sumField(repos, "views"),
viewUniques: sumField(repos, "view_uniques"),
clones: sumField(repos, "clones"),
cloneUniques: sumField(repos, "clone_uniques"),
};
}

function emptyDay(date) {
return { date, views: 0, viewUniques: 0, clones: 0, cloneUniques: 0 };
}

function addDayPoints(byDay, points, countKey, uniqueKey) {
for (const point of points || []) {
const date = (point.timestamp || "").slice(0, 10);
if (!date) continue;
const row = byDay.get(date) || emptyDay(date);
row[countKey] += point.count || 0;
row[uniqueKey] += point.uniques || 0;
byDay.set(date, row);
}
}

function fillDaySpan(rows) {
if (rows.length < 2) return rows;
const byDate = new Map(rows.map((row) => [row.date, row]));
const start = Date.parse(`${rows[0].date}T00:00:00Z`);
const end = Date.parse(`${rows[rows.length - 1].date}T00:00:00Z`);
const out = [];
for (let ts = start; ts <= end; ts += 864e5) {
const date = new Date(ts).toISOString().slice(0, 10);
out.push(byDate.get(date) || emptyDay(date));
}
return out;
}

export function dailyCatalogPulse(repos) {
const byDay = new Map();
for (const repo of trafficRepos(repos)) {
addDayPoints(byDay, repo.traffic.views_by_day, "views", "viewUniques");
addDayPoints(byDay, repo.traffic.clones_by_day, "clones", "cloneUniques");
}
const rows = [...byDay.values()].sort((a, b) => a.date.localeCompare(b.date));
return fillDaySpan(rows);
}

function pulseHeight(value, peak) {
if (value <= 0) return "0%";
return `${Math.max(6, Math.round((value / peak) * 100))}%`;
}

export function pulseBars(days) {
const peak = Math.max(1, ...(days || []).flatMap((day) => [day.viewUniques, day.cloneUniques]));
return (days || []).map((day) => {
const utc = new Date(`${day.date}T00:00:00Z`);
return {
...day,
label: utc.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }),
viewHeight: pulseHeight(day.viewUniques, peak),
cloneHeight: pulseHeight(day.cloneUniques, peak),
};
});
}

export function uniqueVisitorsByLanguage(repos, limit = 8) {
const totals = new Map();
for (const repo of trafficRepos(repos)) {
const language = repo.language || "Not specified";
totals.set(language, (totals.get(language) || 0) + (repo.traffic.view_uniques || 0));
}
const ranked = [...totals.entries()]
.map(([language, value]) => ({ language, value }))
.filter((row) => row.value > 0)
.sort((a, b) => b.value - a.value);
const head = ranked.slice(0, limit);
const rest = ranked.slice(limit).reduce((total, row) => total + row.value, 0);
if (rest > 0) head.push({ language: "Other", value: rest });
return withShare(head, (row) => row.value);
}

export function quietStarsRank(repos, limit = 10) {
const rows = trafficRepos(repos)
.map((repo) => {
const uniques = repo.traffic.view_uniques || 0;
const stars = repo.stargazers_count || 0;
return { name: repo.name, uniques, stars, score: uniques / Math.max(stars, 1) };
})
.filter((row) => row.uniques > 0)
.sort((a, b) => b.score - a.score || b.uniques - a.uniques)
.slice(0, limit);
return withShare(rows, (row) => row.score);
}
Loading
Loading