From 1117be40cce896119ecab45bb769802a4f4ac426 Mon Sep 17 00:00:00 2001 From: Joseph McQueen Date: Tue, 21 Jul 2026 14:41:26 -0400 Subject: [PATCH] Format chat footers in DISPLAY_TIMEZONE instead of UTC. Docker hosts default to UTC, so bare toLocaleTimeString() showed wrong "Last checked" times in avstatus and other commands. Add formatDisplayTime() (default America/New_York, overridable via DISPLAY_TIMEZONE) and use it across renderers and command footers. --- .env.example | 5 ++++ commands/jiraHistory.js | 3 ++- commands/jiraTicket.js | 3 ++- commands/woHistory.js | 3 ++- commands/woSummary.js | 3 ++- services/renderers/avStatusRenderer.js | 4 +-- services/renderers/phoneStatusRenderer.js | 6 ++--- services/renderers/voiceDiagRenderer.js | 5 ++-- services/renderers/wanDiagnosticsRenderer.js | 3 ++- tests/time.test.js | 26 ++++++++++++++++++++ tests/voiceDiagRenderer.test.js | 4 +-- utils/time.js | 26 ++++++++++++++++++++ 12 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 tests/time.test.js diff --git a/.env.example b/.env.example index f57a99c..bfe19e1 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,11 @@ SERVER_PORT=1800 # Logging level: info (default - clean), debug (verbose, includes per-fetch details) LOG_LEVEL=info +# IANA timezone for "Last checked" footers in chat output (avstatus, +# phonestatus, etc.). The bot often runs in UTC inside Docker; this +# keeps timestamps in operator-local time. Default: America/New_York. +# DISPLAY_TIMEZONE=America/New_York + # Verbose Webex framework debug logs. Default off; auto-enabled when LOG_LEVEL=debug. # WEBEX_FRAMEWORK_DEBUG=false diff --git a/commands/jiraHistory.js b/commands/jiraHistory.js index 227b1a5..f4277ef 100644 --- a/commands/jiraHistory.js +++ b/commands/jiraHistory.js @@ -8,6 +8,7 @@ import { import { summarizeJiraTicket } from '../services/jiraSummarizer.js'; import { analyzeCommonIssues } from '../services/jiraSummarizer.js'; import jira from '../integrations/jira/JiraClient.js'; +import { formatDisplayTime } from '../utils/time.js'; import { logger } from '../utils/logger.js'; const MAX_TICKETS = 20; @@ -134,7 +135,7 @@ export async function handleJiraHistory(bot, trigger) { } } - reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; + reply += `\n*Last checked: ${formatDisplayTime()}*`; await bot.say('markdown', reply.trim()); } catch (err) { diff --git a/commands/jiraTicket.js b/commands/jiraTicket.js index 0cf1c81..fa88594 100644 --- a/commands/jiraTicket.js +++ b/commands/jiraTicket.js @@ -3,6 +3,7 @@ import jira from '../integrations/jira/JiraClient.js'; import { summarizeJiraTicket } from '../services/jiraSummarizer.js'; import { logger } from '../utils/logger.js'; import { getStatusEmoji, calculateDaysOpen } from '../services/jiraService.js'; +import { formatDisplayTime } from '../utils/time.js'; export async function handleJiraTicket(bot, trigger) { logger('jira:ticket', 'Handler entered', 'debug'); @@ -44,7 +45,7 @@ export async function handleJiraTicket(bot, trigger) { reply += `${aiSummary}\n\n`; reply += `${statusEmoji} **Status:** ${fields.status?.name || '—'} • Component: ${component}\n`; reply += `Assigned: ${assignee} • Open for: ${days} days\n\n`; - reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; + reply += `\n*Last checked: ${formatDisplayTime()}*`; await bot.say('markdown', reply.trim()); diff --git a/commands/woHistory.js b/commands/woHistory.js index ec13827..0a7b782 100644 --- a/commands/woHistory.js +++ b/commands/woHistory.js @@ -1,6 +1,7 @@ // src/commands/woHistory.js import { collectWoHistory } from '../services/woService.js'; import { logger } from '../utils/logger.js'; +import { formatDisplayTime } from '../utils/time.js'; export async function handleWoHistory(bot, trigger) { logger('wo:history', 'Handler entered'); @@ -52,7 +53,7 @@ export async function handleWoHistory(bot, trigger) { } } - reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; + reply += `\n*Last checked: ${formatDisplayTime()}*`; await bot.say('markdown', reply.trim() || 'No data available.'); diff --git a/commands/woSummary.js b/commands/woSummary.js index 4529406..9eda866 100644 --- a/commands/woSummary.js +++ b/commands/woSummary.js @@ -1,6 +1,7 @@ // src/commands/woSummary.js import { collectWoSummary } from '../services/woService.js'; import { logger } from '../utils/logger.js'; +import { formatDisplayTime } from '../utils/time.js'; export async function handleWoSummary(bot, trigger) { logger('wo:summary', 'Handler entered'); @@ -29,7 +30,7 @@ export async function handleWoSummary(bot, trigger) { let reply = `**Work Order Summary – ${woNumber}**\n\n`; reply += `${summary || 'No summary data available.'}\n`; - reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; + reply += `\n*Last checked: ${formatDisplayTime()}*`; await bot.say('markdown', reply.trim()); diff --git a/services/renderers/avStatusRenderer.js b/services/renderers/avStatusRenderer.js index 2a78c03..a8325b3 100644 --- a/services/renderers/avStatusRenderer.js +++ b/services/renderers/avStatusRenderer.js @@ -20,7 +20,7 @@ // handler — same wired-vs-wireless branch, same double-arrow indent // convention, same fallback for `{client:...}` vs flat shapes. -import { simpleTimeAgo } from '../../utils/time.js'; +import { simpleTimeAgo, formatDisplayTime } from '../../utils/time.js'; /** * Render an AV device-status markdown snapshot from a @@ -250,7 +250,7 @@ export function renderAvStatusMarkdown(data, opts = {}) { } if (footer) { - reply += `*Last checked: ${new Date().toLocaleTimeString()}*`; + reply += `*Last checked: ${formatDisplayTime()}*`; } return reply.trim(); diff --git a/services/renderers/phoneStatusRenderer.js b/services/renderers/phoneStatusRenderer.js index 9db4de3..811cff0 100644 --- a/services/renderers/phoneStatusRenderer.js +++ b/services/renderers/phoneStatusRenderer.js @@ -18,7 +18,7 @@ // `simpleTimeAgo` — the same helper the chat handler used, so relative // times ("2h ago") stay consistent across chat and Jira surfaces. -import { simpleTimeAgo, formatBytes } from '../../utils/time.js'; +import { simpleTimeAgo, formatBytes, formatDisplayTime } from '../../utils/time.js'; /** * Render a phone-status markdown snapshot from a `collectPhoneStatus` @@ -232,7 +232,7 @@ export function renderPhoneStatusMarkdown(data, opts = {}) { } if (footer) { - reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; + reply += `\n*Last checked: ${formatDisplayTime()}*`; } return reply.trim(); @@ -272,7 +272,7 @@ export function renderDectDiagnosticsMarkdown(results, opts = {}) { } if (footer) { - out += `\n*Base diagnostics pulled at ${new Date().toLocaleTimeString()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`; + out += `\n*Base diagnostics pulled at ${formatDisplayTime()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`; } return out.trim(); } diff --git a/services/renderers/voiceDiagRenderer.js b/services/renderers/voiceDiagRenderer.js index 1a89e71..a50a58a 100644 --- a/services/renderers/voiceDiagRenderer.js +++ b/services/renderers/voiceDiagRenderer.js @@ -34,6 +34,8 @@ // The renderer never emits an adaptive card itself. The caller // (commands/voiceDiag.js) walks the same results array to post cards. +import { formatDisplayTime } from '../../utils/time.js'; + const SEVERITY_ORDER = ['error', 'warn', 'skipped', 'ok']; const SEVERITY_LABEL = { error: 'ERRORS', @@ -118,8 +120,7 @@ export function renderVoiceDiagMarkdown(results, opts = {}) { } if (emitFooter) { - const now = new Date(); - reply += `_Last checked: ${now.toISOString()}_\n`; + reply += `_Last checked: ${formatDisplayTime()}_\n`; } return reply.trim(); diff --git a/services/renderers/wanDiagnosticsRenderer.js b/services/renderers/wanDiagnosticsRenderer.js index 3508eeb..588fe80 100644 --- a/services/renderers/wanDiagnosticsRenderer.js +++ b/services/renderers/wanDiagnosticsRenderer.js @@ -22,6 +22,7 @@ import { rollupAlarms as sharedRollupAlarms, humanizeAge, } from '../enrichment/alarmSemantics.js'; +import { formatDisplayTime } from '../../utils/time.js'; // Thresholds are read from env at render time so the rendered // icons stay in sync with the check bucket verdicts. Same defaults @@ -152,7 +153,7 @@ export function renderWanDiagnosticsMarkdown(data, opts = {}) { if (footer) { const store = storeNum || data.storeNum; - out += `\n*WAN metrics pulled at ${new Date().toLocaleTimeString()} from Prisma SD-WAN. ` + + out += `\n*WAN metrics pulled at ${formatDisplayTime()} from Prisma SD-WAN. ` + `Use \`/voicediag ${store} --only wanLatency,wanJitter,wanLoss,wanMos,wanHealthscore,wanLinkState,wanAlarms\` for link-probe breakdowns, ` + `or \`/voicediag ${store} --only wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter\` for per-app RTP quality.*`; } diff --git a/tests/time.test.js b/tests/time.test.js new file mode 100644 index 0000000..e5664e0 --- /dev/null +++ b/tests/time.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { formatDisplayTime, DISPLAY_TIMEZONE } from '../utils/time.js'; + +test('DISPLAY_TIMEZONE defaults to America/New_York', () => { + // If DISPLAY_TIMEZONE env is unset in test runner, we expect the default. + const expected = process.env.DISPLAY_TIMEZONE || 'America/New_York'; + assert.equal(DISPLAY_TIMEZONE, expected); +}); + +test('formatDisplayTime: converts UTC instant to Eastern wall clock', () => { + // 2026-07-21 18:13:45 UTC → 2:13:45 PM EDT (DST) + const d = new Date('2026-07-21T18:13:45.000Z'); + const formatted = formatDisplayTime(d); + assert.match(formatted, /2:13:45 PM/); + assert.match(formatted, /EDT/); +}); + +test('formatDisplayTime: winter offset uses EST', () => { + // 2026-01-15 18:00:00 UTC → 1:00:00 PM EST + const d = new Date('2026-01-15T18:00:00.000Z'); + const formatted = formatDisplayTime(d); + assert.match(formatted, /1:00:00 PM/); + assert.match(formatted, /EST/); +}); diff --git a/tests/voiceDiagRenderer.test.js b/tests/voiceDiagRenderer.test.js index f68d51c..6851388 100644 --- a/tests/voiceDiagRenderer.test.js +++ b/tests/voiceDiagRenderer.test.js @@ -118,12 +118,12 @@ test('renderer: emitFooter=false suppresses trailing timestamp', () => { assert.equal(md.includes('Last checked'), false); }); -test('renderer: emitFooter=true (default) adds an ISO timestamp line', () => { +test('renderer: emitFooter=true (default) adds a display-timezone timestamp line', () => { const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], { storeNum: '99', detailed: true, }); - assert.match(md, /_Last checked: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + assert.match(md, /_Last checked: .+ (AM|PM) [A-Z]{2,5}_/); }); test('renderer: details values — arrays truncated past 3 items, nested objects JSON-ified', () => { diff --git a/utils/time.js b/utils/time.js index b8ff422..9be36d4 100644 --- a/utils/time.js +++ b/utils/time.js @@ -1,6 +1,32 @@ // utils/time.js import { logger } from './logger.js'; +/** + * IANA timezone for human-facing footer timestamps ("Last checked", etc.). + * The bot process often runs in UTC (Docker default); this keeps chat + * output in operator-local time without requiring TZ on the container. + * Override via DISPLAY_TIMEZONE in .env. + */ +export const DISPLAY_TIMEZONE = process.env.DISPLAY_TIMEZONE || 'America/New_York'; + +/** + * Format a Date for chat footers ("Last checked: …"). Uses DISPLAY_TIMEZONE + * and includes a short zone label (e.g. "EDT") so UTC-looking output is obvious. + * + * @param {Date} [date=new Date()] + * @returns {string} e.g. "2:13:45 PM EDT" + */ +export function formatDisplayTime(date = new Date()) { + return date.toLocaleTimeString('en-US', { + timeZone: DISPLAY_TIMEZONE, + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, + timeZoneName: 'short', + }); +} + /** * Human-readable "X time ago" from ISO string or Date * @param {string|Date|number} input - ISO string, Date object, or timestamp