From c3dc1b0e7de08e9b52e5ab277099604cdcbc83c0 Mon Sep 17 00:00:00 2001 From: regeter <2320305+regeter@users.noreply.github.com> Date: Thu, 21 May 2026 19:49:02 -0700 Subject: [PATCH] feat: add interactive column formatting to LogTable --- src/LogTable.js | 409 +++++++++++++++++++++++++++++++++++++------ src/LogTable.test.js | 205 ++++++++++++++++++++++ 2 files changed, 556 insertions(+), 58 deletions(-) create mode 100644 src/LogTable.test.js diff --git a/src/LogTable.js b/src/LogTable.js index cc6a5eb..e4f4a3a 100644 --- a/src/LogTable.js +++ b/src/LogTable.js @@ -5,6 +5,259 @@ import { FixedSizeList as List } from "react-window"; import AutoSizer from "react-virtualized-auto-sizer"; import _ from "lodash"; +const TableStateContext = React.createContext(null); + +const SparkleIcon = ({ active }) => ( + + + +); + +export function getTooltipText(category, state) { + if (category === "timestamp") { + const states = ["Original Timestamp", "Month-Day Time (MM-DD HH:MM:SS)"]; + const nextState = states[(state + 1) % 2]; + return `Format: ${states[state]}\nClick to change to: ${nextState}`; + } + if (category === "duration") { + const states = ["Original Seconds", "Clock Time ETA (MM-DD HH:MM:SS)", "Friendly Duration (Xm Ys)"]; + const nextState = states[(state + 1) % 3]; + return `Format: ${states[state]}\nClick to change to: ${nextState}`; + } + if (category === "distance") { + const states = ["Original Meters", "Kilometers (km)", "Miles (mi)"]; + const nextState = states[(state + 1) % 3]; + return `Format: ${states[state]}\nClick to change to: ${nextState}`; + } + return ""; +} + +export function parseTimestamp(val) { + if (!val) return null; + if (val instanceof Date) return val; + let target = val; + if (typeof val === "string") { + const cleanVal = val.trim(); + if ( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(cleanVal) && + !cleanVal.endsWith("Z") && + !/[+-]\d{2}:?\d{2}$/.test(cleanVal) + ) { + target = cleanVal + "Z"; + } else { + target = cleanVal; + } + } + const parsed = Date.parse(target); + return isNaN(parsed) ? null : new Date(parsed); +} + +export function formatDateAndTime(date) { + if (!date) return ""; + const pad = (n) => String(n).padStart(2, "0"); + const mm = pad(date.getUTCMonth() + 1); + const dd = pad(date.getUTCDate()); + const hh = pad(date.getUTCHours()); + const min = pad(date.getUTCMinutes()); + const ss = pad(date.getUTCSeconds()); + return `${mm}-${dd} ${hh}:${min}:${ss}`; +} + +export function formatFriendlyDuration(durationSeconds) { + const absSeconds = Math.abs(durationSeconds); + const minutes = Math.floor(absSeconds / 60); + const seconds = Math.round(absSeconds % 60); + + if (minutes === 0) { + return `${durationSeconds < 0 ? "-" : ""}${seconds}s`; + } + return `${durationSeconds < 0 ? "-" : ""}${minutes}m ${seconds}s`; +} + +export function getBaseTimeForDuration(rowData, path) { + if (typeof path === "string" && path.startsWith("request")) { + const pathParts = path.split("."); + if (pathParts.length >= 2) { + const prefix = pathParts.slice(0, 2).join("."); + const candidatePaths = [`${prefix}.lastlocation.updatetime`, `${prefix}.lastlocation.rawlocationtime`]; + for (const p of candidatePaths) { + const val = _.get(rowData, p); + const parsed = parseTimestamp(val); + if (parsed) return parsed; + } + } + } + + const genericVal = rowData.formattedDate || rowData.timestamp; + if (genericVal) { + const parsed = parseTimestamp(genericVal); + if (parsed) return parsed; + } + + return null; +} + +export function formatValue(value, category, state, rowData, columnId) { + if (category === "timestamp") { + const date = parseTimestamp(value); + if (!date) return String(value); + + switch (state) { + case 1: + return formatDateAndTime(date); + default: + return String(value); + } + } + + if (category === "duration") { + const durationSeconds = Number(value); + if (isNaN(durationSeconds)) return String(value); + + switch (state) { + case 1: { + const baseDate = getBaseTimeForDuration(rowData, columnId); + if (!baseDate) return String(value); + const targetDate = new Date(baseDate.getTime() + durationSeconds * 1000); + return formatDateAndTime(targetDate); + } + case 2: + return formatFriendlyDuration(durationSeconds); + default: + return String(value); + } + } + + if (category === "distance") { + const meters = Number(value); + if (isNaN(meters)) return String(value); + + switch (state) { + case 1: + return `${(meters / 1000).toFixed(2)} km`; + case 2: + return `${(meters * 0.000621371).toFixed(2)} mi`; + default: + return String(value); + } + } + + return String(value); +} + +export function getColumnCategory(columnId, sampleValue) { + if (columnId === undefined || columnId === null) return null; + const idLower = String(columnId).toLowerCase(); + + if (columnId === "formattedDate" || idLower === "daytime") { + return null; + } + + if ( + idLower.includes("time") || + idLower.includes("timestamp") || + idLower.includes("version") || + idLower.includes("date") || + (typeof sampleValue === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(sampleValue)) + ) { + if (typeof sampleValue === "string" && !isNaN(Date.parse(sampleValue))) { + return "timestamp"; + } + } + + const isNumeric = sampleValue !== null && sampleValue !== undefined && !isNaN(Number(sampleValue)); + if (isNumeric) { + if (idLower.includes("seconds") || idLower.includes("duration") || idLower.includes("remainingtime")) { + return "duration"; + } + if (idLower.includes("meters") || idLower.includes("distance")) { + return "distance"; + } + } + + return null; +} + +const CellWrapper = ({ originalCell, cellProps, columnId, category }) => { + const context = React.useContext(TableStateContext); + if (!context) { + if (originalCell) return React.createElement(originalCell, cellProps); + return <>{cellProps.value !== undefined && cellProps.value !== null ? String(cellProps.value) : ""}; + } + + const { columnFormats } = context; + const activeFormat = columnId in columnFormats ? columnFormats[columnId] : category ? 1 : 0; + + if (activeFormat === 0 || !category) { + if (originalCell) return React.createElement(originalCell, cellProps); + const val = cellProps.value; + return <>{val !== undefined && val !== null ? String(val) : ""}; + } + + const rawValue = cellProps.value; + if (rawValue === undefined || rawValue === null) return null; + + const formattedValue = formatValue(rawValue, category, activeFormat, cellProps.row.original, columnId); + + return ( + + {formattedValue} + + ); +}; + +const HeaderCellWrapper = ({ column, restColumnProps }) => { + const { columnFormats, toggleColumnFormat } = React.useContext(TableStateContext); + const columnId = column.id || (typeof column.accessor === "string" ? column.accessor : column.Header); + const activeFormat = columnId in columnFormats ? columnFormats[columnId] : column.category ? 1 : 0; + + return ( +
+
+ + {column.render("Header")} + + {column.category && ( +
{ + e.stopPropagation(); + toggleColumnFormat(columnId, column.category); + }} + title={getTooltipText(column.category, activeFormat)} + className="simplify-button-container" + > + 0} /> +
+ )} +
+
e.stopPropagation()} + /> +
+ ); +}; + const CELL_PADDING = 24; function getDisplayText(col, entry) { @@ -38,7 +291,16 @@ function computeColumnWidths(columns, data) { }); } -function Table({ columns, data, onSelectionChange, listRef, selectedRow, centerOnLocation }) { +function Table({ + columns, + data, + onSelectionChange, + listRef, + selectedRow, + centerOnLocation, + columnFormats, + toggleColumnFormat, +}) { const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable( { columns, @@ -142,69 +404,71 @@ function Table({ columns, data, onSelectionChange, listRef, selectedRow, centerO ); return ( -
- - {({ height, width }) => ( -
-
-
- {headerGroups.map((headerGroup) => { - const { key, ...restHeaderGroupProps } = headerGroup.getHeaderGroupProps(); - return ( -
- {headerGroup.headers.map((column) => { - const { key, ...restColumnProps } = column.getHeaderProps(); - return ( -
- {column.render("Header")} -
e.stopPropagation()} - /> -
- ); - })} -
- ); - })} -
-
- width ? totalColumnsWidth : width} - overscanCount={10} - itemData={totalColumnsWidth} - style={{ overflowX: "hidden" }} - > - {Row} - + +
+ + {({ height, width }) => ( +
+
+
+ {headerGroups.map((headerGroup) => { + const { key, ...restHeaderGroupProps } = headerGroup.getHeaderGroupProps(); + return ( +
+ {headerGroup.headers.map((column) => { + const { key, ...restColumnProps } = column.getHeaderProps(); + return ; + })} +
+ ); + })} +
+
+ width ? totalColumnsWidth : width} + overscanCount={10} + itemData={totalColumnsWidth} + style={{ overflowX: "hidden" }} + > + {Row} + +
-
- )} - -
+ )} + +
+ ); } function LogTable(props) { const listRef = React.useRef(null); const [selectedRowIndex, setSelectedRowIndex] = useState(-1); + const [columnFormats, setColumnFormats] = useState({}); + + const toggleColumnFormat = React.useCallback((columnId, category) => { + setColumnFormats((prev) => { + const current = columnId in prev ? prev[columnId] : category ? 1 : 0; + const maxStates = category === "timestamp" ? 2 : category === "duration" ? 3 : category === "distance" ? 3 : 1; + const next = (current + 1) % maxStates; + return { + ...prev, + [columnId]: next, + }; + }); + }, []); + const minTime = props.timeRange.minTime; const maxTime = props.timeRange.maxTime; const data = React.useMemo(() => { @@ -368,8 +632,35 @@ function LogTable(props) { }, }); }); - computeColumnWidths(stdColumns, data); - return stdColumns; + const processedColumns = stdColumns.map((col) => { + const columnId = col.id || (typeof col.accessor === "string" ? col.accessor : col.Header); + + // Find a sample value to detect the category + const sampleRow = data.find((row) => { + const val = typeof col.accessor === "function" ? col.accessor(row) : _.get(row, col.accessor); + return val !== null && val !== undefined; + }); + const sampleValue = sampleRow + ? typeof col.accessor === "function" + ? col.accessor(sampleRow) + : _.get(sampleRow, col.accessor) + : null; + + const category = getColumnCategory(columnId, sampleValue); + const originalCell = col.Cell; + + return { + ...col, + id: columnId, + category, + Cell: (cellProps) => ( + + ), + }; + }); + + computeColumnWidths(processedColumns, data); + return processedColumns; }, [props.extraColumns, props.logData.solutionType, data]); const handleRowSelection = React.useCallback( @@ -414,6 +705,8 @@ function LogTable(props) { disableResizing={true} listRef={listRef} centerOnLocation={props.centerOnLocation} + columnFormats={columnFormats} + toggleColumnFormat={toggleColumnFormat} /> ); } diff --git a/src/LogTable.test.js b/src/LogTable.test.js new file mode 100644 index 0000000..1b749a4 --- /dev/null +++ b/src/LogTable.test.js @@ -0,0 +1,205 @@ +// src/LogTable.test.js +import { + getTooltipText, + parseTimestamp, + formatDateAndTime, + formatFriendlyDuration, + getBaseTimeForDuration, + formatValue, + getColumnCategory, +} from "./LogTable"; + +describe("parseTimestamp", () => { + test("should parse a valid ISO UTC timestamp string correctly", () => { + const ts = "2026-04-30T06:50:35.454Z"; + const date = parseTimestamp(ts); + expect(date).toBeInstanceOf(Date); + expect(date.toISOString()).toBe("2026-04-30T06:50:35.454Z"); + }); + + test("should automatically append 'Z' to force UTC parsing for timezone-naive ISO strings", () => { + const ts = "2026-04-30T06:50:35.454"; + const date = parseTimestamp(ts); + expect(date).toBeInstanceOf(Date); + expect(date.toISOString()).toBe("2026-04-30T06:50:35.454Z"); + }); + + test("should preserve timezone offset if explicitly supplied", () => { + const ts = "2026-04-30T06:50:35.454-07:00"; + const date = parseTimestamp(ts); + expect(date).toBeInstanceOf(Date); + expect(date.toISOString()).toBe("2026-04-30T13:50:35.454Z"); + }); + + test("should handle whitespace trimmed strings", () => { + const ts = " 2026-04-30T06:50:35.454Z "; + const date = parseTimestamp(ts); + expect(date.toISOString()).toBe("2026-04-30T06:50:35.454Z"); + }); + + test("should pass through existing Date instances", () => { + const original = new Date("2026-04-30T06:50:35.000Z"); + const date = parseTimestamp(original); + expect(date).toBe(original); + }); + + test("should return null for null, empty, undefined or invalid strings", () => { + expect(parseTimestamp(null)).toBeNull(); + expect(parseTimestamp(undefined)).toBeNull(); + expect(parseTimestamp("")).toBeNull(); + expect(parseTimestamp("not-a-date")).toBeNull(); + }); +}); + +describe("formatDateAndTime", () => { + test("should format a date to UTC MM-DD HH:MM:SS format", () => { + const date = new Date("2026-04-30T06:50:35.454Z"); + expect(formatDateAndTime(date)).toBe("04-30 06:50:35"); + }); + + test("should pad small numbers with a leading zero", () => { + const date = new Date("2026-01-05T03:04:09.000Z"); + expect(formatDateAndTime(date)).toBe("01-05 03:04:09"); + }); + + test("should return an empty string for falsy dates", () => { + expect(formatDateAndTime(null)).toBe(""); + }); +}); + +describe("formatFriendlyDuration", () => { + test("should format durations under a minute with trailing 's'", () => { + expect(formatFriendlyDuration(45)).toBe("45s"); + expect(formatFriendlyDuration(0)).toBe("0s"); + }); + + test("should format durations over a minute into readable minutes and seconds", () => { + expect(formatFriendlyDuration(741)).toBe("12m 21s"); + expect(formatFriendlyDuration(60)).toBe("1m 0s"); + }); + + test("should handle negative durations correctly", () => { + expect(formatFriendlyDuration(-45)).toBe("-45s"); + expect(formatFriendlyDuration(-741)).toBe("-12m 21s"); + }); +}); + +describe("getBaseTimeForDuration", () => { + test("should look up proper nested vehicle location update paths inside request blocks first", () => { + const row = { + request: { + vehicle: { + lastlocation: { + updatetime: "2026-04-30T06:33:40.000Z", + }, + }, + }, + formattedDate: "2026-04-30T06:35:00.000Z", + }; + const baseTime = getBaseTimeForDuration(row, "request.vehicle.remainingtimeseconds"); + expect(baseTime.toISOString()).toBe("2026-04-30T06:33:40.000Z"); + }); + + test("should look up delivery vehicle path for LMFS requests", () => { + const row = { + request: { + deliveryvehicle: { + lastlocation: { + updatetime: "2026-04-30T06:33:40.000Z", + }, + }, + }, + }; + const baseTime = getBaseTimeForDuration(row, "request.deliveryvehicle.remainingtimeseconds"); + expect(baseTime.toISOString()).toBe("2026-04-30T06:33:40.000Z"); + }); + + test("should fallback to top-level formattedDate or timestamp if request path has no valid target", () => { + const row = { + request: { + vehicle: {}, + }, + formattedDate: "2026-04-30T06:35:00.000Z", + }; + const baseTime = getBaseTimeForDuration(row, "request.vehicle.remainingtimeseconds"); + expect(baseTime.toISOString()).toBe("2026-04-30T06:35:00.000Z"); + }); + + test("should return null if no timestamp targets can be parsed", () => { + const row = { request: {} }; + expect(getBaseTimeForDuration(row, "request.vehicle.remainingtimeseconds")).toBeNull(); + }); +}); + +describe("getColumnCategory", () => { + test("should return null for standard DayTime column (formattedDate) or keyword daytime", () => { + expect(getColumnCategory("formattedDate", "2026-04-30T06:50:35Z")).toBeNull(); + expect(getColumnCategory("DayTime", "2026-04-30T06:50:35Z")).toBeNull(); + expect(getColumnCategory("daytime", "2026-04-30T06:50:35Z")).toBeNull(); + }); + + test("should return 'timestamp' for path names or ISO value samples matching timestamp conditions", () => { + expect(getColumnCategory("request.vehicle.lastlocation.updatetime", "2026-04-30T06:33:40.045Z")).toBe("timestamp"); + expect(getColumnCategory("response.servertime", "2026-04-30T06:33:40Z")).toBe("timestamp"); + expect(getColumnCategory("response.remainingwaypointsversion", "2026-04-30T06:33:40.045Z")).toBe("timestamp"); + expect(getColumnCategory("custom.timestamp", "2026-04-30T06:33:40.045Z")).toBe("timestamp"); + }); + + test("should return 'duration' for numeric value samples and path names containing duration keywords", () => { + expect(getColumnCategory("request.vehicle.remainingtimeseconds", 741)).toBe("duration"); + expect(getColumnCategory("response.duration", "120")).toBe("duration"); + expect(getColumnCategory("custom.remainingtime", 30.5)).toBe("duration"); + }); + + test("should return 'distance' for numeric value samples and path names containing distance/meters keywords", () => { + expect(getColumnCategory("request.deliveryvehicle.remainingdistancemeters", 15420)).toBe("distance"); + expect(getColumnCategory("response.distance", "154.2")).toBe("distance"); + expect(getColumnCategory("custom.meters", 5000)).toBe("distance"); + }); + + test("should return null for standard non-formattable properties", () => { + expect(getColumnCategory("Method", "updateVehicle")).toBeNull(); + expect(getColumnCategory("Kmph", 45)).toBeNull(); + expect(getColumnCategory("Sensor", "LOCATION_SENSOR_GPS")).toBeNull(); + }); +}); + +describe("formatValue", () => { + test("should correctly format timestamp states", () => { + const val = "2026-04-30T06:50:35.000Z"; + expect(formatValue(val, "timestamp", 0, null, "path")).toBe(val); + expect(formatValue(val, "timestamp", 1, null, "path")).toBe("04-30 06:50:35"); + }); + + test("should correctly format duration states", () => { + const row = { + request: { + vehicle: { + lastlocation: { + updatetime: "2026-04-30T06:33:40.000Z", + }, + }, + }, + }; + expect(formatValue(741, "duration", 0, row, "request.vehicle.remainingtimeseconds")).toBe("741"); + // State 1: ETA Clock Time (06:33:40 + 741s = 06:46:01) + expect(formatValue(741, "duration", 1, row, "request.vehicle.remainingtimeseconds")).toBe("04-30 06:46:01"); + // State 2: Friendly Duration + expect(formatValue(741, "duration", 2, row, "request.vehicle.remainingtimeseconds")).toBe("12m 21s"); + }); + + test("should correctly format distance states", () => { + expect(formatValue(15420, "distance", 0, null, "path")).toBe("15420"); + expect(formatValue(15420, "distance", 1, null, "path")).toBe("15.42 km"); + expect(formatValue(15420, "distance", 2, null, "path")).toBe("9.58 mi"); + }); +}); + +describe("getTooltipText", () => { + test("should return valid tooltip context cycles for all categories", () => { + expect(getTooltipText("timestamp", 0)).toContain("Original Timestamp"); + expect(getTooltipText("duration", 1)).toContain("Clock Time ETA"); + expect(getTooltipText("distance", 2)).toContain("Miles"); + expect(getTooltipText("unknown", 0)).toBe(""); + }); +});