Spaces:
Running
Running
| // 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 = ` | |
| <div class="flex flex-col items-center justify-center space-y-4"> | |
| <div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-600"></div> | |
| <p class="text-gray-600 dark:text-gray-400">Processing ${file.name}...</p> | |
| </div> | |
| `; | |
| 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 = ` | |
| <div class="flex items-center"> | |
| <i data-feather="alert-circle" class="w-5 h-5 mr-2"></i> | |
| <span>${message}</span> | |
| </div> | |
| `; | |
| feather.replace(); | |
| } | |
| function resetDropZoneUI() { | |
| elements.dropZone.innerHTML = ` | |
| <div class="flex flex-col items-center justify-center space-y-4"> | |
| <i data-feather="upload" class="w-12 h-12 text-indigo-600 dark:text-indigo-400"></i> | |
| <h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Drag & Drop your commit file here</h2> | |
| <p class="text-gray-600 dark:text-gray-400">or</p> | |
| <label class="cursor-pointer bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md transition-colors"> | |
| <input type="file" id="file-input" class="hidden" accept=".json,.txt"> | |
| <span>Select File</span> | |
| </label> | |
| <p class="text-sm text-gray-500 dark:text-gray-400">Supports .json or .txt files with commit data</p> | |
| </div> | |
| `; | |
| 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(` | |
| <div class="font-semibold text-indigo-600 dark:text-indigo-400">${episode.title}</div> | |
| <div>${formatDate(new Date(episode.start))} → ${formatDate(new Date(episode.end))}</div> | |
| <div>${episode.commits.length} commits</div> | |
| `); | |
| 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 = ` | |
| <div class="font-medium text-indigo-600 dark:text-indigo-400">${index + 1}. ${episode.title}</div> | |
| <div class="text-sm text-gray-500 dark:text-gray-400"> | |
| ${formatDate(new Date(episode.start))} → ${formatDate(new Date(episode.end))} | |
| <span class="float-right">${episode.commits.length} commits</span> | |
| </div> | |
| `; | |
| 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 = ` | |
| <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> | |
| <thead class="bg-gray-50 dark:bg-gray-800"> | |
| <tr> | |
| <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th> | |
| <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Hash</th> | |
| <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Author</th> | |
| <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th> | |
| </tr> | |
| </thead> | |
| <tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> | |
| ${episode.commits.map(commit => ` | |
| <tr class="commit-row"> | |
| <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"> | |
| ${formatDateTime(new Date(commit.date_iso))} | |
| </td> | |
| <td class="px-4 py-3 whitespace-nowrap text-sm font-mono text-indigo-600 dark:text-indigo-400"> | |
| <button class="copy-hash" data-hash="${commit.hash}" title="Copy hash"> | |
| ${commit.hash.substring(0, 7)} | |
| </button> | |
| </td> | |
| <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"> | |
| ${commit.author_name} | |
| </td> | |
| <td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-200"> | |
| ${commit.message.split('\n')[0].substring(0, 80)} | |
| ${commit.message.split('\n')[0].length > 80 ? '...' : ''} | |
| </td> | |
| </tr> | |
| `).join('')} | |
| </tbody> | |
| </table> | |
| `; | |
| // 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 = |