hn-bits 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,7 +107,15 @@ All three keys are required, `telegram.enabled` alone is not enough. With nothin
107
107
  hn watch --once # one pass: query subscriptions, notify new matches, exit
108
108
  ```
109
109
 
110
- Schedule it, e.g. every 30 minutes via crontab:
110
+ The first `hn sub add` offers to install a cron job for you (every 30 minutes). Manage it anytime via the CLI, or `c` on the Subs tab (which also shows a live installed/not-installed status line):
111
+
112
+ ```bash
113
+ hn schedule status # installed / not installed
114
+ hn schedule install # install now (no-op if already installed)
115
+ hn schedule remove # uninstall
116
+ ```
117
+
118
+ Or set it up by hand:
111
119
 
112
120
  ```
113
121
  */30 * * * * cd /path/to/hn-bits && hn watch --once
@@ -188,6 +196,9 @@ Same as story list, minus search; `B` removes a bookmark instead of adding one.
188
196
  | `a` | add subscription |
189
197
  | `e` | edit subscription |
190
198
  | `d` | delete subscription |
199
+ | `c` | toggle watch cron schedule (install/remove) |
200
+
201
+ Shows a `schedule: installed (every 30 min)` / `schedule: not installed` status line, so you don't need to drop to `hn schedule status` to check.
191
202
 
192
203
  **Sub matches** (opened from Subs via `enter`)
193
204
 
@@ -50,19 +50,32 @@ export async function searchStories(query, page = 0) {
50
50
  totalHits: res.nbHits,
51
51
  };
52
52
  }
53
+ const DEFAULT_HITS_PER_PAGE = 50;
53
54
  /**
54
55
  * Recency-ordered search (subscription matching + watcher window rescans),
55
56
  * as opposed to searchStories' relevance ordering.
57
+ *
58
+ * Both thresholds are always pushed server-side. With one active it's a flat AND'd
59
+ * numericFilters string, same as before minComments existed. With both active, Algolia's
60
+ * nested-array numericFilters OR syntax (`[a, [b, c]]` = a AND (b OR c)) — verified live against
61
+ * hn.algolia.com — expresses `created_at_i>X AND (points>=N OR num_comments>=M)` in one request.
56
62
  */
