Merge pull request #2638 from paperclipai/fix/inbox-last-activity-ordering

fix(inbox): prefer canonical last activity
This commit is contained in:
Dotta 2026-04-03 07:27:46 -05:00 committed by GitHub
commit 422dd51a87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 20 additions and 12 deletions

View file

@ -143,6 +143,7 @@ export interface Issue {
mentionedProjects?: Project[]; mentionedProjects?: Project[];
myLastTouchAt?: Date | null; myLastTouchAt?: Date | null;
lastExternalCommentAt?: Date | null; lastExternalCommentAt?: Date | null;
lastActivityAt?: Date | null;
isUnreadForMe?: boolean; isUnreadForMe?: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;

View file

@ -180,6 +180,7 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
labelIds: [], labelIds: [],
myLastTouchAt: new Date("2026-03-11T00:00:00.000Z"), myLastTouchAt: new Date("2026-03-11T00:00:00.000Z"),
lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"), lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"),
lastActivityAt: new Date("2026-03-11T01:00:00.000Z"),
isUnreadForMe, isUnreadForMe,
}; };
} }
@ -357,10 +358,10 @@ describe("inbox helpers", () => {
it("mixes approvals into the inbox feed by most recent activity", () => { it("mixes approvals into the inbox feed by most recent activity", () => {
const newerIssue = makeIssue("1", true); const newerIssue = makeIssue("1", true);
newerIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z"); newerIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
const olderIssue = makeIssue("2", false); const olderIssue = makeIssue("2", false);
olderIssue.lastExternalCommentAt = new Date("2026-03-11T02:00:00.000Z"); olderIssue.lastActivityAt = new Date("2026-03-11T02:00:00.000Z");
const approval = makeApprovalWithTimestamps( const approval = makeApprovalWithTimestamps(
"approval-between", "approval-between",
@ -385,19 +386,21 @@ describe("inbox helpers", () => {
]); ]);
}); });
it("sorts touched issues by latest external comment timestamp", () => { it("prefers canonical lastActivityAt over comment-only timestamps", () => {
const newerIssue = makeIssue("1", true); const activityIssue = makeIssue("1", true);
newerIssue.lastExternalCommentAt = new Date("2026-03-11T05:00:00.000Z"); activityIssue.lastExternalCommentAt = new Date("2026-03-11T01:00:00.000Z");
activityIssue.lastActivityAt = new Date("2026-03-11T05:00:00.000Z");
const olderIssue = makeIssue("2", true); const commentIssue = makeIssue("2", true);
olderIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z"); commentIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
commentIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
expect(getRecentTouchedIssues([olderIssue, newerIssue]).map((issue) => issue.id)).toEqual(["1", "2"]); expect(getRecentTouchedIssues([commentIssue, activityIssue]).map((issue) => issue.id)).toEqual(["1", "2"]);
}); });
it("mixes join requests into the inbox feed by most recent activity", () => { it("mixes join requests into the inbox feed by most recent activity", () => {
const issue = makeIssue("1", true); const issue = makeIssue("1", true);
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z"); issue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
const joinRequest = makeJoinRequest("join-1"); const joinRequest = makeJoinRequest("join-1");
joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z"); joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z");
@ -482,7 +485,7 @@ describe("inbox helpers", () => {
it("limits recent touched issues before unread badge counting", () => { it("limits recent touched issues before unread badge counting", () => {
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => { const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
const issue = makeIssue(String(index + 1), index < 3); const issue = makeIssue(String(index + 1), index < 3);
issue.lastExternalCommentAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000); issue.lastActivityAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
return issue; return issue;
}); });

View file

@ -217,6 +217,9 @@ export function normalizeTimestamp(value: string | Date | null | undefined): num
} }
export function issueLastActivityTimestamp(issue: Issue): number { export function issueLastActivityTimestamp(issue: Issue): number {
const lastActivityAt = normalizeTimestamp(issue.lastActivityAt);
if (lastActivityAt > 0) return lastActivityAt;
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt); const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
if (lastExternalCommentAt > 0) return lastExternalCommentAt; if (lastExternalCommentAt > 0) return lastExternalCommentAt;

View file

@ -56,6 +56,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
labelIds: [], labelIds: [],
myLastTouchAt: null, myLastTouchAt: null,
lastExternalCommentAt: null, lastExternalCommentAt: null,
lastActivityAt: new Date("2026-03-11T00:00:00.000Z"),
isUnreadForMe: false, isUnreadForMe: false,
...overrides, ...overrides,
}; };

View file

@ -214,7 +214,7 @@ export function InboxIssueMetaLeading({
} }
function issueActivityText(issue: Issue): string { function issueActivityText(issue: Issue): string {
return `Updated ${timeAgo(issue.lastExternalCommentAt ?? issue.updatedAt)}`; return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
} }
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string { function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
@ -246,7 +246,7 @@ export function InboxIssueTrailingColumns({
assigneeName: string | null; assigneeName: string | null;
currentUserId: string | null; currentUserId: string | null;
}) { }) {
const activityText = timeAgo(issue.lastExternalCommentAt ?? issue.updatedAt); const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"; const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
return ( return (