Posts

Code

28 Jun 2026

8 MIN read

••• views

Live "presence" chips!

The homepage chips that show what I'm listening to, the time spend coding, and github stats. Provider based api approach!

Next.jsTypeScriptAPI

Well, its been a minute!.

You know those little status chips people have on their websites? The ones showing what's playing on Spotify, how much they've coded this week, or some other tiny slice of live activity?

I've wanted them on my own site for years now. And a few weeks ago I finally sat down to build them.

Ahh.. the integrations.. every service authenticates differently, returns a different shape, refreshes on a different schedule, and comes with its own little pile of edge cases. Spotify wants OAuth, WakaTime exposes public share URLs, GitHub prefers GraphQL. None of them agree on anything.

And the interesting part of the whole project ended up being that layer: a small provider system that takes three completely different APIs and gives the rest of the app one consistent shape.

They're live btw. Spotify refreshes roughly every 30 seconds, so if you catch me mid-song, that's actually what's playing right now.

Let us dive in!

Here's the lineup:

  1. Spotify → what's currently playing, or the last thing I listened to.
  2. WakaTime → coding time for the current week.
  3. GitHub → commits pushed over the last seven days.
  4. Vercel → static, because their token model isn't something I want exposed to a public-facing feature.
  5. Figma → also static. I'd love to claim I live in Figma, but most of my "design" happens straight in code.

The first three are fully live. The last two are honest placeholders.

The structure

Everything lives under src/modules/external/, sliced three ways:

plaintext
external/
  shared/   # types + config, safe on both sides of the wire
  server/   # providers, registry, zod schemas, secrets live here
  client/   # the SWR context that feeds the chips

The idea is simple:

  • shared defines the contracts.
  • server deals with the third-party APIs.
  • client renders whatever comes back.

The base class

Every provider extends the same abstract class. It holds all the tedious, error-prone bits so I don't have to rewrite them for each service:

ts
export abstract class StatusProvider<T> {
  abstract readonly name: string;
  abstract readonly cacheSeconds: number;
  abstract getStatus(): Promise<T>;
 
  protected async getJson<S>(url: string, schema: ZodType<S>, config?: AxiosRequestConfig) {
    try {
      const res = await axios.get(url, config);
      const parsed = schema.safeParse(res.data);
      return parsed.success ? parsed.data : null;
    } catch {
      return null;
    }
  }
}

Two small decisions are doing a lot of work here. safeParse means that when a third party sends back garbage (and they will, sometimes) the chip quietly falls back to null and shows its offline face instead of handing you a 500. And every provider declares its own cacheSeconds, how long the edge may hold onto a response before refetching. Spotify sets 15, WakaTime and GitHub set 1800. That's a separate dial from the browser's 30-second poll above: the edge cache is deliberately shorter, so a poll never lands on stale data. The provider knows how fresh it needs to be, so the provider gets to decide.

One route for all of them

There's a single dynamic route, app/api/[provider]/status/route.ts, and it's almost entirely plumbing:

ts
export async function GET(_req: Request, { params }: { params: Promise<{ provider: string }> }) {
  const { provider } = await params;
  if (!isProviderName(provider)) {
    return Response.json({ error: "Unknown provider" }, { status: 404 });
  }
 
  const source = providers[provider];
  const data = await source.getStatus();
 
  return Response.json(data, {
      headers: {
        "Cache-Control": `public, s-maxage=${source.cacheSeconds}, stale-while-revalidate=30`,
      },
    });
}

isProviderName is a type guard over a registry: just a plain object mapping names to instances:

ts
export const providers = {
  spotify: new SpotifyProvider(),
  wakatime: new WakaTimeProvider(),
  github: new GitHubProvider(),
} as const;

Adding a fourth chip is wonderfully boring: write a class that extends StatusProvider, drop it in that object, done. The route, the caching, the validation, and the client all keep working without a single edit. /api/steam/status would simply exist.

Spotify, the Lee Robinson way

Spotify is the one that needs real auth. The trick is the one Lee Robinson popularised: you log in once, by hand, to mint a long-lived refresh token, and stash it as a secret. After that, the server quietly swaps the refresh token for a short-lived access token whenever it needs one. No login flow, no popups, no user ever involved again.