57
63
  export async function searchRecent(query, options) {
58
- const numericFilters = [`created_at_i>${options.createdAfter}`];
59
- if (options.minPoints)
60
- numericFilters.push(`points>=${options.minPoints}`);
64
+ const minPoints = options.minPoints ?? 0;
65
+ const minComments = options.minComments ?? 0;
66
+ const createdAfterFilter = `created_at_i>${options.createdAfter}`;
67
+ const numericFilters = minPoints > 0 && minComments > 0
68
+ ? JSON.stringify([createdAfterFilter, [`points>=${minPoints}`, `num_comments>=${minComments}`]])
69
+ : [
70
+ createdAfterFilter,
71
+ ...(minPoints ? [`points>=${minPoints}`] : []),
72
+ ...(minComments ? [`num_comments>=${minComments}`] : []),
73
+ ].join(',');
61
74
  const params = new URLSearchParams({
62
75
  query,
63
76
  tags: 'story',
64
- numericFilters: numericFilters.join(','),
65
- hitsPerPage: String(options.hitsPerPage ?? 50),
77
+ numericFilters,
78
+ hitsPerPage: String(options.hitsPerPage ?? DEFAULT_HITS_PER_PAGE),
66
79
  typoTolerance: 'false',
67
80
  queryType: 'prefixNone',
68
81
  });
package/dist/db/db.js CHANGED
@@ -31,6 +31,7 @@ const MIGRATIONS = [
31
31
  bookmarked_at INTEGER NOT NULL
32
32
  );
33
33
  `,
34
+ `ALTER TABLE subscriptions ADD COLUMN min_comments INTEGER NOT NULL DEFAULT 0;`,
34
35
  ];
35
36
  export function dbPath() {
36
37
  return process.env.HN_BITS_DB || join(homedir(), '.local', 'share', 'hn-bits', 'hn-bits.db');
@@ -5,6 +5,7 @@ function fromRow(row) {
5
5
  name: row.name,
6
6
  query: row.query,
7
7
  minPoints: row.min_points,
8
+ minComments: row.min_comments,
8
9
  createdAt: row.created_at,
9
10
  lastRunAt: row.last_run_at,
10
11
  };
@@ -17,13 +18,13 @@ function assertNameFree(name, excludeId) {
17
18
  if (conflict)
18
19
  throw new Error(`subscription '${name}' already exists`);
19
20
  }
20
- export function addSubscription(name, query, minPoints) {
21
+ export function addSubscription(name, query, minPoints, minComments = 0) {
21
22
  assertNameFree(name);
22
23
  const createdAt = Math.floor(Date.now() / 1000);
23
24
  const info = openDb()
24
- .prepare('INSERT INTO subscriptions (name, query, min_points, created_at) VALUES (?, ?, ?, ?)')
25
- .run(name, query, minPoints, createdAt);
26
- return { id: Number(info.lastInsertRowid), name, query, minPoints, createdAt, lastRunAt: null };
25
+ .prepare('INSERT INTO subscriptions (name, query, min_points, min_comments, created_at) VALUES (?, ?, ?, ?, ?)')
26
+ .run(name, query, minPoints, minComments, createdAt);
27
+ return { id: Number(info.lastInsertRowid), name, query, minPoints, minComments, createdAt, lastRunAt: null };
27
28
  }
28
29
  export function listSubscriptions() {
29
30
  const rows = openDb().prepare('SELECT * FROM subscriptions ORDER BY created_at ASC').all();
@@ -36,7 +37,7 @@ export function removeSubscription(name) {
36
37
  export function updateSubscription(id, fields) {
37
38
  assertNameFree(fields.name, id);
38
39
  const db = openDb();
39
- db.prepare('UPDATE subscriptions SET name = ?, query = ?, min_points = ? WHERE id = ?').run(fields.name, fields.query, fields.minPoints, id);
40
+ db.prepare('UPDATE subscriptions SET name = ?, query = ?, min_points = ?, min_comments = ? WHERE id = ?').run(fields.name, fields.query, fields.minPoints, fields.minComments ?? 0, id);
40
41
  return fromRow(db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id));
41
42
  }
42
43
  export function touchLastRun(id, at) {
package/dist/index.js CHANGED
@@ -112,25 +112,53 @@ config
112
112
  }
113
113
  });
114
114
  const sub = program.command('sub').description('Manage topic subscriptions');
115
+ function parseNonNegativeInt(value, flag) {
116
+ const parsed = Number(value);
117
+ if (!Number.isInteger(parsed) || parsed < 0) {
118
+ console.error(`${flag} must be a non-negative integer, got '${value}'`);
119
+ process.exit(1);
120
+ }
121
+ return parsed;
122
+ }
123
+ async function promptInstallSchedule() {
124
+ const readline = await import('node:readline/promises');
125
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
126
+ const answer = await rl.question('No watch schedule installed yet. Install one now (every 30 min)? [y/N] ');
127
+ rl.close();
128
+ if (!/^y(es)?$/i.test(answer.trim())) {
129
+ console.log("skipped. Run 'hn schedule install' anytime, or add the cron line yourself (see README).");
130
+ return;
131
+ }
132
+ const { installScheduledJob } = await import('./lib/schedule.js');
133
+ try {
134
+ const line = installScheduledJob();
135
+ console.log(`installed: ${line}`);
136
+ }
137
+ catch {
138
+ console.log("couldn't find 'hn' on PATH — add the cron line yourself (see README) or run 'hn schedule install' later.");
139
+ }
140
+ }
115
141
  sub
116
142
  .command('add <name> <query...>')
117
143
  .description('Add a subscription')
118
144
  .option('--min-points <n>', 'minimum points threshold', '0')
145
+ .option('--min-comments <n>', 'minimum comments threshold', '0')
119
146
  .action(async (name, queryParts, options) => {
120
- const minPoints = Number(options.minPoints);
121
- if (!Number.isInteger(minPoints) || minPoints < 0) {
122
- console.error(`--min-points must be a non-negative integer, got '${options.minPoints}'`);
123
- process.exit(1);
124
- }
125
- const { addSubscription } = await import('./db/subscriptions.js');
147
+ const minPoints = parseNonNegativeInt(options.minPoints, '--min-points');
148
+ const minComments = parseNonNegativeInt(options.minComments, '--min-comments');
149
+ const { addSubscription, listSubscriptions } = await import('./db/subscriptions.js');
126
150
  try {
127
- addSubscription(name, queryParts.join(' '), minPoints);
151
+ addSubscription(name, queryParts.join(' '), minPoints, minComments);
128
152
  console.log(`added '${name}'`);
129
153
  }
130
154
  catch (err) {
131
155
  console.error(err.message);
132
156
  process.exit(1);
133
157
  }
158
+ const { hasScheduledJob } = await import('./lib/schedule.js');
159
+ if (listSubscriptions().length === 1 && !hasScheduledJob()) {
160
+ await promptInstallSchedule();
161
+ }
134
162
  });
135
163
  sub
136
164
  .command('list')
@@ -138,6 +166,7 @@ sub
138
166
  .action(async () => {
139
167
  const { listSubscriptions } = await import('./db/subscriptions.js');
140
168
  const { formatAge } = await import('./lib/format.js');
169
+ const { thresholdLabel } = await import('./lib/subscriptionLabel.js');
141
170
  const subs = listSubscriptions();
142
171
  if (subs.length === 0) {
143
172
  console.log('no subscriptions yet');
@@ -145,9 +174,9 @@ sub
145
174
  }
146
175
  const nameWidth = Math.max(...subs.map((s) => s.name.length));
147
176
  for (const s of subs) {
148
- const points = s.minPoints === 0 ? 'any' : `>=${s.minPoints}`;
177
+ const threshold = thresholdLabel(s.minPoints, s.minComments);
149
178
  const lastRun = s.lastRunAt == null ? 'never' : `${formatAge(s.lastRunAt)} ago`;
150
- console.log(`${s.name.padEnd(nameWidth)} "${s.query}" ${points} ${lastRun}`);
179
+ console.log(`${s.name.padEnd(nameWidth)} "${s.query}" ${threshold} ${lastRun}`);
151
180
  }
152
181
  });
153
182
  sub
@@ -161,6 +190,34 @@ sub
161
190
  }
162
191
  console.log(`removed '${name}'`);
163
192
  });
193
+ const schedule = program.command('schedule').description('Manage the cron job that runs `hn watch --once`');
194
+ schedule
195
+ .command('status')
196
+ .description('Show whether the watch cron job is installed')
197
+ .action(async () => {
198
+ const { hasScheduledJob } = await import('./lib/schedule.js');
199
+ console.log(hasScheduledJob() ? 'installed' : 'not installed');
200
+ });
201
+ schedule
202
+ .command('install')
203
+ .description('Install the watch cron job (no-op if already installed)')
204
+ .action(async () => {
205
+ const { installScheduledJob } = await import('./lib/schedule.js');
206
+ try {
207
+ console.log(`installed: ${installScheduledJob()}`);
208
+ }
209
+ catch {
210
+ console.error("couldn't find 'hn' on PATH; add the cron line yourself (see README)");
211
+ process.exit(1);
212
+ }
213
+ });
214
+ schedule
215
+ .command('remove')
216
+ .description('Remove the watch cron job')
217
+ .action(async () => {
218
+ const { removeScheduledJob } = await import('./lib/schedule.js');
219
+ console.log(removeScheduledJob() ? 'removed' : 'not installed');
220
+ });
164
221
  program
165
222
  .command('watch')
166
223
  .description('Check subscriptions for new matches and notify (one-shot; cron owns scheduling)')
@@ -0,0 +1,53 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const MARKER = '# hn-bits watch';
5
+ function watchLogPath() {
6
+ return join(homedir(), '.local', 'share', 'hn-bits', 'watch.log');
7
+ }
8
+ function readCrontab() {
9
+ try {
10
+ return execSync('crontab -l', { encoding: 'utf-8' });
11
+ }
12
+ catch {
13
+ return '';
14
+ }
15
+ }
16
+ function writeCrontab(content) {
17
+ execSync('crontab -', { input: content, encoding: 'utf-8' });
18
+ }
19
+ function resolveHnPath() {
20
+ return execSync('which hn', { encoding: 'utf-8' }).trim();
21
+ }
22
+ function resolveNodePath() {
23
+ return execSync('which node', { encoding: 'utf-8' }).trim();
24
+ }
25
+ /** Invokes node directly rather than relying on hn's `#!/usr/bin/env node` shebang, since cron's
26
+ * minimal PATH (unlike an interactive shell) usually can't resolve a version-manager-installed node. */
27
+ function buildCronLine(nodePath, hnPath) {
28
+ return `*/30 * * * * ${nodePath} ${hnPath} watch --once >> ${watchLogPath()} 2>&1 ${MARKER}`;
29
+ }
30
+ export function hasScheduledJob() {
31
+ return readCrontab()
32
+ .split('\n')
33
+ .some((line) => line.includes(MARKER));
34
+ }
35
+ /** Idempotent: no-ops if a job is already installed. Throws if `hn` isn't on PATH. */
36
+ export function installScheduledJob() {
37
+ const current = readCrontab();
38
+ const existing = current.split('\n').find((line) => line.includes(MARKER));
39
+ if (existing)
40
+ return existing;
41
+ const line = buildCronLine(resolveNodePath(), resolveHnPath());
42
+ const body = current.trim().length > 0 ? `${current.replace(/\n+$/, '')}\n${line}\n` : `${line}\n`;
43
+ writeCrontab(body);
44
+ return line;
45
+ }
46
+ export function removeScheduledJob() {
47
+ const lines = readCrontab().split('\n');
48
+ const filtered = lines.filter((line) => !line.includes(MARKER));
49
+ if (filtered.length === lines.length)
50
+ return false;
51
+ writeCrontab(filtered.join('\n'));
52
+ return true;
53
+ }
@@ -0,0 +1,9 @@
1
+ /** "any" / "≥20 pts" / "≥5 cmts" / "≥20 pts or ≥5 cmts" — shared by CLI and TUI. */
2
+ export function thresholdLabel(minPoints, minComments) {
3
+ const parts = [];
4
+ if (minPoints > 0)
5
+ parts.push(`≥${minPoints} pts`);
6
+ if (minComments > 0)
7
+ parts.push(`≥${minComments} cmts`);
8
+ return parts.length === 0 ? 'any' : parts.join(' or ');
9
+ }
@@ -2,13 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { Box, Text, useInput, useWindowSize } from 'ink';
4
4
  import { searchRecent } from '../api/algolia.js';
5
- import { addSubscription, updateSubscription } from '../db/subscriptions.js';
5
+ import { addSubscription, listSubscriptions, updateSubscription } from '../db/subscriptions.js';
6
6
  import { truncateTitle } from '../lib/format.js';
7
+ import { hasScheduledJob, installScheduledJob } from '../lib/schedule.js';
8
+ import { thresholdLabel } from '../lib/subscriptionLabel.js';
7
9
  import { useTheme } from './theme.js';
8
10
  const PREVIEW_DEBOUNCE_MS = 300;
9
11
  const PREVIEW_WINDOW_DAYS = 7;
10
12
  const PREVIEW_LIMIT = 5;
11
- const FIELD_ORDER = ['name', 'query', 'minPoints'];
13
+ const FIELD_ORDER = ['name', 'query', 'minPoints', 'minComments'];
12
14
  function windowStart() {
13
15
  return Math.floor(Date.now() / 1000) - PREVIEW_WINDOW_DAYS * 24 * 60 * 60;
14
16
  }
@@ -18,9 +20,11 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
18
20
  const [name, setName] = useState(subscription?.name ?? '');
19
21
  const [query, setQuery] = useState(subscription?.query ?? prefillQuery ?? '');
20
22
  const [minPoints, setMinPoints] = useState(String(subscription?.minPoints ?? 0));
23
+ const [minComments, setMinComments] = useState(String(subscription?.minComments ?? 0));
21
24
  const [field, setField] = useState('name');
22
25
  const [error, setError] = useState('');
23
26
  const [preview, setPreview] = useState([]);
27
+ const [scheduleConfirm, setScheduleConfirm] = useState(false);
24
28
  const debounceRef = useRef(null);
25
29
  const previewToken = useRef(0);
26
30
  useEffect(() => {
@@ -36,12 +40,18 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
36
40
  clearTimeout(debounceRef.current);
37
41
  };
38
42
  // eslint-disable-next-line react-hooks/exhaustive-deps
39
- }, [query, minPoints]);
43
+ }, [query, minPoints, minComments]);
40
44
  async function loadPreview() {
41
45
  const myToken = ++previewToken.current;
42
46
  const points = Number(minPoints) || 0;
47
+ const comments = Number(minComments) || 0;
43
48
  try {
44
- const results = await searchRecent(query, { createdAfter: windowStart(), minPoints: points, hitsPerPage: PREVIEW_LIMIT });
49
+ const results = await searchRecent(query, {
50
+ createdAfter: windowStart(),
51
+ minPoints: points,
52
+ minComments: comments,
53
+ hitsPerPage: PREVIEW_LIMIT,
54
+ });
45
55
  if (myToken === previewToken.current)
46
56
  setPreview(results);
47
57
  }
@@ -58,18 +68,47 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
58
68
  if (!trimmedQuery)
59
69
  return setError('query is required');
60
70
  const points = Number(minPoints) || 0;
71
+ const comments = Number(minComments) || 0;
61
72
  try {
62
73
  if (mode === 'add')
63
- addSubscription(trimmedName, trimmedQuery, points);
74
+ addSubscription(trimmedName, trimmedQuery, points, comments);
64
75
  else
65
- updateSubscription(subscription.id, { name: trimmedName, query: trimmedQuery, minPoints: points });
76
+ updateSubscription(subscription.id, {
77
+ name: trimmedName,
78
+ query: trimmedQuery,
79
+ minPoints: points,
80
+ minComments: comments,
81
+ });
82
+ if (mode === 'add' && listSubscriptions().length === 1 && !hasScheduledJob()) {
83
+ setScheduleConfirm(true);
84
+ return;
85
+ }
66
86
  onSave();
67
87
  }
68
88
  catch (err) {
69
89
  setError(err.message);
70
90
  }
71
91
  }
