Skip to content

Latest commit

 

History

History
62 lines (42 loc) · 6.38 KB

File metadata and controls

62 lines (42 loc) · 6.38 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

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).

Commands

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 test

There 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.

Styling stack

  • Tailwind via the Play CDN (<script src="https://cdn.tailwindcss.com">) with a custom theme (palette + Inter font + shimmer/modalIn keyframes) declared in an inline tailwind.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 .open class pattern in a separate <style> block. Tailwind's hidden utility doesn't compose cleanly with flex/grid defaults here, so don't try to swap to it.

Architecture

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 from viewer.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 cursor
  • viewer.organizations.nodes[] — each with login/name/description/avatar AND its own repositories(first: 100, …) connection (totalCount + nodes + cursor)
  • authorPRs, assigneePRs, reviewerPRs — three aliased global search fields for author:@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

  • state.orgs — sidebar list, personal at index 0. Each entry carries _hasMore/_cursor for repo pagination.
  • state.selected — current owner login (persisted as gh_selected_org).
  • state.cache[login]{ loading, repos, prs, reposError?, prsError? }. The renderer still checks cache.loading and cache.{repos,prs}Error defensively, but loadAllData populates buckets synchronously, so those branches are effectively dead in the happy path.
  • state.gen — generation counter; incremented at the top of reload() to cancel in-flight workers.
  • state.loadingMore — true while paginateOrgRepos is running.
  • state.viewerLogin — captured during loadAllData (kept around but no longer load-bearing since relations come from the alias the PR matched, not field comparison).
  • PAT lives in localStorage under gh_token, read fresh on each request.