diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 9965f62aee..65c56da3ec 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -6,7 +6,12 @@ import type { AnchorHTMLAttributes, ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, Project } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { IssuesList } from "./IssuesList"; +import { + IssuesList, + issueAgeBucket, + issueAgeBucketsCrossed, + issueAgeSeparatorLabel, +} from "./IssuesList"; import { TooltipProvider } from "@/components/ui/tooltip"; const companyState = vi.hoisted(() => ({ @@ -1958,4 +1963,149 @@ describe("IssuesList", () => { root.unmount(); }); }); + + it("draws day and week separators between recency-sorted rows", async () => { + const now = Date.now(); + const hourAgo = new Date(now - 60 * 60 * 1000); + const threeDaysAgo = new Date(now - 3 * 24 * 60 * 60 * 1000); + const tenDaysAgo = new Date(now - 10 * 24 * 60 * 60 * 1000); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const separators = Array.from(container.querySelectorAll("[data-issues-date-separator]")); + const labels = separators.map((el) => el.getAttribute("aria-label")); + expect(labels).toEqual(["Older than a day", "Older than a week"]); + }); + + act(() => { + root.unmount(); + }); + }); + + it("draws both separators when adjacent rows skip the middle age bucket", async () => { + const now = Date.now(); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const labels = Array.from(container.querySelectorAll("[data-issues-date-separator]")) + .map((el) => el.getAttribute("aria-label")); + expect(labels).toEqual(["Older than a day", "Older than a week"]); + }); + + act(() => { + root.unmount(); + }); + }); + + it("places separators around expanded nested rows in visible order", async () => { + const now = Date.now(); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const visibleOrder = Array.from( + container.querySelectorAll("[data-testid='issue-row'], [data-issues-date-separator]"), + ).map((element) => element.getAttribute("aria-label") ?? element.firstElementChild?.textContent); + expect(visibleOrder).toEqual([ + "Recent parent", + "Older than a day", + "Older child", + "Older than a week", + "Old root", + ]); + }); + + act(() => { + root.unmount(); + }); + }); + + it("omits date separators when all rows share a recency bucket", async () => { + const now = Date.now(); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.querySelector("[data-testid='issue-row']")).not.toBeNull(); + }); + expect(container.querySelectorAll("[data-issues-date-separator]").length).toBe(0); + + act(() => { + root.unmount(); + }); + }); +}); + +describe("issueAgeBucket", () => { + const now = new Date("2026-04-10T12:00:00.000Z").getTime(); + + it("buckets by day and week boundaries", () => { + expect(issueAgeBucket(new Date(now - 60 * 60 * 1000), now)).toBe(0); + expect(issueAgeBucket(new Date(now - 3 * 24 * 60 * 60 * 1000), now)).toBe(1); + expect(issueAgeBucket(new Date(now - 10 * 24 * 60 * 60 * 1000), now)).toBe(2); + }); + + it("labels the day and week separators", () => { + expect(issueAgeSeparatorLabel(1)).toBe("Older than a day"); + expect(issueAgeSeparatorLabel(2)).toBe("Older than a week"); + }); + + it("returns every boundary crossed between adjacent rows", () => { + expect(issueAgeBucketsCrossed(0, 1)).toEqual([1]); + expect(issueAgeBucketsCrossed(1, 2)).toEqual([2]); + expect(issueAgeBucketsCrossed(0, 2)).toEqual([1, 2]); + expect(issueAgeBucketsCrossed(2, 1)).toEqual([]); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 921e15793c..63bdb73d84 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,4 +1,5 @@ import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; +import type { ReactNode } from "react"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useVisibilityRefetchInterval } from "@/lib/polling"; import { accessApi } from "../api/access"; @@ -303,6 +304,60 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { return sorted; } +const AGE_BUCKET_DAY_MS = 24 * 60 * 60 * 1000; +const AGE_BUCKET_WEEK_MS = 7 * AGE_BUCKET_DAY_MS; + +// Recency buckets for the date separators shown in date-sorted lists: +// 0 = within the last day, 1 = within the last week, 2 = older than a week. +// A separator is drawn between rows whenever the bucket increases, so the +// "1 day" and "1 week" boundaries appear as the list ages downward. +export function issueAgeBucket(date: Date | string, now: number = Date.now()): 0 | 1 | 2 { + const age = now - new Date(date).getTime(); + if (age < AGE_BUCKET_DAY_MS) return 0; + if (age < AGE_BUCKET_WEEK_MS) return 1; + return 2; +} + +export function issueAgeSeparatorLabel(bucket: 1 | 2): string { + return bucket === 1 ? "Older than a day" : "Older than a week"; +} + +export function issueAgeBucketsCrossed( + previousBucket: 0 | 1 | 2, + currentBucket: 0 | 1 | 2, +): Array<1 | 2> { + const crossedBuckets: Array<1 | 2> = []; + if (previousBucket < 1 && currentBucket >= 1) crossedBuckets.push(1); + if (previousBucket < 2 && currentBucket >= 2) crossedBuckets.push(2); + return crossedBuckets; +} + +// Only recency sorts (newest first) get date separators — for any other +// sort/direction the boundaries would be meaningless. +function issueDateSeparatorField(state: IssueViewState): "createdAt" | "updatedAt" | null { + if (state.sortDir !== "desc") return null; + if (state.sortField === "created") return "createdAt"; + if (state.sortField === "updated") return "updatedAt"; + return null; +} + +function IssueDateSeparator({ label }: { label: string }) { + return ( +
+
+ + {label} + +
+
+ ); +} + function issueMatchesLocalSearch(issue: Issue, normalizedSearch: string): boolean { if (!normalizedSearch) return true; return [ @@ -2254,12 +2309,44 @@ export function IssuesList({ ) : undefined )} /> - {hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))}
); }; - return roots.map((issue) => renderIssueRow(issue, 0)).filter((node) => node !== null); + const separatorField = issueDateSeparatorField(viewState); + const separatorNow = Date.now(); + const nodes: ReactNode[] = []; + let prevBucket: 0 | 1 | 2 | null = null; + const appendIssueRow = (issue: Issue, depth: number) => { + const node = renderIssueRow(issue, depth); + // Skip rows the render budget dropped so separators never + // dangle above an unrendered (or absent) row. + if (node === null) return; + if (separatorField) { + const bucket = issueAgeBucket(issue[separatorField], separatorNow); + for (const crossedBucket of prevBucket === null + ? [] + : issueAgeBucketsCrossed(prevBucket, bucket)) { + nodes.push( + , + ); + } + prevBucket = bucket; + } + nodes.push(node); + if (!viewState.collapsedParents.includes(issue.id)) { + for (const child of childMap.get(issue.id) ?? []) { + appendIssueRow(child, depth + 1); + } + } + }; + for (const issue of roots) { + appendIssueRow(issue, 0); + } + return nodes; })()}