From e46e42df6902792f2e32eec55b9096f1b170e49d Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 11:43:34 -0300 Subject: [PATCH 1/2] fix(work-orders): show full audit timestamps and previous type values (SH-361, SH-363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit Log entries now show date and time for every entry instead of a time-only stamp for manual changes. Type and Severity entries read 'Changed Type from X to Y', a severity cleared by a type correction reads 'from SEV N to —', and the generic fallback no longer renders a raw FieldChanged action name. --- .../detail/slide-over-audit-list.tsx | 33 +----------- .../utils/board-audit-action-rules.ts | 45 +++++++++++++--- .../utils/format-audit-timestamp.ts | 24 +++++++++ .../utils/format-board-audit-action.ts | 6 ++- .../slide-over-audit-list-timestamp.test.tsx | 54 +++++++++++++++++++ .../utils/format-audit-timestamp.test.ts | 34 ++++++++++++ .../utils/format-board-audit-action.test.ts | 51 ++++++++++++++++-- 7 files changed, 202 insertions(+), 45 deletions(-) create mode 100644 src/domain/work-orders/utils/format-audit-timestamp.ts create mode 100644 src/test/app/(protected)/workorders/slide-over-audit-list-timestamp.test.tsx create mode 100644 src/test/domain/work-orders/utils/format-audit-timestamp.test.ts diff --git a/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx b/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx index 57c72270..c64145c8 100644 --- a/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx +++ b/src/app/(protected)/workorders/_components/detail/slide-over-audit-list.tsx @@ -6,6 +6,7 @@ import { getDispatcherAvatarColor, getInitialsFromName, } from "@/domain/work-orders/utils/dispatcher-avatar-color"; +import { formatAuditTimestamp } from "@/domain/work-orders/utils/format-audit-timestamp"; import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action"; function isSystemEntry(entry: WorkOrderAuditEntry): boolean { @@ -15,36 +16,6 @@ function isSystemEntry(entry: WorkOrderAuditEntry): boolean { return !name || name.toLowerCase() === "system"; } -function formatClock(date: Date): string { - return date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - }); -} - -/** - * Manual (image): `· 9:48 AM` - * System (image): `05/13 · 8:00 AM` - * Prefer API display strings that already include · / AM|PM. - */ -function formatAuditTime(value: string, isSystem: boolean): string { - if (!value) return ""; - if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value; - - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - - const timePart = formatClock(date); - if (isSystem) { - const datePart = date.toLocaleDateString("en-US", { - month: "2-digit", - day: "2-digit", - }); - return `${datePart} · ${timePart}`; - } - return `· ${timePart}`; -} - type SlideOverAuditListProps = { entries: WorkOrderAuditEntry[]; users?: WorkOrderUser[]; @@ -151,7 +122,7 @@ export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListPr color: "#9CA3AF", }} > - {formatAuditTime(entry.createdAt, isSys)} + {formatAuditTimestamp(entry.createdAt)} diff --git a/src/domain/work-orders/utils/board-audit-action-rules.ts b/src/domain/work-orders/utils/board-audit-action-rules.ts index 4a9a4c72..5073d022 100644 --- a/src/domain/work-orders/utils/board-audit-action-rules.ts +++ b/src/domain/work-orders/utils/board-audit-action-rules.ts @@ -39,24 +39,53 @@ function formatSeverityAuditLabel(raw: string): string { return raw; } -function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null { +const CLEARED_AUDIT_VALUE: FormattedAuditValue = { text: "—", isQuotedList: false }; + +/** SH-363: `from X to Y` whenever the previous value is known; a cleared value reads as "—". */ +function formatFieldChangeSentence( + label: string, + oldValue: FormattedAuditValue, + newValue: FormattedAuditValue, +): string | null { + if (!oldValue.text && !newValue.text) return null; + const next = wrapAuditValue(newValue.text ? newValue : CLEARED_AUDIT_VALUE); + if (oldValue.text) { + return `Changed ${label} from ${wrapAuditValue(oldValue)} to ${next}`; + } + return `Changed ${label} to ${next}`; +} + +function tryTypeRule({ + fieldKey, + actionKey, + oldValue, + newValue, +}: AuditActionRuleContext): string | null { const matches = fieldKey === "type" || fieldKey === "workordertype" || fieldKey === "wotype" || actionKey === "typechanged" || actionKey.includes("workordertype"); - if (!matches || !newValue.text) return null; + if (!matches) return null; - return `Changed Type to ${wrapAuditValue(newValue)}`; + return formatFieldChangeSentence("Type", oldValue, newValue); } -function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null { - const matches = fieldKey.includes("severity") || actionKey.includes("severity"); - if (!matches || !newValue.text) return null; +function severityValue(value: FormattedAuditValue): FormattedAuditValue { + return value.text ? { text: formatSeverityAuditLabel(value.text), isQuotedList: false } : value; +} - const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false }; - return `Changed Severity to ${wrapAuditValue(labeled)}`; +function trySeverityRule({ + fieldKey, + actionKey, + oldValue, + newValue, +}: AuditActionRuleContext): string | null { + const matches = fieldKey.includes("severity") || actionKey.includes("severity"); + if (!matches) return null; + + return formatFieldChangeSentence("Severity", severityValue(oldValue), severityValue(newValue)); } function tryStatusRule({ diff --git a/src/domain/work-orders/utils/format-audit-timestamp.ts b/src/domain/work-orders/utils/format-audit-timestamp.ts new file mode 100644 index 00000000..b2e8ad7d --- /dev/null +++ b/src/domain/work-orders/utils/format-audit-timestamp.ts @@ -0,0 +1,24 @@ +const AUDIT_DATE_PATTERN = /\b\d{1,2}\/\d{1,2}(\/\d{2,4})?\b/; + +/** + * SH-361: every entry, manual or system, shows a complete timestamp — `09/15/2026 · 7:42 PM`. + * Pre-formatted API strings are kept only when they already carry a calendar date. + */ +export function formatAuditTimestamp(value: string): string { + if (!value) return ""; + if (AUDIT_DATE_PATTERN.test(value) && /\b(AM|PM)\b/i.test(value)) return value; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + const datePart = date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }); + const timePart = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); + return `${datePart} · ${timePart}`; +} diff --git a/src/domain/work-orders/utils/format-board-audit-action.ts b/src/domain/work-orders/utils/format-board-audit-action.ts index 05ebe168..dcee298f 100644 --- a/src/domain/work-orders/utils/format-board-audit-action.ts +++ b/src/domain/work-orders/utils/format-board-audit-action.ts @@ -127,6 +127,10 @@ function formatGenericAuditFallback(args: { if (newValue.text) { return `Changed ${label} to ${wrapAuditValue(newValue)}`; } - if (action) return action; + if (oldValue.text) { + return `Changed ${label} from ${wrapAuditValue(oldValue)} to "—"`; + } + // SH-363: never surface a raw audit enum such as "FieldChanged" as the sentence. + if (fieldName && action) return `Updated ${label}`; return "Updated work order"; } diff --git a/src/test/app/(protected)/workorders/slide-over-audit-list-timestamp.test.tsx b/src/test/app/(protected)/workorders/slide-over-audit-list-timestamp.test.tsx new file mode 100644 index 00000000..ac8d7393 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-audit-list-timestamp.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list"; +import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order"; + +function statusEntry(overrides: Partial = {}): WorkOrderAuditEntry { + return { + id: "s1", + fieldName: "LifecycleStatus", + oldValue: "Pending", + newValue: "En Route", + action: "StatusChanged", + createdAt: "2026-09-15T19:42:00", + userName: "Alex Dispatcher", + type: "manual", + ...overrides, + }; +} + +describe("SlideOverAuditList timestamps (SH-361)", () => { + it("shows the date as well as the time on a manual status change", () => { + render(); + + expect(screen.getByText('Changed status from "Pending" to "En Route"')).toBeInTheDocument(); + expect(screen.getByText(/^\d{2}\/\d{2}\/2026 · 7:42 PM$/)).toBeInTheDocument(); + }); + + it("renders a type correction with its previous value (SH-363)", () => { + render( + , + ); + + expect(screen.getByText('Changed Type from "PM" to "Reactive"')).toBeInTheDocument(); + expect(screen.getByText('Changed Severity from "SEV 3" to "—"')).toBeInTheDocument(); + expect(screen.queryByText("FieldChanged")).toBeNull(); + }); +}); diff --git a/src/test/domain/work-orders/utils/format-audit-timestamp.test.ts b/src/test/domain/work-orders/utils/format-audit-timestamp.test.ts new file mode 100644 index 00000000..c7bb498b --- /dev/null +++ b/src/test/domain/work-orders/utils/format-audit-timestamp.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { formatAuditTimestamp } from "@/domain/work-orders/utils/format-audit-timestamp"; + +describe("formatAuditTimestamp (SH-361)", () => { + it("includes the calendar date and the time for an ISO audit time", () => { + const iso = "2026-09-15T19:42:00"; + const date = new Date(iso); + const expectedDate = date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }); + + const formatted = formatAuditTimestamp(iso); + + expect(formatted).toBe(`${expectedDate} · 7:42 PM`); + expect(formatted).toMatch(/^\d{2}\/\d{2}\/\d{4} · \d{1,2}:\d{2} (AM|PM)$/); + }); + + it("distinguishes the same time of day on different dates", () => { + expect(formatAuditTimestamp("2026-09-14T09:48:00")).not.toBe( + formatAuditTimestamp("2026-09-15T09:48:00"), + ); + }); + + it("keeps pre-formatted or unparseable strings unchanged", () => { + expect(formatAuditTimestamp("· 7:42 PM")).toBe("· 7:42 PM"); + expect(formatAuditTimestamp("09/15/2026 · 7:42 PM")).toBe("09/15/2026 · 7:42 PM"); + }); + + it("returns an empty string when there is no time", () => { + expect(formatAuditTimestamp("")).toBe(""); + }); +}); diff --git a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts index 8c9ff415..e0a3f0b8 100644 --- a/src/test/domain/work-orders/utils/format-board-audit-action.test.ts +++ b/src/test/domain/work-orders/utils/format-board-audit-action.test.ts @@ -89,26 +89,67 @@ describe("formatBoardAuditAction", () => { ).toBe("WO marked as Past Due. Schedule On has passed without completion."); }); - it("formats type correction as Changed Type to (AAP, new value only)", () => { + it("formats type correction with the previous and new type (SH-363)", () => { expect( formatBoardAuditAction({ - action: "FieldPatched", + action: "FieldChanged", fieldName: "WorkOrderType", oldValue: "PM", + newValue: "Reactive", + }), + ).toBe('Changed Type from "PM" to "Reactive"'); + }); + + it("formats a first type assignment without a from clause", () => { + expect( + formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "WorkOrderType", + oldValue: "", newValue: "Emergency", }), ).toBe('Changed Type to "Emergency"'); }); - it("formats severity as Changed Severity to SEV N", () => { + it("formats severity with the previous and new SEV labels", () => { expect( formatBoardAuditAction({ - action: "FieldPatched", + action: "FieldChanged", fieldName: "Severity", oldValue: "3", newValue: "1", }), - ).toBe('Changed Severity to "SEV 1"'); + ).toBe('Changed Severity from "SEV 3" to "SEV 1"'); + }); + + it("formats severity cleared by a type correction instead of a raw FieldChanged (SH-363)", () => { + expect( + formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "Severity", + oldValue: "3", + newValue: "", + }), + ).toBe('Changed Severity from "SEV 3" to "—"'); + }); + + it("never renders a raw audit action enum for a cleared unknown field", () => { + const cleared = formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "VendorNte", + oldValue: "100", + newValue: "", + }); + const empty = formatBoardAuditAction({ + action: "FieldChanged", + fieldName: "VendorNte", + oldValue: "", + newValue: "", + }); + + expect(cleared).toBe('Changed vendor nte from "100" to "—"'); + expect(empty).toBe("Updated vendor nte"); + expect([cleared, empty]).not.toContain("FieldChanged"); }); it("falls back to from → to for unknown fields", () => { From c75f8b62398143944252bd9ee88034bdd7959628 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 16:56:26 -0300 Subject: [PATCH 2/2] chore(work-orders): remove ticket keys from audit docs --- src/domain/work-orders/utils/board-audit-action-rules.ts | 2 +- src/domain/work-orders/utils/format-audit-timestamp.ts | 2 +- src/domain/work-orders/utils/format-board-audit-action.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domain/work-orders/utils/board-audit-action-rules.ts b/src/domain/work-orders/utils/board-audit-action-rules.ts index 5073d022..e522356f 100644 --- a/src/domain/work-orders/utils/board-audit-action-rules.ts +++ b/src/domain/work-orders/utils/board-audit-action-rules.ts @@ -41,7 +41,7 @@ function formatSeverityAuditLabel(raw: string): string { const CLEARED_AUDIT_VALUE: FormattedAuditValue = { text: "—", isQuotedList: false }; -/** SH-363: `from X to Y` whenever the previous value is known; a cleared value reads as "—". */ +/** `from X to Y` whenever the previous value is known; a cleared value reads as "—". */ function formatFieldChangeSentence( label: string, oldValue: FormattedAuditValue, diff --git a/src/domain/work-orders/utils/format-audit-timestamp.ts b/src/domain/work-orders/utils/format-audit-timestamp.ts index b2e8ad7d..4d987df4 100644 --- a/src/domain/work-orders/utils/format-audit-timestamp.ts +++ b/src/domain/work-orders/utils/format-audit-timestamp.ts @@ -1,7 +1,7 @@ const AUDIT_DATE_PATTERN = /\b\d{1,2}\/\d{1,2}(\/\d{2,4})?\b/; /** - * SH-361: every entry, manual or system, shows a complete timestamp — `09/15/2026 · 7:42 PM`. + * Every entry, manual or system, shows a complete timestamp — `09/15/2026 · 7:42 PM`. * Pre-formatted API strings are kept only when they already carry a calendar date. */ export function formatAuditTimestamp(value: string): string { diff --git a/src/domain/work-orders/utils/format-board-audit-action.ts b/src/domain/work-orders/utils/format-board-audit-action.ts index dcee298f..e29cbcbd 100644 --- a/src/domain/work-orders/utils/format-board-audit-action.ts +++ b/src/domain/work-orders/utils/format-board-audit-action.ts @@ -130,7 +130,7 @@ function formatGenericAuditFallback(args: { if (oldValue.text) { return `Changed ${label} from ${wrapAuditValue(oldValue)} to "—"`; } - // SH-363: never surface a raw audit enum such as "FieldChanged" as the sentence. + // Never surface a raw audit enum such as "FieldChanged" as the sentence. if (fieldName && action) return `Updated ${label}`; return "Updated work order"; }