diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 9a74406..c08945d 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -32,6 +32,18 @@ import { SectionHeader } from "./components/SectionHeader"; import { SettingsModal } from "./components/SettingsModal"; import { ShortcutHelp } from "./components/ShortcutHelp"; import { Skeleton } from "./components/Skeleton"; +import { SortControl } from "./components/SortControl"; +import { + NOTIFICATION_SORT_FIELDS, + PR_SORT_FIELDS, + REVIEW_SORT_FIELDS, + compareNotifications, + comparePrs, + compareReviews, + notificationSortAtom, + prSortAtom, + reviewSortAtom, +} from "./sort"; import { type Section, useAllAuthoredPrs, @@ -608,20 +620,36 @@ function Dashboard({ source }: { source: DashboardSource }) { const queryClient = useQueryClient(); const { instances, prs, recentPrs, reviews, notifications } = source; const [dismissed, setDismissed] = useAtom(dismissedReviewsAtom); + const [prSort, setPrSort] = useAtom(prSortAtom); + const [reviewSort, setReviewSort] = useAtom(reviewSortAtom); + const [notificationSort, setNotificationSort] = useAtom(notificationSortAtom); + + const sortedPrs = useMemo( + () => [...prs.data].sort((a, b) => comparePrs(a, b, prSort)), + [prs.data, prSort], + ); const filteredReviews = useMemo( () => - reviews.data.filter( - (r) => !isDismissed(dismissed, r.repo, r.number, r.updatedAt), + reviews.data + .filter((r) => !isDismissed(dismissed, r.repo, r.number, r.updatedAt)) + .sort((a, b) => compareReviews(a, b, reviewSort)), + [reviews.data, dismissed, reviewSort], + ); + + const sortedNotifications = useMemo( + () => + [...notifications.data].sort((a, b) => + compareNotifications(a, b, notificationSort), ), - [reviews.data, dismissed], + [notifications.data, notificationSort], ); const sections: Section[] = ["prs", "reviews", "notifications"]; const itemCounts = { - prs: prs.data.length + recentPrs.data.length, + prs: sortedPrs.length + recentPrs.data.length, reviews: filteredReviews.length, - notifications: notifications.data.length, + notifications: sortedNotifications.length, }; const nav = useKeyboardNav(sections, itemCounts); @@ -699,16 +727,16 @@ function Dashboard({ source }: { source: DashboardSource }) { }; const navRef = useRef(nav); - const prsRef = useRef(prs.data); + const prsRef = useRef(sortedPrs); const recentPrsRef = useRef(recentPrs.data); const reviewsRef = useRef(filteredReviews); - const notificationsRef = useRef(notifications.data); + const notificationsRef = useRef(sortedNotifications); const instancesRef = useRef(instances); navRef.current = nav; - prsRef.current = prs.data; + prsRef.current = sortedPrs; recentPrsRef.current = recentPrs.data; reviewsRef.current = filteredReviews; - notificationsRef.current = notifications.data; + notificationsRef.current = sortedNotifications; instancesRef.current = instances; const getFocused = () => @@ -920,6 +948,46 @@ function Dashboard({ source }: { source: DashboardSource }) { }) .catch(() => {}); } + } else if (e.code === "KeyS") { + if (e.shiftKey) { + const flip = (d: "asc" | "desc") => (d === "asc" ? "desc" : "asc"); + if (activeSection === "prs") { + setPrSort((cur) => ({ ...cur, dir: flip(cur.dir) })); + } else if (activeSection === "reviews") { + setReviewSort((cur) => ({ ...cur, dir: flip(cur.dir) })); + } else if (activeSection === "notifications") { + setNotificationSort((cur) => ({ ...cur, dir: flip(cur.dir) })); + } + } else { + if (activeSection === "prs") { + setPrSort((cur) => { + const i = PR_SORT_FIELDS.findIndex((f) => f.field === cur.field); + const next = PR_SORT_FIELDS[(i + 1) % PR_SORT_FIELDS.length]; + return { field: next.field, dir: "desc" }; + }); + } else if (activeSection === "reviews") { + setReviewSort((cur) => { + const i = REVIEW_SORT_FIELDS.findIndex( + (f) => f.field === cur.field, + ); + const next = + REVIEW_SORT_FIELDS[(i + 1) % REVIEW_SORT_FIELDS.length]; + return { field: next.field, dir: "desc" }; + }); + } else if (activeSection === "notifications") { + setNotificationSort((cur) => { + const i = NOTIFICATION_SORT_FIELDS.findIndex( + (f) => f.field === cur.field, + ); + const next = + NOTIFICATION_SORT_FIELDS[ + (i + 1) % NOTIFICATION_SORT_FIELDS.length + ]; + return { field: next.field, dir: "desc" }; + }); + } + } + e.preventDefault(); } }; @@ -939,6 +1007,13 @@ function Dashboard({ source }: { source: DashboardSource }) { nav.setActiveSection("prs"); nav.setFocusIndex(0); }} + right={ + + } > {prs.isLoading ? ( @@ -946,14 +1021,14 @@ function Dashboard({ source }: { source: DashboardSource }) { ) : ( { - const pr = prs.data.find((p) => p.number === prNumber); + const pr = sortedPrs.find((p) => p.number === prNumber); if (!pr) return; const instanceId = pr.instanceId ?? instances[0]?.id; if (!instanceId) return; @@ -980,6 +1055,13 @@ function Dashboard({ source }: { source: DashboardSource }) { nav.setActiveSection("reviews"); nav.setFocusIndex(0); }} + right={ + + } > {reviews.isLoading ? ( @@ -1004,6 +1086,13 @@ function Dashboard({ source }: { source: DashboardSource }) { nav.setActiveSection("notifications"); nav.setFocusIndex(0); }} + right={ + + } > {notifications.isLoading ? ( @@ -1011,7 +1100,7 @@ function Dashboard({ source }: { source: DashboardSource }) { ) : ( @@ -1202,6 +1291,7 @@ function Column({ isActive, isFetching, onActivate, + right, children, }: { section: Section; @@ -1210,6 +1300,7 @@ function Column({ isActive: boolean; isFetching: boolean; onActivate: () => void; + right?: React.ReactNode; children: React.ReactNode; }) { return ( @@ -1222,6 +1313,7 @@ function Column({ isActive={isActive} isFetching={isFetching} onClick={onActivate} + right={right} />
diff --git a/packages/web/src/components/PrCard.tsx b/packages/web/src/components/PrCard.tsx index fbf5839..d8b8cb0 100644 --- a/packages/web/src/components/PrCard.tsx +++ b/packages/web/src/components/PrCard.tsx @@ -253,7 +253,7 @@ export function PrCard({ variant="tertiary" className="text-[10px]" > - opened + created diff --git a/packages/web/src/components/SectionHeader.tsx b/packages/web/src/components/SectionHeader.tsx index 66f58a4..cdabfa2 100644 --- a/packages/web/src/components/SectionHeader.tsx +++ b/packages/web/src/components/SectionHeader.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import type { Section } from "../hooks"; import { Text } from "./Text"; @@ -8,12 +9,19 @@ interface Props { isActive: boolean; isFetching: boolean; onClick: () => void; + right?: ReactNode; } -export function SectionHeader({ label, count, isFetching, onClick }: Props) { +export function SectionHeader({ + label, + count, + isFetching, + onClick, + right, +}: Props) { return (
@@ -29,9 +37,12 @@ export function SectionHeader({ label, count, isFetching, onClick }: Props) { {count}
- {isFetching && ( - - )} +
+ {right} + {isFetching && ( + + )} +
); } diff --git a/packages/web/src/components/ShortcutHelp.tsx b/packages/web/src/components/ShortcutHelp.tsx index b976de0..49dd984 100644 --- a/packages/web/src/components/ShortcutHelp.tsx +++ b/packages/web/src/components/ShortcutHelp.tsx @@ -36,6 +36,8 @@ const sections: { title: string; shortcuts: [string, string][] }[] = [ ["a", "Approve PR"], ["c", "Close PR"], ["e", "Dismiss review / notification"], + ["s", "Cycle sort dimension"], + ["S", "Toggle sort direction"], ], }, { diff --git a/packages/web/src/components/SortControl.tsx b/packages/web/src/components/SortControl.tsx new file mode 100644 index 0000000..9aa521a --- /dev/null +++ b/packages/web/src/components/SortControl.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/lib/utils"; +import type { SortFieldOption, SortState } from "../sort"; + +interface Props { + fields: readonly SortFieldOption[]; + value: SortState; + onChange: (next: SortState) => void; +} + +export function SortControl({ + fields, + value, + onChange, +}: Props) { + return ( +
+ {fields.map((opt) => { + const active = opt.field === value.field; + return ( + + ); + })} +
+ ); +} diff --git a/packages/web/src/sort.test.ts b/packages/web/src/sort.test.ts new file mode 100644 index 0000000..430d2bb --- /dev/null +++ b/packages/web/src/sort.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; +import { + compareNotifications, + comparePrs, + compareReviews, + type NotificationSortField, + type PrSortField, + type ReviewSortField, + type SortState, +} from "./sort"; +import type { Notification, PR, ReviewRequest } from "./types"; + +function pr(overrides: Partial): PR { + return { + id: 1, + number: 1, + title: "t", + body: "", + url: "", + repo: "r", + updatedAt: "2024-01-01T00:00:00Z", + author: "a", + authorAvatar: "", + draft: false, + ciStatus: "", + inMergeQueue: false, + autoMerge: false, + headBranch: "", + baseBranch: "", + reviews: { approved: [], changesRequested: [] }, + additions: 0, + deletions: 0, + commits: 0, + commentCount: 0, + labels: [], + ...overrides, + }; +} + +function review(overrides: Partial): ReviewRequest { + return { + id: 1, + number: 1, + title: "t", + body: "", + url: "", + repo: "r", + updatedAt: "2024-01-01T00:00:00Z", + author: "a", + authorAvatar: "", + draft: false, + merged: false, + ciStatus: "", + inMergeQueue: false, + autoMerge: false, + headBranch: "", + baseBranch: "", + reviews: { approved: [], changesRequested: [] }, + additions: 0, + deletions: 0, + commits: 0, + commentCount: 0, + ...overrides, + }; +} + +function notif(overrides: Partial): Notification { + return { + id: "1", + title: "t", + type: "", + reason: "", + repo: "r", + updatedAt: "2024-01-01T00:00:00Z", + unread: false, + url: "", + ...overrides, + }; +} + +function sign(n: number) { + return n === 0 ? 0 : n > 0 ? 1 : -1; +} + +describe("comparePrs", () => { + const older = pr({ + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-02-01T00:00:00Z", + title: "alpha", + additions: 1, + deletions: 1, + }); + const newer = pr({ + createdAt: "2024-03-01T00:00:00Z", + updatedAt: "2024-04-01T00:00:00Z", + title: "beta", + additions: 10, + deletions: 10, + }); + + it("sorts by created, desc puts newer first", () => { + expect( + sign(comparePrs(older, newer, { field: "created", dir: "desc" })), + ).toBe(1); + expect( + sign(comparePrs(older, newer, { field: "created", dir: "asc" })), + ).toBe(-1); + }); + + it("sorts by updated", () => { + expect( + sign(comparePrs(older, newer, { field: "updated", dir: "desc" })), + ).toBe(1); + }); + + it("sorts by title alphabetically", () => { + expect(sign(comparePrs(older, newer, { field: "title", dir: "asc" }))).toBe( + -1, + ); + expect( + sign(comparePrs(older, newer, { field: "title", dir: "desc" })), + ).toBe(1); + }); + + it("sorts by size (additions + deletions)", () => { + expect(sign(comparePrs(older, newer, { field: "size", dir: "asc" }))).toBe( + -1, + ); + expect(sign(comparePrs(older, newer, { field: "size", dir: "desc" }))).toBe( + 1, + ); + }); + + it("treats missing createdAt as epoch", () => { + const noDate = pr({ createdAt: undefined }); + expect( + sign(comparePrs(noDate, newer, { field: "created", dir: "asc" })), + ).toBe(-1); + }); + + it("returns 0 for unknown field (stale persisted state)", () => { + const stale = { + field: "bogus", + dir: "desc", + } as unknown as SortState; + expect(comparePrs(older, newer, stale)).toBe(0); + }); +}); + +describe("compareReviews", () => { + const a = review({ + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-02-01T00:00:00Z", + title: "alpha", + author: "ann", + }); + const b = review({ + createdAt: "2024-03-01T00:00:00Z", + updatedAt: "2024-04-01T00:00:00Z", + title: "beta", + author: "bob", + }); + + it("sorts by created", () => { + expect(sign(compareReviews(a, b, { field: "created", dir: "asc" }))).toBe( + -1, + ); + expect(sign(compareReviews(a, b, { field: "created", dir: "desc" }))).toBe( + 1, + ); + }); + + it("sorts by updated", () => { + expect(sign(compareReviews(a, b, { field: "updated", dir: "asc" }))).toBe( + -1, + ); + }); + + it("sorts by title", () => { + expect(sign(compareReviews(a, b, { field: "title", dir: "asc" }))).toBe(-1); + }); + + it("sorts by author", () => { + expect(sign(compareReviews(a, b, { field: "author", dir: "asc" }))).toBe( + -1, + ); + }); + + it("returns 0 for unknown field", () => { + const stale = { + field: "bogus", + dir: "desc", + } as unknown as SortState; + expect(compareReviews(a, b, stale)).toBe(0); + }); +}); + +describe("compareNotifications", () => { + const a = notif({ + updatedAt: "2024-01-01T00:00:00Z", + title: "alpha", + repo: "anvil", + }); + const b = notif({ + updatedAt: "2024-02-01T00:00:00Z", + title: "beta", + repo: "boulder", + }); + + it("sorts by updated", () => { + expect( + sign(compareNotifications(a, b, { field: "updated", dir: "asc" })), + ).toBe(-1); + expect( + sign(compareNotifications(a, b, { field: "updated", dir: "desc" })), + ).toBe(1); + }); + + it("sorts by title", () => { + expect( + sign(compareNotifications(a, b, { field: "title", dir: "asc" })), + ).toBe(-1); + }); + + it("sorts by repo", () => { + expect( + sign(compareNotifications(a, b, { field: "repo", dir: "asc" })), + ).toBe(-1); + }); + + it("returns 0 for unknown field", () => { + const stale = { + field: "bogus", + dir: "desc", + } as unknown as SortState; + expect(compareNotifications(a, b, stale)).toBe(0); + }); +}); diff --git a/packages/web/src/sort.ts b/packages/web/src/sort.ts new file mode 100644 index 0000000..c1194eb --- /dev/null +++ b/packages/web/src/sort.ts @@ -0,0 +1,113 @@ +import { atomWithStorage } from "jotai/utils"; +import type { Notification, PR, ReviewRequest } from "./types"; + +export type SortDir = "asc" | "desc"; + +export interface SortState { + field: F; + dir: SortDir; +} + +export interface SortFieldOption { + field: F; + label: string; +} + +export const PR_SORT_FIELDS = [ + { field: "created", label: "created" }, + { field: "updated", label: "updated" }, + { field: "title", label: "title" }, + { field: "size", label: "size" }, +] as const satisfies readonly SortFieldOption[]; +export type PrSortField = (typeof PR_SORT_FIELDS)[number]["field"]; + +export const REVIEW_SORT_FIELDS = [ + { field: "created", label: "created" }, + { field: "updated", label: "updated" }, + { field: "title", label: "title" }, + { field: "author", label: "author" }, +] as const satisfies readonly SortFieldOption[]; +export type ReviewSortField = (typeof REVIEW_SORT_FIELDS)[number]["field"]; + +export const NOTIFICATION_SORT_FIELDS = [ + { field: "updated", label: "updated" }, + { field: "title", label: "title" }, + { field: "repo", label: "repo" }, +] as const satisfies readonly SortFieldOption[]; +export type NotificationSortField = + (typeof NOTIFICATION_SORT_FIELDS)[number]["field"]; + +export const prSortAtom = atomWithStorage>("prSort", { + field: "created", + dir: "desc", +}); + +export const reviewSortAtom = atomWithStorage>( + "reviewSort", + { field: "updated", dir: "desc" }, +); + +export const notificationSortAtom = atomWithStorage< + SortState +>("notificationSort", { field: "updated", dir: "desc" }); + +function cmpDate(a?: string, b?: string) { + const aT = a ? new Date(a).getTime() : 0; + const bT = b ? new Date(b).getTime() : 0; + return aT - bT; +} + +function cmpString(a: string, b: string) { + return a.localeCompare(b); +} + +export function comparePrs(a: PR, b: PR, sort: SortState) { + const sign = sort.dir === "asc" ? 1 : -1; + switch (sort.field) { + case "created": + return sign * cmpDate(a.createdAt, b.createdAt); + case "updated": + return sign * cmpDate(a.updatedAt, b.updatedAt); + case "title": + return sign * cmpString(a.title, b.title); + case "size": + return sign * (a.additions + a.deletions - (b.additions + b.deletions)); + } + return 0; +} + +export function compareReviews( + a: ReviewRequest, + b: ReviewRequest, + sort: SortState, +) { + const sign = sort.dir === "asc" ? 1 : -1; + switch (sort.field) { + case "created": + return sign * cmpDate(a.createdAt, b.createdAt); + case "updated": + return sign * cmpDate(a.updatedAt, b.updatedAt); + case "title": + return sign * cmpString(a.title, b.title); + case "author": + return sign * cmpString(a.author, b.author); + } + return 0; +} + +export function compareNotifications( + a: Notification, + b: Notification, + sort: SortState, +) { + const sign = sort.dir === "asc" ? 1 : -1; + switch (sort.field) { + case "updated": + return sign * cmpDate(a.updatedAt, b.updatedAt); + case "title": + return sign * cmpString(a.title, b.title); + case "repo": + return sign * cmpString(a.repo, b.repo); + } + return 0; +}