feat(ui): add recency separators to task lists (#10454)
## Thinking Path > - Paperclip is the open source control plane people use to manage AI agents and their work > - Project pages give operators a dense task list for understanding what has changed recently > - A purely chronological list makes the transition from fresh work to aging work difficult to scan > - The existing activity feed already uses a quiet labeled divider to communicate a recency boundary > - This pull request applies that familiar pattern to task lists at the one-day and one-week boundaries > - The benefit is faster age-based scanning without adding filters, badges, or repeated metadata to every row ## Linked Issues or Issue Description ### Subsystem affected `ui/` — React + Vite board UI. ### Problem or motivation Operators scanning a project task list cannot quickly see where recently created or updated work gives way to tasks that are more than a day or a week old. ### Proposed solution Insert subtle, accessible “Older than a day” and “Older than a week” separators when a task list is sorted newest-first by creation or update time. ### Alternatives considered Per-row age badges would repeat state and add noise; persistent age-based groups would interfere with the list's existing grouping controls. Lightweight boundary markers preserve the current ordering and interaction model. ### Roadmap alignment This is a tightly scoped board-UI polish change and does not duplicate a roadmap milestone. ### Additional context The visual treatment follows the existing activity-feed recency separator pattern. A public GitHub search found no duplicate or related open issue or pull request. ## What Changed - Added rolling one-day and one-week recency buckets for created/updated timestamps. - Rendered token-compliant, accessible separators only for newest-first date sorts and only when visible rows cross a boundary. - Traversed expanded nested rows in their exact visible order and emitted every crossed boundary when adjacent rows skip an age bucket. - Added component and helper coverage for sequential boundaries, skipped buckets, expanded nested rows, and the no-separator same-bucket case. ## Verification - `pnpm exec vitest run ui/src/components/IssuesList.test.tsx` — 42 tests passed. - `pnpm check:token-gates` — all token gates clean. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm --filter @paperclipai/ui build` — passed (with existing build warnings only). ## Risks - Low risk: the change is presentation-only and limited to list mode when sorting `created` or `updated` descending. - Boundaries use rolling 24-hour and 7-day windows rather than calendar-day boundaries, matching the age-based labels. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 family (the runtime did not expose a more specific model build or context-window size), with reasoning, repository tool use, code execution, and GitHub CLI access. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
24aa2f516d
commit
a6436126ce
|
|
@ -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(
|
||||
<IssuesList
|
||||
issues={[
|
||||
createIssue({ id: "issue-recent", identifier: "PAP-1", title: "Just updated", updatedAt: hourAgo }),
|
||||
createIssue({ id: "issue-mid", identifier: "PAP-2", title: "A few days old", updatedAt: threeDaysAgo }),
|
||||
createIssue({ id: "issue-old", identifier: "PAP-3", title: "Over a week old", updatedAt: tenDaysAgo }),
|
||||
]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => 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(
|
||||
<IssuesList
|
||||
issues={[
|
||||
createIssue({ id: "issue-recent", identifier: "PAP-1", title: "Just updated", updatedAt: new Date(now - 60 * 60 * 1000) }),
|
||||
createIssue({ id: "issue-old", identifier: "PAP-2", title: "Over a week old", updatedAt: new Date(now - 10 * 24 * 60 * 60 * 1000) }),
|
||||
]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => 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(
|
||||
<IssuesList
|
||||
issues={[
|
||||
createIssue({ id: "issue-parent", identifier: "PAP-1", title: "Recent parent", updatedAt: new Date(now - 60 * 60 * 1000) }),
|
||||
createIssue({ id: "issue-child", identifier: "PAP-2", parentId: "issue-parent", title: "Older child", updatedAt: new Date(now - 3 * 24 * 60 * 60 * 1000) }),
|
||||
createIssue({ id: "issue-old", identifier: "PAP-3", title: "Old root", updatedAt: new Date(now - 10 * 24 * 60 * 60 * 1000) }),
|
||||
]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => 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(
|
||||
<IssuesList
|
||||
issues={[
|
||||
createIssue({ id: "issue-a", identifier: "PAP-1", title: "One", updatedAt: new Date(now - 60 * 60 * 1000) }),
|
||||
createIssue({ id: "issue-b", identifier: "PAP-2", title: "Two", updatedAt: new Date(now - 2 * 60 * 60 * 1000) }),
|
||||
]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => 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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 sm:pl-0 sm:pr-4"
|
||||
role="separator"
|
||||
aria-label={label}
|
||||
data-issues-date-separator=""
|
||||
>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-(length:--text-nano) font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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(
|
||||
<IssueDateSeparator
|
||||
key={`age-sep-${issue.id}-${crossedBucket}`}
|
||||
label={issueAgeSeparatorLabel(crossedBucket)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
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;
|
||||
})()}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
|
|
|||
Loading…
Reference in New Issue