tui-trends 1.0.3 → 1.0.4

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.
Files changed (4) hide show
  1. package/README.md +40 -15
  2. package/dist/cli.js +270 -131
  3. package/package.json +5 -1
  4. package/dist/cli.cjs +0 -46297
package/README.md CHANGED
@@ -6,14 +6,14 @@ Cyberpunk Google Trends visualizer in your terminal.
6
6
 
7
7
  ## Install globally
8
8
 
9
- **From npm (once published):**
9
+ **From npm:**
10
10
 
11
11
  ```bash
12
12
  npm install -g tui-trends
13
13
  tui-trends "buy bitcoin"
14
14
  ```
15
15
 
16
- **From this repo (no npm publish needed):**
16
+ **From this repo:**
17
17
 
18
18
  ```bash
19
19
  npm install
@@ -28,38 +28,63 @@ tui-trends "buy bitcoin"
28
28
  npx tui-trends "buy bitcoin"
29
29
  ```
30
30
 
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Google Trends (default)
35
+ tui-trends "buy bitcoin"
36
+
37
+ # npm package download trends — no rate limits, great for testing
38
+ tui-trends --npm react
39
+ tui-trends --npm typescript
40
+ tui-trends --npm next
41
+ ```
42
+
31
43
  ## Development
32
44
 
33
45
  ```bash
34
46
  npm install
