PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/components/AnalyticsDashboard.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:26:35 UTC 16,135 bytes, 399 lines download raw bytes

1/**
2 * Analytics Dashboard Component
3 *
4 * Full-page view with interactive data visualizations
5 * showing archive statistics, trends, and insights.
6 */
7
8import { useEffect, useState } from 'react';
9import { html } from '../html.js?v=3.8.36';
10import {
11  BarChart3,
12  TrendingUp,
13  Users,
14  Tag,
15  Calendar,
16  Database,
17  Loader2,
18  ArrowLeft,
19  RefreshCw
20} from 'lucide-react';
21import {
22  initSqlite,
23  isSqliteReady,
24  fetchAnalytics,
25  queryAsObjects
26} from '../services/archiveService.js?v=3.8.36';
27import QueryBuilder from './QueryBuilder.js?v=3.8.36';
28
29// Simple bar chart component
30const BarChart = ({ data, labelKey, valueKey, maxBars = 10, color = '#1c1917' }) => {
31  if (!data || data.length === 0) return html`<p className="text-stone-600 text-sm">No data available</p>`;
32
33  const displayData = data.slice(0, maxBars);
34  const maxValue = Math.max(...displayData.map(d => d[valueKey]));
35
36  // Label sits above each bar so every bar shares the same left baseline and
37  // full width, regardless of how long the label is. A fixed-width label
38  // column would either misalign the bars or truncate long names (e.g. the
39  // era labels), which made the charts hard to compare.
40  return html`
41    <div className="archive-data-bars">
42      ${displayData.map((item, i) => html`
43        <div key=${i} className="archive-data-bar">
44          <div className="archive-data-bar__labels">
45            <span>
46              ${item[labelKey]}
47            </span>
48            <strong>
49              ${item[valueKey].toLocaleString()}
50            </strong>
51          </div>
52          <div className="archive-data-bar__track">
53            <div
54              className="archive-data-bar__fill"
55              style=${{
56                width: `${maxValue ? (item[valueKey] / maxValue) * 100 : 0}%`,
57                backgroundColor: color
58              }}
59            />
60          </div>
61        </div>
62      `)}
63    </div>
64  `;
65};
66
67// Stat card component
68const StatCard = ({ icon: Icon, label, value, sublabel, accent }) => html`
69  <div className="archive-data-stat" style=${{ '--archive-stat-accent': accent }}>
70    <div className="archive-data-stat__label">
71      <div className="archive-data-stat__icon">
72        <${Icon} aria-hidden="true" />
73      </div>
74      <span>${label}</span>
75    </div>
76    <div className="archive-data-stat__value">${value}</div>
77    ${sublabel && html`<div className="archive-data-stat__note">${sublabel}</div>`}
78  </div>
79`;
80
81const AnalyticsDashboard = ({ onBack, onRecordResults, embedded = false }) => {
82  const [loading, setLoading] = useState(true);
83  const [error, setError] = useState(null);
84  const [stats, setStats] = useState(null);
85  const [byYear, setByYear] = useState([]);
86  const [byCategory, setByCategory] = useState([]);
87  const [byEra, setByEra] = useState([]);
88  const [topPeople, setTopPeople] = useState([]);
89  const [topConcepts, setTopConcepts] = useState([]);
90  const [coOccurrence, setCoOccurrence] = useState([]);
91  const [customQueryResult, setCustomQueryResult] = useState(null);
92  const [customQuery, setCustomQuery] = useState('SELECT year, COUNT(*) as count FROM records GROUP BY year ORDER BY count DESC LIMIT 10');
93  // Tracks the lazy SQLite load triggered by the raw-SQL box (the charts above
94  // render from the prebuilt aggregates and never need SQLite).
95  const [sqlLoading, setSqlLoading] = useState(false);
96
97  // Load the prebuilt analytics aggregates. This is a ~1KB fetch, not the
98  // ~28MB SQLite source — SQLite only loads when a user runs a custom query.
99  useEffect(() => {
100    const loadData = async () => {
101      setLoading(true);
102      setError(null);
103
104      try {
105        const analytics = await fetchAnalytics();
106        setStats(analytics.stats);
107        setByYear(analytics.byYear);
108        setByCategory(analytics.byCategory);
109        setByEra(analytics.byEra);
110        setTopPeople(analytics.topPeople);
111        setTopConcepts(analytics.topConcepts);
112        setCoOccurrence(analytics.coOccurrence);
113        setLoading(false);
114      } catch (err) {
115        console.error('[Analytics] Error:', err);
116        setError('The archive summary could not load. Please refresh the page or try again later.');
117        setLoading(false);
118      }
119    };
120
121    loadData();
122  }, []);
123
124  // Run a raw SQL query. SQLite is loaded on demand here (and in the Query
125  // Builder) rather than on page load, so the first query pays the load cost.
126  const runCustomQuery = async () => {
127    try {
128      if (!isSqliteReady()) {
129        setSqlLoading(true);
130        const ready = await initSqlite();
131        setSqlLoading(false);
132        if (!ready) {
133          setCustomQueryResult({ success: false, error: 'Could not load the query database. Please try again.' });
134          return;
135        }
136      }
137      const result = queryAsObjects(customQuery);
138      setCustomQueryResult({ success: true, data: result });
139    } catch (err) {
140      setSqlLoading(false);
141      setCustomQueryResult({ success: false, error: err.message });
142    }
143  };
144
145  return html`
146    <div className=${embedded ? 'desktop-analytics-surface archive-data-route archive-data-route--analytics is-embedded' : 'archive-data-route archive-data-route--analytics'}>
147      ${!embedded && html`<header className="archive-data-route__header">
148        <div className="archive-data-route__header-inner">
149          <button
150            type="button"
151            aria-label="Return to archive"
152            onClick=${onBack}
153            className="archive-action archive-action--quiet archive-data-route__back"
154          >
155            <${ArrowLeft} aria-hidden="true" />
156            <span>Archive</span>
157          </button>
158          <div className="archive-data-route__identity">
159            <div className="archive-data-route__mark">
160              <${BarChart3} aria-hidden="true" />
161            </div>
162            <div>
163              <h2
164                data-route-entry-focus
165                tabIndex="-1"
166                className="archive-data-route__title"
167              >Archive analytics</h2>
168              <p>Powered by sql.js (SQLite in your browser)</p>
169            </div>
170          </div>
171        </div>
172      </header>`}
173
174      <div className=${embedded ? 'desktop-analytics-content archive-data-surface archive-data-surface--analytics' : 'archive-data-surface archive-data-surface--analytics'}>
175        ${loading && html`
176          <div className="archive-data-loading" role="status" aria-live="polite">
177            <${Loader2} className="archive-data-loading__icon animate-spin" aria-hidden="true" />
178            <p>Loading analytics...</p>
179          </div>
180        `}
181
182        ${error && html`
183          <div className="archive-notice archive-notice--danger archive-data-error" role="alert">
184            <div>
185            <h3>Error loading analytics</h3>
186            <p>${error}</p>
187            <button
188              type="button"
189              onClick=${() => window.location.reload()}
190              className="archive-action archive-action--danger"
191            >
192              Reload page
193            </button>
194            </div>
195          </div>
196        `}
197
198        ${!loading && !error && html`
199          <div className="archive-data-sections">
200            <!-- Summary Stats -->
201            <section className="archive-data-section">
202              <h3 className="archive-data-heading">
203                <${Database} aria-hidden="true" /> Database overview
204              </h3>
205              <div className="archive-data-stats">
206                <${StatCard}
207                  icon=${BarChart3}
208                  label="Records"
209                  value=${stats?.records?.toLocaleString() || '0'}
210                  accent="var(--archive-ink)"
211                />
212                <${StatCard}
213                  icon=${Tag}
214                  label="Categories"
215                  value=${stats?.categories || '0'}
216                  accent="var(--archive-sky-dark)"
217                />
218                <${StatCard}
219                  icon=${TrendingUp}
220                  label="Concepts"
221                  value=${stats?.concepts || '0'}
222                  accent="var(--archive-amber-dark)"
223                />
224                <${StatCard}
225                  icon=${Users}
226                  label="Entities"
227                  value=${stats?.entities?.toLocaleString() || '0'}
228                  accent="var(--archive-green-dark)"
229                />
230              </div>
231            </section>
232
233            <!-- Charts Grid -->
234            <div className="archive-data-chart-grid">
235              <section className="archive-data-panel archive-data-chart">
236                <h3 className="archive-data-heading">
237                  <${Calendar} aria-hidden="true" /> Records by era
238                </h3>
239                <${BarChart} data=${byEra} labelKey="era" valueKey="count" color="#059669" />
240              </section>
241
242              <section className="archive-data-panel archive-data-chart">
243                <h3 className="archive-data-heading">
244                  <${Tag} aria-hidden="true" /> Top categories
245                </h3>
246                <${BarChart} data=${byCategory} labelKey="category" valueKey="count" color="#0284c7" />
247              </section>
248
249              <section className="archive-data-panel archive-data-chart">
250                <h3 className="archive-data-heading">
251                  <${Users} aria-hidden="true" /> Most mentioned people
252                </h3>
253                <${BarChart} data=${topPeople} labelKey="name" valueKey="mentions" color="#7c3aed" />
254              </section>
255
256              <section className="archive-data-panel archive-data-chart">
257                <h3 className="archive-data-heading">
258                  <${TrendingUp} aria-hidden="true" /> Most common concepts
259                </h3>
260                <${BarChart} data=${topConcepts} labelKey="concept" valueKey="count" color="#dc2626" />
261              </section>
262            </div>
263
264            <!-- Records by Year -->
265            <section className="archive-data-panel archive-data-chart archive-data-chart--wide">
266              <h3 className="archive-data-heading">
267                <${Calendar} aria-hidden="true" /> Output by year
268              </h3>
269              <div className="overflow-x-auto">
270                <${BarChart} data=${byYear} labelKey="year" valueKey="count" maxBars=${30} color="#1c1917" />
271              </div>
272            </section>
273
274            <!-- Category Co-occurrence -->
275            <section className="archive-data-panel archive-data-cooccurrence">
276              <h3 className="archive-data-heading">
277                Categories that appear together
278              </h3>
279              <div className="archive-data-cooccurrence__grid">
280                ${coOccurrence.map((item, i) => html`
281                  <div key=${i} className="archive-data-cooccurrence__row">
282                    <span>
283                      ${item.category1} <b aria-hidden="true">+</b> ${item.category2}
284                    </span>
285                    <strong>${item.co_occurrences}</strong>
286                  </div>
287                `)}
288              </div>
289            </section>
290
291            <!-- Query Builder -->
292            <section className="archive-data-panel archive-query-lab">
293              <h3 className="archive-data-heading">
294                <${Database} aria-hidden="true" /> Query builder
295                <span className="archive-data-note">No SQL knowledge required</span>
296              </h3>
297              <p className="archive-query-lab__intro">
298                Build custom queries by completing sentences. Choose from dropdowns and enter values to explore the archive your way.
299              </p>
300              <${QueryBuilder} onRecordResults=${onRecordResults} />
301            </section>
302
303            <!-- Custom Query Section -->
304            <section className="archive-query-terminal">
305              <h3 className="archive-data-heading">
306                <${Database} aria-hidden="true" /> Advanced: raw SQL query
307              </h3>
308              <div className="archive-query-terminal__body">
309                <textarea
310                  value=${customQuery}
311                  onChange=${(e) => setCustomQuery(e.target.value)}
312                  className="archive-query-terminal__input"
313                  placeholder="Enter SQL query..."
314                  aria-label="Raw SQL query"
315                />
316                <div className="archive-query-terminal__actions">
317                  <button
318                    type="button"
319                    onClick=${runCustomQuery}
320                    disabled=${sqlLoading}
321                    className="archive-action archive-query-terminal__run"
322                  >
323                    ${sqlLoading
324                      ? html`<${Loader2} className="w-4 h-4 animate-spin" /> Loading database...`
325                      : html`<${RefreshCw} className="w-4 h-4" /> Run query`}
326                  </button>
327                  <span className="archive-query-terminal__hint">
328                    ${sqlLoading
329                      ? 'First query loads the full archive into SQLite (~28MB, one time)'
330                      : 'Tables: records, record_categories, record_concepts, entities, record_entities'}
331                  </span>
332                </div>
333
334                ${customQueryResult && html`
335                  <div className="archive-query-terminal__results">
336                    ${customQueryResult.success ? html`
337                      <div className="archive-data-scroll" role="region" tabIndex="0" aria-label="Raw SQL results">
338                        <pre>
339                          ${JSON.stringify(customQueryResult.data, null, 2)}
340                        </pre>
341                      </div>
342                    ` : html`
343                      <div className="archive-query-terminal__error" role="alert">
344                        <p>${customQueryResult.error}</p>
345                      </div>
346                    `}
347                  </div>
348                `}
349              </div>
350            </section>
351
352            <!-- Example Queries -->
353            <section className="archive-data-panel archive-query-examples">
354              <h3 className="archive-data-heading">
355                Example queries to try
356              </h3>
357              <div className="archive-query-examples__grid">
358                <button
359                  type="button"
360                  onClick=${() => setCustomQuery("SELECT pub, COUNT(*) as count FROM records GROUP BY pub ORDER BY count DESC LIMIT 15")}
361                  className="archive-query-example"
362                >
363                  <span>Top publications</span>
364                  <p>Which outlets has Jay written for most?</p>
365                </button>
366                <button
367                  type="button"
368                  onClick=${() => setCustomQuery("SELECT year, type, COUNT(*) as count FROM records GROUP BY year, type ORDER BY year DESC")}
369                  className="archive-query-example"
370                >
371                  <span>Articles vs social by year</span>
372                  <p>Compare output types over time</p>
373                </button>
374                <button
375                  type="button"
376                  onClick=${() => setCustomQuery("SELECT e.name, e.type, COUNT(*) as mentions FROM entities e JOIN record_entities re ON e.id = re.entity_id WHERE e.type = 'Organization' GROUP BY e.id ORDER BY mentions DESC LIMIT 15")}
377                  className="archive-query-example"
378                >
379                  <span>Top organizations</span>
380                  <p>Most mentioned organizations</p>
381                </button>
382                <button
383                  type="button"
384                  onClick=${() => setCustomQuery("SELECT title, date, pub FROM records WHERE title LIKE '%Trump%' ORDER BY date DESC LIMIT 20")}
385                  className="archive-query-example"
386                >
387                  <span>Articles about Trump</span>
388                  <p>Search for specific topics</p>
389                </button>
390              </div>
391            </section>
392          </div>
393        `}
394      </div>
395    </div>
396  `;
397};
398
399export default AnalyticsDashboard;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.