fieldtheory 1.0.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.
@@ -0,0 +1,531 @@
1
+ import { openDb } from './db.js';
2
+ import { twitterBookmarksIndexPath } from './paths.js';
3
+ // ── ANSI helpers ─────────────────────────────────────────────────────────────
4
+ const ESC = '\x1b[';
5
+ const RESET = `${ESC}0m`;
6
+ const BOLD = `${ESC}1m`;
7
+ const DIM = `${ESC}2m`;
8
+ const rgb = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`;
9
+ // Palette — muted, tasteful
10
+ const C = {
11
+ title: rgb(199, 146, 234), // soft lavender
12
+ accent: rgb(130, 170, 255), // periwinkle
13
+ warm: rgb(255, 180, 120), // peach
14
+ green: rgb(120, 220, 170), // mint
15
+ dim: rgb(100, 100, 120), // muted gray
16
+ text: rgb(200, 200, 210), // light gray
17
+ hot: rgb(255, 120, 140), // coral
18
+ gold: rgb(240, 200, 100), // amber
19
+ cyan: rgb(100, 220, 230), // teal
20
+ violet: rgb(170, 130, 255), // violet
21
+ };
22
+ // ── Block characters for bar charts ──────────────────────────────────────────
23
+ const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
24
+ function bar(value, max, width, color) {
25
+ const ratio = max > 0 ? value / max : 0;
26
+ const filled = ratio * width;
27
+ const full = Math.floor(filled);
28
+ const partial = Math.round((filled - full) * 8);
29
+ return (color +
30
+ '█'.repeat(full) +
31
+ (partial > 0 ? BLOCKS[partial] : '') +
32
+ RESET +
33
+ ' '.repeat(Math.max(0, width - full - (partial > 0 ? 1 : 0))));
34
+ }
35
+ // ── Sparkline ────────────────────────────────────────────────────────────────
36
+ const SPARKS = '▁▂▃▄▅▆▇█';
37
+ function sparkline(data, color) {
38
+ const max = Math.max(...data, 1);
39
+ return (color +
40
+ data.map((v) => SPARKS[Math.round((v / max) * 7)] || SPARKS[0]).join('') +
41
+ RESET);
42
+ }
43
+ // ── Braille dot chart (2-wide × 4-tall per character) ────────────────────────
44
+ const BRAILLE_BASE = 0x2800;
45
+ const BRAILLE_DOTS = [
46
+ [0x01, 0x08],
47
+ [0x02, 0x10],
48
+ [0x04, 0x20],
49
+ [0x40, 0x80],
50
+ ];
51
+ function brailleChart(data, width, color) {
52
+ const max = Math.max(...data, 1);
53
+ const heights = data.map((v) => Math.round((v / max) * 7));
54
+ const chars = new Array(Math.ceil(data.length / 2)).fill(BRAILLE_BASE);
55
+ for (let i = 0; i < heights.length; i++) {
56
+ const col = i % 2; // 0 = left, 1 = right
57
+ const charIdx = Math.floor(i / 2);
58
+ const h = heights[i];
59
+ for (let row = 0; row < 4; row++) {
60
+ if (7 - row * 2 <= h) {
61
+ chars[charIdx] |= BRAILLE_DOTS[row][col];
62
+ }
63
+ }
64
+ }
65
+ return color + chars.map((c) => String.fromCharCode(c)).join('') + RESET;
66
+ }
67
+ // ── Box drawing ──────────────────────────────────────────────────────────────
68
+ function boxTop(width) {
69
+ return C.dim + '╭' + '─'.repeat(width - 2) + '╮' + RESET;
70
+ }
71
+ function boxBottom(width) {
72
+ return C.dim + '╰' + '─'.repeat(width - 2) + '╯' + RESET;
73
+ }
74
+ function boxRow(content, width) {
75
+ const stripped = content.replace(/\x1b\[[^m]*m/g, '');
76
+ const pad = Math.max(0, width - 4 - stripped.length);
77
+ return C.dim + '│ ' + RESET + content + ' '.repeat(pad) + C.dim + ' │' + RESET;
78
+ }
79
+ function boxDivider(width) {
80
+ return C.dim + '├' + '─'.repeat(width - 2) + '┤' + RESET;
81
+ }
82
+ // ── Gradient helpers ─────────────────────────────────────────────────────────
83
+ function lerpColor(from, to, t) {
84
+ const r = Math.round(from[0] + (to[0] - from[0]) * t);
85
+ const g = Math.round(from[1] + (to[1] - from[1]) * t);
86
+ const b = Math.round(from[2] + (to[2] - from[2]) * t);
87
+ return rgb(r, g, b);
88
+ }
89
+ async function queryVizData() {
90
+ const db = await openDb(twitterBookmarksIndexPath());
91
+ try {
92
+ const total = db.exec('SELECT COUNT(*) FROM bookmarks')[0]?.values[0]?.[0];
93
+ const authors = db.exec('SELECT COUNT(DISTINCT author_handle) FROM bookmarks')[0]?.values[0]?.[0];
94
+ const range = db.exec('SELECT MIN(posted_at), MAX(posted_at) FROM bookmarks WHERE posted_at IS NOT NULL')[0]?.values[0];
95
+ const topAuthorsRows = db.exec(`SELECT author_handle, COUNT(*) as c FROM bookmarks
96
+ WHERE author_handle IS NOT NULL
97
+ GROUP BY author_handle ORDER BY c DESC LIMIT 20`);
98
+ // Twitter date format: "Sat Mar 28 18:55:23 +0000 2026"
99
+ // Year is at end (-4), month name at 5-7, hour at 12-13
100
+ // Build a synthetic YYYY-MonName from the twitter date parts
101
+ const monthlyRows = db.exec(`SELECT
102
+ substr(bookmarked_at, -4) || '-' || substr(bookmarked_at, 5, 3) as ym,
103
+ COUNT(*) as c
104
+ FROM bookmarks WHERE bookmarked_at IS NOT NULL
105
+ GROUP BY ym ORDER BY ym`);
106
+ // Day of week — first 3 chars
107
+ const dowRows = db.exec(`SELECT substr(bookmarked_at, 1, 3) as dow, COUNT(*) as c
108
+ FROM bookmarks WHERE bookmarked_at IS NOT NULL
109
+ GROUP BY dow ORDER BY c DESC`);
110
+ // Hour of day — chars 12-13
111
+ const hourRows = db.exec(`SELECT CAST(substr(bookmarked_at, 12, 2) AS INTEGER) as h, COUNT(*) as c
112
+ FROM bookmarks WHERE bookmarked_at IS NOT NULL AND length(bookmarked_at) > 13
113
+ GROUP BY h ORDER BY h`);
114
+ // Domains from links_json
115
+ const domainRows = db.exec(`SELECT links_json FROM bookmarks WHERE links_json IS NOT NULL AND links_json != '[]'`);
116
+ const domainCounts = new Map();
117
+ for (const row of domainRows[0]?.values ?? []) {
118
+ try {
119
+ const links = JSON.parse(row[0]);
120
+ for (const link of links) {
121
+ const url = typeof link === 'string' ? link : link.expanded_url ?? link.url ?? '';
122
+ try {
123
+ const domain = new URL(url).hostname.replace(/^www\./, '');
124
+ if (domain && domain !== 'x.com' && domain !== 't.co') {
125
+ domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
126
+ }
127
+ }
128
+ catch { }
129
+ }
130
+ }
131
+ catch { }
132
+ }
133
+ const topDomains = [...domainCounts.entries()]
134
+ .sort((a, b) => b[1] - a[1])
135
+ .slice(0, 12)
136
+ .map(([domain, count]) => ({ domain, count }));
137
+ const mediaStats = {
138
+ withMedia: db.exec('SELECT COUNT(*) FROM bookmarks WHERE media_count > 0')[0]?.values[0]?.[0],
139
+ withLinks: db.exec('SELECT COUNT(*) FROM bookmarks WHERE link_count > 0')[0]?.values[0]?.[0],
140
+ total,
141
+ };
142
+ const langRows = db.exec(`SELECT language, COUNT(*) as c FROM bookmarks WHERE language IS NOT NULL
143
+ GROUP BY language ORDER BY c DESC LIMIT 8`);
144
+ const avgLen = db.exec('SELECT AVG(length(text)) FROM bookmarks')[0]?.values[0]?.[0];
145
+ // Recent 30 days top authors
146
+ const recentAuthorsRows = db.exec(`SELECT author_handle, COUNT(*) as c FROM bookmarks
147
+ WHERE author_handle IS NOT NULL
148
+ AND bookmarked_at >= (SELECT MAX(bookmarked_at) FROM bookmarks)
149
+ GROUP BY author_handle ORDER BY c DESC LIMIT 10`);
150
+ // Time capsules: oldest posts, one per year to spread the range
151
+ const capsuleRows = db.exec(`SELECT author_handle, text, tweet_id, posted_at, substr(posted_at, -4) as yr
152
+ FROM bookmarks
153
+ WHERE posted_at IS NOT NULL
154
+ AND CAST(substr(posted_at, -4) AS INTEGER) < 2023
155
+ GROUP BY substr(posted_at, -4)
156
+ ORDER BY posted_at ASC
157
+ LIMIT 8`);
158
+ const timeCapsules = (capsuleRows[0]?.values ?? []).map((r) => ({
159
+ author: r[0],
160
+ text: r[1],
161
+ tweetId: r[2],
162
+ postedAt: r[3],
163
+ }));
164
+ // Hidden gems: authors bookmarked exactly once, with long text (> 250 chars)
165
+ const gemRows = db.exec(`SELECT b.author_handle, b.text, b.tweet_id, b.posted_at
166
+ FROM bookmarks b
167
+ JOIN (
168
+ SELECT author_handle FROM bookmarks
169
+ WHERE author_handle IS NOT NULL
170
+ GROUP BY author_handle HAVING COUNT(*) = 1
171
+ ) singles ON b.author_handle = singles.author_handle
172
+ WHERE length(b.text) > 250
173
+ ORDER BY length(b.text) DESC
174
+ LIMIT 8`);
175
+ const hiddenGems = (gemRows[0]?.values ?? []).map((r) => ({
176
+ author: r[0],
177
+ text: r[1],
178
+ tweetId: r[2],
179
+ postedAt: r[3],
180
+ }));
181
+ // Rising voices: authors with 3+ bookmarks, all from the most recent month
182
+ const latestMonth = db.exec(`SELECT substr(bookmarked_at, -4) || '-' || substr(bookmarked_at, 5, 3)
183
+ FROM bookmarks WHERE bookmarked_at IS NOT NULL
184
+ ORDER BY bookmarked_at DESC LIMIT 1`)[0]?.values[0]?.[0];
185
+ let risingVoices = [];
186
+ if (latestMonth) {
187
+ const risingRows = db.exec(`SELECT author_handle, COUNT(*) as c FROM bookmarks
188
+ WHERE author_handle IS NOT NULL
189
+ GROUP BY author_handle
190
+ HAVING c >= 3
191
+ AND MIN(substr(bookmarked_at, -4) || '-' || substr(bookmarked_at, 5, 3)) = ?
192
+ ORDER BY c DESC LIMIT 8`, [latestMonth]);
193
+ risingVoices = (risingRows[0]?.values ?? []).map((r) => ({
194
+ handle: r[0],
195
+ count: r[1],
196
+ }));
197
+ }
198
+ // Convert "2026-Mar" to "2026-03" for proper sorting
199
+ const monthNumMap = {
200
+ Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
201
+ Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12',
202
+ };
203
+ const rawMonthly = (monthlyRows[0]?.values ?? []).map((r) => {
204
+ const raw = r[0]; // "2026-Mar"
205
+ const [year, monName] = raw.split('-');
206
+ const num = monthNumMap[monName] ?? '00';
207
+ return { month: `${year}-${num}`, label: `${monName} ${year}`, count: r[1] };
208
+ });
209
+ rawMonthly.sort((a, b) => a.month.localeCompare(b.month));
210
+ return {
211
+ total,
212
+ uniqueAuthors: authors,
213
+ dateRange: {
214
+ earliest: range?.[0] ?? '?',
215
+ latest: range?.[1] ?? '?',
216
+ },
217
+ topAuthors: (topAuthorsRows[0]?.values ?? []).map((r) => ({
218
+ handle: r[0],
219
+ count: r[1],
220
+ })),
221
+ monthlyActivity: rawMonthly.map((r) => ({
222
+ month: r.label,
223
+ count: r.count,
224
+ })),
225
+ dayOfWeekActivity: (dowRows[0]?.values ?? []).map((r) => ({
226
+ day: r[0],
227
+ count: r[1],
228
+ })),
229
+ hourActivity: (hourRows[0]?.values ?? []).map((r) => ({
230
+ hour: r[0],
231
+ count: r[1],
232
+ })),
233
+ topDomains,
234
+ mediaStats,
235
+ recentAuthors: (recentAuthorsRows[0]?.values ?? []).map((r) => ({
236
+ handle: r[0],
237
+ count: r[1],
238
+ })),
239
+ languages: (langRows[0]?.values ?? []).map((r) => ({
240
+ lang: r[0],
241
+ count: r[1],
242
+ })),
243
+ avgTextLength: avgLen,
244
+ timeCapsules,
245
+ hiddenGems,
246
+ risingVoices,
247
+ };
248
+ }
249
+ finally {
250
+ db.close();
251
+ }
252
+ }
253
+ // ── Render sections ──────────────────────────────────────────────────────────
254
+ const W = 72; // box width
255
+ function renderHeader(data) {
256
+ const lines = [];
257
+ lines.push('');
258
+ lines.push(boxTop(W));
259
+ lines.push(boxRow(`${C.title}${BOLD} ✦ FIELD THEORY · BOOKMARK OBSERVATORY ✦ ${RESET}`, W));
260
+ lines.push(boxDivider(W));
261
+ lines.push(boxRow(`${C.text}${data.total.toLocaleString()} bookmarks${C.dim} · ${C.text}${data.uniqueAuthors.toLocaleString()} voices${C.dim} · ${C.text}${data.languages.length} languages`, W));
262
+ lines.push(boxRow(`${C.dim}${data.dateRange.earliest.slice(0, 16)} → ${data.dateRange.latest.slice(0, 16)}`, W));
263
+ lines.push(boxBottom(W));
264
+ return lines;
265
+ }
266
+ function renderTopAuthors(data) {
267
+ const lines = [];
268
+ const maxCount = data.topAuthors[0]?.count ?? 1;
269
+ const barWidth = 28;
270
+ lines.push('');
271
+ lines.push(` ${C.accent}${BOLD}WHO YOU LISTEN TO${RESET}`);
272
+ lines.push(` ${C.dim}top 20 most-bookmarked voices${RESET}`);
273
+ lines.push('');
274
+ for (const author of data.topAuthors) {
275
+ const t = author.count / maxCount;
276
+ const color = lerpColor([100, 160, 255], [255, 120, 180], t);
277
+ const handle = `@${author.handle}`.padEnd(20);
278
+ const count = String(author.count).padStart(4);
279
+ lines.push(` ${C.text}${handle}${RESET} ${bar(author.count, maxCount, barWidth, color)} ${C.dim}${count}${RESET}`);
280
+ }
281
+ return lines;
282
+ }
283
+ function renderActivity(data) {
284
+ const lines = [];
285
+ const counts = data.monthlyActivity.map((m) => m.count);
286
+ const maxCount = Math.max(...counts, 1);
287
+ lines.push('');
288
+ lines.push(` ${C.warm}${BOLD}RHYTHM${RESET}`);
289
+ lines.push(` ${C.dim}monthly bookmarking cadence${RESET}`);
290
+ lines.push('');
291
+ // Sparkline overview
292
+ lines.push(` ${sparkline(counts, C.warm)}`);
293
+ lines.push('');
294
+ // Monthly bars
295
+ for (const m of data.monthlyActivity) {
296
+ const label = m.month.padEnd(8);
297
+ const t = m.count / maxCount;
298
+ const color = lerpColor([255, 160, 100], [255, 100, 120], t);
299
+ const count = String(m.count).padStart(5);
300
+ lines.push(` ${C.dim}${label}${RESET} ${bar(m.count, maxCount, 36, color)} ${C.dim}${count}${RESET}`);
301
+ }
302
+ return lines;
303
+ }
304
+ function renderDayOfWeek(data) {
305
+ const lines = [];
306
+ const dayOrder = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
307
+ const ordered = dayOrder
308
+ .map((d) => data.dayOfWeekActivity.find((r) => r.day === d))
309
+ .filter(Boolean);
310
+ if (ordered.length === 0)
311
+ return [];
312
+ const maxCount = Math.max(...ordered.map((d) => d.count), 1);
313
+ const counts = ordered.map((d) => d.count);
314
+ lines.push('');
315
+ lines.push(` ${C.green}${BOLD}WEEKLY PULSE${RESET}`);
316
+ lines.push(` ${C.dim}which days you bookmark${RESET}`);
317
+ lines.push('');
318
+ lines.push(` ${brailleChart(counts, 14, C.green)}`);
319
+ lines.push('');
320
+ for (const d of ordered) {
321
+ const label = d.day.padEnd(5);
322
+ const t = d.count / maxCount;
323
+ const color = lerpColor([80, 200, 160], [120, 255, 200], t);
324
+ const count = String(d.count).padStart(5);
325
+ lines.push(` ${C.text}${label}${RESET} ${bar(d.count, maxCount, 36, color)} ${C.dim}${count}${RESET}`);
326
+ }
327
+ return lines;
328
+ }
329
+ function renderHourOfDay(data) {
330
+ const lines = [];
331
+ if (data.hourActivity.length === 0)
332
+ return [];
333
+ // Fill in all 24 hours
334
+ const hourMap = new Map(data.hourActivity.map((h) => [h.hour, h.count]));
335
+ const allHours = Array.from({ length: 24 }, (_, i) => hourMap.get(i) ?? 0);
336
+ const maxCount = Math.max(...allHours, 1);
337
+ lines.push('');
338
+ lines.push(` ${C.cyan}${BOLD}DAILY ARC${RESET}`);
339
+ lines.push(` ${C.dim}when you reach for the bookmark button${RESET}`);
340
+ lines.push('');
341
+ // Vertical bar chart using blocks
342
+ const chartHeight = 8;
343
+ for (let row = chartHeight; row >= 1; row--) {
344
+ let line = ' ';
345
+ for (let h = 0; h < 24; h++) {
346
+ const t = allHours[h] / maxCount;
347
+ const barH = t * chartHeight;
348
+ if (barH >= row) {
349
+ const color = lerpColor([60, 180, 200], [100, 240, 255], t);
350
+ line += color + '██' + RESET;
351
+ }
352
+ else if (barH >= row - 1 && barH > 0) {
353
+ const frac = barH - Math.floor(barH);
354
+ const partials = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
355
+ const idx = Math.round(frac * 7);
356
+ const color = lerpColor([60, 180, 200], [100, 240, 255], t);
357
+ line += color + partials[idx] + partials[idx] + RESET;
358
+ }
359
+ else {
360
+ line += ' ';
361
+ }
362
+ }
363
+ lines.push(line);
364
+ }
365
+ // Hour labels
366
+ let labelLine = ' ';
367
+ for (let h = 0; h < 24; h++) {
368
+ if (h % 3 === 0) {
369
+ labelLine += C.dim + String(h).padStart(2, '0') + RESET;
370
+ if (h + 1 < 24)
371
+ labelLine += C.dim + RESET;
372
+ }
373
+ else {
374
+ labelLine += ' ';
375
+ }
376
+ }
377
+ lines.push(labelLine);
378
+ // Peak hours annotation
379
+ const peak = data.hourActivity.reduce((a, b) => (a.count > b.count ? a : b));
380
+ const quiet = data.hourActivity.reduce((a, b) => (a.count < b.count ? a : b));
381
+ lines.push('');
382
+ lines.push(` ${C.cyan}peak${RESET} ${C.text}${peak.hour}:00${RESET}${C.dim} · ${C.cyan}quiet${RESET} ${C.text}${quiet.hour}:00${RESET}`);
383
+ return lines;
384
+ }
385
+ function renderDomains(data) {
386
+ const lines = [];
387
+ if (data.topDomains.length === 0)
388
+ return [];
389
+ const maxCount = data.topDomains[0]?.count ?? 1;
390
+ lines.push('');
391
+ lines.push(` ${C.violet}${BOLD}WHERE LINKS LEAD${RESET}`);
392
+ lines.push(` ${C.dim}most-bookmarked external domains${RESET}`);
393
+ lines.push('');
394
+ for (const d of data.topDomains) {
395
+ const label = d.domain.padEnd(24);
396
+ const t = d.count / maxCount;
397
+ const color = lerpColor([140, 100, 230], [200, 150, 255], t);
398
+ const count = String(d.count).padStart(4);
399
+ lines.push(` ${C.text}${label}${RESET} ${bar(d.count, maxCount, 22, color)} ${C.dim}${count}${RESET}`);
400
+ }
401
+ return lines;
402
+ }
403
+ function renderMediaBreakdown(data) {
404
+ const lines = [];
405
+ const { withMedia, withLinks, total } = data.mediaStats;
406
+ lines.push('');
407
+ lines.push(` ${C.gold}${BOLD}COMPOSITION${RESET}`);
408
+ lines.push(` ${C.dim}what your bookmarks contain${RESET}`);
409
+ lines.push('');
410
+ const barWidth = 50;
411
+ const mediaPct = (withMedia / total) * 100;
412
+ const linkPct = (withLinks / total) * 100;
413
+ const textPct = Math.max(0, 100 - mediaPct - linkPct);
414
+ const mediaW = Math.round((withMedia / total) * barWidth);
415
+ const linkW = Math.round((withLinks / total) * barWidth);
416
+ const textW = barWidth - mediaW - linkW;
417
+ // Stacked bar
418
+ const stackedBar = rgb(120, 220, 170) + '█'.repeat(mediaW) +
419
+ rgb(130, 170, 255) + '█'.repeat(linkW) +
420
+ rgb(100, 100, 120) + '█'.repeat(Math.max(0, textW)) +
421
+ RESET;
422
+ lines.push(` ${stackedBar}`);
423
+ lines.push('');
424
+ lines.push(` ${rgb(120, 220, 170)}██${RESET} ${C.text}media ${withMedia.toLocaleString()} (${mediaPct.toFixed(0)}%)${RESET} ${rgb(130, 170, 255)}██${RESET} ${C.text}links ${withLinks.toLocaleString()} (${linkPct.toFixed(0)}%)${RESET} ${rgb(100, 100, 120)}██${RESET} ${C.text}text ${textPct.toFixed(0)}%${RESET}`);
425
+ return lines;
426
+ }
427
+ function renderFingerprint(data) {
428
+ const lines = [];
429
+ lines.push('');
430
+ lines.push(boxTop(W));
431
+ lines.push(boxRow(`${C.title}${BOLD}FINGERPRINT${RESET}`, W));
432
+ lines.push(boxDivider(W));
433
+ const mediaPct = ((data.mediaStats.withMedia / data.total) * 100).toFixed(0);
434
+ const longTailPct = data.uniqueAuthors > 0
435
+ ? (((data.uniqueAuthors - data.topAuthors.length) / data.uniqueAuthors) * 100).toFixed(0)
436
+ : '0';
437
+ lines.push(boxRow(`${C.dim}avg bookmark length${RESET} ${C.text}${Math.round(data.avgTextLength)} chars${RESET}`, W));
438
+ lines.push(boxRow(`${C.dim}media-bearing${RESET} ${C.text}${mediaPct}%${RESET}`, W));
439
+ lines.push(boxRow(`${C.dim}long-tail authors${RESET} ${C.text}${longTailPct}% bookmarked ≤ once${RESET}`, W));
440
+ lines.push(boxRow(`${C.dim}top voice${RESET} ${C.text}@${data.topAuthors[0]?.handle ?? '?'} (${data.topAuthors[0]?.count ?? 0})${RESET}`, W));
441
+ if (data.recentAuthors.length > 0) {
442
+ lines.push(boxDivider(W));
443
+ lines.push(boxRow(`${C.accent}${BOLD}LATEST SESSION${RESET}`, W));
444
+ for (const a of data.recentAuthors.slice(0, 5)) {
445
+ lines.push(boxRow(` ${C.text}@${a.handle}${RESET} ${C.dim}×${a.count}${RESET}`, W));
446
+ }
447
+ }
448
+ lines.push(boxBottom(W));
449
+ return lines;
450
+ }
451
+ function truncateText(text, max) {
452
+ const clean = text.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
453
+ if (clean.length <= max)
454
+ return clean;
455
+ return clean.slice(0, max - 1) + '…';
456
+ }
457
+ function twitterDateYear(date) {
458
+ return date.slice(-4);
459
+ }
460
+ function renderTimeCapsules(data) {
461
+ const lines = [];
462
+ if (data.timeCapsules.length === 0)
463
+ return [];
464
+ lines.push('');
465
+ lines.push(` ${C.gold}${BOLD}TIME CAPSULES${RESET}`);
466
+ lines.push(` ${C.dim}your oldest bookmarked posts — still saved after all these years${RESET}`);
467
+ lines.push('');
468
+ for (const b of data.timeCapsules) {
469
+ const year = twitterDateYear(b.postedAt);
470
+ const monthDay = b.postedAt.slice(4, 10); // " Mar 28"
471
+ const color = lerpColor([240, 200, 100], [200, 160, 80], 0.5);
472
+ const url = `x.com/${b.author}/status/${b.tweetId}`;
473
+ lines.push(` ${color}${year}${RESET}${C.dim}${monthDay}${RESET} ${C.text}@${b.author}${RESET}`);
474
+ lines.push(` ${C.dim}${truncateText(b.text, 62)}${RESET}`);
475
+ lines.push(` ${DIM}${url}${RESET}`);
476
+ lines.push('');
477
+ }
478
+ return lines;
479
+ }
480
+ function renderHiddenGems(data) {
481
+ const lines = [];
482
+ if (data.hiddenGems.length === 0)
483
+ return [];
484
+ lines.push('');
485
+ lines.push(` ${C.hot}${BOLD}HIDDEN GEMS${RESET}`);
486
+ lines.push(` ${C.dim}one-time voices you saved — long, substantive, easy to forget${RESET}`);
487
+ lines.push('');
488
+ for (const b of data.hiddenGems) {
489
+ const url = `x.com/${b.author}/status/${b.tweetId}`;
490
+ lines.push(` ${C.hot}◆${RESET} ${C.text}@${b.author}${RESET}`);
491
+ lines.push(` ${C.dim}${truncateText(b.text, 62)}${RESET}`);
492
+ lines.push(` ${DIM}${url}${RESET}`);
493
+ lines.push('');
494
+ }
495
+ return lines;
496
+ }
497
+ function renderRisingVoices(data) {
498
+ const lines = [];
499
+ if (data.risingVoices.length === 0)
500
+ return [];
501
+ const maxCount = data.risingVoices[0]?.count ?? 1;
502
+ lines.push('');
503
+ lines.push(` ${C.green}${BOLD}RISING${RESET}`);
504
+ lines.push(` ${C.dim}new voices — all bookmarks from your most recent month${RESET}`);
505
+ lines.push('');
506
+ for (const v of data.risingVoices) {
507
+ const handle = `@${v.handle}`.padEnd(22);
508
+ const dots = C.green + '●'.repeat(v.count) + C.dim + '○'.repeat(Math.max(0, maxCount - v.count)) + RESET;
509
+ lines.push(` ${C.text}${handle}${RESET} ${dots} ${C.dim}${v.count}${RESET}`);
510
+ }
511
+ return lines;
512
+ }
513
+ // ── Main render ──────────────────────────────────────────────────────────────
514
+ export async function renderViz() {
515
+ const data = await queryVizData();
516
+ const sections = [
517
+ ...renderHeader(data),
518
+ ...renderTopAuthors(data),
519
+ ...renderActivity(data),
520
+ ...renderDayOfWeek(data),
521
+ ...renderHourOfDay(data),
522
+ ...renderDomains(data),
523
+ ...renderMediaBreakdown(data),
524
+ ...renderTimeCapsules(data),
525
+ ...renderHiddenGems(data),
526
+ ...renderRisingVoices(data),
527
+ ...renderFingerprint(data),
528
+ '', // trailing newline
529
+ ];
530
+ return sections.join('\n');
531
+ }