/** * Shared timeline formatting utilities * * Pure formatting and grouping functions extracted from context-generator.ts * to be reused by SearchManager and other services. */ import path from 'path'; import { logger } from '../utils/logger.js'; /** * Parse JSON array string, returning empty array on failure */ export function parseJsonArray(json: string | null): string[] { if (!json) return []; try { const parsed = JSON.parse(json); return Array.isArray(parsed) ? parsed : []; } catch (err) { logger.debug('PARSER', 'Failed to parse JSON array, using empty fallback', { preview: json?.substring(0, 50) }, err as Error); return []; } } /** * Format date with time (e.g., "Dec 14, 7:30 PM") * Accepts either ISO date string or epoch milliseconds */ export function formatDateTime(dateInput: string | number): string { const date = new Date(dateInput); return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); } /** * Format just time, no date (e.g., "7:30 PM") * Accepts either ISO date string or epoch milliseconds */ export function formatTime(dateInput: string | number): string { const date = new Date(dateInput); return date.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } /** * Format just date (e.g., "Dec 14, 2025") * Accepts either ISO date string or epoch milliseconds */ export function formatDate(dateInput: string | number): string { const date = new Date(dateInput); return date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } /** * Convert absolute paths to relative paths */ export function toRelativePath(filePath: string, cwd: string): string { if (path.isAbsolute(filePath)) { return path.relative(cwd, filePath); } return filePath; } /** * Extract first relevant file from files_modified OR files_read JSON arrays. * Prefers files_modified, falls back to files_read. * Returns 'General' only if both are empty. */ export function extractFirstFile( filesModified: string | null, cwd: string, filesRead?: string | null ): string { // Try files_modified first const modified = parseJsonArray(filesModified); if (modified.length > 0) { return toRelativePath(modified[0], cwd); } // Fall back to files_read if (filesRead) { const read = parseJsonArray(filesRead); if (read.length > 0) { return toRelativePath(read[0], cwd); } } return 'General'; } /** * Estimate token count for text (rough approximation: ~4 chars per token) */ export function estimateTokens(text: string | null): number { if (!text) return 0; return Math.ceil(text.length / 4); } /** * Group items by date * * Generic function that works with any item type that has a date field. * Returns a Map of date string -> items array, sorted chronologically. * * @param items - Array of items to group * @param getDate - Function to extract date string from each item * @returns Map of formatted date strings to item arrays, sorted chronologically */ export function groupByDate( items: T[], getDate: (item: T) => string ): Map { // Group by day const itemsByDay = new Map(); for (const item of items) { const itemDate = getDate(item); const day = formatDate(itemDate); if (!itemsByDay.has(day)) { itemsByDay.set(day, []); } itemsByDay.get(day)!.push(item); } // Sort days chronologically const sortedEntries = Array.from(itemsByDay.entries()).sort((a, b) => { const aDate = new Date(a[0]).getTime(); const bDate = new Date(b[0]).getTime(); return aDate - bDate; }); return new Map(sortedEntries); }