| author | |
| committer | |
| log | ca6416ec2d1d194738bc78a81df4b82a75cb72e2 |
| tree | 34e10d2ed9956c4088506ba654cb86ca2e38a39b |
| parent | 4a4c6dd2adc350a68ba18bb28b24b5a7e4660dab |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ | ||
| 1 | import { EventId, ProjectId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | getLatestThreadForProject, | ||
| 4 | sortActiveThreadsByOrderKey, | ||
| 5 | sortPinnedThreadsByOrderKey, | ||
| 6 | sortThreads, | ||
| 7 | } from "@t3tools/client-runtime/state/thread-sort"; | ||
| 8 | import { formatHourShort, formatRelativeHourShort } from "@t3tools/shared/usageFormat"; | ||
| 9 | import { bench, describe } from "vite-plus/test"; | ||
| 10 | |||
| 11 | import { deriveActivePlanState } from "./session-logic"; | ||
| 12 | |||
| 13 | const projectId = ProjectId.make("benchmark-project"); | ||
| 14 | const turnId = TurnId.make("benchmark-turn"); | ||
| 15 | const start = Date.parse("2026-08-11T00:00:00.000Z"); | ||
| 16 | const 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 | }); | ||
| 28 | const 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 | })); | ||
| 38 | const hours = Array.from({ length: 24 }, (_, index) => | ||
| 39 | new Date(start + index * 3_600_000).toISOString(), | ||
| 40 | ); | ||
| 41 | const referenceTime = "2026-08-12T00:00:00.000Z"; | ||
| 42 | |||
| 43 | describe("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 | } |
| 63 | 63 | ||
| 64 | describe("deriveActivePlanState", () => { | 64 | describe("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 turn | 332 | // 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 @@ |
| 1 | import { ProjectId } from "@t3tools/contracts"; | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 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 | }); |
| 59 | 61 | ||
| 60 | describe("sortThreads", () => { | 62 | describe("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 | }); |
| 114 | 132 | ||
| 133 | describe("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 | |||
| 115 | describe("planPinnedReorder with hidden rows", () => { | 160 | describe("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 @@ |
| 1 | import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts"; | 1 | import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts"; |
| 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 3 | import * as Arr from "effect/Array"; | ||
| 4 | import * as Order from "effect/Order"; | ||
| 5 | 3 | ||
| 6 | export interface ThreadSortInput { | 4 | export 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 | } |
| 138 | 132 | ||
| 139 | export function getLatestThreadForProject< | 133 | export 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] ?? null | 144 | 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 | } |
| 153 | 156 | ||
| 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 | } |
| 316 | 319 | ||
| ... | @@ -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"; |
| 11 | 11 | ||
| 12 | describe("hourly usage formatting", () => { | 12 | describe("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"); |
| 15 | 39 |
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 |
| 82 | 82 | ||
| 83 | const HOUR_MS = 60 * 60 * 1000; | 83 | const HOUR_MS = 60 * 60 * 1000; |
| 84 | 84 | ||
| 85 | const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>(); | ||
| 86 | |||
| 87 | function 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. */ |
| 86 | export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] { | 103 | export 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 | ); |
| 124 | 141 | ||
| 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 { |
| 134 | export function formatDateTimeShort(instant: string, timeZone?: string): string { | 151 | export 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 | } |
| 156 | 173 | ||
| 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", |