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.