92
+ function resolveScheduleConfirm(install) {
93
+ if (install) {
94
+ try {
95
+ installScheduledJob();
96
+ }
97
+ catch {
98
+ // hn not on PATH; user can run `hn schedule install` later
99
+ }
100
+ }
101
+ setScheduleConfirm(false);
102
+ onSave();
103
+ }
72
104
  function handleInput(input, key) {
105
+ if (scheduleConfirm) {
106
+ if (input === 'y')
107
+ return resolveScheduleConfirm(true);
108
+ if (input === 'n' || key.escape)
109
+ return resolveScheduleConfirm(false);
110
+ return;
111
+ }
73
112
  if (key.escape)
74
113
  return onCancel();
75
114
  if (key.tab) {
@@ -85,6 +124,8 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
85
124
  setQuery((v) => v.slice(0, -1));
86
125
  if (field === 'minPoints')
87
126
  setMinPoints((v) => v.slice(0, -1));
127
+ if (field === 'minComments')
128
+ setMinComments((v) => v.slice(0, -1));
88
129
  return;
89
130
  }
90
131
  if (input && !key.ctrl && !key.meta) {
@@ -95,10 +136,13 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
95
136
  if (field === 'minPoints' && /^\d+$/.test(input)) {
96
137
  setMinPoints((v) => (v === '0' ? input : v + input));
97
138
  }
139
+ if (field === 'minComments' && /^\d+$/.test(input)) {
140
+ setMinComments((v) => (v === '0' ? input : v + input));
141
+ }
98
142
  }
99
143
  }
