UNPKG

commute-when

Version:

Find the best time to leave for your commute

418 lines (363 loc) 11.7 kB
#!/usr/bin/env node import dotenv from "dotenv"; import ora from "ora"; import chalk from "chalk"; import fs from "fs/promises"; import path from "path"; import os from "os"; import { Command } from "commander"; dotenv.config(); const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY as string; if (!GOOGLE_MAPS_API_KEY) { console.error("Please set the GOOGLE_MAPS_API_KEY environment variable."); process.exit(1); } interface CommuteConfig { origin: string; destination: string; } // Add these interfaces and types interface DateOption { date: Date; description: string; } type DayOfWeek = | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday"; // Add this function to calculate next occurrence of a day function getNextDayOfWeek(dayName: DayOfWeek): Date { const days = [ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", ]; const today = new Date(); const targetDay = days.indexOf(dayName); const todayDay = today.getDay(); let daysUntilTarget = targetDay - todayDay; if (daysUntilTarget <= 0) { daysUntilTarget += 7; } const nextDate = new Date(); nextDate.setDate(today.getDate() + daysUntilTarget); return nextDate; } async function getConfig(): Promise<CommuteConfig | null> { const configPath = path.join(os.homedir(), ".config", "commute.json"); try { const configData = await fs.readFile(configPath, "utf-8"); return JSON.parse(configData); } catch (error) { return null; } } async function saveConfig(config: CommuteConfig): Promise<void> { const configDir = path.join(os.homedir(), ".config"); const configPath = path.join(configDir, "commute.json"); try { // Ensure .config directory exists await fs.mkdir(configDir, { recursive: true }); await fs.writeFile(configPath, JSON.stringify(config, null, 2)); } catch (error) { console.error("Failed to save configuration:", error); } } async function getCommuteLocations(): Promise<CommuteConfig> { // Check command line arguments const args = process.argv.slice(2); if (args.length >= 2) { const config = { origin: args[0], destination: args[1], }; // Save the config for future use await saveConfig(config); return config; } // fallback to use config const savedConfig = await getConfig(); if (savedConfig) { return savedConfig; } // If neither config file nor command line args are present, show usage and exit console.log(chalk.yellow("Usage: commute-when <origin> <destination>")); console.log( chalk.gray( 'Example: commute-when "1234 Culver Drive, Irvine, CA 92602" "4077 Ince Blvd, Culver City, CA 90232"', ), ); console.log( chalk.gray( "\nOr create a config file at ~/.config/commute.json with the following format:", ), ); console.log( chalk.gray(`{ "origin": "your origin address", "destination": "your destination address" }`), ); process.exit(1); } const DEPARTURE_TIME = "now"; export async function getDirections( origin: string, destination: string, departureTime: string | number = DEPARTURE_TIME, ): Promise<any> { try { const url = new URL("https://maps.googleapis.com/maps/api/directions/json"); url.searchParams.append("origin", origin); url.searchParams.append("destination", destination); url.searchParams.append("key", GOOGLE_MAPS_API_KEY); url.searchParams.append( "departure_time", typeof departureTime === "string" ? Math.floor(Date.now() / 1000).toString() : departureTime.toString(), ); url.searchParams.append("traffic_model", "best_guess"); // Add these additional required parameters url.searchParams.append("mode", "driving"); url.searchParams.append("alternatives", "false"); const response = await fetch(url); const data = await response.json(); if (data.status !== "OK") { throw new Error( `Direction request failed: ${data.status} - ${JSON.stringify(data.error_message)}`, ); } const route = data.routes[0]; const legs = route.legs[0]; return { distance: legs.distance.text, duration: legs.duration.text, durationInTraffic: legs.duration_in_traffic?.text || legs.duration.text, startAddress: legs.start_address, endAddress: legs.end_address, steps: legs.steps.map((step: any) => ({ instruction: step.html_instructions.replace(/<[^>]+>/g, ""), distance: step.distance.text, duration: step.duration.text, })), }; } catch (error) { if (error instanceof Error) { console.error("Error getting directions:", error.message); } throw error; } } // Enhanced bar chart with better styling function createBarChart(trips: any[], title: string): void { if (trips.length === 0) return; const maxDuration = Math.max(...trips.map((t) => t.totalMinutes)); const minDuration = Math.min(...trips.map((t) => t.totalMinutes)); const scale = 5; console.log(`\n${chalk.bold(title)}`); console.log(chalk.gray("╭" + "─".repeat(78) + "╮")); console.log( chalk.gray("│") + chalk.dim(" Duration in minutes (each █ = 5 minutes)").padEnd(77) + chalk.gray("│"), ); console.log(chalk.gray("├" + "─".repeat(78) + "┤")); trips.forEach((trip) => { const time = trip.departureTime.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); const bars = "█".repeat(Math.round(trip.totalMinutes / scale)); const duration = `${trip.totalMinutes}min`; const barColor = trip.totalMinutes === minDuration ? chalk.green : trip.totalMinutes === maxDuration ? chalk.red : chalk.yellow; const indicator = trip.totalMinutes === minDuration ? chalk.green("✓ BEST ") : trip.totalMinutes === maxDuration ? chalk.red("⚠ WORST ") : " "; console.log( chalk.gray("│ ") + `${chalk.blue(time.padEnd(7))} [${barColor(bars.padEnd(maxDuration / scale))}] ` + `${chalk.cyan(duration.padEnd(8))} ${indicator}` + chalk.gray(" │"), ); }); console.log(chalk.gray("├" + "─".repeat(78) + "┤")); const bestTrip = trips.reduce((min, trip) => trip.totalMinutes < min.totalMinutes ? trip : min, ); console.log( chalk.gray("│ ") + chalk.green( `Best time to leave: ${bestTrip.departureTime.toLocaleTimeString()} (${bestTrip.duration})`, ) + " ".repeat(30) + chalk.gray(" │"), ); console.log(chalk.gray("╰" + "─".repeat(78) + "╯")); } async function findBestTimes( origin: string, destination: string, startHour: number, endHour: number, direction: string, analysisDate: Date = new Date(), ): Promise<void> { const trips = []; const today = new Date(analysisDate); today.setDate(today.getDate() + 1); today.setHours(startHour, 30, 0, 0); const endTime = new Date(today); endTime.setHours(endHour, 0, 0, 0); // Create a spinner const spinner = ora({ text: `Analyzing ${direction.toLowerCase()}...`, color: "blue", }).start(); let totalChecks = 0; const expectedChecks = Math.ceil( (endTime.getTime() - today.getTime()) / (30 * 60 * 1000), ); while (today <= endTime) { const timestamp = Math.floor(today.getTime() / 1000); try { const directions = await getDirections(origin, destination, timestamp); const durationStr = directions.durationInTraffic; const hours = durationStr.match(/(\d+) hour/)?.[1] || 0; const mins = durationStr.match(/(\d+) min/)?.[1] || 0; const totalMinutes = parseInt(hours) * 60 + parseInt(mins); trips.push({ departureTime: new Date(today), duration: directions.durationInTraffic, totalMinutes: totalMinutes, distance: directions.distance, }); totalChecks++; spinner.text = `Analyzing ${direction.toLowerCase()}... [${totalChecks}/${expectedChecks}]`; } catch (error) { // Silent error handling, just continue } await new Promise((resolve) => setTimeout(resolve, 1000)); today.setMinutes(today.getMinutes() + 30); } spinner.succeed(`${direction} analysis completed`); if (trips.length === 0) { console.log(`\n=== ${direction} ===`); console.log("No valid trips found for this time period."); return; } createBarChart(trips, `=== ${direction} ===`); } // Modify the main function to handle date options async function main() { const program = new Command(); program .name("commute-when") .description("Analyze commute times for different days") .argument("[origin]", "Starting location") .argument("[destination]", "Destination location") .option("--today", "Analyze commute for today") .option("--tomorrow", "Analyze commute for tomorrow") .option("--next-monday", "Analyze commute for next Monday") .option("--next-tuesday", "Analyze commute for next Tuesday") .option("--next-wednesday", "Analyze commute for next Wednesday") .option("--next-thursday", "Analyze commute for next Thursday") .option("--next-friday", "Analyze commute for next Friday") .option("--next-saturday", "Analyze commute for next Saturday") .option("--next-sunday", "Analyze commute for next Sunday"); program.parse(); const options = program.opts(); const args = program.args; try { // Get locations either from arguments or config const locations = args.length >= 2 ? { origin: args[0], destination: args[1] } : await getCommuteLocations(); // Determine which date to analyze let analysisDate: DateOption = { date: new Date(), description: "Today" }; if (options.tomorrow) { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); analysisDate = { date: tomorrow, description: "Tomorrow" }; } else if (options.nextMonday) { analysisDate = { date: getNextDayOfWeek("monday"), description: "Next Monday", }; } else if (options.nextTuesday) { analysisDate = { date: getNextDayOfWeek("tuesday"), description: "Next Tuesday", }; } else if (options.nextWednesday) { analysisDate = { date: getNextDayOfWeek("wednesday"), description: "Next Wednesday", }; } else if (options.nextThursday) { analysisDate = { date: getNextDayOfWeek("thursday"), description: "Next Thursday", }; } else if (options.nextFriday) { analysisDate = { date: getNextDayOfWeek("friday"), description: "Next Friday", }; } else if (options.nextSaturday) { analysisDate = { date: getNextDayOfWeek("saturday"), description: "Next Saturday", }; } else if (options.nextSunday) { analysisDate = { date: getNextDayOfWeek("sunday"), description: "Next Sunday", }; } console.log( chalk.bold.blue( `Traffic Analysis for Your Commute - ${analysisDate.description}\n`, ), ); // Modify findBestTimes to accept a date parameter await findBestTimes( locations.origin, locations.destination, 6, 10, "Morning Commute (To Work)", analysisDate.date, ); await findBestTimes( locations.destination, locations.origin, 13, 19, "Afternoon Commute (To Home)", analysisDate.date, ); } catch (error) { console.error(chalk.red("Failed to run commute analysis:", error)); process.exit(1); } } // Run the analysis main();