Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# CLAUDE.md

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

## What this is

A personal command center for GitHub-starred repos: review pipeline, star-list filing, deployment tracking,
notes, org following, and discovery. It's a pure client-side React app — **no backend, no server-side
storage**. It talks directly to `api.github.com` using a personal access token the user pastes in; the token
lives in browser `localStorage`, and any app-specific data that isn't a native GitHub object (review stage,
deployment record, note) is stored as JSON in a private gist on the user's own account (see "GSM:STATE" below).

## Commands

```sh
npm run dev # Vite dev server
npm run build # tsc -b (type-check) then vite build
npm run preview # preview a production build locally
npm run lint # oxlint
npm test # vitest run (single pass)
npm run test:watch # vitest watch mode
npx vitest run path/to/file.test.ts # run a single test file
npx vitest run -t "test name" # run tests matching a name
npm run format # prettier --write .
npm run format:check # prettier --check .
```

CI (`.github/workflows/ci.yml`) runs, in order: `lint` → `format:check` → `test` → `build`. Match that order
locally before considering work done. There's also CodeQL analysis and gitleaks secret scanning as separate
workflows, and deploy is via Azure Static Web Apps.

## Architecture

### Two data domains, kept deliberately separate

- **Real GitHub data** (stars, star lists, follows, issues, orgs) — fetched live from GitHub's REST and
GraphQL APIs, cached in Zustand stores, never round-tripped through app-specific storage.
- **App-specific data** (review stage, deployment record, freeform note per repo) — has no home in GitHub's
data model, so it's persisted as a single JSON file (`gsm-state.json`, description `GSM:STATE`) inside a
private gist on the user's own account. This is what makes state (tags, deployments, notes) sync across
devices without a backend. See `src/types/gsmState.ts`, `src/api/gistApi.ts`, `src/store/gsmStateStore.ts`.