100
144
  useInput(handleInput);
101
145
  const cursor = (f) => (field === f ? '▏' : '');
102
- const previewLabel = Number(minPoints) > 0 ? `≥${minPoints} pts` : 'any points';
103
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: mode === 'add' ? 'New subscription' : 'Edit subscription' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["name: ", name, cursor('name')] }), _jsxs(Text, { children: ["query: ", query, cursor('query')] }), _jsxs(Text, { children: ["min points: ", minPoints, cursor('minPoints')] }), _jsx(Text, { children: " " }), query.trim() && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["preview (last 7 days, ", previewLabel, "):"] }), preview.length === 0 && _jsx(Text, { dimColor: true, children: " no matches" }), preview.map((story, i) => (_jsxs(Text, { children: [' ', i + 1, ". ", truncateTitle(story.title, Math.max(1, columns - 24)), " ", story.score, " pts"] }, story.id)))] })), error && _jsx(Text, { color: theme.colors.error, children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "tab next field \u00B7 enter save \u00B7 esc cancel" })] }));
146
+ const previewLabel = thresholdLabel(Number(minPoints) || 0, Number(minComments) || 0);
147
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: mode === 'add' ? 'New subscription' : 'Edit subscription' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["name: ", name, cursor('name')] }), _jsxs(Text, { children: ["query: ", query, cursor('query')] }), _jsxs(Text, { children: ["min points: ", minPoints, cursor('minPoints')] }), _jsxs(Text, { children: ["min comments: ", minComments, cursor('minComments')] }), _jsx(Text, { children: " " }), query.trim() && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["preview (last 7 days, ", previewLabel, "):"] }), preview.length === 0 && _jsx(Text, { dimColor: true, children: " no matches" }), preview.map((story, i) => (_jsxs(Text, { children: [' ', i + 1, ". ", truncateTitle(story.title, Math.max(1, columns - 24)), " ", story.score, " pts"] }, story.id)))] })), error && _jsx(Text, { color: theme.colors.error, children: error }), _jsx(Text, { children: " " }), scheduleConfirm ? (_jsx(Text, { dimColor: true, children: "no watch schedule installed yet - install one now (every 30 min)? y/n" })) : (_jsx(Text, { dimColor: true, children: "tab next field \u00B7 enter save \u00B7 esc cancel" }))] }));
104
148
  }
