Skip to content

Commit 5958018

Browse files
authored
feat(ui): sorts repos by activity and displays push timestamps in selector (#5)
* feat(ui): sorts repos by activity and displays push timestamps in selector * fix: adds NaN guard to relativeTime, defers sort until all orgs loaded * fix(ui): uses loadedCount guard instead of loading flag for sort deferral * fix: addresses PR review findings across sort, perf, and tests * perf: inlines allVisibleInOrgSelected to use visible memo
1 parent f75aeaf commit 5958018

6 files changed

Lines changed: 262 additions & 48 deletions

File tree

src/app/components/onboarding/RepoSelector.tsx

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import {
66
Show,
77
Index,
88
} from "solid-js";
9-
import { fetchOrgs, fetchRepos, OrgEntry, RepoRef } from "../../services/api";
9+
import { fetchOrgs, fetchRepos, OrgEntry, RepoRef, RepoEntry } from "../../services/api";
1010
import { getClient } from "../../services/github";
11+
import { relativeTime } from "../../lib/format";
1112
import LoadingSpinner from "../shared/LoadingSpinner";
1213
import FilterInput from "../shared/FilterInput";
1314

@@ -20,7 +21,7 @@ interface RepoSelectorProps {
2021
interface OrgRepoState {
2122
org: string;
2223
type: "org" | "user";
23-
repos: RepoRef[];
24+
repos: RepoEntry[];
2425
loading: boolean;
2526
error: string | null;
2627
}
@@ -146,23 +147,46 @@ export default function RepoSelector(props: RepoSelectorProps) {
146147
new Set(props.selected.map((r) => r.fullName))
147148
);
148149

150+
const sortedOrgStates = createMemo(() => {
151+
const states = orgStates();
152+
// Defer sorting during initial load to prevent layout shift as orgs trickle in.
153+
// After initial load (all orgs resolved), sorting stays active during retries
154+
// because loadedCount is not reset by retryOrg.
155+
if (loadedCount() < props.selectedOrgs.length) return states;
156+
const maxPushedAt = new Map(
157+
states.map((s) => [
158+
s.org,
159+
s.repos.reduce((max, r) => r.pushedAt && r.pushedAt > max ? r.pushedAt : max, ""),
160+
])
161+
);
162+
return [...states].sort((a, b) => {
163+
const aMax = maxPushedAt.get(a.org) ?? "";
164+
const bMax = maxPushedAt.get(b.org) ?? "";
165+
return aMax > bMax ? -1 : aMax < bMax ? 1 : 0;
166+
});
167+
});
168+
169+
function toRepoRef(entry: RepoEntry): RepoRef {
170+
return { owner: entry.owner, name: entry.name, fullName: entry.fullName };
171+
}
172+
149173
function isSelected(fullName: string) {
150174
return selectedSet().has(fullName);
151175
}
152176

153-
function toggleRepo(repo: RepoRef) {
177+
function toggleRepo(repo: RepoEntry) {
154178
if (isSelected(repo.fullName)) {
155179
props.onChange(props.selected.filter((r) => r.fullName !== repo.fullName));
156180
} else {
157-
props.onChange([...props.selected, repo]);
181+
props.onChange([...props.selected, toRepoRef(repo)]);
158182
}
159183
}
160184

161185
// ── Filtering ──────────────────────────────────────────────────────────────
162186

163187
const q = () => filter().toLowerCase().trim();
164188

165-
function filteredReposForOrg(state: OrgRepoState): RepoRef[] {
189+
function filteredReposForOrg(state: OrgRepoState): RepoEntry[] {
166190
const query = q();
167191
if (!query) return state.repos;
168192
return state.repos.filter(
@@ -177,7 +201,7 @@ export default function RepoSelector(props: RepoSelectorProps) {
177201
function selectAllInOrg(state: OrgRepoState) {
178202
const visible = filteredReposForOrg(state);
179203
const current = new Map(props.selected.map((r) => [r.fullName, r]));
180-
for (const repo of visible) current.set(repo.fullName, repo);
204+
for (const repo of visible) current.set(repo.fullName, toRepoRef(repo));
181205
props.onChange([...current.values()]);
182206
}
183207

@@ -186,18 +210,13 @@ export default function RepoSelector(props: RepoSelectorProps) {
186210
props.onChange(props.selected.filter((r) => !visible.has(r.fullName)));
187211
}
188212

189-
function allVisibleInOrgSelected(state: OrgRepoState): boolean {
190-
const visible = filteredReposForOrg(state);
191-
return visible.length > 0 && visible.every((r) => isSelected(r.fullName));
192-
}
193-
194213
// ── Global select/deselect all ────────────────────────────────────────────
195214

196215
function selectAll() {
197216
const current = new Map(props.selected.map((r) => [r.fullName, r]));
198217
for (const state of orgStates()) {
199218
for (const repo of filteredReposForOrg(state)) {
200-
current.set(repo.fullName, repo);
219+
current.set(repo.fullName, toRepoRef(repo));
201220
}
202221
}
203222
props.onChange([...current.values()]);
@@ -252,9 +271,9 @@ export default function RepoSelector(props: RepoSelectorProps) {
252271
</Show>
253272

254273
{/* Per-org repo lists */}
255-
<For each={orgStates()}>
274+
<For each={sortedOrgStates()}>
256275
{(state) => {
257-
const visible = () => filteredReposForOrg(state);
276+
const visible = createMemo(() => filteredReposForOrg(state));
258277

259278
return (
260279
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
@@ -269,8 +288,8 @@ export default function RepoSelector(props: RepoSelectorProps) {
269288
type="button"
270289
onClick={() => selectAllInOrg(state)}
271290
disabled={
272-
allVisibleInOrgSelected(state) ||
273-
visible().length === 0
291+
visible().length === 0 ||
292+
visible().every((r) => isSelected(r.fullName))
274293
}
275294
class="text-xs text-blue-600 hover:underline disabled:cursor-not-allowed disabled:opacity-40 dark:text-blue-400"
276295
>
@@ -341,9 +360,14 @@ export default function RepoSelector(props: RepoSelectorProps) {
341360
/>
342361
<div class="min-w-0 flex-1">
343362
<div class="flex items-center gap-2">
344-
<span class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
363+
<span class="min-w-0 truncate text-sm font-medium text-gray-900 dark:text-gray-100">
345364
{repo().name}
346365
</span>
366+
<Show when={repo().pushedAt}>
367+
<span class="ml-auto shrink-0 text-xs text-gray-500 dark:text-gray-400">
368+
{relativeTime(repo().pushedAt!)}
369+
</span>
370+
</Show>
347371
</div>
348372
</div>
349373
</label>

src/app/lib/format.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
66
*/
77
export function relativeTime(isoString: string): string {
88
const diffMs = Date.now() - new Date(isoString).getTime();
9+
if (isNaN(diffMs)) return "";
910
const diffSec = Math.floor(diffMs / 1000);
1011

1112
if (diffSec < 60) return rtf.format(-diffSec, "second");

src/app/services/api.ts

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export interface RepoRef {
1616
fullName: string;
1717
}
1818

19+
export interface RepoEntry extends RepoRef {
20+
pushedAt: string | null;
21+
}
22+
1923
export interface Issue {
2024
id: number;
2125
number: number;
@@ -110,6 +114,7 @@ interface RawRepo {
110114
owner: { login: string };
111115
name: string;
112116
full_name: string;
117+
pushed_at: string | null;
113118
}
114119

115120
interface RawPullRequest {
@@ -380,32 +385,42 @@ export async function fetchRepos(
380385
octokit: ReturnType<typeof getClient>,
381386
orgOrUser: string,
382387
type: "org" | "user"
383-
): Promise<RepoRef[]> {
388+
): Promise<RepoEntry[]> {
384389
if (!octokit) throw new Error("No GitHub client available");
385390

386-
const route =
387-
type === "org"
388-
? `GET /orgs/{org}/repos`
389-
: `GET /user/repos`;
390-
391-
const params =
392-
type === "org"
393-
? { org: orgOrUser, per_page: 100 }
394-
: { affiliation: "owner", per_page: 100 };
395-
396-
const repos: RepoRef[] = [];
391+
const repos: RepoEntry[] = [];
397392

398-
for await (const response of octokit.paginate.iterator(route, params)) {
399-
const page = response.data as RawRepo[];
393+
function collectRepos(page: RawRepo[], into: RepoEntry[]): void {
400394
for (const repo of page) {
401-
repos.push({
395+
into.push({
402396
owner: repo.owner.login,
403397
name: repo.name,
404398
fullName: repo.full_name,
399+
pushedAt: repo.pushed_at ?? null,
405400
});
406401
}
407402
}
408403

404+
if (type === "org") {
405+
for await (const response of octokit.paginate.iterator(`GET /orgs/{org}/repos`, {
406+
org: orgOrUser,
407+
per_page: 100,
408+
sort: "pushed" as const,
409+
direction: "desc" as const,
410+
})) {
411+
collectRepos(response.data as RawRepo[], repos);
412+
}
413+
} else {
414+
for await (const response of octokit.paginate.iterator(`GET /user/repos`, {
415+
affiliation: "owner",
416+
per_page: 100,
417+
sort: "pushed" as const,
418+
direction: "desc" as const,
419+
})) {
420+
collectRepos(response.data as RawRepo[], repos);
421+
}
422+
}
423+
409424
return repos;
410425
}
411426

@@ -1022,14 +1037,14 @@ export async function fetchWorkflowRuns(
10221037
runs,
10231038
latestAt: runs.reduce((max, r) => r.updated_at > max ? r.updated_at : max, ""),
10241039
}));
1025-
workflowEntries.sort((a, b) => b.latestAt > a.latestAt ? -1 : b.latestAt < a.latestAt ? 1 : 0);
1040+
workflowEntries.sort((a, b) => a.latestAt > b.latestAt ? -1 : a.latestAt < b.latestAt ? 1 : 0);
10261041
const topWorkflows = workflowEntries
10271042
.slice(0, maxWorkflows);
10281043

10291044
// Take most recent M runs per workflow
10301045
for (const { runs: workflowRuns } of topWorkflows) {
10311046
const sorted = workflowRuns.sort(
1032-
(a, b) => b.created_at > a.created_at ? -1 : b.created_at < a.created_at ? 1 : 0
1047+
(a, b) => a.created_at > b.created_at ? -1 : a.created_at < b.created_at ? 1 : 0
10331048
);
10341049
for (const run of sorted.slice(0, maxRuns)) {
10351050
allRuns.push({

0 commit comments

Comments
 (0)