35
- npm start -- "buy bitcoin" # runs via tsx, no build step
36
- npm run dev -- "buy bitcoin" # tsx watch mode, reloads on save
47
+ npm start -- "buy bitcoin" # runs via tsx, no build step
48
+ npm run dev -- "buy bitcoin" # tsx watch mode, reloads on save
49
+ npm start -- --npm react # npm mode in dev
37
50
  ```
38
51
 
39
52
  ## Keybindings
40
53
 
41
- | Key | Action |
42
- | ----- | -------------------------------- |
43
- | `q` | Quit |
44
- | `r` | Refresh / re-fetch data |
45
- | `↑ ↓` | Scroll the related queries table |
54
+ | Key | Action |
55
+ | --------- | ------------------------ |
56
+ | `q` | Quit |
57
+ | `← →` | Cycle through themes |
58
+
59
+ ## Themes
60
+
61
+ Cycle through 6 built-in colour themes with `←` / `→`:
62
+
63
+ | # | Name | Vibe |
64
+ |---|-------------|-------------------------------|
65
+ | 1 | Synthwave | Neon cyan & hot pink (default)|
66
+ | 2 | Matrix | All green on black |
67
+ | 3 | C64 | Commodore 64 blue/white |
68
+ | 4 | Amber | Phosphor amber monitor |
69
+ | 5 | Nord | Muted arctic pastels |
70
+ | 6 | Blood Moon | Deep red & orange |
46
71
 
47
72
  ## What you'll see
48
73
 
49
74
  - **Loading screen** — animated spinner with ASCII art banner while data is fetched
50
- - **Interest Over Time** — braille-rendered line chart (0–100 scale, last 12 months)
51
- - **Top Regions** — horizontal bar chart of countries with highest search interest
52
- - **Related Queries** — scrollable table of related search terms and their scores
75
+ - **Line chart** — braille-rendered trend line (0–100 index, last 12 months)
76
+ - **Bar chart** — top regions (Google mode) or peak download weeks (npm mode)
77
+ - **Table** — related queries (Google mode) or monthly download breakdown (npm mode)
53
78
 
54
79
  ## Stack
55
80
 
56
81
  - [Rezi](https://rezitui.dev) — TypeScript TUI framework with native C rendering engine
57
82
  - [google-trends-api](https://www.npmjs.com/package/google-trends-api) — unofficial Google Trends client
58
- - [figlet](https://www.npmjs.com/package/figlet) — ASCII art banner
59
83
  - [tsx](https://github.com/privatenumber/tsx) — run TypeScript directly
60
84
 
61
85
  ## Notes
62
86
 
63
- - Requires an internet connection (hits `trends.google.com` directly)
64
- - Google Trends may rate-limit requests if called too frequently
87
+ - Requires Node 18+ (uses native `fetch` for npm API)
88
+ - Google Trends mode hits `trends.google.com` directly and may rate-limit if called repeatedly
89
+ - Use `--npm` mode for unlimited local testing
65
90
  - Best displayed in a terminal at least 120 columns wide
package/dist/cli.js CHANGED
@@ -1,14 +1,96 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { ui, rgb, draculaTheme } from "@rezi-ui/core";
4
+ import {
5
+ ui,
6
+ rgb,
7
+ draculaTheme,
8
+ darkTheme,
9
+ nordTheme,
10
+ dimmedTheme
11
+ } from "@rezi-ui/core";
5
12
  import { createNodeApp } from "@rezi-ui/node";
6
13
  import googleTrends from "google-trends-api";
7
- var NEON_CYAN = rgb(0, 255, 200);
8
- var NEON_PINK = rgb(255, 46, 151);
9
- var NEON_GREEN = rgb(80, 250, 123);
10
- var DIM_TEXT = rgb(98, 114, 164);
11
- var CHART_COLOR = "#00ffc8";
14
+ var THEMES = [
15
+ {
16
+ name: "Synthwave",
17
+ rezi: draculaTheme,
18
+ chart: "#00ffc8",
19
+ accent1: rgb(80, 250, 123),
20
+ accent2: rgb(0, 255, 200),
21
+ accent3: rgb(255, 46, 151),
22
+ banner1: rgb(0, 255, 200),
23
+ banner2: rgb(255, 46, 151),
24
+ barVariant: "info",
25
+ dim: rgb(98, 114, 164),
26
+ border: rgb(0, 200, 160)
27
+ },
28
+ {
29
+ name: "Matrix",
30
+ rezi: darkTheme,
31
+ chart: "#00ff41",
32
+ accent1: rgb(0, 255, 65),
33
+ accent2: rgb(57, 255, 20),
34
+ accent3: rgb(0, 200, 40),
35
+ banner1: rgb(0, 255, 65),
36
+ banner2: rgb(0, 160, 30),
37
+ barVariant: "success",
38
+ dim: rgb(0, 100, 25),
39
+ border: rgb(0, 160, 40)
40
+ },
41
+ {
42
+ name: "C64",
43
+ rezi: darkTheme,
44
+ chart: "#acacff",
45
+ accent1: rgb(172, 172, 255),
46
+ accent2: rgb(255, 255, 255),
47
+ accent3: rgb(255, 255, 100),
48
+ banner1: rgb(172, 172, 255),
49
+ banner2: rgb(100, 100, 220),
50
+ barVariant: "info",
51
+ dim: rgb(80, 80, 160),
52
+ border: rgb(120, 120, 220)
53
+ },
54
+ {
55
+ name: "Amber",
56
+ rezi: dimmedTheme,
57
+ chart: "#ffaa00",
58
+ accent1: rgb(255, 200, 50),
59
+ accent2: rgb(255, 140, 0),
60
+ accent3: rgb(255, 100, 0),
61
+ banner1: rgb(255, 160, 0),
62
+ banner2: rgb(200, 80, 0),
63
+ barVariant: "warning",
64
+ dim: rgb(120, 70, 0),
65
+ border: rgb(180, 100, 0)
66
+ },
67
+ {
68
+ name: "Nord",
69
+ rezi: nordTheme,
70
+ chart: "#88c0d0",
71
+ accent1: rgb(163, 190, 140),
72
+ accent2: rgb(136, 192, 208),
73
+ accent3: rgb(180, 142, 173),
74
+ banner1: rgb(136, 192, 208),
75
+ banner2: rgb(94, 129, 172),
76
+ barVariant: "info",
77
+ dim: rgb(76, 86, 106),
78
+ border: rgb(94, 129, 172)
79
+ },
80
+ {
81
+ name: "Blood Moon",
82
+ rezi: darkTheme,
83
+ chart: "#ff3030",
84
+ accent1: rgb(255, 80, 80),
85
+ accent2: rgb(255, 140, 0),
86
+ accent3: rgb(255, 220, 50),
87
+ banner1: rgb(255, 48, 48),
88
+ banner2: rgb(180, 0, 0),
89
+ barVariant: "error",
90
+ dim: rgb(100, 30, 30),
91
+ border: rgb(180, 20, 20)
92
+ }
93
+ ];
12
94
  var BANNER_TEXT = " _____ _ _ ___ _____ ____ _____ _ _ ____ ____ \n |_ _| | | |_ _| |_ _| _ \\| ____| \\ | | _ \\/ ___| \n | | | | | || | | | | |_) | _| | \\| | | | \\___ \\ \n | | | |_| || | | | | _ <| |___| |\\ | |_| |___) |\n |_| \\___/|___| |_| |_| \\_\\_____|_| \\_|____/|____/ ";
13
95
  var BANNER_LINES = BANNER_TEXT.split("\n").filter((l) => l.length > 0);
14
96
  async function fetchTrends(keyword2) {
@@ -28,164 +110,211 @@ async function fetchTrends(keyword2) {
28
110
  })
29
111
  );
30
112
  const regionsJson = JSON.parse(regionsRaw);
31
- const regions = (regionsJson.default?.geoMapData ?? []).map((d) => ({
32
- geoName: String(d.geoName ?? ""),
33
- value: Number(d.value?.[0] ?? 0)
34
- })).sort((a, b) => b.value - a.value).slice(0, 8);
113
+ const regions = (regionsJson.default?.geoMapData ?? []).map((d) => ({ geoName: String(d.geoName ?? ""), value: Number(d.value?.[0] ?? 0) })).sort((a, b) => b.value - a.value).slice(0, 8);
35
114
  const queriesJson = JSON.parse(queriesRaw);
36
115
  const rankedList = queriesJson.default?.rankedList ?? [];
37
- const queries = (rankedList[0]?.rankedKeyword ?? []).slice(0, 10).map((d) => ({
38
- query: String(d.query ?? ""),
39
- value: Number(d.value ?? 0)
116
+ const queries = (rankedList[0]?.rankedKeyword ?? []).slice(0, 12).map((d) => ({ query: String(d.query ?? ""), value: Number(d.value ?? 0) }));
117
+ return { timeline, regions, queries };
118
+ }
119
+ function monthLabel(yyyyMM) {
120
+ const names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
121
+ const [y, m] = yyyyMM.split("-");
122
+ return `${names[parseInt(m ?? "1") - 1] ?? "?"} '${(y ?? "").slice(2)}`;
123
+ }
124
+ async function fetchNpm(pkg) {
125
+ const res = await fetch(
126
+ `https://api.npmjs.org/downloads/range/last-year/${encodeURIComponent(pkg)}`
127
+ );
128
+ if (!res.ok) throw new Error(`npm registry error: ${res.status} ${res.statusText}`);
129
+ const json = await res.json();
130
+ if (json.error) throw new Error(`Package not found: "${pkg}"`);
131
+ const daily = json.downloads ?? [];
132
+ const weeks = [];
133
+ for (let i = 0; i < daily.length; i += 7) {
134
+ const chunk = daily.slice(i, Math.min(i + 7, daily.length));
135
+ const total = chunk.reduce((s, d) => s + d.downloads, 0);
136
+ weeks.push({ label: chunk[0].day, total });
137
+ }
138
+ const maxWeek = Math.max(...weeks.map((w) => w.total), 1);
139
+ const timeline = weeks.map((w) => ({
140
+ formattedTime: w.label,
141
+ value: Math.round(w.total / maxWeek * 100)
142
+ }));
143
+ const topWeeks = [...weeks].sort((a, b) => b.total - a.total).slice(0, 8);
144
+ const regions = topWeeks.map((w) => ({
145
+ // Show as "MMM 'YY" — e.g. "Mar '25"
146
+ geoName: monthLabel(w.label.slice(0, 7)) + " " + w.label.slice(8, 10),
147
+ value: Math.round(w.total / maxWeek * 100)
148
+ }));
149
+ const monthMap = /* @__PURE__ */ new Map();
150
+ for (const { day, downloads } of daily) {
151
+ const mo = day.slice(0, 7);
152
+ monthMap.set(mo, (monthMap.get(mo) ?? 0) + downloads);
153
+ }
154
+ const sortedMonths = [...monthMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
155
+ const maxMonth = Math.max(...sortedMonths.map(([, v]) => v), 1);
156
+ const queries = sortedMonths.map(([mo, total]) => ({
157
+ query: monthLabel(mo),
158
+ value: Math.round(total / maxMonth * 100)
40
159
  }));
41
160
  return { timeline, regions, queries };
42
161
  }
43
- var keyword = process.argv[2] ?? "bitcoin";
162
+ var rawArgs = process.argv.slice(2);
163
+ var mode = "google";
164
+ var keyword;
165
+ if (rawArgs[0] === "--npm") {
166
+ mode = "npm";
167
+ keyword = rawArgs[1] ?? "react";
168
+ } else {
169
+ keyword = rawArgs[0] ?? "bitcoin";
170
+ }
44
171
  var app = createNodeApp({
45
- initialState: { status: "loading", keyword, data: null, error: null },
46
- theme: draculaTheme
172
+ initialState: { status: "loading", keyword, mode, data: null, error: null, themeIndex: 0 },
173
+ theme: THEMES[0].rezi
47
174
  });
48
- async function refetch() {
49
- app.update((s) => ({ ...s, status: "loading", data: null, error: null }));
175
+ async function fetchOnce(kw) {
50
176
  try {
51
- const data = await fetchTrends(keyword);
177
+ const data = mode === "npm" ? await fetchNpm(kw) : await fetchTrends(kw);
52
178
  app.update((s) => ({ ...s, status: "ready", data }));
53
179
  } catch (err) {
54
- const msg = err instanceof Error ? err.message : String(err);
180
+ const raw = err instanceof Error ? err.message : String(err);
181
+ const isRateLimit = raw.includes("<HEAD>") || raw.includes("DOCTYPE") || raw.includes("not valid JSON");
182
+ const msg = isRateLimit ? "Google Trends is rate-limiting requests from this IP.\n\nWait 1\u20132 minutes and try again." : raw;
55
183
  app.update((s) => ({ ...s, status: "error", error: msg }));
56
184
  }
57
185
  }
186
+ function theme(state) {
187
+ return THEMES[state.themeIndex] ?? THEMES[0];
188
+ }
189
+ function themedHeader(title, subtitle, t) {
190
+ return ui.box({ border: "rounded", px: 1, py: 0, style: { fg: t.border } }, [
191
+ ui.row({ gap: 1, items: "center" }, [
192
+ ui.text(title, { variant: "heading" }),
193
+ ui.text(subtitle, { dim: true }),
194
+ ui.spacer({ flex: 1 })
195
+ ])
196
+ ]);
197
+ }
198
+ function xAxisLabels(timeline, count = 7) {
199
+ if (timeline.length === 0) return [];
200
+ const step = (timeline.length - 1) / (count - 1);
201
+ return Array.from({ length: count }, (_, i) => {
202
+ const raw = timeline[Math.round(i * step)]?.formattedTime ?? "";
203
+ if (raw.includes("-")) {
204
+ return monthLabel(raw.slice(0, 7));
205
+ }
206
+ const month = raw.slice(0, 3);
207
+ const year = raw.match(/\d{4}/)?.[0]?.slice(2) ?? "";
208
+ return year ? `${month} '${year}` : month;
209
+ });
210
+ }
211
+ function footer(state) {
212
+ const t = theme(state);
213
+ return ui.statusBar({
214
+ left: [
215
+ ui.text("[q]", { style: { fg: t.accent3, bold: true } }),
216
+ ui.text("quit", { style: { fg: t.dim } }),
217
+ ui.text(" [\u2190 \u2192]", { style: { fg: t.accent3, bold: true } }),
218
+ ui.text("cycle theme", { style: { fg: t.dim } })
219
+ ],
220
+ right: [
221
+ ui.text(`${t.name} (${state.themeIndex + 1}/${THEMES.length})`, { style: { fg: t.accent2 } })
222
+ ]
223
+ });
224
+ }
58
225
  function loadingView(state) {
226
+ const t = theme(state);
59
227
  return ui.page({
60
228
  body: ui.column({ gap: 1, items: "center", justify: "center" }, [
61
- ui.column(
62
- { gap: 0 },
63
- BANNER_LINES.map(
64
- (line, i) => ui.text(line, {
65
- style: {
66
- fg: i % 2 === 0 ? NEON_CYAN : NEON_PINK,
67
- bold: true
68
- },
69
- key: String(i)
70
- })
71
- )
72
- ),
229
+ ui.column({ gap: 0 }, BANNER_LINES.map(
230
+ (line, i) => ui.text(line, { style: { fg: i % 2 === 0 ? t.banner1 : t.banner2, bold: true }, key: String(i) })
231
+ )),
73
232
  ui.spacer({ size: 1 }),
74
233
  ui.row({ gap: 1, items: "center" }, [
75
234
  ui.spinner({ variant: "dots" }),
76
- ui.text(`Fetching trends for "${state.keyword}"\u2026`, {
77
- style: { fg: NEON_GREEN }
78
- })
235
+ ui.text(`Fetching ${state.mode === "npm" ? "npm downloads" : "trends"} for "${state.keyword}"\u2026`, { style: { fg: t.accent1 } })
79
236
  ]),
80
- ui.text("This may take a few seconds", { style: { fg: DIM_TEXT } })
81
- ])
237
+ ui.text("This may take a few seconds", { style: { fg: t.dim } })
238
+ ]),
239
+ footer: footer(state)
82
240
  });
83
241
  }
