|
13 | 13 | </p> |
14 | 14 | </div> |
15 | 15 |
|
| 16 | + <!-- |
| 17 | + Pre-filled with reasonable last-known-good values so the strip never |
| 18 | + renders as an empty skeleton. On page load the fetch replaces them with |
| 19 | + live numbers; if the fetch fails these stay and the refresh stamp reads |
| 20 | + "stats unavailable". |
| 21 | + --> |
16 | 22 | <div class="grid grid-cols-2 gap-6 sm:grid-cols-4"> |
17 | 23 | <div> |
18 | | - <div class="text-[11px] uppercase tracking-wider text-ink-500">Total rows</div> |
19 | | - <div class="mt-1 font-mono text-3xl text-ink-50 sm:text-4xl tabular-nums" data-counter="0">0</div> |
20 | | - <div class="mt-1 text-xs text-ink-500">OHLC + ticks</div> |
| 24 | + <div class="text-[11px] uppercase tracking-wider text-ink-500">On disk</div> |
| 25 | + <div class="mt-1 font-mono text-3xl text-ink-50 sm:text-4xl tabular-nums" data-disk>~4 GB</div> |
| 26 | + <div class="mt-1 text-xs text-ink-500" data-disk-sub>DuckDB, compressed</div> |
21 | 27 | </div> |
22 | 28 | <div> |
23 | 29 | <div class="text-[11px] uppercase tracking-wider text-ink-500">Instruments</div> |
24 | | - <div class="mt-1 font-mono text-3xl text-ink-50 sm:text-4xl tabular-nums" data-counter="0">0</div> |
25 | | - <div class="mt-1 text-xs text-ink-500">FX, metals, indices, crypto, commodities</div> |
| 30 | + <div class="mt-1 font-mono text-3xl text-ink-50 sm:text-4xl tabular-nums" data-counter>73</div> |
| 31 | + <div class="mt-1 text-xs text-ink-500">across asset classes</div> |
26 | 32 | </div> |
27 | 33 | <div> |
28 | 34 | <div class="text-[11px] uppercase tracking-wider text-ink-500">Timeframes</div> |
29 | | - <div class="mt-1 font-mono text-3xl text-ink-50 sm:text-4xl tabular-nums" data-timeframe-count>0</div> |
30 | | - <div class="mt-1 text-xs text-ink-500 truncate" data-timeframes>M1 → D1 + TICK</div> |
| 35 | + <div class="mt-1 font-mono text-3xl text-ink-50 sm:text-4xl tabular-nums" data-timeframe-count>8</div> |
| 36 | + <div class="mt-1 text-xs text-ink-500">M1 → D1, plus ticks</div> |
31 | 37 | </div> |
32 | 38 | <div> |
33 | | - <div class="text-[11px] uppercase tracking-wider text-ink-500">Date range</div> |
34 | | - <div class="mt-1 font-mono text-xl text-ink-50 sm:text-2xl tabular-nums" data-date-range>— → —</div> |
35 | | - <div class="mt-1 text-xs text-ink-500">UTC</div> |
| 39 | + <div class="text-[11px] uppercase tracking-wider text-ink-500">Years of coverage</div> |
| 40 | + <div class="mt-1 font-mono text-3xl text-ink-50 sm:text-4xl tabular-nums" data-years>25</div> |
| 41 | + <div class="mt-1 text-xs text-ink-500" data-years-sub>2000-07-16 → 2026-04-20</div> |
36 | 42 | </div> |
37 | 43 | </div> |
38 | 44 | </div> |
39 | 45 | </section> |
40 | 46 |
|
41 | 47 | <script is:inline> |
42 | 48 | (function () { |
| 49 | + // Fallback values that render immediately in the HTML. If the fetch below |
| 50 | + // fails, the page keeps showing these rather than an empty skeleton. |
| 51 | + // Count-up animations interpolate FROM these to the live numbers. |
| 52 | + const FALLBACK = { |
| 53 | + instruments: 73, |
| 54 | + timeframes_count: 8, |
| 55 | + }; |
| 56 | + |
43 | 57 | const formatNumber = (n) => { |
44 | 58 | if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + "B"; |
45 | 59 | if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M"; |
46 | 60 | if (n >= 1_000) return (n / 1_000).toFixed(1) + "k"; |
47 | 61 | return String(n); |
48 | 62 | }; |
49 | 63 |
|
| 64 | + const formatBytes = (n) => { |
| 65 | + if (n == null) return "—"; |
| 66 | + if (n >= 1024 ** 4) return (n / 1024 ** 4).toFixed(2) + " TB"; |
| 67 | + if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(2) + " GB"; |
| 68 | + if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + " MB"; |
| 69 | + if (n >= 1024) return (n / 1024).toFixed(0) + " KB"; |
| 70 | + return n + " B"; |
| 71 | + }; |
| 72 | + |
| 73 | + const yearsBetween = (startIso, endIso) => { |
| 74 | + if (!startIso || !endIso) return null; |
| 75 | + const start = new Date(startIso); |
| 76 | + const end = new Date(endIso); |
| 77 | + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null; |
| 78 | + return (end - start) / (365.25 * 24 * 3600 * 1000); |
| 79 | + }; |
| 80 | + |
50 | 81 | const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3); |
51 | 82 |
|
52 | | - function countUp(el, target, duration) { |
| 83 | + function countUp(el, target, duration, fromValue = 0) { |
| 84 | + if (fromValue === target) { |
| 85 | + el.textContent = formatNumber(target); |
| 86 | + return; |
| 87 | + } |
53 | 88 | const start = performance.now(); |
54 | 89 | const tick = (now) => { |
55 | 90 | const t = Math.min(1, (now - start) / duration); |
56 | | - const v = Math.round(target * easeOutCubic(t)); |
| 91 | + const v = Math.round(fromValue + (target - fromValue) * easeOutCubic(t)); |
57 | 92 | el.textContent = formatNumber(v); |
58 | 93 | if (t < 1) requestAnimationFrame(tick); |
59 | 94 | else el.textContent = formatNumber(target); |
|
87 | 122 | async function render() { |
88 | 123 | const counters = document.querySelectorAll("[data-counter]"); |
89 | 124 | const tfCount = document.querySelector("[data-timeframe-count]"); |
90 | | - const tfList = document.querySelector("[data-timeframes]"); |
91 | | - const range = document.querySelector("[data-date-range]"); |
| 125 | + const diskEl = document.querySelector("[data-disk]"); |
| 126 | + const diskSubEl = document.querySelector("[data-disk-sub]"); |
| 127 | + const yearsEl = document.querySelector("[data-years]"); |
| 128 | + const yearsSubEl = document.querySelector("[data-years-sub]"); |
92 | 129 | const refreshEl = document.querySelector("[data-stats-refresh-value]"); |
93 | 130 |
|
94 | 131 | try { |
95 | 132 | const res = await fetch("/api/public/stats", { headers: { accept: "application/json" } }); |
96 | 133 | if (!res.ok) throw new Error("status " + res.status); |
97 | 134 | const data = await res.json(); |
98 | 135 |
|
99 | | - const totals = [data.total_rows ?? 0, data.instruments ?? 0]; |
100 | | - counters.forEach((el, i) => countUp(el, totals[i] ?? 0, 1200)); |
| 136 | + // Instruments — animate from fallback to live (small jump most of the time). |
| 137 | + const liveInstruments = data.instruments ?? FALLBACK.instruments; |
| 138 | + counters.forEach((el) => countUp(el, liveInstruments, 1200, FALLBACK.instruments)); |
| 139 | + |
| 140 | + const tfCountLive = (data.timeframes || []).length || FALLBACK.timeframes_count; |
| 141 | + if (tfCount) tfCount.textContent = String(tfCountLive); |
| 142 | + |
| 143 | + // Only overwrite disk/years if the API actually returned usable values. |
| 144 | + // Null or malformed → leave the static fallback in place. |
| 145 | + if (diskEl && data.disk_bytes != null) { |
| 146 | + diskEl.textContent = formatBytes(data.disk_bytes); |
| 147 | + } |
| 148 | + if (diskSubEl && data.total_rows != null) { |
| 149 | + diskSubEl.textContent = formatNumber(data.total_rows) + " rows, compressed"; |
| 150 | + } |
101 | 151 |
|
102 | | - if (tfCount) tfCount.textContent = String((data.timeframes || []).length); |
103 | | - if (tfList) tfList.textContent = (data.timeframes || []).join(" · ") || "—"; |
104 | | - if (range) { |
105 | | - range.textContent = |
106 | | - formatDate(data.date_range?.start) + " → " + formatDate(data.date_range?.end); |
| 152 | + const years = yearsBetween(data.date_range?.start, data.date_range?.end); |
| 153 | + if (yearsEl && years != null) yearsEl.textContent = years.toFixed(1); |
| 154 | + if (yearsSubEl && data.date_range?.start && data.date_range?.end) { |
| 155 | + yearsSubEl.textContent = |
| 156 | + formatDate(data.date_range.start) + " → " + formatDate(data.date_range.end); |
107 | 157 | } |
| 158 | + |
108 | 159 | if (refreshEl) refreshEl.textContent = relativeTime(data.last_refresh); |
109 | 160 | } catch (err) { |
110 | | - counters.forEach((el) => (el.textContent = "—")); |
111 | | - if (refreshEl) refreshEl.textContent = "unavailable"; |
| 161 | + // Fetch failed — leave every value at its HTML fallback. Only signal |
| 162 | + // that the numbers aren't fresh via the refresh stamp. |
| 163 | + if (refreshEl) refreshEl.textContent = "stats unavailable"; |
112 | 164 | } |
113 | 165 | } |
114 | 166 |
|
|
0 commit comments