diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts index 3f15b3cb..07e2ce06 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-table-day-view.ts @@ -4,6 +4,7 @@ import { useWorkOrdersDayPagination } from "@/app/(protected)/workorders/_hooks/ import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; +import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; import { pageSelectedDayGroups } from "@/domain/work-orders/utils/day-pagination"; import { buildSelectedDayGroup, @@ -71,7 +72,7 @@ export function useWorkOrdersTableDayView({ const isEmptyWeek = !advActive && scheduled.length === 0 && !searchActive; const todayKey = filters.weekMonday === currentWeekMonday() - ? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup + ? deriveDayFields(todayIso()).dayGroup : null; return { diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 4f2a045b..3d8eb132 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -10,6 +10,7 @@ import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced- import { isCarryOverDisplayRow } from "@/domain/work-orders/utils/carry-over-display"; import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils"; import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; +import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]); @@ -17,8 +18,10 @@ export function computeIsPastDue(row: WorkOrderTableRow): boolean { if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) { return false; } - const today = new Date().toISOString().slice(0, 10); - return row.scheduledOn.slice(0, 10) < today; + // scheduledOn is a calendar date, so compare it to the user's local calendar + // day. A UTC "today" rolls over hours early west of UTC and flags work + // scheduled for today as Past Due every evening. + return row.scheduledOn.slice(0, 10) < todayIso(); } export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow { diff --git a/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx b/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx index f26b9ea5..efb56316 100644 --- a/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx +++ b/src/test/app/(protected)/pmschedules/pm-schedules-list-actions.test.tsx @@ -1,6 +1,9 @@ import { screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import PmSchedulesListPage from "@/app/(protected)/pmschedules/index"; +// Static import: a dynamic import inside the test transforms the edit page's +// module graph against the 5s test budget, which times out under load. +import EditPage from "@/app/(protected)/pmschedules/edit/[id]"; import { renderWithProviders } from "@/test/test-utils"; const usePmSchedulesList = vi.fn(); @@ -75,9 +78,7 @@ describe("PmSchedulesListPage actions", () => { }); describe("PmScheduleEditUnavailablePage", () => { - it("shows unavailable message instead of edit form when canEdit is false", async () => { - const EditPage = (await import("@/app/(protected)/pmschedules/edit/[id]")).default; - + it("shows unavailable message instead of edit form when canEdit is false", () => { renderWithProviders(, { route: "/pmschedules/edit/1", routerProps: { initialEntries: ["/pmschedules/edit/1"] }, diff --git a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx index 355f99f1..644fc329 100644 --- a/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-create-modal.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { VendorCreateModal } from "@/app/(protected)/vendors/_components/vendor-create-modal"; import { VendorRosterFormFields } from "@/app/(protected)/vendors/_components/vendor-roster-form-fields"; import { @@ -49,6 +49,10 @@ function renderCreateModal() { }); } +// delay: null skips user-event's per-keystroke setTimeout yields, which only +// add real-timer latency here. +let user = userEvent.setup({ delay: null }); + async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { fireEvent.change(screen.getByRole("combobox", { name: "Company (required)" }), { target: { value: name }, @@ -57,7 +61,7 @@ async function fillCompanyNameAndSubmit(name = "Gateway Plumbing") { // phone-or-email rule, so the address is filled to keep the form's only // outstanding problem the one under test. fillRequiredAddress(); - await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); + await user.click(screen.getByRole("button", { name: "Add Vendor" })); } function fillRequiredAddress({ @@ -74,7 +78,15 @@ function fillRequiredAddress({ pickVendorState(state); } -describe("VendorCreateModal validation", () => { +// Each test mounts the full MUI dialog (Autocompletes, 50-option State list) +// and costs ~1s of CPU when idle; the first also pays JIT warm-up. Under +// parallel agent load the 5s default is exceeded by CPU starvation alone, +// matching the 10s budgets already used by vendors-list.test.tsx. +describe("VendorCreateModal validation", { timeout: 15_000 }, () => { + beforeEach(() => { + user = userEvent.setup({ delay: null }); + }); + it("explains the company phone-or-email requirement after submission", async () => { renderCreateModal(); await fillCompanyNameAndSubmit(); @@ -138,8 +150,8 @@ describe("VendorCreateModal validation", () => { const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" }); const email = screen.getByRole("textbox", { name: "Email (optional)" }); - await userEvent.type(phone, "314"); - await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); + await user.type(phone, "314"); + await user.click(screen.getByRole("button", { name: "Add Vendor" })); expect(await screen.findByText("Enter a 10-digit phone number")).toBeInTheDocument(); expect(phone).toHaveAttribute("aria-invalid", "true"); @@ -153,8 +165,8 @@ describe("VendorCreateModal validation", () => { const phone = screen.getByRole("textbox", { name: "Company Phone (optional)" }); const email = screen.getByRole("textbox", { name: "Email (optional)" }); - await userEvent.type(email, "not-an-email"); - await userEvent.click(screen.getByRole("button", { name: "Add Vendor" })); + await user.type(email, "not-an-email"); + await user.click(screen.getByRole("button", { name: "Add Vendor" })); expect(await screen.findByText("Invalid email")).toBeInTheDocument(); expect(email).toHaveAttribute("aria-invalid", "true"); @@ -169,7 +181,7 @@ describe("VendorCreateModal validation", () => { expect(await screen.findByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).toBeInTheDocument(); - await userEvent.type(phone, "3145550100"); + await user.type(phone, "3145550100"); await waitFor(() => expect(screen.queryByText(VENDOR_COMPANY_CONTACT_REQUIRED_MESSAGE)).not.toBeInTheDocument(), ); diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index 0da2e959..0e5ea3eb 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -68,11 +68,19 @@ function rosterWith(technicians: Array>) { }; } +// delay: null skips user-event's per-keystroke setTimeout yields, which only +// add real-timer latency here. +let user = userEvent.setup({ delay: null }); + beforeEach(() => { saveMutate.mockReset(); + user = userEvent.setup({ delay: null }); }); -describe("VendorDetailDrawer selected-technician display", () => { +// The edit-mode drawer is a heavy MUI render and the first test pays JIT +// warm-up; under parallel agent load the 5s default is exceeded by CPU +// starvation alone, matching the 10s budgets in vendors-list.test.tsx. +describe("VendorDetailDrawer selected-technician display", { timeout: 15_000 }, () => { it.each([ { removedPosition: 1, remainingId: 2, remainingName: "Beth" }, { removedPosition: 2, remainingId: 1, remainingName: "Adam" }, @@ -107,10 +115,10 @@ describe("VendorDetailDrawer selected-technician display", () => { { route: "/vendors", withAuth: false }, ); - await userEvent.click( + await user.click( screen.getByRole("button", { name: `Remove technician ${removedPosition}` }), ); - await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + await user.click(screen.getByRole("button", { name: "Save changes" })); await waitFor(() => expect(saveMutate).toHaveBeenCalledTimes(1)); expect(saveMutate).toHaveBeenCalledWith( @@ -161,12 +169,12 @@ describe("VendorDetailDrawer selected-technician display", () => { { route: "/vendors", withAuth: false }, ); - await userEvent.click(screen.getByRole("button", { name: "Remove technician 2" })); + await user.click(screen.getByRole("button", { name: "Remove technician 2" })); const save = screen.getByRole("button", { name: "Save changes" }); expect(save).toBeEnabled(); - await userEvent.click(save); + await user.click(save); expect( await screen.findByText("Provide a company phone or email (at least one required)"), @@ -444,10 +452,10 @@ describe("VendorDetailDrawer deactivation prompt", () => { ); const toggle = screen.getByRole("switch", { name: "Active status" }); - await userEvent.click(toggle); + await user.click(toggle); expect(toggle).toBeChecked(); - await userEvent.click(toggle); + await user.click(toggle); expect(toggle).not.toBeChecked(); expect(onRequestDeactivation).not.toHaveBeenCalled(); @@ -472,7 +480,7 @@ describe("VendorDetailDrawer deactivation prompt", () => { { route: "/vendors", withAuth: false }, ); - await userEvent.click(screen.getByRole("switch", { name: "Active status" })); + await user.click(screen.getByRole("switch", { name: "Active status" })); expect(onRequestDeactivation).toHaveBeenCalledWith(vendor); }); diff --git a/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx b/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx index 1ef0734c..4ca3e8d8 100644 --- a/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-adv-search-page-sync.integration.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; @@ -120,6 +120,12 @@ function pagedResult(rows: WorkOrderTableRow[], page: number, pageSize: number) }; } +// Role and text queries over the whole board scan every row cell; scoping them +// to the pagination footer keeps each waitFor poll cheap on a loaded machine. +function pagination() { + return within(screen.getByTestId("wo-day-pagination")); +} + function pagesAfter(callCount: number): number[] { return searchBoard.mock.calls.slice(callCount).map((call) => { const params = call[0] as WorkOrderBoardSearchQueryParams; @@ -213,14 +219,14 @@ describe("advanced search page sync after mutation", () => { ); }); expect(screen.getByText("WO-0")).toBeInTheDocument(); - expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + expect(pagination().getByText("Page 1 of 2")).toBeInTheDocument(); expect(screen.queryByText("WO-25")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Next page" })); + fireEvent.click(pagination().getByRole("button", { name: "Next page" })); await waitFor(() => { expect(screen.getByTestId("harness-state")).toHaveTextContent("page=1"); expect(screen.getByText("WO-25")).toBeInTheDocument(); - expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + expect(pagination().getByText("Page 2 of 2")).toBeInTheDocument(); }); const callsBeforeMutation = searchBoard.mock.calls.length; @@ -236,12 +242,12 @@ describe("advanced search page sync after mutation", () => { expect(screen.getByTestId("harness-state")).toHaveTextContent( "adv=true rows=20 total=20 page=0", ); - expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + expect(pagination().getByText("Page 1 of 1")).toBeInTheDocument(); }); expect(screen.getByText("WO-0")).toBeInTheDocument(); expect(screen.queryByText("WO-25")).not.toBeInTheDocument(); expect(screen.getByText("RESULTS ยท 20 items")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled(); + expect(pagination().getByRole("button", { name: "Previous page" })).toBeDisabled(); + expect(pagination().getByRole("button", { name: "Next page" })).toBeDisabled(); }, 15000); }); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index a2e1f2eb..022d36fe 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; @@ -526,6 +526,41 @@ describe("computeIsPastDue", () => { }); }); +describe("computeIsPastDue across the local/UTC day boundary", () => { + beforeEach(() => { + // UTC-3 with no DST: local evening is already the next day in UTC. + vi.stubEnv("TZ", "America/Sao_Paulo"); + vi.useFakeTimers({ toFake: ["Date"] }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it("does not flag today's work as Past Due at 23:30 local (02:30 UTC next day)", () => { + vi.setSystemTime(new Date("2026-09-17T02:30:00.000Z")); + + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe( + false, + ); + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-15", status: "Scheduled" }))).toBe( + true, + ); + }); + + it("flags yesterday's work as Past Due at 00:30 local", () => { + vi.setSystemTime(new Date("2026-09-17T03:30:00.000Z")); + + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-16", status: "Scheduled" }))).toBe( + true, + ); + expect(computeIsPastDue(makeRow({ scheduledOn: "2026-09-17", status: "Scheduled" }))).toBe( + false, + ); + }); +}); + describe("enrichTableRow", () => { it("recomputes isPastDue from scheduledOn and ignores sticky API true", () => { const enriched = enrichTableRow(