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;
+}