diff --git a/web/src/components/CatalogCharts.svelte b/web/src/components/CatalogCharts.svelte
new file mode 100644
index 0000000..9c069ae
--- /dev/null
+++ b/web/src/components/CatalogCharts.svelte
@@ -0,0 +1,88 @@
+
+
+{#if tracked.length === 0}
+
diff --git a/web/src/components/RepoCard.test.js b/web/src/components/RepoCard.test.js
index 26e1f8c..8674728 100644
--- a/web/src/components/RepoCard.test.js
+++ b/web/src/components/RepoCard.test.js
@@ -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();
diff --git a/web/src/lib/trafficCharts.js b/web/src/lib/trafficCharts.js
new file mode 100644
index 0000000..e830efe
--- /dev/null
+++ b/web/src/lib/trafficCharts.js
@@ -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);
+}
diff --git a/web/src/lib/trafficCharts.test.js b/web/src/lib/trafficCharts.test.js
new file mode 100644
index 0000000..9ae8b60
--- /dev/null
+++ b/web/src/lib/trafficCharts.test.js
@@ -0,0 +1,140 @@
+import { describe, expect, test } from "vitest";
+import {
+ catalogTrafficTotals,
+ dailyCatalogPulse,
+ pulseBars,
+ quietStarsRank,
+ rankTraffic,
+ trafficRepos,
+ uniqueVisitorsByLanguage,
+} from "./trafficCharts.js";
+
+const catalog = [
+ {
+ name: "alpha",
+ language: "Go",
+ stargazers_count: 100,
+ traffic: {
+ available: true,
+ views: 50,
+ view_uniques: 40,
+ clones: 10,
+ clone_uniques: 8,
+ views_by_day: [{ timestamp: "2026-08-27T00:00:00Z", count: 7, uniques: 3 }],
+ clones_by_day: [{ timestamp: "2026-08-27T00:00:00Z", count: 2, uniques: 1 }],
+ },
+ },
+ {
+ name: "beta",
+ 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-28T00:00:00Z", count: 5, uniques: 4 }],
+ },
+ },
+ {
+ name: "gamma",
+ language: "Python",
+ stargazers_count: 10,
+ traffic: {
+ available: true,
+ views: 20,
+ view_uniques: 5,
+ clones: 90,
+ clone_uniques: 60,
+ },
+ },
+ { name: "delta", language: "Rust", stargazers_count: 1 },
+];
+
+describe("trafficRepos", () => {
+ test("keeps only repositories with a traffic snapshot", () => {
+ expect(trafficRepos(catalog).map((repo) => repo.name)).toEqual(["alpha", "beta", "gamma"]);
+ });
+});
+
+describe("rankTraffic", () => {
+ test("ranks top unique visitors and unique cloners", () => {
+ expect(rankTraffic(catalog, "view_uniques", 10).map((row) => row.name)).toEqual(["beta", "alpha", "gamma"]);
+ expect(rankTraffic(catalog, "clone_uniques", 2).map((row) => [row.name, row.value])).toEqual([
+ ["gamma", 60],
+ ["alpha", 8],
+ ]);
+ });
+
+ test("scales bar share against the peak value", () => {
+ const [lead, second] = rankTraffic(catalog, "view_uniques", 10);
+ expect(lead.share).toBe(100);
+ expect(second.share).toBe(57);
+ });
+});
+
+describe("catalogTrafficTotals", () => {
+ test("sums GitHub windows across tracked repositories", () => {
+ expect(catalogTrafficTotals(catalog)).toEqual({
+ tracked: 3,
+ loaded: 4,
+ views: 150,
+ viewUniques: 115,
+ clones: 105,
+ cloneUniques: 72,
+ });
+ });
+});
+
+describe("dailyCatalogPulse", () => {
+ test("merges daily unique views and clones by date", () => {
+ expect(dailyCatalogPulse(catalog)).toEqual([
+ { date: "2026-08-27", views: 17, viewUniques: 11, clones: 2, cloneUniques: 1 },
+ { date: "2026-08-28", views: 0, viewUniques: 0, clones: 5, cloneUniques: 4 },
+ ]);
+ });
+
+ test("fills missing dates between the first and last day", () => {
+ const sparse = [
+ {
+ name: "alpha",
+ traffic: {
+ available: true,
+ views_by_day: [
+ { timestamp: "2026-08-27T00:00:00Z", count: 1, uniques: 1 },
+ { timestamp: "2026-08-29T00:00:00Z", count: 2, uniques: 2 },
+ ],
+ },
+ },
+ ];
+ expect(dailyCatalogPulse(sparse).map((day) => day.date)).toEqual(["2026-08-27", "2026-08-28", "2026-08-29"]);
+ });
+});
+
+describe("pulseBars", () => {
+ test("labels UTC days and scales both series to the same peak", () => {
+ const bars = pulseBars(dailyCatalogPulse(catalog));
+ expect(bars[0].label).toBe("Aug 27");
+ expect(bars[0].viewHeight).toBe("100%");
+ expect(bars[1].cloneHeight).toBe(`${Math.round((4 / 11) * 100)}%`);
+ });
+});
+
+describe("uniqueVisitorsByLanguage", () => {
+ test("groups unique visitors by language", () => {
+ expect(uniqueVisitorsByLanguage(catalog).map((row) => [row.language, row.value])).toEqual([
+ ["Go", 110],
+ ["Python", 5],
+ ]);
+ });
+});
+
+describe("quietStarsRank", () => {
+ test("ranks repositories with the most unique visitors per star", () => {
+ expect(quietStarsRank(catalog).map((row) => row.name)).toEqual(["beta", "gamma", "alpha"]);
+ expect(quietStarsRank(catalog)[0].uniques).toBe(70);
+ expect(quietStarsRank(catalog)[0].stars).toBe(2);
+ });
+});
diff --git a/web/src/pages/Home.svelte b/web/src/pages/Home.svelte
index 784aab7..3e423e0 100644
--- a/web/src/pages/Home.svelte
+++ b/web/src/pages/Home.svelte
@@ -2,6 +2,7 @@
import { router } from "@inertiajs/svelte";
import ThemeToggle from "../components/ThemeToggle.svelte";
import RepoCard from "../components/RepoCard.svelte";
+ import CatalogCharts from "../components/CatalogCharts.svelte";
import SortModal from "../components/SortModal.svelte";
import { applyFilters, languageOptions } from "../lib/filter.js";
import { absoluteUrl, formatCount } from "../lib/format.js";
@@ -27,6 +28,7 @@
let sortOpen = false;
let loading = false;
let preferTraffic = false;
+ let view = "catalog";
$: if (!preferTraffic && (repos || []).some((repo) => repo.traffic?.available)) {
sortBy = "traffic";
preferTraffic = true;
@@ -235,23 +237,33 @@
{/if}
{#if populated && !loading}
-
- {#if visible.length === 0}
-
- {#if source?.type === "org"}
- Nothing in {source.login} matches. Loosen language, stars, or the homepage toggle.
- {:else if source?.type === "all"}
- Nothing across personal and orgs matches. Loosen language, stars, or the homepage toggle.
- {:else}
- Nothing matches these filters. Loosen language, stars, or the homepage toggle.
- {/if}
-
- {:else}
- {#each visible as repo, index}
-
- {/each}
- {/if}
+
+ (view = "catalog")}>Catalog
+ (view = "charts")}>Charts
+ {#if view === "catalog"}
+
+ {#if visible.length === 0}
+
+ {#if source?.type === "org"}
+ Nothing in {source.login} matches. Loosen language, stars, or the homepage toggle.
+ {:else if source?.type === "all"}
+ Nothing across personal and orgs matches. Loosen language, stars, or the homepage toggle.
+ {:else}
+ Nothing matches these filters. Loosen language, stars, or the homepage toggle.
+ {/if}
+
+ {:else}
+ {#each visible as repo, index}
+
+ {/each}
+ {/if}
+
+ {:else}
+
+
+
+ {/if}
{/if}
diff --git a/web/src/pages/Home.test.js b/web/src/pages/Home.test.js
index ba1da8a..135474b 100644
--- a/web/src/pages/Home.test.js
+++ b/web/src/pages/Home.test.js
@@ -92,4 +92,36 @@ describe("Home", () => {
await fireEvent.click(allChip);
expect(router.get).toHaveBeenCalledWith("/u/puppe1990/all", {}, expect.any(Object));
});
+
+ test("switches from catalog cards to a charts tab", async () => {
+ render(Home, {
+ props: {
+ populated: true,
+ lookup: "octocat",
+ profile: { login: "octocat", name: "The Octocat", html_url: "https://github.com/octocat", avatar_url: "https://example.com/a.png", public_repos: 1, followers: 1, following: 1 },
+ orgs: [],
+ source: { type: "user", login: "octocat" },
+ repos: [
+ {
+ name: "hello-world",
+ html_url: "https://github.com/octocat/hello-world",
+ stargazers_count: 10,
+ forks_count: 2,
+ language: "Go",
+ description: "demo",
+ updated_at: "2026-08-01T00:00:00Z",
+ traffic: { available: true, views: 40, view_uniques: 22, clones: 9, clone_uniques: 6 },
+ },
+ ],
+ error: "",
+ },
+ });
+ expect(screen.getByRole("link", { name: "hello-world" })).toBeInTheDocument();
+ await fireEvent.click(screen.getByRole("tab", { name: "Charts" }));
+ expect(screen.getByRole("tab", { name: "Charts" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("region", { name: "Top unique visitors" })).toBeInTheDocument();
+ expect(screen.queryByRole("link", { name: "hello-world" })).not.toBeInTheDocument();
+ await fireEvent.click(screen.getByRole("tab", { name: "Catalog" }));
+ expect(screen.getByRole("link", { name: "hello-world" })).toBeInTheDocument();
+ });
});
diff --git a/web/static/css/atlas.css b/web/static/css/atlas.css
index fc2115f..cc6d2e5 100644
--- a/web/static/css/atlas.css
+++ b/web/static/css/atlas.css
@@ -967,6 +967,13 @@ a.stat:hover {
flex: 0 0 auto;
}
+.card-traffic {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ margin: -0.45rem 0 0.85rem;
+}
+
.lang,
.stars,
.forks,
@@ -986,6 +993,16 @@ a.stat:hover {
flex: 0 0 auto;
}
+.views,
+.clones-metric {
+ white-space: nowrap;
+}
+
+.metric-uniques {
+ color: var(--ink-mute);
+ font-size: 0.64rem;
+}
+
.lang i {
width: 8px;
height: 8px;
@@ -1143,6 +1160,202 @@ a.stat:hover {
border: 0;
}
+.view-tabs {
+ display: inline-flex;
+ margin: 1.15rem 0 0;
+ padding: 0.22rem;
+ gap: 0.15rem;
+ border: 1px solid var(--line-strong);
+ border-radius: 999px;
+ background: var(--bg-elev);
+}
+
+.view-tab {
+ border: 0;
+ background: transparent;
+ color: var(--ink-dim);
+ border-radius: 999px;
+ padding: 0.42rem 1rem;
+ font: 600 0.88rem/1 var(--font-ui);
+ cursor: pointer;
+}
+
+.view-tab[aria-selected="true"] {
+ background: var(--copper-dim);
+ color: var(--copper-hot);
+}
+
+.view-tab:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--copper-dim);
+}
+
+.catalog-charts {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ margin-top: 1.1rem;
+}
+
+.chart-totals {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.75rem;
+ grid-column: 1 / -1;
+}
+
+.chart-totals .traffic-stat {
+ margin: 0;
+}
+
+.chart-panel {
+ padding: 1.05rem 1.1rem 1.15rem;
+ border: 1px solid var(--line);
+ border-radius: 20px;
+ background: linear-gradient(180deg, var(--card-from), var(--card-to));
+ box-shadow: var(--card-shadow);
+}
+
+.chart-panel-wide {
+ grid-column: 1 / -1;
+}
+
+.chart-head {
+ margin-bottom: 0.95rem;
+}
+
+.chart-head h3 {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 1.55rem;
+ font-weight: 400;
+ letter-spacing: -0.02em;
+}
+
+.chart-head p,
+.chart-empty,
+.chart-legend {
+ margin: 0.28rem 0 0;
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.02em;
+ color: var(--ink-mute);
+}
+
+.rank-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 0.55rem;
+}
+
+.rank-row {
+ display: grid;
+ grid-template-columns: minmax(4.8rem, 7.2rem) 1fr auto;
+ gap: 0.65rem;
+ align-items: center;
+}
+
+.rank-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ color: var(--ink-dim);
+}
+
+.rank-track {
+ height: 9px;
+ border-radius: 999px;
+ background: var(--inset);
+ overflow: hidden;
+}
+
+.rank-fill {
+ display: block;
+ height: 100%;
+ width: var(--bar, 0%);
+ border-radius: inherit;
+ background: var(--copper);
+}
+
+.rank-fill.is-clone {
+ background: var(--moss);
+}
+
+.rank-value {
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ font-weight: 500;
+ color: var(--ink);
+}
+
+.pulse-chart {
+ display: flex;
+ align-items: flex-end;
+ justify-content: flex-start;
+ gap: 0.45rem;
+ height: 148px;
+ overflow-x: auto;
+}
+
+.pulse-col {
+ flex: 1 1 1.7rem;
+ max-width: 2.4rem;
+ min-width: 1.35rem;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.pulse-pair {
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ gap: 2px;
+ width: 100%;
+ height: 100%;
+}
+
+.pulse-pair .traffic-bar {
+ flex: 1;
+ max-width: 9px;
+ min-height: 0;
+}
+
+.pulse-col span {
+ margin-top: 0.35rem;
+ font-family: var(--font-mono);
+ font-size: 0.58rem;
+ color: var(--ink-mute);
+ white-space: nowrap;
+}
+
+.chart-legend {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ flex-wrap: wrap;
+ margin-top: 0.85rem;
+}
+
+.chart-swatch {
+ display: inline-block;
+ width: 0.7rem;
+ height: 0.55rem;
+ border-radius: 2px;
+ background: var(--copper);
+}
+
+.chart-swatch.is-clone {
+ background: var(--moss);
+ margin-left: 0.45rem;
+}
+
@keyframes rise {
from {
opacity: 0;
@@ -1184,6 +1397,11 @@ a.stat:hover {
.profile-copy h2 {
font-size: 1.55rem;
}
+
+ .catalog-charts,
+ .chart-totals {
+ grid-template-columns: 1fr 1fr;
+ }
}
@media (max-width: 560px) {
@@ -1218,6 +1436,11 @@ a.stat:hover {
grid-column: span 12;
}
+ .catalog-charts,
+ .chart-totals {
+ grid-template-columns: 1fr;
+ }
+
.split,
.split-sort {
grid-template-columns: 1fr 1fr;
diff --git a/web/static/js/sw.js b/web/static/js/sw.js
index 792b019..b13bd37 100644
--- a/web/static/js/sw.js
+++ b/web/static/js/sw.js
@@ -1,4 +1,4 @@
-const CACHE_VERSION = 1;
+const CACHE_VERSION = 3;
const CACHE = "cais-static-v" + CACHE_VERSION;
const PRECACHE = [