Life as Data: Strava, Chess, and the UI Sprint That Completed js17.dev
A portfolio that only shows what you build professionally is missing half the engineer. Here's how I built a live hobbies dashboard — Strava stats, Chess.com ratings, and a dark theme that finally knows what it is.
A resume lists your skills. A portfolio shows your work. But neither of those tells you how someone thinks — what they care about, how they operate outside the office, whether their discipline extends beyond business hours.
I believe the best engineers are systems thinkers in every context. Not just at work. And I wanted js17.dev to reflect that.
So I built /hobbies.
The Premise: Track Everything, Hide Nothing
The existing live data surfaces on this site — Credly certifications, YouTube metrics, GitHub stats — all follow the same principle: don't claim it if you can't prove it. Live data over static claims.
The same principle should apply to the rest of who I am.
If I can build automated certification freshness and public video metrics, I can apply the same architecture to my running cadence and chess rating. Data is data. The integration patterns are identical.
The result: a /hobbies page that pulls live stats from two external APIs, caches them in Vercel Blob, and refreshes on a daily cron — using the exact same infrastructure pattern that powers every other live section on the site.
The Architecture: Two Sources, One Pattern
The /hobbies page serves stats from two independent live data sources:
Running stats: total distance, run count, elevation gain, activity streaks. Token auto-refresh stored in Blob. Activities feed with real dates, paces, and distances.
Chess ratings across Bullet, Blitz, and Rapid formats. Win/Loss/Draw breakdown per format. No OAuth required — Chess.com's public API is completely open.
Both follow the same two-layer cache architecture introduced by the Credly integration:
- Blob-first: read from
strava/stats-cache.jsonorchess/stats-cache.jsonon every page load — fast, cheap, zero external latency - ISR fallback: if the Blob is empty, fetch directly from the API with
next: { revalidate: 21600 }— 6-hour freshness guaranteed - Daily cron: one consolidated background job refreshes all three external caches — Credly, Strava, and Chess — every morning at 08:00 UTC
The page uses export const revalidate = 21600 and Promise.all() to fetch both data sources in parallel:
const [stats, chess] = await Promise.all([getStravaStats(), getChessStats()])
Strava: OAuth2 Token Management at the Edge
Strava's API uses OAuth2 with short-lived access tokens (6-hour expiry). Every integration I've seen handles this poorly: hardcode the token, let it expire, debug in production.
The approach here is different: the token lives in Blob.
The token refresh is fully automatic. No manual rotation. No cron required. The blob becomes a self-updating credential store that keeps itself valid indefinitely:
async function getValidAccessToken(): Promise<string> {
try {
const { blobs } = await list({ prefix: "strava/token-cache.json" })
if (blobs.length > 0) {
const res = await fetch(blobs[0].url, { cache: "no-store" })
const cached: StravaTokenCache = await res.json()
// Valid if more than 5 minutes remain
if (cached.expires_at > Math.floor(Date.now() / 1000) + 300) {
return cached.access_token
}
return await refreshAccessToken(cached.refresh_token)
}
} catch { /* fall through */ }
// No cache — bootstrap from env
return await refreshAccessToken(process.env.STRAVA_REFRESH_TOKEN!)
}
Streak Calculation: Days to Weeks
Running streaks are typically displayed in days. But days become noisy at scale — "14-day streak" communicates less than "2-week streak." The displayed metric is in weeks, with one decimal for precision when the streak doesn't land on a whole week.
The underlying algorithm runs on days:
- Extract unique run dates from the activities list (deduplicated by
start_date_localprefix) - Sort descending
- Starting from today (or yesterday, if no run today yet), count consecutive days backward
- Track longest streak via sliding window scan
Display conversion: (days / 7).toFixed(1).replace(/\.0$/, "") + " wks" — renders "2 wks" for whole weeks, "1.4 wks" for partial.
The StravaStatCard animates numeric values via requestAnimationFrame over 1.2 seconds with ease-out cubic easing. It parses the leading numeric portion from the value string — so "1.4 wks" correctly animates 0.0 → 1.4 before appending " wks". No special handling needed for the weeks format.
Chess.com: No Auth, No Problem
Chess.com's public API requires zero authentication to read player stats. A single endpoint returns current ratings and W/L/D totals for every active format.
The visualization uses Recharts BarChart with layout="vertical" and three stacked series — Win, Loss, Draw — color-coded for immediate comprehension:
Each format bar (Bullet / Blitz / Rapid) displays the current rating as a label. The W/L/D ratio tells a more complete story than a rating alone — it shows consistency, aggression style, and how often games reach decisive outcomes.
Locking the Identity: Circuit Dark, Forever
Two UI changes shipped in the same sprint, and they form a coherent statement.
The theme picker is gone.
js17.dev now runs exclusively in Circuit Dark — the deep navy + electric blue palette that's been the default since launch. The ThemeProvider now has forcedTheme="dark", which overrides any setTheme() call from client code, localStorage, or system preferences:
<ThemeProvider
attribute="class"
defaultTheme="dark"
forcedTheme="dark"
themes={["light", "dark", "titanium", "aurora"]}
disableTransitionOnChange
>
One prop change. The ThemeInitializer — previously a 30-line effect hook managing localStorage flags and random palette assignment — reduced to a no-op:
// Theme is locked to dark via forcedTheme in ThemeProvider — no initialization needed.
export function ThemeInitializer() {
return null
}
This isn't laziness. It's a deliberate identity decision. Circuit Dark is the visual language of this site — every component, every gradient, every orange-on-navy accent was designed for it. Supporting four themes was generosity to no one.
GitHub and LinkedIn icons moved to the header.
Previously in the footer — visible only after scrolling the entire page. Now they live beside the CV download button in the top navigation, visible on every page, every route, above the fold. The footer reduced to logo + copyright. No information lost. Signal increased.
The Blog Readability Fix
A quiet but important correction: blog post prose now renders correctly in dark mode.
The root cause: Tailwind Typography's prose-slate applies light-mode styles by default — dark text, light blockquote borders, light code backgrounds. Without dark:prose-invert, those styles don't flip when the site is dark.
The fix:
// Before — dark text on dark background
<div className="prose prose-slate max-w-none ...">
// After — correct inversion for dark mode
<div className="prose prose-slate dark:prose-invert max-w-none ...">
dark:prose-invert applies a full color inversion across all Typography tokens when .dark is on <html>: light text, inverted blockquotes, accessible link colors, visible code backgrounds. With forcedTheme="dark", this class is always active. The prose is always readable.
The Automated Stack: Three Sources, One Cron
Adding Strava and Chess didn't require new cron entries. Both refreshStravaCache() and refreshChessCache() are called inside the existing refresh-credly handler at 08:00 UTC. One authorization check. One execution window. Three caches refreshed.
Constraint-driven architecture: Vercel Hobby allows only daily cron frequency — one execution per day per route. Rather than attempt multiple cron entries (which are rejected), all external cache refreshes consolidate into the 08:00 job. The result is cleaner: one auth check, one log, one atomic refresh sweep.
Why This Matters
There's a version of a professional portfolio that is purely credential-forward: certifications, GitHub stats, years of experience. Legible. Forgettable.
js17.dev is trying to be something different: a living document of the person who built it. The running stats aren't there to impress. They're there because they're true, they're current, and they reflect the same discipline I bring to production systems. I don't skip leg day. I don't let caches go stale. I don't ship with broken styles.
For Startup Founders
You're hiring for judgment, not just output. An engineer who instruments their own performance data — and builds the infrastructure to surface it — is showing you how they think about observability. That carries directly into how they'll instrument your product, maintain your systems, and respond to incidents under pressure.
For Engineering Teams
The Blob + ISR + cron pattern shown here applies to any frequently-changing external data: pricing feeds, inventory, analytics dashboards, third-party API responses. The architecture is identical whether you're caching chess ratings or your SaaS product's conversion metrics. Learn it once, apply it everywhere.
For Everyone
A portfolio is a product. Products should be engineered, not assembled. The difference shows in the details: no stale data, no broken styles in dark mode, no credentials buried in footers nobody reads. This sprint — hobbies dashboard, identity lock, readability fix — shipped as a single coherent release. Build passes clean. Nothing regresses.
The same engineering discipline — applied to your systems
Live data integrations, automated cache pipelines, clean UI with zero stale state — these aren't portfolio features. They're the baseline for any production system that users trust.
What I build for clients:
- Multi-source data integrations with graceful Blob-first fallback chains
- Background job pipelines that consolidate under infrastructure constraints
- Clean, opinionated UI with no ambiguity about state or identity
- Systems that remain current and accurate without manual intervention
Most clients receive a scoped technical proposal within 24 hours.