A programmable Hacker News trend widget for Claude Code, powered by TinyFish Search and Fetch

A Claude Code mod that keeps a live Hacker News trend widget beside your session. You program the widget in plain language, and your main chat stays free.
HN pulse · TinyFish API · refreshed 2m ago r: Refresh
+ Track something: Rust launches with 100+ points today ⏎ add
Trending now · 3m ago edit ↓ x
front page · 24h · by velocity · list · top 8 · every 15m
▸ 1 Cloudflare acquires Deno 1190 606c +66/h ↑2
▸ 2 Triple-A Minesweeper 904 173c +57/h
▸ 3 REA Reverse – Engineer Anything 344 119c +49/h new
Rising themes · 2h ago edit ↑ x
front page · 7d · by points · themes · top 6 · every 360m
↑ Programming languages and tools ▃▁▃█▆▆▆ 18 stories
↑ Retro software in browser ▁▁█▅██▅ 8 stories
→ AI model releases █▁▅▅█▅▅ 15 stories
▸ beside a story to read a three-line summary of its thread, in the pane.The data comes from TinyFish Search and Fetch, and from the official HN API.
tinyfish CLI login.Run this command in your shell. It adds the marketplace and installs the mod for your user account:
claude plugin install hn-mod --marketplace aravindputrevu/claude-code-mods
Then start a new Claude Code session, in the terminal or in the desktop app. The pane opens by itself.
To install from inside a Claude Code terminal session, run this command instead:
/plugin install hn-mod --marketplace aravindputrevu/claude-code-mods
Answer y to add the marketplace, then select Install for you (user scope).
[!NOTE] A mod runs with your Claude Code permissions. Read the source before you install it. This mod sends HTTP requests only to
api.search.tinyfish.ai,api.fetch.tinyfish.aiandhacker-news.firebaseio.com. It runs one local command, thetinyfishCLI, and only when no API key is set.
The mod uses the first of these that it finds.
/plugin configure hn-mod@hn-mod and paste the key into TinyFish API key. Claude Code keeps it in your system's secure storage.TINYFISH_API_KEY environment variable, in the environment that starts Claude Code.tinyfish CLI login. Install it with npm install -g @tiny-fish/cli, then run tinyfish auth login. With the CLI, search cards cannot ask TinyFish for a time window, so the mod filters the results by story time.[!WARNING] Do not pass the key with
--configon the command line. Your shell history keeps it.
The pane header shows which route is in use: TinyFish API or TinyFish CLI.
The pane opens when a session starts. A terminal must be at least 144 columns wide for a pane that opens by itself. In a narrower terminal, a one-line band above the prompt shows the top story. Press Open on the band, or run /hn.
| To | Do this |
|---|---|
| Give the pane the keyboard | Click it, or press Ctrl+X then Tab. Esc gives the keyboard back to the prompt |
| Add a card | Type what to track in the + box and press Enter. Check the preview, then press Add card |
| Change the preview | Type a change, such as "only the last 6 hours", and press Enter again |
| Change a card | Press edit on the card, type the change, then press Save card |
| Move or remove a card | Press ↑, ↓ or x on the card |
| Read a thread | Press ▸ beside a story. The title opens the story, and the comment count opens the HN thread |
| Refresh every card | Press r (Refresh), or run /hn refresh |
Two cards come with the mod: Trending now and Rising themes. The catch-up card appears only after more than 12 hours away, and x removes it.
Each card is one small spec. Haiku writes the spec from your words, and the pane checks every field before it shows the preview. Typical results:
| You type | The card |
|---|---|
| Rust launches with 100+ points today | Show HN · "rust" · ≥100 pts · 24h · by points · list |
| what is hot on the front page right now | front page · 6h · by velocity · list |
| MCP and agent tooling chatter this week, summarized | search · "MCP agent tooling" · 7d · by points · digest |
| biggest debates on Ask HN this week | Ask HN · ≥50 comments · 7d · by comments · list |
| themes of the front page this month | front page · 30d · by points · themes |
| show how Postgres stories trend | search · "Postgres" · 7d · by new · spark |
The spec has these fields:
| Field | Values | |
|---|---|---|
source | front (front page), new, show (Show HN), ask (Ask HN), search (TinyFish Search limited to HN) | |
query | The search terms for search. A title filter for the lists, with alternatives split by `\ | ` |
window | 1h, 6h, 24h, 7d, 30d. A front card with 7d or 30d reads the daily archive pages | |
minPoints, minComments | Whole numbers | |
sort | velocity (points per hour), points, comments, new | |
view | list; spark (a points sparkline per story, or stories over time for a search); themes; digest (two sentences above the list) | |
limit | 1 to 15 stories | |
everyMinutes | How often the card refreshes, 5 to 1440 |
flowchart LR
F["TinyFish Fetch<br/>HN list pages"] -->|every 15 min| SNAP["Snapshots<br/>rank and points"]
SNAP --> VEL["Points per hour<br/>rank movement"]
F -->|"front?day=…"| ARCH["Daily archive"]
ARCH --> TH["Haiku: themes per day"]
ARCH --> CU["Catch-up digest"]
S["TinyFish Search<br/>news.ycombinator.com"] --> ITEM["HN item API<br/>points, comments, time"]
VEL --> CARDS["Cards in the pane"]
TH --> CARDS
CU --> CARDS
ITEM --> CARDS
Change the settings with /plugin configure hn-mod@hn-mod. The settings that are not secret are also rows in /config.
| Setting | Default | What it does |
|---|---|---|
tinyfish_api_key | empty | Your TinyFish API key. Kept in secure storage |
tinyfish_cli | tinyfish | The CLI command when no key is set. Use an absolute path if Claude Code does not find the CLI on its PATH |
model | haiku | The model for card specs, digests, themes and thread summaries. Calls use low effort |
snapshot_minutes | 15 | How often the mod reads the HN lists (5 to 120) |
auto_open | true | Open the pane when a session starts |
+ box.~/.claude/plugins/store/hn-mod_*.json. Every Claude Code session on the machine shares this store.| You see | Do this |
|---|---|
| No pane | Widen the terminal to 144 columns, press Open on the band, or run /hn |
"tinyfish" is not on PATH | Set a TinyFish API key, or set tinyfish_cli to the absolute path of the CLI |
TinyFish Search refused the API key (401) | Check the key at agent.tinyfish.ai/api-keys and set it again |
… is over its limit (429) | The free tier is used up for today. The card tries again later |
The model call for "haiku" failed: … | The text gives the reason. A failed card tries again after 5 minutes |
| Nothing from the mod at all | Run /plugin and open the Errors tab. Your organization can turn mods off |
The last error of each card is also kept under errors in the store file. To see every hook problem, start Claude Code with claude --debug.
Third-party marketplaces do not update by themselves. To update the mod, run:
claude plugin update hn-mod@hn-mod
To turn on updates for this marketplace, open /plugin, go to Marketplaces, select hn-mod, and select Enable auto-update.
To uninstall the mod and remove the marketplace, run:
claude plugin uninstall hn-mod@hn-mod
claude plugin marketplace remove hn-mod
Clone the repo and load your copy for one session. Saved files reload by themselves:
git clone https://github.com/aravindputrevu/claude-code-mods.git
cd claude-code-mods
claude --plugin-dir .
Check and test the mod:
claude plugin validate .
claude plugin test .
| File | What it holds |
|---|---|
hooks/register.tsx | The hooks module: every call on $, the state atoms, the refresh timer, the pane and band hooks |
hooks/pane.tsx | The pane and the band, sized to the width the pane gets |
hooks/sources.ts | TinyFish request shapes, the HN list parser (both layouts that TinyFish Fetch returns), and the HN item API rows |
hooks/trend.ts | Snapshots, points per hour, filters, sort, sparklines |
hooks/spec.ts | The card spec, its checks, and the default cards |
hooks/compile.ts, hooks/digest.ts | The model prompts and the reply readers |
types/index.d.ts | The $.state contract |
tests/ | Tests, with a real TinyFish front-page reply (user names replaced) |
See the Claude Code docs for mods and plugin install.
hooks/register.tsx 599 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { CardData, CardSpec, CatchUp, Draft, Expanded, Row, Status, Theme } from '../types'
5import { checkReply, compileAsk } from './compile'
6import { catchupAsk, digestAsk, themesAsk, themesIn, threadAsk, type Ask } from './digest'
7import { bandTree, paneTree, type Actions, type El } from './pane'
8import {
9 dayOf,
10 dayUrl,
11 daysBefore,
12 fetchArgv,
13 fetchBody,
14 FETCH_ENDPOINT,
15 hitsOf,
16 httpProblem,
17 ITEM_ENDPOINT,
18 itemUrl,
19 LIST_URL,
20 pagesOf,
21 parseList,
22 rowsFromHits,
23 searchArgv,
24 searchQuery,
25 type FetchOptions,
26 type Item,
27 type ListSource,
28 type Page,
29 type SearchHit,
30 type SearchParams,
31 type Tinyfish,
32} from './sources'
33import { archiveDays, loadCards, newCardId, readsArchive, WINDOW_MS } from './spec'
34import { addSnap, bucketCounts, filterRows, snapOf, sortRows, withAverage, withTrend, type Snap } from './trend'
35
36type Engine = EngineInterface
37type Timer = ReturnType<Engine['clock']['every']>
38
39const MINUTE = 60_000
40const DAY = 86_400_000
41const AWAY_MS = 12 * 60 * MINUTE
42
43const PANE = 'hn-pulse'
44
45const cardsAtom = atom({ plugin: 'hn-mod', key: 'cards' } as const, [] as CardSpec[])
46const dataAtom = atom({ plugin: 'hn-mod', key: 'data' } as const, {} as Record<string, CardData>)
47const draftAtom = atom({ plugin: 'hn-mod', key: 'draft' } as const, null as Draft | null)
48const editingAtom = atom({ plugin: 'hn-mod', key: 'editing' } as const, null as string | null)
49const expandedAtom = atom({ plugin: 'hn-mod', key: 'expanded' } as const, null as Expanded | null)
50const catchupAtom = atom({ plugin: 'hn-mod', key: 'catchup' } as const, null as CatchUp | null)
51const statusAtom = atom({ plugin: 'hn-mod', key: 'status' } as const, { mode: 'cli', refreshedAt: 0 } as Status)
52const paneShownAtom = atom({ plugin: 'hn-mod', key: 'paneShown' } as const, false)
53const bandHiddenAtom = atom({ plugin: 'hn-mod', key: 'bandHidden' } as const, false)
54
55// --- Configuration and module state (a reload starts these over) --------------
56
57let options: PluginOptions = {}
58let tinyfish: Tinyfish = { mode: 'cli', cli: 'tinyfish' }
59let isConfigured = false
60let tick: Timer | undefined
61let running: Promise<void> | undefined
62const lists = new Map<ListSource, { at: number; rows: Promise<Row[]> }>()
63const items = new Map<number, { at: number; item: Item | null }>()
64const threads = new Map<number, string>()
65
66const model = (): string =>
67 typeof options.model === 'string' && options.model.trim() !== '' ? options.model.trim() : 'haiku'
68
69const snapshotMs = (): number =>
70 Math.min(120, Math.max(5, typeof options.snapshot_minutes === 'number' ? options.snapshot_minutes : 15)) * MINUTE
71
72const problem = (error: unknown): string => (error instanceof Error ? error.message : String(error)).slice(0, 200)
73
74const uniqueById = (rows: readonly Row[]): Row[] => {
75 const seen = new Set<number>()
76 return rows.filter(row => (seen.has(row.id) ? false : (seen.add(row.id), true)))
77}
78
79/** The TinyFish route: the key option, then TINYFISH_API_KEY, then the CLI's own login. */
80async function configure($: Engine): Promise<void> {
81 if (isConfigured) return
82 const fromConfig = typeof options.tinyfish_api_key === 'string' ? options.tinyfish_api_key.trim() : ''
83 const fromEnv = fromConfig === '' ? ((await $.env.get('TINYFISH_API_KEY')) ?? '').trim() : ''
84 const cli = typeof options.tinyfish_cli === 'string' && options.tinyfish_cli.trim() !== '' ? options.tinyfish_cli.trim() : 'tinyfish'
85 const key = fromConfig !== '' ? fromConfig : fromEnv
86 tinyfish = key !== '' ? { mode: 'api', key } : { mode: 'cli', cli }
87 isConfigured = true
88 await update($, statusAtom, status => ({ ...status, mode: tinyfish.mode }))
89}
90
91// --- TinyFish, the HN item API and the model ----------------------------------
92
93async function runCli($: Engine, argv: string[]): Promise<string> {
94 let out
95 try {
96 out = await $.process.run(argv, { timeoutMs: 60_000 })
97 } catch {
98 throw new Error(`Cannot run "${argv[0]}". Set TINYFISH_API_KEY or the TinyFish API key option.`)
99 }
100 if (out.exitCode === 127) {
101 throw new Error(`"${argv[0]}" is not on PATH. Set TINYFISH_API_KEY or the TinyFish API key option.`)
102 }
103 if (out.exitCode !== 0) {
104 const last = out.stderr.trim().split('\n').pop() ?? ''
105 throw new Error(`TinyFish CLI failed: ${last.slice(0, 160) || `exit ${out.exitCode}`}`)
106 }
107 return out.stdout
108}
109
110/** HN-only search. The CLI has no time window, so its hits are filtered by item time later. */
111async function tinyfishSearch($: Engine, p: SearchParams): Promise<SearchHit[]> {
112 await configure($)
113 const tf = tinyfish
114 if (tf.mode === 'cli') return hitsOf(await runCli($, searchArgv(tf.cli, p)))
115 const res = await $.http.fetch(searchQuery(p), { headers: { 'X-API-Key': tf.key, Accept: 'application/json' } })
116 if (!res.ok) throw new Error(httpProblem('Search', res.status))
117 return hitsOf(res.text)
118}
119
120/** Clean page text (and links) through TinyFish Fetch, ten URLs to a request. */
121async function tinyfishFetch($: Engine, urls: readonly string[], opts: FetchOptions): Promise<Page[]> {
122 await configure($)
123 const tf = tinyfish
124 const pages: Page[] = []
125 for (let i = 0; i < urls.length; i += 10) {
126 const batch = urls.slice(i, i + 10)
127 if (tf.mode === 'cli') {
128 pages.push(...pagesOf(await runCli($, fetchArgv(tf.cli, batch, opts))))
129 continue
130 }
131 const res = await $.http.fetch(FETCH_ENDPOINT, {
132 method: 'POST',
133 headers: { 'X-API-Key': tf.key, 'Content-Type': 'application/json', Accept: 'application/json' },
134 body: fetchBody(batch, opts),
135 })
136 if (!res.ok) throw new Error(httpProblem('Fetch', res.status))
137 pages.push(...pagesOf(res.text))
138 }
139 return pages
140}
141
142/** Points, comments and time for HN items, kept for ten minutes. */
143async function readItems($: Engine, ids: readonly number[], now: number): Promise<Map<number, Item>> {
144 const out = new Map<number, Item>()
145 await Promise.all(
146 ids.map(async id => {
147 const known = items.get(id)
148 if (known !== undefined && now - known.at < 10 * MINUTE) {
149 if (known.item !== null) out.set(id, known.item)
150 return
151 }
152 try {
153 const res = await $.http.fetch(`${ITEM_ENDPOINT}${id}.json`)
154 const item = res.ok ? (JSON.parse(res.text) as Item | null) : null
155 items.set(id, { at: now, item })
156 if (item !== null) out.set(id, item)
157 } catch {
158 items.set(id, { at: now, item: null })
159 }
160 }),
161 )
162 return out
163}
164
165/** One model call. The fixed instructions lead the prompt with a cache mark, as the mods guide advises. */
166async function complete($: Engine, ask: Ask): Promise<string> {
167 let reply
168 try {
169 reply = await $.model.complete({
170 model: model(),
171 prompt: [{ text: ask.system, cache: true }, { text: ask.prompt }],
172 maxTokens: ask.maxTokens,
173 // Short summary and JSON jobs: low effort keeps the thinking short.
174 effort: 'low',
175 timeoutMs: 60_000,
176 })
177 } catch (error) {
178 throw new Error(`Claude Code refused the model call for "${model()}": ${problem(error)}`)
179 }
180 if (!reply.isAnswered) {
181 const why = reply.reason === 'api-error'
182 ? `the API answered ${reply.status ?? 'with no status'} (${reply.error})`
183 : reply.reason === 'aborted'
184 ? 'the call timed out or was cancelled'
185 : 'the reply had no text'
186 throw new Error(`The model call for "${model()}" failed: ${why}.`)
187 }
188 return reply.text.trim()
189}
190
191// --- Story sources ----------------------------------------------------------------
192
193/** A list page, read at most once per half snapshot period, with trend fields added. */
194function listRows($: Engine, source: ListSource, now: number): Promise<Row[]> {
195 const known = lists.get(source)
196 if (known !== undefined && now - known.at < snapshotMs() / 2) return known.rows
197 const rows = readList($, source, now)
198 lists.set(source, { at: now, rows })
199 rows.catch(() => lists.delete(source))
200 return rows
201}
202
203async function readList($: Engine, source: ListSource, now: number): Promise<Row[]> {
204 const [page] = await tinyfishFetch($, [LIST_URL[source]], { links: true, ttlSeconds: 120 })
205 const rows = page === undefined ? [] : parseList(page, now)
206 if (rows.length === 0) throw new Error(`TinyFish returned no stories for ${LIST_URL[source]}.`)
207
208 const snaps = ((await $.store.get('snaps')) ?? {}) as Partial<Record<ListSource, Snap[]>>
209 const history = snaps[source] ?? []
210 const last = history[history.length - 1]
211 if (last === undefined || now - last.at >= snapshotMs() - MINUTE) {
212 await $.store.set('snaps', { ...snaps, [source]: addSnap(history, snapOf(rows, now), 26 * 60 * MINUTE) })
213 }
214 return withTrend(rows, history, now)
215}
216
217type DayEntry = { at: number; rows: Row[] }
218
219/** A day page read 12 hours after the day ended is final; an earlier read is good for 3 hours. */
220const isFresh = (day: string, entry: DayEntry | undefined, now: number): boolean => {
221 if (entry === undefined || entry.rows.length === 0) return false
222 const dayEnd = Date.parse(`${day}T00:00:00Z`) + DAY
223 return entry.at - dayEnd >= 12 * 60 * MINUTE || now - entry.at < 3 * 60 * MINUTE
224}
225
226/** Front-page rows for each day, from the store when a fresh read is there. */
227async function archive($: Engine, days: readonly string[], now: number): Promise<Map<string, Row[]>> {
228 const saved = ((await $.store.get('archive')) ?? {}) as Record<string, DayEntry>
229 const missing = days.filter(day => !isFresh(day, saved[day], now))
230 if (missing.length > 0) {
231 const pages = await tinyfishFetch($, missing.map(dayUrl), { links: true })
232 const empty: string[] = []
233 for (const day of missing) {
234 const page = pages.find(one => one.url.endsWith(`day=${day}`))
235 // The age text on a day page counts from the time of the fetch.
236 const rows = page === undefined ? [] : parseList(page, now).slice(0, 30)
237 if (rows.length > 0) saved[day] = { at: now, rows }
238 else if (saved[day] === undefined) empty.push(day)
239 }
240 const keep = Object.keys(saved).sort().slice(-40)
241 await $.store.set('archive', Object.fromEntries(keep.map(day => [day, saved[day]])))
242 if (empty.length === days.length) throw new Error(`TinyFish returned no stories for ${empty.join(', ')}.`)
243 }
244 return new Map(days.map(day => [day, saved[day]?.rows ?? []]))
245}
246
247async function searchRows($: Engine, spec: CardSpec, now: number): Promise<Row[]> {
248 const query = spec.query ?? spec.title
249 const recencyMinutes = WINDOW_MS[spec.window] / MINUTE
250 const hits = await tinyfishSearch($, { query, recencyMinutes })
251 if (hits.length >= 8) {
252 const more = await tinyfishSearch($, { query, recencyMinutes, page: 1 }).catch(() => [])
253 for (const hit of more) if (!hits.some(one => one.id === hit.id)) hits.push(hit)
254 }
255 const found = await readItems($, hits.map(hit => hit.id), now)
256 return rowsFromHits(hits, found).map(row => withAverage(row, now))
257}
258
259// --- Cards ------------------------------------------------------------------------
260
261type Cached = { key: string; digest?: string; themes?: Theme[] }
262
263async function cachedEntry($: Engine, id: string): Promise<Cached | undefined> {
264 const all = ((await $.store.get('cache')) ?? {}) as Record<string, Cached>
265 return all[id]
266}
267
268async function keepEntry($: Engine, id: string, entry: Cached): Promise<void> {
269 const all = ((await $.store.get('cache')) ?? {}) as Record<string, Cached>
270 const ids = new Set((await read($, cardsAtom)).map(card => card.id))
271 const kept = Object.fromEntries(Object.entries(all).filter(([key]) => ids.has(key)))
272 await $.store.set('cache', { ...kept, [id]: entry })
273}
274
275async function setData($: Engine, id: string, fn: (old: CardData | undefined) => CardData): Promise<void> {
276 await update($, dataAtom, data => ({ ...data, [id]: fn(data[id]) }))
277}
278
279async function refreshCard($: Engine, spec: CardSpec): Promise<void> {
280 const now = await $.clock.now()
281 await setData($, spec.id, old => ({ rows: [], updatedAt: 0, ...old, isLoading: true }))
282 try {
283 let candidates: Row[]
284 let buckets: Array<{ label: string; rows: Row[] }> | undefined
285 if (spec.source === 'search') {
286 candidates = await searchRows($, spec, now)
287 } else if (readsArchive(spec)) {
288 const days = daysBefore(now, archiveDays(spec))
289 const byDay = await archive($, days, now)
290 buckets = days.map(day => ({ label: day, rows: filterRows(byDay.get(day) ?? [], spec) }))
291 candidates = uniqueById(days.flatMap(day => byDay.get(day) ?? [])).map(row => withAverage(row, now))
292 } else {
293 candidates = await listRows($, spec.source, now)
294 }
295
296 // An archive day is complete: its rows are filtered by query and points, not by age.
297 const filtered = filterRows(candidates, spec, readsArchive(spec) ? undefined : now)
298 const rows = sortRows(filtered, spec.sort).slice(0, spec.limit)
299 const next: CardData = { rows, updatedAt: now, isLoading: false }
300
301 if (spec.view === 'spark' && (spec.source === 'search' || buckets !== undefined)) {
302 next.spark = buckets?.map(b => b.rows.length) ?? bucketCounts(filtered, WINDOW_MS[spec.window], now)
303 }
304 if (spec.view === 'themes') {
305 const groups = buckets ?? [{ label: dayOf(now), rows: filtered }]
306 const key = `${JSON.stringify(spec)}|${groups.map(g => `${g.label}:${g.rows.length}`).join(',')}`
307 const hit = await cachedEntry($, spec.id)
308 if (hit?.key === key && hit.themes !== undefined) {
309 next.themes = hit.themes
310 } else {
311 next.themes = themesIn(await complete($, themesAsk(groups, spec.limit)), groups.length, spec.limit)
312 await keepEntry($, spec.id, { key, themes: next.themes })
313 }
314 }
315 if (spec.view === 'digest' && rows.length > 0) {
316 const key = rows.map(row => row.id).join(',')
317 const hit = await cachedEntry($, spec.id)
318 if (hit?.key === key && hit.digest !== undefined) {
319 next.digest = hit.digest
320 } else {
321 next.digest = await complete($, digestAsk(spec.title, rows))
322 await keepEntry($, spec.id, { key, digest: next.digest })
323 }
324 }
325 await setData($, spec.id, () => next)
326 await noteError($, spec.id, undefined, now)
327 } catch (error) {
328 await setData($, spec.id, old => ({ ...old, rows: old?.rows ?? [], updatedAt: now, isLoading: false, error: problem(error) }))
329 await noteError($, spec.id, problem(error), now)
330 }
331}
332
333/** Keeps each card's last error in the store, so it can be read after the session. */
334async function noteError($: Engine, id: string, message: string | undefined, now: number): Promise<void> {
335 const all = ((await $.store.get('errors')) ?? {}) as Record<string, { at: number; message: string }>
336 if (message === undefined && all[id] === undefined) return
337 const { [id]: _old, ...rest } = all
338 await $.store.set('errors', message === undefined ? rest : { ...rest, [id]: { at: now, message } })
339}
340
341/** Refreshes each card whose interval has passed, one after another. */
342function refreshDue($: Engine, force = false): Promise<void> {
343 if (running !== undefined) return running
344 running = (async () => {
345 try {
346 const now = await $.clock.now()
347 const cards = await read($, cardsAtom)
348 const data = await read($, dataAtom)
349 for (const spec of cards) {
350 const card = data[spec.id]
351 // A card that failed tries again after five minutes, not after its whole interval.
352 const minutes = card?.error !== undefined ? Math.min(5, spec.everyMinutes) : spec.everyMinutes
353 if (force || now - (card?.updatedAt ?? 0) >= minutes * MINUTE) await refreshCard($, spec)
354 }
355 await update($, statusAtom, status => ({ ...status, refreshedAt: now }))
356 } finally {
357 running = undefined
358 }
359 })()
360 return running
361}
362
363/** The catch-up card: the front-page archive for each day since the last visit. */
364async function runCatchup($: Engine, since: number, now: number): Promise<void> {
365 const missed = Math.min(7, Math.max(0, Math.floor(now / DAY) - Math.floor(since / DAY)))
366 const days = missed > 0 ? daysBefore(now, missed) : []
367 await update($, catchupAtom, () => ({ since, days, rows: [], isBusy: true }))
368 try {
369 const byDay = days.length > 0
370 ? await archive($, days, now)
371 : new Map([[dayOf(now), await listRows($, 'front', now)]])
372 const rows = sortRows(uniqueById([...byDay.values()].flat()), 'points').slice(0, 8)
373 await update($, catchupAtom, old => (old === null ? null : { ...old, rows }))
374 const digest = await complete($, catchupAsk([...byDay].map(([day, list]) => ({ day, rows: list }))))
375 await update($, catchupAtom, old => (old === null ? null : { ...old, rows, digest, isBusy: false }))
376 } catch (error) {
377 await update($, catchupAtom, old => (old === null ? null : { ...old, isBusy: false, error: problem(error) }))
378 }
379}
380
381/** Opens or closes the summary under a row; reads the thread with TinyFish Fetch. */
382async function toggleThread($: Engine, cardId: string, row: Row): Promise<void> {
383 const open = await read($, expandedAtom)
384 if (open !== null && open.cardId === cardId && open.itemId === row.id) {
385 await update($, expandedAtom, () => null)
386 return
387 }
388 const known = threads.get(row.id)
389 await update($, expandedAtom, () => ({ cardId, itemId: row.id, isBusy: known === undefined, ...(known === undefined ? {} : { text: known }) }))
390 if (known !== undefined) return
391 let text: string
392 try {
393 const [page] = await tinyfishFetch($, [itemUrl(row.id)], { ttlSeconds: 600 })
394 text = await complete($, threadAsk(row.title, page?.text ?? ''))
395 threads.set(row.id, text)
396 } catch (error) {
397 text = problem(error)
398 }
399 await update($, expandedAtom, old =>
400 old !== null && old.cardId === cardId && old.itemId === row.id ? { ...old, text, isBusy: false } : old,
401 )
402}
403
404// --- The pane's actions -------------------------------------------------------------
405
406async function saveCards($: Engine, cards: CardSpec[]): Promise<void> {
407 await update($, cardsAtom, () => cards)
408 await $.store.set('cards', cards)
409}
410
411/** Plain language to a preview card: a new card, or a change to the preview or the edited card. */
412async function submitRequest($: Engine, text: string): Promise<void> {
413 const request = text.trim()
414 if (request === '') return
415 const [draft, editing, cards] = await Promise.all([read($, draftAtom), read($, editingAtom), read($, cardsAtom)])
416 const base = draft?.spec ?? cards.find(card => card.id === editing)
417 const editId = draft?.editId ?? (draft?.spec === undefined ? base?.id : undefined)
418 const keep = { ...(editId === undefined ? {} : { editId }), ...(draft?.spec === undefined ? {} : { spec: draft.spec }) }
419 await update($, draftAtom, () => ({ text: request, isBusy: true, ...keep }))
420 const id = base?.id ?? newCardId(await $.clock.now(), cards)
421 const ask = compileAsk(request, base)
422 let reply: string
423 try {
424 reply = await complete($, { ...ask, maxTokens: 2000 })
425 } catch (error) {
426 await update($, draftAtom, () => ({ text: request, isBusy: false, error: problem(error), ...keep }))
427 return
428 }
429 const checked = checkReply(reply, id)
430 await update($, draftAtom, () =>
431 checked.spec !== undefined
432 ? { text: request, isBusy: false, spec: checked.spec, ...(editId === undefined ? {} : { editId }) }
433 : { text: request, isBusy: false, error: checked.error, ...keep },
434 )
435}
436
437async function saveDraft($: Engine): Promise<void> {
438 const draft = await read($, draftAtom)
439 const spec = draft?.spec
440 if (draft === null || spec === undefined) return
441 const cards = await read($, cardsAtom)
442 const next = draft.editId === undefined
443 ? [spec, ...cards]
444 : cards.map(card => (card.id === draft.editId ? spec : card))
445 await saveCards($, next)
446 await update($, draftAtom, () => null)
447 await update($, editingAtom, () => null)
448 await refreshCard($, spec)
449}
450
451async function moveCard($: Engine, id: string, by: -1 | 1): Promise<void> {
452 const cards = [...(await read($, cardsAtom))]
453 const from = cards.findIndex(card => card.id === id)
454 const moved = cards[from]
455 const to = from + by
456 if (moved === undefined || to < 0 || to >= cards.length) return
457 cards.splice(from, 1)
458 cards.splice(to, 0, moved)
459 await saveCards($, cards)
460}
461
462async function removeCard($: Engine, id: string): Promise<void> {
463 await saveCards($, (await read($, cardsAtom)).filter(card => card.id !== id))
464 await update($, dataAtom, data => Object.fromEntries(Object.entries(data).filter(([key]) => key !== id)))
465 await update($, editingAtom, current => (current === id ? null : current))
466}
467
468function actionsFor($: Engine): Actions {
469 return {
470 refresh: () => void refreshDue($, true),
471 submit: text => void submitRequest($, text),
472 saveDraft: () => void saveDraft($),
473 cancelDraft: () => {
474 void update($, draftAtom, () => null)
475 void update($, editingAtom, () => null)
476 },
477 edit: id => {
478 void update($, draftAtom, () => null)
479 void update($, editingAtom, current => (current === id ? null : id))
480 },
481 move: (id, by) => void moveCard($, id, by),
482 remove: id => void removeCard($, id),
483 toggle: (cardId, row) => void toggleThread($, cardId, row),
484 dismissCatchup: () => void update($, catchupAtom, () => null),
485 }
486}
487
488// --- Session ------------------------------------------------------------------------
489
490async function openPane($: Engine, isAsked: boolean): Promise<void> {
491 const pane = { id: PANE, title: 'HN pulse' }
492 const opened = await $.ui.open(isAsked ? { ...pane, focus: true as const } : pane)
493 await update($, paneShownAtom, () => opened.isPlaced)
494}
495
496/** Cards from the store into the session's state: at start, and again after /clear. */
497async function loadState($: Engine): Promise<void> {
498 // 0.1.0 kept day pages under 'days' with no read time; 'archive' replaces it.
499 if ((await $.store.get('days')) !== undefined) await $.store.delete('days')
500 const saved = await $.store.get('cards')
501 const cards = loadCards(saved)
502 await update($, cardsAtom, () => cards)
503 if (saved === undefined) await $.store.set('cards', cards)
504}
505
506async function start($: Engine, isInteractive: boolean): Promise<void> {
507 await configure($)
508 await loadState($)
509
510 const now = await $.clock.now()
511 const lastSeen = await $.store.get('lastSeenAt')
512 await $.store.set('lastSeenAt', now)
513 if (typeof lastSeen === 'number' && now - lastSeen > AWAY_MS) {
514 $.clock.after(500, () => void runCatchup($, lastSeen, now))
515 }
516
517 tick?.cancel()
518 tick = $.clock.every(MINUTE, () => {
519 void refreshDue($)
520 void $.clock.now().then(at => $.store.set('lastSeenAt', at))
521 })
522 $.clock.after(100, () => void refreshDue($))
523 if (isInteractive && options.auto_open !== false) await openPane($, false)
524}
525
526export const register: Register = (on, opts) => {
527 options = opts
528 isConfigured = false
529
530 on('session.start', async ($, e, next) => {
531 await $.command.register({ name: 'hn', description: 'Open the HN pulse pane (/hn refresh reads every card again)' })
532 await start($, e.isInteractive)
533 return next(e)
534 })
535
536 // /clear, /resume and /branch reset $.state and fire no session.start.
537 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
538 await loadState($)
539 $.clock.after(100, () => void refreshDue($, true))
540 return next(e)
541 })
542
543 on('command.run', { command: 'hn' }, async ($, e) => {
544 await openPane($, true)
545 if (e.args.trim() === 'refresh') void refreshDue($, true)
546 return {}
547 })
548
549 on('ui.close', { id: PANE }, async ($, e, next) => {
550 const closed = await next(e)
551 await update($, paneShownAtom, () => false)
552 return closed
553 })
554
555 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
556 const el: El = $.ui.resolve(e)
557 const [cards, data, draft, editing, expanded, catchup, status] = await Promise.all([
558 read($, cardsAtom),
559 read($, dataAtom),
560 read($, draftAtom),
561 read($, editingAtom),
562 read($, expandedAtom),
563 read($, catchupAtom),
564 read($, statusAtom),
565 ])
566 const now = await $.clock.now()
567 return paneTree(el, actionsFor($), {
568 cards,
569 data,
570 draft,
571 editing,
572 expanded,
573 catchup,
574 status,
575 now,
576 width: e.props.bodyColumns,
577 })
578 })
579
580 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
581 if (e.props.hasSurvey) return next(e)
582 const [isShown, isHidden] = await Promise.all([read($, paneShownAtom), read($, bandHiddenAtom)])
583 if (isShown || isHidden) return next(e)
584 const [cards, data] = await Promise.all([read($, cardsAtom), read($, dataAtom)])
585 const rows = cards.flatMap(card => (card.source === 'search' ? [] : (data[card.id]?.rows ?? [])))
586 const top = [...rows].sort((a, b) => (b.velocity ?? -1) - (a.velocity ?? -1))[0]
587 if (top === undefined) return next(e)
588 const fresh = new Set(rows.filter(row => row.isNew === true).map(row => row.id)).size
589 return bandTree(
590 $.ui.resolve(e),
591 top,
592 fresh,
593 () => void openPane($, true),
594 () => void update($, bandHiddenAtom, () => true),
595 e.props.bodyColumns,
596 )
597 })
598}
599hooks/compile.ts 69 lines1import type { CardSpec } from '../types'
2import { normalize, type Checked } from './spec'
3
4const RULES = `You write card specs for HN pulse, a Hacker News widget inside Claude Code.
5Reply with one JSON object and nothing else: no prose, no code fence.
6
7Fields:
8- title: a short card title, at most 40 characters.
9- source: "front" (the front page), "new" (newest stories), "show" (Show HN), "ask" (Ask HN), or "search" (a web search limited to news.ycombinator.com; it needs "query").
10- query: for "search", the search terms. For the other sources, an optional title filter; split alternatives with "|".
11- window: "1h", "6h", "24h", "7d" or "30d". For "front", "7d" and "30d" read the daily front-page archive.
12- minPoints, minComments: optional whole numbers.
13- sort: "velocity" (points per hour, for what rises now), "points", "comments" (for debates) or "new".
14- view: "list"; "spark" (a points sparkline per story, or stories over time for a search); "themes" (groups the titles into topics); or "digest" (a two-sentence summary above the list).
15- limit: 1 to 15 stories. Default 5.
16- everyMinutes: how often the card refreshes, 5 to 1440. Default 15.
17
18Rules:
19- Use a list source when the request names an HN section: front page, new, Show HN, Ask HN.
20- Use "search" for a topic, product, company, language or person that is not tied to a section.
21- "launches" or "projects" means Show HN. "questions" means Ask HN. "debates" or "discussions" means sort by comments.
22- "today" means "24h". "this week" means "7d". "this month" means "30d". "trending", "hot" or "rising" means sort by velocity.
23- When a current card is given, change only what the request asks for and keep every other field.
24
25Examples:
26Request: Rust launches with 100+ points today
27{"title":"Rust launches","source":"show","query":"rust","window":"24h","minPoints":100,"sort":"points","view":"list","limit":5,"everyMinutes":15}
28Request: what is hot on the front page right now
29{"title":"Hot now","source":"front","window":"6h","sort":"velocity","view":"list","limit":8,"everyMinutes":15}
30Request: MCP and agent tooling chatter this week, summarized
31{"title":"MCP and agents","source":"search","query":"MCP agent tooling","window":"7d","sort":"points","view":"digest","limit":6,"everyMinutes":60}
32Request: biggest debates on Ask HN this week
33{"title":"Ask HN debates","source":"ask","window":"7d","minComments":50,"sort":"comments","view":"list","limit":5,"everyMinutes":60}
34Request: themes of the front page this month
35{"title":"Monthly themes","source":"front","window":"30d","sort":"points","view":"themes","limit":8,"everyMinutes":720}
36Request: show how Postgres stories trend
37{"title":"Postgres","source":"search","query":"Postgres","window":"7d","sort":"new","view":"spark","limit":5,"everyMinutes":60}
38Request: brand new posts about LLMs or local models
39{"title":"New on LLMs","source":"new","query":"llm|local model|llama|inference","window":"6h","sort":"new","view":"list","limit":6,"everyMinutes":10}
40Current card: {"title":"Rust launches","source":"show","query":"rust","window":"24h","minPoints":100,"sort":"points","view":"list","limit":5,"everyMinutes":15}
41Request: only the last 6 hours, and show 3
42{"title":"Rust launches","source":"show","query":"rust","window":"6h","minPoints":100,"sort":"points","view":"list","limit":3,"everyMinutes":15}`
43
44/** The first JSON object in a reply, with or without a code fence around it. */
45export function jsonIn(reply: string): unknown {
46 const start = reply.indexOf('{')
47 const end = reply.lastIndexOf('}')
48 if (start === -1 || end <= start) return undefined
49 try {
50 return JSON.parse(reply.slice(start, end + 1))
51 } catch {
52 return undefined
53 }
54}
55
56/** The model request that turns plain language into a spec; `current` is the card a change edits. */
57export function compileAsk(request: string, current?: CardSpec): { system: string; prompt: string } {
58 if (current === undefined) return { system: RULES, prompt: `Request: ${request}` }
59 const { id: _id, ...shown } = current
60 return { system: RULES, prompt: `Current card: ${JSON.stringify(shown)}\nRequest: ${request}` }
61}
62
63/** The model reply as a checked spec, under the card id the pane chose. */
64export function checkReply(reply: string, id: string): Checked {
65 const raw = jsonIn(reply)
66 if (raw === undefined) return { error: 'The model reply was not a JSON spec. Try other words.' }
67 return normalize(raw, id)
68}
69hooks/digest.ts 62 lines1import type { Row, Theme } from '../types'
2import { jsonIn } from './compile'
3import { trendOf } from './trend'
4
5/**
6 * A model request: the instructions, the prompt and the reply cap. Haiku 5.5 thinks by default,
7 * and its thinking counts against the cap, so each cap leaves room for thinking and the answer.
8 */
9export type Ask = { system: string; prompt: string; maxTokens: number }
10
11const line = (row: Row): string =>
12 `- ${row.title}${row.points === undefined ? '' : ` (${row.points} points, ${row.comments ?? 0} comments)`}`
13
14/** Two sentences on what a card's stories have in common. */
15export const digestAsk = (title: string, rows: readonly Row[]): Ask => ({
16 system:
17 'You summarize Hacker News stories for a busy developer. Write two short plain sentences, at most 45 words in all. No lists, no headings, no preamble.',
18 prompt: `Card: ${title}\nStories:\n${rows.map(line).join('\n')}`,
19 maxTokens: 1500,
20})
21
22/** Up to four bullets on what the person did not see while away. */
23export const catchupAsk = (days: ReadonlyArray<{ day: string; rows: readonly Row[] }>): Ask => ({
24 system:
25 'You brief a developer who was away from Hacker News. Write at most four bullets, each one line under 20 words, starting with "- ". Name the topics, not the counts. No preamble.',
26 prompt: days.map(d => `${d.day}:\n${d.rows.slice(0, 12).map(line).join('\n')}`).join('\n\n'),
27 maxTokens: 2000,
28})
29
30/** Three short lines on a thread, from the page text TinyFish Fetch returned. */
31export const threadAsk = (title: string, page: string): Ask => ({
32 system:
33 'You summarize a Hacker News thread in exactly three short lines: what the post is; what most commenters agree on; the main disagreement. Each line under 20 words. No preamble.',
34 prompt: `Title: ${title}\n\n${page.slice(0, 14_000)}`,
35 maxTokens: 1500,
36})
37
38/** Groups titles into themes, with one count for each bucket (a day), oldest bucket first. */
39export const themesAsk = (buckets: ReadonlyArray<{ label: string; rows: readonly Row[] }>, limit: number): Ask => ({
40 system: `You find themes in Hacker News titles. Reply with JSON only: {"themes":[{"name":"...","perBucket":[n,...]}]}. Give at most ${limit} themes, each a 1 to 4 word name. "perBucket" has one count for each bucket, in the order given. Count a title in at most one theme. Skip one-off topics.`,
41 prompt: buckets.map((b, i) => `Bucket ${i + 1} (${b.label}):\n${b.rows.map(row => `- ${row.title}`).join('\n')}`).join('\n\n'),
42 maxTokens: 4000,
43})
44
45/** The themes in a reply, rising first, then by count. */
46export function themesIn(reply: string, buckets: number, limit: number): Theme[] {
47 const json = jsonIn(reply) as { themes?: unknown } | undefined
48 const list = Array.isArray(json?.themes) ? json.themes : []
49 const themes: Theme[] = []
50 for (const one of list as Array<Record<string, unknown>>) {
51 if (typeof one.name !== 'string' || one.name.trim() === '') continue
52 const spark = (Array.isArray(one.perBucket) ? one.perBucket : [])
53 .slice(0, buckets)
54 .map(n => (typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0))
55 const count = spark.reduce((a, b) => a + b, 0)
56 if (count === 0) continue
57 themes.push({ name: one.name.trim().slice(0, 40), count, trend: trendOf(spark), spark })
58 }
59 const order = { up: 0, flat: 1, down: 2 } as const
60 return themes.sort((a, b) => order[a.trend] - order[b.trend] || b.count - a.count).slice(0, limit)
61}
62hooks/pane.tsx 242 lines1import type { Elements } from 'claude-code'
2
3import type { CardData, CardSpec, CatchUp, Draft, Expanded, Row, Status } from '../types'
4import { itemUrl } from './sources'
5import { chips } from './spec'
6import { ago, rate, sparkline } from './trend'
7
8/** The elements the pane draws with; `Input` is absent where a surface draws no field. */
9export type El = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Link'> & {
10 Input?: Elements['desktop']['Input']
11}
12
13export type Actions = {
14 refresh: () => void
15 submit: (text: string) => void
16 saveDraft: () => void
17 cancelDraft: () => void
18 edit: (id: string) => void
19 move: (id: string, by: -1 | 1) => void
20 remove: (id: string) => void
21 toggle: (cardId: string, row: Row) => void
22 dismissCatchup: () => void
23}
24
25export type PaneModel = {
26 cards: readonly CardSpec[]
27 data: Readonly<Record<string, CardData>>
28 draft: Draft | null
29 editing: string | null
30 expanded: Expanded | null
31 catchup: CatchUp | null
32 status: Status
33 now: number
34 width: number
35}
36
37const clip = (text: string, width: number): string =>
38 text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`
39
40const pad = (text: string, width: number): string => text.padStart(width)
41
42const ARROW = { up: '↑', flat: '→', down: '↓' } as const
43
44function rowLines(el: El, a: Actions, card: CardSpec, rows: readonly Row[], m: PaneModel, inner: number) {
45 const { Box, Text, Button, Link } = el
46 const showSpark = card.view === 'spark' && card.source !== 'search'
47 const titleWidth = Math.max(12, inner - (showSpark ? 34 : 26))
48 return rows.map((row, i) => {
49 const isOpen = m.expanded?.cardId === card.id && m.expanded.itemId === row.id
50 const delta = row.isNew === true ? 'new' : row.rankDelta ? `${row.rankDelta > 0 ? '↑' : '↓'}${Math.abs(row.rankDelta)}` : ''
51 return (
52 <Box key={`row-${card.id}-${row.id}`} flexDirection="column">
53 <Box flexDirection="row">
54 <Button key={`open-${card.id}-${row.id}`} label={isOpen ? '▾' : '▸'} plain onPress={() => a.toggle(card.id, row)} />
55 <Text dimColor>{pad(String(i + 1), 3)} </Text>
56 <Box flexGrow={1}>
57 <Link href={row.url} label={clip(row.title, titleWidth)} />
58 </Box>
59 {showSpark && <Text color="suggestion"> {pad(sparkline(row.spark ?? []), 8)}</Text>}
60 <Text> {pad(row.points === undefined ? '' : String(row.points), 5)}</Text>
61 <Link href={itemUrl(row.id)} label={pad(row.comments === undefined ? '' : `${row.comments}c`, 6)} />
62 <Text color={(row.velocity ?? 0) >= 20 ? 'success' : 'subtle'}> {pad(rate(row.velocity), 6)}</Text>
63 <Text color={row.isNew === true ? 'suggestion' : (row.rankDelta ?? 0) > 0 ? 'success' : 'subtle'}> {pad(delta, 3)}</Text>
64 </Box>
65 {isOpen && (
66 <Box paddingLeft={4}>
67 <Text dimColor wrap="wrap">
68 {m.expanded?.isBusy === true ? 'Reading the thread…' : (m.expanded?.text ?? '')}
69 </Text>
70 </Box>
71 )}
72 </Box>
73 )
74 })
75}
76
77function cardBox(el: El, a: Actions, card: CardSpec, index: number, m: PaneModel) {
78 const { Box, Text, Button } = el
79 const data = m.data[card.id]
80 const inner = Math.max(20, m.width - 4)
81 const rows = data?.rows ?? []
82 const updated = data === undefined || data.updatedAt === 0 ? 'waiting' : ago(m.now - data.updatedAt)
83 return (
84 <Box key={`card-${card.id}`} flexDirection="column" borderStyle="round" borderColor={m.editing === card.id ? 'suggestion' : 'subtle'} paddingX={1}>
85 <Box flexDirection="row" justifyContent="space-between">
86 <Text wrap="truncate-end">
87 <Text bold>{card.title}</Text>
88 <Text dimColor> · {data?.isLoading === true ? 'refreshing…' : updated}</Text>
89 </Text>
90 <Box flexDirection="row" columnGap={1}>
91 <Button key={`edit-${card.id}`} label="edit" plain dimColor onPress={() => a.edit(card.id)} />
92 {index > 0 && <Button key={`up-${card.id}`} label="↑" plain dimColor onPress={() => a.move(card.id, -1)} />}
93 {index < m.cards.length - 1 && <Button key={`down-${card.id}`} label="↓" plain dimColor onPress={() => a.move(card.id, 1)} />}
94 <Button key={`remove-${card.id}`} label="x" plain dimColor onPress={() => a.remove(card.id)} />
95 </Box>
96 </Box>
97 <Text dimColor wrap="truncate-end">{chips(card).join(' · ')}</Text>
98 {data?.error !== undefined && <Text color="error" wrap="wrap">{data.error}</Text>}
99 {data?.digest !== undefined && card.view === 'digest' && <Text wrap="wrap">{data.digest}</Text>}
100 {data?.spark !== undefined && card.view === 'spark' && (
101 <Text>
102 <Text color="suggestion">{sparkline(data.spark)}</Text>
103 <Text dimColor> {data.spark.reduce((s, n) => s + n, 0)} stories over {card.window}</Text>
104 </Text>
105 )}
106 {card.view === 'themes'
107 ? (data?.themes ?? []).map(theme => (
108 <Text key={`theme-${card.id}-${theme.name}`} wrap="truncate-end">
109 <Text color={theme.trend === 'up' ? 'success' : theme.trend === 'down' ? 'error' : 'subtle'}>{ARROW[theme.trend]} </Text>
110 <Text>{theme.name} </Text>
111 <Text color="suggestion">{sparkline(theme.spark)}</Text>
112 <Text dimColor> {theme.count} stories</Text>
113 </Text>
114 ))
115 : rowLines(el, a, card, rows, m, inner)}
116 {rows.length === 0 && card.view !== 'themes' && data?.error === undefined && (
117 <Text dimColor>{data?.isLoading === false ? 'No stories match yet.' : 'Loading…'}</Text>
118 )}
119 {card.view === 'themes' && (data?.themes ?? []).length === 0 && data?.error === undefined && (
120 <Text dimColor>{data?.isLoading === false ? 'No themes yet.' : 'Reading the archive…'}</Text>
121 )}
122 </Box>
123 )
124}
125
126function catchupBox(el: El, a: Actions, c: CatchUp, m: PaneModel) {
127 const { Box, Text, Button, Link } = el
128 const span = c.days.length === 0 ? 'today' : c.days.length === 1 ? '1 day' : `${c.days.length} days`
129 return (
130 <Box key="catchup" flexDirection="column" borderStyle="round" borderColor="suggestion" paddingX={1}>
131 <Box flexDirection="row" justifyContent="space-between">
132 <Text wrap="truncate-end">
133 <Text bold>Since your last visit</Text>
134 <Text dimColor> · {ago(m.now - c.since)} · {span}</Text>
135 </Text>
136 <Button key="catchup-dismiss" label="x" plain dimColor onPress={a.dismissCatchup} />
137 </Box>
138 {c.error !== undefined && <Text color="error" wrap="wrap">{c.error}</Text>}
139 {c.digest !== undefined
140 ? <Text wrap="wrap">{c.digest}</Text>
141 : c.isBusy && <Text dimColor>Reading the days you missed…</Text>}
142 {c.rows.slice(0, 5).map(row => (
143 <Box key={`catchup-${row.id}`} flexDirection="row">
144 <Box flexGrow={1}>
145 <Link href={row.url} label={clip(row.title, Math.max(12, m.width - 20))} />
146 </Box>
147 <Text dimColor> {row.points ?? ''} pts</Text>
148 </Box>
149 ))}
150 </Box>
151 )
152}
153
154function programBox(el: El, a: Actions, m: PaneModel) {
155 const { Box, Text, Button, Input } = el
156 const editing = m.editing === null ? undefined : m.cards.find(card => card.id === m.editing)
157 const draft = m.draft
158 const placeholder = draft?.spec !== undefined
159 ? 'Change the preview: only the last 6 hours'
160 : editing !== undefined
161 ? `Change "${editing.title}": only Rust, top 3`
162 : 'Track something: Rust launches with 100+ points today'
163 return (
164 <Box key="program" flexDirection="column">
165 {Input !== undefined
166 ? (
167 <Input
168 key="program"
169 label="+"
170 placeholder={placeholder}
171 value=""
172 submitLabel={draft?.spec !== undefined ? 'refine' : editing !== undefined ? 'change' : 'add'}
173 autoFocus
174 onSubmit={text => a.submit(text)}
175 />
176 )
177 : <Text dimColor>Open HN pulse in the terminal or the desktop app to add cards.</Text>}
178 {draft?.isBusy === true && <Text dimColor>Writing the card spec for "{clip(draft.text, 40)}"…</Text>}
179 {draft?.error !== undefined && <Text color="error" wrap="wrap">{draft.error}</Text>}
180 {draft?.spec !== undefined && (
181 <Box flexDirection="column" borderStyle="dashed" borderColor="suggestion" paddingX={1}>
182 <Text wrap="wrap">
183 <Text bold>{draft.spec.title}</Text>
184 <Text dimColor> · {chips(draft.spec).join(' · ')}</Text>
185 </Text>
186 <Box flexDirection="row" columnGap={2}>
187 <Button key="draft-save" label={draft.editId === undefined ? 'Add card' : 'Save card'} variant="primary" onPress={a.saveDraft} />
188 <Button key="draft-cancel" label="Cancel" onPress={a.cancelDraft} />
189 </Box>
190 </Box>
191 )}
192 {editing !== undefined && draft?.spec === undefined && draft?.isBusy !== true && (
193 <Box flexDirection="row" columnGap={1}>
194 <Text dimColor>Type a change for "{editing.title}" and press Enter.</Text>
195 <Button key="edit-cancel" label="Stop editing" plain dimColor onPress={a.cancelDraft} />
196 </Box>
197 )}
198 </Box>
199 )
200}
201
202/** The whole pane: header, the plain-language box, the catch-up card, then the cards. */
203export function paneTree(el: El, a: Actions, m: PaneModel) {
204 const { Box, Text, Button } = el
205 const route = m.status.mode === 'api' ? 'TinyFish API' : 'TinyFish CLI'
206 const refreshed = m.status.refreshedAt === 0 ? 'starting' : `refreshed ${ago(m.now - m.status.refreshedAt)}`
207 return (
208 <Box flexDirection="column" gap={1}>
209 <Box flexDirection="row" justifyContent="space-between">
210 <Text wrap="truncate-end">
211 <Text bold color="claude">HN pulse</Text>
212 <Text dimColor> · {route} · {refreshed}</Text>
213 </Text>
214 <Button key="refresh" label="Refresh" hotkey="r" plain onPress={a.refresh} />
215 </Box>
216 {programBox(el, a, m)}
217 {m.catchup !== null && catchupBox(el, a, m.catchup, m)}
218 {m.cards.map((card, i) => cardBox(el, a, card, i, m))}
219 {m.cards.length === 0 && <Text dimColor>No cards. Type what to track in the box above.</Text>}
220 </Box>
221 )
222}
223
224/** One line above the prompt while the pane is not on screen. */
225export function bandTree(el: El, top: Row | undefined, fresh: number, open: () => void, hide: () => void, width: number) {
226 const { Box, Text, Button } = el
227 return (
228 <Box flexDirection="row" columnGap={1}>
229 <Text bold color="claude">HN</Text>
230 <Box flexGrow={1}>
231 <Text wrap="truncate-end">
232 {top === undefined ? 'Reading the front page…' : clip(top.title, Math.max(12, width - 34))}
233 <Text color="success"> {rate(top?.velocity)}</Text>
234 {fresh > 0 && <Text dimColor> · {fresh} new</Text>}
235 </Text>
236 </Box>
237 <Button key="band-open" label="Open" onPress={open} />
238 <Button key="band-hide" label="Hide" plain dimColor onPress={hide} />
239 </Box>
240 )
241}
242hooks/sources.ts 284 lines1import type { Row, Source } from '../types'
2
3const HN = 'https://news.ycombinator.com'
4const SEARCH_URL = 'https://api.search.tinyfish.ai/'
5const FETCH_URL = 'https://api.fetch.tinyfish.ai/'
6const ITEM_URL = 'https://hacker-news.firebaseio.com/v0/item/'
7
8export type ListSource = Exclude<Source, 'search'>
9
10export const LIST_URL: Record<ListSource, string> = {
11 front: `${HN}/news`,
12 new: `${HN}/newest`,
13 show: `${HN}/show`,
14 ask: `${HN}/ask`,
15}
16
17export const dayUrl = (day: string): string => `${HN}/front?day=${day}`
18export const itemUrl = (id: number): string => `${HN}/item?id=${id}`
19
20// --- TinyFish: request shapes and reply readers (the hooks module makes the calls) ---
21
22/** How the mod reaches TinyFish: the REST API with a key, or the CLI's own login. */
23export type Tinyfish = { mode: 'api'; key: string } | { mode: 'cli'; cli: string }
24
25export const SEARCH_ENDPOINT = SEARCH_URL
26export const FETCH_ENDPOINT = FETCH_URL
27export const ITEM_ENDPOINT = ITEM_URL
28
29export const httpProblem = (what: string, status: number): string =>
30 status === 401 || status === 403
31 ? `TinyFish ${what} refused the API key (${status}).`
32 : status === 402 || status === 429
33 ? `TinyFish ${what} is over its limit (${status}). The card tries again later.`
34 : `TinyFish ${what} failed (${status}).`
35
36export type SearchParams = { query: string; recencyMinutes?: number; page?: number }
37
38export function searchQuery(p: SearchParams): string {
39 const q = new URLSearchParams({
40 query: p.query,
41 include_domains: 'news.ycombinator.com',
42 purpose: 'Find Hacker News story threads about this topic.',
43 })
44 if (p.recencyMinutes !== undefined) q.set('recency_minutes', String(Math.max(1, Math.round(p.recencyMinutes))))
45 if (p.page !== undefined) q.set('page', String(p.page))
46 return `${SEARCH_URL}?${q.toString()}`
47}
48
49export function searchArgv(cli: string, p: SearchParams): string[] {
50 const argv = [cli, 'search', 'query', p.query, '--include-domains', 'news.ycombinator.com']
51 if (p.page !== undefined) argv.push('--page', String(p.page))
52 return argv
53}
54
55export type FetchOptions = { links?: boolean; ttlSeconds?: number }
56
57export const fetchBody = (urls: readonly string[], opts: FetchOptions): string =>
58 JSON.stringify({
59 urls,
60 format: 'markdown',
61 ...(opts.links === true ? { links: true } : {}),
62 ...(opts.ttlSeconds === undefined ? {} : { ttl: opts.ttlSeconds }),
63 })
64
65export const fetchArgv = (cli: string, urls: readonly string[], opts: FetchOptions): string[] => [
66 cli, 'fetch', 'content', 'get', '--format', 'markdown', ...(opts.links === true ? ['--links'] : []), ...urls,
67]
68
69export const parseJson = (text: string, what: string): unknown => {
70 try {
71 return JSON.parse(text)
72 } catch {
73 throw new Error(`TinyFish ${what} sent a reply that is not JSON.`)
74 }
75}
76
77export type SearchHit = { id: number; title: string; url: string; snippet: string }
78
79/** The HN item links of a TinyFish Search reply, without duplicates. */
80export function hitsOf(body: string): SearchHit[] {
81 const json = parseJson(body, 'Search') as { results?: unknown }
82 const hits: SearchHit[] = []
83 for (const one of (Array.isArray(json.results) ? json.results : []) as Array<Record<string, unknown>>) {
84 const url = typeof one.url === 'string' ? one.url : ''
85 const id = itemIdOf(url)
86 if (id === undefined || hits.some(hit => hit.id === id)) continue
87 hits.push({
88 id,
89 title: (typeof one.title === 'string' ? one.title : '').replace(/\s*\|\s*Hacker News\s*$/, ''),
90 url,
91 snippet: typeof one.snippet === 'string' ? one.snippet : '',
92 })
93 }
94 return hits
95}
96
97export type Page = { url: string; text: string; links: string[] }
98
99/** The pages of a TinyFish Fetch reply. */
100export function pagesOf(body: string): Page[] {
101 const json = parseJson(body, 'Fetch') as { results?: unknown }
102 return ((Array.isArray(json.results) ? json.results : []) as Array<Record<string, unknown>>).map(one => ({
103 url: typeof one.url === 'string' ? one.url : '',
104 text: typeof one.text === 'string' ? one.text : '',
105 links: Array.isArray(one.links) ? one.links.filter((l): l is string => typeof l === 'string') : [],
106 }))
107}
108
109// --- The official HN item API: points, comments and time for search hits ----
110
111export type Item = {
112 id: number
113 type?: string
114 title?: string
115 url?: string
116 score?: number
117 descendants?: number
118 time?: number
119 dead?: boolean
120 deleted?: boolean
121}
122
123/** Search hits as rows: stories only, with points, comments and time from the item API. */
124export function rowsFromHits(hits: readonly SearchHit[], found: ReadonlyMap<number, Item>): Row[] {
125 const rows: Row[] = []
126 for (const hit of hits) {
127 const item = found.get(hit.id)
128 if (item === undefined) {
129 rows.push({ id: hit.id, title: hit.title, url: itemUrl(hit.id) })
130 continue
131 }
132 if (item.dead === true || item.deleted === true) continue
133 if (item.type !== 'story' && item.type !== 'poll') continue
134 const url = item.url ?? itemUrl(hit.id)
135 const site = siteOf(item.url)
136 rows.push({
137 id: hit.id,
138 title: item.title ?? hit.title,
139 url,
140 ...(site === undefined ? {} : { site }),
141 ...(item.score === undefined ? {} : { points: item.score }),
142 ...(item.descendants === undefined ? {} : { comments: item.descendants }),
143 ...(item.time === undefined ? {} : { time: item.time * 1000 }),
144 })
145 }
146 return rows
147}
148
149// --- Reading an HN list page from TinyFish Fetch ------------------------------
150
151const ITEM = /^https?:\/\/news\.ycombinator\.com\/item\?id=(\d+)$/
152const ON_HN = /^https?:\/\/(news\.)?ycombinator\.com(\/|$)/
153
154export const itemIdOf = (url: string): number | undefined => {
155 const m = ITEM.exec(url)
156 return m === null ? undefined : Number(m[1])
157}
158
159export const siteOf = (url: string | undefined): string | undefined => {
160 const m = url === undefined ? null : /^https?:\/\/(?:www\.)?([^/?#]+)/.exec(url)
161 return m === null ? undefined : m[1]
162}
163
164/** Story ids and links, in page order, from the link list TinyFish Fetch returns. */
165export function storiesFromLinks(links: readonly string[]): Array<{ id: number; url: string }> {
166 const out: Array<{ id: number; url: string }> = []
167 const seen = new Set<number>()
168 let pending: string | undefined
169 for (const href of links) {
170 if (!ON_HN.test(href)) {
171 pending ??= href
172 continue
173 }
174 const id = itemIdOf(href)
175 if (id === undefined || seen.has(id)) continue
176 seen.add(id)
177 out.push({ id, url: pending ?? itemUrl(id) })
178 pending = undefined
179 }
180 return out
181}
182
183const UNIT_MS: Record<string, number> = {
184 minute: 60_000,
185 hour: 3_600_000,
186 day: 86_400_000,
187 month: 30 * 86_400_000,
188 year: 365 * 86_400_000,
189}
190
191const RANK = /^(\d{1,3})\.$/
192const RANK_INLINE = /^(\d{1,3})\.\s+(.+)$/
193const POINTS = /(\d+)\s+points?\b/
194const COMMENTS = /(\d+)\s+comments?\b/
195const AGE = /(\d+)\s+(minute|hour|day|month|year)s?\s+ago\b/
196const SITE_SUFFIX = /^(.*\S)\s+\(([a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/[^\s()]*)?)\)$/i
197const CELL = /(?<!\\)\|/
198const RULE = /^:?-{3,}:?$/
199
200const unescape = (token: string): string => token.replace(/\\([\\`*_{}[\]()#+\-.!|~>])/g, '$1')
201
202/**
203 * The page text as tokens. TinyFish Fetch writes an HN list either one token per line
204 * or as a Markdown table (`| 1. | Title (site) |`); both become the same token list.
205 */
206export const tokensOf = (text: string): string[] =>
207 text
208 .replace(/ /g, ' ')
209 .split('\n')
210 .flatMap(line => line.split(CELL))
211 .map(token => unescape(token.trim()))
212 .filter(token => token !== '' && !RULE.test(token))
213
214type Entry = { rank: number; title: string; site?: string; points?: number; comments?: number; time?: number }
215
216/** The ranked entries of an HN list page as TinyFish Fetch writes it. */
217export function entriesFromText(text: string, now: number): Entry[] {
218 const tokens = tokensOf(text)
219 const entries: Entry[] = []
220 let current: Entry | undefined
221 for (let i = 0; i < tokens.length; i += 1) {
222 const token = tokens[i] ?? ''
223 const ranked = RANK.exec(token)
224 const inline = ranked === null ? RANK_INLINE.exec(token) : null
225 if (ranked !== null || inline !== null) {
226 let title = ranked !== null ? (tokens[i + 1] ?? '') : (inline?.[2] ?? '')
227 if (ranked !== null) i += 1
228 current = { rank: Number((ranked ?? inline)?.[1]), title }
229 const suffix = SITE_SUFFIX.exec(title)
230 if (suffix !== null) {
231 title = suffix[1] ?? title
232 current.site = suffix[2]
233 } else if (tokens[i + 1] === '(' && tokens[i + 3] === ')') {
234 current.site = tokens[i + 2]
235 i += 3
236 }
237 current.title = title
238 entries.push(current)
239 continue
240 }
241 if (current === undefined) continue
242 const points = POINTS.exec(token)
243 if (points !== null && current.points === undefined) current.points = Number(points[1])
244 const comments = COMMENTS.exec(token)
245 if (comments !== null) current.comments = Number(comments[1])
246 if (token === 'discuss') current.comments = 0
247 const age = AGE.exec(token)
248 if (age !== null && current.time === undefined) {
249 current.time = now - Number(age[1]) * (UNIT_MS[age[2] ?? 'hour'] ?? 3_600_000)
250 }
251 }
252 return entries
253}
254
255/** An HN list page as rows: the text gives rank, title, points and age; the links give ids. */
256export function parseList(page: Page, now: number): Row[] {
257 const entries = entriesFromText(page.text, now)
258 const stories = storiesFromLinks(page.links)
259 const rows: Row[] = []
260 for (let i = 0; i < Math.min(entries.length, stories.length); i += 1) {
261 const entry = entries[i]
262 const story = stories[i]
263 if (entry === undefined || story === undefined) continue
264 rows.push({
265 id: story.id,
266 title: entry.title,
267 url: story.url,
268 rank: entry.rank,
269 ...(entry.site === undefined ? {} : { site: entry.site }),
270 ...(entry.points === undefined ? {} : { points: entry.points }),
271 ...(entry.comments === undefined ? {} : { comments: entry.comments }),
272 ...(entry.time === undefined ? {} : { time: entry.time }),
273 })
274 }
275 return rows
276}
277
278/** `YYYY-MM-DD` of a time, in UTC. */
279export const dayOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
280
281/** The `count` whole days before `now`, oldest first. */
282export const daysBefore = (now: number, count: number): string[] =>
283 Array.from({ length: count }, (_, i) => dayOf(now - (count - i) * 86_400_000))
284hooks/spec.ts 137 lines1import type { CardSpec, Sort, Source, View, Window } from '../types'
2
3export const SOURCES: readonly Source[] = ['front', 'new', 'show', 'ask', 'search']
4export const WINDOWS: readonly Window[] = ['1h', '6h', '24h', '7d', '30d']
5export const SORTS: readonly Sort[] = ['velocity', 'points', 'comments', 'new']
6export const VIEWS: readonly View[] = ['list', 'spark', 'themes', 'digest']
7
8const HOUR = 3_600_000
9export const WINDOW_MS: Record<Window, number> = {
10 '1h': HOUR,
11 '6h': 6 * HOUR,
12 '24h': 24 * HOUR,
13 '7d': 7 * 24 * HOUR,
14 '30d': 30 * 24 * HOUR,
15}
16
17export const SOURCE_LABEL: Record<Source, string> = {
18 front: 'front page',
19 new: 'new',
20 show: 'Show HN',
21 ask: 'Ask HN',
22 search: 'search',
23}
24
25export const DEFAULT_CARDS: readonly CardSpec[] = [
26 {
27 id: 'trending',
28 title: 'Trending now',
29 source: 'front',
30 window: '24h',
31 sort: 'velocity',
32 view: 'list',
33 limit: 8,
34 everyMinutes: 15,
35 },
36 {
37 id: 'themes',
38 title: 'Rising themes',
39 source: 'front',
40 window: '7d',
41 sort: 'points',
42 view: 'themes',
43 limit: 6,
44 everyMinutes: 360,
45 },
46]
47
48/** A front-page card over more than a day reads the daily archive pages. */
49export const readsArchive = (spec: CardSpec): boolean =>
50 spec.source === 'front' && WINDOW_MS[spec.window] > WINDOW_MS['24h']
51
52export const archiveDays = (spec: CardSpec): number =>
53 Math.round(WINDOW_MS[spec.window] / WINDOW_MS['24h'])
54
55const isOne = <T extends string>(list: readonly T[], value: unknown): value is T =>
56 typeof value === 'string' && (list as readonly string[]).includes(value)
57
58const whole = (value: unknown, min: number, max: number): number | undefined => {
59 const n = typeof value === 'string' ? Number(value) : value
60 if (typeof n !== 'number' || !Number.isFinite(n)) return undefined
61 return Math.min(max, Math.max(min, Math.round(n)))
62}
63
64const text = (value: unknown, max: number): string | undefined =>
65 typeof value === 'string' && value.trim() !== '' ? value.trim().slice(0, max) : undefined
66
67export type Checked = { spec: CardSpec; error?: undefined } | { spec?: undefined; error: string }
68
69/** Checks a spec from the model or the store, fills the defaults, and keeps `id`. */
70export function normalize(raw: unknown, id: string): Checked {
71 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
72 return { error: 'The spec is not a JSON object.' }
73 }
74 const r = raw as Record<string, unknown>
75 if (!isOne(SOURCES, r.source)) {
76 return { error: `Unknown source "${String(r.source)}". Use one of: ${SOURCES.join(', ')}.` }
77 }
78 const source = r.source
79 const query = text(r.query, 200)
80 if (source === 'search' && query === undefined) {
81 return { error: 'A search card needs a query.' }
82 }
83 const minPoints = whole(r.minPoints, 0, 100_000)
84 const minComments = whole(r.minComments, 0, 100_000)
85
86 return {
87 spec: {
88 id,
89 title: text(r.title, 48) ?? query ?? SOURCE_LABEL[source],
90 source,
91 ...(query === undefined ? {} : { query }),
92 window: isOne(WINDOWS, r.window) ? r.window : '24h',
93 ...(minPoints ? { minPoints } : {}),
94 ...(minComments ? { minComments } : {}),
95 sort: isOne(SORTS, r.sort) ? r.sort : 'velocity',
96 view: isOne(VIEWS, r.view) ? r.view : 'list',
97 limit: whole(r.limit, 1, 15) ?? 5,
98 everyMinutes: whole(r.everyMinutes, 5, 1440) ?? 15,
99 },
100 }
101}
102
103/** Reads the saved card list; drops a card that no longer checks. */
104export function loadCards(raw: unknown): CardSpec[] {
105 if (!Array.isArray(raw)) return [...DEFAULT_CARDS]
106 const cards: CardSpec[] = []
107 for (const one of raw) {
108 const id = typeof one === 'object' && one !== null ? (one as { id?: unknown }).id : undefined
109 if (typeof id !== 'string') continue
110 const checked = normalize(one, id)
111 if (checked.spec !== undefined) cards.push(checked.spec)
112 }
113 return cards
114}
115
116export const newCardId = (now: number, taken: readonly CardSpec[]): string => {
117 let n = 0
118 let id = `c${now.toString(36)}`
119 while (taken.some(card => card.id === id)) id = `c${now.toString(36)}${(n += 1)}`
120 return id
121}
122
123/** The spec as short chips, in the order the pane draws them. */
124export function chips(spec: CardSpec): string[] {
125 return [
126 SOURCE_LABEL[spec.source],
127 ...(spec.query === undefined ? [] : [`"${spec.query}"`]),
128 ...(spec.minPoints === undefined ? [] : [`≥${spec.minPoints} pts`]),
129 ...(spec.minComments === undefined ? [] : [`≥${spec.minComments} comments`]),
130 spec.window,
131 `by ${spec.sort}`,
132 spec.view,
133 `top ${spec.limit}`,
134 `every ${spec.everyMinutes}m`,
135 ]
136}
137hooks/trend.ts 133 lines1import type { CardSpec, Row, Sort } from '../types'
2import { WINDOW_MS } from './spec'
3
4const MINUTE = 60_000
5const HOUR = 60 * MINUTE
6
7/** One reading of a list: story id to [rank, points, comments]. */
8export type Snap = { at: number; pts: Record<string, [number, number, number]> }
9
10export function snapOf(rows: readonly Row[], at: number): Snap {
11 const pts: Snap['pts'] = {}
12 for (const row of rows) pts[String(row.id)] = [row.rank ?? 0, row.points ?? 0, row.comments ?? 0]
13 return { at, pts }
14}
15
16/** Adds a snapshot and drops the ones older than `keepMs`. */
17export const addSnap = (list: readonly Snap[] | undefined, snap: Snap, keepMs: number): Snap[] =>
18 [...(list ?? []), snap].filter(one => snap.at - one.at <= keepMs).sort((a, b) => a.at - b.at)
19
20/** The snapshot to compare with: at least 10 minutes old, the nearest to one hour old. */
21export function baseSnap(snaps: readonly Snap[], now: number): Snap | undefined {
22 let best: Snap | undefined
23 for (const snap of snaps) {
24 if (now - snap.at < 10 * MINUTE) continue
25 if (best === undefined || Math.abs(now - snap.at - HOUR) < Math.abs(now - best.at - HOUR)) best = snap
26 }
27 return best
28}
29
30/** Adds points per hour, rank movement, a new mark and a points history to each row. */
31export function withTrend(rows: readonly Row[], snaps: readonly Snap[], now: number): Row[] {
32 const base = baseSnap(snaps, now)
33 const recent = snaps.slice(-8)
34 return rows.map(row => {
35 const key = String(row.id)
36 const before = base?.pts[key]
37 const spark = recent.map(snap => snap.pts[key]?.[1]).filter((n): n is number => n !== undefined)
38 if (row.points !== undefined) spark.push(row.points)
39 const next: Row = { ...row, ...(spark.length > 1 ? { spark } : {}) }
40 if (base !== undefined && before !== undefined && row.points !== undefined) {
41 next.velocity = (row.points - before[1]) / ((now - base.at) / HOUR)
42 if (row.rank !== undefined && before[0] > 0) next.rankDelta = before[0] - row.rank
43 return next
44 }
45 if (base !== undefined && before === undefined) next.isNew = true
46 return withAverage(next, now)
47 })
48}
49
50/** Points per hour since the post: the measure when there is no snapshot to compare with. */
51export function withAverage(row: Row, now: number): Row {
52 if (row.points === undefined || row.time === undefined) return row
53 return { ...row, velocity: row.points / Math.max(1, (now - row.time) / HOUR) }
54}
55
56const termsOf = (query: string): RegExp[] =>
57 query
58 .split('|')
59 .map(term => term.trim().toLowerCase())
60 .filter(term => term !== '')
61 .map(term => new RegExp(`(^|[^a-z0-9])${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i'))
62
63/** The card's filters: query terms (lists only), window (when `now` is given), minimum points and comments. */
64export function filterRows(rows: readonly Row[], spec: CardSpec, now?: number): Row[] {
65 const terms = spec.source === 'search' || spec.query === undefined ? [] : termsOf(spec.query)
66 const oldest = now === undefined ? -Infinity : now - WINDOW_MS[spec.window]
67 return rows.filter(row => {
68 if (terms.length > 0 && !terms.some(t => t.test(row.title) || t.test(row.site ?? ''))) return false
69 if (row.time !== undefined && row.time < oldest) return false
70 if (spec.minPoints !== undefined && (row.points ?? 0) < spec.minPoints) return false
71 if (spec.minComments !== undefined && (row.comments ?? 0) < spec.minComments) return false
72 return true
73 })
74}
75
76const KEY: Record<Sort, (row: Row) => number> = {
77 velocity: row => row.velocity ?? -1,
78 points: row => row.points ?? -1,
79 comments: row => row.comments ?? -1,
80 new: row => row.time ?? row.id,
81}
82
83export const sortRows = (rows: readonly Row[], sort: Sort): Row[] =>
84 [...rows].sort((a, b) => KEY[sort](b) - KEY[sort](a))
85
86/** Stories per bucket: seven equal buckets across the window, oldest first. */
87export function bucketCounts(rows: readonly Row[], windowMs: number, now: number, buckets = 7): number[] {
88 const counts = new Array<number>(buckets).fill(0)
89 const size = windowMs / buckets
90 for (const row of rows) {
91 if (row.time === undefined) continue
92 const age = now - row.time
93 if (age < 0 || age >= windowMs) continue
94 const slot = buckets - 1 - Math.floor(age / size)
95 counts[slot] = (counts[slot] ?? 0) + 1
96 }
97 return counts
98}
99
100const BARS = '▁▂▃▄▅▆▇█'
101
102export function sparkline(values: readonly number[]): string {
103 if (values.length === 0) return ''
104 const low = Math.min(...values)
105 const high = Math.max(...values)
106 return values
107 .map(v => BARS[high === low ? 3 : Math.round(((v - low) / (high - low)) * (BARS.length - 1))] ?? BARS[0])
108 .join('')
109}
110
111/** A rate as the pane writes it: `+180/h`. */
112export const rate = (velocity: number | undefined): string =>
113 velocity === undefined ? '' : `${velocity >= 0 ? '+' : ''}${Math.round(velocity)}/h`
114
115/** Up when the newest third is above the oldest third. */
116export function trendOf(values: readonly number[]): 'up' | 'flat' | 'down' {
117 if (values.length < 2) return 'flat'
118 const third = Math.max(1, Math.floor(values.length / 3))
119 const sum = (list: readonly number[]) => list.reduce((a, b) => a + b, 0)
120 const early = sum(values.slice(0, third))
121 const late = sum(values.slice(-third))
122 if (late > early * 1.25 && late - early >= 1) return 'up'
123 if (late < early * 0.75 && early - late >= 1) return 'down'
124 return 'flat'
125}
126
127export function ago(ms: number): string {
128 if (ms < MINUTE) return 'just now'
129 if (ms < HOUR) return `${Math.round(ms / MINUTE)}m ago`
130 if (ms < 48 * HOUR) return `${Math.round(ms / HOUR)}h ago`
131 return `${Math.round(ms / (24 * HOUR))}d ago`
132}
133types/index.d.ts 93 lines1export type Source = 'front' | 'new' | 'show' | 'ask' | 'search'
2export type Window = '1h' | '6h' | '24h' | '7d' | '30d'
3export type Sort = 'velocity' | 'points' | 'comments' | 'new'
4export type View = 'list' | 'spark' | 'themes' | 'digest'
5
6/** One card of the pane: the program the plain-language box writes. */
7export type CardSpec = {
8 id: string
9 title: string
10 source: Source
11 /** Search terms for `search`; a title filter (alternatives split by `|`) for the lists. */
12 query?: string
13 window: Window
14 minPoints?: number
15 minComments?: number
16 sort: Sort
17 view: View
18 limit: number
19 everyMinutes: number
20}
21
22/** One HN story as a card draws it. */
23export type Row = {
24 id: number
25 title: string
26 url: string
27 site?: string
28 points?: number
29 comments?: number
30 /** When the story was posted, in ms since the epoch. */
31 time?: number
32 rank?: number
33 /** Points per hour: between snapshots when there are two, else since the post. */
34 velocity?: number
35 /** Ranks moved up since the snapshot the velocity compares with. */
36 rankDelta?: number
37 isNew?: boolean
38 /** Points at each snapshot, oldest first. */
39 spark?: number[]
40}
41
42export type Theme = { name: string; count: number; trend: 'up' | 'flat' | 'down'; spark: number[] }
43
44export type CardData = {
45 rows: Row[]
46 /** Stories per time bucket over the card's window, oldest first. */
47 spark?: number[]
48 themes?: Theme[]
49 digest?: string
50 updatedAt: number
51 error?: string
52 isLoading?: boolean
53}
54
55/** What the plain-language box holds while a spec is written or checked. */
56export type Draft = {
57 text: string
58 spec?: CardSpec
59 error?: string
60 isBusy: boolean
61 /** The card a saved draft replaces. */
62 editId?: string
63}
64
65export type Expanded = { cardId: string; itemId: number; text?: string; isBusy: boolean }
66
67export type CatchUp = {
68 since: number
69 days: string[]
70 rows: Row[]
71 digest?: string
72 isBusy: boolean
73 error?: string
74}
75
76export type Status = { mode: 'api' | 'cli'; refreshedAt: number; note?: string }
77
78declare module 'claude-code' {
79 interface PluginState {
80 'hn-mod': {
81 cards: CardSpec[]
82 data: Record<string, CardData>
83 draft: Draft | null
84 editing: string | null
85 expanded: Expanded | null
86 catchup: CatchUp | null
87 status: Status
88 paneShown: boolean
89 bandHidden: boolean
90 }
91 }
92}
93