@@ -44,6 +44,7 @@ export function SubscriptionMatches({ subscription, config, onSelectStory, onBac
44
44
  const results = await searchRecent(subscription.query, {
45
45
  createdAfter: windowStart(),
46
46
  minPoints: subscription.minPoints,
47
+ minComments: subscription.minComments,
47
48
  });
48
49
  setStories(results);
49
50
  setStatus('ready');
@@ -4,13 +4,13 @@ import { Box, Text, useInput, useWindowSize } from 'ink';
4
4
  import { listSubscriptions, removeSubscription } from '../db/subscriptions.js';
5
5
  import { formatAge } from '../lib/format.js';
6
6
  import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
7
+ import { hasScheduledJob, installScheduledJob, removeScheduledJob } from '../lib/schedule.js';
8
+ import { thresholdLabel } from '../lib/subscriptionLabel.js';
7
9
  import { ensureVisibleLines, sliceByLines } from '../lib/viewport.js';
8
10
  import { footerRows, SUBS_KEYS } from './keymap.js';
9
11
  import { HEADER_ROWS } from './Layout.js';
10
12
  import { useTheme } from './theme.js';
11
- function pointsLabel(minPoints) {
12
- return minPoints === 0 ? 'any' : `≥${minPoints} pts`;
13
- }
13
+ import { useFlash } from './useFlash.js';
14
14
  export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange, onTabChange, }) {
15
15
  const theme = useTheme();
16
16
  const { columns, rows } = useWindowSize();
@@ -18,6 +18,8 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
18
18
  const [subs, setSubs] = useState(() => listSubscriptions());
19
19
  const [selected, setSelected] = useState(0);
20
20
  const [deleteConfirm, setDeleteConfirm] = useState(null);
21
+ const [scheduleInstalled, setScheduleInstalled] = useState(() => hasScheduledJob());
22
+ const [scheduleFlash, flashSchedule] = useFlash();
21
23
  const pendingTopJump = useRef(false);
22
24
  const topLineRef = useRef(0);
23
25
  function reload() {
@@ -25,6 +27,22 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
25
27
  setSubs(next);
26
28
  setSelected((s) => clampSelection(s, 0, next.length));
27
29
  }
30
+ function toggleSchedule() {
31
+ if (scheduleInstalled) {
32
+ removeScheduledJob();
33
+ setScheduleInstalled(false);
34
+ flashSchedule('schedule removed');
35
+ return;
36
+ }
37
+ try {
38
+ installScheduledJob();
39
+ setScheduleInstalled(true);
40
+ flashSchedule('schedule installed');
41
+ }
42
+ catch {
43
+ flashSchedule("couldn't find 'hn' on PATH");
44
+ }
45
+ }
28
46
  function handleInput(input, key) {
29
47
  if (deleteConfirm) {
30
48
  if (input === 'y') {
@@ -61,12 +79,17 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
61
79
  return onEdit(subs[selected]);
62
80
  if (input === 'd' && subs[selected])
63
81
  return setDeleteConfirm(subs[selected].name);
82
+ if (input === 'c')
83
+ return toggleSchedule();
64
84
  }
65
85
  useInput(handleInput);
86
+ const scheduleStatusText = scheduleInstalled ? 'schedule: installed (every 30 min)' : 'schedule: not installed';
66
87
  if (subs.length === 0) {
67
- return _jsx(Text, { dimColor: true, children: "no subscriptions yet - press a to add" });
88
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no subscriptions yet - press a to add" }), _jsx(Text, { dimColor: true, children: scheduleStatusText }), scheduleFlash && _jsx(Text, { dimColor: true, children: scheduleFlash })] }));
68
89
  }
69
- const statusLineHeight = deleteConfirm ? 1 : 0;
90
+ const deleteLineHeight = deleteConfirm ? 1 : 0;
91
+ const scheduleFlashLineHeight = scheduleFlash ? 1 : 0;
92
+ const statusLineHeight = 1 + deleteLineHeight + scheduleFlashLineHeight;
70
93
  const listHeight = Math.max(1, bodyHeight - statusLineHeight);
71
94
  const heights = subs.map(() => 1);
72
95
  const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
@@ -78,6 +101,6 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
78
101
  const isSelected = index === selected;
79
102
  const marker = isSelected ? theme.glyphs.selection : ' ';
80
103
  const background = isSelected ? theme.colors.selectionBackground : undefined;
81
- return (_jsxs(Text, { backgroundColor: background, children: [marker, " ", sub.name.padEnd(nameWidth), " \"", sub.query, "\" ", pointsLabel(sub.minPoints).padEnd(9), " added", ' ', formatAge(sub.createdAt), " ago"] }, sub.id));
82
- }), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] })] }));
104
+ return (_jsxs(Text, { backgroundColor: background, children: [marker, " ", sub.name.padEnd(nameWidth), " \"", sub.query, "\" ", thresholdLabel(sub.minPoints, sub.minComments).padEnd(19), " added", ' ', formatAge(sub.createdAt), " ago"] }, sub.id));
105
+ }), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] }), _jsx(Text, { dimColor: true, children: scheduleStatusText }), scheduleFlash && _jsx(Text, { dimColor: true, children: scheduleFlash })] }));
83
106
  }
