collabSupport/utils/time.js
Joseph McQueen 1117be40cc 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.
2026-07-21 14:41:26 -04:00

117 lines
No EOL
3.7 KiB
JavaScript

// 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
* @returns {string} e.g. "2 hours ago", "just now", "3 days ago"
*/
export function simpleTimeAgo(input) {
if (!input) return 'never';
let date;
try {
if (input instanceof Date) {
date = input;
} else if (typeof input === 'number') {
date = new Date(input);
} else if (typeof input === 'string') {
let cleaned = input.trim();
// If no timezone (no Z or offset), assume UTC and append Z
if (!cleaned.endsWith('Z') && !cleaned.match(/[+-]\d{2}:\d{2}$/)) {
cleaned += 'Z';
}
date = new Date(cleaned);
} else {
logger('time', `Invalid input type to simpleTimeAgo: ${typeof input}`, 'warn');
return 'invalid';
}
if (isNaN(date.getTime())) {
logger('time', `Invalid date parsed from: ${input}`, 'warn');
return 'invalid';
}
const now = Date.now();
const diffMs = now - date.getTime();
// Small clock skew tolerance (< 5 minutes in the future → treat as "just now")
if (diffMs < 0 && diffMs > -300000) {
return 'just now';
}
// Future dates
if (diffMs < 0) {
const futureMs = -diffMs;
const futureSeconds = Math.floor(futureMs / 1000);
const futureMinutes = Math.floor(futureSeconds / 60);
const futureHours = Math.floor(futureMinutes / 60);
const futureDays = Math.floor(futureHours / 24);
if (futureSeconds < 60) return `in ${futureSeconds} seconds`;
if (futureMinutes < 60) return `in ${futureMinutes} minutes`;
if (futureHours < 24) return `in ${futureHours} hours`;
return `in ${futureDays} days`;
}
// Past dates
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return `${seconds} seconds ago`;
if (minutes < 60) return `${minutes} minutes ago`;
if (hours < 24) return `${hours} hours ago`;
if (days < 30) return `${days} days ago`;
// Fallback for very old dates
return date.toLocaleDateString();
} catch (err) {
logger('time', `Error in simpleTimeAgo: ${err.message}`, 'warn');
return 'invalid';
}
}
/**
* Format a byte count into a human-readable string (e.g. "172 KB").
* Used for Meraki client usage in phone status (and reusable elsewhere).
*/
export function formatBytes(bytes, decimals = 1) {
if (bytes == null || bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}