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.
This commit is contained in:
Joseph McQueen 2026-07-21 14:41:26 -04:00
parent 9d0dbb071e
commit 1117be40cc
12 changed files with 77 additions and 14 deletions

View file

@ -11,6 +11,11 @@ SERVER_PORT=1800
# Logging level: info (default - clean), debug (verbose, includes per-fetch details) # Logging level: info (default - clean), debug (verbose, includes per-fetch details)
LOG_LEVEL=info 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. # Verbose Webex framework debug logs. Default off; auto-enabled when LOG_LEVEL=debug.
# WEBEX_FRAMEWORK_DEBUG=false # WEBEX_FRAMEWORK_DEBUG=false

View file

@ -8,6 +8,7 @@ import {
import { summarizeJiraTicket } from '../services/jiraSummarizer.js'; import { summarizeJiraTicket } from '../services/jiraSummarizer.js';
import { analyzeCommonIssues } from '../services/jiraSummarizer.js'; import { analyzeCommonIssues } from '../services/jiraSummarizer.js';
import jira from '../integrations/jira/JiraClient.js'; import jira from '../integrations/jira/JiraClient.js';
import { formatDisplayTime } from '../utils/time.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
const MAX_TICKETS = 20; 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()); await bot.say('markdown', reply.trim());
} catch (err) { } catch (err) {

View file

@ -3,6 +3,7 @@ import jira from '../integrations/jira/JiraClient.js';
import { summarizeJiraTicket } from '../services/jiraSummarizer.js'; import { summarizeJiraTicket } from '../services/jiraSummarizer.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { getStatusEmoji, calculateDaysOpen } from '../services/jiraService.js'; import { getStatusEmoji, calculateDaysOpen } from '../services/jiraService.js';
import { formatDisplayTime } from '../utils/time.js';
export async function handleJiraTicket(bot, trigger) { export async function handleJiraTicket(bot, trigger) {
logger('jira:ticket', 'Handler entered', 'debug'); logger('jira:ticket', 'Handler entered', 'debug');
@ -44,7 +45,7 @@ export async function handleJiraTicket(bot, trigger) {
reply += `${aiSummary}\n\n`; reply += `${aiSummary}\n\n`;
reply += `${statusEmoji} **Status:** ${fields.status?.name || '—'} • Component: ${component}\n`; reply += `${statusEmoji} **Status:** ${fields.status?.name || '—'} • Component: ${component}\n`;
reply += `Assigned: ${assignee} • Open for: ${days} days\n\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()); await bot.say('markdown', reply.trim());

View file

@ -1,6 +1,7 @@
// src/commands/woHistory.js // src/commands/woHistory.js
import { collectWoHistory } from '../services/woService.js'; import { collectWoHistory } from '../services/woService.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { formatDisplayTime } from '../utils/time.js';
export async function handleWoHistory(bot, trigger) { export async function handleWoHistory(bot, trigger) {
logger('wo:history', 'Handler entered'); 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.'); await bot.say('markdown', reply.trim() || 'No data available.');

View file

@ -1,6 +1,7 @@
// src/commands/woSummary.js // src/commands/woSummary.js
import { collectWoSummary } from '../services/woService.js'; import { collectWoSummary } from '../services/woService.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { formatDisplayTime } from '../utils/time.js';
export async function handleWoSummary(bot, trigger) { export async function handleWoSummary(bot, trigger) {
logger('wo:summary', 'Handler entered'); logger('wo:summary', 'Handler entered');
@ -29,7 +30,7 @@ export async function handleWoSummary(bot, trigger) {
let reply = `**Work Order Summary ${woNumber}**\n\n`; let reply = `**Work Order Summary ${woNumber}**\n\n`;
reply += `${summary || 'No summary data available.'}\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()); await bot.say('markdown', reply.trim());

View file

@ -20,7 +20,7 @@
// handler — same wired-vs-wireless branch, same double-arrow indent // handler — same wired-vs-wireless branch, same double-arrow indent
// convention, same fallback for `{client:...}` vs flat shapes. // 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 * Render an AV device-status markdown snapshot from a
@ -250,7 +250,7 @@ export function renderAvStatusMarkdown(data, opts = {}) {
} }
if (footer) { if (footer) {
reply += `*Last checked: ${new Date().toLocaleTimeString()}*`; reply += `*Last checked: ${formatDisplayTime()}*`;
} }
return reply.trim(); return reply.trim();

View file

@ -18,7 +18,7 @@
// `simpleTimeAgo` — the same helper the chat handler used, so relative // `simpleTimeAgo` — the same helper the chat handler used, so relative
// times ("2h ago") stay consistent across chat and Jira surfaces. // 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` * Render a phone-status markdown snapshot from a `collectPhoneStatus`
@ -232,7 +232,7 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
} }
if (footer) { if (footer) {
reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; reply += `\n*Last checked: ${formatDisplayTime()}*`;
} }
return reply.trim(); return reply.trim();
@ -272,7 +272,7 @@ export function renderDectDiagnosticsMarkdown(results, opts = {}) {
} }
if (footer) { 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(); return out.trim();
} }

View file

@ -34,6 +34,8 @@
// The renderer never emits an adaptive card itself. The caller // The renderer never emits an adaptive card itself. The caller
// (commands/voiceDiag.js) walks the same results array to post cards. // (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_ORDER = ['error', 'warn', 'skipped', 'ok'];
const SEVERITY_LABEL = { const SEVERITY_LABEL = {
error: 'ERRORS', error: 'ERRORS',
@ -118,8 +120,7 @@ export function renderVoiceDiagMarkdown(results, opts = {}) {
} }
if (emitFooter) { if (emitFooter) {
const now = new Date(); reply += `_Last checked: ${formatDisplayTime()}_\n`;
reply += `_Last checked: ${now.toISOString()}_\n`;
} }
return reply.trim(); return reply.trim();

View file

@ -22,6 +22,7 @@ import {
rollupAlarms as sharedRollupAlarms, rollupAlarms as sharedRollupAlarms,
humanizeAge, humanizeAge,
} from '../enrichment/alarmSemantics.js'; } from '../enrichment/alarmSemantics.js';
import { formatDisplayTime } from '../../utils/time.js';
// Thresholds are read from env at render time so the rendered // Thresholds are read from env at render time so the rendered
// icons stay in sync with the check bucket verdicts. Same defaults // icons stay in sync with the check bucket verdicts. Same defaults
@ -152,7 +153,7 @@ export function renderWanDiagnosticsMarkdown(data, opts = {}) {
if (footer) { if (footer) {
const store = storeNum || data.storeNum; 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, ` + `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.*`; `or \`/voicediag ${store} --only wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter\` for per-app RTP quality.*`;
} }

26
tests/time.test.js Normal file
View file

@ -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/);
});

View file

@ -118,12 +118,12 @@ test('renderer: emitFooter=false suppresses trailing timestamp', () => {
assert.equal(md.includes('Last checked'), false); 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')], { const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], {
storeNum: '99', storeNum: '99',
detailed: true, 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', () => { test('renderer: details values — arrays truncated past 3 items, nested objects JSON-ified', () => {

View file

@ -1,6 +1,32 @@
// utils/time.js // utils/time.js
import { logger } from './logger.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 * Human-readable "X time ago" from ISO string or Date
* @param {string|Date|number} input - ISO string, Date object, or timestamp * @param {string|Date|number} input - ISO string, Date object, or timestamp