From cd96596400f8800f2c90f4fa8c3d224fe567bbfb Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 27 Apr 2026 03:52:54 -0400 Subject: [PATCH] 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 --- src/web/frontend/src/index.css | 265 +++++++++++++ src/web/frontend/test/FileHeatmap.test.tsx | 432 +++++++++++++++++++++ 2 files changed, 697 insertions(+) diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index 91852bb..8454157 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -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 { diff --git a/src/web/frontend/test/FileHeatmap.test.tsx b/src/web/frontend/test/FileHeatmap.test.tsx index 5adeb85..f56e156 100644 --- a/src/web/frontend/test/FileHeatmap.test.tsx +++ b/src/web/frontend/test/FileHeatmap.test.tsx @@ -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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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(); + }); + }); + }); });