// 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]; }