// Main application state let appState = { commits: [], episodes: [], currentScale: 'week', clusterWindow: 240, selectedEpisode: null, filteredCommits: [], searchQuery: '', darkMode: false }; // DOM elements const elements = { fileInput: document.getElementById('file-input'), dropZone: document.getElementById('drop-zone'), controls: document.getElementById('controls'), stats: document.getElementById('stats'), visualization: document.getElementById('visualization'), details: document.getElementById('details'), clusterSlider: document.getElementById('cluster-slider'), clusterValue: document.getElementById('cluster-value'), scaleWeek: document.getElementById('scale-week'), scaleMonth: document.getElementById('scale-month'), scaleOverall: document.getElementById('scale-overall'), searchInput: document.getElementById('search-input'), totalCommits: document.getElementById('total-commits'), totalEpisodes: document.getElementById('total-episodes'), dateRange: document.getElementById('date-range'), episodesList: document.getElementById('episodes-list'), selectedEpisodeTitle: document.getElementById('selected-episode-title'), commitDetails: document.getElementById('commit-details'), exportJson: document.getElementById('export-json'), exportMd: document.getElementById('export-md'), themeToggle: document.getElementById('theme-toggle'), timeline: document.getElementById('timeline') }; // Initialize the application function init() { setupEventListeners(); checkDarkModePreference(); } // Set up event listeners function setupEventListeners() { // File input handling elements.fileInput.addEventListener('change', handleFileSelect); // Drag and drop handling elements.dropZone.addEventListener('dragover', (e) => { e.preventDefault(); elements.dropZone.classList.add('active'); }); elements.dropZone.addEventListener('dragleave', () => { elements.dropZone.classList.remove('active'); }); elements.dropZone.addEventListener('drop', (e) => { e.preventDefault(); elements.dropZone.classList.remove('active'); if (e.dataTransfer.files.length) { elements.fileInput.files = e.dataTransfer.files; handleFileSelect({ target: elements.fileInput }); } }); // Controls elements.clusterSlider.addEventListener('input', () => { const value = parseInt(elements.clusterSlider.value); elements.clusterValue.textContent = `${value} min`; appState.clusterWindow = value; processCommits(); }); elements.scaleWeek.addEventListener('click', () => { setActiveScale('week'); renderTimeline(); }); elements.scaleMonth.addEventListener('click', () => { setActiveScale('month'); renderTimeline(); }); elements.scaleOverall.addEventListener('click', () => { setActiveScale('overall'); renderTimeline(); }); elements.searchInput.addEventListener('input', (e) => { appState.searchQuery = e.target.value.toLowerCase(); filterCommits(); }); // Export buttons elements.exportJson.addEventListener('click', exportEpisodesJson); elements.exportMd.addEventListener('click', exportEpisodesMarkdown); // Theme toggle elements.themeToggle.addEventListener('click', toggleDarkMode); } // Check user's dark mode preference function checkDarkModePreference() { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; if (prefersDark) { document.documentElement.classList.add('dark'); appState.darkMode = true; elements.themeToggle.innerHTML = feather.icons['sun'].toSvg(); } else { elements.themeToggle.innerHTML = feather.icons['moon'].toSvg(); } } // Toggle dark mode function toggleDarkMode() { appState.darkMode = !appState.darkMode; if (appState.darkMode) { document.documentElement.classList.add('dark'); elements.themeToggle.innerHTML = feather.icons['sun'].toSvg(); } else { document.documentElement.classList.remove('dark'); elements.themeToggle.innerHTML = feather.icons['moon'].toSvg(); } } // Set active scale and update UI function setActiveScale(scale) { appState.currentScale = scale; elements.scaleWeek.classList.remove('active'); elements.scaleMonth.classList.remove('active'); elements.scaleOverall.classList.remove('active'); if (scale === 'week') { elements.scaleWeek.classList.add('active'); } else if (scale === 'month') { elements.scaleMonth.classList.add('active'); } else { elements.scaleOverall.classList.add('active'); } } // Handle file selection function handleFileSelect(event) { const file = event.target.files[0]; if (!file) return; // Show loading state elements.dropZone.innerHTML = `

Processing ${file.name}...

