diff --git a/app/assets/js/charts.js b/app/assets/js/charts.js index f230e8f0..45fdf350 100644 --- a/app/assets/js/charts.js +++ b/app/assets/js/charts.js @@ -15,7 +15,7 @@ // IMPORTS // ============================================================ -import { Logger, TEXT_CONSTANTS } from './base.js'; +import { Logger, TEXT_CONSTANTS, formatTimestamp } from './base.js'; import { originalRequests, parseTime, @@ -7171,7 +7171,7 @@ size: 12 lineData.push({ value: [timestamp.getTime(), collIdx, avgValue], collection: collection, - time: timestamp.toISOString().substring(0, 19).replace('T', ' '), + time: formatTimestamp(timestamp, "YYYY-MM-DD HH:MM:SS"), actualValue: avgValue, count: bucket.count }); diff --git a/app/assets/js/insights.js b/app/assets/js/insights.js index c1e73020..0db5baca 100644 --- a/app/assets/js/insights.js +++ b/app/assets/js/insights.js @@ -4,7 +4,7 @@ // This module handles the Insights tab logic and automated analysis. // ============================================================ -import { Logger, TEXT_CONSTANTS } from './base.js'; +import { Logger, TEXT_CONSTANTS, formatTimestamp } from './base.js'; import { originalRequests, parseTime, @@ -13,6 +13,7 @@ import { import { formatNumber, formatDuration, + formatTime, copyToClipboard } from './ui-helpers.js'; @@ -1342,7 +1343,7 @@ import { // Apply timezone conversion to requestTime const originalTime = query.requestTime || ""; const convertedDate = getChartDate(originalTime); - const formattedDate = convertedDate ? convertedDate.toISOString().replace('T', ' ').substring(0, 23) + 'Z' : originalTime; + const formattedDate = convertedDate ? formatTimestamp(convertedDate, "YYYY-MM-DD HH:MM:SS.sssZ") : originalTime; Logger.trace(`[Insights] Query ${index}: Original=${originalTime}, Converted=${formattedDate}`); diff --git a/app/assets/js/main-legacy-cleaned.js b/app/assets/js/main-legacy-cleaned.js index 7efdaac5..112d51ee 100644 --- a/app/assets/js/main-legacy-cleaned.js +++ b/app/assets/js/main-legacy-cleaned.js @@ -553,63 +553,11 @@ if (window.TEXT_CONSTANTS) { return totalMs; } - // Format time in standardized mm:ss.sss format - // ============================================================ - // FORMATTERS MODULE (Optimization Step 2b) - // Consolidated formatting helper functions - // ============================================================ - const Formatters = { - // Format milliseconds to MM:SS.mmm - formatTime(milliseconds) { - if (!milliseconds || isNaN(milliseconds) || milliseconds <= 0) { - return "00:00.000"; - } - - // Handle very small values (less than 1ms) by rounding to nearest millisecond - // but ensuring they show as at least 0.001 if they're greater than 0 - if (milliseconds < 1) { - milliseconds = Math.max(0.001, Math.round(milliseconds * 1000) / 1000); - } - - const totalSeconds = Math.floor(milliseconds / 1000); - const remainingMs = milliseconds % 1000; - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - - // Format with leading zeros - const formattedMinutes = minutes.toString().padStart(2, "0"); - const formattedSeconds = seconds.toString().padStart(2, "0"); - - // Format milliseconds as 3-digit integer (rounded) - const formattedMs = Math.round(remainingMs).toString().padStart(3, "0"); - - return `${formattedMinutes}:${formattedSeconds}.${formattedMs}`; - }, - - // Format original time value for tooltip display - formatTimeTooltip(timeStr, milliseconds) { - if (!timeStr || timeStr === "N/A") { - return ""; - } - - // If it's a very small value, show the original string for precision - if (milliseconds < 1) { - return `Original: ${timeStr}`; - } - - // For larger values, show both formatted time and original - const formatted = this.formatTime(milliseconds); - if (timeStr !== formatted) { - return `Original: ${timeStr}`; - } - - return ""; - } - }; - - // Backward compatibility - keep original function names as aliases - const formatTime = (milliseconds) => Formatters.formatTime(milliseconds); - const formatTimeTooltip = (timeStr, milliseconds) => Formatters.formatTimeTooltip(timeStr, milliseconds); + // Duration + timestamp display — live implementations are in + // ui-helpers.js / base.js and exposed on window by those modules. + const formatTime = (milliseconds) => window.formatTime(milliseconds); + const formatTimeTooltip = (timeStr, milliseconds) => window.formatTimeTooltip(timeStr, milliseconds); + const formatTimestamp = (dateInput, format) => window.formatTimestamp(dateInput, format); @@ -2585,7 +2533,7 @@ if (window.TEXT_CONSTANTS) { // Apply timezone conversion const convertedDate = getChartDate(originalTime); - return convertedDate ? convertedDate.toISOString().replace('T', ' ').substring(0, 23) + 'Z' : originalTime; + return convertedDate ? formatTimestamp(convertedDate, "YYYY-MM-DD HH:MM:SS.sssZ") : originalTime; }, render: (value) => value }, @@ -5759,7 +5707,7 @@ function renderQueryGroupPhaseTimesChart(group) { const d = params.data; if (!d.value) return ''; const timestamp = new Date(d.value[0]); - const timeStr = timestamp.toISOString().substring(0, 19).replace('T', ' '); + const timeStr = formatTimestamp(timestamp, "YYYY-MM-DD HH:MM:SS"); const collectionIdx = d.value[1]; const collection = data.collections[collectionIdx]; const timeValue = d.value[2]; @@ -9188,7 +9136,7 @@ function renderQueryGroupPhaseTimesChart(group) { // Apply timezone conversion to requestTime const originalTime = query.requestTime || ""; const convertedDate = getChartDate(originalTime); - const formattedDate = convertedDate ? convertedDate.toISOString().replace('T', ' ').substring(0, 23) + 'Z' : originalTime; + const formattedDate = convertedDate ? formatTimestamp(convertedDate, "YYYY-MM-DD HH:MM:SS.sssZ") : originalTime; Logger.trace(`[Insights] JOIN Query ${index}: Original=${originalTime}, Converted=${formattedDate}`); const flags = query.flags || []; diff --git a/app/assets/js/main-legacy.js b/app/assets/js/main-legacy.js index c11e1543..6af87c2f 100644 --- a/app/assets/js/main-legacy.js +++ b/app/assets/js/main-legacy.js @@ -878,63 +878,11 @@ if (window.TEXT_CONSTANTS) { return totalMs; } - // Format time in standardized mm:ss.sss format - // ============================================================ - // FORMATTERS MODULE (Optimization Step 2b) - // Consolidated formatting helper functions - // ============================================================ - const Formatters = { - // Format milliseconds to MM:SS.mmm - formatTime(milliseconds) { - if (!milliseconds || isNaN(milliseconds) || milliseconds <= 0) { - return "00:00.000"; - } - - // Handle very small values (less than 1ms) by rounding to nearest millisecond - // but ensuring they show as at least 0.001 if they're greater than 0 - if (milliseconds < 1) { - milliseconds = Math.max(0.001, Math.round(milliseconds * 1000) / 1000); - } - - const totalSeconds = Math.floor(milliseconds / 1000); - const remainingMs = milliseconds % 1000; - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - - // Format with leading zeros - const formattedMinutes = minutes.toString().padStart(2, "0"); - const formattedSeconds = seconds.toString().padStart(2, "0"); - - // Format milliseconds as 3-digit integer (rounded) - const formattedMs = Math.round(remainingMs).toString().padStart(3, "0"); - - return `${formattedMinutes}:${formattedSeconds}.${formattedMs}`; - }, - - // Format original time value for tooltip display - formatTimeTooltip(timeStr, milliseconds) { - if (!timeStr || timeStr === "N/A") { - return ""; - } - - // If it's a very small value, show the original string for precision - if (milliseconds < 1) { - return `Original: ${timeStr}`; - } - - // For larger values, show both formatted time and original - const formatted = this.formatTime(milliseconds); - if (timeStr !== formatted) { - return `Original: ${timeStr}`; - } - - return ""; - } - }; - - // Backward compatibility - keep original function names as aliases - const formatTime = (milliseconds) => Formatters.formatTime(milliseconds); - const formatTimeTooltip = (timeStr, milliseconds) => Formatters.formatTimeTooltip(timeStr, milliseconds); + // Duration + timestamp display — live implementations are in + // ui-helpers.js / base.js and exposed on window by those modules. + const formatTime = (milliseconds) => window.formatTime(milliseconds); + const formatTimeTooltip = (timeStr, milliseconds) => window.formatTimeTooltip(timeStr, milliseconds); + const formatTimestamp = (dateInput, format) => window.formatTimestamp(dateInput, format); @@ -12098,7 +12046,7 @@ size: 12 lineData.push({ value: [timestamp.getTime(), collIdx, avgValue], collection: collection, - time: timestamp.toISOString().substring(0, 19).replace('T', ' '), + time: formatTimestamp(timestamp, "YYYY-MM-DD HH:MM:SS"), actualValue: avgValue, count: bucket.count }); @@ -12374,7 +12322,7 @@ size: 12 const value = params.data[2]; const timeStr = tIdx >= 0 && tIdx < data.timeBuckets.length - ? data.timeBuckets[tIdx].toISOString().substring(0, 19).replace('T', ' ') + ? formatTimestamp(data.timeBuckets[tIdx], "YYYY-MM-DD HH:MM:SS") : 'N/A'; const collection = cIdx >= 0 && cIdx < data.collections.length ? data.collections[cIdx] @@ -20232,7 +20180,7 @@ function generateElapsedTimeChart(requests) { // Apply timezone conversion to requestTime const originalTime = query.requestTime || ""; const convertedDate = getChartDate(originalTime); - const formattedDate = convertedDate ? convertedDate.toISOString().replace('T', ' ').substring(0, 23) + 'Z' : originalTime; + const formattedDate = convertedDate ? formatTimestamp(convertedDate, "YYYY-MM-DD HH:MM:SS.sssZ") : originalTime; Logger.trace(`[updateSampleQueriesTable] Query ${index}: Original=${originalTime}, Converted=${formattedDate}`); @@ -20368,7 +20316,7 @@ function generateElapsedTimeChart(requests) { // Apply timezone conversion to requestTime const originalTime = query.requestTime || ""; const convertedDate = getChartDate(originalTime); - const formattedDate = convertedDate ? convertedDate.toISOString().replace('T', ' ').substring(0, 23) + 'Z' : originalTime; + const formattedDate = convertedDate ? formatTimestamp(convertedDate, "YYYY-MM-DD HH:MM:SS.sssZ") : originalTime; Logger.trace(`[Insights] Query ${index}: Original=${originalTime}, Converted=${formattedDate}`); @@ -20439,7 +20387,7 @@ function generateElapsedTimeChart(requests) { // Apply timezone conversion to requestTime const originalTime = query.requestTime || ""; const convertedDate = getChartDate(originalTime); - const formattedDate = convertedDate ? convertedDate.toISOString().replace('T', ' ').substring(0, 23) + 'Z' : originalTime; + const formattedDate = convertedDate ? formatTimestamp(convertedDate, "YYYY-MM-DD HH:MM:SS.sssZ") : originalTime; Logger.trace(`[Insights] JOIN Query ${index}: Original=${originalTime}, Converted=${formattedDate}`); const flags = query.flags || []; @@ -29233,19 +29181,6 @@ ${info.features.map((f) => ` • ${f}`).join("\n")} } } - /** - * Format ISO timestamp to readable format - */ - function formatTimestamp(isoStr) { - if (!isoStr) return ''; - try { - const d = new Date(isoStr); - return d.toISOString().replace('T', ' ').substring(0, 19); - } catch (e) { - return isoStr; - } - } - /** * Render vis.js Timeline for AI Chart Trends insights * @param {Array} insights - Array of insight objects with start, end, group, severity, title, content @@ -29327,7 +29262,7 @@ ${info.features.map((f) => ` • ${f}`).join("\n")} title: `
${insight.title || 'Insight'}
-
FROM: ${formatTimestamp(insight.start)} → TO: ${formatTimestamp(insight.end || insight.start)} (${duration})
+
FROM: ${formatTimestamp(insight.start, "YYYY-MM-DD HH:MM:SS")} → TO: ${formatTimestamp(insight.end || insight.start, "YYYY-MM-DD HH:MM:SS")} (${duration})
${insight.content || ''}
`, @@ -29338,7 +29273,7 @@ ${info.features.map((f) => ` • ${f}`).join("\n")} // Add stake focus item if enabled if (stakeFocus && stakeFocus.enabled && stakeFocus.datetime) { const stakeColors = severityColors.stake; - const stakeTime = formatTimestamp(stakeFocus.datetime); + const stakeTime = formatTimestamp(stakeFocus.datetime, "YYYY-MM-DD HH:MM:SS"); itemsData.push({ id: 'stake-point', @@ -29401,7 +29336,7 @@ ${info.features.map((f) => ` • ${f}`).join("\n")} if (stakeFocus && stakeFocus.enabled && stakeFocus.datetime) { const stakeDate = new Date(stakeFocus.datetime); window._aiInsightsTimeline.addCustomTime(stakeDate, 'stake-focus-line'); - window._aiInsightsTimeline.setCustomTimeTitle('📍 Stake Focus: ' + formatTimestamp(stakeFocus.datetime), 'stake-focus-line'); + window._aiInsightsTimeline.setCustomTimeTitle('📍 Stake Focus: ' + formatTimestamp(stakeFocus.datetime, "YYYY-MM-DD HH:MM:SS"), 'stake-focus-line'); Logger.info('[AI] 📍 Added stake focus vertical line at: ' + stakeFocus.datetime); } @@ -29462,8 +29397,8 @@ ${info.features.map((f) => ` • ${f}`).join("\n")} sortedInsights.forEach((insight, idx) => { const duration = formatDuration(insight.start, insight.end); - const startTime = formatTimestamp(insight.start).substring(5); // Remove year - const endTime = formatTimestamp(insight.end || insight.start).substring(5); + const startTime = formatTimestamp(insight.start, "YYYY-MM-DD HH:MM:SS").substring(5); // Remove year + const endTime = formatTimestamp(insight.end || insight.start, "YYYY-MM-DD HH:MM:SS").substring(5); const rowBg = idx % 2 === 0 ? '#fff' : '#f8f9fa'; html += ` diff --git a/app/assets/js/main.js b/app/assets/js/main.js index 39659039..214c8375 100644 --- a/app/assets/js/main.js +++ b/app/assets/js/main.js @@ -49,6 +49,9 @@ import { formatNumber, formatBytes, formatDuration, + formatTime, + formatTimeTooltip, + formatTimestamp, escapeHtml, openModal, closeModal, @@ -68,6 +71,9 @@ window.throttle = throttle; window.formatNumber = formatNumber; window.formatBytes = formatBytes; window.formatDuration = formatDuration; +window.formatTime = formatTime; +window.formatTimeTooltip = formatTimeTooltip; +window.formatTimestamp = formatTimestamp; // Data Layer Helpers window.detectTimezoneFromData = detectTimezoneFromData; diff --git a/app/assets/js/tables.js b/app/assets/js/tables.js index 5d2d7254..4b0d3d00 100644 --- a/app/assets/js/tables.js +++ b/app/assets/js/tables.js @@ -13,7 +13,7 @@ // IMPORTS // ============================================================ -import { Logger, TEXT_CONSTANTS } from './base.js'; +import { Logger, TEXT_CONSTANTS, formatTimestamp } from './base.js'; import { originalRequests, statementStore, @@ -566,7 +566,7 @@ import { // Apply timezone conversion to requestTime const originalTime = query.requestTime || ""; const convertedDate = getChartDate(originalTime); - const formattedDate = convertedDate ? convertedDate.toISOString().replace('T', ' ').substring(0, 23) + 'Z' : originalTime; + const formattedDate = convertedDate ? formatTimestamp(convertedDate, "YYYY-MM-DD HH:MM:SS.sssZ") : originalTime; Logger.trace(`[tables] Query ${index}: Original=${originalTime}, Converted=${formattedDate}`); diff --git a/app/assets/js/time-format.js b/app/assets/js/time-format.js new file mode 100644 index 00000000..786e1ac7 --- /dev/null +++ b/app/assets/js/time-format.js @@ -0,0 +1,54 @@ +/** + * Pure duration formatters (issue #228). + * + * No imports, so Jest can `require()` this file and ES modules can import it + * for side-effect (assigns globalThis.formatTime / formatTimeTooltip). + * ui-helpers.js re-exports the same functions as the app-facing API. + */ +(function (root, factory) { + var api = factory(); + if (typeof module === "object" && module.exports) { + module.exports = api; + } + if (root) { + root.formatTime = api.formatTime; + root.formatTimeTooltip = api.formatTimeTooltip; + } +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + function formatTime(milliseconds) { + if (!milliseconds || isNaN(milliseconds) || milliseconds <= 0) { + return "00:00.000"; + } + + if (milliseconds < 1) { + milliseconds = Math.max(0.001, Math.round(milliseconds * 1000) / 1000); + } + + var totalSeconds = Math.floor(milliseconds / 1000); + var remainingMs = milliseconds % 1000; + var minutes = Math.floor(totalSeconds / 60); + var seconds = totalSeconds % 60; + + var formattedMinutes = minutes.toString().padStart(2, "0"); + var formattedSeconds = seconds.toString().padStart(2, "0"); + var formattedMs = Math.round(remainingMs).toString().padStart(3, "0"); + + return formattedMinutes + ":" + formattedSeconds + "." + formattedMs; + } + + function formatTimeTooltip(timeStr, milliseconds) { + if (!timeStr || timeStr === "N/A") { + return ""; + } + if (milliseconds < 1) { + return "Original: " + timeStr; + } + var formatted = formatTime(milliseconds); + if (timeStr !== formatted) { + return "Original: " + timeStr; + } + return ""; + } + + return { formatTime: formatTime, formatTimeTooltip: formatTimeTooltip }; +}); diff --git a/app/assets/js/ui-helpers.js b/app/assets/js/ui-helpers.js index 07dddf11..3528445f 100644 --- a/app/assets/js/ui-helpers.js +++ b/app/assets/js/ui-helpers.js @@ -6,11 +6,27 @@ // - Toast notifications // - Modal helpers // - Clipboard utilities -// - Formatters (number, duration, bytes) +// - Formatters (number, duration, MM:SS.mmm, timestamps) // - Debounce/throttle // ============================================================ -import { TEXT_CONSTANTS, Logger } from './base.js'; +import { TEXT_CONSTANTS, Logger, formatTimestamp } from './base.js'; +import './time-format.js'; + +export { formatTimestamp }; + +// Captured from time-format.js (UMD) so we re-export a stable ESM binding +// and so later window.formatTime = formatTime does not recurse. +const _formatTime = globalThis.formatTime; +const _formatTimeTooltip = globalThis.formatTimeTooltip; + +export function formatTime(milliseconds) { + return _formatTime(milliseconds); +} + +export function formatTimeTooltip(timeStr, milliseconds) { + return _formatTimeTooltip(timeStr, milliseconds); +} // ============================================================ // DOM UTILITIES @@ -368,3 +384,6 @@ export const PerformanceUtils = { // Expose globally window.PerformanceUtils = PerformanceUtils; +window.formatTime = formatTime; +window.formatTimeTooltip = formatTimeTooltip; +window.formatTimestamp = formatTimestamp; diff --git a/tests/formatters.test.js b/tests/formatters.test.js new file mode 100644 index 00000000..83ca6e6e --- /dev/null +++ b/tests/formatters.test.js @@ -0,0 +1,40 @@ +/** + * Issue #228 — duration and timestamp display contract. + * + * formatTime / formatTimeTooltip live in app/assets/js/time-format.js + * and are re-exported from ui-helpers.js. formatTimestamp lives in base.js. + */ +const { formatTime, formatTimeTooltip } = require('../app/assets/js/time-format.js'); + +describe('formatTime (MM:SS.mmm)', () => { + test('zero and invalid values are 00:00.000', () => { + expect(formatTime(0)).toBe('00:00.000'); + expect(formatTime(null)).toBe('00:00.000'); + expect(formatTime(NaN)).toBe('00:00.000'); + expect(formatTime(-5)).toBe('00:00.000'); + }); + + test('issue #228 examples', () => { + expect(formatTime(1500)).toBe('00:01.500'); + expect(formatTime(65000)).toBe('01:05.000'); + }); + + test('values in [0.5, 1) ms round to 00:00.001', () => { + expect(formatTime(0.6)).toBe('00:00.001'); + }); +}); + +describe('formatTimeTooltip', () => { + test('empty / N/A returns empty string', () => { + expect(formatTimeTooltip('', 10)).toBe(''); + expect(formatTimeTooltip('N/A', 10)).toBe(''); + }); + + test('sub-millisecond shows original string', () => { + expect(formatTimeTooltip('146.266µs', 0.146)).toMatch(/Original: 146.266µs/); + }); + + test('values that differ from MM:SS.mmm show original', () => { + expect(formatTimeTooltip('1.5s', 1500)).toMatch(/Original: 1.5s/); + }); +});