// 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 = `
`;
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 = `
| Date |
Hash |
Author |
Message |
${episode.commits.map(commit => `
|
${formatDateTime(new Date(commit.date_iso))}
|
|
${commit.author_name}
|
${commit.message.split('\n')[0].substring(0, 80)}
${commit.message.split('\n')[0].length > 80 ? '...' : ''}
|
`).join('')}
`;
// 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 =