Skip to content

Commit 24b87f4

Browse files
feat: better hero stats
1 parent 30bb16f commit 24b87f4

4 files changed

Lines changed: 161 additions & 38 deletions

File tree

src/routes/public.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
1010
Cached server-side so a hot-loop can't pound DuckDB.
1111
"""
12+
import os
1213
import threading
1314
import time
1415
from datetime import datetime, timezone
1516

1617
from fastapi import APIRouter, Request
1718

19+
from src.config import DUCKDB_PATH
1820
from src.core.datalake import get_db_connection
1921
from src.middleware.logging_config import get_logger
2022
from src.middleware.ratelimit import limiter
@@ -26,6 +28,9 @@
2628
_cache_lock = threading.Lock()
2729
_cache: dict = {"expires_at": 0.0, "value": None}
2830

31+
# Canonical chronological order for timeframes; anything unknown falls to the end.
32+
_TIMEFRAME_ORDER = {"M1": 0, "M5": 1, "M15": 2, "M30": 3, "H1": 4, "H4": 5, "D1": 6, "W1": 7, "MN1": 8, "TICK": 99}
33+
2934

3035
def _compute_stats() -> dict:
3136
with get_db_connection() as con:
@@ -37,13 +42,14 @@ def _compute_stats() -> dict:
3742
" UNION SELECT instrument FROM tick_data"
3843
")"
3944
).fetchone()[0]
40-
timeframes = [
45+
raw_timeframes = [
4146
r[0] for r in con.execute(
42-
"SELECT DISTINCT timeframe FROM ohlc_data ORDER BY timeframe"
47+
"SELECT DISTINCT timeframe FROM ohlc_data"
4348
).fetchall()
4449
]
4550
if tick_rows > 0:
46-
timeframes.append("TICK")
51+
raw_timeframes.append("TICK")
52+
timeframes = sorted(raw_timeframes, key=lambda t: _TIMEFRAME_ORDER.get(t, 50))
4753

4854
ohlc_range = con.execute(
4955
"SELECT MIN(timestamp), MAX(timestamp) FROM ohlc_data"
@@ -57,13 +63,19 @@ def _compute_stats() -> dict:
5763
start = min(mins).isoformat() if mins else None
5864
end = max(maxs).isoformat() if maxs else None
5965

66+
try:
67+
disk_bytes = os.path.getsize(DUCKDB_PATH)
68+
except OSError:
69+
disk_bytes = None
70+
6071
return {
6172
"ohlc_rows": ohlc_rows,
6273
"tick_rows": tick_rows,
6374
"total_rows": ohlc_rows + tick_rows,
6475
"instruments": instrument_count,
6576
"timeframes": timeframes,
6677
"date_range": {"start": start, "end": end},
78+
"disk_bytes": disk_bytes,
6779
"last_refresh": datetime.now(timezone.utc).isoformat(),
6880
"cache_ttl_seconds": _CACHE_TTL_SECONDS,
6981
}

web/src/components/Hero.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
const repoUrl = "https://github.com/lucasguerin/datalake-api";
2+
const repoUrl = "https://github.com/lucas-guerin-44/datalake-api";
33
---
44

55
<section class="relative overflow-hidden">

web/src/components/Stack.astro

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
---
2+
// Each tool has a dominant brand-ish colour. Shown as a fixed radial aura
3+
// (center of the card, not cursor-tracking) that fades in on hover, with a
4+
// tiled SVG noise overlay to stop it feeling plasticky.
25
const items = [
3-
{ name: "FastAPI", role: "REST + WebSocket API" },
4-
{ name: "DuckDB", role: "OHLC + tick storage" },
5-
{ name: "PostgreSQL", role: "users + API keys" },
6-
{ name: "Caddy", role: "TLS reverse proxy" },
7-
{ name: "Docker Compose", role: "container orchestration" },
8-
{ name: "Wine", role: "runs MT5 on Linux" },
9-
{ name: "systemd + fail2ban", role: "ops on the host" },
10-
{ name: "Hetzner", role: "VPS" },
6+
{ name: "FastAPI", role: "REST + WebSocket API", color: "#009688" },
7+
{ name: "DuckDB", role: "OHLC + tick storage", color: "#F4C430" },
8+
{ name: "PostgreSQL", role: "users + API keys", color: "#336791" },
9+
{ name: "Caddy", role: "TLS reverse proxy", color: "#30D094" },
10+
{ name: "Docker Compose", role: "container orchestration", color: "#2496ED" },
11+
{ name: "Wine", role: "runs MT5 on Linux", color: "#7B1F1F" },
12+
{ name: "systemd + fail2ban", role: "ops on the host", color: "#E3542D" },
13+
{ name: "Hetzner", role: "VPS", color: "#D50C2D" },
1114
];
1215
---
1316

@@ -21,11 +24,67 @@ const items = [
2124
</div>
2225
<ul class="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-ink-800 bg-ink-800 sm:grid-cols-4">
2326
{items.map((it) => (
24-
<li class="bg-ink-900 p-5">
25-
<div class="font-medium text-ink-100">{it.name}</div>
26-
<div class="mt-1 text-xs text-ink-500">{it.role}</div>
27+
<li class="stack-card relative overflow-hidden bg-ink-900 p-5" style={`--aura: ${it.color}`}>
28+
<div class="relative z-10">
29+
<div class="font-medium text-ink-100 transition-colors">{it.name}</div>
30+
<div class="mt-1 text-xs text-ink-500 transition-colors">{it.role}</div>
31+
</div>
2732
</li>
2833
))}
2934
</ul>
3035
</div>
3136
</section>
37+
38+
<style>
39+
.stack-card::before,
40+
.stack-card::after {
41+
content: "";
42+
position: absolute;
43+
inset: 0;
44+
pointer-events: none;
45+
opacity: 0;
46+
transition: opacity 500ms cubic-bezier(0.22, 1, 0.36, 1);
47+
}
48+
49+
/* The coloured aura: the radial origin sits BELOW the card's bottom edge
50+
so the glow reads as light spilling up from underneath rather than a
51+
point-source inside. Ellipse stretches it wider-than-tall for a gentler
52+
fall-off across the width. Multiple stops + heavy blur = smooth. */
53+
.stack-card::before {
54+
background: radial-gradient(
55+
ellipse 140% 110% at 50% 118%,
56+
color-mix(in srgb, var(--aura) 95%, transparent) 0%,
57+
color-mix(in srgb, var(--aura) 70%, transparent) 18%,
58+
color-mix(in srgb, var(--aura) 40%, transparent) 40%,
59+
color-mix(in srgb, var(--aura) 15%, transparent) 65%,
60+
transparent 88%
61+
);
62+
filter: blur(28px);
63+
z-index: 0;
64+
}
65+
66+
/* SVG noise layer — feTurbulence at high frequency gives the grainy
67+
texture; the feColorMatrix collapses it to grey and dials alpha. */
68+
.stack-card::after {
69+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
70+
background-size: 160px 160px;
71+
mix-blend-mode: overlay;
72+
z-index: 1;
73+
}
74+
75+
.stack-card:hover::before {
76+
opacity: 0.75;
77+
}
78+
.stack-card:hover::after {
79+
opacity: 0.22;
80+
}
81+
82+
/* Reduced motion: skip the fade transition entirely. The aura still
83+
appears on hover, it just snaps instead of easing. */
84+
@media (prefers-reduced-motion: reduce) {
85+
.stack-card::before,
86+
.stack-card::after {
87+
transition: none;
88+
}
89+
}
90+
</style>

web/src/components/StatsStrip.astro

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,47 +13,82 @@
1313
</p>
1414
</div>
1515

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+
-->
1622
<div class="grid grid-cols-2 gap-6 sm:grid-cols-4">
1723
<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>
2127
</div>
2228
<div>
2329
<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>
2632
</div>
2733
<div>
2834
<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>
3137
</div>
3238
<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>
3642
</div>
3743
</div>
3844
</div>
3945
</section>
4046

4147
<script is:inline>
4248
(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+
4357
const formatNumber = (n) => {
4458
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + "B";
4559
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
4660
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
4761
return String(n);
4862
};
4963

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+
5081
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
5182

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+
}
5388
const start = performance.now();
5489
const tick = (now) => {
5590
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));
5792
el.textContent = formatNumber(v);
5893
if (t < 1) requestAnimationFrame(tick);
5994
else el.textContent = formatNumber(target);
@@ -87,28 +122,45 @@
87122
async function render() {
88123
const counters = document.querySelectorAll("[data-counter]");
89124
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]");
92129
const refreshEl = document.querySelector("[data-stats-refresh-value]");
93130

94131
try {
95132
const res = await fetch("/api/public/stats", { headers: { accept: "application/json" } });
96133
if (!res.ok) throw new Error("status " + res.status);
97134
const data = await res.json();
98135

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+
}
101151

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);
107157
}
158+
108159
if (refreshEl) refreshEl.textContent = relativeTime(data.last_refresh);
109160
} 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";
112164
}
113165
}
114166

0 commit comments

Comments
 (0)