mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Fix attention group-by default
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5129db46f7
commit
ad58432220
4 files changed
+96
-47
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { AttentionFeed, AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
|
||||
import {
|
||||
ATTENTION_GROUP_BY_KEY,
|
||||
ATTENTION_GROUP_BY_OPTIONS,
|
||||
attentionBadgeCount,
|
||||
attentionDateBucket,
|
||||
attentionDetailLine,
|
||||
@@ -12,7 +14,9 @@ import {
|
||||
filterAttentionItems,
|
||||
groupAttentionItems,
|
||||
isInlineResolvable,
|
||||
loadAttentionGroupBy,
|
||||
NO_GROUP_SENTINEL,
|
||||
saveAttentionGroupBy,
|
||||
severityBadge,
|
||||
severityStyle,
|
||||
sortAttentionItems,
|
||||
@@ -46,6 +50,25 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
||||
};
|
||||
}
|
||||
|
||||
describe("attention group preference persistence", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("defaults to None and lists it as the first group option", () => {
|
||||
expect(loadAttentionGroupBy()).toBe("none");
|
||||
expect(ATTENTION_GROUP_BY_OPTIONS[0]).toEqual(["none", "None"]);
|
||||
});
|
||||
|
||||
it("round-trips explicit grouped choices and treats stale values as None", () => {
|
||||
saveAttentionGroupBy("date");
|
||||
expect(loadAttentionGroupBy()).toBe("date");
|
||||
|
||||
localStorage.setItem(ATTENTION_GROUP_BY_KEY, "unexpected");
|
||||
expect(loadAttentionGroupBy()).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInlineResolvable", () => {
|
||||
it("is true for approvals/interactions/join when server flags inlineResolvable", () => {
|
||||
for (const kind of ["approval", "issue_thread_interaction", "join_request"] as AttentionSourceKind[]) {
|
||||
@@ -252,6 +275,20 @@ describe("attentionDateBucket", () => {
|
||||
describe("groupAttentionItems", () => {
|
||||
const now = new Date("2026-07-10T12:00:00Z").getTime();
|
||||
|
||||
it("leaves None as one unlabeled group that preserves caller sort order", () => {
|
||||
const items = sortAttentionItems(
|
||||
[
|
||||
buildItem({ id: "old", activityAt: "2026-07-10T08:00:00Z" }),
|
||||
buildItem({ id: "new", activityAt: "2026-07-10T10:00:00Z" }),
|
||||
],
|
||||
"newest",
|
||||
);
|
||||
const groups = groupAttentionItems(items, "none", { now });
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].label).toBeNull();
|
||||
expect(groups[0].items.map((i) => i.id)).toEqual(["new", "old"]);
|
||||
});
|
||||
|
||||
it("groups by date into fixed Today/Yesterday/This week/Earlier order", () => {
|
||||
const items = [
|
||||
buildItem({ id: "earlier", activityAt: "2026-06-01T00:00:00Z" }),
|
||||
|
||||
+14
-10
@@ -276,18 +276,18 @@ export function attentionBadgeCount(feed: AttentionFeed | null | undefined): num
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grouping / sorting / filtering (PAP-13408 — Inbox-style toolbar)
|
||||
//
|
||||
// The queue defaults to a date-first grouping (Today / Yesterday / This week /
|
||||
// Earlier) sorted by `activityAt` desc, mirroring the `InboxWorkItemGroupBy`
|
||||
// pattern in `lib/inbox.ts`. All of these are pure functions so the page can
|
||||
// re-bucket on the client without refetching, and so the logic is unit-tested
|
||||
// independently of React.
|
||||
// The queue defaults to no grouping, sorted by `activityAt` desc, mirroring the
|
||||
// `InboxWorkItemGroupBy` pattern in `lib/inbox.ts`. All of these are pure
|
||||
// functions so the page can re-bucket on the client without refetching, and so
|
||||
// the logic is unit-tested independently of React.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AttentionGroupBy = "date" | "type" | "project" | "severity";
|
||||
export type AttentionGroupBy = "none" | "date" | "type" | "project" | "severity";
|
||||
export type AttentionSortOrder = "newest" | "oldest";
|
||||
|
||||
/** Ordered list used to render the group-by picker (label + value). */
|
||||
export const ATTENTION_GROUP_BY_OPTIONS: ReadonlyArray<[AttentionGroupBy, string]> = [
|
||||
["none", "None"],
|
||||
["date", "Date"],
|
||||
["type", "Type"],
|
||||
["project", "Project"],
|
||||
@@ -321,7 +321,7 @@ export const defaultAttentionFilterState: AttentionFilterState = {
|
||||
|
||||
export interface AttentionGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
label: string | null;
|
||||
items: AttentionItem[];
|
||||
}
|
||||
|
||||
@@ -342,15 +342,15 @@ export const ATTENTION_FILTERS_KEY_PREFIX = "paperclip:attention:filters";
|
||||
export const ATTENTION_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:attention:collapsed-groups";
|
||||
|
||||
function isAttentionGroupBy(value: unknown): value is AttentionGroupBy {
|
||||
return value === "date" || value === "type" || value === "project" || value === "severity";
|
||||
return value === "none" || value === "date" || value === "type" || value === "project" || value === "severity";
|
||||
}
|
||||
|
||||
export function loadAttentionGroupBy(): AttentionGroupBy {
|
||||
try {
|
||||
const raw = localStorage.getItem(ATTENTION_GROUP_BY_KEY);
|
||||
return isAttentionGroupBy(raw) ? raw : "date";
|
||||
return isAttentionGroupBy(raw) ? raw : "none";
|
||||
} catch {
|
||||
return "date";
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,6 +576,10 @@ export function groupAttentionItems(
|
||||
): AttentionGroup[] {
|
||||
if (items.length === 0) return [];
|
||||
|
||||
if (groupBy === "none") {
|
||||
return [{ key: "__all", label: null, items }];
|
||||
}
|
||||
|
||||
if (groupBy === "date") {
|
||||
const now = options.now ?? Date.now();
|
||||
const buckets = new Map<DateBucket, AttentionItem[]>();
|
||||
|
||||
@@ -273,7 +273,7 @@ export function WhatNeedsMe() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8 shrink-0", groupBy !== "date" && "bg-accent")}
|
||||
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
|
||||
title="Group"
|
||||
aria-label="Group"
|
||||
>
|
||||
@@ -345,18 +345,21 @@ export function WhatNeedsMe() {
|
||||
<CaughtUpNote filtered={activeItems.length > 0} />
|
||||
) : (
|
||||
groups.map((group) => {
|
||||
const collapsed = collapsedGroupKeys.has(group.key);
|
||||
const groupLabel = group.label;
|
||||
const collapsed = groupLabel !== null && collapsedGroupKeys.has(group.key);
|
||||
return (
|
||||
<section key={group.key} className="space-y-2">
|
||||
<IssueGroupHeader
|
||||
label={group.label}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onToggle={() => toggleGroupCollapse(group.key)}
|
||||
trailing={
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{group.items.length}</span>
|
||||
}
|
||||
/>
|
||||
{groupLabel !== null && (
|
||||
<IssueGroupHeader
|
||||
label={groupLabel}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onToggle={() => toggleGroupCollapse(group.key)}
|
||||
trailing={
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{group.items.length}</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<div className="space-y-2">
|
||||
{group.items.map((item) => (
|
||||
|
||||
@@ -309,7 +309,7 @@ function ToolbarButton({ icon: Icon, active }: { icon: typeof Layers; active?: b
|
||||
|
||||
function Queue({
|
||||
items,
|
||||
groupBy = "date",
|
||||
groupBy = "none",
|
||||
sortOrder = "newest",
|
||||
snoozed = [],
|
||||
dismissed = [],
|
||||
@@ -344,7 +344,7 @@ function Queue({
|
||||
</span>
|
||||
)}
|
||||
<ToolbarButton icon={ListFilter} />
|
||||
<ToolbarButton icon={Layers} active={groupBy !== "date"} />
|
||||
<ToolbarButton icon={Layers} active={groupBy !== "none"} />
|
||||
<ToolbarButton icon={ArrowUpDown} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,30 +361,35 @@ function Queue({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<section key={group.key} className="space-y-2">
|
||||
<IssueGroupHeader
|
||||
label={group.label}
|
||||
collapsible
|
||||
collapsed={false}
|
||||
trailing={<span className="text-xs tabular-nums text-muted-foreground">{group.items.length}</span>}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{group.items.map((it) => (
|
||||
<AttentionQueueRow
|
||||
key={it.id}
|
||||
item={it}
|
||||
companyId={companyId}
|
||||
expanded={expandedId === it.id}
|
||||
onToggleExpand={() => setExpandedId((p) => (p === it.id ? null : it.id))}
|
||||
onDismiss={(d) => setCleared((prev) => new Set(prev).add(d.id))}
|
||||
onSnooze={(d) => setCleared((prev) => new Set(prev).add(d.id))}
|
||||
onFilterProject={() => {}}
|
||||
{groups.map((group) => {
|
||||
const groupLabel = group.label;
|
||||
return (
|
||||
<section key={group.key} className="space-y-2">
|
||||
{groupLabel !== null && (
|
||||
<IssueGroupHeader
|
||||
label={groupLabel}
|
||||
collapsible
|
||||
collapsed={false}
|
||||
trailing={<span className="text-xs tabular-nums text-muted-foreground">{group.items.length}</span>}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{group.items.map((it) => (
|
||||
<AttentionQueueRow
|
||||
key={it.id}
|
||||
item={it}
|
||||
companyId={companyId}
|
||||
expanded={expandedId === it.id}
|
||||
onToggleExpand={() => setExpandedId((p) => (p === it.id ? null : it.id))}
|
||||
onDismiss={(d) => setCleared((prev) => new Set(prev).add(d.id))}
|
||||
onSnooze={(d) => setCleared((prev) => new Set(prev).add(d.id))}
|
||||
onFilterProject={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{snoozed.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
|
||||
Reference in new issue
Block a user