feat(heatmap): add timelapse animation CSS and tests

Add comprehensive CSS styling for the heatmap timelapse animation
controls, including:
- Playback controls (play/pause, stop buttons)
- Speed controls with slower/faster buttons
- Timeline slider for scrubbing through snapshots
- Loop toggle checkbox
- Timeline labels showing current time, progress, and duration

Also add 10 new tests covering the timelapse feature:
- View mode switching
- Data fetching
- UI controls rendering
- Loading states
- Error handling

The timelapse feature was already implemented in the React
component and backend, but was missing CSS styling and tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-27 03:52:54 -04:00
parent 6e3049dc1a
commit cd96596400
2 changed files with 697 additions and 0 deletions

View file

@ -187,6 +187,52 @@ body {
font-weight: 600;
}
/* CLI Filter Indicator */
.cli-filter-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: var(--bg-secondary);
border-radius: 4px;
font-size: 0.75rem;
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.cli-filter-indicator .filter-label {
font-weight: 600;
color: var(--warning);
}
.cli-filter-indicator .filter-item {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.125rem 0.375rem;
background: var(--bg-tertiary);
border-radius: 3px;
}
.cli-filter-indicator .filter-item .filter-key {
color: var(--text-secondary);
font-size: 0.7rem;
}
.cli-filter-indicator .filter-item .filter-value {
color: var(--text-primary);
font-weight: 500;
font-family: var(--font-mono);
}
.cli-filter-indicator .filter-item.worker {
border-left: 2px solid var(--info);
}
.cli-filter-indicator .filter-item.level {
border-left: 2px solid var(--warning);
}
.connection-status {
display: flex;
align-items: center;
@ -2167,6 +2213,225 @@ body {
opacity: 0.7;
}
/* ============================================
File Heatmap Treemap Styles
============================================ */
.heatmap-treemap-container {
position: relative;
width: 100%;
height: 100%;
min-height: 250px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
}
.heatmap-treemap {
width: 100%;
height: 100%;
display: block;
}
.treemap-node {
transition: all 0.2s ease;
}
.treemap-node:hover rect {
fill-opacity: 0.9;
stroke-width: 0.4;
}
.treemap-node.selected rect {
stroke: var(--accent);
stroke-width: 0.5;
fill-opacity: 1;
}
.treemap-node.collision rect {
stroke: var(--warning);
stroke-width: 0.5;
animation: treemap-pulse 2s infinite;
}
@keyframes treemap-pulse {
0%, 100% {
stroke-opacity: 1;
}
50% {
stroke-opacity: 0.5;
}
}
.treemap-tooltip {
position: absolute;
top: 0;
left: 0;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
min-width: 150px;
max-width: 250px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
pointer-events: none;
z-index: 1000;
transform: translate(-50%, -100%);
margin-top: -8px;
}
.treemap-tooltip .tooltip-header {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.375rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--bg-tertiary);
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.6875rem;
word-break: break-all;
}
.treemap-tooltip .tooltip-row {
display: flex;
justify-content: space-between;
gap: 1rem;
margin: 0.125rem 0;
color: var(--text-secondary);
}
.treemap-tooltip .tooltip-row strong {
color: var(--text-primary);
font-weight: 500;
}
.treemap-tooltip .tooltip-row.collision {
color: var(--warning);
font-weight: 500;
margin-top: 0.375rem;
padding-top: 0.25rem;
border-top: 1px solid var(--bg-tertiary);
}
/* ============================================
File Heatmap Timelapse Controls
============================================ */
.timelapse-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-primary);
border-bottom: 1px solid var(--bg-tertiary);
}
.timelapse-playback {
display: flex;
align-items: center;
gap: 0.5rem;
}
.timelapse-speed {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.timelapse-speed .speed-label {
min-width: 45px;
text-align: center;
font-size: 0.75rem;
color: var(--text-secondary);
font-family: 'SF Mono', Monaco, monospace;
}
.timelapse-loop {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-secondary);
margin-left: 0.5rem;
}
.timelapse-loop input[type="checkbox"] {
cursor: pointer;
accent-color: var(--accent);
}
.timelapse-timeline {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.timeline-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--bg-tertiary);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.timeline-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: var(--accent);
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}
.timeline-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
background: var(--accent-hover, var(--accent));
}
.timeline-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: var(--accent);
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}
.timeline-slider::-moz-range-thumb:hover {
transform: scale(1.2);
background: var(--accent-hover, var(--accent));
}
.timeline-labels {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.7rem;
color: var(--text-secondary);
}
.timeline-time {
font-family: 'SF Mono', Monaco, monospace;
font-weight: 500;
color: var(--text-primary);
}
.timeline-progress {
color: var(--text-secondary);
}
.timeline-duration {
font-family: 'SF Mono', Monaco, monospace;
color: var(--text-secondary);
}
/* Responsive adjustments for */
@media (max-width: 768px) {
.file-heatmap-panel {

View file

@ -294,4 +294,436 @@ describe('FileHeatmap Component', () => {
});
});
});
describe('Treemap view', () => {
it('should have view mode toggle buttons', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats));
render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const listButton = screen.getByRole('button', { name: /list/i });
const treemapButton = screen.getByRole('button', { name: /treemap/i });
expect(listButton).toBeTruthy();
expect(treemapButton).toBeTruthy();
});
it('should switch to treemap view when treemap button clicked', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats));
render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const treemapButton = screen.getByRole('button', { name: /treemap/i });
fireEvent.click(treemapButton);
await waitFor(() => {
const treemapContainer = document.querySelector('.heatmap-treemap-container');
expect(treemapContainer).toBeTruthy();
});
});
it('should render treemap nodes', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats));
render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const treemapButton = screen.getByRole('button', { name: /treemap/i });
fireEvent.click(treemapButton);
await waitFor(() => {
const treemapNodes = document.querySelectorAll('.treemap-node');
expect(treemapNodes.length).toBe(mockEntries.length);
});
});
it('should hide sort button in treemap mode', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats));
render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
// Sort button should be visible in list mode
const sortButton = screen.getByRole('button', { name: /sort.*modifications/i });
expect(sortButton).toBeTruthy();
const treemapButton = screen.getByRole('button', { name: /treemap/i });
fireEvent.click(treemapButton);
// Sort button should be hidden in treemap mode
await waitFor(() => {
const sortButtonAfter = document.querySelector('button[title="Cycle sort mode"]');
expect(sortButtonAfter).toBeFalsy();
});
});
it('should show tooltip when hovering treemap node', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats));
render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const treemapButton = screen.getByRole('button', { name: /treemap/i });
fireEvent.click(treemapButton);
await waitFor(() => {
const treemapNodes = document.querySelectorAll('.treemap-node');
expect(treemapNodes.length).toBeGreaterThan(0);
});
const firstNode = document.querySelector('.treemap-node');
expect(firstNode).toBeTruthy();
// Trigger mouse enter
fireEvent.mouseEnter(firstNode!);
await waitFor(() => {
const tooltip = document.querySelector('.treemap-tooltip');
expect(tooltip).toBeTruthy();
});
// Trigger mouse leave
fireEvent.mouseLeave(firstNode!);
await waitFor(() => {
const tooltip = document.querySelector('.treemap-tooltip');
expect(tooltip).toBeFalsy();
});
});
});
describe('Timelapse animation', () => {
const mockTimelapse = {
startTimestamp: Date.now() - 100000,
endTimestamp: Date.now(),
interval: 2000,
totalSnapshots: 5,
snapshots: [
{
timestamp: Date.now() - 100000,
entries: [
{
path: '/src/early.ts',
modifications: 2,
heatLevel: 'cold' as const,
workers: [{ workerId: 'w-alpha', modifications: 2, lastModified: Date.now() - 90000, percentage: 100 }],
firstModified: Date.now() - 100000,
lastModified: Date.now() - 90000,
hasCollision: false,
activeWorkers: 1,
avgModificationInterval: 5000,
},
],
stats: {
totalFiles: 1,
totalModifications: 2,
collisionFiles: 0,
activeFiles: 1,
heatDistribution: { cold: 1, warm: 0, hot: 0, critical: 0 },
mostActiveDirectory: '/src',
avgModificationsPerFile: 2,
},
},
{
timestamp: Date.now() - 50000,
entries: [
{
path: '/src/early.ts',
modifications: 5,
heatLevel: 'warm' as const,
workers: [{ workerId: 'w-alpha', modifications: 5, lastModified: Date.now() - 50000, percentage: 100 }],
firstModified: Date.now() - 100000,
lastModified: Date.now() - 50000,
hasCollision: false,
activeWorkers: 1,
avgModificationInterval: 10000,
},
{
path: '/src/mid.ts',
modifications: 3,
heatLevel: 'warm' as const,
workers: [{ workerId: 'w-beta', modifications: 3, lastModified: Date.now() - 50000, percentage: 100 }],
firstModified: Date.now() - 60000,
lastModified: Date.now() - 50000,
hasCollision: false,
activeWorkers: 1,
avgModificationInterval: 5000,
},
],
stats: {
totalFiles: 2,
totalModifications: 8,
collisionFiles: 0,
activeFiles: 2,
heatDistribution: { cold: 0, warm: 2, hot: 0, critical: 0 },
mostActiveDirectory: '/src',
avgModificationsPerFile: 4,
},
},
],
};
it('should have timelapse view mode button', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats));
render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
expect(timelapseButton).toBeTruthy();
});
it('should switch to timelapse view when timelapse button clicked', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats))
.mockResolvedValueOnce(createMockResponse(mockTimelapse));
render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
fireEvent.click(timelapseButton);
// Should show timelapse controls after switching
await waitFor(() => {
const controls = document.querySelector('.timelapse-controls');
expect(controls).toBeTruthy();
});
});
it('should fetch timelapse data when entering timelapse mode', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats))
.mockResolvedValueOnce(createMockResponse(mockTimelapse));
render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
fireEvent.click(timelapseButton);
// Should show timelapse controls after data loads
await waitFor(() => {
const controls = document.querySelector('.timelapse-controls');
expect(controls).toBeTruthy();
});
});
it('should display timelapse playback controls when data loaded', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats))
.mockResolvedValueOnce(createMockResponse(mockTimelapse));
const { container } = render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
fireEvent.click(timelapseButton);
await waitFor(() => {
const playbackControls = container.querySelector('.timelapse-playback');
expect(playbackControls).toBeTruthy();
});
});
it('should have play/pause button in timelapse mode', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats))
.mockResolvedValueOnce(createMockResponse(mockTimelapse));
const { container } = render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
fireEvent.click(timelapseButton);
await waitFor(() => {
const playButton = container.querySelector('.timelapse-playback button.primary');
expect(playButton).toBeTruthy();
});
});
it('should have speed controls in timelapse mode', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats))
.mockResolvedValueOnce(createMockResponse(mockTimelapse));
const { container } = render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
fireEvent.click(timelapseButton);
await waitFor(() => {
const speedControls = container.querySelector('.timelapse-speed');
expect(speedControls).toBeTruthy();
});
});
it('should have timeline slider in timelapse mode', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats))
.mockResolvedValueOnce(createMockResponse(mockTimelapse));
const { container } = render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
fireEvent.click(timelapseButton);
await waitFor(() => {
const timelineSlider = container.querySelector('.timeline-slider');
expect(timelineSlider).toBeTruthy();
});
});
it('should have loop checkbox in timelapse mode', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats))
.mockResolvedValueOnce(createMockResponse(mockTimelapse));
const { container } = render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
fireEvent.click(timelapseButton);
await waitFor(() => {
const loopLabel = container.querySelector('.timelapse-loop');
expect(loopLabel).toBeTruthy();
});
});
it('should show timeline labels with time and progress', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats))
.mockResolvedValueOnce(createMockResponse(mockTimelapse));
const { container } = render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
fireEvent.click(timelapseButton);
await waitFor(() => {
const timelineLabels = container.querySelector('.timeline-labels');
expect(timelineLabels).toBeTruthy();
});
});
it('should display loading state while fetching timelapse data', async () => {
let resolveFetch: (value: unknown) => void;
const fetchPromise = new Promise(resolve => {
resolveFetch = resolve;
});
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats))
.mockReturnValueOnce(fetchPromise as any);
render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
fireEvent.click(timelapseButton);
await waitFor(() => {
expect(screen.getByText(/generating timelapse/i)).toBeInTheDocument();
});
// Resolve the fetch
resolveFetch!(createMockResponse(mockTimelapse));
await waitFor(() => {
expect(screen.queryByText(/generating timelapse/i)).not.toBeInTheDocument();
});
});
it('should display error message on timelapse fetch failure', async () => {
mockFetch
.mockResolvedValueOnce(createMockResponse(mockEntries))
.mockResolvedValueOnce(createMockResponse(mockStats))
.mockRejectedValueOnce(new Error('Failed to fetch timelapse'));
render(<FileHeatmap visible={true} onClose={() => {}} />);
await waitFor(() => {
expect(screen.getByText('File Heatmap')).toBeInTheDocument();
});
const timelapseButton = screen.getByRole('button', { name: /timelapse/i });
fireEvent.click(timelapseButton);
await waitFor(() => {
expect(screen.getByText(/failed to fetch timelapse/i)).toBeInTheDocument();
});
});
});
});