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 ( +