SLOPSHOPPER

HN pulse

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

newpanebandcommandmodelprocess
v0.1.0MITupdated 2026-10-13aravindputrevu/claude-code-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hn-mod
│ ┃ HN pulse ✕ › fix the failing auth test and add an audit log call │ ┃ HN pulse · TinyFish CLI · startingr: Refresh │ ┃ ⏺ Read(src/auth.ts) │ ┃ +: Track something: Rust launches with 100+ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ Trending now · waiting edit ↓ x │ ⏺ Bash(bun test) │ ┃ │ front page · 24h · by velocity · list ·… │ ⎿ 3 pass, 1 fail │ ┃ │ Loading… │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Rising themes · waiting edit ↑ x │ │ ┃ │ front page · 7d · by points · themes · … │ › /hn │ ┃ │ Reading the archive… │ │ ┃ ╰──────────────────────────────────────────╯ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · HN pulse
HN pulse · TinyFish CLI · starting r: Refresh +: Track something: Rust launches with 100+ points today ⏎ a ╭──────────────────────────────────────────────────────────╮ │ Trending now · waiting edit ↓ x │ │ front page · 24h · by velocity · list · top 8 · every 1… │ │ Loading… │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Rising themes · waiting edit ↑ x │ │ front page · 7d · by points · themes · top 6 · every 36… │ │ Reading the archive… │ ╰──────────────────────────────────────────────────────────╯
README

HN pulse

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
  • Trending now: every 15 minutes the mod reads the HN lists and measures points per hour and rank movement.
  • Programmable cards: type what you want to track. Claude (Haiku) turns your words into a card, you check the preview, and you add it.
  • Rising themes: Haiku groups the titles of the daily front pages into themes and shows how each theme moves.
  • Catch-up: after more than 12 hours away, a card lists the top stories of the days that you missed, with a short digest.
  • Thread summaries: press ▸ 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.

Requirements

  • Claude Code 2.1.293 or later, in a terminal or in a local session of the desktop app. Mods are on by default. Your organization can turn them off.
  • A TinyFish account. The free tier covers normal use. You can use an API key or the tinyfish CLI login.
  • Claude usage. Card specs, digests, themes and thread summaries are short Haiku calls on your Claude Code plan or API key.

Install

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.ai and hacker-news.firebaseio.com. It runs one local command, the tinyfish CLI, and only when no API key is set.

Connect TinyFish

The mod uses the first of these that it finds.

  1. The API key option (recommended). Get a key at agent.tinyfish.ai/api-keys. In a Claude Code session, run /plugin configure hn-mod@hn-mod and paste the key into TinyFish API key. Claude Code keeps it in your system's secure storage.
  2. The TINYFISH_API_KEY environment variable, in the environment that starts Claude Code.
  3. The 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 --config on the command line. Your shell history keeps it.

The pane header shows which route is in use: TinyFish API or TinyFish CLI.

Use the pane

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.

ToDo this
Give the pane the keyboardClick it, or press Ctrl+X then Tab. Esc gives the keyboard back to the prompt
Add a cardType what to track in the + box and press Enter. Check the preview, then press Add card
Change the previewType a change, such as "only the last 6 hours", and press Enter again
Change a cardPress edit on the card, type the change, then press Save card
Move or remove a cardPress ↑, ↓ or x on the card
Read a threadPress ▸ beside a story. The title opens the story, and the comment count opens the HN thread
Refresh every cardPress 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.

Program the pane

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 typeThe card
Rust launches with 100+ points todayShow HN · "rust" · ≥100 pts · 24h · by points · list
what is hot on the front page right nowfront page · 6h · by velocity · list
MCP and agent tooling chatter this week, summarizedsearch · "MCP agent tooling" · 7d · by points · digest
biggest debates on Ask HN this weekAsk HN · ≥50 comments · 7d · by comments · list
themes of the front page this monthfront page · 30d · by points · themes
show how Postgres stories trendsearch · "Postgres" · 7d · by new · spark

The spec has these fields:

FieldValues
sourcefront (front page), new, show (Show HN), ask (Ask HN), search (TinyFish Search limited to HN)
queryThe search terms for search. A title filter for the lists, with alternatives split by `\`
window1h, 6h, 24h, 7d, 30d. A front card with 7d or 30d reads the daily archive pages
minPoints, minCommentsWhole numbers
sortvelocity (points per hour), points, comments, new
viewlist; spark (a points sparkline per story, or stories over time for a search); themes; digest (two sentences above the list)
limit1 to 15 stories
everyMinutesHow often the card refreshes, 5 to 1440

How it measures trends

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
  • Points per hour compares the newest snapshot with the snapshot nearest to one hour old. Until there are two snapshots, it is the average since the story was posted.
  • Archive days are cached. A day read 12 hours after it ended is final. A more recent day is read again after 3 hours.
  • Digests and themes call the model again only when their stories change.

Settings

Change the settings with /plugin configure hn-mod@hn-mod. The settings that are not secret are also rows in /config.

SettingDefaultWhat it does
tinyfish_api_keyemptyYour TinyFish API key. Kept in secure storage
tinyfish_clitinyfishThe CLI command when no key is set. Use an absolute path if Claude Code does not find the CLI on its PATH
modelhaikuThe model for card specs, digests, themes and thread summaries. Calls use low effort
snapshot_minutes15How often the mod reads the HN lists (5 to 120)
auto_opentrueOpen the pane when a session starts

Costs and limits

  • TinyFish Fetch is free for up to 1,000 URLs a day. The mod reads each HN list that your cards use once per snapshot: about 96 URLs a day per list at the default interval. Archive days and thread pages add a few more.
  • TinyFish Search is free for up to 12,000 requests a day. Each search card sends one or two requests per refresh.
  • Claude calls run on your plan or API key. A card spec, a digest or a thread summary is one short Haiku call. Themes run again only when the archive changes.

Data and privacy

  • TinyFish gets the HN page addresses and the search terms of your cards.
  • The HN API gets the ids of stories that search cards show.
  • Claude gets story titles, thread text for the summaries that you open, and the words that you type in the + box.
  • Your machine keeps the cards, snapshots, archive and caches in ~/.claude/plugins/store/hn-mod_*.json. Every Claude Code session on the machine shares this store.

Troubleshooting

You seeDo this
No paneWiden the terminal to 144 columns, press Open on the band, or run /hn
"tinyfish" is not on PATHSet 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 allRun /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.

Update and uninstall

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

Develop

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 .
FileWhat it holds
hooks/register.tsxThe hooks module: every call on $, the state atoms, the refresh timer, the pane and band hooks
hooks/pane.tsxThe pane and the band, sized to the width the pane gets
hooks/sources.tsTinyFish request shapes, the HN list parser (both layouts that TinyFish Fetch returns), and the HN item API rows
hooks/trend.tsSnapshots, points per hour, filters, sort, sparklines
hooks/spec.tsThe card spec, its checks, and the default cards
hooks/compile.ts, hooks/digest.tsThe model prompts and the reply readers
types/index.d.tsThe $.state contract
tests/Tests, with a real TinyFish front-page reply (user names replaced)

See the Claude Code docs for mods and plugin install.

Source 8 files
hooks/register.tsx 599 lines
1import { 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}
599
hooks/compile.ts 69 lines
1import 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}
69
hooks/digest.ts 62 lines
1import 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}
62
hooks/pane.tsx 242 lines
1import 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}
242
hooks/sources.ts 284 lines
1import 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))
284
hooks/spec.ts 137 lines
1import 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}
137
hooks/trend.ts 133 lines
1import 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}
133
types/index.d.ts 93 lines
1export 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