package/dist/ui/keymap.js CHANGED
@@ -49,6 +49,7 @@ export const SUBS_KEYS = [
49
49
  { key: 'a', label: 'add' },
50
50
  { key: 'e', label: 'edit' },
51
51
  { key: 'd', label: 'delete' },
52
+ { key: 'c', label: 'schedule' },
52
53
  ];
53
54
  export const SUB_MATCHES_KEYS = [
54
55
  { key: 'j/k', label: 'move' },
package/dist/watch.js CHANGED
@@ -35,7 +35,11 @@ async function notifyStory(sub, story, notifiers) {
35
35
  async function processSubscription(sub, notifiers, now, dryRun) {
36
36
  let stories;
37
37
  try {
38
- stories = await searchRecent(sub.query, { createdAfter: windowStart(sub.lastRunAt, now), minPoints: sub.minPoints });
38
+ stories = await searchRecent(sub.query, {
39
+ createdAfter: windowStart(sub.lastRunAt, now),
40
+ minPoints: sub.minPoints,
41
+ minComments: sub.minComments,
42
+ });
39
43
  }
40
44
  catch (err) {
41
45
  log(`${sub.name}: query failed - ${err.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hn-bits",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Terminal-first Hacker News client",
5
5
  "type": "module",
6
6
  "repository": {