84
242
  function errorView(state) {
243
+ const t = theme(state);
85
244
  return ui.page({
86
- header: ui.header({ title: "TUI TRENDS", subtitle: "Failed to fetch data" }),
245
+ header: themedHeader("TUI TRENDS", `"${state.keyword}"`, t),
87
246
  body: ui.column({ gap: 2 }, [
88
247
  ui.callout(state.error ?? "Unknown error", { variant: "error" }),
89
- ui.text("Press [r] to retry or [q] to quit", { style: { fg: DIM_TEXT } })
90
- ])
91
- });
92
- }
93
- function xAxisLabels(timeline, count = 7) {
94
- if (timeline.length === 0) return [];
95
- const step = (timeline.length - 1) / (count - 1);
96
- return Array.from({ length: count }, (_, i) => {
97
- const point = timeline[Math.round(i * step)];
98
- const raw = point?.formattedTime ?? "";
99
- const month = raw.slice(0, 3);
100
- const year = raw.match(/\d{4}/)?.[0]?.slice(2) ?? "";
101
- return year ? `${month} '${year}` : month;
248
+ ui.text("Press [q] to quit", { style: { fg: t.dim } })
249
+ ]),
250
+ footer: footer(state)
102
251
  });
103
252
  }
104
253
  function dashboardView(state) {
254
+ const t = theme(state);
105
255
  const data = state.data;
106
- const timelineValues = data.timeline.map((t) => t.value);
107
- const xLabels = xAxisLabels(data.timeline);
256
+ const isNpm = state.mode === "npm";
257
+ const vals = data.timeline.map((p) => p.value);
258
+ const xlabels = xAxisLabels(data.timeline);
108
259
  return ui.page({
109
- header: ui.header({
110
- title: "TUI TRENDS",
111
- subtitle: `"${state.keyword}" \u2014 interest over the last 12 months`
112
- }),
260
+ header: themedHeader(
261
+ "TUI TRENDS",
262
+ isNpm ? `"${state.keyword}" \u2014 npm downloads (weekly index, 0\u2013100)` : `"${state.keyword}" \u2014 interest over the last 12 months`,
263
+ t
264
+ ),
113
265
  body: ui.column({ gap: 1 }, [
114
- // ── Interest over time ───────────────────────────────────────
115
- ui.panel(
116
- { title: "\u25B8 Interest Over Time", variant: "rounded", p: 1, gap: 1 },
117
- [
118
- ui.lineChart({
119
- width: 90,
120
- height: 12,
121
- series: [{ label: state.keyword, color: CHART_COLOR, data: timelineValues }],
122
- axes: {
123
- y: { min: 0, max: 100 }
124
- },
125
- showLegend: false,
126
- blitter: "braille"
127
- }),
128
- // Manual X-axis date labels
129
- ui.row({ gap: 0 }, xLabels.flatMap((label, i) => [
130
- ui.text(label, { style: { fg: DIM_TEXT }, key: `xl${i}` }),
131
- ...i < xLabels.length - 1 ? [ui.spacer({ flex: 1, key: `xs${i}` })] : []
132
- ])),
133
- ui.row({ gap: 2 }, [
134
- ui.text(`Peak: ${Math.max(...timelineValues)}`, { style: { fg: NEON_GREEN, bold: true } }),
135
- ui.text(`Avg: ${Math.round(timelineValues.reduce((a, b) => a + b, 0) / timelineValues.length)}`, { style: { fg: NEON_CYAN } }),
136
- ui.text(`Current: ${timelineValues.at(-1) ?? 0}`, { style: { fg: NEON_PINK } })
137
- ])
138
- ]
139
- ),
140
- // ── Bottom row: regions + queries ────────────────────────────
266
+ // ── Line chart ───────────────────────────────────────────────
267
+ ui.panel({ title: isNpm ? "\u25B8 Weekly Downloads" : "\u25B8 Interest Over Time", variant: "rounded", p: 1, gap: 1, style: { fg: t.border } }, [
268
+ ui.lineChart({
269
+ width: 90,
270
+ height: 12,
271
+ series: [{ label: state.keyword, color: t.chart, data: vals }],
272
+ axes: { y: { min: 0, max: 100 } },
273
+ showLegend: false,
274
+ blitter: "braille"
275
+ }),
276
+ ui.row({ gap: 0 }, xlabels.flatMap((label, i) => [
277
+ ui.text(label, { style: { fg: t.dim }, key: `xl${i}` }),
278
+ ...i < xlabels.length - 1 ? [ui.spacer({ flex: 1, key: `xs${i}` })] : []
279
+ ])),
280
+ ui.row({ gap: 2 }, [
281
+ ui.text(`Peak: ${Math.max(...vals)}`, { style: { fg: t.accent1, bold: true } }),
282
+ ui.text(`Avg: ${Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)}`, { style: { fg: t.accent2 } }),
283
+ ui.text(`Current: ${vals.at(-1) ?? 0}`, { style: { fg: t.accent3 } })
284
+ ])
285
+ ]),
286
+ // ── Bottom row ───────────────────────────────────────────────
141
287
  ui.row({ gap: 1 }, [
142
- ui.box({ flex: 1 }, [
143
- ui.panel(
144
- { title: "\u25B8 Top Regions", variant: "rounded", p: 1 },
145
- [
146
- ui.barChart(
147
- data.regions.map((r) => ({
148
- label: r.geoName.length > 16 ? r.geoName.slice(0, 15) + "\u2026" : r.geoName,
149
- value: r.value,
150
- variant: "info"
151
- })),
152
- { orientation: "horizontal", showValues: true }
153
- )
154
- ]
155
- )
288
+ ui.box({ flex: 1, border: "none" }, [
289
+ ui.panel({ title: isNpm ? "\u25B8 Peak Weeks" : "\u25B8 Top Regions", variant: "rounded", p: 1, style: { fg: t.border } }, [
290
+ ui.barChart(
291
+ data.regions.map((r) => ({
292
+ label: r.geoName.length > 16 ? r.geoName.slice(0, 15) + "\u2026" : r.geoName,
293
+ value: r.value,
294
+ variant: t.barVariant
295
+ })),
296
+ { orientation: "horizontal", showValues: true }
297
+ )
298
+ ])
156
299
  ]),
157
- ui.box({ flex: 1 }, [
158
- ui.panel(
159
- { title: "\u25B8 Related Queries", variant: "rounded", p: 1 },
160
- [
161
- ui.table({
162
- id: "related-queries",
163
- columns: [
164
- { key: "query", header: "Query", flex: 1, overflow: "ellipsis" },
165
- { key: "value", header: "Score", width: 7, align: "right" }
166
- ],
167
- data: data.queries,
168
- getRowKey: (r) => r.query,
169
- showHeader: true,
170
- borderStyle: { variant: "single", color: rgb(68, 71, 90) }
171
- })
172
- ]
173
- )
300
+ ui.box({ flex: 1, border: "none" }, [
301
+ ui.panel({ title: isNpm ? "\u25B8 Monthly Breakdown" : "\u25B8 Related Queries", variant: "rounded", p: 1, style: { fg: t.border } }, [
302
+ ui.table({
303
+ id: "queries-table",
304
+ columns: [
305
+ { key: "query", header: isNpm ? "Month" : "Query", flex: 1, overflow: "ellipsis" },
306
+ { key: "value", header: isNpm ? "Index" : "Score", width: 7, align: "right" }
307
+ ],
308
+ data: data.queries,
309
+ getRowKey: (r) => r.query,
310
+ showHeader: true,
311
+ borderStyle: { variant: "single", color: t.dim }
312
+ })
313
+ ])
174
314
  ])
175
315
  ])
176
316
  ]),
177
- footer: ui.statusBar({
178
- left: [
179
- ui.text("[q]", { style: { fg: NEON_PINK, bold: true } }),
180
- ui.text("quit", { style: { fg: DIM_TEXT } }),
181
- ui.text(" [r]", { style: { fg: NEON_PINK, bold: true } }),
182
- ui.text("refresh", { style: { fg: DIM_TEXT } }),
183
- ui.text(" [\u2191\u2193] scroll queries", { style: { fg: DIM_TEXT } })
184
- ],
185
- right: [
186
- ui.text("powered by Google Trends", { style: { fg: DIM_TEXT } })
187
- ]
188
- })
317
+ footer: footer(state)
189
318
  });
190
319
  }
191
320
  app.view((state) => {
@@ -193,13 +322,23 @@ app.view((state) => {
193
322
  if (state.status === "error") return errorView(state);
194
323
  return dashboardView(state);
195
324
  });
325
+ function cycleTheme(currentIndex, dir) {
326
+ const next = (currentIndex + dir + THEMES.length) % THEMES.length;
327
+ app.update((s) => ({ ...s, themeIndex: next }));
328
+ }
196
329
  app.keys({
197
- q: () => {
330
+ q: (ctx) => {
198
331
  void app.stop();
199
332
  },
200
- r: () => {
201
- void refetch();
333
+ left: (ctx) => {
334
+ cycleTheme(ctx.state.themeIndex, -1);
335
+ },
336
+ right: (ctx) => {
337
+ cycleTheme(ctx.state.themeIndex, 1);
338
+ },
339
+ t: (ctx) => {
340
+ cycleTheme(ctx.state.themeIndex, 1);
202
341
  }
203
342
  });
204
- void refetch();
343
+ void fetchOnce(keyword);
205
344
  await app.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tui-trends",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Cyberpunk Google Trends visualizer in your terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,8 +22,12 @@
22
22
  "@rezi-ui/core": "0.1.0-alpha.45",
23
23
  "@rezi-ui/native": "0.1.0-alpha.45",
24
24
  "@rezi-ui/node": "0.1.0-alpha.45",
25
+ "g": "^2.0.1",
25
26
  "google-trends-api": "^4.9.2"
26
27
  },
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
27
31
  "devDependencies": {
28
32
  "@types/node": "^22.0.0",
29
33
  "esbuild": "^0.27.3",