This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A single-file static HTML dashboard that talks directly to the GitHub API from the browser using a Personal Access Token stored in localStorage. There is no backend, no build step, and no test suite. The entire app — markup, styles, and logic — lives in index.html. docker-compose.yml runs nginx:alpine that bind-mounts index.html read-only and publishes it on the host port from .env (PORT).
docker compose up -d # start nginx on http://localhost:${PORT}
docker compose down # stop
docker logs github-dashboard # nginx access/error log
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:30200/ # smoke testThere is no build, no lint, no tests. Edits to index.html are live — refresh the browser. Bind-mount quirk: the Write tool replaces the file via rename, which briefly causes nginx to return 403 for ~1 second while the inode swaps. Not a bug; retry the curl/refresh.
- Tailwind via the Play CDN (
<script src="https://cdn.tailwindcss.com">) with a custom theme (palette + Inter font +shimmer/modalInkeyframes) declared in an inlinetailwind.config = {…}script. - Reusable UI primitives live in a
<style type="text/tailwindcss">block under@layer components—.btn,.btn-primary,.icon-btn,.badge/.badge-{green,red,yellow},.card-head,.item,.org-row,.input-field,.field-hint,.empty-state,.error-state,.skeleton-bar,.external-icon. Pick the right component class first; only inline utilities for one-offs. - Tabler Icons (
@tabler/icons-webfont) provides the icon set as<i class="ti ti-*">. - Display-toggling overlays (
#search-modal,#settings-popover,#layout,#onboarding) use a plain-CSS.openclass pattern in a separate<style>block. Tailwind'shiddenutility doesn't compose cleanly withflex/griddefaults here, so don't try to swap to it.
Two-pane layout, one source of truth.
- Sidebar: list of "owners" (personal account + GitHub organizations).
- Detail pane: the selected owner's Repositories and Active Pull Requests, side by side.
- Personal account is a synthetic first row in
state.orgs(isPersonal: true); its repos come fromviewer.ownedRepos, everything else is rendered identically to a real org.
One bulk GraphQL query loads everything. loadAllData(token) issues a single request that returns:
viewer { login, name, avatarUrl, bio, personalRepoCount.totalCount }viewer.ownedRepos— first 100 personal repos with cursorviewer.organizations.nodes[]— each with login/name/description/avatar AND its ownrepositories(first: 100, …)connection (totalCount + nodes + cursor)authorPRs,assigneePRs,reviewerPRs— three aliased globalsearchfields forauthor:@me,assignee:@me,user-review-requested:@me(no org scope)
Repos go into state.cache[login].repos from the connection they came from; PRs are bucketed by repository.nameWithOwner.split("/")[0] and merged into a Map keyed on URL, with relations: ["author", "assignee", "reviewer"] populated from which alias(es) the PR appeared in (drives the colored badges).
Per-org connections are the source of truth for repos. viewer.repositories(affiliations: ORGANIZATION_MEMBER) was tried as a single flat list; it silently dropped some orgs' repos under fine-grained-permission / SAML setups. Reverted — query each org's repositories directly.
Pagination is per-org and lazy. state.orgs[i]._hasMore and _cursor are populated from each connection's pageInfo. After the initial render, paginateOrgRepos(token, pendingOrgs) walks only the orgs that need more (sequentially), each via its own follow-up query. state.loadingMore flips to false when done and surfaces as "loading more repos…" in the search modal header. For the typical case (every owner has ≤100 repos), there is exactly one GraphQL query for the whole dashboard.
Don't combine the three PR searches. (author:@me OR assignee:@me OR user-review-requested:@me) looks like it should work but GitHub treats OR between qualifiers as plain text — the combined query returns nothing. Tried; reverted. Three aliases in one GraphQL request is the working compromise.
user-review-requested is intentional. review-requested:@me would also match PRs where a team the user belongs to was requested. The product rule is "explicitly mine, not via my groups" — don't loosen it.
selectOrg(login) is a pure UI switch. No fetching — all data is in state.cache upfront. Refresh re-runs loadAllData and bumps state.gen; in-flight pagination workers check state.gen !== myGen between awaits and bail, so a stale loader can't overwrite a freshly-emptied cache.
Search lives in a modal, not the detail pane. The topbar's "Search…" element is a <button id="search-trigger"> that opens #search-modal (its own <input id="modal-input"> + two stacked sections for Repositories and PRs). The detail pane never enters a search mode — it always shows the selected owner. / opens the modal, Esc closes and clears (also closes the settings popover), backdrop click closes, R triggers refreshCurrent() (re-runs reload()). Shortcuts are suppressed while focus is in an <input>. Each result row has a small org avatar prefix so the source is obvious. Don't reintroduce the old "search overrides detail pane" branch.
state.orgs— sidebar list, personal at index 0. Each entry carries_hasMore/_cursorfor repo pagination.state.selected— current owner login (persisted asgh_selected_org).state.cache[login]—{ loading, repos, prs, reposError?, prsError? }. The renderer still checkscache.loadingandcache.{repos,prs}Errordefensively, butloadAllDatapopulates buckets synchronously, so those branches are effectively dead in the happy path.state.gen— generation counter; incremented at the top ofreload()to cancel in-flight workers.state.loadingMore— true whilepaginateOrgReposis running.state.viewerLogin— captured duringloadAllData(kept around but no longer load-bearing since relations come from the alias the PR matched, not field comparison).- PAT lives in
localStorageundergh_token, read fresh on each request.