diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index ee3a1a6900..9cda604b8c 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -2312,10 +2312,21 @@ describe("IssueProperties", () => { }); it("renders scheduled, retrying, due, overdue, cleared, and empty monitor row states", async () => { - const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-07-17T13:56:00.000Z").getTime()); + // Anchored to a fixed *local* time, not a fixed UTC instant. The row renders + // these in the machine's timezone and labels them "Today" only while they + // share a local calendar day with `now`. Pinned to UTC, the pair straddles + // local midnight from UTC+8 to UTC+9:30 — the label becomes a date and every + // assertion below fails for a reason that has nothing to do with row states. + // Anchoring locally keeps them on one day everywhere, which also makes the + // rendered clock identical in every timezone, so the times below can stay + // exact rather than being relaxed to a pattern. + const NOW = new Date(2026, 6, 17, 13, 56, 0, 0); + const at = (minutesFromNow: number) => + new Date(NOW.getTime() + minutesFromNow * 60_000).toISOString(); + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(NOW.getTime()); const baseMonitorState = { status: "scheduled" as const, - nextCheckAt: "2026-07-17T16:08:00.000Z", + nextCheckAt: at(132), lastTriggeredAt: null, attemptCount: 1, notes: "Verify deployment", @@ -2343,12 +2354,16 @@ describe("IssueProperties", () => { })); await flush(); expect(monitorRowText()).toContain("In 2h 12m"); + // The hour is rendered in the machine's timezone, so it is not pinned here: + // this instant is 4:08 PM at UTC and 9:08 AM at UTC-7. What the row states + // are actually about — the countdown and the attempt suffix — is asserted + // exactly, and the countdown above is timezone-independent already. expect(monitorRowText()).toContain("Today, 4:08 PM · Attempt 1"); renderMonitor(createIssue({ - executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T18:08:00.000Z" } }), - executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T16:08:00.000Z" } }), - monitorNextCheckAt: new Date("2026-07-17T17:08:00.000Z"), + executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(252) } }), + executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(132) } }), + monitorNextCheckAt: new Date(at(192)), })); await flush(); expect(monitorRowText()).toContain("In 2h 12m"); @@ -2363,16 +2378,16 @@ describe("IssueProperties", () => { expect(monitorRowText()).toContain("Attempt 3"); renderMonitor(createIssue({ - executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:56:00.000Z" } }), - executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:56:00.000Z" } }), + executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(0) } }), + executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(0) } }), })); await flush(); expect(monitorRowText()).toContain("Due now"); expect(monitorRowText()).toContain("checking momentarily…"); renderMonitor(createIssue({ - executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:38:00.000Z" } }), - executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:38:00.000Z" } }), + executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: at(-18) } }), + executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: at(-18) } }), })); await flush(); expect(monitorRowText()).toContain("Overdue by 18m"); @@ -2384,13 +2399,13 @@ describe("IssueProperties", () => { ...baseMonitorState, status: "cleared", nextCheckAt: null, - lastTriggeredAt: "2026-07-17T11:56:00.000Z", + lastTriggeredAt: at(-120), attemptCount: 2, - clearedAt: "2026-07-17T12:00:00.000Z", + clearedAt: at(-116), clearReason: "manual", } }), monitorAttemptCount: 2, - monitorLastTriggeredAt: new Date("2026-07-17T11:56:00.000Z"), + monitorLastTriggeredAt: new Date(at(-120)), })); await flush(); expect(monitorRowText()).toContain("Cleared"); diff --git a/ui/src/components/SummarySlotCard.test.tsx b/ui/src/components/SummarySlotCard.test.tsx index 4e81c718ee..3a0fd086b5 100644 --- a/ui/src/components/SummarySlotCard.test.tsx +++ b/ui/src/components/SummarySlotCard.test.tsx @@ -409,22 +409,28 @@ describe("SummarySlotCard", () => { }); it("switches to a historical revision from a dated dropdown", async () => { + // Local, not UTC: the dropdown renders these dates in the machine's + // timezone, so `2026-07-14T17:10:00.000Z` is the 15th at UTC+9 and the + // "Jul 14" assertions below fail. Midday local keeps each revision on its + // intended calendar day everywhere. + const localAt = (day: number, hour: number) => + new Date(2026, 6, day, hour, 0, 0, 0).toISOString(); mockSummarySlotsApi.get.mockResolvedValue({ slot: slot({ documentId: "doc-1" }), document: summaryDocument({ body: "## Latest\nCurrent body", latestRevisionId: "rev-3", latestRevisionNumber: 3, - updatedAt: "2026-07-14T17:10:00.000Z", + updatedAt: localAt(14, 17), }), generatingIssue: null, } satisfies GetSummarySlotResponse); mockSummarySlotsApi.revisions.mockResolvedValue({ slot: slot({ documentId: "doc-1" }), revisions: [ - revision({ id: "rev-1", revisionNumber: 1, body: "## Old\nOld body", createdAt: "2026-07-13T17:10:00.000Z" }), - revision({ id: "rev-2", revisionNumber: 2, body: "## Middle\nMiddle body", createdAt: "2026-07-14T09:15:00.000Z" }), - revision({ id: "rev-3", revisionNumber: 3, body: "## Latest\nCurrent body", createdAt: "2026-07-14T17:10:00.000Z" }), + revision({ id: "rev-1", revisionNumber: 1, body: "## Old\nOld body", createdAt: localAt(13, 17) }), + revision({ id: "rev-2", revisionNumber: 2, body: "## Middle\nMiddle body", createdAt: localAt(14, 9) }), + revision({ id: "rev-3", revisionNumber: 3, body: "## Latest\nCurrent body", createdAt: localAt(14, 17) }), ], }); diff --git a/ui/src/components/artifacts/ArtifactCard.test.tsx b/ui/src/components/artifacts/ArtifactCard.test.tsx index 538691e8de..9046b30ece 100644 --- a/ui/src/components/artifacts/ArtifactCard.test.tsx +++ b/ui/src/components/artifacts/ArtifactCard.test.tsx @@ -40,7 +40,9 @@ function makeArtifact(overrides: Partial = {}): CompanyArtifact issue: { id: "issue-1", identifier: "PAP-10306", title: "Landing visuals" }, project: { id: "proj-1", name: "Paperclip App" }, createdByAgent: { id: "agent-1", name: "ClaudeCoder" }, - updatedAt: "2026-06-01T12:00:00.000Z", + // Local, not UTC: the card renders "Last edited" from the local calendar + // day, and noon UTC is already the 2nd at UTC+14. + updatedAt: new Date(2026, 5, 1, 12, 0, 0, 0).toISOString(), href: "/issues/PAP-10306#attachment-art-1", ...overrides, }; @@ -69,7 +71,7 @@ describe("ArtifactCard", () => { artifact={makeArtifact({ title: "Social launch clip", issue: { id: "issue-2", identifier: "PAP-10370", title: "Make artifact page look like this" }, - updatedAt: "2025-10-08T12:00:00.000Z", + updatedAt: new Date(2025, 9, 8, 12, 0, 0, 0).toISOString(), createdByAgent: null, })} />, diff --git a/ui/src/fixtures/issueThreadInteractionFixtures.ts b/ui/src/fixtures/issueThreadInteractionFixtures.ts index a5cc470199..217e4651d2 100644 --- a/ui/src/fixtures/issueThreadInteractionFixtures.ts +++ b/ui/src/fixtures/issueThreadInteractionFixtures.ts @@ -912,8 +912,11 @@ export const issueClosedRequestConfirmationInteraction = id: "interaction-confirmation-issue-closed", title: "Expired: confirm the migration cutover", status: "expired", - resolvedAt: new Date("2026-04-20T15:12:00.000Z"), - updatedAt: new Date("2026-04-20T15:12:00.000Z"), + // Local, not UTC. The expiry footer renders this in the machine's timezone, + // and 15:12Z on the 20th is already the 21st at UTC+9, which breaks the + // "Apr 20" assertion in IssueThreadInteractionCard.test.tsx. + resolvedAt: new Date(2026, 3, 20, 15, 12, 0, 0), + updatedAt: new Date(2026, 3, 20, 15, 12, 0, 0), result: { version: 1, outcome: "issue_closed", diff --git a/ui/src/lib/attention.test.ts b/ui/src/lib/attention.test.ts index 76e1bdff0f..afd5050d41 100644 --- a/ui/src/lib/attention.test.ts +++ b/ui/src/lib/attention.test.ts @@ -430,14 +430,22 @@ describe("sortAttentionItems", () => { }); }); +// `attentionDateBucket` walks back from the start of the *local* day +// (`setHours(0, 0, 0, 0)`), so every date fixture below is local too. Pinned to +// UTC instants they drift across the boundary under test: `2026-07-09T23:00:00Z` +// is 08:00 on the 10th at UTC+9 and buckets as "today", and at UTC+14 and UTC-11 +// even the mid-morning fixtures land on the wrong calendar day. +const localTime = (month: number, day: number, hour: number) => + new Date(2026, month - 1, day, hour, 0, 0, 0); + describe("attentionDateBucket", () => { - const now = new Date("2026-07-10T12:00:00Z").getTime(); + const now = localTime(7, 10, 12).getTime(); it("buckets by rolling calendar-day windows relative to now", () => { - expect(attentionDateBucket("2026-07-10T09:00:00Z", now)).toBe("today"); - expect(attentionDateBucket("2026-07-09T23:00:00Z", now)).toBe("yesterday"); - expect(attentionDateBucket("2026-07-06T09:00:00Z", now)).toBe("this_week"); - expect(attentionDateBucket("2026-06-01T09:00:00Z", now)).toBe("earlier"); + expect(attentionDateBucket(localTime(7, 10, 9).toISOString(), now)).toBe("today"); + expect(attentionDateBucket(localTime(7, 9, 23).toISOString(), now)).toBe("yesterday"); + expect(attentionDateBucket(localTime(7, 6, 9).toISOString(), now)).toBe("this_week"); + expect(attentionDateBucket(localTime(6, 1, 9).toISOString(), now)).toBe("earlier"); }); it("treats invalid timestamps as earlier", () => { @@ -446,7 +454,7 @@ describe("attentionDateBucket", () => { }); describe("groupAttentionItems", () => { - const now = new Date("2026-07-10T12:00:00Z").getTime(); + const now = localTime(7, 10, 12).getTime(); it("leaves None as one unlabeled group that preserves caller sort order", () => { const items = sortAttentionItems( @@ -464,9 +472,9 @@ describe("groupAttentionItems", () => { it("groups by date into fixed Today/Yesterday/This week/Earlier order", () => { const items = [ - buildItem({ id: "earlier", activityAt: "2026-06-01T00:00:00Z" }), - buildItem({ id: "today", activityAt: "2026-07-10T08:00:00Z" }), - buildItem({ id: "yesterday", activityAt: "2026-07-09T08:00:00Z" }), + buildItem({ id: "earlier", activityAt: localTime(6, 1, 9).toISOString() }), + buildItem({ id: "today", activityAt: localTime(7, 10, 8).toISOString() }), + buildItem({ id: "yesterday", activityAt: localTime(7, 9, 8).toISOString() }), ]; const groups = groupAttentionItems(items, "date", { now }); expect(groups.map((g) => g.label)).toEqual(["Today", "Yesterday", "Earlier"]); @@ -508,8 +516,8 @@ describe("groupAttentionItems", () => { it("preserves the caller-provided intra-group order (sort governs within a bucket)", () => { const items = sortAttentionItems( [ - buildItem({ id: "t1", activityAt: "2026-07-10T08:00:00Z" }), - buildItem({ id: "t2", activityAt: "2026-07-10T10:00:00Z" }), + buildItem({ id: "t1", activityAt: localTime(7, 10, 8).toISOString() }), + buildItem({ id: "t2", activityAt: localTime(7, 10, 10).toISOString() }), ], "newest", ); diff --git a/ui/src/pages/StatusCards/format.test.ts b/ui/src/pages/StatusCards/format.test.ts index d44f58e3e4..c5007b9e62 100644 --- a/ui/src/pages/StatusCards/format.test.ts +++ b/ui/src/pages/StatusCards/format.test.ts @@ -22,7 +22,7 @@ function update(overrides: Partial): StatusCardUpdate { model: null, queryVersion: 1, changeSummary: null, - startedAt: new Date().toISOString(), + startedAt: NOW.toISOString(), finishedAt: null, status: "ok", error: null, @@ -30,18 +30,25 @@ function update(overrides: Partial): StatusCardUpdate { }; } -// A fixed instant rather than the real clock. `rollupUpdatesToday` filters on -// the *UTC* day boundary, so a suite that builds its fixtures from `new Date()` -// fails in two ways: it straddles midnight UTC if the run happens to cross it, -// and in any zone east of UTC+12 "today at local noon" is already yesterday in -// UTC, so the rows it means to count are filtered out. The function takes `now` -// for exactly this reason — the sibling test below already passes one. +// A fixed instant rather than the real clock, so these fixtures mean the same +// thing on every machine and at every hour. `rollupUpdatesToday` filters on the +// *UTC* day boundary, and building from `new Date()` fails against that in two +// separate ways — see `iso` below. The function takes `now` for exactly this +// reason, and the sibling test at the bottom of this file already passes one. const NOW = new Date("2026-07-23T12:00:00.000Z"); function iso(daysAgo: number): string { const d = new Date(NOW); d.setUTCDate(d.getUTCDate() - daysAgo); // Noon UTC, so a row is unambiguously inside the UTC day it belongs to. + // + // Built on the *local* day instead, `iso(0)` misses in both directions. West + // of UTC it lands in the previous UTC day for the stretch between UTC + // midnight and local midnight — about seven hours a day at UTC-7 — and east + // of UTC+12 "today at local noon" is already yesterday in UTC outright. + // Either way today's rows drop out of a filter that means to count them. A + // run crossing midnight UTC splits the same way, with `iso(0)` and the + // function's default `now` landing on different days. d.setUTCHours(12, 0, 0, 0); return d.toISOString(); } diff --git a/ui/src/pages/agent-skills/AgentSkillReleasePicker.test.ts b/ui/src/pages/agent-skills/AgentSkillReleasePicker.test.ts index 6f896b341d..6fc383362f 100644 --- a/ui/src/pages/agent-skills/AgentSkillReleasePicker.test.ts +++ b/ui/src/pages/agent-skills/AgentSkillReleasePicker.test.ts @@ -31,8 +31,12 @@ describe("formatReleaseDate", () => { }); it("collapses a full timestamp to its calendar day", () => { - // Anchored to noon UTC so it stays on the 15th regardless of local offset. - expect(formatReleaseDate("2026-07-15T12:00:00Z" as never)).toBe("2026-07-15"); + // Anchored to local noon. `formatReleaseDate` reads the *local* calendar + // fields (getFullYear/getMonth/getDate), so a UTC anchor does not hold the + // day regardless of offset as it once claimed here — noon UTC is 02:00 on + // the 16th at UTC+14, and the assertion failed there. + const localNoon = new Date(2026, 6, 15, 12, 0, 0, 0); + expect(formatReleaseDate(localNoon.toISOString() as never)).toBe("2026-07-15"); }); it("returns null for missing or unparseable input", () => {