authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-12 09:26:56+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 11:56:56+05:30
logca6416ec2d1d194738bc78a81df4b82a75cb72e2
tree34e10d2ed9956c4088506ba654cb86ca2e38a39b
parent4a4c6dd2adc350a68ba18bb28b24b5a7e4660dab
signature Signed by PGP key B5690EEEBB952194

perf(client): reduce repeated sorting and date formatting (#11019)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

7 files changed, 226 insertions(+), 37 deletions(-)

apps/web/src/performance.bench.ts created+65
...@@ -0,0 +1,65 @@
1import { EventId, ProjectId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
2import {
3 getLatestThreadForProject,
4 sortActiveThreadsByOrderKey,
5 sortPinnedThreadsByOrderKey,
6 sortThreads,
7} from "@t3tools/client-runtime/state/thread-sort";
8import { formatHourShort, formatRelativeHourShort } from "@t3tools/shared/usageFormat";
9import { bench, describe } from "vite-plus/test";
10
11import { deriveActivePlanState } from "./session-logic";
12
13const projectId = ProjectId.make("benchmark-project");
14const turnId = TurnId.make("benchmark-turn");
15const start = Date.parse("2026-08-11T00:00:00.000Z");
16const threads = Array.from({ length: 1_000 }, (_, index) => {
17 const timestamp = new Date(start + ((index * 997) % 1_000) * 60_000).toISOString();
18 return {
19 id: `thread-${index}`,
20 projectId,
21 archivedAt: null,
22 createdAt: timestamp,
23 updatedAt: timestamp,
24 latestUserMessageAt: timestamp,
25 unsettledAt: null,
26 };
27});
28const activities: OrchestrationThreadActivity[] = Array.from({ length: 500 }, (_, index) => ({
29 id: EventId.make(`activity-${index}`),
30 turnId,
31 sequence: index,
32 createdAt: new Date(start + index * 1_000).toISOString(),
33 kind: index % 100 === 0 ? "turn.plan.updated" : "tool.completed",
34 summary: "Benchmark activity",
35 tone: "info",
36 payload: index % 100 === 0 ? { plan: [{ step: "Run checks", status: "inProgress" }] } : {},
37}));
38const hours = Array.from({ length: 24 }, (_, index) =>
39 new Date(start + index * 3_600_000).toISOString(),
40);
41const referenceTime = "2026-08-12T00:00:00.000Z";
42
43describe("client performance", () => {
44 bench("sort 1000 threads by recent activity", () => {
45 sortThreads(threads, "updated_at");
46 });
47 bench("sort 1000 active threads", () => {
48 sortActiveThreadsByOrderKey(threads);
49 });
50 bench("sort 1000 keyless pinned threads", () => {
51 sortPinnedThreadsByOrderKey(threads);
52 });
53 bench("select latest project thread from 1000 threads", () => {
54 getLatestThreadForProject(threads, projectId, "updated_at");
55 });
56 bench("derive plan from 500 activities with 5 plan updates", () => {
57 deriveActivePlanState(activities, turnId);
58 });
59 bench("format 24 hourly usage labels and tooltips", () => {
60 hours.map((hour) => [
61 formatHourShort(hour, "America/New_York"),
62 formatRelativeHourShort(hour, referenceTime, "America/New_York"),
63 ]);
64 });
65});
apps/web/src/session-logic.test.ts+27
...@@ -62,6 +62,33 @@ function makeActivity(overrides: {...@@ -62,6 +62,33 @@ function makeActivity(overrides: {
62}62}
6363
64describe("deriveActivePlanState", () => {64describe("deriveActivePlanState", () => {
65 it("orders plan snapshots by sequence while ignoring unrelated activities", () => {
66 const activities = Object.freeze([
67 makeActivity({
68 id: "completed",
69 kind: "turn.plan.updated",
70 turnId: "turn-1",
71 sequence: 3,
72 createdAt: "2026-02-23T00:00:05.000Z",
73 payload: { plan: [{ step: "Check", status: "completed" }] },
74 }),
75 makeActivity({ sequence: 4, kind: "tool.completed" }),
76 makeActivity({
77 id: "started",
78 kind: "turn.plan.updated",
79 turnId: "turn-1",
80 sequence: 1,
81 payload: { plan: [{ step: "Check", status: "inProgress" }] },
82 }),
83 makeActivity({ sequence: 2, kind: "context-window.updated" }),
84 ]);
85 expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([
86 { step: "Check", status: "completed", durationMs: 5_000 },
87 ]);
88 expect(activities[0]?.id).toBe("completed");
89 expect(deriveActivePlanState([makeActivity({ kind: "tool.completed" })], undefined)).toBeNull();
90 });
91
65 it("returns the latest plan update for the active turn", () => {92 it("returns the latest plan update for the active turn", () => {
66 const activities: OrchestrationThreadActivity[] = [93 const activities: OrchestrationThreadActivity[] = [
67 makeActivity({94 makeActivity({
apps/web/src/session-logic.ts+3-2
...@@ -326,8 +326,9 @@ export function deriveActivePlanState(...@@ -326,8 +326,9 @@ export function deriveActivePlanState(
326 activities: ReadonlyArray<OrchestrationThreadActivity>,326 activities: ReadonlyArray<OrchestrationThreadActivity>,
327 latestTurnId: TurnId | undefined,327 latestTurnId: TurnId | undefined,
328): ActivePlanState | null {328): ActivePlanState | null {
329 const ordered = [...activities].toSorted(compareActivitiesByOrder);329 const allPlanActivities = activities
330 const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");330 .filter((activity) => activity.kind === "turn.plan.updated")
331 .sort(compareActivitiesByOrder);
331 // Prefer plan from the current turn; fall back to the most recent plan from any turn332 // Prefer plan from the current turn; fall back to the most recent plan from any turn
332 // so that TodoWrite tasks persist across follow-up messages.333 // so that TodoWrite tasks persist across follow-up messages.
333 const latest = Option.firstSomeOf([334 const latest = Option.firstSomeOf([
packages/client-runtime/src/state/threadSort.test.ts+45
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import { ProjectId } from "@t3tools/contracts";
1import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
23
3import {4import {
4 generateSpreadPinOrderKeys,5 generateSpreadPinOrderKeys,
6 getLatestThreadForProject,
5 pinOrderKeyBetween,7 pinOrderKeyBetween,
6 planPinnedMove,8 planPinnedMove,
7 planPinnedReorder,9 planPinnedReorder,
...@@ -58,6 +60,22 @@ describe("resolveSettledThreadTimestamp", () => {...@@ -58,6 +60,22 @@ describe("resolveSettledThreadTimestamp", () => {
58});60});
5961
60describe("sortThreads", () => {62describe("sortThreads", () => {
63 it.each(["created_at", "updated_at"] as const)(
64 "preserves references, input order and descending id ties for %s",
65 (sortOrder) => {
66 const threads = Object.freeze([
67 makeThread({ id: "a" }),
68 makeThread({ id: "z" }),
69 makeThread({ id: "invalid-a", createdAt: "invalid", updatedAt: "invalid" }),
70 makeThread({ id: "invalid-z", createdAt: "invalid", updatedAt: "invalid" }),
71 ]);
72 const sorted = sortThreads(threads, sortOrder);
73 expect(sorted).toEqual([threads[1], threads[0], threads[3], threads[2]]);
74 expect(sorted[0]).toBe(threads[1]);
75 expect(threads[0]?.id).toBe("a");
76 },
77 );
78
61 it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => {79 it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => {
62 const sorted = sortThreads(80 const sorted = sortThreads(
63 [81 [
...@@ -112,6 +130,33 @@ describe("sortThreads", () => {...@@ -112,6 +130,33 @@ describe("sortThreads", () => {
112 });130 });
113});131});
114132
133describe("getLatestThreadForProject", () => {
134 it.each(["created_at", "updated_at"] as const)(
135 "matches the first sorted eligible thread for %s",
136 (sortOrder) => {
137 const projectId = ProjectId.make("project");
138 const threads = [
139 { ...makeThread({ id: "a" }), projectId, archivedAt: null },
140 { ...makeThread({ id: "z" }), projectId, archivedAt: null },
141 { ...makeThread({ id: "zz" }), projectId, archivedAt: "2026-03-10T00:00:00Z" },
142 { ...makeThread({ id: "zzz" }), projectId: ProjectId.make("other"), archivedAt: null },
143 ];
144 expect(getLatestThreadForProject(threads, projectId, sortOrder)).toBe(threads[1]);
145 expect(getLatestThreadForProject([], projectId, sortOrder)).toBeNull();
146 expect(getLatestThreadForProject(threads, ProjectId.make("missing"), sortOrder)).toBeNull();
147 const invalid = threads.slice(0, 2).map((thread) => ({
148 ...thread,
149 createdAt: "invalid",
150 updatedAt: "invalid",
151 }));
152 expect(getLatestThreadForProject(invalid, projectId, sortOrder)).toBe(invalid[1]);
153 expect(
154 getLatestThreadForProject([threads[1]!, { ...threads[1]! }], projectId, sortOrder),
155 ).toBe(threads[1]);
156 },
157 );
158});
159
115describe("planPinnedReorder with hidden rows", () => {160describe("planPinnedReorder with hidden rows", () => {
116 it("keeps hidden slots available when inserting between visible neighbors", () => {161 it("keeps hidden slots available when inserting between visible neighbors", () => {
117 const midpoint = pinOrderKeyBetween("f", "t")!;162 const midpoint = pinOrderKeyBetween("f", "t")!;
packages/client-runtime/src/state/threadSort.ts+40-30
...@@ -1,7 +1,5 @@...@@ -1,7 +1,5 @@
1import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts";1import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts";
2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";2import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings";
3import * as Arr from "effect/Array";
4import * as Order from "effect/Order";
53
6export interface ThreadSortInput {4export interface ThreadSortInput {
7 readonly createdAt: string;5 readonly createdAt: string;
...@@ -121,19 +119,15 @@ export function sortThreads<T extends { readonly id: string } & ThreadSortInput>...@@ -121,19 +119,15 @@ export function sortThreads<T extends { readonly id: string } & ThreadSortInput>
121 threads: readonly T[],119 threads: readonly T[],
122 sortOrder: SidebarThreadSortOrder,120 sortOrder: SidebarThreadSortOrder,
123): T[] {121): T[] {
124 return Arr.sort(122 if (threads.length < 2) return [...threads];
125 threads,123 return threads
126 Order.mapInput(124 .map((thread) => ({ thread, timestamp: getThreadSortTimestamp(thread, sortOrder) }))
127 Order.Struct({125 .sort(
128 timestamp: Order.flip(Order.Number),126 (left, right) =>
129 id: Order.flip(Order.String),127 right.timestamp - left.timestamp ||
130 }),128 (left.thread.id < right.thread.id ? 1 : left.thread.id > right.thread.id ? -1 : 0),
131 (thread: T) => ({129 )
132 timestamp: getThreadSortTimestamp(thread, sortOrder),130 .map(({ thread }) => thread);
133 id: thread.id,
134 }),
135 ),
136 );
137}131}
138132
139export function getLatestThreadForProject<133export function getLatestThreadForProject<
...@@ -143,12 +137,21 @@ export function getLatestThreadForProject<...@@ -143,12 +137,21 @@ export function getLatestThreadForProject<
143 readonly archivedAt: string | null;137 readonly archivedAt: string | null;
144 } & ThreadSortInput,138 } & ThreadSortInput,
145>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null {139>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null {
146 return (140 let latest: T | null = null;
147 sortThreads(141 let latestTimestamp = Number.NEGATIVE_INFINITY;
148 threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null),142 for (const thread of threads) {
149 sortOrder,143 if (thread.projectId !== projectId || thread.archivedAt !== null) continue;
150 )[0] ?? null144 const timestamp = getThreadSortTimestamp(thread, sortOrder);
151 );145 if (
146 latest === null ||
147 timestamp > latestTimestamp ||
148 (timestamp === latestTimestamp && thread.id > latest.id)
149 ) {
150 latest = thread;
151 latestTimestamp = timestamp;
152 }
153 }
154 return latest;
152}155}
153156
154// ── Pinned reorder: fractional index keys ──────────────────────────────157// ── Pinned reorder: fractional index keys ──────────────────────────────
...@@ -290,6 +293,7 @@ export function sortPinnedThreadsByOrderKey<...@@ -290,6 +293,7 @@ export function sortPinnedThreadsByOrderKey<
290 readonly environmentId?: string | undefined;293 readonly environmentId?: string | undefined;
291 },294 },
292>(threads: readonly T[]): T[] {295>(threads: readonly T[]): T[] {
296 if (threads.length < 2) return [...threads];
293 const keyed: T[] = [];297 const keyed: T[] = [];
294 const keyless: T[] = [];298 const keyless: T[] = [];
295 for (const thread of threads) {299 for (const thread of threads) {
...@@ -303,14 +307,13 @@ export function sortPinnedThreadsByOrderKey<...@@ -303,14 +307,13 @@ export function sortPinnedThreadsByOrderKey<
303 const rightKey = right.pinOrderKey!;307 const rightKey = right.pinOrderKey!;
304 return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right);308 return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right);
305 });309 });
306 keyless.sort((left, right) => {310 const timestamps = new Map(
307 const leftMs = Date.parse(left.createdAt);311 keyless.map((thread) => [thread, toSortableTimestamp(thread.createdAt) ?? 0]),
308 const rightMs = Date.parse(right.createdAt);312 );
309 return (313 keyless.sort(
310 (Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) ||314 (left, right) =>
311 identityTiebreak(left, right)315 timestamps.get(right)! - timestamps.get(left)! || identityTiebreak(left, right),
312 );316 );
313 });
314 return [...keyed, ...keyless];317 return [...keyed, ...keyless];
315}318}
316319
...@@ -325,6 +328,13 @@ export function sortActiveThreadsByOrderKey<...@@ -325,6 +328,13 @@ export function sortActiveThreadsByOrderKey<
325 readonly environmentId?: string | undefined;328 readonly environmentId?: string | undefined;
326 },329 },
327>(threads: readonly T[]): T[] {330>(threads: readonly T[]): T[] {
331 if (threads.length < 2) return [...threads];
332 const timestamps = new Map<T, number>();
333 for (const thread of threads) {
334 if (thread.activeOrderKey == null) {
335 timestamps.set(thread, activeThreadAnchorTimestampMs(thread));
336 }
337 }
328 return [...threads].sort((left, right) => {338 return [...threads].sort((left, right) => {
329 const leftKey = left.activeOrderKey;339 const leftKey = left.activeOrderKey;
330 const rightKey = right.activeOrderKey;340 const rightKey = right.activeOrderKey;
...@@ -334,7 +344,7 @@ export function sortActiveThreadsByOrderKey<...@@ -334,7 +344,7 @@ export function sortActiveThreadsByOrderKey<
334 if (leftKey != null && rightKey != null) {344 if (leftKey != null && rightKey != null) {
335 order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;345 order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
336 } else {346 } else {
337 order = activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left);347 order = timestamps.get(right)! - timestamps.get(left)!;
338 }348 }
339 return (349 return (
340 order ||350 order ||
packages/shared/src/usageFormat.test.ts+24
...@@ -10,6 +10,30 @@ import {...@@ -10,6 +10,30 @@ import {
10} from "./usageFormat.ts";10} from "./usageFormat.ts";
1111
12describe("hourly usage formatting", () => {12describe("hourly usage formatting", () => {
13 it("keeps requested zones separate when formatting repeated calls", () => {
14 const instant = "2026-08-11T12:37:00.000Z";
15 for (const zone of ["UTC", "America/New_York", "Asia/Kathmandu", "UTC"]) {
16 expect(formatHourShort(instant, zone)).toBe(
17 new Intl.DateTimeFormat("en-US", { timeZone: zone, hour: "numeric" }).format(
18 new Date(instant),
19 ),
20 );
21 }
22 expect(() => formatHourShort(instant, "Etc/Unknown")).toThrow(RangeError);
23 expect(formatHourShort("invalid", "UTC")).toBe("invalid");
24 });
25
26 it("uses the current system zone when no zone is supplied", () => {
27 try {
28 vi.stubEnv("TZ", "UTC");
29 expect(formatHourShort("2026-08-11T12:37:00.000Z")).toBe("12 PM");
30 vi.stubEnv("TZ", "America/New_York");
31 expect(formatHourShort("2026-08-11T12:37:00.000Z")).toBe("8 AM");
32 } finally {
33 vi.unstubAllEnvs();
34 }
35 });
36
13 it("enumerates 24 fixed buckets across a rolling window", () => {37 it("enumerates 24 fixed buckets across a rolling window", () => {
14 const hours = enumerateHourStarts("2026-08-10T12:37:00.000Z", "2026-08-11T12:37:00.000Z");38 const hours = enumerateHourStarts("2026-08-10T12:37:00.000Z", "2026-08-11T12:37:00.000Z");
1539
packages/shared/src/usageFormat.ts+22-5
...@@ -82,6 +82,23 @@ export function enumerateDays(sinceDay: string, untilDay: string): readonly stri...@@ -82,6 +82,23 @@ export function enumerateDays(sinceDay: string, untilDay: string): readonly stri
8282
83const HOUR_MS = 60 * 60 * 1000;83const HOUR_MS = 60 * 60 * 1000;
8484
85const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
86
87function dateTimeFormatter(
88 locale: string,
89 options: Intl.DateTimeFormatOptions,
90): Intl.DateTimeFormat {
91 if (options.timeZone === undefined) return new Intl.DateTimeFormat(locale, options);
92 const key = JSON.stringify([locale, options]);
93 let formatter = dateTimeFormatters.get(key);
94 if (formatter === undefined) {
95 formatter = new Intl.DateTimeFormat(locale, options);
96 if (dateTimeFormatters.size >= 16) dateTimeFormatters.clear();
97 dateTimeFormatters.set(key, formatter);
98 }
99 return formatter;
100}
101
85/** Every fixed-duration bucket start in an hourly rolling window. */102/** Every fixed-duration bucket start in an hourly rolling window. */
86export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] {103export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] {
87 const starts: string[] = [];104 const starts: string[] = [];
...@@ -105,11 +122,11 @@ export function formatHourShort(hourStart: string, timeZone?: string): string {...@@ -105,11 +122,11 @@ export function formatHourShort(hourStart: string, timeZone?: string): string {
105 const instant = new Date(hourStart);122 const instant = new Date(hourStart);
106 if (Number.isNaN(instant.getTime())) return hourStart;123 if (Number.isNaN(instant.getTime())) return hourStart;
107 const options = timeZone === undefined ? {} : { timeZone };124 const options = timeZone === undefined ? {} : { timeZone };
108 const hourFormat = new Intl.DateTimeFormat("en-US", {125 const hourFormat = dateTimeFormatter("en-US", {
109 ...options,126 ...options,
110 hour: "numeric",127 hour: "numeric",
111 });128 });
112 const wallHourFormat = new Intl.DateTimeFormat("en-CA", {129 const wallHourFormat = dateTimeFormatter("en-CA", {
113 ...options,130 ...options,
114 year: "numeric",131 year: "numeric",
115 month: "2-digit",132 month: "2-digit",
...@@ -123,7 +140,7 @@ export function formatHourShort(hourStart: string, timeZone?: string): string {...@@ -123,7 +140,7 @@ export function formatHourShort(hourStart: string, timeZone?: string): string {
123 );140 );
124141
125 if (!isRepeatedHour) return hourFormat.format(instant);142 if (!isRepeatedHour) return hourFormat.format(instant);
126 return new Intl.DateTimeFormat("en-US", {143 return dateTimeFormatter("en-US", {
127 ...(timeZone === undefined ? {} : { timeZone }),144 ...(timeZone === undefined ? {} : { timeZone }),
128 hour: "numeric",145 hour: "numeric",
129 timeZoneName: "short",146 timeZoneName: "short",
...@@ -134,7 +151,7 @@ export function formatHourShort(hourStart: string, timeZone?: string): string {...@@ -134,7 +151,7 @@ export function formatHourShort(hourStart: string, timeZone?: string): string {
134export function formatDateTimeShort(instant: string, timeZone?: string): string {151export function formatDateTimeShort(instant: string, timeZone?: string): string {
135 const date = new Date(instant);152 const date = new Date(instant);
136 if (Number.isNaN(date.getTime())) return instant;153 if (Number.isNaN(date.getTime())) return instant;
137 return new Intl.DateTimeFormat("en-US", {154 return dateTimeFormatter("en-US", {
138 ...(timeZone === undefined ? {} : { timeZone }),155 ...(timeZone === undefined ? {} : { timeZone }),
139 month: "short",156 month: "short",
140 day: "numeric",157 day: "numeric",
...@@ -154,7 +171,7 @@ export function formatRelativeHourShort(...@@ -154,7 +171,7 @@ export function formatRelativeHourShort(
154 return formatDateTimeShort(hourStart, timeZone);171 return formatDateTimeShort(hourStart, timeZone);
155 }172 }
156173
157 const dayFormat = new Intl.DateTimeFormat("en-CA", {174 const dayFormat = dateTimeFormatter("en-CA", {
158 ...(timeZone === undefined ? {} : { timeZone }),175 ...(timeZone === undefined ? {} : { timeZone }),
159 year: "numeric",176 year: "numeric",
160 month: "2-digit",177 month: "2-digit",