fix(agents): prevent updated_at regression causing sidebar group flicker (#22672)

This commit is contained in:
Danielle Maywood
2026-03-05 21:21:14 +00:00
committed by GitHub
parent 56bdea73b8
commit 9f6f4ba74d
7 changed files with 868 additions and 28 deletions
+265
View File
@@ -0,0 +1,265 @@
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { QueryClient } from "react-query";
import { describe, expect, it, vi } from "vitest";
import { archiveChat, chatKey, chatsKey, unarchiveChat } from "./chats";
vi.mock("api/api", () => ({
API: {
archiveChat: vi.fn(),
unarchiveChat: vi.fn(),
},
}));
const makeChat = (
id: string,
overrides?: Partial<TypesGen.Chat>,
): TypesGen.Chat => ({
id,
owner_id: "owner-1",
last_model_config_id: "model-1",
title: `Chat ${id}`,
status: "running",
created_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
archived: false,
last_error: null,
...overrides,
});
const makeChatWithMessages = (
chatId: string,
overrides?: Partial<TypesGen.Chat>,
): TypesGen.ChatWithMessages => ({
chat: makeChat(chatId, overrides),
messages: [],
queued_messages: [],
});
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
describe("archiveChat optimistic update", () => {
it("optimistically sets archived to true in the chats list", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
const initialChats = [makeChat(chatId), makeChat("chat-2")];
queryClient.setQueryData(chatsKey, initialChats);
vi.mocked(API.archiveChat).mockResolvedValue();
const mutation = archiveChat(queryClient);
await mutation.onMutate(chatId);
const updatedChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
expect(updatedChats).toHaveLength(2);
expect(updatedChats?.find((c) => c.id === chatId)?.archived).toBe(true);
// Other chats are unchanged.
expect(updatedChats?.find((c) => c.id === "chat-2")?.archived).toBe(false);
});
it("optimistically sets archived to true in the individual chat cache", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
queryClient.setQueryData(chatsKey, [makeChat(chatId)]);
queryClient.setQueryData(chatKey(chatId), makeChatWithMessages(chatId));
vi.mocked(API.archiveChat).mockResolvedValue();
const mutation = archiveChat(queryClient);
await mutation.onMutate(chatId);
const cachedChat = queryClient.getQueryData<TypesGen.ChatWithMessages>(
chatKey(chatId),
);
expect(cachedChat?.chat.archived).toBe(true);
});
it("rolls back the chats list on error", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
const initialChats = [makeChat(chatId)];
queryClient.setQueryData(chatsKey, initialChats);
queryClient.setQueryData(chatKey(chatId), makeChatWithMessages(chatId));
const mutation = archiveChat(queryClient);
const context = await mutation.onMutate(chatId);
// Verify the optimistic update took effect.
expect(
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
).toBe(true);
// Simulate an error — the onError handler should restore original
// data.
mutation.onError(new Error("server error"), chatId, context);
const rolledBack = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
expect(rolledBack?.[0].archived).toBe(false);
});
it("rolls back the individual chat cache on error", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
queryClient.setQueryData(chatsKey, [makeChat(chatId)]);
queryClient.setQueryData(chatKey(chatId), makeChatWithMessages(chatId));
const mutation = archiveChat(queryClient);
const context = await mutation.onMutate(chatId);
expect(
queryClient.getQueryData<TypesGen.ChatWithMessages>(chatKey(chatId))?.chat
.archived,
).toBe(true);
mutation.onError(new Error("server error"), chatId, context);
const rolledBack = queryClient.getQueryData<TypesGen.ChatWithMessages>(
chatKey(chatId),
);
expect(rolledBack?.chat.archived).toBe(false);
});
it("handles error rollback gracefully when context is undefined", () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
queryClient.setQueryData(chatsKey, [makeChat(chatId, { archived: true })]);
const mutation = archiveChat(queryClient);
// Calling onError with undefined context should not throw.
expect(() => {
mutation.onError(new Error("fail"), chatId, undefined);
}).not.toThrow();
// Data should remain unchanged since there was nothing to roll
// back to.
expect(
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
).toBe(true);
});
it("handles onMutate when no individual chat cache exists", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
queryClient.setQueryData(chatsKey, [makeChat(chatId)]);
// Deliberately do NOT set chatKey(chatId) data.
const mutation = archiveChat(queryClient);
const context = await mutation.onMutate(chatId);
// The list should still be optimistically updated.
expect(
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
).toBe(true);
// previousChat should be undefined.
expect(context?.previousChat).toBeUndefined();
});
it("invalidates queries on settled regardless of outcome", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const mutation = archiveChat(queryClient);
await mutation.onSettled(undefined, undefined, chatId);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: chatsKey,
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: chatKey(chatId),
});
});
});
describe("unarchiveChat optimistic update", () => {
it("optimistically sets archived to false in the chats list", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
queryClient.setQueryData(chatsKey, [makeChat(chatId, { archived: true })]);
const mutation = unarchiveChat(queryClient);
await mutation.onMutate(chatId);
expect(
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
).toBe(false);
});
it("optimistically sets archived to false in the individual chat cache", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
queryClient.setQueryData(chatsKey, [makeChat(chatId, { archived: true })]);
queryClient.setQueryData(
chatKey(chatId),
makeChatWithMessages(chatId, { archived: true }),
);
const mutation = unarchiveChat(queryClient);
await mutation.onMutate(chatId);
expect(
queryClient.getQueryData<TypesGen.ChatWithMessages>(chatKey(chatId))?.chat
.archived,
).toBe(false);
});
it("rolls back both caches on error", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
queryClient.setQueryData(chatsKey, [makeChat(chatId, { archived: true })]);
queryClient.setQueryData(
chatKey(chatId),
makeChatWithMessages(chatId, { archived: true }),
);
const mutation = unarchiveChat(queryClient);
const context = await mutation.onMutate(chatId);
// Verify optimistic update.
expect(
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
).toBe(false);
expect(
queryClient.getQueryData<TypesGen.ChatWithMessages>(chatKey(chatId))?.chat
.archived,
).toBe(false);
// Roll back.
mutation.onError(new Error("server error"), chatId, context);
expect(
queryClient.getQueryData<TypesGen.Chat[]>(chatsKey)?.[0].archived,
).toBe(true);
expect(
queryClient.getQueryData<TypesGen.ChatWithMessages>(chatKey(chatId))?.chat
.archived,
).toBe(true);
});
it("invalidates queries on settled", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const mutation = unarchiveChat(queryClient);
await mutation.onSettled(undefined, undefined, chatId);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: chatsKey,
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: chatKey(chatId),
});
});
});
@@ -1,6 +1,6 @@
import { act, render, renderHook, waitFor } from "@testing-library/react";
import { watchChat } from "api/api";
import { chatKey } from "api/queries/chats";
import { chatKey, chatsKey } from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import type { FC, PropsWithChildren } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
@@ -2029,3 +2029,484 @@ describe("useChatStore", () => {
});
});
});
describe("updateSidebarChat via stream events", () => {
it("updates sidebar chat status on status stream event", async () => {
immediateAnimationFrame();
const chatID = "chat-sidebar-status";
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const initialChat = makeChat(chatID);
// Seed the chats list so updateSidebarChat can find it.
queryClient.setQueryData(chatsKey, [initialChat]);
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [],
chatRecord: initialChat,
chatData: {
chat: initialChat,
messages: [],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return { chatStatus: useChatSelector(store, selectChatStatus) };
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
act(() => {
mockSocket.emitData({
type: "status",
chat_id: chatID,
status: { status: "completed" },
});
});
await waitFor(() => {
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
expect(sidebarChats?.[0].status).toBe("completed");
});
});
it("does not change sidebar updated_at on message stream event", async () => {
immediateAnimationFrame();
const chatID = "chat-sidebar-message";
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const initialChat = makeChat(chatID);
queryClient.setQueryData(chatsKey, [initialChat]);
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [],
chatRecord: initialChat,
chatData: {
chat: initialChat,
messages: [],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return {
orderedIDs: useChatSelector(store, selectOrderedMessageIDs),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
const messageTimestamp = "2025-06-15T12:00:00.000Z";
act(() => {
mockSocket.emitData({
type: "message",
chat_id: chatID,
message: {
...makeMessage(chatID, 42, "assistant", "hello"),
created_at: messageTimestamp,
},
});
});
// The per-chat WebSocket does not write updated_at — only the
// global chat-list WebSocket delivers the authoritative server
// timestamp. Verify it stays at the original value.
await waitFor(() => {
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
expect(sidebarChats?.[0].updated_at).toBe(initialChat.updated_at);
});
});
it("updates sidebar chat status to error on error stream event", async () => {
immediateAnimationFrame();
const chatID = "chat-sidebar-error";
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const initialChat = makeChat(chatID);
queryClient.setQueryData(chatsKey, [initialChat]);
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [],
chatRecord: initialChat,
chatData: {
chat: initialChat,
messages: [],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return { chatStatus: useChatSelector(store, selectChatStatus) };
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
act(() => {
mockSocket.emitData({
type: "error",
chat_id: chatID,
error: { message: "something went wrong" },
});
});
await waitFor(() => {
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
expect(sidebarChats?.[0].status).toBe("error");
});
});
it("does not update sidebar for a different chatID", async () => {
immediateAnimationFrame();
const chatID = "chat-active";
const otherChatID = "chat-other";
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const activeChat = makeChat(chatID);
const otherChat = makeChat(otherChatID);
queryClient.setQueryData(chatsKey, [activeChat, otherChat]);
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
renderHook(
() => {
useChatStore({
chatID,
chatMessages: [],
chatRecord: activeChat,
chatData: {
chat: activeChat,
messages: [],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
// Emit a status event for the *active* chat.
act(() => {
mockSocket.emitData({
type: "status",
chat_id: chatID,
status: { status: "completed" },
});
});
await waitFor(() => {
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
expect(sidebarChats?.find((c) => c.id === chatID)?.status).toBe(
"completed",
);
});
// The other chat should remain unchanged.
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
expect(sidebarChats?.find((c) => c.id === otherChatID)?.status).toBe(
"running",
);
});
it("does not regress updated_at on message events", async () => {
immediateAnimationFrame();
const chatID = "chat-no-regress-msg";
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const futureTimestamp = "2099-01-01T00:00:00.000Z";
const initialChat = { ...makeChat(chatID), updated_at: futureTimestamp };
queryClient.setQueryData(chatsKey, [initialChat]);
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [],
chatRecord: initialChat,
chatData: {
chat: initialChat,
messages: [],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return {
orderedIDs: useChatSelector(store, selectOrderedMessageIDs),
};
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
// The per-chat WS no longer writes updated_at, so any
// message event should leave it untouched.
act(() => {
mockSocket.emitData({
type: "message",
chat_id: chatID,
message: {
...makeMessage(chatID, 99, "assistant", "old message"),
created_at: "2020-01-01T00:00:00.000Z",
},
});
});
await waitFor(() => {
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
expect(sidebarChats?.[0].updated_at).toBe(futureTimestamp);
});
});
it("does not change updated_at on status events", async () => {
immediateAnimationFrame();
const chatID = "chat-no-regress-status";
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const initialChat = makeChat(chatID);
queryClient.setQueryData(chatsKey, [initialChat]);
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [],
chatRecord: initialChat,
chatData: {
chat: initialChat,
messages: [],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return { chatStatus: useChatSelector(store, selectChatStatus) };
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
act(() => {
mockSocket.emitData({
type: "status",
chat_id: chatID,
status: { status: "completed" },
});
});
await waitFor(() => {
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
// Status should update, but updated_at must stay untouched.
expect(sidebarChats?.[0].status).toBe("completed");
expect(sidebarChats?.[0].updated_at).toBe(initialChat.updated_at);
});
});
it("does not change updated_at on error events", async () => {
immediateAnimationFrame();
const chatID = "chat-no-regress-error";
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
});
const initialChat = makeChat(chatID);
queryClient.setQueryData(chatsKey, [initialChat]);
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
renderHook(
() => {
const { store } = useChatStore({
chatID,
chatMessages: [],
chatRecord: initialChat,
chatData: {
chat: initialChat,
messages: [],
queued_messages: [],
},
chatQueuedMessages: [],
setChatErrorReason,
clearChatErrorReason,
});
return { chatStatus: useChatSelector(store, selectChatStatus) };
},
{ wrapper },
);
await waitFor(() => {
expect(watchChat).toHaveBeenCalledWith(chatID, undefined);
});
act(() => {
mockSocket.emitData({
type: "error",
chat_id: chatID,
error: { message: "something broke" },
});
});
await waitFor(() => {
const sidebarChats = queryClient.getQueryData<TypesGen.Chat[]>(chatsKey);
expect(sidebarChats?.[0].status).toBe("error");
expect(sidebarChats?.[0].updated_at).toBe(initialChat.updated_at);
});
});
});
@@ -636,10 +636,11 @@ export const useChatStore = (
if (changed) {
scheduleStreamReset();
}
updateSidebarChat((chat) => ({
...chat,
updated_at: message.created_at ?? new Date().toISOString(),
}));
// Do not update updated_at here. The global
// chat-list WebSocket delivers the authoritative
// server timestamp; fabricating a client-side
// value causes the chat to flicker between time
// groups when the two sources race.
continue;
}
case "queue_update":
@@ -679,9 +680,7 @@ export const useChatStore = (
updateSidebarChat((chat) => ({
...chat,
status: nextStatus,
updated_at: new Date().toISOString(),
}));
continue;
}
case "error": {
@@ -695,7 +694,6 @@ export const useChatStore = (
updateSidebarChat((chat) => ({
...chat,
status: "error",
updated_at: new Date().toISOString(),
}));
continue;
}
+8 -2
View File
@@ -449,7 +449,10 @@ const AgentsPage: FC = () => {
...c,
status: updatedChat.status,
title: updatedChat.title,
updated_at: updatedChat.updated_at,
updated_at:
c.updated_at > updatedChat.updated_at
? c.updated_at
: updatedChat.updated_at,
}
: c,
);
@@ -472,7 +475,10 @@ const AgentsPage: FC = () => {
...previousChat.chat,
status: updatedChat.status,
title: updatedChat.title,
updated_at: updatedChat.updated_at,
updated_at:
previousChat.chat.updated_at > updatedChat.updated_at
? previousChat.chat.updated_at
: updatedChat.updated_at,
},
};
},
+1 -18
View File
@@ -53,6 +53,7 @@ import {
import { NavLink, useParams } from "react-router";
import { cn } from "utils/cn";
import { shortRelativeTime } from "utils/time";
import { getTimeGroup, TIME_GROUPS } from "./timeGroups";
interface AgentsSidebarProps {
chats: readonly Chat[];
@@ -88,24 +89,6 @@ type ChatTree = {
readonly parentById: ReadonlyMap<string, string | undefined>;
};
const TIME_GROUPS = ["Today", "Yesterday", "This Week", "Older"] as const;
type TimeGroup = (typeof TIME_GROUPS)[number];
function getTimeGroup(dateStr: string): TimeGroup {
const now = new Date();
const date = new Date(dateStr);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
if (date >= today) return "Today";
if (date >= yesterday) return "Yesterday";
if (date >= weekAgo) return "This Week";
return "Older";
}
const getStatusConfig = (status: ChatStatus) => {
return statusConfig[status] ?? statusConfig.completed;
};
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getTimeGroup, TIME_GROUPS } from "./timeGroups";
describe("getTimeGroup", () => {
beforeEach(() => {
// Pin "now" to 2025-07-15T14:30:00.000Z (a Tuesday) so all
// assertions are deterministic.
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-07-15T14:30:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("exports the expected group labels in order", () => {
expect(TIME_GROUPS).toEqual(["Today", "Yesterday", "This Week", "Older"]);
});
it('returns "Today" for a date later today', () => {
expect(getTimeGroup("2025-07-15T18:00:00.000Z")).toBe("Today");
});
it('returns "Today" for a date at start of today (midnight)', () => {
expect(getTimeGroup("2025-07-15T00:00:00.000Z")).toBe("Today");
});
it('returns "Today" for a date earlier today', () => {
expect(getTimeGroup("2025-07-15T08:00:00.000Z")).toBe("Today");
});
it('returns "Yesterday" for a date in the previous calendar day', () => {
expect(getTimeGroup("2025-07-14T10:00:00.000Z")).toBe("Yesterday");
});
it('returns "Yesterday" for exactly midnight of yesterday', () => {
expect(getTimeGroup("2025-07-14T00:00:00.000Z")).toBe("Yesterday");
});
it('returns "This Week" for a date 2 days ago', () => {
expect(getTimeGroup("2025-07-13T12:00:00.000Z")).toBe("This Week");
});
it('returns "This Week" for a date 6 days ago', () => {
expect(getTimeGroup("2025-07-09T12:00:00.000Z")).toBe("This Week");
});
it('returns "This Week" for exactly 7 days ago at midnight', () => {
// weekAgo = today - 7 days = 2025-07-08T00:00:00 local.
// A date at exactly that boundary should be "This Week"
// because the comparison is date >= weekAgo.
expect(getTimeGroup("2025-07-08T00:00:00.000Z")).toBe("This Week");
});
it('returns "Older" for a date 8 days ago', () => {
expect(getTimeGroup("2025-07-07T23:59:59.000Z")).toBe("Older");
});
it('returns "Older" for a date far in the past', () => {
expect(getTimeGroup("2024-01-01T00:00:00.000Z")).toBe("Older");
});
it('returns "Today" for a date in the future', () => {
expect(getTimeGroup("2025-07-20T00:00:00.000Z")).toBe("Today");
});
describe("midnight boundary edge cases", () => {
it('classifies 1 second before midnight as "Yesterday"', () => {
// One second before today's midnight in UTC.
expect(getTimeGroup("2025-07-14T23:59:59.000Z")).toBe("Yesterday");
});
it('classifies exactly midnight as "Today"', () => {
expect(getTimeGroup("2025-07-15T00:00:00.000Z")).toBe("Today");
});
it('classifies 1 second after midnight as "Today"', () => {
expect(getTimeGroup("2025-07-15T00:00:01.000Z")).toBe("Today");
});
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Time-based grouping utility used by the sidebar to categorize
* chats into "Today", "Yesterday", "This Week", and "Older".
*/
export const TIME_GROUPS = [
"Today",
"Yesterday",
"This Week",
"Older",
] as const;
type TimeGroup = (typeof TIME_GROUPS)[number];
export function getTimeGroup(dateStr: string): TimeGroup {
const now = new Date();
const date = new Date(dateStr);
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
if (date >= today) return "Today";
if (date >= yesterday) return "Yesterday";
if (date >= weekAgo) return "This Week";
return "Older";
}