diff --git a/README.md b/README.md index c45248f..9297949 100644 --- a/README.md +++ b/README.md @@ -476,20 +476,43 @@ movement are ranked by a weighted score; each comes with its traffic, the top ones with a 14-day chart, and new or lost followers are named. ```sh -gh-pulse # scan every repo, email the report, snapshot -gh-pulse --dry-run # scan and write the report, send nothing -gh-pulse show # the last report and the history, as a TUI -gh-pulse open # the last HTML report, in the browser -gh-pulse text # the last report as plain text -gh-pulse json # the last report as JSON -gh-pulse --repo profullstack/nixamp --dry-run # one repo, for a look +gh-pulse # the daily run: scan every repo, email, snapshot +gh-pulse --dry-run # scan and write the daily report, send nothing +gh-pulse --range week # what moved in the last 7 days, to stdout +gh-pulse --range year --send # the same for a year, emailed +gh-pulse --since 2026-09-01 # any start date +gh-pulse show # the TUI: ranges and filters are clickable +gh-pulse show --range quarter # start on a range +gh-pulse open [--range all] # the HTML report, in the browser +gh-pulse text [--range month] # plain text +gh-pulse json [--range day] # JSON (what `show` reads) +gh-pulse --repo profullstack/nixamp --range week # one repo, for a look ``` The same report reaches every surface: the email (HTML with inline charts, -through Resend), the terminal (`show`, built on hqtui: click a row, the -selected repo's 14-day views and clones, a History tab over every snapshot), -the browser (`open`), and pipes (`text`, `json`). The moshcode pit alias is -`/pulse`. +through Resend), the terminal (`show`, built on hqtui), the browser (`open`), +and pipes (`text`, `json`). The moshcode pit alias is `/pulse`. + +**Ranges.** `hour`, `day`, `week`, `month`, `quarter`, `year` and `all` (also +`1h`, `24h`, `7d`, `30d`, `90d`, `365d`), or `--since` a date. A range scan +pulls the events live from GitHub for the whole range (commits, PRs, issues, +releases, who starred and forked, walking as many pages as the range +deserves) and the traffic from the ledger of daily snapshots, which is the +only place GitHub's fourteen-day traffic window survives: the longer the +daily run has been going, the further back `year` and `all` can see, and the +report says exactly which days it covers. A range scan never moves the daily +baseline. `show`, `open`, `text` and `json` reuse a range report under an hour +old and scan otherwise. `hour` has hourly events but daily traffic, because +GitHub publishes nothing finer. + +**In the TUI** the range row and the filter rows are clickable. Filters are +on/off toggles: which kinds of movement count (stars, forks, commits, PRs, +issues, releases, traffic), which owners, and private repos; a repo stays in +the list while at least one enabled kind moved for it, and keeps its rank. +Keys do the same: `h d w m q y a` pick a range, `l` returns to the latest +daily report, `1`-`7` flip the kinds, `p` flips private, `r` rescans, Tab +switches to History, `o` opens the HTML, `q` quits. Ranges load in the +background while the current view stays up. GitHub publishes traffic in UTC-day buckets one to two days late, so "the last 24 hours" cannot be read off the clock. Each run instead counts the growth of diff --git a/bin/gh-pulse.ts b/bin/gh-pulse.ts index 3ed5bb3..50d6c13 100755 --- a/bin/gh-pulse.ts +++ b/bin/gh-pulse.ts @@ -1,31 +1,31 @@ #!/usr/bin/env node /** - * gh-pulse — what moved on GitHub since yesterday, ranked, with traffic. + * gh-pulse — what moved on GitHub, ranked, with traffic. * - * gh-pulse scan every repo, email the report, snapshot - * gh-pulse --dry-run scan and write the report, send nothing - * gh-pulse show the last report and the history, as a TUI - * gh-pulse open the last HTML report, in the browser - * gh-pulse text the last report as plain text - * gh-pulse json the last report as JSON (what `show` reads) + * gh-pulse the daily run: scan every repo, email, snapshot + * gh-pulse --dry-run scan and write the daily report, send nothing + * gh-pulse --range week what moved in the last week, to stdout (no mail) + * gh-pulse --range month --send the same, emailed + * gh-pulse --since 2026-09-01 any start date + * gh-pulse show [--range year] the TUI: ranges and filters are clickable + * gh-pulse open [--range quarter] the HTML report, in the browser + * gh-pulse text [--range all] plain text + * gh-pulse json [--range day] JSON (what `show` reads) + * + * Ranges: hour, day, week, month, quarter, year, all (also 1h, 24h, 7d, 30d, + * 90d, 365d). A range scan pulls the events live from GitHub for the whole + * range and the traffic from the ledger of daily snapshots, which is the only + * place GitHub's fourteen-day traffic window survives. It never moves the + * daily baseline. `show`, `open`, `text` and `json` reuse a range report that + * is under an hour old and scan otherwise. * * Options for a scan: - * --to a@b.c recipient (default GH_PULSE_TO, then the git email) + * --to a@b.c recipient (default GH_PULSE_TO, then the committer email) * --from "N " sender on a verified Resend domain (default GH_PULSE_FROM) * --top N how many ranked repos get a chart and detail (12) * --repo owner/name only this repo; repeatable; never moves the baseline - * --hours N window when there is no previous snapshot (24) - * - * Every repo the gh token can see (yours plus every org, forks excluded) is - * checked for movement since the previous run: stars, forks, commits, PRs, - * issues, releases, and the traffic GitHub shows at /graphs/traffic. Movers are - * ranked by a weighted score; each comes with its traffic, the top ones with a - * 14-day chart, and new or lost followers are named. - * - * GitHub publishes traffic in UTC-day buckets one to two days late, so the - * window is the growth of the buckets since the previous snapshot rather than - * a clock. Snapshots live under ~/.local/share/gh-pulse (GH_PULSE_DATA) and are - * the only long-run traffic record GitHub leaves you. + * --hours N daily window when there is no previous snapshot (24) + * --send email a range report (the daily run always mails) * * Mail goes through Resend: RESEND_API_KEY from the environment, else from * ~/.config/logicsrc/shell.env (the cron case). A crash sends a FAILED mail. @@ -36,29 +36,51 @@ import { readFileSync } from 'node:fs'; import { UsageError, parseArgs } from '../src/args.ts'; import { DEFAULT_FROM, + RANGE_KEYS, + customRange, dataDir, defaultDeps, gitEmail, loadShellEnv, openInBrowser, outputPaths, + parseRangeKey, + rangeOutputPaths, + rangeScan, + rangeSlug, + rangeSpec, + readRangeReport, run, sendFailure, + type RangeKey, + type RangeSpec, + type ReportJson, } from '../src/gh-pulse.ts'; import { isMain } from '../src/is-main.ts'; export const USAGE = `Usage: gh-pulse [--dry-run] [--to ADDR] [--from ADDR] [--top N] [--hours N] [--repo OWNER/NAME]... - gh-pulse show - gh-pulse open - gh-pulse text - gh-pulse json + gh-pulse --range ${RANGE_KEYS.join('|')} [--send] [--top N] [--repo OWNER/NAME]... + gh-pulse --since YYYY-MM-DD [--send] + gh-pulse show [--range KEY] + gh-pulse open [--range KEY] + gh-pulse text [--range KEY] + gh-pulse json [--range KEY] gh-pulse --help`; +function specFrom(rangeText: string | undefined, sinceText: string | undefined, now: Date): RangeSpec | null { + if (rangeText !== undefined && sinceText !== undefined) throw new UsageError('pass --range or --since, not both'); + if (sinceText !== undefined) return customRange(sinceText, now); + if (rangeText === undefined) return null; + const key = parseRangeKey(rangeText); + if (!key) throw new UsageError(`--range wants one of ${RANGE_KEYS.join(', ')} (or 1h, 24h, 7d, 30d, 90d, 365d), not ${rangeText}`); + return rangeSpec(key, now); +} + export async function main(argv: readonly string[]): Promise { const parsed = parseArgs(argv, { - boolean: ['--dry-run', '--help', '-h'], - string: ['--to', '--from', '--top', '--hours', '--repo'], + boolean: ['--dry-run', '--send', '--help', '-h'], + string: ['--to', '--from', '--top', '--hours', '--repo', '--range', '--since'], }); if (parsed.flags.has('--help') || parsed.flags.has('-h')) { process.stdout.write(`${USAGE}\n`); @@ -68,20 +90,47 @@ export async function main(argv: readonly string[]): Promise { const [verb, ...rest] = parsed.positional; if (rest.length > 0) throw new UsageError(`unexpected argument: ${rest[0]}`); + const top = Number(parsed.values.get('--top') ?? 12); + const hours = Number(parsed.values.get('--hours') ?? 24); + if (!Number.isFinite(top) || top < 0) throw new UsageError('--top wants a non-negative number'); + if (!Number.isFinite(hours) || hours <= 0) throw new UsageError('--hours wants a positive number'); + // parseArgs keeps the last value of a repeated flag; --repo is the one flag + // people repeat, so it is gathered from argv directly. + const repos: string[] = []; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]!; + if (a === '--repo' && argv[i + 1]) repos.push(argv[i + 1]!); + else if (a.startsWith('--repo=')) repos.push(a.slice('--repo='.length)); + } + + loadShellEnv(); + const to = parsed.values.get('--to') ?? process.env['GH_PULSE_TO'] ?? gitEmail(); + const from = parsed.values.get('--from') ?? process.env['GH_PULSE_FROM'] ?? DEFAULT_FROM; + const deps = defaultDeps(); + const spec = specFrom(parsed.values.get('--range'), parsed.values.get('--since'), deps.now()); + + /** A range report, from the hour-fresh cache or a scan. Progress goes wherever the caller wants it. */ + const loadRange = async (s: RangeSpec, progress: (line: string) => void, force = false): Promise => { + const cached = force ? null : readRangeReport(dir, rangeSlug(s, repos)); + if (cached) return cached; + return (await rangeScan({ spec: s, top, repos, dataDir: dir, send: false, to, from, progress }, deps)).report; + }; + if (verb === 'show') { const { showTui } = await import('../src/gh-pulse-tui.ts'); - return showTui(dir); - } - if (verb === 'open') { - const file = outputPaths(dir).html; - if (openInBrowser(file)) return 0; - process.stderr.write(`gh-pulse open: no opener found; the report is at ${file}\n`); - return 1; + const startKey: RangeKey | undefined = spec && spec.key !== 'custom' ? spec.key : undefined; + return showTui(dir, { range: startKey, loadRange: (key, progress) => loadRange(rangeSpec(key, deps.now()), progress) }); } - if (verb === 'text' || verb === 'json') { - const file = verb === 'text' ? outputPaths(dir).text : outputPaths(dir).json; + if (verb === 'open' || verb === 'text' || verb === 'json') { + if (spec) await loadRange(spec, deps.log); + const paths = spec ? rangeOutputPaths(dir, rangeSlug(spec, repos)) : outputPaths(dir); + if (verb === 'open') { + if (openInBrowser(paths.html)) return 0; + process.stderr.write(`gh-pulse open: no opener found; the report is at ${paths.html}\n`); + return 1; + } try { - process.stdout.write(readFileSync(file, 'utf8')); + process.stdout.write(readFileSync(verb === 'text' ? paths.text : paths.json, 'utf8')); return 0; } catch { process.stderr.write(`gh-pulse ${verb}: no report yet in ${dir}. Run \`gh-pulse\` first.\n`); @@ -90,26 +139,22 @@ export async function main(argv: readonly string[]): Promise { } if (verb !== undefined) throw new UsageError(`unknown command: ${verb}`); - const top = Number(parsed.values.get('--top') ?? 12); - const hours = Number(parsed.values.get('--hours') ?? 24); - if (!Number.isFinite(top) || top < 0) throw new UsageError('--top wants a non-negative number'); - if (!Number.isFinite(hours) || hours <= 0) throw new UsageError('--hours wants a positive number'); + if (spec) { + // A range scan prints the report; mail is opt-in, and the baseline never moves. + const send = parsed.flags.has('--send'); + if (send && !to) throw new UsageError('no recipient: pass --to, set GH_PULSE_TO, or configure git user.email'); + try { + await rangeScan({ spec, top, repos, dataDir: dir, send, to, from }, deps); + process.stdout.write(readFileSync(rangeOutputPaths(dir, rangeSlug(spec, repos)).text, 'utf8')); + return 0; + } catch (error) { + process.stderr.write(`gh-pulse: ${(error as Error).stack ?? String(error)}\n`); + return 1; + } + } - loadShellEnv(); - const to = parsed.values.get('--to') ?? process.env['GH_PULSE_TO'] ?? gitEmail(); if (!to) throw new UsageError('no recipient: pass --to, set GH_PULSE_TO, or configure git user.email'); - const from = parsed.values.get('--from') ?? process.env['GH_PULSE_FROM'] ?? DEFAULT_FROM; const dryRun = parsed.flags.has('--dry-run'); - // parseArgs keeps the last value of a repeated flag; --repo is the one flag - // people repeat, so it is gathered from argv directly. - const repos: string[] = []; - for (let i = 0; i < argv.length; i += 1) { - const a = argv[i]!; - if (a === '--repo' && argv[i + 1]) repos.push(argv[i + 1]!); - else if (a.startsWith('--repo=')) repos.push(a.slice('--repo='.length)); - } - - const deps = defaultDeps(); try { await run({ dryRun, to, from, top, hours, repos, dataDir: dir }, deps); return 0; diff --git a/package.json b/package.json index 73ce3fe..995b3e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.33.0", + "version": "0.34.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/gh-pulse-tui.ts b/src/gh-pulse-tui.ts index a231d4c..8084ce5 100644 --- a/src/gh-pulse-tui.ts +++ b/src/gh-pulse-tui.ts @@ -1,26 +1,38 @@ /** - * gh-pulse show — the last report and the history, in the terminal. + * gh-pulse show — the report and the history, in the terminal. * * Built on @profullstack/hqtui so the same data that goes out by email is * readable over SSH without a mail client: the ranked movers on the left, the - * selected repo's 14-day traffic and detail on the right, and a History tab - * over every snapshot on disk (stars, followers, movers, traffic per run). + * selected repo's traffic and detail on the right, and a History tab over + * every snapshot on disk (stars, followers, movers, traffic per run). * - * Mouse: one click selects a row, the row under the pointer is lit, the wheel - * scrolls. Keys: up/down or j/k move, 1/2 or left/right switch tabs, o opens - * the HTML report in the browser, q quits. + * The range row and the filter row are clickable. Ranges (latest report, last + * hour, day, week, month, quarter, year, all time) run a live scan when no + * fresh one is cached, in the background, while the current view stays up. + * Filters are on/off toggles: which kinds of movement count (stars, forks, + * commits, PRs, issues, releases, traffic), which owners, and private repos. + * A repo stays in the list while at least one enabled kind moved for it. + * + * Mouse: one click selects a row or flips a toggle, the row under the pointer + * is lit, the wheel scrolls. Keys: up/down or j/k move, Tab switches Movers + * and History, h d w m q y a l pick a range, 1-7 flip the kind filters, p + * flips private, r rescans the current range, o opens the HTML, q quits. */ import { createApp } from '@profullstack/hqtui'; import { + RANGE_KEYS, + RANGE_LABEL, listSnapshots, movementBits, openInBrowser, outputPaths, + rangeOutputPaths, readReport, readSnapshot, trafficBits, + type RangeKey, type ReportJson, type ReportMover, type Snapshot, @@ -66,16 +78,68 @@ export function loadHistory(dataDir: string, max = 120): HistoryRow[] { return historyRows(snaps); } +// ---------------------------------------------------------------- filters + +export const KINDS = ['stars', 'forks', 'commits', 'prs', 'issues', 'releases', 'traffic'] as const; +export type Kind = (typeof KINDS)[number]; + +export interface Filters { + kinds: Set; + /** Owners switched OFF; an empty set means every owner shows. */ + hiddenOwners: Set; + showPrivate: boolean; +} + +export const defaultFilters = (): Filters => ({ kinds: new Set(KINDS), hiddenOwners: new Set(), showPrivate: true }); + +export function movedIn(m: ReportMover['movement'], kind: Kind): boolean { + switch (kind) { + case 'stars': return m.stars !== 0; + case 'forks': return m.forks !== 0; + case 'commits': return m.commits > 0; + case 'prs': return m.prOpened + m.prMerged + m.prClosed > 0; + case 'issues': return m.issuesOpened + m.issuesClosed > 0; + case 'releases': return m.releases > 0; + case 'traffic': return m.views > 0 || m.clones > 0; + default: return false; + } +} + +export const ownerOf = (repo: string): string => repo.split('/')[0] ?? repo; + +/** The movers that pass every toggle. Rank order is kept; the rank shown is the position in the full list. */ +export function applyFilters(movers: ReportMover[], f: Filters): { mover: ReportMover; rank: number }[] { + const out: { mover: ReportMover; rank: number }[] = []; + movers.forEach((mover, i) => { + if (!f.showPrivate && mover.private) return; + if (f.hiddenOwners.has(ownerOf(mover.repo))) return; + if (![...f.kinds].some((k) => movedIn(mover.movement, k))) return; + out.push({ mover, rank: i + 1 }); + }); + return out; +} + +// ---------------------------------------------------------------- the app + const fmtStamp = (iso: string): string => iso.replace('T', ' ').slice(0, 16); +const RANGE_HOTKEY: Record = { hour: 'h', day: 'd', week: 'w', month: 'm', quarter: 'q', year: 'y', all: 'a' }; -export async function showTui(dataDir: string): Promise { - const report: ReportJson | null = readReport(dataDir); - if (!report) { +export interface ShowOptions { + /** Start on this range instead of the latest daily report. */ + range?: RangeKey | undefined; + /** Produce (or fetch from cache) the report for a range. Called off the render loop. */ + loadRange: (key: RangeKey, progress: (line: string) => void) => Promise; +} + +export async function showTui(dataDir: string, opts: ShowOptions): Promise { + let report: ReportJson | null = readReport(dataDir); + let current: RangeKey | 'latest' = 'latest'; + if (!report && !opts.range) { process.stderr.write(`gh-pulse show: no report yet in ${dataDir}. Run \`gh-pulse\` (or \`gh-pulse --dry-run\`) first.\n`); return 1; } const history = loadHistory(dataDir); - const movers = report.movers; + const filters = defaultFilters(); let tab = 0; let selected = 0; @@ -85,14 +149,38 @@ export async function showTui(dataDir: string): Promise { let hSelected = Math.max(0, history.length - 1); let hHovered = -1; let notice = ''; + let scanning: RangeKey | null = null; + let scanLine = ''; const app = await createApp({ mouse: true, quitKeys: ['q', 'ctrl+c', 'escape'] }); + const visible = (): { mover: ReportMover; rank: number }[] => (report ? applyFilters(report.movers, filters) : []); + const owners = (): string[] => [...new Set((report?.movers ?? []).map((m) => ownerOf(m.repo)))].sort(); + const move = (delta: number): void => { - if (tab === 0) selected = Math.max(0, Math.min(movers.length - 1, selected + delta)); + if (tab === 0) selected = Math.max(0, Math.min(visible().length - 1, selected + delta)); else hSelected = Math.max(0, Math.min(history.length - 1, hSelected + delta)); }; + const pickRange = (key: RangeKey | 'latest', force = false): void => { + if (key === 'latest') { + const latest = readReport(dataDir); + if (latest) { report = latest; current = 'latest'; selected = 0; offset = 0; notice = ''; } + else notice = 'no daily report yet'; + app.invalidate(); + return; + } + if (scanning) { notice = `still scanning ${RANGE_LABEL[scanning]}`; app.invalidate(); return; } + scanning = key; + scanLine = force ? 'rescanning' : 'loading'; + notice = ''; + app.invalidate(); + opts.loadRange(key, (line) => { scanLine = line; app.invalidate(); }).then( + (r) => { report = r; current = key; selected = 0; offset = 0; scanning = null; scanLine = ''; app.invalidate(); }, + (error: unknown) => { scanning = null; scanLine = ''; notice = `scan failed: ${(error as Error).message}`; app.invalidate(); }, + ); + }; + app.on('key', (e) => { if (e.name === 'down' || e.name === 'j') move(1); else if (e.name === 'up' || e.name === 'k') move(-1); @@ -100,12 +188,22 @@ export async function showTui(dataDir: string): Promise { else if (e.name === 'pageup') move(-10); else if (e.name === 'home') move(-1e9); else if (e.name === 'end') move(1e9); - else if (e.name === '1' || e.name === 'left') tab = 0; - else if (e.name === '2' || e.name === 'right') tab = 1; - else if (e.name === 'tab') tab = (tab + 1) % 2; + else if (e.name === 'tab' || e.name === 'left' || e.name === 'right') tab = (tab + 1) % 2; + else if (e.name === 'l') { pickRange('latest'); return; } + else if (e.name === 'r') { pickRange(current === 'latest' ? 'day' : current, true); return; } + else if (e.name === 'p') { filters.showPrivate = !filters.showPrivate; selected = 0; } else if (e.name === 'o') { - notice = openInBrowser(outputPaths(dataDir).html) ? 'opened the HTML report in the browser' : `no opener found; the report is at ${outputPaths(dataDir).html}`; - } else return; + const file = current === 'latest' ? outputPaths(dataDir).html : rangeOutputPaths(dataDir, current).html; + notice = openInBrowser(file) ? 'opened the HTML report in the browser' : `no opener found; the report is at ${file}`; + } else if (/^[1-7]$/.test(e.name)) { + const kind = KINDS[Number(e.name) - 1]!; + if (filters.kinds.has(kind)) filters.kinds.delete(kind); else filters.kinds.add(kind); + selected = 0; + } else { + const key = (Object.keys(RANGE_HOTKEY) as RangeKey[]).find((k) => RANGE_HOTKEY[k] === e.name); + if (key) pickRange(key); + return; + } app.invalidate(); }); @@ -113,42 +211,68 @@ export async function showTui(dataDir: string): Promise { if (e.action === 'move') { hovered = -1; hHovered = -1; app.invalidate(); } }); - const sel = (): ReportMover | undefined => movers[selected]; + if (opts.range) pickRange(opts.range); app.render(({ ui, theme, width, height }) => { - const bodyRows = Math.max(3, height - 8); - // The histogram draws one cell per value; repeat each day so fourteen days - // fill the detail pane instead of its left fifth. - const paneCols = Math.max(14, Math.floor((width - 3) / 2) - 4); - const perDay = Math.max(1, Math.floor(paneCols / 14)); - const widen = (values: number[]): number[] => values.flatMap((v) => Array(perDay).fill(v)); + const rows = visible(); + const bodyRows = Math.max(3, height - 13); + if (selected >= rows.length) selected = Math.max(0, rows.length - 1); if (selected < offset) offset = selected; if (selected >= offset + bodyRows) offset = selected - bodyRows + 1; if (hSelected < hOffset) hOffset = hSelected; if (hSelected >= hOffset + bodyRows) hOffset = hSelected - bodyRows + 1; + const paneCols = Math.max(14, Math.floor((width - 3) / 2) - 4); ui.column({ padding: 0 }, (col) => { - col.tabs({ tabs: ['Movers', 'History'], active: tab, size: 1, variant: 'underline', onSelect: (i) => { tab = i; app.invalidate(); } }); + // Range row: one button per range, the current one lit, all clickable. + col.buttons([ + { label: 'latest', variant: current === 'latest' ? 'primary' : 'ghost', onPress: () => pickRange('latest') }, + ...RANGE_KEYS.map((k) => ({ + label: `${RANGE_LABEL[k].replace('last ', '')}${scanning === k ? ' …' : ''}`, + variant: (current === k ? 'primary' : 'ghost') as 'primary' | 'ghost', + onPress: () => pickRange(k), + })), + ], { size: 1 }); + // Filter rows: kinds and private on one, owners on the next (eleven orgs do not fit beside the kinds). + col.row({ size: 1, gap: 2 }, (r) => { + for (const k of KINDS) { + r.checkbox({ label: k, checked: filters.kinds.has(k), variant: 'toggle', width: k.length + 5, onToggle: () => { if (filters.kinds.has(k)) filters.kinds.delete(k); else filters.kinds.add(k); selected = 0; } }); + } + r.spacer(1); + r.checkbox({ label: 'private', checked: filters.showPrivate, variant: 'toggle', width: 12, onToggle: () => { filters.showPrivate = !filters.showPrivate; selected = 0; } }); + }); + col.row({ size: 1, gap: 2 }, (r) => { + for (const o of owners()) { + r.checkbox({ label: o, checked: !filters.hiddenOwners.has(o), width: o.length + 5, onToggle: () => { if (filters.hiddenOwners.has(o)) filters.hiddenOwners.delete(o); else filters.hiddenOwners.add(o); selected = 0; } }); + } + }); + + if (!report) { + col.panel({ title: scanning ? `Scanning ${RANGE_LABEL[scanning]}` : 'No report' }, (p) => { p.text(scanLine || notice || 'nothing loaded yet', { fg: theme.muted }); }); + return; + } + const rep = report; + const rangeLabel = rep.range ? rep.range.label : `since last run (${fmtStamp(rep.since)})`; col.row({ size: 4, gap: 2 }, (r) => { r.keyValues([ - { label: 'Report', value: fmtStamp(report.at) }, - { label: 'Window since', value: fmtStamp(report.since) }, - { label: 'Moved', value: report.totals.repos ? `${movers.length} of ${report.totals.repos} repos` : String(movers.length) }, - { label: 'Followers', value: `${report.followers} (+${report.followersGained.length} / -${report.followersLost.length})` }, + { label: 'Report', value: `${fmtStamp(rep.at)} · ${rangeLabel}` }, + { label: 'Coverage', value: rep.range ? rep.range.coverage : `${rep.trafficLabel}, newest GitHub day ${rep.newestTrafficDay}` }, + { label: 'Moved', value: `${rows.length} shown of ${rep.movers.length} movers, ${rep.totals.repos} repos` }, + { label: 'Followers', value: `${rep.followers} (+${rep.followersGained.length} / -${rep.followersLost.length})` }, ], { width: '1fr' }); r.keyValues([ - { label: 'Stars', value: `${report.totals.stars}${report.totals.starsDelta === null ? '' : ` (${report.totals.starsDelta >= 0 ? '+' : ''}${report.totals.starsDelta})`}`, color: theme.accent }, - { label: 'Views', value: `${report.totals.views} / ${report.totals.uniques} unique` }, - { label: 'Clones', value: `${report.totals.clones} / ${report.totals.cloners} unique` }, - { label: 'Traffic', value: `${report.trafficLabel}, newest GitHub day ${report.newestTrafficDay}` }, + { label: 'Stars', value: `${rep.totals.stars}${rep.totals.starsDelta === null ? '' : ` (${rep.totals.starsDelta >= 0 ? '+' : ''}${rep.totals.starsDelta})`}`, color: theme.accent }, + { label: 'Views', value: `${rep.totals.views} / ${rep.totals.uniques} unique` }, + { label: 'Clones', value: `${rep.totals.clones} / ${rep.totals.cloners} unique` }, + { label: scanning ? 'Scanning' : 'Status', value: scanning ? `${RANGE_LABEL[scanning]}: ${scanLine}` : (notice || 'ready'), color: scanning ? theme.warning : theme.muted }, ], { width: '1fr' }); }); if (tab === 0) { col.grid({ columns: ['1fr', '1fr'], rows: ['1fr'], gap: 1 }, (grid) => { - grid.panel({ title: `Ranked by movement (${movers.length})`, footer: notice || '↑/↓ move · click selects · o opens HTML · 2 history · q quit' }, (p) => { + grid.panel({ title: `Ranked by movement, ${rangeLabel} (${rows.length})`, footer: '↑/↓ move · click selects · 1-7 kinds · h d w m q y a l range · r rescan · o HTML · Tab history · q quit' }, (p) => { p.table({ - rows: movers, + rows, selected, hovered, offset, @@ -156,35 +280,40 @@ export async function showTui(dataDir: string): Promise { scrollbar: true, zebra: true, columns: [ - { key: 'rank', title: '#', width: 4, align: 'right', render: (_r, i) => String(i + 1) }, - { key: 'repo', title: 'Repo', color: theme.primary }, - { key: 'score', title: 'pts', width: 6, align: 'right', render: (r) => r.score.toFixed(0) }, - { key: 'bits', title: 'Movement', render: (r) => [...movementBits(r.movement), ...trafficBits(r.movement)].join(' · ') }, + { key: 'rank', title: '#', width: 4, align: 'right', render: (r) => String(r.rank) }, + { key: 'repo', title: 'Repo', color: theme.primary, render: (r) => r.mover.repo }, + { key: 'score', title: 'pts', width: 6, align: 'right', render: (r) => r.mover.score.toFixed(0) }, + { key: 'bits', title: 'Movement', render: (r) => [...movementBits(r.mover.movement), ...trafficBits(r.mover.movement)].join(' · ') }, ], onSelectRow: (row) => { selected = offset + row; app.invalidate(); }, onHoverRow: (row) => { hovered = row === null ? -1 : offset + row; app.invalidate(); }, onScroll: (delta) => { move(delta); app.invalidate(); }, }); }); - const x = sel(); - grid.panel({ title: x ? x.repo : 'Nothing moved', subtitle: x ? `★ ${x.stars} · ⑂ ${x.forks} · ${x.score.toFixed(0)} pts` : '' }, (p) => { - if (!x) return; + const x = rows[selected]?.mover; + grid.panel({ title: x ? x.repo : 'Nothing matches', subtitle: x ? `★ ${x.stars} · ⑂ ${x.forks} · ${x.score.toFixed(0)} pts` : '' }, (p) => { + if (!x) { p.text('Switch a filter back on, or pick another range.', { fg: theme.muted }); return; } const m = x.movement; - const v14 = x.views14d.reduce((t, d) => t + d.count, 0); - const c14 = x.clones14d.reduce((t, d) => t + d.count, 0); + const vs = x.views14d; + const cs = x.clones14d; + const vTotal = vs.reduce((t, d) => t + d.count, 0); + const cTotal = cs.reduce((t, d) => t + d.count, 0); + const perBar = Math.max(1, Math.floor(paneCols / Math.max(1, vs.length))); + const widen = (values: number[]): number[] => values.flatMap((v) => Array(perBar).fill(v)); + const span = vs.length ? `${vs[0]!.day} to ${rep.newestTrafficDay}` : ''; p.text(movementBits(m).join(' · ') || 'traffic only', { fg: theme.muted, size: 1 }); p.keyValues([ - { label: `Views (${report.trafficLabel})`, value: `${m.views} / ${m.uniques} unique`, color: theme.info }, - { label: `Clones (${report.trafficLabel})`, value: `${m.clones} / ${m.cloners} unique`, color: theme.warning }, + { label: `Views (${rangeLabel})`, value: `${m.views} / ${m.uniques} unique`, color: theme.info }, + { label: `Clones (${rangeLabel})`, value: `${m.clones} / ${m.cloners} unique`, color: theme.warning }, ], { size: 2 }); - p.text(`Views per day, ${x.views14d[0]?.day ?? ''} to ${report.newestTrafficDay} (${v14} in 14d, peak ${Math.max(0, ...x.views14d.map((d) => d.count))})`, { fg: theme.muted, size: 1 }); - p.histogram({ values: widen(x.views14d.map((d) => d.count)), color: theme.info, size: 5 }); - p.text(`Clones per day (${c14} in 14d, peak ${Math.max(0, ...x.clones14d.map((d) => d.count))})`, { fg: theme.muted, size: 1 }); - p.histogram({ values: widen(x.clones14d.map((d) => d.count)), color: theme.warning, size: 5 }); + p.text(`Views, ${span} (${vTotal} charted, peak ${Math.max(0, ...vs.map((d) => d.count))})`, { fg: theme.muted, size: 1 }); + p.histogram({ values: widen(vs.map((d) => d.count)), color: theme.info, size: 5 }); + p.text(`Clones (${cTotal} charted, peak ${Math.max(0, ...cs.map((d) => d.count))})`, { fg: theme.muted, size: 1 }); + p.histogram({ values: widen(cs.map((d) => d.count)), color: theme.warning, size: 5 }); const lines: string[] = []; - if (x.referrers.length) lines.push(`Referrers: ${x.referrers.slice(0, 5).map((q) => `${q.referrer} ${q.count}/${q.uniques}`).join(' · ')}`); - if (x.paths.length) lines.push(`Popular: ${x.paths.slice(0, 4).map((q) => `${q.path.replace(`/${x.repo}`, '') || '/'} ${q.count}/${q.uniques}`).join(' · ')}`); - if (m.newStargazers.length) lines.push(`Starred by ${m.newStargazers.slice(0, 10).join(', ')}`); + if (x.referrers.length) lines.push(`Referrers (14d): ${x.referrers.slice(0, 5).map((q) => `${q.referrer} ${q.count}/${q.uniques}`).join(' · ')}`); + if (x.paths.length) lines.push(`Popular (14d): ${x.paths.slice(0, 4).map((q) => `${q.path.replace(`/${x.repo}`, '') || '/'} ${q.count}/${q.uniques}`).join(' · ')}`); + if (m.newStargazers.length) lines.push(`Starred by ${m.newStargazers.slice(0, 10).join(', ')}${m.newStargazers.length > 10 ? ` and ${m.newStargazers.length - 10} more` : ''}`); if (m.newForks.length) lines.push(`Forked by ${m.newForks.slice(0, 6).map((f) => f.split('/')[0]).join(', ')}`); if (m.authors.length) lines.push(`Commits by ${m.authors.slice(0, 6).join(', ')}`); for (const pr of m.mergedPrs.slice(0, 5)) lines.push(`merged #${pr.n} ${pr.t}`); @@ -197,7 +326,7 @@ export async function showTui(dataDir: string): Promise { }); }); } else { - col.panel({ title: `History (${history.length} snapshot${history.length === 1 ? '' : 's'})`, footer: notice || 'one row per run · ↑/↓ move · 1 movers · q quit' }, (p) => { + col.panel({ title: `History (${history.length} snapshot${history.length === 1 ? '' : 's'})`, footer: 'one row per daily run · ↑/↓ move · Tab movers · q quit' }, (p) => { const stars = history.map((h) => h.stars); const followers = history.map((h) => h.followers); p.sparkline({ values: stars, label: 'Stars', text: String(stars.at(-1) ?? 0), color: theme.accent, size: 1 }); diff --git a/src/gh-pulse.ts b/src/gh-pulse.ts index 249691a..c1a972b 100644 --- a/src/gh-pulse.ts +++ b/src/gh-pulse.ts @@ -169,6 +169,21 @@ export interface ReportContext { port: Portfolio; trafficBlind: number; portfolioImg: string; + /** Set by a range scan; the daily run leaves it out. */ + range?: RangeInfo; + /** The per-repo day series a range scan charts; the daily run uses fourteenDays. */ + series?: (x: RepoState, kind: 'views' | 'clones') => Day[]; +} + +export interface RangeInfo { + key: RangeKey | 'custom'; + label: string; + since: string; + fromDay: string; + toDay: string; + coverage: string; + partialRepos: number; + days: number; } export interface Image { @@ -353,6 +368,172 @@ export function baselineFor(prev: Snapshot | null, now: Date): Snapshot | null { return ageH >= 6 && ageH <= 96 ? prev : null; } +// ---------------------------------------------------------------- ranges + +export const RANGE_KEYS = ['hour', 'day', 'week', 'month', 'quarter', 'year', 'all'] as const; +export type RangeKey = (typeof RANGE_KEYS)[number]; + +const DAY_MS = 86400000; +export const RANGE_MS: Record = { + hour: 3600000, day: DAY_MS, week: 7 * DAY_MS, month: 30 * DAY_MS, quarter: 91 * DAY_MS, year: 365 * DAY_MS, all: Number.POSITIVE_INFINITY, +}; +export const RANGE_LABEL: Record = { + hour: 'last hour', day: 'last 24 hours', week: 'last 7 days', month: 'last 30 days', quarter: 'last 90 days', year: 'last 365 days', all: 'all time', +}; +const RANGE_ALIASES: Record = { + h: 'hour', '1h': 'hour', hour: 'hour', lasthour: 'hour', + d: 'day', '1d': 'day', '24h': 'day', day: 'day', today: 'day', yesterday: 'day', lastday: 'day', + w: 'week', '7d': 'week', '1w': 'week', week: 'week', lastweek: 'week', + m: 'month', '30d': 'month', '1m': 'month', month: 'month', lastmonth: 'month', + q: 'quarter', '90d': 'quarter', '3m': 'quarter', quarter: 'quarter', lastquarter: 'quarter', + y: 'year', '365d': 'year', '1y': 'year', '12m': 'year', year: 'year', lastyear: 'year', + a: 'all', all: 'all', alltime: 'all', 'all-time': 'all', ever: 'all', forever: 'all', +}; + +/** What one report covers: a named range, or a custom start date. */ +export interface RangeSpec { + key: RangeKey | 'custom'; + since: Date; + label: string; + /** File-safe name for out/range/.* */ + slug: string; +} + +export function parseRangeKey(text: string): RangeKey | null { + return RANGE_ALIASES[text.trim().toLowerCase().replace(/[\s_]+/g, '')] ?? null; +} + +export function rangeSpec(key: RangeKey, now: Date): RangeSpec { + const since = key === 'all' ? new Date(0) : new Date(now.getTime() - RANGE_MS[key]); + return { key, since, label: RANGE_LABEL[key], slug: key }; +} + +/** The cache name: a `--repo` subset gets its own file so the TUI never shows three repos as the whole portfolio. */ +export const rangeSlug = (spec: RangeSpec, repos: readonly string[]): string => (repos.length ? `${spec.slug}-subset` : spec.slug); + +export function customRange(sinceText: string, now: Date): RangeSpec { + const t = Date.parse(sinceText.length === 10 ? `${sinceText}T00:00:00Z` : sinceText); + if (!Number.isFinite(t) || t > now.getTime()) throw new Error(`--since wants a past date like 2026-09-01, not ${sinceText}`); + const since = new Date(t); + return { key: 'custom', since, label: `since ${since.toISOString().slice(0, 10)}`, slug: `since-${since.toISOString().slice(0, 10)}` }; +} + +export const rangeDays = (spec: RangeSpec, now: Date): number => + spec.key === 'all' ? Number.POSITIVE_INFINITY : Math.max(1, Math.ceil((now.getTime() - spec.since.getTime()) / DAY_MS)); + +/** Commits are capped per day so one busy afternoon cannot outrank a star; longer ranges get a proportionally larger cap. */ +export const commitCapFor = (days: number): number => (Number.isFinite(days) ? COMMIT_CAP * Math.max(1, days) : Number.POSITIVE_INFINITY); + +/** How many pages each endpoint may be walked for a range. Longer ranges pay more; `all` pays what it takes, within reason. */ +export interface PageBudget { commits: number; pulls: number; issues: number; releases: number; stars: number; forks: number } +export function pageBudget(key: RangeKey | 'custom' | 'daily', days = 1): PageBudget { + const k = key === 'custom' ? (days <= 1 ? 'day' : days <= 7 ? 'week' : days <= 31 ? 'month' : days <= 92 ? 'quarter' : days <= 366 ? 'year' : 'all') : key; + switch (k) { + case 'daily': case 'hour': case 'day': return { commits: 3, pulls: 1, issues: 2, releases: 1, stars: 2, forks: 1 }; + case 'week': return { commits: 5, pulls: 3, issues: 3, releases: 1, stars: 3, forks: 1 }; + case 'month': return { commits: 15, pulls: 10, issues: 8, releases: 2, stars: 5, forks: 2 }; + case 'quarter': return { commits: 30, pulls: 20, issues: 15, releases: 3, stars: 10, forks: 3 }; + case 'year': return { commits: 60, pulls: 40, issues: 30, releases: 5, stars: 20, forks: 5 }; + default: return { commits: 100, pulls: 100, issues: 100, releases: 10, stars: 60, forks: 10 }; + } +} + +// ---------------------------------------------------------------- ledger +// +// GitHub keeps traffic for fourteen days. Every daily snapshot stores that +// window, so laying the snapshots end to end gives a per-repo, per-day series +// that only grows. The newest snapshot that mentions a day wins, because +// GitHub revises a day's numbers for a while after first publishing them. + +export interface Ledger { + views: Map>; + clones: Map>; + /** One point per snapshot, oldest first. */ + points: { at: string; stars: number; followers: string[]; starsByRepo: Map; forksByRepo: Map }[]; + firstDay: string | null; +} + +export function buildLedger(snapshots: Snapshot[]): Ledger { + const ledger: Ledger = { views: new Map(), clones: new Map(), points: [], firstDay: null }; + for (const s of [...snapshots].sort((a, b) => a.at.localeCompare(b.at))) { + const starsByRepo = new Map(); + const forksByRepo = new Map(); + let stars = 0; + for (const [name, r] of Object.entries(s.repos)) { + starsByRepo.set(name, r.stars); + forksByRepo.set(name, r.forks); + stars += r.stars; + mergeBuckets(ledger, name, r.views, r.clones); + } + ledger.points.push({ at: s.at, stars, followers: s.followers, starsByRepo, forksByRepo }); + } + return ledger; +} + +export function mergeBuckets(ledger: Ledger, name: string, views: Bucket[] | null, clones: Bucket[] | null): void { + for (const [kind, buckets] of [['views', views], ['clones', clones]] as const) { + if (!buckets) continue; + let m = ledger[kind].get(name); + if (!m) { m = new Map(); ledger[kind].set(name, m); } + for (const b of buckets) { + const day = b.timestamp.slice(0, 10); + m.set(day, { timestamp: `${day}T00:00:00Z`, count: b.count, uniques: b.uniques }); + if (ledger.firstDay === null || day < ledger.firstDay) ledger.firstDay = day; + } + } +} + +/** Views or clones for one repo between two days inclusive. */ +export function ledgerSum(series: Map | undefined, fromDay: string, toDay: string): { count: number; uniques: number } { + let count = 0; + let uniques = 0; + for (const [day, b] of series ?? []) { + if (day >= fromDay && day <= toDay) { count += b.count; uniques += b.uniques; } + } + return { count, uniques }; +} + +/** A zero-filled day series between two days inclusive, oldest first. */ +export function ledgerDays(series: Map | undefined, fromDay: string, toDay: string): Day[] { + const out: Day[] = []; + const start = Date.parse(`${fromDay}T00:00:00Z`); + const end = Date.parse(`${toDay}T00:00:00Z`); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return out; + for (let t = start; t <= end; t += DAY_MS) { + const day = new Date(t).toISOString().slice(0, 10); + const b = series?.get(day); + out.push({ day, count: b ? b.count : 0, uniques: b ? b.uniques : 0 }); + } + return out; +} + +/** Fold a long day series into at most `maxBars` bars so a year still reads as a chart. Each bar is labelled by its first day. */ +export function foldDays(days: Day[], maxBars: number): Day[] { + if (days.length <= maxBars) return days; + const size = Math.ceil(days.length / maxBars); + const out: Day[] = []; + for (let i = 0; i < days.length; i += size) { + const chunk = days.slice(i, i + size); + out.push({ day: chunk[0]!.day, count: chunk.reduce((t, d) => t + d.count, 0), uniques: chunk.reduce((t, d) => t + d.uniques, 0) }); + } + return out; +} + +/** + * The newest snapshot taken at or before `since`, else null. A snapshot from + * inside the range is not a baseline: measured against it, a week's stars + * would shrink to whatever arrived since this morning. + */ +export function pointAt(ledger: Ledger, since: Date): Ledger['points'][number] | null { + const sinceIso = since.toISOString(); + let before: Ledger['points'][number] | null = null; + for (const p of ledger.points) { + if (p.at <= sinceIso) before = p; + else break; + } + return before; +} + // ---------------------------------------------------------------- snapshots export function snapshotsDir(dir: string): string { @@ -518,7 +699,7 @@ function bars({ title, color, days }: ChartSeries, x0: number, y0: number, w: nu if (d.count && (i === peak || i === n - 1)) { s += `${d.count}`; } - if (i === 0 || i === n - 1 || i === 7) { + if (i === 0 || i === n - 1 || i === Math.floor(n / 2)) { s += `${d.day.slice(5)}`; } }); @@ -582,28 +763,29 @@ export function renderHtml(c: ReportContext): string { `
${value}
` + (sub ? `
${sub}
` : '') + ''; const n = (v: number): string => v.toLocaleString('en-US'); - const starDelta = c.prevTotalStars === null ? 'all repos' : `${signed(c.totalStars - c.prevTotalStars)} in window`; + const inWindow = c.range ? c.range.label : 'in window'; + const starDelta = c.prevTotalStars === null ? 'all repos' : c.range?.key === 'all' ? 'all repos' : `${signed(c.totalStars - c.prevTotalStars)} ${inWindow}`; const followerDelta = c.useBaseline - ? `${signed(c.followerMoves.gained.length - c.followerMoves.lost.length)} (${c.followerMoves.gained.length} new, ${c.followerMoves.lost.length} lost)` - : 'baseline captured'; + ? `${signed(c.followerMoves.gained.length - c.followerMoves.lost.length)} (${c.followerMoves.gained.length} new, ${c.followerMoves.lost.length} lost)${c.range ? ` ${c.range.label}` : ''}` + : c.range ? `no snapshot yet from the start of the range` : 'baseline captured'; const userLink = (l: string): string => `${esc(l)}`; const refLink = (p: Ref): string => `#${p.n} ${esc(p.t)}`; let h = `
-
GitHub pulse
-
${esc(c.login)} · ${fmtWhen(c.now)} · window ${windowH}h since ${fmtWhen(c.cutoff)}${c.useBaseline ? '' : ' (no previous snapshot; clock window)'}
+
GitHub pulse${c.range ? ` · ${esc(c.range.label)}` : ''}
+
${esc(c.login)} · ${fmtWhen(c.now)} · ${c.range ? esc(c.range.coverage) : `window ${windowH}h since ${fmtWhen(c.cutoff)}${c.useBaseline ? '' : ' (no previous snapshot; clock window)'}`}
${tile('Repos moved', movers.length, `of ${c.repoCount} scanned`)} ${tile('Stars', n(c.totalStars), starDelta)} ${tile('Views', n(c.port.views), `${n(c.port.uniques)} unique visitors · ${esc(c.trafficLabel)}`)} ${tile('Clones', n(c.port.clones), `${n(c.port.cloners)} unique cloners · ${esc(c.trafficLabel)}`)}
-
Followers: ${n(c.followers.length)} · ${followerDelta}${c.trafficBlind ? ` · traffic unavailable for ${c.trafficBlind} repos (no push access)` : ''}
-14-day views and clones across all repos +
Followers: ${n(c.followers.length)} · ${followerDelta}${c.trafficBlind ? ` · traffic unavailable for ${c.trafficBlind} repos (no push access)` : ''}${c.range?.partialRepos ? ` · counts for ${c.range.partialRepos} repos are lower bounds (page budget reached)` : ''}
+${c.range ? esc(c.range.label) : '14-day'} views and clones across all repos `; - h += '
Ranked by movement
'; + h += `
Ranked by movement${c.range ? `, ${esc(c.range.label)}` : ''}
`; if (movers.length === 0) h += '
Nothing moved in the window.
'; h += ''; movers.slice(0, 40).forEach((x, i) => { @@ -627,7 +809,7 @@ ${tile('Clones', n(c.port.clones), `${n(c.port.cloners)} unique cloners · ${esc
${esc(movementBits(m).join(' · ') || 'traffic only')}
`; if (x.trafficOk) { h += `
- +
${esc(c.trafficLabel)}14 days to ${esc(c.newestDay)}
${esc(c.range ? c.range.label : c.trafficLabel)}14 days to ${esc(c.newestDay)}
Views / unique visitors${m.views} / ${m.uniques}${v14}
Clones / unique cloners${m.clones} / ${m.cloners}${c14}
`; @@ -671,8 +853,8 @@ ${tile('Clones', n(c.port.clones), `${n(c.port.cloners)} unique cloners · ${esc export function renderText(c: ReportContext): string { const out: string[] = []; - out.push(`GITHUB PULSE ${isoDay(c.now)} (${c.login})`); - out.push(`window since ${c.cutoff.toISOString()} · traffic: ${c.trafficLabel}, newest GitHub day ${c.newestDay}`); + out.push(`GITHUB PULSE ${isoDay(c.now)} (${c.login})${c.range ? ` · ${c.range.label}` : ''}`); + out.push(c.range ? c.range.coverage : `window since ${c.cutoff.toISOString()} · traffic: ${c.trafficLabel}, newest GitHub day ${c.newestDay}`); out.push(''); out.push(`${c.movers.length} of ${c.repoCount} repos moved · stars ${c.totalStars}${c.prevTotalStars === null ? '' : ` (${signed(c.totalStars - c.prevTotalStars)})`} · views ${c.port.views}/${c.port.uniques} unique · clones ${c.port.clones}/${c.port.cloners} unique · followers ${c.followers.length} (${signed(c.followerMoves.gained.length - c.followerMoves.lost.length)})`); out.push(''); @@ -695,6 +877,8 @@ export interface ReportJson { followersLost: string[]; totals: { repos: number; stars: number; starsDelta: number | null; views: number; uniques: number; clones: number; cloners: number }; movers: ReportMover[]; + /** Present on range reports. */ + range?: RangeInfo; } export interface ReportMover { @@ -717,6 +901,7 @@ export function reportJson(c: ReportContext): ReportJson { since: c.cutoff.toISOString(), newestTrafficDay: c.newestDay, trafficLabel: c.trafficLabel, + ...(c.range ? { range: c.range } : {}), user: c.login, followers: c.followers.length, followersGained: c.followerMoves.gained, @@ -728,7 +913,8 @@ export function reportJson(c: ReportContext): ReportJson { movers: c.movers.map((x) => ({ repo: x.repo.full_name, private: x.repo.private, url: x.repo.html_url, stars: x.repo.stargazers_count, forks: x.repo.forks_count, score: Number(x.m.score.toFixed(2)), movement: x.m, - views14d: fourteenDays(x.views, c.newestDay), clones14d: fourteenDays(x.clones, c.newestDay), + views14d: c.series ? c.series(x, 'views') : fourteenDays(x.views, c.newestDay), + clones14d: c.series ? c.series(x, 'clones') : fourteenDays(x.clones, c.newestDay), referrers: x.referrers ?? [], paths: x.paths ?? [], })), }; @@ -790,9 +976,77 @@ export async function sendFailure(error: unknown, to: string, from: string, key: interface StarEntry { starred_at: string; user: { login: string } } interface ForkEntry { full_name: string; created_at: string } interface CommitEntry { author?: { login?: string } | null; commit?: { author?: { name?: string } } } -interface PullEntry { number: number; title: string; created_at: string; merged_at: string | null; closed_at: string | null; user?: { login?: string }; html_url: string } +interface PullEntry { number: number; title: string; created_at: string; updated_at: string; merged_at: string | null; closed_at: string | null; user?: { login?: string }; html_url: string } interface IssueEntry { number: number; title: string; created_at: string; closed_at: string | null; user?: { login?: string }; html_url: string; pull_request?: unknown } -interface ReleaseEntry { tag_name: string; published_at: string | null; html_url: string } +interface ReleaseEntry { tag_name: string; published_at: string | null; created_at: string; html_url: string } + +/** Walk pages newest-first until `stop` says the rest is older than we need, or the budget is spent. Returns whether the walk was cut short. */ +async function walk(gh: GitHub, path: string, max: number, stop: (item: T) => boolean, options: { accept?: string; allow?: number[] } = {}): Promise<{ items: T[]; partial: boolean }> { + const items: T[] = []; + let url: string | undefined = path; + for (let i = 0; url; i += 1) { + if (i >= max) return { items, partial: true }; + const { data, link } = await gh.get(url, options); + if (!Array.isArray(data)) break; + items.push(...data); + const last = data[data.length - 1]; + if (last !== undefined && stop(last)) break; + url = nextLink(link); + } + return { items, partial: false }; +} + +/** Commits, PRs, issues and releases since `cutoffMs`, written into `m`. */ +export async function collectEvents(gh: GitHub, name: string, cutoffMs: number, budget: PageBudget, m: Movement): Promise { + const cutoffIso = new Date(cutoffMs).toISOString(); + const older = (iso: string | null | undefined): boolean => !!iso && Date.parse(iso) < cutoffMs; + const [commits, pulls, issues, releases] = await Promise.all([ + walk(gh, `/repos/${name}/commits?since=${cutoffIso}&per_page=100`, budget.commits, () => false, { allow: [409, 404] }), + walk(gh, `/repos/${name}/pulls?state=all&sort=updated&direction=desc&per_page=100`, budget.pulls, (p) => older(p.updated_at), { allow: [404] }), + walk(gh, `/repos/${name}/issues?state=all&since=${cutoffIso}&sort=updated&per_page=100`, budget.issues, () => false, { allow: [404] }), + walk(gh, `/repos/${name}/releases?per_page=100`, budget.releases, (r) => older(r.published_at ?? r.created_at), { allow: [404] }), + ]); + m.commits = commits.items.length; + m.authors = [...new Set(commits.items.map((c) => c.author?.login ?? c.commit?.author?.name).filter((a): a is string => !!a))]; + for (const pr of pulls.items) { + const ref: Ref = { n: pr.number, t: pr.title, u: pr.user?.login ?? '', url: pr.html_url }; + if (Date.parse(pr.created_at) >= cutoffMs) { m.prOpened += 1; m.openedPrs.push(ref); } + if (pr.merged_at && Date.parse(pr.merged_at) >= cutoffMs) { m.prMerged += 1; m.mergedPrs.push(ref); } + else if (pr.closed_at && Date.parse(pr.closed_at) >= cutoffMs) m.prClosed += 1; + } + for (const is of issues.items) { + if (is.pull_request) continue; + if (Date.parse(is.created_at) >= cutoffMs) { m.issuesOpened += 1; m.openedIssues.push({ n: is.number, t: is.title, u: is.user?.login ?? '', url: is.html_url }); } + if (is.closed_at && Date.parse(is.closed_at) >= cutoffMs) m.issuesClosed += 1; + } + for (const rel of releases.items) { + if (rel.published_at && Date.parse(rel.published_at) >= cutoffMs) { m.releases += 1; m.releaseList.push({ tag: rel.tag_name, url: rel.html_url }); } + } + return commits.partial || pulls.partial || issues.partial || releases.partial; +} + +/** Stargazers since `cutoffMs`, newest first. GitHub lists stars oldest first, so the walk starts at the LAST page and moves back. */ +export async function collectStars(gh: GitHub, name: string, cutoffMs: number, maxPages: number): Promise<{ logins: string[]; partial: boolean }> { + const accept = 'application/vnd.github.star+json'; + const first = await gh.get(`/repos/${name}/stargazers?per_page=100`, { accept, allow: [404] }); + if (!first.data) return { logins: [], partial: false }; + const last = lastPage(first.link); + const logins: string[] = []; + let partial = false; + for (let page = last, walked = 0; page >= 1; page -= 1, walked += 1) { + if (walked >= maxPages) { partial = true; break; } + const data = page === 1 ? first.data : (await gh.get(`/repos/${name}/stargazers?per_page=100&page=${page}`, { accept })).data ?? []; + const recent = data.filter((s) => Date.parse(s.starred_at) >= cutoffMs); + logins.push(...recent.map((s) => s.user.login).reverse()); + if (recent.length < data.length) break; // the rest of this page, and every earlier page, is older + } + return { logins, partial }; +} + +export async function collectForks(gh: GitHub, name: string, cutoffMs: number, maxPages: number): Promise<{ names: string[]; partial: boolean }> { + const r = await walk(gh, `/repos/${name}/forks?sort=newest&per_page=100`, maxPages, (f) => Date.parse(f.created_at) < cutoffMs, { allow: [404] }); + return { names: r.items.filter((f) => Date.parse(f.created_at) >= cutoffMs).map((f) => f.full_name), partial: r.partial }; +} export const defaultDeps = (): RunDeps => ({ fetch: globalThis.fetch, @@ -863,50 +1117,22 @@ export async function run(opt: RunOptions, deps: RunDeps = defaultDeps()): Promi } // 4. detail for candidates + const budget = pageBudget('daily'); await Promise.all([...R.values()].map((x) => gh.limit(async () => { const n = x.repo.full_name; const m = x.m; const hasBaseline = prevRepos.has(n); if (needStars.has(n)) { - // Newest stargazers live on the LAST page. - const first = await gh.get(`/repos/${n}/stargazers?per_page=100`, { accept: 'application/vnd.github.star+json', allow: [404] }); - let page = first.data ?? []; - const last = lastPage(first.link); - if (last > 1) page = (await gh.get(`/repos/${n}/stargazers?per_page=100&page=${last}`, { accept: 'application/vnd.github.star+json' })).data ?? []; - const recent = page.filter((s) => Date.parse(s.starred_at) >= cutoffMs); - m.newStargazers = recent.map((s) => s.user.login).reverse(); - if (!hasBaseline) m.stars = recent.length; + const { logins } = await collectStars(gh, n, cutoffMs, budget.stars); + m.newStargazers = logins; + if (!hasBaseline) m.stars = logins.length; } if (needForks.has(n)) { - const f = await gh.get(`/repos/${n}/forks?sort=newest&per_page=30`, { allow: [404] }); - const recent = (f.data ?? []).filter((k) => Date.parse(k.created_at) >= cutoffMs); - m.newForks = recent.map((k) => k.full_name); - if (!hasBaseline) m.forks = recent.length; - } - if (needEvents.has(n)) { - const [commits, pulls, issues, releases] = await Promise.all([ - gh.all(`/repos/${n}/commits?since=${cutoffIso}&per_page=100`, { max: 3, allow: [409, 404] }), - gh.get(`/repos/${n}/pulls?state=all&sort=updated&direction=desc&per_page=60`, { allow: [404] }), - gh.all(`/repos/${n}/issues?state=all&since=${cutoffIso}&sort=updated&per_page=100`, { max: 2, allow: [404] }), - gh.get(`/repos/${n}/releases?per_page=10`, { allow: [404] }), - ]); - m.commits = commits.length; - m.authors = [...new Set(commits.map((c) => c.author?.login ?? c.commit?.author?.name).filter((a): a is string => !!a))]; - for (const pr of pulls.data ?? []) { - const ref: Ref = { n: pr.number, t: pr.title, u: pr.user?.login ?? '', url: pr.html_url }; - if (Date.parse(pr.created_at) >= cutoffMs) { m.prOpened += 1; m.openedPrs.push(ref); } - if (pr.merged_at && Date.parse(pr.merged_at) >= cutoffMs) { m.prMerged += 1; m.mergedPrs.push(ref); } - else if (pr.closed_at && Date.parse(pr.closed_at) >= cutoffMs) m.prClosed += 1; - } - for (const is of issues) { - if (is.pull_request) continue; - if (Date.parse(is.created_at) >= cutoffMs) { m.issuesOpened += 1; m.openedIssues.push({ n: is.number, t: is.title, u: is.user?.login ?? '', url: is.html_url }); } - if (is.closed_at && Date.parse(is.closed_at) >= cutoffMs) m.issuesClosed += 1; - } - for (const rel of releases.data ?? []) { - if (rel.published_at && Date.parse(rel.published_at) >= cutoffMs) { m.releases += 1; m.releaseList.push({ tag: rel.tag_name, url: rel.html_url }); } - } + const { names } = await collectForks(gh, n, cutoffMs, budget.forks); + m.newForks = names; + if (!hasBaseline) m.forks = names.length; } + if (needEvents.has(n)) await collectEvents(gh, n, cutoffMs, budget, m); m.score = score(m); m.mover = isMover(m); }))); @@ -1009,6 +1235,198 @@ export async function run(opt: RunOptions, deps: RunDeps = defaultDeps()): Promi return { subject, movers: movers.length, repos: repos.length, sent, snapshot: snapshotFile, html, calls: gh.calls.made }; } +// ---------------------------------------------------------------- range scans +// +// A range scan answers "what moved in the last week / month / quarter / year / +// ever" on demand. Events come live from GitHub for the whole range; traffic +// comes from the ledger, because GitHub only serves fourteen days of it and the +// ledger is where every earlier day survives. It never moves the daily +// baseline and never writes a snapshot. + +export interface RangeOptions { + spec: RangeSpec; + top: number; + repos: string[]; + dataDir: string; + send: boolean; + to: string; + from: string; + /** Skip the live GitHub calls for events; traffic and star totals still come from the ledger and the listing. */ + progress?: (line: string) => void; +} + +export async function rangeScan(opt: RangeOptions, deps: RunDeps = defaultDeps()): Promise<{ report: ReportJson; html: string; subject: string; sent: boolean; calls: number }> { + const gh = new GitHub(deps.token(), deps.fetch); + const now = deps.now(); + const { spec } = opt; + const cutoffMs = spec.since.getTime(); + const days = rangeDays(spec, now); + const budget = pageBudget(spec.key, days); + const say = opt.progress ?? deps.log; + + const me = (await gh.get<{ login: string; followers: number }>('/user')).data!; + say(`${me.login}, ${spec.label}${spec.key === 'all' ? '' : ` (since ${spec.since.toISOString()})`}`); + + let repos = await gh.all('/user/repos?affiliation=owner,organization_member&per_page=100&sort=pushed', { max: 30 }); + repos = repos.filter((r) => !r.fork); + if (opt.repos.length) repos = repos.filter((r) => opt.repos.includes(r.full_name)); + + // The ledger: every snapshot, plus today's live fourteen days on top. + const ledger = buildLedger(listSnapshots(opt.dataDir).flatMap((f) => { try { return [readSnapshot(f)]; } catch { return []; } })); + const R = new Map(); + await Promise.all(repos.map((r) => gh.limit(async () => { + const [v, c] = await Promise.all([ + gh.get<{ views: Bucket[] }>(`/repos/${r.full_name}/traffic/views`, { allow: [403, 404] }), + gh.get<{ clones: Bucket[] }>(`/repos/${r.full_name}/traffic/clones`, { allow: [403, 404] }), + ]); + const views = v.data?.views ?? null; + const clones = c.data?.clones ?? null; + mergeBuckets(ledger, r.full_name, views, clones); + R.set(r.full_name, { repo: r, views, clones, trafficOk: !!(v.data && c.data), m: emptyMovement() }); + }))); + say(`${repos.length} repos, traffic merged into the ledger`); + + const newest = newestDay([...R.values()].flatMap((x) => [x.views, x.clones]), isoDay(now)); + // Traffic is daily, so an hour is the newest day GitHub has; anything longer is the range clipped to what the ledger has. + const fromDay = spec.key === 'hour' ? newest : spec.key === 'all' ? (ledger.firstDay ?? newest) : isoDay(spec.since) > (ledger.firstDay ?? '0') ? isoDay(spec.since) : (ledger.firstDay ?? isoDay(spec.since)); + const toDay = newest; + const coverage = spec.key === 'hour' + ? `traffic is daily; showing GitHub's newest day, ${newest}` + : `traffic covers ${fromDay} to ${toDay}${ledger.firstDay && isoDay(spec.since) < ledger.firstDay && spec.key !== 'all' ? ' (the ledger starts there; GitHub keeps 14 days and the daily snapshots keep the rest)' : ''}`; + + // Baseline point for followers and net stars: a snapshot from before the range started, if there is one. + const start = spec.key === 'all' ? null : pointAt(ledger, spec.since); + + const cap = commitCapFor(days); + let partialRepos = 0; + await Promise.all([...R.values()].map((x) => gh.limit(async () => { + const r = x.repo; + const n = r.full_name; + const m = x.m; + const dv = ledgerSum(ledger.views.get(n), fromDay, toDay); + const dc = ledgerSum(ledger.clones.get(n), fromDay, toDay); + m.views = dv.count; m.uniques = dv.uniques; m.clones = dc.count; m.cloners = dc.uniques; + let partial = false; + if (spec.key === 'all') { + m.stars = r.stargazers_count; + m.forks = r.forks_count; + if (r.stargazers_count > 0) { const s = await collectStars(gh, n, 0, budget.stars); m.newStargazers = s.logins; partial ||= s.partial; } + if (r.forks_count > 0) { const f = await collectForks(gh, n, 0, budget.forks); m.newForks = f.names; partial ||= f.partial; } + } else { + if (r.stargazers_count > 0) { const s = await collectStars(gh, n, cutoffMs, budget.stars); m.newStargazers = s.logins; m.stars = s.logins.length; partial ||= s.partial; } + if (r.forks_count > 0) { const f = await collectForks(gh, n, cutoffMs, budget.forks); m.newForks = f.names; m.forks = f.names.length; partial ||= f.partial; } + // Net movement can be negative; the snapshot at the start of the range knows. + const then = start?.starsByRepo.get(n); + if (then !== undefined && r.stargazers_count - then < m.stars) m.stars = r.stargazers_count - then; + } + const touched = spec.key === 'all' || Date.parse(r.pushed_at) >= cutoffMs || Date.parse(r.updated_at) >= cutoffMs; + if (touched) partial = (await collectEvents(gh, n, cutoffMs, budget, m)) || partial; + if (partial) partialRepos += 1; + const W = WEIGHTS; + m.score = Math.max(0, m.stars) * W.star + Math.max(0, m.forks) * W.fork + m.uniques * W.unique + m.views * W.view + + m.clones * W.clone + m.cloners * W.cloner + Math.min(m.commits, cap) * W.commit + + m.prOpened * W.prOpened + m.prMerged * W.prMerged + m.prClosed * W.prClosed + + m.issuesOpened * W.issueOpened + m.issuesClosed * W.issueClosed + m.releases * W.release; + m.mover = isMover(m); + }))); + say(`events collected (${gh.calls.made} calls so far)`); + + const movers = [...R.values()].filter((x) => x.m.mover).sort((a, b) => b.m.score - a.m.score); + await Promise.all(movers.map((x) => gh.limit(async () => { + if (!x.trafficOk) return; + const n = x.repo.full_name; + const [ref, paths] = await Promise.all([ + gh.get(`/repos/${n}/traffic/popular/referrers`, { allow: [403, 404] }), + gh.get(`/repos/${n}/traffic/popular/paths`, { allow: [403, 404] }), + ]); + x.referrers = ref.data ?? []; + x.paths = paths.data ?? []; + }))); + + const followers = (await gh.all<{ login: string }>('/user/followers?per_page=100', { max: 60 })).map((u) => u.login).sort(); + const startFollowers = new Set(start?.followers ?? []); + const followerMoves = start + ? { gained: followers.filter((l) => !startFollowers.has(l)), lost: [...startFollowers].filter((l) => !followers.includes(l)) } + : { gained: [], lost: [] }; + + const totalStars = repos.reduce((s, r) => s + r.stargazers_count, 0); + // Only the scanned repos count, so a --repo run does not compare three repos against the whole portfolio. + const prevTotalStars = spec.key === 'all' ? 0 : start + ? repos.reduce((s, r) => s + (start.starsByRepo.get(r.full_name) ?? r.stargazers_count), 0) + : totalStars - [...R.values()].reduce((s, x) => s + Math.max(0, x.m.stars), 0); + const port: Portfolio = { views: 0, uniques: 0, clones: 0, cloners: 0, viewsByDay: new Map(), clonesByDay: new Map() }; + for (const x of R.values()) { + port.views += x.m.views; port.uniques += x.m.uniques; port.clones += x.m.clones; port.cloners += x.m.cloners; + for (const b of ledgerDays(ledger.views.get(x.repo.full_name), fromDay, toDay)) port.viewsByDay.set(b.day, (port.viewsByDay.get(b.day) ?? 0) + b.count); + for (const b of ledgerDays(ledger.clones.get(x.repo.full_name), fromDay, toDay)) port.clonesByDay.set(b.day, (port.clonesByDay.get(b.day) ?? 0) + b.count); + } + const trafficBlind = [...R.values()].filter((x) => !x.trafficOk).length; + + const images: Image[] = []; + const addImage = (id: string, svg: string): string => { images.push({ id, buf: deps.render(svg) }); return `cid:${id}`; }; + const portDays = [...port.viewsByDay.keys()].sort(); + const fold = (d: Day[]): Day[] => foldDays(d, 60); + const portfolioImg = addImage('portfolio', chartPair( + { title: `Views per ${portDays.length > 60 ? 'period' : 'day'}, all repos`, color: '#2a78d6', days: fold(portDays.map((d) => ({ day: d, count: port.viewsByDay.get(d) ?? 0, uniques: 0 }))) }, + { title: `Clones per ${portDays.length > 60 ? 'period' : 'day'}, all repos`, color: '#eb6834', days: fold(portDays.map((d) => ({ day: d, count: port.clonesByDay.get(d) ?? 0, uniques: 0 }))) }, + 640, 170)); + const detailed = movers.slice(0, opt.top); + const seriesFor = (x: RepoState, kind: 'views' | 'clones'): Day[] => fold(ledgerDays(ledger[kind].get(x.repo.full_name), fromDay, toDay)); + for (const x of detailed) { + if (!x.trafficOk) continue; + x.img = addImage(`r${x.repo.id}`, chartPair( + { title: 'Views', color: '#2a78d6', days: seriesFor(x, 'views') }, + { title: 'Clones', color: '#eb6834', days: seriesFor(x, 'clones') }, 640, 120)); + } + + const ctx: ReportContext = { + login: me.login, now, cutoff: spec.key === 'all' ? new Date(`${fromDay}T00:00:00Z`) : spec.since, useBaseline: !!start, newestDay: newest, + trafficLabel: spec.label, repoCount: repos.length, movers, detailed, followers, followerMoves, totalStars, prevTotalStars, port, trafficBlind, portfolioImg, + range: { key: spec.key, label: spec.label, since: spec.since.toISOString(), fromDay, toDay, coverage, partialRepos, days }, + series: seriesFor, + }; + const html = renderHtml(ctx); + const text = renderText(ctx); + const report = reportJson(ctx); + const out = join(opt.dataDir, 'out', 'range'); + const slug = rangeSlug(spec, opt.repos); + mkdirSync(join(out, 'charts'), { recursive: true }); + writeFileSync(join(out, `${slug}.html`), html.replace(/cid:([\w-]+)/g, (_, id: string) => `data:image/png;base64,${images.find((i) => i.id === id)?.buf.toString('base64') ?? ''}`)); + writeFileSync(join(out, `${slug}.txt`), text); + writeFileSync(join(out, `${slug}.json`), `${JSON.stringify(report, null, 2)}\n`); + for (const i of images) writeFileSync(join(out, 'charts', `${slug}-${i.id}.png`), i.buf); + + const subject = `GitHub pulse, ${spec.label}: ${movers.length} repos moved, ${signed(totalStars - prevTotalStars)} stars, ${port.views} views, ${port.clones} clones`; + let sent = false; + if (opt.send) { + const key = deps.resendKey(); + if (!key) throw new Error('RESEND_API_KEY is not set (and not in ~/.config/logicsrc/shell.env)'); + await sendResend({ to: opt.to, from: opt.from, subject, html, text, images }, key, deps.fetch); + sent = true; + say(`sent to ${opt.to}: ${subject}`); + } + say(`${gh.calls.made} API calls (${gh.calls.retries} retries), ${gh.remaining} remaining this hour`); + return { report, html, subject, sent, calls: gh.calls.made }; +} + +/** The cached range report, if one exists and is younger than `maxAgeMs`. */ +export function readRangeReport(dir: string, slug: string, maxAgeMs = 3600000): ReportJson | null { + const p = join(dir, 'out', 'range', `${slug}.json`); + if (!existsSync(p)) return null; + try { + const r = JSON.parse(readFileSync(p, 'utf8')) as ReportJson; + if (Date.now() - Date.parse(r.at) > maxAgeMs) return null; + return r; + } catch { + return null; + } +} + +export function rangeOutputPaths(dir: string, slug: string): { html: string; text: string; json: string } { + const out = join(dir, 'out', 'range'); + return { html: join(out, `${slug}.html`), text: join(out, `${slug}.txt`), json: join(out, `${slug}.json`) }; +} + /** Where the last report's files are, for `show`, `open` and `--json`. */ export function outputPaths(dir: string): { html: string; text: string; json: string } { const out = join(dir, 'out'); diff --git a/src/registry.ts b/src/registry.ts index 0b466d7..86c3e1f 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -49,7 +49,7 @@ const SUMMARIES: Record = { 'gh-prs': 'Every open PR across the owners you name', 'gh-prs-fix-all': 'Repair the open scan PRs that are broken because of us', 'gh-prs-merge': 'Squash-merge the PRs that are genuinely ready', - 'gh-pulse': 'What moved on GitHub since yesterday, ranked, with traffic: by email, TUI or JSON', + 'gh-pulse': 'What moved on GitHub, last hour to all time, ranked, with traffic: by email, TUI or JSON', hqtui: 'Every vital of this box, in the terminal: sockets, HTTP, sessions, services', porkbun: 'Read and change DNS at Porkbun, and un-park a domain', shorten: 'Mint a short link on the pit, and follow it from /f/', diff --git a/test/gh-pulse-range.test.ts b/test/gh-pulse-range.test.ts new file mode 100644 index 0000000..4673edd --- /dev/null +++ b/test/gh-pulse-range.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildLedger, + commitCapFor, + customRange, + emptyMovement, + foldDays, + ledgerDays, + ledgerSum, + mergeBuckets, + pageBudget, + parseRangeKey, + pointAt, + rangeDays, + rangeSpec, + type Bucket, + type Movement, + type ReportMover, + type Snapshot, +} from '../src/gh-pulse.ts'; +import { applyFilters, defaultFilters, movedIn } from '../src/gh-pulse-tui.ts'; + +const b = (day: string, count: number, uniques = count): Bucket => ({ timestamp: `${day}T00:00:00Z`, count, uniques }); +const snap = (at: string, repos: Snapshot['repos'], followers: string[] = []): Snapshot => ({ at, user: 'u', followersCount: followers.length, followers, repos }); +const row = (stars: number, views: Bucket[] | null, clones: Bucket[] | null = null): Snapshot['repos'][string] => + ({ id: 1, stars, forks: 0, openIssues: 0, pushedAt: '', private: false, archived: false, views, clones }); +const now = new Date('2026-09-13T13:05:00Z'); + +describe('ranges', () => { + it('reads every spelling people type', () => { + expect(parseRangeKey('week')).toBe('week'); + expect(parseRangeKey('7d')).toBe('week'); + expect(parseRangeKey('Last Month')).toBe('month'); + expect(parseRangeKey('1h')).toBe('hour'); + expect(parseRangeKey('all-time')).toBe('all'); + expect(parseRangeKey('fortnight')).toBeNull(); + }); + + it('turns a key into a start date and a label', () => { + const w = rangeSpec('week', now); + expect(w.since.toISOString()).toBe('2026-09-06T13:05:00.000Z'); + expect(w.label).toBe('last 7 days'); + expect(rangeDays(w, now)).toBe(7); + expect(rangeSpec('all', now).since.getTime()).toBe(0); + expect(rangeDays(rangeSpec('all', now), now)).toBe(Number.POSITIVE_INFINITY); + }); + + it('accepts a custom start date and refuses the future', () => { + const c = customRange('2026-09-01', now); + expect(c.key).toBe('custom'); + expect(c.slug).toBe('since-2026-09-01'); + expect(rangeDays(c, now)).toBe(13); + expect(() => customRange('2027-01-01', now)).toThrow(/past date/); + }); + + it('scales the commit cap with the range and grows the page budget', () => { + expect(commitCapFor(1)).toBe(25); + expect(commitCapFor(30)).toBe(750); + expect(commitCapFor(Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY); + expect(pageBudget('week').pulls).toBeGreaterThan(pageBudget('day').pulls); + expect(pageBudget('all').commits).toBeGreaterThan(pageBudget('year').commits); + expect(pageBudget('custom', 45)).toEqual(pageBudget('quarter')); + }); +}); + +describe('ledger', () => { + it('lays snapshots end to end and lets the newest one revise a day', () => { + const older = snap('2026-09-12T13:00:00Z', { 'o/a': row(5, [b('2026-09-10', 3), b('2026-09-11', 8)]) }, ['x']); + const newer = snap('2026-09-13T13:00:00Z', { 'o/a': row(6, [b('2026-09-11', 9), b('2026-09-12', 2)]) }, ['x', 'y']); + const ledger = buildLedger([newer, older]); + const series = ledger.views.get('o/a')!; + expect([...series.keys()]).toEqual(['2026-09-10', '2026-09-11', '2026-09-12']); + expect(series.get('2026-09-11')?.count).toBe(9); + expect(ledger.firstDay).toBe('2026-09-10'); + expect(ledger.points.map((p) => p.stars)).toEqual([5, 6]); + expect(ledgerSum(series, '2026-09-11', '2026-09-12')).toEqual({ count: 11, uniques: 11 }); + expect(ledgerDays(series, '2026-09-09', '2026-09-12').map((d) => d.count)).toEqual([0, 3, 9, 2]); + }); + + it('merges live buckets on top and picks the point at the start of a range', () => { + const ledger = buildLedger([snap('2026-09-12T13:00:00Z', { 'o/a': row(5, null) }), snap('2026-09-13T13:00:00Z', { 'o/a': row(7, null) })]); + mergeBuckets(ledger, 'o/a', [b('2026-09-13', 4)], null); + expect(ledger.views.get('o/a')?.get('2026-09-13')?.count).toBe(4); + expect(pointAt(ledger, new Date('2026-09-12T20:00:00Z'))?.stars).toBe(5); + expect(pointAt(ledger, new Date('2026-09-01T00:00:00Z'))).toBeNull(); + expect(pointAt(ledger, new Date('2026-09-14T00:00:00Z'))?.stars).toBe(7); + expect(pointAt(buildLedger([]), now)).toBeNull(); + }); + + it('folds a long series into a readable number of bars', () => { + const days = ledgerDays(new Map([['2026-01-05', b('2026-01-05', 10)]]), '2026-01-01', '2026-04-10'); + expect(days).toHaveLength(100); + const folded = foldDays(days, 60); + expect(folded.length).toBeLessThanOrEqual(60); + expect(folded.reduce((t, d) => t + d.count, 0)).toBe(10); + expect(folded[0]?.day).toBe('2026-01-01'); + expect(foldDays(days.slice(0, 14), 60)).toHaveLength(14); + }); +}); + +describe('filters', () => { + const mover = (repo: string, patch: Partial, isPrivate = false): ReportMover => ({ + repo, private: isPrivate, url: '', stars: 0, forks: 0, score: 1, movement: { ...emptyMovement(), ...patch }, + views14d: [], clones14d: [], referrers: [], paths: [], + }); + const movers = [ + mover('profullstack/a', { stars: 2 }), + mover('ralyodio/b', { commits: 3 }, true), + mover('profullstack/c', { views: 9, uniques: 3 }), + ]; + + it('knows which kinds moved', () => { + expect(movedIn(movers[0]!.movement, 'stars')).toBe(true); + expect(movedIn(movers[0]!.movement, 'traffic')).toBe(false); + expect(movedIn(movers[2]!.movement, 'traffic')).toBe(true); + expect(movedIn({ ...emptyMovement(), prClosed: 1 }, 'prs')).toBe(true); + }); + + it('keeps a repo while any enabled kind moved, and keeps the original rank', () => { + const f = defaultFilters(); + expect(applyFilters(movers, f).map((r) => r.rank)).toEqual([1, 2, 3]); + f.kinds.delete('stars'); + expect(applyFilters(movers, f).map((r) => r.mover.repo)).toEqual(['ralyodio/b', 'profullstack/c']); + f.showPrivate = false; + expect(applyFilters(movers, f).map((r) => r.rank)).toEqual([3]); + f.hiddenOwners.add('profullstack'); + expect(applyFilters(movers, f)).toEqual([]); + }); +});