Fix attention group-by default

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-10 20:08:18 +00:00
1 parent 5129db46f7
commit ad58432220
4 files changed
+96 -47

No files matched your search

+38 -1
View File
@@ -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
View File
@@ -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[]>();
+14 -11
View File
@@ -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) => (
+30 -25
View File
@@ -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">