ts
private async accessToken(): Promise<string | null> {
  const { SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN } = env;
  if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET || !SPOTIFY_REFRESH_TOKEN) return null;
  if (this.token && this.token.expiresAt > Date.now() + 60_000) return this.token.value;
  const basic = btoa(`${client_id}:${client_secret}`);
  const res = await axios.post(
    TOKEN_URL,
    new URLSearchParams({ grant_type: "refresh_token", refresh_token: SPOTIFY_REFRESH_TOKEN }),
    {
      headers: {
        Authorization: `Basic ${basic}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
    },
  );
  // ...parse, cache { value, expiresAt }, return value
}

The access token gets cached in memory with a 60-second safety buffer, so most requests skip the token dance entirely. The status read itself is a simple two-step flow: ask currently-playing (which answers 200 when something's on and 204 when it isn't), and if it's quiet, fall back to recently-played:

ts
async getStatus(): Promise<NowPlaying> {
  const token = await this.accessToken();
  if (!token) return OFFLINE;
  try {
    return (await this.current(token)) ?? (await this.recent(token));
  } catch {
    return OFFLINE;
  }
}
ts
private async current(token: string): Promise<NowPlaying | null> {
  const res = await axios.get(CURRENT_URL, {
    headers: { Authorization: `Bearer ${token}` },
    validateStatus: (s) => s === 200 || s === 204,
  });
  if (res.status !== 200) return null;
 
  const parsed = SpotifyCurrentSchema.safeParse(res.data);
  if (!parsed.success || !parsed.data.is_playing || !parsed.data.item) return null;
  return this.normalize(parsed.data.item, "playing");
}
ts
private async recent(token: string): Promise<NowPlaying> {
  const data = await this.getJson(RECENT_URL, SpotifyRecentSchema, {
    headers: { Authorization: `Bearer ${token}` },
  });
  const track = data?.items[0]?.track;
  return track ? this.normalize(track, "recent") : OFFLINE;
}

current() returns null to mean nothing's playing right now, which is a normal signal rather than a failure, so the ?? quietly falls through to recent(). A thrown error is a different story: it lands in the catch and the chip goes offline, instead of pretending the last track is still live.

Every exit returns a NowPlaying, either { state: "playing" | "recent", ... } or { state: "offline" }. The chip renders a discriminated union, so there's no loading forever and no undefined.title waiting to blow up in the view.

Don't ask for more scopes than you need. These chips only read playback, so user-read-currently-playing and user-read-recently-played are plenty. A leaked token can only do what its scopes allow, so give it as little to do as possible.

WakaTime and GitHub, the easy ones

WakaTime and GitHub are refreshingly low-maintenance. WakaTime skips auth altogether. It exposes a public share URL that hands back the last 7 days as JSON, so the provider just adds up the seconds and formats them:

ts
const total = data.data.reduce((sum, day) => sum + day.grand_total.total_seconds, 0);
return { ok: true, label: formatDuration(total) }; // "12h 30m"

GitHub prefers GraphQL: contributionsCollection.totalCommitContributions gives an exact weekly count straight from the API. No token, no count, so the chip just sits out until you've wired up your secrets. Same base class, same { ok: true | false } shape, no drama.

Polling, on a deliberately dumb client

The browser side is kept intentionally simple. One context provider runs three SWR subscriptions against the routes, each on its own clock:

tsx
const { data: nowPlaying } = useSWR<NowPlaying>("/api/spotify/status", fetcher, {
  refreshInterval: POLL.nowPlaying, // 30s
});
const { data: coding } = useSWR<CodingStats>("/api/wakatime/status", fetcher, {
  refreshInterval: POLL.stats, // 30min
});
const { data: commits } = useSWR<GitHubActivity>("/api/github/status", fetcher, {
  refreshInterval: POLL.stats,
});

SWR handles dedup, focus revalidation, and retries; the chips just read from context and render. The poll cadence and the edge cacheSeconds are set independently, on purpose. Spotify polls at 30s against a 15s cache so a track change shows up fast, while the stats poll every 30 minutes against a 30-minute cache, because nobody on earth would needs their weekly coding hours updating in real time real fast. 🫣

What this whole thing buys

Underneath all the specifics, the shape is pretty simple:

  • shared/ types are the common language: one NowPlaying, validated on the way in, rendered on the way out.
  • server/ providers soak up each API's mess and hand back a clean union, never throwing.
  • One route + a registry turn "add an integration" into "write a class."
  • The client knows exactly one trick: poll a URL.

None of these pieces is clever on its own, and that was the goal. A flaky Spotify response, a missing GitHub token, a malformed WakaTime payload: they all fail the same boring way. The chip falls back to its resting state, and the page never notices anything went wrong.

That's a wrap, folks! Hope it helps you wire up your own.

More fun stuff awaits. Happy hacking!

Keep reading