The activity timeline on my dashboard combines three services, but it does not pretend they all provide the same kind of data. GitHub public events and Spotify listening history have useful timestamps. Steam exposes current profile state and rolling playtime, not reliable timestamps for individual sessions. The implementation in lib/activity.ts preserves that distinction instead of manufacturing a single misleading chronology.
This feed extends the lightweight API approach described in Integrating Lightweight GitHub Metrics and fits the broader client/API split in Designing My Personal Portfolio Architecture. The important change is not merely adding more providers. It is defining one honest contract for dated events, undated summaries, and source health.
Two Shapes, Not One
ActivityEvent requires occurredAt, so only records with real timestamps enter the timeline. ActivitySummary has no timestamp and is currently Steam-only:
export interface ActivityEvent {
id: string;
source: ActivitySource;
kind: string;
title: string;
occurredAt: string;
}
export interface ActivitySummary {
id: string;
source: 'steam';
kind: 'live' | 'recent-playtime';
title: string;
live?: boolean;
}
That separation matters. A Steam response can say that a game has 3.2 hours in the past two weeks, but it cannot establish when those hours occurred. Likewise, a profile's current game is a live snapshot, not a historical event. loadSteam() therefore returns an empty events array and creates summaries for current status plus recent playtime.
The UI reinforces the contract. components/ActivityTimeline.tsx labels the lower section “Steam snapshot · no session timestamps” and uses ● live or · 2w, while dated GitHub and Spotify records are grouped by UTC calendar day. The richer Steam presentation remains available on the games page, and the cache details behind it are covered in Caching Steam Data with Prisma.
Mapping Real Provider Data
GitHub data comes from fetchPublicEvents('mofdabo', 20). In lib/github.js, that helper calls:
return ghJson(`/users/${encodeURIComponent(username)}/events/public?per_page=${safeLimit}`);
An optional server-side GITHUB_TOKEN raises API limits, but the endpoint itself is the user's public event stream. mapGithubEvent() handles push, create, pull request, issue, comment, watch, fork, and release events. Each item keeps GitHub's created_at, receives a source-prefixed ID, and links to the relevant repository or payload URL. Unknown event types still get a conservative label rather than being discarded.
Spotify follows a similar rule. getRecentlyPlayed(20) requests /v1/me/player/recently-played; mapRecentTrackItem() reads the provider's played_at value. loadSpotify() filters out tracks without that value before creating events:
.filter((track: any) => track.playedAt)
.map((track: any, index: number): ActivityEvent => ({
id: `spotify:${track.playedAt}:${index}`,
source: 'spotify',
kind: 'track',
title: `Listened to ${track.title}`,
occurredAt: track.playedAt,
}));
GitHub and Spotify events are merged, sorted newest-first by occurredAt, and sliced to a caller-controlled limit. Both pages/api/activity.ts and getActivityFeed() clamp that limit from 1 through 30, so a query cannot turn the endpoint into an unbounded aggregator.
Freshness Belongs to Each Source
One global “updated” timestamp would hide which provider failed. Instead, lib/activity.ts maintains an independent in-memory cache entry for github, spotify, and steam. Each entry stores data, fetchedAt, expiresAt, and retryAfter.
The current constants are intentionally straightforward:
const SOURCE_TTL_MS = 5 * 60 * 1000;
const FAILURE_RETRY_MS = 2 * 60 * 1000;
All three sources use the same five-minute TTL, but their entries expire and fail independently. During the TTL, a source is fresh. If a refresh throws, the source enters a two-minute retry pause. Existing data is returned as stale; without prior data, the source is unavailable. Requests during that pause do not repeatedly hit the failing upstream.
getActivityFeed() starts all loaders with Promise.all, but each loadSource() catches its own error. That is the key to partial failure: Spotify authorization can fail while cached Spotify records remain visible and GitHub and Steam continue refreshing normally. Only an unexpected error outside those source wrappers reaches the route's 500 response.
pages/api/activity.ts adds another cache layer with:
res.setHeader('Cache-Control', 'public, s-maxage=120, stale-while-revalidate=600');
This allows a shared cache to serve the response for two minutes and revalidate stale responses for up to ten more. It complements, rather than replaces, the five-minute per-process source caches.
Showing Degradation Instead of Hiding It
ActivityTimeline uses SWR with a 120-second refresh interval, a 60-second deduping interval, and focus revalidation disabled. It renders one FreshnessBadge per source using that source's fetchedAt and status. components/FreshnessBadge.tsx displays normal, amber stale, or error unavailable states; stale labels receive a ~ prefix.
When any source is degraded, the component prints a concise partial-feed message and explicitly says cached data remains visible when available. A total request failure uses DataError; an empty successful response says no dated public activity is available. Those are different states and deserve different UI.
The result is a feed that feels live without overstating what the APIs know. Real timestamps drive chronology, Steam stays visibly undated, and one provider's outage does not erase everything else.