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
60 changes: 43 additions & 17 deletions tools/star-tracker/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,35 +577,51 @@ export type ViewsSummary = {
topReferers: { host: string; count: number }[]; // chart + page combined
topCountries: { country: string; count: number }[];
daily: { day: string; chart: number; page: number }[]; // ascending day
// Per-destination breakdown. repo='' rows are the tenant-level
// (org-wide) chart/page; repo='<owner>/<name>' are per-repo. Sorted
// by total (chart+page) desc.
byDestination: { repo: string; chart: number; page: number }[];
};

// Compact analytics summary used by the owner's tenant page. One pass of
// the day range pulls everything we need; the panel does no further work.
// Pass `repo` (e.g. "owner/name") to scope the summary to a single repo's
// rows — used by the per-repo owner view. Omit for the full tenant view.
export async function viewsSummary(
db: D1Database,
tenant: string,
windowDays: number,
now: number,
repo?: string,
): Promise<ViewsSummary> {
const cutoffDay = utcDay(now - (windowDays - 1) * 86400_000);

const { results } = await db
.prepare(
`SELECT repo, kind, day, referer_host, country, ua_class, cached, count
FROM views_daily
WHERE tenant_slug = ? AND day >= ?`,
)
.bind(tenant, cutoffDay)
.all<{
repo: string;
kind: ViewKind;
day: string;
referer_host: string;
country: string;
ua_class: UAClass;
cached: number;
count: number;
}>();
const stmt = repo === undefined
? db
.prepare(
`SELECT repo, kind, day, referer_host, country, ua_class, cached, count
FROM views_daily
WHERE tenant_slug = ? AND day >= ?`,
)
.bind(tenant, cutoffDay)
: db
.prepare(
`SELECT repo, kind, day, referer_host, country, ua_class, cached, count
FROM views_daily
WHERE tenant_slug = ? AND repo = ? AND day >= ?`,
)
.bind(tenant, repo, cutoffDay);

const { results } = await stmt.all<{
repo: string;
kind: ViewKind;
day: string;
referer_host: string;
country: string;
ua_class: UAClass;
cached: number;
count: number;
}>();

let totalChart = 0;
let totalPage = 0;
Expand All @@ -615,6 +631,7 @@ export async function viewsSummary(
const refs = new Map<string, number>();
const countries = new Map<string, number>();
const dailyMap = new Map<string, { chart: number; page: number }>();
const destMap = new Map<string, { chart: number; page: number }>();

for (const r of results ?? []) {
if (r.kind === 'chart') {
Expand All @@ -631,6 +648,10 @@ export async function viewsSummary(
if (r.kind === 'chart') d.chart += r.count;
else d.page += r.count;
dailyMap.set(r.day, d);
const dest = destMap.get(r.repo) ?? { chart: 0, page: 0 };
if (r.kind === 'chart') dest.chart += r.count;
else dest.page += r.count;
destMap.set(r.repo, dest);
}

// Fill in zero-days so the sparkline has a continuous baseline.
Expand All @@ -650,6 +671,10 @@ export async function viewsSummary(
const refSorted = toSorted(refs).slice(0, 8).map((x) => ({ host: x.k, count: x.count }));
const countrySorted = toSorted(countries).slice(0, 8).map((x) => ({ country: x.k, count: x.count }));

const byDestination = Array.from(destMap.entries())
.map(([repo, v]) => ({ repo, chart: v.chart, page: v.page }))
.sort((a, b) => (b.chart + b.page) - (a.chart + a.page));

return {
windowDays,
totalChart,
Expand All @@ -660,5 +685,6 @@ export async function viewsSummary(
topReferers: refSorted,
topCountries: countrySorted,
daily,
byDestination,
};
}
10 changes: 8 additions & 2 deletions tools/star-tracker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,9 +644,15 @@ app.get('/:slug/:repo', async (c) => {
const all = await db.tenantPerRepoTimelines(c.env.DB, slug);
const series = all.find((r) => r.repo === fullName);
const total = series?.total ?? 0;
const gains = db.recentForSeries(series?.points ?? [], total, Date.now());
const now = Date.now();
const gains = db.recentForSeries(series?.points ?? [], total, now);
const viewer = c.get('user');
// Per-repo analytics only for the owner. Scoped to this repo so they
// can see traffic to /:slug/:repo and its chart-svg in isolation.
const isOwner = !!(viewer && viewer.id === tenant.owner_user_id);
const views = isOwner ? await db.viewsSummary(c.env.DB, slug, 30, now, fullName) : undefined;
recordView(c, slug, fullName, 'page', 0, tenant.owner_user_id);
return c.html(pages.repoDetail(c.get('user'), tenant, repoRow, total, gains, c.env.PUBLIC_URL));
return c.html(pages.repoDetail(viewer, tenant, repoRow, total, gains, c.env.PUBLIC_URL, views));
});

function djb2(s: string): string {
Expand Down
59 changes: 43 additions & 16 deletions tools/star-tracker/src/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,6 @@ gtag('config', 'G-P2YBZ0W8HQ');
@media (prefers-color-scheme: dark) { .seg-toggle .seg-label { border-right-color: #334155; } }
.chart-preview img { max-width: 100%; border-radius: 6px; display: block; border: 1px solid #e2e8f0; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); }
@media (prefers-color-scheme: dark) { .chart-preview img { border-color: #1e293b; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); } }
.chart-section .chart-preview .dark { display: none; }
.chart-section #theme-dark:checked ~ .chart-preview .light { display: none; }
.chart-section #theme-dark:checked ~ .chart-preview .dark { display: block; }
.chart-preview-auto .dark { display: none; }
@media (prefers-color-scheme: dark) {
.chart-preview-auto .light { display: none; }
.chart-preview-auto .dark { display: block; }
}
.repo-list { list-style: none; padding: 0; margin: .5rem 0; display: flex; flex-direction: column; gap: 4px; }
.repo-list li { display: flex; align-items: center; gap: 8px; font-size: 0.9em; }
.recent-list { list-style: none; padding: 0; margin: .5rem 0 1rem; display: flex; flex-direction: column; gap: 6px; }
Expand Down Expand Up @@ -249,9 +241,9 @@ ${body}
var n = currentSplit();
var style = currentStyle();
var range = currentRange();
var theme = currentTheme();
preview.querySelectorAll('img').forEach(function (img) {
var isDark = img.classList.contains('dark');
img.src = base + buildQuery(isDark ? 'dark' : 'light', n, style, range, 'v=' + version);
img.src = base + buildQuery(theme, n, style, range, 'v=' + version);
});
}
function refreshEmbed() {
Expand Down Expand Up @@ -306,8 +298,10 @@ export function landing(user: User | null): string {
<h2>Live example</h2>
<p>This site dogfoods the tool — here's the <a href="/wavekat">wavekat</a> org's own star history, split by top repo:</p>
<div class="chart-preview chart-preview-auto">
<img class="light" src="/wavekat/chart.svg?split=5" alt="wavekat star history (light)"/>
<img class="dark" src="/wavekat/chart.svg?split=5&amp;theme=dark" alt="wavekat star history (dark)"/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/wavekat/chart.svg?split=5&amp;theme=dark"/>
<img src="/wavekat/chart.svg?split=5" alt="wavekat star history"/>
</picture>
</div>
<h2>How it works</h2>
<ol>
Expand Down Expand Up @@ -426,8 +420,12 @@ function chartBlock(
embedAlt: string,
showSplit: boolean,
): string {
const previewLight = `${chartSvg}?v=${totalStars}`;
const previewDark = `${chartSvg}?v=${totalStars}&theme=dark`;
// Single preview img — JS swaps `src` on theme/split/style/range toggle.
// The previous dual <img class="light"/dark"> design fetched both
// variants on every page load (CSS only hid the off-theme one), which
// doubled chart-view counts in the analytics panel. One img halves
// that and still supports the full toggle UX as long as JS is on.
const previewSrc = `${chartSvg}?v=${totalStars}`;
const label = esc(displayName ?? slug);
const altText = esc(embedAlt);
const copyBtn = (kind: string) => `<button type="button" class="icon-btn copy-btn" aria-label="Copy ${kind}" data-copied="false">
Expand Down Expand Up @@ -488,8 +486,7 @@ function chartBlock(
</div>
</div>
<div class="chart-preview" data-base="${chartSvg}" data-version="${totalStars}">
<img class="light" src="${previewLight}" alt="${label} star history (light)"/>
<img class="dark" src="${previewDark}" alt="${label} star history (dark)"/>
<img src="${previewSrc}" alt="${label} star history"/>
</div>
<dl class="embed" data-embed data-base="${chartSvg}" data-link="${linkTarget}" data-slug="${altText}">
<dt>Markdown</dt>
Expand Down Expand Up @@ -596,6 +593,32 @@ function viewsPanel(views: ViewsSummary): string {
? ''
: `<ul class="repo-list">${views.uaBreakdown.map((u) => `<li><span class="muted" style="font-variant-numeric:tabular-nums;min-width:3.5em">${u.count.toLocaleString('en-US')}</span> ${esc(uaLabels[u.ua_class] ?? u.ua_class)}</li>`).join('')}</ul>`;

// Per-destination rows. repo='' → tenant-wide chart/page; others →
// per-repo. Tenant-wide is pinned first because it's always the
// headline embed; per-repo rows sort by total below it. Skip rendering
// entirely when there's only one destination (e.g. the per-repo
// owner view, where the panel is already scoped).
const destRows = views.byDestination.length <= 1
? ''
: (() => {
const tenantRow = views.byDestination.find((d) => d.repo === '');
const repoRows = views.byDestination.filter((d) => d.repo !== '');
const rows: { label: string; chart: number; page: number }[] = [];
if (tenantRow) rows.push({ label: 'org (all repos)', chart: tenantRow.chart, page: tenantRow.page });
for (const r of repoRows) {
const name = r.repo.includes('/') ? r.repo.split('/')[1]! : r.repo;
rows.push({ label: name, chart: r.chart, page: r.page });
}
return `<ul class="repo-list">${rows.map((r) => `<li>
<span class="muted" style="font-variant-numeric:tabular-nums;min-width:6em">
<strong style="color:#2196f3">${r.chart.toLocaleString('en-US')}</strong>
· <span>${r.page.toLocaleString('en-US')}</span>
</span>
<code>${esc(r.label)}</code>
</li>`).join('')}</ul>
<p class="muted" style="font-size:0.8em;margin:.25rem 0 0">chart · page views per destination over the window.</p>`;
})();

const cacheLine = views.totalChart > 0
? `<p class="muted" style="font-size:0.85em;margin:.25rem 0 0">${views.freshChart.toLocaleString('en-US')} fresh · ${views.cachedChart.toLocaleString('en-US')} cached (304). High 304 ratio means GitHub Camo or browsers re-validated the same image.</p>`
: '';
Expand All @@ -609,6 +632,7 @@ function viewsPanel(views: ViewsSummary): string {
</dl>
<div style="overflow-x:auto;margin:0 0 .25rem">${sparkline}</div>
<p class="muted" style="font-size:0.8em;margin:0 0 1rem"><span style="display:inline-block;width:8px;height:8px;background:#2196f3;border-radius:1px;vertical-align:middle"></span> chart · <span style="display:inline-block;width:8px;height:8px;background:#64748b;border-radius:1px;vertical-align:middle"></span> page · ${views.daily[0]?.day ?? ''} → ${views.daily[views.daily.length - 1]?.day ?? ''}</p>
${destRows ? `<h3 style="font-size:0.95rem;margin:1rem 0 .25rem">Views by destination</h3>${destRows}` : ''}
<h3 style="font-size:0.95rem;margin:1rem 0 .25rem">Top referers</h3>
${refRows}
<h3 style="font-size:0.95rem;margin:1rem 0 .25rem">Top countries</h3>
Expand Down Expand Up @@ -752,6 +776,7 @@ export function repoDetail(
totalStars: number,
gains: { gained_7d: number; gained_30d: number },
publicUrl: string,
views?: ViewsSummary,
): string {
const parts = repo.full_name.split('/');
const slug = parts[0] ?? tenant.slug;
Expand All @@ -775,6 +800,8 @@ ${recentLine}

${chartBlock(slug, name, chartSvg, linkTarget, totalStars, repo.full_name, false)}

${views ? viewsPanel(views) : ''}

<h2>Other repos in ${esc(slug)}</h2>
<p>See the <a href="/${esc(slug)}">full ${esc(slug)} chart</a> for all tracked repos in this account.</p>`,
);
Expand Down
Loading