Files
PaperClipAI/ui/src/lib/issue-sort.ts
T
DottaandPaperclip 946735d03a Persist task sorting in URLs and show newest routine tasks first
Save task sort choices in URLs across task views. Open routine tasks by creation time, newest first, with stable sorting before pagination.

Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
2026-11-02 13:32:40 -06:00

57 lines
2.9 KiB
TypeScript

import type { Issue } from "@paperclipai/shared";
import { issuePriorityOrder, issueStatusOrder } from "./issue-filters";
import { workflowSort } from "./workflow-sort";
/** URL sort fields are shared by all task interfaces, including hidden columns. */
export const ISSUE_SORT_FIELDS = ["status", "priority", "title", "created", "updated", "workflow", "taskId", "createdBy", "assignee", "project", "parentTask", "tags", "worktree"] as const;
export type IssueSortField = typeof ISSUE_SORT_FIELDS[number];
export type IssueSort = { sortField: IssueSortField; sortDir: "asc" | "desc" };
export function readIssueSortParams(params: URLSearchParams): IssueSort | undefined {
const field = params.get("sortField");
if (!ISSUE_SORT_FIELDS.includes(field as IssueSortField)) return undefined;
return { sortField: field as IssueSortField, sortDir: params.get("sortDir") === "asc" ? "asc" : "desc" };
}
export type IssueSortContext = {
createdBy?: string | null;
assignee?: string | null;
project?: string | null;
parentTask?: string | null;
};
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
/** Compare the same raw values in every interface. Missing values stay last. */
export function sortIssues(issues: readonly Issue[], sort: IssueSort, context: (issue: Issue) => IssueSortContext = () => ({})): Issue[] {
if (sort.sortField === "workflow") {
const ordered = workflowSort([...issues]);
return sort.sortDir === "desc" ? ordered.reverse() : ordered;
}
const value = (issue: Issue): string | number | null | undefined => {
switch (sort.sortField) {
case "status": return issueStatusOrder.indexOf(issue.displayStatus ?? issue.status);
case "priority": return issuePriorityOrder.indexOf(issue.priority);
case "title": return issue.title;
case "created": return new Date(issue.createdAt).getTime();
case "updated": return new Date(issue.updatedAt).getTime();
case "taskId": return issue.identifier;
case "createdBy": return context(issue).createdBy;
case "assignee": return context(issue).assignee;
case "project": return context(issue).project ?? issue.project?.name;
case "parentTask": return context(issue).parentTask;
case "tags": return issue.labels?.map(label => label.name).sort().join(", ");
case "worktree": return issue.currentExecutionWorkspace?.branchName;
}
};
return [...issues].sort((a, b) => {
const left = value(a), right = value(b);
const missing = (value: string | number | null | undefined) => value == null || value === "" || (typeof value === "number" && !Number.isFinite(value));
const leftMissing = missing(left), rightMissing = missing(right);
if (leftMissing || rightMissing) return Number(leftMissing) - Number(rightMissing);
const result = typeof left === "number" && typeof right === "number" ? left - right : collator.compare(String(left), String(right));
return sort.sortDir === "asc" ? result : -result;
});
}