|
| 1 | +--- |
| 2 | +import BaseLayout from '@/layouts/BaseLayout.astro'; |
| 3 | +import Icon from '@/components/Icon.astro'; |
| 4 | +import { formatDisplayDate } from '@/lib/utils'; |
| 5 | +import fs from 'node:fs'; |
| 6 | +import path from 'node:path'; |
| 7 | +
|
| 8 | +// Data manifest structure |
| 9 | +interface QueryData { |
| 10 | + fetched_at: string; |
| 11 | + query_hash: string; |
| 12 | + row_count: number; |
| 13 | + file_size_bytes: number; |
| 14 | +} |
| 15 | +
|
| 16 | +interface DataManifest { |
| 17 | + dates: string[]; |
| 18 | + latest: string; |
| 19 | + date_queries: Record<string, Record<string, QueryData>>; |
| 20 | +} |
| 21 | +
|
| 22 | +// Load data manifest from notebooks/data/manifest.json |
| 23 | +function loadDataManifest(): DataManifest { |
| 24 | + const manifestPath = path.join(process.cwd(), '..', 'notebooks', 'data', 'manifest.json'); |
| 25 | + try { |
| 26 | + if (fs.existsSync(manifestPath)) { |
| 27 | + const content = fs.readFileSync(manifestPath, 'utf-8'); |
| 28 | + return JSON.parse(content); |
| 29 | + } |
| 30 | + } catch (e) { |
| 31 | + console.error('Failed to load data manifest', e); |
| 32 | + } |
| 33 | + return { dates: [], latest: '', date_queries: {} }; |
| 34 | +} |
| 35 | +
|
| 36 | +// Format bytes to human-readable size |
| 37 | +function formatFileSize(bytes: number): string { |
| 38 | + if (bytes < 1024) return `${bytes} B`; |
| 39 | + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; |
| 40 | + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; |
| 41 | +} |
| 42 | +
|
| 43 | +const manifest = loadDataManifest(); |
| 44 | +const base = import.meta.env.BASE_URL; |
| 45 | +
|
| 46 | +// Sort dates newest first |
| 47 | +const sortedDates = [...manifest.dates].sort().reverse(); |
| 48 | +
|
| 49 | +// Get total file count and size |
| 50 | +let totalFiles = 0; |
| 51 | +let totalSize = 0; |
| 52 | +for (const date of sortedDates) { |
| 53 | + const queries = manifest.date_queries[date] || {}; |
| 54 | + for (const query of Object.values(queries)) { |
| 55 | + totalFiles++; |
| 56 | + totalSize += query.file_size_bytes; |
| 57 | + } |
| 58 | +} |
| 59 | +--- |
| 60 | + |
| 61 | +<BaseLayout title="Data downloads"> |
| 62 | + <div class="max-w-4xl"> |
| 63 | + <!-- Header --> |
| 64 | + <header class="mb-12"> |
| 65 | + <div class="text-muted-foreground bg-muted mb-6 inline-flex items-center gap-2 px-3 py-1.5 font-mono text-[0.6875rem] tracking-widest uppercase"> |
| 66 | + <Icon name="Download" size={12} /> |
| 67 | + <span>Parquet Files</span> |
| 68 | + </div> |
| 69 | + <h1 class="m-0 mb-6 font-serif text-4xl leading-tight font-normal -tracking-wide max-md:text-3xl"> |
| 70 | + <span class="text-foreground">Data downloads</span> |
| 71 | + </h1> |
| 72 | + <p class="text-muted-foreground max-w-xl text-base leading-relaxed"> |
| 73 | + Parquet files generated by our data pipelines, from which the notebook visualizations are rendered. |
| 74 | + Download them for your own analysis. |
| 75 | + </p> |
| 76 | + <div class="text-muted-foreground mt-4 flex items-center gap-4 text-sm"> |
| 77 | + <span>{totalFiles} files</span> |
| 78 | + <span class="text-border">|</span> |
| 79 | + <span>{formatFileSize(totalSize)} total</span> |
| 80 | + <span class="text-border">|</span> |
| 81 | + <span>{sortedDates.length} dates</span> |
| 82 | + </div> |
| 83 | + </header> |
| 84 | + |
| 85 | + <!-- Data by Date --> |
| 86 | + {sortedDates.map((date) => { |
| 87 | + const queries = manifest.date_queries[date] || {}; |
| 88 | + const queryNames = Object.keys(queries).sort(); |
| 89 | + const isLatest = date === manifest.latest; |
| 90 | + |
| 91 | + return ( |
| 92 | + <section class="mb-10"> |
| 93 | + <div class="mb-4 flex items-center gap-3"> |
| 94 | + <h2 class="m-0 font-serif text-xl font-normal -tracking-tight"> |
| 95 | + {formatDisplayDate(date)} |
| 96 | + </h2> |
| 97 | + {isLatest && ( |
| 98 | + <span class="bg-primary text-primary-foreground px-2 py-0.5 text-[0.625rem] font-mono tracking-wide uppercase"> |
| 99 | + Latest |
| 100 | + </span> |
| 101 | + )} |
| 102 | + </div> |
| 103 | + |
| 104 | + <div class="border-border bg-card overflow-hidden border"> |
| 105 | + <table class="w-full text-sm"> |
| 106 | + <thead> |
| 107 | + <tr class="border-border bg-muted border-b"> |
| 108 | + <th class="text-muted-foreground px-4 py-2.5 text-left font-mono text-[0.6875rem] tracking-wide uppercase font-medium">File</th> |
| 109 | + <th class="text-muted-foreground px-4 py-2.5 text-right font-mono text-[0.6875rem] tracking-wide uppercase font-medium">Rows</th> |
| 110 | + <th class="text-muted-foreground px-4 py-2.5 text-right font-mono text-[0.6875rem] tracking-wide uppercase font-medium">Size</th> |
| 111 | + </tr> |
| 112 | + </thead> |
| 113 | + <tbody> |
| 114 | + {queryNames.map((queryName, idx) => { |
| 115 | + const query = queries[queryName]; |
| 116 | + const filename = `${queryName}.parquet`; |
| 117 | + const downloadUrl = `${base}data/${date}/${filename}`; |
| 118 | + |
| 119 | + return ( |
| 120 | + <tr class={`group border-border ${idx < queryNames.length - 1 ? 'border-b' : ''} hover:bg-muted/50 transition-colors`}> |
| 121 | + <td class="px-4 py-3"> |
| 122 | + <a |
| 123 | + href={downloadUrl} |
| 124 | + download |
| 125 | + class="text-foreground hover:text-primary inline-flex items-center gap-2 no-underline transition-colors" |
| 126 | + > |
| 127 | + <code class="font-mono text-[0.8125rem]">{filename}</code> |
| 128 | + <span class="text-muted-foreground group-hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity"> |
| 129 | + <Icon name="Download" size={14} /> |
| 130 | + </span> |
| 131 | + </a> |
| 132 | + </td> |
| 133 | + <td class="text-muted-foreground px-4 py-3 text-right font-mono text-[0.8125rem]"> |
| 134 | + {query.row_count.toLocaleString()} |
| 135 | + </td> |
| 136 | + <td class="text-muted-foreground px-4 py-3 text-right font-mono text-[0.8125rem]"> |
| 137 | + {formatFileSize(query.file_size_bytes)} |
| 138 | + </td> |
| 139 | + </tr> |
| 140 | + ); |
| 141 | + })} |
| 142 | + </tbody> |
| 143 | + </table> |
| 144 | + </div> |
| 145 | + </section> |
| 146 | + ); |
| 147 | + })} |
| 148 | + |
| 149 | + {sortedDates.length === 0 && ( |
| 150 | + <div class="border-border bg-muted text-muted-foreground border p-8 text-center"> |
| 151 | + No data files available yet. |
| 152 | + </div> |
| 153 | + )} |
| 154 | + </div> |
| 155 | +</BaseLayout> |
0 commit comments