When adding a new piece of app-specific per-repo data, add a field to `RepoState` in `src/types/gsmState.ts`
(or to `GsmStateFile` directly if it's global, not per-repo) rather than inventing a new storage mechanism.

### GSM:STATE schema versioning

`GsmStateFile.schemaVersion` is checked against `CURRENT_SCHEMA_VERSION` (`src/types/gsmState.ts`) every time
the gist is loaded (`findOrCreateGsmStateGist` in `src/api/gistApi.ts`). If a gist is behind, it's run through
`migrateGsmState` (`src/api/gsmStateMigrations.ts`) — a chain of `MigrationStep`s, each describing how to get
from one version to the next — then the pre-migration content is snapshotted into its own file in the same
gist (`gsm-state.snapshot-v<old-version>.json`) before the live file is rewritten. **Any change to
`GsmStateFile`'s shape must**: bump `CURRENT_SCHEMA_VERSION`, and add a corresponding step to `MIGRATIONS`. A
version this build doesn't recognize (e.g. written by a newer build) is left untouched rather than guessed at.

### Multi-account support

`authStore` keys accounts by GitHub user id (`accounts: Record<string, StoredAccount>`) and tracks
`currentUserId` separately, so multiple GitHub accounts can be signed into the same browser. Every other
store that holds per-account data follows the same `Record<userId, ...>` shape (`starsStore.dataByUser`,
`gsmStateStore.byUser`) and every store action takes `(userId, token, ...)` explicitly rather than reading a
single "current" token — don't reintroduce an implicit-current-account assumption into new store code.

### Store conventions

Each Zustand store in `src/store/` owns one domain and is `persist`-ed to `localStorage` independently
(`auth`, `starred repos`, `GSM state`, `organizations`, `trending`, `issues`, `toasts`). Mutating actions
follow the same pattern throughout: snapshot `before`, apply an optimistic update via `set`, perform the
network call, and on failure roll back to `before` while setting `error`. Follow this pattern for new
mutations rather than awaiting-then-setting, so the UI stays responsive and consistent on failure.

### API layer

`src/api/` is thin and stateless — no React, no store imports (except the stores importing from here, never
the reverse). `src/api/githubClient.ts` is the only place raw `fetch` calls to `api.github.com` happen
(`githubRest`, `githubGraphql`, both throwing `GithubApiError` with a `status` on failure); everything else in
`src/api/` builds on those two functions. `src/api/pagination.ts` handles cursor-paginated GraphQL fetches
(e.g. all starred repos, which REST can't get list-membership for in one call — that's why starred repos go
through GraphQL while most other single-repo operations use REST).

### Data-driven filters

`src/config/filterDefinitions.ts` defines `FILTER_DEFINITIONS`, an array where each entry bundles its UI
control, predicate, URL (de)serializer, and pill-label logic in one place (built through the `defineFilter`
generic helper, which exists solely to keep each definition's own value type checked at its definition site).
Adding a new repo filter means adding one entry here, not touching the filter bar component. Filtering itself
happens in `src/selectors/filterRepos.ts` over this array.

### Routing

`src/App.tsx` wires up routes with `react-router-dom`. `HomePage` is eager (it's the landing page right after
auth); every other route is `lazy`-loaded. `AuthGate` redirects to `/login` when `authStore.currentAccount()`
is null, and wraps authenticated routes in the `Sidebar` layout.

### Directory map (see README.md "Project structure" for the authoritative version)

- `src/api` — GitHub REST/GraphQL wrappers, no state, no React.
- `src/store` — Zustand stores, one per domain, each owning its own fetch/optimistic-update/rollback logic.
- `src/hooks` — cross-cutting React hooks (data loading, URL-synced filters, infinite scroll, etc.).
- `src/selectors` — pure functions over domain data (filtering, sorting, deployment drift calculation).
- `src/config` — declarative/data-driven definitions (filter fields, quick-filter cards, review-stage labels).
- `src/components` — grouped by area (`repos/`, `orgs/`, `deployments/`, `notes/`, `issues/`, `shared/`).
- `src/routes` — one component per top-level page.

## Testing notes

Tests are colocated as `*.test.ts` next to the module under test, using Vitest. Store tests mock
`zustand/middleware`'s `persist` to strip out `localStorage` (unavailable in the Vitest `node` environment)
while still capturing options like `merge` so that logic can be tested directly — see
`src/store/gsmStateStore.test.ts` for the pattern if you need to test a new store.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ purpose, track what's deployed where, keep markdown notes, and discover trending
[![CodeQL](https://github.com/Mornante/GithubStarsManager/actions/workflows/codeql.yml/badge.svg)](https://github.com/Mornante/GithubStarsManager/actions/workflows/codeql.yml)
[![Secret scanning](https://github.com/Mornante/GithubStarsManager/actions/workflows/secret-scan.yml/badge.svg)](https://github.com/Mornante/GithubStarsManager/actions/workflows/secret-scan.yml)

**[Try the live demo →](https://red-moss-07b3d1c03.7.azurestaticapps.net/)** — no install required, just paste a
personal access token to connect your own starred repos.

Runs entirely in the browser: you connect with a personal access token, and the app talks directly to
`api.github.com`. There's no backend and no server-side storage — your token stays in your browser's local storage,
and cross-device state (review stage tags, deployment records, notes) is stored as JSON in a private gist on your
Expand Down
9 changes: 9 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const OrganizationsPage = lazy(() => import('./routes/OrganizationsPage'))
const DeploymentsPage = lazy(() => import('./routes/DeploymentsPage'))
const NotesPage = lazy(() => import('./routes/NotesPage'))
const IssuesPage = lazy(() => import('./routes/IssuesPage'))
const RecentItemsPage = lazy(() => import('./routes/RecentItemsPage'))

function AuthGate({ children }: { children: React.ReactNode }) {
const account = useAuthStore((s) => s.currentAccount())
Expand Down Expand Up @@ -99,6 +100,14 @@ export default function App() {
</AuthGate>
}
/>
<Route
path="/recent"
element={
<AuthGate>
<RecentItemsPage />
</AuthGate>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
Expand Down
13 changes: 13 additions & 0 deletions src/api/gsmStateMigrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ describe('migrateGsmState', () => {
expect(result.state.meta.app).toBe('GitHub Stars Manager')
})

it('backfills recentItems as an empty array for a gist written before that field existed', () => {
// A 1.0.0 gist predating recentItems has no such key at all — this is the
// purely-additive-field case, so no schemaVersion bump/migration step was
// needed; mergeWithDefaults alone must supply the empty default.
const result = migrateGsmState({
schemaVersion: CURRENT_SCHEMA_VERSION,
repos: { r1: { reviewTag: 'REVIEWED' } },
})

expect(result.migratedFrom).toBeNull()
expect(result.state.recentItems).toEqual([])
})

it('passes through unchanged when the gist is at a version this build has no migration path from', () => {
const fromTheFuture = { schemaVersion: '9.9.9', repos: { r1: { reviewTag: 'REVIEWED' } } }

Expand Down
35 changes: 34 additions & 1 deletion src/components/layout/AccountMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { useAuthStore } from '../../store/authStore'
import { useGsmUserState } from '../../store/gsmStateStore'
import { useTheme } from '../../hooks/useTheme'
import { useDismissableOpen } from '../../hooks/useDismissableOpen'
import { OcticonChevronUpDown, OcticonMoon, OcticonSignOut, OcticonSun } from '../icons/Octicons'
import {
OcticonChevronUpDown,
OcticonMoon,
OcticonNote,
OcticonSignOut,
OcticonStar,
OcticonSun,
} from '../icons/Octicons'

export default function AccountMenu({ collapsed = false }: { collapsed?: boolean }) {
const { open, setOpen, ref } = useDismissableOpen<HTMLDivElement>()
const account = useAuthStore((s) => s.currentAccount())
const logout = useAuthStore((s) => s.logout)
const { theme, toggleTheme } = useTheme()
const { gistId } = useGsmUserState(account?.user.id ?? null)

if (!account) return null

Expand Down Expand Up @@ -42,6 +51,30 @@ export default function AccountMenu({ collapsed = false }: { collapsed?: boolean
Switch to {theme === 'dark' ? 'light' : 'dark'} theme
</button>

<a
href="https://github.com/Mornante/GithubStarsManager"
target="_blank"
rel="noreferrer"
role="menuitem"
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-canvas-subtle"
>
<OcticonStar className="text-attention-fg" />
Star on GitHub
</a>

{gistId && (
<a
href={`https://gist.github.com/${gistId}`}
target="_blank"
rel="noreferrer"
role="menuitem"
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-canvas-subtle"
>
<OcticonNote />
View your Gist
</a>
)}

<div className="border-t border-border-default" />

<button
Expand Down
45 changes: 45 additions & 0 deletions src/components/layout/RecentItemsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Link } from 'react-router-dom'
import { useRecentItems } from '../../hooks/useRecentItems'
import { RECENT_ITEMS_NAV_LIMIT } from '../../types/gsmState'
import { OcticonChevronDown } from '../icons/Octicons'
import RecentItemRow from '../shared/RecentItemRow'

export default function RecentItemsSection({ collapsed = false }: { collapsed?: boolean }) {
const { items } = useRecentItems()
const visible = items.slice(0, RECENT_ITEMS_NAV_LIMIT)

return (
<div className="p-2 border-t border-border-default space-y-1">
{!collapsed && (
<div className="px-3 pt-1 pb-1 text-xs font-semibold uppercase tracking-wide text-fg">
Recent Activity
</div>
)}

{visible.length === 0 ? (
!collapsed && (
<p className="px-3 pb-1 text-xs text-fg-muted">
Repositories, Organizatiions, deployments, notes, and issues you open will show up here.
</p>
)
) : (
<>
{visible.map((item) => (
<RecentItemRow key={item.id} item={item} collapsed={collapsed} />
))}

<Link
to="/recent"
title={collapsed ? 'View more' : undefined}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-fg-muted hover:text-fg-default hover:bg-canvas-subtle ${
collapsed ? 'justify-center' : ''
}`}
>
<OcticonChevronDown className="-rotate-90" />
{!collapsed && 'View more'}
</Link>
</>
)}
</div>
)
}
4 changes: 4 additions & 0 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
OcticonSearch,
} from '../icons/Octicons'
import AccountMenu from './AccountMenu'
import RecentItemsSection from './RecentItemsSection'

const NAV_ITEMS = [
{ to: '/', end: true, label: 'Home', icon: OcticonHome },
Expand Down Expand Up @@ -65,6 +66,8 @@ export default function Sidebar() {
))}
</nav>

<RecentItemsSection collapsed={collapsed} />

<div className="p-2 border-t border-border-default space-y-1">
<button
type="button"
Expand All @@ -77,6 +80,7 @@ export default function Sidebar() {
<OcticonChevronDown className={collapsed ? '-rotate-90' : 'rotate-90'} />
{!collapsed && 'Collapse'}
</button>

<AccountMenu collapsed={collapsed} />
</div>
</aside>
Expand Down
12 changes: 12 additions & 0 deletions src/components/orgs/OrgDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Organization, Repo, StarList } from '../../types/github'
import { useAuthStore } from '../../store/authStore'
import { useOrgsStore } from '../../store/orgsStore'
import { getOrgProfileReadme } from '../../api/rest'
import { useRecentItems } from '../../hooks/useRecentItems'
import { OcticonLinkExternal, OcticonPeople, OcticonRepo, OcticonStar } from '../icons/Octicons'
import ReadmePreview from '../repos/ReadmePreview'
import RepoDetailModal from '../repos/RepoDetailModal'
Expand Down Expand Up @@ -39,6 +40,17 @@ export default function OrgDetailModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account, org.login])

const { recordVisit } = useRecentItems()
useEffect(() => {
recordVisit({
id: `org:${org.login.toLowerCase()}`,
type: 'org',
name: org.login,
href: `/organizations?org=${encodeURIComponent(org.login)}`,
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- record once per opened org, not on every recordVisit identity change
}, [org.login])

async function handleFollow() {
if (!account) return
setFollowing(true)
Expand Down
12 changes: 12 additions & 0 deletions src/components/repos/DeploymentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useGsmStateStore, useGsmUserState } from '../../store/gsmStateStore'
import { useRepoDeploymentInfo } from '../../hooks/useRepoDeploymentInfo'
import { useRecentItems } from '../../hooks/useRecentItems'
import { computeDeploymentDrift } from '../../selectors/deploymentDrift'
import type { DeploymentRecord } from '../../types/gsmState'
import type { Repo } from '../../types/github'
Expand Down Expand Up @@ -30,6 +31,17 @@ export default function DeploymentModal({ repo, onClose }: { repo: Repo; onClose

const { info, loading: loadingInfo } = useRepoDeploymentInfo(account?.token, repo.owner.login, repo.name)

const { recordVisit } = useRecentItems()
useEffect(() => {
recordVisit({
id: `deployment:${repo.nameWithOwner.toLowerCase()}`,
type: 'deployment',
name: repo.nameWithOwner,
href: `/deployments?repo=${encodeURIComponent(repo.nameWithOwner)}`,
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- record once per opened repo, not on every recordVisit identity change
}, [repo.nameWithOwner])

useEffect(() => {
// Only suggest a starting ref the first time info arrives for a repo that
// isn't deployed yet — not on every render, and not once a record exists
Expand Down
12 changes: 12 additions & 0 deletions src/components/repos/NoteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useGsmStateStore, useGsmUserState } from '../../store/gsmStateStore'
import { getReadme } from '../../api/rest'
import { useRecentItems } from '../../hooks/useRecentItems'
import type { Repo } from '../../types/github'
import MarkdownContent from '../shared/MarkdownContent'
import ConfirmDialog from '../shared/ConfirmDialog'
Expand Down Expand Up @@ -30,6 +31,17 @@ export default function NoteModal({ repo, onClose }: { repo: Repo; onClose: () =
if (tab === 'edit') textareaRef.current?.focus()
}, [tab])

const { recordVisit } = useRecentItems()
useEffect(() => {
recordVisit({
id: `note:${repo.nameWithOwner.toLowerCase()}`,
type: 'note',
name: repo.nameWithOwner,
href: `/notes?repo=${encodeURIComponent(repo.nameWithOwner)}`,
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- record once per opened repo, not on every recordVisit identity change
}, [repo.nameWithOwner])

function handleEditClick() {
setDraft(record?.content ?? '')
setTab('edit')
Expand Down
14 changes: 13 additions & 1 deletion src/components/repos/RepoDetailModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import type { Repo, StarList } from '../../types/github'
import { useAuthStore } from '../../store/authStore'
import { useStarsStore } from '../../store/starsStore'
import { useAsync } from '../../hooks/useAsync'
import { useRecentItems } from '../../hooks/useRecentItems'
import { getReadme, getRepoDetails } from '../../api/rest'
import { OcticonFork, OcticonStar } from '../icons/Octicons'
import ReadmePreview from './ReadmePreview'
Expand Down Expand Up @@ -52,6 +53,17 @@ export default function RepoDetailModal({
const repo = storeRepo ?? fetchedRepo
const isStarred = Boolean(storeRepo)

const { recordVisit } = useRecentItems()
useEffect(() => {
recordVisit({
id: `repo:${nameWithOwner.toLowerCase()}`,
type: 'repo',
name: nameWithOwner,
href: `/repos?repo=${encodeURIComponent(nameWithOwner)}`,
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- record once per opened repo, not on every recordVisit identity change
}, [nameWithOwner])

async function handleStar() {
if (!account) return
setStarring(true)
Expand Down
Loading
Loading