`; const reader = new FileReader(); reader.onload = (e) => { try { // Try to parse as JSON regardless of file extension const data = JSON.parse(e.target.result); parseCommitData(data); } catch (error) { console.error("File parse error:", error); showError(`Failed to parse file (${error.message}). Please ensure it contains valid JSON data.`); resetDropZoneUI(); } }; reader.onerror = () => { showError("Error reading file. Please try again."); resetDropZoneUI(); }; reader.readAsText(file); } function showError(message) { // Create or update error message element let errorEl = document.getElementById('error-message'); if (!errorEl) { errorEl = document.createElement('div'); errorEl.id = 'error-message'; elements.dropZone.parentNode.insertBefore(errorEl, elements.dropZone.nextSibling); } errorEl.className = 'mt-4 p-4 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-100 rounded-md'; errorEl.innerHTML = `
${message}
`; feather.replace(); } function resetDropZoneUI() { elements.dropZone.innerHTML = `

Drag & Drop your commit file here

or

Supports .json or .txt files with commit data

`; feather.replace(); } // Parse commit data from different formats function parseCommitData(data) { let commits = []; if (!data) { showError("File appears to be empty."); return; } if (Array.isArray(data)) { // Check if it's the slim format or GitHub format if (data[0]?.hash || data[0]?.author_name) { // Slim format commits = data.map(item => ({ hash: item.hash, author_name: item.author_name, author_email: item.author_email, date_iso: item.date_iso, message: item.message })); } else if (data[0]?.sha || data[0]?.commit?.author) { // GitHub format commits = data.map(item => ({ hash: item.sha, author_name: item.commit.author.name, author_email: item.commit.author.email, date_iso: item.commit.author.date, message: item.commit.message })); } else { showError("Unsupported JSON format detected."); return; } } else { showError("File should contain an array of commit objects."); return; } // Sort commits by date commits.sort((a, b) => new Date(a.date_iso) - new Date(b.date_iso)); appState.commits = commits; processCommits(); } // Process commits into episodes based on clustering window function processCommits() { if (appState.commits.length === 0) return; const episodes = []; let currentEpisode = null; const windowMs = appState.clusterWindow * 60 * 1000; for (let i = 0; i < appState.commits.length; i++) { const commit = appState.commits[i]; const commitDate = new Date(commit.date_iso); if (!currentEpisode) { // Start new episode currentEpisode = { title: commit.message.split('\n')[0].substring(0, 60), start: commit.date_iso, end: commit.date_iso, commits: [commit] }; } else { const prevCommitDate = new Date(appState.commits[i-1].date_iso); const timeDiff = commitDate - prevCommitDate; if (timeDiff > windowMs) { // Close current episode and start a new one episodes.push(currentEpisode); currentEpisode = { title: commit.message.split('\n')[0].substring(0, 60), start: commit.date_iso, end: commit.date_iso, commits: [commit] }; } else { // Add to current episode currentEpisode.commits.push(commit); currentEpisode.end = commit.date_iso; // Update title if this commit's message is shorter const firstLine = commit.message.split('\n')[0].substring(0, 60); if (firstLine.length < currentEpisode.title.length) { currentEpisode.title = firstLine; } } } } // Add the last episode if (currentEpisode) { episodes.push(currentEpisode); } appState.episodes = episodes; appState.filteredCommits = [...appState.commits]; updateUI(); renderTimeline(); renderEpisodesList(); } // Filter commits based on search query function filterCommits() { if (!appState.searchQuery) { appState.filteredCommits = [...appState.commits]; } else { appState.filteredCommits = appState.commits.filter(commit => commit.message.toLowerCase().includes(appState.searchQuery) || commit.author_name.toLowerCase().includes(appState.searchQuery) ); } // Re-process commits with current cluster window processCommits(); } // Update UI elements with stats function updateUI() { // Show controls and visualization elements.controls.classList.remove('hidden'); elements.stats.classList.remove('hidden'); elements.visualization.classList.remove('hidden'); elements.details.classList.remove('hidden'); // Update stats elements.totalCommits.textContent = appState.commits.length; elements.totalEpisodes.textContent = appState.episodes.length; if (appState.commits.length > 0) { const firstDate = new Date(appState.commits[0].date_iso); const lastDate = new Date(appState.commits[appState.commits.length - 1].date_iso); elements.dateRange.textContent = `${formatDate(firstDate)} → ${formatDate(lastDate)}`; } } // Format date for display function formatDate(date) { return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } // Render D3 timeline function renderTimeline() { if (appState.episodes.length === 0) return; // Clear previous timeline elements.timeline.innerHTML = ''; // Get dimensions const width = elements.timeline.clientWidth; const height = elements.timeline.clientHeight; const margin = { top: 20, right: 30, bottom: 30, left: 40 }; const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; // Parse dates const firstCommit = new Date(appState.commits[0].date_iso); const lastCommit = new Date(appState.commits[appState.commits.length - 1].date_iso); // Create SVG const svg = d3.select(elements.timeline) .append('svg') .attr('width', width) .attr('height', height); const g = svg.append('g') .attr('transform', `translate(${margin.left}, ${margin.top})`); // Create scales based on current time scale let xScale; let xAxis; let timeFormat; if (appState.currentScale === 'week') { // Group by week xScale = d3.scaleTime() .domain([d3.timeWeek.floor(firstCommit), d3.timeWeek.ceil(lastCommit)]) .range([0, innerWidth]); xAxis = d3.axisBottom(xScale) .tickFormat(d3.timeFormat('%V (week %V)')); } else if (appState.currentScale === 'month') { // Group by month xScale = d3.scaleTime() .domain([d3.timeMonth.floor(firstCommit), d3.timeMonth.ceil(lastCommit)]) .range([0, innerWidth]); xAxis = d3.axisBottom(xScale) .tickFormat(d3.timeFormat('%b %Y')); } else { // Overall view xScale = d3.scaleTime() .domain([firstCommit, lastCommit]) .range([0, innerWidth]); xAxis = d3.axisBottom(xScale); } // Create y scale for commit density const maxCommits = d3.max(appState.episodes, d => d.commits.length); const yScale = d3.scaleLinear() .domain([0, maxCommits]) .range([innerHeight, 0]); // Add axes g.append('g') .attr('class', 'axis axis-x') .attr('transform', `translate(0, ${innerHeight})`) .call(xAxis); g.append('g') .attr('class', 'axis axis-y') .call(d3.axisLeft(yScale) .ticks(5) .tickFormat(d => Math.floor(d) === d ? d : '')); // Add grid lines g.append('g') .attr('class', 'grid') .call(d3.axisLeft(yScale) .ticks(5) .tickSize(-innerWidth) .tickFormat('')); // Add episodes as rectangles or density plots if (appState.currentScale === 'overall') { // Show individual episodes as rectangles appState.episodes.forEach(episode => { const start = new Date(episode.start); const end = new Date(episode.end); g.append('rect') .attr('class', 'episode-rect') .attr('x', xScale(start)) .attr('y', yScale(episode.commits.length)) .attr('width', Math.max(1, xScale(end) - xScale(start))) .attr('height', innerHeight - yScale(episode.commits.length)) .attr('data-id', episode.start) .on('mouseover', function() { d3.select(this).attr('fill-opacity', 0.4); showTooltip(episode, d3.event.pageX, d3.event.pageY); }) .on('mouseout', function() { d3.select(this).attr('fill-opacity', 0.2); hideTooltip(); }) .on('click', () => selectEpisode(episode)); }); // Add commit circles appState.commits.forEach(commit => { g.append('circle') .attr('class', 'commit-circle') .attr('cx', xScale(new Date(commit.date_iso))) .attr('cy', innerHeight - 10) .attr('r', 3) .attr('data-hash', commit.hash) .on('mouseover', function() { d3.select(this).attr('r', 5); }) .on('mouseout', function() { d3.select(this).attr('r', 3); }); }); } else { // Show aggregated density for week/month view const timeBins = {}; const timeKeyFn = appState.currentScale === 'week' ? d => d3.timeWeek.floor(new Date(d.date_iso)).getTime() : d => d3.timeMonth.floor(new Date(d.date_iso)).getTime(); appState.commits.forEach(commit => { const key = timeKeyFn(commit); if (!timeBins[key]) { timeBins[key] = 0; } timeBins[key]++; }); const maxBinValue = d3.max(Object.values(timeBins)); const binYScale = d3.scaleLinear() .domain([0, maxBinValue]) .range([innerHeight, 0]); Object.entries(timeBins).forEach(([time, count]) => { const date = new Date(parseInt(time)); g.append('rect') .attr('class', 'episode-rect') .attr('x', xScale(date)) .attr('y', binYScale(count)) .attr('width', appState.currentScale === 'week' ? innerWidth / 52 : innerWidth / 12) .attr('height', innerHeight - binYScale(count)) .attr('fill-opacity', 0.3); }); } } // Show tooltip for episode function showTooltip(episode, x, y) { const tooltip = d3.select('body').append('div') .attr('class', 'tooltip') .style('left', `${x + 10}px`) .style('top', `${y + 10}px`) .html(`
${episode.title}
${formatDate(new Date(episode.start))} → ${formatDate(new Date(episode.end))}
${episode.commits.length} commits
`); tooltip.transition().duration(200).style('opacity', 1); } // Hide tooltip function hideTooltip() { d3.select('.tooltip').remove(); } // Render episodes list function renderEpisodesList() { elements.episodesList.innerHTML = ''; appState.episodes.forEach((episode, index) => { const episodeElement = document.createElement('div'); episodeElement.className = 'episode-item'; if (appState.selectedEpisode && appState.selectedEpisode.start === episode.start) { episodeElement.classList.add('active'); } episodeElement.innerHTML = `
${index + 1}. ${episode.title}
${formatDate(new Date(episode.start))} → ${formatDate(new Date(episode.end))} ${episode.commits.length} commits
`; episodeElement.addEventListener('click', () => selectEpisode(episode)); elements.episodesList.appendChild(episodeElement); }); } // Select an episode to view its commits function selectEpisode(episode) { appState.selectedEpisode = episode; renderSelectedEpisode(); renderEpisodesList(); } // Render selected episode details function renderSelectedEpisode() { if (!appState.selectedEpisode) return; const episode = appState.selectedEpisode; elements.selectedEpisodeTitle.textContent = episode.title; // Render commit table elements.commitDetails.innerHTML = ` ${episode.commits.map(commit => ` `).join('')}
Date Hash Author Message
${formatDateTime(new Date(commit.date_iso))} ${commit.author_name} ${commit.message.split('\n')[0].substring(0, 80)} ${commit.message.split('\n')[0].length > 80 ? '...' : ''}
`; // Add event listeners for copy hash buttons document.querySelectorAll('.copy-hash').forEach(button => { button.addEventListener('click', (e) => { const hash = e.target.getAttribute('data-hash'); navigator.clipboard.writeText(hash).then(() => { const originalText = e.target.textContent; e.target.textContent = 'Copied!'; setTimeout(() => { e.target.textContent = originalText; }, 2000); }); }); }); } // Format date and time for display function formatDateTime(date) { return date.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } // Export episodes as JSON function exportEpisodesJson() { if (!appState.episodes.length) return; const exportData = { repository: "local-export", generated_at: new Date().toISOString(), time_window_minutes: appState.clusterWindow, episodes: appState.episodes }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `git-odyssey-episodes-${new Date().toISOString().split('T')[0]}.md`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // Format duration for display function formatDuration(ms) { const minutes = Math.floor(ms / 60000); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days}d ${hours % 24}h`; if (hours > 0) return `${hours}h ${minutes % 60}m`; return `${minutes}m`; `git-odyssey-episodes-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // Export episodes as Markdown function exportEpisodesMarkdown() { if (!appState.episodes.length) return; let markdown = `# Git Odyssey Export\n\n`; markdown += `- **Total Episodes**: ${appState.episodes.length}\n`; markdown += `- **Total Commits**: ${appState.commits.length}\n`; markdown += `- **Date Range**: ${formatDate(new Date(appState.commits[0].date_iso))} → ${formatDate(new Date(appState.commits[appState.commits.length - 1].date_iso))}\n`; markdown += `- **Cluster Window**: ${appState.clusterWindow} minutes\n\n`; appState.episodes.forEach((episode, index) => { markdown += `## Episode ${index + 1}: ${episode.title}\n\n`; markdown += `- **Date Range**: ${formatDate(new Date(episode.start))} → ${formatDate(new Date(episode.end))}\n`; markdown += `- **Duration**: ${formatDuration(new Date(episode.end) - new Date(episode.start))}\n`; markdown += `- **Commits**: ${episode.commits.length}\n\n`; markdown += `### Commits\n\n`; markdown += `| Date | Hash | Author | Message |\n`; markdown += `|------|------|--------|---------|\n`; episode.commits.forEach(commit => { const shortHash = commit.hash.substring(0, 7); const firstLine = commit.message.split('\n')[0]; markdown += `| ${formatDateTime(new Date(commit.date_iso))} | ${shortHash} | ${commit.author_name} | ${firstLine.substring(0, 60)}${firstLine.length > 60 ? '...' : ''} |\n`; }); markdown += '\n'; }); const blob = new Blob([markdown], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download =