diff --git a/ui/src/components/analytics/project-name-utils.ts b/ui/src/components/analytics/project-name-utils.ts
new file mode 100644
index 00000000..7e475337
--- /dev/null
+++ b/ui/src/components/analytics/project-name-utils.ts
@@ -0,0 +1,30 @@
+/**
+ * Project Name Utility Functions
+ *
+ * Utility functions for extracting meaningful project names from file paths
+ */
+
+/**
+ * Extracts the leaf folder name from a project path
+ *
+ * This function takes a full project path and returns just the leaf folder name,
+ * which represents the actual project name that the user would recognize.
+ *
+ * Examples:
+ * - '/home/user/projects/my-app' → 'my-app'
+ * - '/Users/joe/Developer/share-pi' → 'share-pi'
+ * - '/Users/joe/Developer/ExaDev/.../worktrees/2026-01-08' → '2026-01-08'
+ *
+ * @param path - The full project path
+ * @returns The leaf folder name (project name)
+ */
+export function getProjectDisplayName(path: string): string {
+ if (!path) return '';
+
+ // Remove leading/trailing slashes and split into segments
+ const cleanPath = path.replace(/^\/|\/$/g, '');
+ const segments = cleanPath.split('/').filter(segment => segment.length > 0);
+
+ // Return the last segment (leaf folder name)
+ return segments[segments.length - 1] || '';
+}
\ No newline at end of file
diff --git a/ui/src/components/analytics/session-stats-card.tsx b/ui/src/components/analytics/session-stats-card.tsx
index 89d13dda..2ebb787e 100644
--- a/ui/src/components/analytics/session-stats-card.tsx
+++ b/ui/src/components/analytics/session-stats-card.tsx
@@ -13,6 +13,7 @@ import type { PaginatedSessions } from '@/hooks/use-usage';
import { cn } from '@/lib/utils';
import { formatDistanceToNow } from 'date-fns';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
+import { getProjectDisplayName } from './project-name-utils';
interface SessionStatsCardProps {
data: PaginatedSessions | undefined;
@@ -152,16 +153,6 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
);
}
-function getProjectDisplayName(path: string): string {
- if (!path) return '';
-
- // Remove leading/trailing slashes and split into segments
- const cleanPath = path.replace(/^\/|\/$/g, '');
- const segments = cleanPath.split('/').filter(segment => segment.length > 0);
-
- // Return the last segment (leaf folder name)
- return segments[segments.length - 1] || '';
-}
function formatCompact(num: number): string {
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
diff --git a/ui/tests/unit/components/analytics/project-name-display.test.ts b/ui/tests/unit/components/analytics/project-name-display.test.ts
new file mode 100644
index 00000000..163a0b44
--- /dev/null
+++ b/ui/tests/unit/components/analytics/project-name-display.test.ts
@@ -0,0 +1,127 @@
+/**
+ * Project Name Display Tests
+ * Unit tests for getProjectDisplayName function
+ */
+
+import { describe, it, expect } from 'vitest';
+
+// Import the function from the utility
+import { getProjectDisplayName } from '../../../../../src/components/analytics/project-name-utils';
+
+describe('getProjectDisplayName', () => {
+ describe('Simple project paths', () => {
+ it('returns the leaf folder name for simple paths', () => {
+ expect(getProjectDisplayName('/home/user/projects/my-app')).toBe('my-app');
+ expect(getProjectDisplayName('/Users/joe/Developer/share-pi')).toBe('share-pi');
+ expect(getProjectDisplayName('/var/www/html')).toBe('html');
+ });
+
+ it('handles paths without leading/trailing slashes', () => {
+ expect(getProjectDisplayName('home/user/projects/my-app')).toBe('my-app');
+ expect(getProjectDisplayName('Users/joe/Developer/share-pi')).toBe('share-pi');
+ });
+ });
+
+ describe('Complex project paths', () => {
+ it('returns leaf folder for worktree paths', () => {
+ expect(getProjectDisplayName('/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08')).toBe('2026-01-08');
+ expect(getProjectDisplayName('/home/user/workspaces/repo-name/worktrees/feature-branch')).toBe('feature-branch');
+ expect(getProjectDisplayName('/project/repo/worktrees/v2.0')).toBe('v2.0');
+ });
+
+ it('handles nested paths', () => {
+ expect(getProjectDisplayName('/home/user/projects/web-dashboard/src/components')).toBe('components');
+ expect(getProjectDisplayName('/opt/apps/my-app/lib/utils')).toBe('utils');
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('handles empty string', () => {
+ expect(getProjectDisplayName('')).toBe('');
+ });
+
+ it('handles only slashes', () => {
+ expect(getProjectDisplayName('///')).toBe('');
+ expect(getProjectDisplayName('/')).toBe('');
+ });
+
+ it('handles single segment paths', () => {
+ expect(getProjectDisplayName('my-app')).toBe('my-app');
+ expect(getProjectDisplayName('project')).toBe('project');
+ });
+
+ it('handles paths with trailing slash', () => {
+ expect(getProjectDisplayName('/home/user/projects/my-app/')).toBe('my-app');
+ expect(getProjectDisplayName('/Users/joe/Developer/share-pi/')).toBe('share-pi');
+ });
+
+ it('handles paths with leading slash only', () => {
+ expect(getProjectDisplayName('/my-app')).toBe('my-app');
+ expect(getProjectDisplayName('/project')).toBe('project');
+ });
+ });
+
+ describe('Real-world examples from the bug report', () => {
+ it('displays correct project name for share-pi project', () => {
+ // Before fix: would show "pi"
+ // After fix: should show "share-pi"
+ const path = '/Users/joe/Developer/share-pi';
+ expect(getProjectDisplayName(path)).toBe('share-pi');
+ });
+
+ it('displays correct project name for worktree project', () => {
+ // Before fix: would show "08"
+ // After fix: should show "2026-01-08"
+ const path = '/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect.worktrees/2026-01-08';
+ expect(getProjectDisplayName(path)).toBe('2026-01-08');
+ });
+
+ it('displays correct project name for nested project', () => {
+ // Example: a project in a subdirectory
+ const path = '/home/user/dev/company/projects/web-app';
+ expect(getProjectDisplayName(path)).toBe('web-app');
+ });
+
+ it('displays correct project name for repo with worktrees', () => {
+ // Example: main repository
+ const path = '/Users/joe/Developer/my-repo';
+ expect(getProjectDisplayName(path)).toBe('my-repo');
+ });
+
+ it('displays correct project name for feature branch worktree', () => {
+ // Example: feature branch worktree
+ const path = '/Users/joe/Developer/my-repo/.git/worktrees/feature-x';
+ expect(getProjectDisplayName(path)).toBe('feature-x');
+ });
+ });
+
+ describe('Regression tests', () => {
+ it('does not return empty string for valid paths', () => {
+ const testCases = [
+ '/project',
+ '/home/user/app',
+ '/var/log/nginx',
+ '/tmp/test-file',
+ ];
+
+ testCases.forEach(path => {
+ const result = getProjectDisplayName(path);
+ expect(result).not.toBe('');
+ expect(result).not.toBeUndefined();
+ expect(result).not.toBeNull();
+ });
+ });
+
+ it('handles paths with special characters', () => {
+ expect(getProjectDisplayName('/home/user/my-project_v2')).toBe('my-project_v2');
+ expect(getProjectDisplayName('/home/user/project-with-dashes')).toBe('project-with-dashes');
+ expect(getProjectDisplayName('/home/user/project.with.dots')).toBe('project.with.dots');
+ });
+
+ it('handles numeric paths correctly', () => {
+ expect(getProjectDisplayName('/home/user/project123')).toBe('project123');
+ expect(getProjectDisplayName('/home/user/123project')).toBe('123project');
+ expect(getProjectDisplayName('/home/user/v1.2.3')).toBe('v1.2.3');
+ });
+ });
+});
\ No newline at end of file
diff --git a/ui/tests/unit/components/analytics/session-stats-card.test.tsx b/ui/tests/unit/components/analytics/session-stats-card.test.tsx
new file mode 100644
index 00000000..e4e2508c
--- /dev/null
+++ b/ui/tests/unit/components/analytics/session-stats-card.test.tsx
@@ -0,0 +1,273 @@
+/**
+ * Session Stats Card Tests
+ * Unit tests for SessionStatsCard component with project name formatting
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { SessionStatsCard } from '../../../../../src/components/analytics/session-stats-card';
+import { AllProviders } from '../../../setup/test-utils';
+import type { PaginatedSessions } from '../../../../../src/hooks/use-usage';
+
+// Mock date-fns to return consistent dates
+const mockFormatDistanceToNow = vi.fn();
+vi.mock('date-fns', async () => {
+ const actual = await vi.importActual('date-fns');
+ return {
+ ...actual,
+ formatDistanceToNow: mockFormatDistanceToNow,
+ };
+});
+
+describe('SessionStatsCard', () => {
+ beforeEach(() => {
+ // Reset all mocks
+ vi.clearAllMocks();
+
+ // Mock formatDistanceToNow to return consistent values
+ mockFormatDistanceToNow.mockReturnValue('27 minutes ago');
+ });
+
+ describe('Loading and Empty States', () => {
+ it('renders loading skeleton when isLoading is true', () => {
+ render(, { wrapper: AllProviders });
+
+ // Should have card structure with skeleton loading state
+ expect(screen.getByRole('generic')).toBeInTheDocument();
+ });
+
+ it('shows empty state when no data available', () => {
+ render(, { wrapper: AllProviders });
+
+ expect(screen.getByText('Session Stats')).toBeInTheDocument();
+ expect(screen.getByText('No session data available')).toBeInTheDocument();
+ });
+
+ it('shows empty state when sessions array is empty', () => {
+ const emptyData: PaginatedSessions = {
+ sessions: [],
+ total: 0,
+ page: 1,
+ pageSize: 10,
+ };
+
+ render(, { wrapper: AllProviders });
+
+ expect(screen.getByText('Session Stats')).toBeInTheDocument();
+ expect(screen.getByText('No session data available')).toBeInTheDocument();
+ });
+ });
+
+ describe('Session Stats Display', () => {
+ const createMockSession = (projectPath: string, inputTokens: number, outputTokens: number, cost: number) => ({
+ sessionId: `session-${Math.random()}`,
+ projectPath,
+ inputTokens,
+ outputTokens,
+ cost,
+ lastActivity: new Date().toISOString(),
+ });
+
+ const mockData: PaginatedSessions = {
+ sessions: [
+ createMockSession('/home/user/projects/my-app', 1500, 2500, 0.08),
+ createMockSession('/home/user/workspaces/repo-name/worktrees/feature-branch', 2000, 3000, 0.12),
+ createMockSession('/Users/joe/Developer/share-pi', 1000, 2000, 0.05),
+ ],
+ total: 3,
+ page: 1,
+ pageSize: 10,
+ };
+
+ beforeEach(() => {
+ mockData.sessions = [
+ createMockSession('/home/user/projects/my-app', 1500, 2500, 0.08),
+ createMockSession('/home/user/workspaces/repo-name/worktrees/feature-branch', 2000, 3000, 0.12),
+ createMockSession('/Users/joe/Developer/share-pi', 1000, 2000, 0.05),
+ ];
+ });
+
+ it('displays session stats header', () => {
+ render(, { wrapper: AllProviders });
+
+ expect(screen.getByText('Session Stats')).toBeInTheDocument();
+ });
+
+ it('shows total sessions count', () => {
+ render(, { wrapper: AllProviders });
+
+ expect(screen.getByText('3')).toBeInTheDocument();
+ expect(screen.getByText('Total Sessions')).toBeInTheDocument();
+ });
+
+ it('calculates and displays average cost per session', () => {
+ render(, { wrapper: AllProviders });
+
+ // Average cost: (0.08 + 0.12 + 0.05) / 3 = 0.0833 → $0.08
+ expect(screen.getByText('$0.08')).toBeInTheDocument();
+ expect(screen.getByText('Avg Cost/Session')).toBeInTheDocument();
+ });
+
+ it('shows recent activity section', () => {
+ render(, { wrapper: AllProviders });
+
+ expect(screen.getByText('Recent Activity')).toBeInTheDocument();
+ });
+ });
+
+ describe('Project Name Formatting', () => {
+ it('displays correct project name for simple path', () => {
+ const mockData: PaginatedSessions = {
+ sessions: [
+ {
+ sessionId: '1',
+ projectPath: '/home/user/projects/my-app',
+ inputTokens: 1000,
+ outputTokens: 2000,
+ cost: 0.05,
+ lastActivity: new Date().toISOString(),
+ }
+ ],
+ total: 1,
+ page: 1,
+ pageSize: 10,
+ };
+
+ render(, { wrapper: AllProviders });
+
+ // Should show "my-app" instead of just "app"
+ expect(screen.getByTitle('/home/user/projects/my-app')).toHaveTextContent('my-app');
+ });
+
+ it('displays correct project name for worktree path', () => {
+ const mockData: PaginatedSessions = {
+ sessions: [
+ {
+ sessionId: '1',
+ projectPath: '/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08',
+ inputTokens: 1000,
+ outputTokens: 2000,
+ cost: 0.05,
+ lastActivity: new Date().toISOString(),
+ }
+ ],
+ total: 1,
+ page: 1,
+ pageSize: 10,
+ };
+
+ render(, { wrapper: AllProviders });
+
+ // Should show "2026-01-08" instead of just "08"
+ expect(screen.getByTitle('/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08'))
+ .toHaveTextContent('2026-01-08');
+ });
+
+ it('displays correct project name for shared project', () => {
+ const mockData: PaginatedSessions = {
+ sessions: [
+ {
+ sessionId: '1',
+ projectPath: '/Users/joe/Developer/share-pi',
+ inputTokens: 1000,
+ outputTokens: 2000,
+ cost: 0.05,
+ lastActivity: new Date().toISOString(),
+ }
+ ],
+ total: 1,
+ page: 1,
+ pageSize: 10,
+ };
+
+ render(, { wrapper: AllProviders });
+
+ // Should show "share-pi" instead of just "pi"
+ expect(screen.getByTitle('/Users/joe/Developer/share-pi')).toHaveTextContent('share-pi');
+ });
+
+ it('handles empty project path gracefully', () => {
+ const mockData: PaginatedSessions = {
+ sessions: [
+ {
+ sessionId: '1',
+ projectPath: '',
+ inputTokens: 1000,
+ outputTokens: 2000,
+ cost: 0.05,
+ lastActivity: new Date().toISOString(),
+ }
+ ],
+ total: 1,
+ page: 1,
+ pageSize: 10,
+ };
+
+ render(, { wrapper: AllProviders });
+
+ // Should not crash and show empty string
+ expect(screen.getByTitle('')).toHaveTextContent('');
+ });
+
+ it('handles project path with only slashes', () => {
+ const mockData: PaginatedSessions = {
+ sessions: [
+ {
+ sessionId: '1',
+ projectPath: '///',
+ inputTokens: 1000,
+ outputTokens: 2000,
+ cost: 0.05,
+ lastActivity: new Date().toISOString(),
+ }
+ ],
+ total: 1,
+ page: 1,
+ pageSize: 10,
+ };
+
+ render(, { wrapper: AllProviders });
+
+ // Should handle gracefully and show empty string
+ expect(screen.getByTitle('///')).toHaveTextContent('');
+ });
+ });
+
+ describe('Token Count Display', () => {
+ it('displays token counts in compact format', () => {
+ const mockData: PaginatedSessions = {
+ sessions: [
+ {
+ sessionId: '1',
+ projectPath: '/project/test',
+ inputTokens: 1500000, // 1.5M
+ outputTokens: 500000, // 500K
+ cost: 0.10,
+ lastActivity: new Date().toISOString(),
+ }
+ ],
+ total: 1,
+ page: 1,
+ pageSize: 10,
+ };
+
+ render(, { wrapper: AllProviders });
+
+ expect(screen.getByText('2.0M toks')).toBeInTheDocument();
+ });
+ });
+
+ describe('Privacy Mode', () => {
+ it('blurs cost information when privacy mode is enabled', () => {
+ // This would require mocking the privacy context
+ // For now, just ensure the component renders with privacy mode
+ const { container } = render(
+ ,
+ { wrapper: AllProviders }
+ );
+
+ // Component should render without errors
+ expect(container).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file