diff --git a/tools/star-tracker/src/db.ts b/tools/star-tracker/src/db.ts index 8a78ac5..e92e16e 100644 --- a/tools/star-tracker/src/db.ts +++ b/tools/star-tracker/src/db.ts @@ -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='/' 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 { 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; @@ -615,6 +631,7 @@ export async function viewsSummary( const refs = new Map(); const countries = new Map(); const dailyMap = new Map(); + const destMap = new Map(); for (const r of results ?? []) { if (r.kind === 'chart') { @@ -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. @@ -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, @@ -660,5 +685,6 @@ export async function viewsSummary( topReferers: refSorted, topCountries: countrySorted, daily, + byDestination, }; } diff --git a/tools/star-tracker/src/index.ts b/tools/star-tracker/src/index.ts index 57ac984..4701f9e 100644 --- a/tools/star-tracker/src/index.ts +++ b/tools/star-tracker/src/index.ts @@ -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 { diff --git a/tools/star-tracker/src/pages.ts b/tools/star-tracker/src/pages.ts index 70c30d4..f3be1a9 100644 --- a/tools/star-tracker/src/pages.ts +++ b/tools/star-tracker/src/pages.ts @@ -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; } @@ -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() { @@ -306,8 +298,10 @@ export function landing(user: User | null): string {

Live example

This site dogfoods the tool — here's the wavekat org's own star history, split by top repo:

- wavekat star history (light) - wavekat star history (dark) + + + wavekat star history +

How it works

    @@ -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 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) => `