A report-style profile page with live data from the Bungie and Last.fm APIs. Config-driven layout, multi-account support, dot-chart activity strips, click-to-copy Bungie tags, dynamic emblem updates (even in the website icon!), and a project showcase.
Built with Next.js. All third-party calls happen on the server, so your API keys never reach the browser. There's no client-side JavaScript outside the copy buttons.
- Quickstart
- What you have to edit
.env.localreferenceconfig/site.tsreference- Section catalog
- Visual indicators on dots
- Project structure
- Recipes
- Deploy
- Known limitations
# 1. Install
npm install
# 2. Create your env file
cp .env.example .env.local
# …then fill in the four required values (see below).
# 3. Run
npm run dev
# Open http://localhost:3000You need exactly two files filled in to make the site yours:
.env.local, secrets, account info, defaults.config/site.ts, page layout (what cards appear, in what order).
Everything else is implementation and rarely needs touching.
In .env.local:
| Variable | What | Where to get it |
|---|---|---|
BUNGIE_API_KEY |
Your Bungie.net application key. | https://www.bungie.net/en/Application, any app, no callback URL needed. |
BUNGIE_NAME |
The part of your Bungie tag before the #. |
In-game roster / bungie.net profile. |
BUNGIE_CODE |
The 4-digit part after the #. Leading zeros are fine. |
Same. |
LASTFM_API_KEY |
Last.fm API key. | https://www.last.fm/api/account/create, any app name, callback URL irrelevant. |
LASTFM_USERNAME |
Your Last.fm handle. | Your profile. |
That's enough to see the hero card, the default activity strip, now playing, and the auto-generated link pills.
Edit config/site.ts to choose what appears on the page (see
Section catalog below). The defaults give you a hero, a
link row, a tracked-activity strip + now-playing pair, and a Projects section
with a single placeholder card, swap in your own projects there.
Everything else in this README is reference for going deeper.
# ---------- Required ----------
BUNGIE_API_KEY=...
BUNGIE_NAME=Yura
BUNGIE_CODE=0618 # leading zeros OK; padded everywhere it's shown
LASTFM_API_KEY=...
LASTFM_USERNAME=...
# ---------- Multiple accounts (optional, unlimited) ----------
# Reference these from config/site.ts with `account: 2`, `account: 3`, etc.
# BUNGIE_NAME_2=
# BUNGIE_CODE_2=
# BUNGIE_NAME_3=
# BUNGIE_CODE_3=
# ---------- Display ----------
# Override the hero card name + browser tab title.
# Each account can have its own DISPLAY_NAME_<n>.
DISPLAY_NAME=[...]
# DISPLAY_NAME_2=
# DISPLAY_NAME_3=
# ---------- Tracked-activity defaults ----------
# Default values for `{ type: "activity" }` sections that don't set anything
# inline. Override per-section from config/site.ts when needed.
# Substring matched against an activity's Bungie display name. Catches every
# difficulty / reissue (Normal, Master, Challenge weeks). Set to "*" or
# leave blank for wildcard mode (any raid or dungeon, total = lifetime).
TRACKED_ACTIVITY_NAME=*
# "raid" or "dungeon", auto-detected per-section from the matched activity
# when set inline, so this is really just the wildcard mode picker.
TRACKED_ACTIVITY_MODE=raid
# Card title; defaults to "Recent <name>" / "Recent Raids" / "Recent Dungeons".
TRACKED_ACTIVITY_TITLE=Recent Raid Clears
# ---------- Link pills ----------
# Hide the auto-generated Join Code / Raid Report / Last.fm defaults.
DISABLE_DEFAULT_LINKS=true
# Extra link pills, indexed. Up to 20 (EXTRA_LINK_1 .. EXTRA_LINK_20).
# Format: "Label|value". Numbering gaps are fine.
# - Normal pill (anchor): EXTRA_LINK_1=Steam|https://steamcommunity.com/id/me
# - Discord pill (copy): EXTRA_LINK_3=Discord|myusername
# Special case: a pill labelled "Discord" (any case) does not open a URL,
# clicking copies the value to the clipboard. Use for handles, not invites.
EXTRA_LINK_1=Steam|https://steamcommunity.com/id/yourname/
EXTRA_LINK_2=Spotify|https://open.spotify.com/user/yourid
EXTRA_LINK_3=Discord|yournameThis file is the entire page layout, as a single TypeScript array. Sections
render top-to-bottom; row packs two children side by side. Everything is
hot-reloaded by next dev, save the file and the page updates.
// Site layout configuration.
//
// This is the single file you edit to add/remove/reorder sections on the
// page. Sections render top-to-bottom; use `row` to put two cards side by
// side. The shape of each section is documented in `lib/sections.ts`.
import type { SectionDef } from "@/lib/sections";
export const sections: SectionDef[] = [
{ type: "hero", account: 1 },
{ type: "links" },
{
type: "row",
columns: [
{ type: "activity" },
{ type: "nowPlaying" },
],
},
];The shape of each section is enforced by SectionDef in lib/sections.ts,
so your editor autocompletes the available fields.
| Type | Fields | Renders |
|---|---|---|
hero |
account?, displayName? |
Big card with the equipped emblem as background. Account-aware. With 2+ heroes anywhere in the config, the name itself becomes a click-to-copy target for each card's Bungie tag. |
links |
, | Row of pill links. Defaults (Join Code / Raid Report / Last.fm) follow the first hero's account. Extras come from EXTRA_LINK_* env vars. The Join Code pill disappears in multi-hero mode (each hero has its own click-to-copy name instead). |
activity |
name?, mode?, title?, account? |
Dot-chart strip of 10 most recent clears + lifetime count. name is a substring match (catches every difficulty/reissue). mode auto-detects from the matched activity, so dungeons "just work". account indexes into BUNGIE_NAME_<n>. |
nowPlaying |
, | Last.fm card showing the currently-playing or most-recent track. Live indicator pulses red when actively scrobbling. |
projects |
title?, items[] |
Grid of project cards. Each item: { title, description?, href?, tags?, icon? }. Cards are clickable if href is set. |
text |
title?, body |
Free-form text card. Useful for an "About" blurb or attribution. |
row |
columns[] |
Two-column wrapper. Stacks to one column under 620px. Children can be any other section type. |
Wildcard activity mode. Setting name: "" or name: "*" (or the env var
to the same) turns an activity section into a "10 most recent raid/dungeon
clears of any kind" card. The CLEARS count becomes lifetime total for that
mode, including clears on deleted characters.
Multiple accounts. Each activity and hero section accepts an
account: <n> field that points at BUNGIE_NAME_<n> / BUNGIE_CODE_<n> in
env. Unlimited accounts, just keep adding numbered pairs. Examples:
{ type: "hero" }, // account 1
{ type: "hero", account: 2 }, // account 2, second hero
{ type: "activity", name: "Vault of Glass", account: 2 }, // account 2's VoGEach dot in an activity strip can carry up to two markers, surfaced both visually and in the hover tooltip:
| Marker | Rule | Look |
|---|---|---|
| ★ (gold star) | Raid: ≤4 players. Dungeon: solo only. | Gold star at the dot's top-right. |
| Glow | Flawless (0 deaths) OR solo dungeon. | Soft gold halo around the dot. |
| Color | Master difficulty | Dot becomes purple instead of its mode color (green for raids, blue for dungeons). |
Fireteam labels in the tooltip use friendly names: Solo / Duo / Trio / Quad for 1–4 players; 5–6 player full-team raid clears show no label.
Dots also link out: raid dots go to raid.report/pgcr/{id}, dungeon dots
to dungeon.report/pgcr/{id}.
dotreport/
├── .env.example # documents every env var
├── .env.local # ← YOU CREATE THIS (from .env.example)
├── config/
│ └── site.ts # ← THE LAYOUT FILE you edit
├── lib/
│ ├── account.ts # account resolution + tag formatting helpers
│ ├── bungie.ts # Bungie API client (server-only)
│ ├── clipboard.ts # client-side copy helper
│ ├── lastfm.ts # Last.fm API client (server-only)
│ └── sections.ts # SectionDef types, add new section kinds here
├── app/
│ ├── layout.tsx # <title>, favicon (driven by first hero's account)
│ ├── page.tsx # thin shell, renders config/site.ts sections
│ ├── globals.css # all styling
│ ├── api/
│ │ ├── destiny/route.ts # /api/destiny, JSON of profile + recent VoG
│ │ └── nowplaying/route.ts # /api/nowplaying, JSON of current track
│ └── components/
│ ├── Activities.tsx # activity strip server component
│ ├── ClearsStrip.tsx # the dot chart itself
│ ├── CopyPill.tsx # click-to-copy pill (Join Code, Discord)
│ ├── Hero.tsx # emblem + name card
│ ├── HeroName.tsx # click-to-copy hero name (multi-hero mode)
│ ├── Links.tsx # link pill row
│ ├── NowPlaying.tsx # Last.fm card
│ ├── Projects.tsx # project showcase
│ └── Section.tsx # section router, switches on SectionDef.type
└── package.json
| Goal | File |
|---|---|
| Add your credentials / accounts | .env.local |
| Reorder, add, or remove sections | config/site.ts |
| Add a new project to the showcase | config/site.ts (the projects.items array) |
| Change the favicon source | app/layout.tsx, generateMetadata |
| Style tweaks (colors, sizes) | app/globals.css |
| Add a brand-new section type | lib/sections.ts + app/components/Section.tsx + your new component |
| Adjust low-man / flawless thresholds | app/components/ClearsStrip.tsx (shouldShowStar, shouldGlow) |
| Change deep-pagination depth | lib/bungie.ts, getRecentClearsForActivity (maxPagesPerCharacter) |
In config/site.ts, push a new entry into the existing projects.items:
{
type: "projects",
title: "Projects",
items: [
{
icon: "<3",
title: "title",
description: "explain your project in small words.",
href: "<link your project here>",
tags: ["<add tags here>"],
},
// ...others
],
},Every field except title is optional. Without href, the card renders
non-clickable (no arrow). Without description, only the title + tags show.
{
type: "row",
columns: [
{ type: "activity", name: "King's Fall", title: "KF" },
{ type: "activity", name: "Salvation's Edge", title: "SE" },
],
},Mode auto-detects from the matched activity's definitions, so dungeons just
work, no mode: "dungeon" needed.
.env.local:
BUNGIE_NAME_2="2B"
BUNGIE_CODE_2=0224
DISPLAY_NAME_2="2B" # optionalconfig/site.ts:
{ type: "hero", account: 2, displayName: "2B" }, // alt hero
{ type: "activity", name: "Vault of Glass", account: 2, title: "VoG (2B)" },With both { type: "hero" } (account 1) and { type: "hero", account: 2 }
in the config, the page enters multi-hero mode, each hero name becomes
click-to-copy, and the global Join Code pill disappears.
Three small edits:
-
lib/sections.ts, add a variant to theSectionDefunion:export type SectionDef = | ...existing... | { type: "twitch"; channel: string };
-
app/components/Section.tsx, add acasefor the new type:case "twitch": return <TwitchEmbed channel={def.channel} />;
-
Your new component, write it in
app/components/, export it, import it intoSection.tsx. Server or client component, your call.
The type system enforces that you provide the right fields in config/site.ts.
# Vercel (recommended)
# 1. Push to GitHub.
# 2. Import the repo on vercel.com.
# 3. Paste your .env.local vars into Project Settings → Environment Variables.
# 4. Deploy. The free Hobby tier handles this easily.Any Node-capable host works (Cloudflare Pages, Render, self-hosted, etc.), nothing platform-specific here.
- Per-activity clears on deleted characters aren't available. Bungie's
AggregateActivityStatsendpoint (which gives us per-raid / per-dungeon counts) only works for active characters. If you cleared VoG on a character you've since deleted, that clear is invisible. The mode-level wildcard total does include deleted-character clears, via Bungie'smergedDeletedCharactersbucket. - "Flawless" means zero deaths for your character, not necessarily a true team-flawless raid clear. The activity history API gives per-character values; team-flawless would require a PGCR lookup per instance.
- Last.fm "now playing" is whatever the API returns last, there's a small lag (~30s, matching the page cache).
- Bungie's Cloudflare front occasionally drops new connections under heavy concurrency. The Bungie client retries once on connect-timeout, so this should be invisible most of the time.
- raid.report / dungeon.report for the dot-chart concept and PGCR routing.
- Bungie.net API and Last.fm API.