diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 1a92a3f1..11d8929f 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -8,6 +8,7 @@ import SessionsPage from '@/pages/SessionsPage'; import SessionDetailPage from '@/pages/SessionDetailPage'; import InsightsPage from '@/pages/InsightsPage'; import AnalyticsPage from '@/pages/AnalyticsPage'; +import ProjectsPage from '@/pages/ProjectsPage'; import SettingsPage from '@/pages/SettingsPage'; import ExportPage from '@/pages/ExportPage'; import JournalPage from '@/pages/JournalPage'; @@ -18,6 +19,7 @@ const ROUTE_TITLES: Record = { '/sessions': 'Sessions', '/insights': 'Insights', '/analytics': 'Analytics', + '/projects': 'Projects', '/patterns': 'Patterns', '/export': 'Export', '/journal': 'Journal', @@ -72,6 +74,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/dashboard/src/components/dashboard/DashboardActivityChart.tsx b/dashboard/src/components/dashboard/DashboardActivityChart.tsx index 43e59f34..bed691c8 100644 --- a/dashboard/src/components/dashboard/DashboardActivityChart.tsx +++ b/dashboard/src/components/dashboard/DashboardActivityChart.tsx @@ -1,5 +1,7 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { + BarChart, + Bar, AreaChart, Area, XAxis, @@ -10,16 +12,35 @@ import { } from 'recharts'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { useThemeColors } from '@/lib/hooks/useThemeColors'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { CHART_COLORS } from '@/lib/constants/colors'; -import type { DailyStats } from '@/lib/types'; +import { formatTokenCount } from '@/lib/utils'; +import { formatCost } from '@/lib/cost-utils'; +import type { ActivityDay } from '@/lib/types'; type DashboardRange = '7d' | '30d' | '90d' | 'all'; +type MetricKey = + | 'composite' + | 'cognitive_load' + | 'cost' + | 'tokens' + | 'tool_calls' + | 'messages' + | 'projects' + | 'sessions'; +type CompositeMode = 'weighted' | 'stacked'; interface DashboardActivityChartProps { - data: DailyStats[]; + days: ActivityDay[]; range: DashboardRange; onRangeChange: (range: DashboardRange) => void; + isLoading?: boolean; } const rangeOptions: { value: DashboardRange; label: string }[] = [ @@ -29,105 +50,325 @@ const rangeOptions: { value: DashboardRange; label: string }[] = [ { value: 'all', label: 'All' }, ]; -export function DashboardActivityChart({ data, range, onRangeChange }: DashboardActivityChartProps) { - const { tooltipBg, tooltipBorder } = useThemeColors(); +// Metric config. Composite is a weighted blend of the keys in COMPOSITE_KEYS; +// `sessions` is deliberately excluded from the composite (a count, not effort). +// Weights match the reference design: cost .30, tokens .25, tool_calls .20, +// messages .15, projects .10 (sum = 1.0). +const M = CHART_COLORS.activityMetrics; +const METRICS: Record string }> = { + composite: { label: 'Composite score', color: M.composite, fmt: (n) => n.toFixed(1) }, + cognitive_load: { label: 'Cognitive load', color: M.cognitive_load, fmt: (n) => Math.round(n).toLocaleString() }, + cost: { label: 'Cost (USD)', color: M.cost, weight: 0.30, fmt: formatCost }, + tokens: { label: 'Tokens', color: M.tokens, weight: 0.25, fmt: formatTokenCount }, + tool_calls: { label: 'Tool calls', color: M.tool_calls, weight: 0.20, fmt: (n) => Math.round(n).toLocaleString() }, + messages: { label: 'Messages', color: M.messages, weight: 0.15, fmt: (n) => Math.round(n).toLocaleString() }, + projects: { label: 'Projects', color: M.projects, weight: 0.10, fmt: (n) => Math.round(n).toLocaleString() }, + sessions: { label: 'Sessions', color: M.sessions, fmt: (n) => Math.round(n).toLocaleString() }, +}; +const METRIC_ORDER: MetricKey[] = ['composite', 'cognitive_load', 'cost', 'tokens', 'tool_calls', 'messages', 'projects', 'sessions']; +const COMPOSITE_KEYS = ['cost', 'tokens', 'tool_calls', 'messages', 'projects'] as const; +const TOTAL_W = COMPOSITE_KEYS.reduce((s, k) => s + (METRICS[k].weight ?? 0), 0); + +type CompositeKey = (typeof COMPOSITE_KEYS)[number]; +type NormFactors = Record; + +function computeNormFactors(days: ActivityDay[]): NormFactors { + const max = {} as NormFactors; + for (const k of COMPOSITE_KEYS) { + max[k] = days.reduce((m, d) => Math.max(m, d[k] || 0), 0) || 1; + } + return max; +} + +// Weighted contribution of metric k for day d, in score points (0..100). The +// contributions sum to the composite score. Uses a FIXED reference (max across +// all-time days) so a day's score is stable regardless of the selected range. +function contribution(d: ActivityDay, k: CompositeKey, norm: NormFactors): number { + return 100 * ((METRICS[k].weight ?? 0) / TOTAL_W) * ((d[k] || 0) / norm[k]); +} +function composite(d: ActivityDay, norm: NormFactors): number { + return COMPOSITE_KEYS.reduce((s, k) => s + contribution(d, k, norm), 0); +} + +function shortNum(n: number): string { + if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`; + if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`; + if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`; + return String(Math.round(n)); +} +function fmtDate(iso: string): string { + return new Date(`${iso}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +interface ChartRow extends ActivityDay { + label: string; + score: number; + contrib_cost: number; + contrib_tokens: number; + contrib_tool_calls: number; + contrib_messages: number; + contrib_projects: number; +} + +// Breakdown tooltip: headline score plus the raw metrics that feed it. +interface ActivityTooltipProps { + active?: boolean; + payload?: Array<{ payload?: ChartRow }>; +} +function ActivityTooltip({ active, payload }: ActivityTooltipProps) { + if (!active || !payload?.length) return null; + const row = payload[0]?.payload; + if (!row) return null; + + const metrics: { label: string; value: string; color?: string }[] = [ + { label: 'Cognitive load', value: Math.round(row.cognitive_load).toLocaleString(), color: M.cognitive_load }, + { label: 'Cost', value: formatCost(row.cost), color: M.cost }, + { label: 'Tokens', value: formatTokenCount(row.tokens), color: M.tokens }, + { label: 'Tool calls', value: row.tool_calls.toLocaleString(), color: M.tool_calls }, + { label: 'Messages', value: row.messages.toLocaleString(), color: M.messages }, + { label: 'Projects', value: row.projects.toLocaleString(), color: M.projects }, + { label: 'Sessions', value: row.sessions.toLocaleString(), color: M.sessions }, + ]; + + return ( +
+
+ {row.label} + + {row.score.toFixed(1)} + /100 + +
+
+ {metrics.map((m) => ( +
+ + + {m.label} + + {m.value} +
+ ))} +
+ {row.peak_sessions > 0 && ( +
+ Peak multitasking: {row.peak_sessions} session + {row.peak_sessions !== 1 ? 's' : ''} across{' '} + {row.peak_projects} project + {row.peak_projects !== 1 ? 's' : ''} +
+ )} +
+ ); +} + +export function DashboardActivityChart({ days, range, onRangeChange, isLoading }: DashboardActivityChartProps) { + const [metric, setMetric] = useState('composite'); + const [compositeMode, setCompositeMode] = useState('weighted'); + + const isComposite = metric === 'composite'; + const stacked = isComposite && compositeMode === 'stacked'; + + // Fixed all-time reference — computed across ALL days, never the window. + const norm = useMemo(() => computeNormFactors(days), [days]); - const chartData = useMemo( + // Window the series client-side; composite scores stay on the fixed reference. + const windowed = useMemo(() => { + if (range === 'all') return days; + const n = range === '7d' ? 7 : range === '30d' ? 30 : 90; + return days.slice(-n); + }, [days, range]); + + const chartData = useMemo( () => - data.map((d) => ({ + windowed.map((d) => ({ ...d, - date: new Date(d.date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }), - // Normalize field names to match recharts dataKey - sessionCount: d.session_count, - insightCount: d.insight_count, + label: fmtDate(d.date), + score: composite(d, norm), + contrib_cost: contribution(d, 'cost', norm), + contrib_tokens: contribution(d, 'tokens', norm), + contrib_tool_calls: contribution(d, 'tool_calls', norm), + contrib_messages: contribution(d, 'messages', norm), + contrib_projects: contribution(d, 'projects', norm), })), - [data] + [windowed, norm] ); + // Y-axis zooms to the min/max actually shown. Stacked must start at 0 (layers + // sum to the whole); a single series zooms to [min, max] so the quietest day + // sits near the floor and the busiest fills the frame. + const [yMin, yMax] = useMemo<[number, number]>(() => { + const vals = chartData.map((d) => (isComposite ? d.score : (d[metric] ?? 0))); + if (vals.length === 0) return [0, 1]; + const maxShown = Math.max(...vals, 0); + const minShown = Math.min(...vals); + const baseline = stacked || minShown === maxShown ? 0 : minShown; + let yTop = maxShown + (maxShown - baseline) * 0.08; + if (yTop <= baseline) yTop = baseline + 1; + return [baseline, yTop]; + }, [chartData, isComposite, metric, stacked]); + + const accent = METRICS[metric].color; + const span = windowed.length; + const dateSpan = span > 0 ? `${windowed[0].date} → ${windowed[span - 1].date}` : ''; + + const tickFormatter = (v: number) => (isComposite ? v.toFixed(0) : shortNum(v)); + const xInterval = range === '7d' ? 0 : range === '30d' ? 4 : range === '90d' ? 13 : Math.ceil(span / 8); + return ( - - Activity -
- {rangeOptions.map(({ value, label }) => ( - - ))} + +
+
+ Activity +

+ {isComposite + ? 'Composite = weighted blend of cost, tokens, tool calls, messages & projects · fixed all-time scale' + : metric === 'cognitive_load' + ? 'Context-switching load — concurrent sessions × projects², integrated over active time' + : `Daily ${METRICS[metric].label.toLowerCase()}`} + {dateSpan && ` · ${dateSpan}`} +

+
+
+ {rangeOptions.map(({ value, label }) => ( + + ))} +
+
+
+ + {isComposite && ( +
+ {(['weighted', 'stacked'] as CompositeMode[]).map((mode) => ( + + ))} +
+ )}
- {chartData.length > 0 ? ( - - - - - - - - - - - - - - - - - - - - - ) : ( + {isLoading || chartData.length === 0 ? (
-

No activity data yet

+

+ {isLoading ? 'Loading activity…' : 'No activity data yet'} +

+ ) : ( + + {stacked ? ( + // Stacked composite → bar chart (layers sum to the score) + 60 ? 1 : span > 30 ? '10%' : '20%'}> + + + + } cursor={{ fill: 'currentColor', opacity: 0.05 }} /> + {COMPOSITE_KEYS.map((k) => ( + + ))} + + ) : ( + // Weighted composite & single metrics → area/line chart + + + + + + + + + + + } cursor={{ stroke: accent, strokeOpacity: 0.3 }} /> + + + )} + )}
+ {/* Legend — stacked composite shows the weighted layers */} + {stacked && ( +
+ {COMPOSITE_KEYS.map((k) => ( + + + {METRICS[k].label} ({Math.round((METRICS[k].weight ?? 0) * 100)}%) + + ))} +
+ )}
); diff --git a/dashboard/src/components/layout/Header.tsx b/dashboard/src/components/layout/Header.tsx index 7235674c..4d93a030 100644 --- a/dashboard/src/components/layout/Header.tsx +++ b/dashboard/src/components/layout/Header.tsx @@ -11,6 +11,7 @@ import { Github, Sparkles, Search, + FolderKanban, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -30,6 +31,7 @@ const NAV_ITEMS = [ { href: '/sessions', label: 'Sessions', icon: MessageSquare, exact: false }, { href: '/insights', label: 'Insights', icon: Lightbulb, exact: false }, { href: '/analytics', label: 'Analytics', icon: BarChart3, exact: false }, + { href: '/projects', label: 'Projects', icon: FolderKanban, exact: false }, { href: '/patterns', label: 'Patterns', icon: Sparkles, exact: false }, { href: '/export', label: 'Export', icon: Download, exact: false }, { href: '/settings', label: 'Settings', icon: Settings, exact: false }, diff --git a/dashboard/src/components/projects/ProjectsLifecycleChart.test.tsx b/dashboard/src/components/projects/ProjectsLifecycleChart.test.tsx new file mode 100644 index 00000000..c383e9c2 --- /dev/null +++ b/dashboard/src/components/projects/ProjectsLifecycleChart.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProjectsLifecycleChart } from './ProjectsLifecycleChart'; +import type { ProjectLifecycleWeek } from '@/lib/types'; + +function makeWeek(overrides: Partial = {}): ProjectLifecycleWeek { + return { + week: '2026-01-05', + active: 1, + reactivated: 0, + dropped: 0, + started: 1, + newly_dropped: 0, + ...overrides, + }; +} + +describe('ProjectsLifecycleChart', () => { + it('shows a loading message while isLoading is true', () => { + render(); + expect(screen.getByText(/loading project lifecycle/i)).toBeInTheDocument(); + }); + + it('shows an empty-state message when there are no weeks', () => { + render(); + expect(screen.getByText(/no project history yet/i)).toBeInTheDocument(); + }); + + it('renders the chart title and legend when data is present', () => { + render(); + expect(screen.getByText('Project Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Reactivated')).toBeInTheDocument(); + expect(screen.getByText('Dropped')).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/components/projects/ProjectsLifecycleChart.tsx b/dashboard/src/components/projects/ProjectsLifecycleChart.tsx new file mode 100644 index 00000000..61eeac81 --- /dev/null +++ b/dashboard/src/components/projects/ProjectsLifecycleChart.tsx @@ -0,0 +1,191 @@ +import { + ComposedChart, + Area, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { CHART_COLORS } from '@/lib/constants/colors'; +import type { ProjectLifecycleWeek } from '@/lib/types'; + +const C = CHART_COLORS.projectLifecycle; + +interface ProjectsLifecycleChartProps { + weeks: ProjectLifecycleWeek[]; + isLoading?: boolean; +} + +function fmtWeek(iso: string): string { + return new Date(`${iso}T00:00:00Z`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +interface ChartRow extends ProjectLifecycleWeek { + label: string; + newly_dropped_neg: number; +} + +interface LifecycleTooltipProps { + active?: boolean; + payload?: Array<{ payload?: ChartRow }>; +} + +function LifecycleTooltip({ active, payload }: LifecycleTooltipProps) { + if (!active || !payload?.length) return null; + const row = payload[0]?.payload; + if (!row) return null; + + const buckets = [ + { label: 'Active', value: row.active, color: C.active }, + { label: 'Reactivated', value: row.reactivated, color: C.reactivated }, + { label: 'Dropped', value: row.dropped, color: C.dropped }, + ]; + + return ( +
+
{row.label}
+
+ {buckets.map((b) => ( +
+ + + {b.label} + + {b.value} +
+ ))} +
+ {(row.started > 0 || row.newly_dropped > 0) && ( +
+ {row.started > 0 && ( +
+ Started / reactivated this week + {row.started} +
+ )} + {row.newly_dropped > 0 && ( +
+ Dropped this week + {row.newly_dropped} +
+ )} +
+ )} +
+ ); +} + +export function ProjectsLifecycleChart({ weeks, isLoading }: ProjectsLifecycleChartProps) { + const chartData: ChartRow[] = weeks.map((w) => ({ + ...w, + label: fmtWeek(w.week), + newly_dropped_neg: -w.newly_dropped, + })); + + const maxCumulative = chartData.reduce((m, d) => Math.max(m, d.active + d.reactivated + d.dropped), 0); + const maxDelta = chartData.reduce((m, d) => Math.max(m, d.started, d.newly_dropped), 0) || 1; + + return ( + + + Project Lifecycle +

+ Cumulative active / reactivated / dropped projects since your first session · full history +

+
+ +
+ {isLoading || chartData.length === 0 ? ( +
+

+ {isLoading ? 'Loading project lifecycle…' : 'No project history yet'} +

+
+ ) : ( + + + + + + + } cursor={{ fill: 'currentColor', opacity: 0.05 }} /> + + + + + + + + )} +
+ {chartData.length > 0 && ( +
+ {[ + { label: 'Active', color: C.active }, + { label: 'Reactivated', color: C.reactivated }, + { label: 'Dropped', color: C.dropped }, + { label: 'Started / reactivated (weekly)', color: C.started }, + { label: 'Dropped (weekly)', color: C.dropped_event }, + ].map((l) => ( + + + {l.label} + + ))} +
+ )} +
+
+ ); +} diff --git a/dashboard/src/components/projects/ProjectsStatusTable.test.tsx b/dashboard/src/components/projects/ProjectsStatusTable.test.tsx new file mode 100644 index 00000000..454352a5 --- /dev/null +++ b/dashboard/src/components/projects/ProjectsStatusTable.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProjectsStatusTable } from './ProjectsStatusTable'; +import type { ProjectLifecycleSummary } from '@/lib/types'; + +function makeProject(overrides: Partial = {}): ProjectLifecycleSummary { + return { + name: 'har-cleaner', + path: '/home/dev/repos/har-cleaner', + first_seen: '2026-01-01', + last_seen: '2026-01-10', + session_count: 5, + status: 'active', + ...overrides, + }; +} + +describe('ProjectsStatusTable', () => { + it('shows an empty-state message when there are no projects', () => { + render(); + expect(screen.getByText(/no project history yet/i)).toBeInTheDocument(); + }); + + it('renders a row per project with name, status badge, and session count', () => { + render( + + ); + expect(screen.getByText('har-cleaner')).toBeInTheDocument(); + expect(screen.getByText('zoom-scheduler')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Dropped')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/components/projects/ProjectsStatusTable.tsx b/dashboard/src/components/projects/ProjectsStatusTable.tsx new file mode 100644 index 00000000..4a9aacf4 --- /dev/null +++ b/dashboard/src/components/projects/ProjectsStatusTable.tsx @@ -0,0 +1,70 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import type { ProjectLifecycleStatus, ProjectLifecycleSummary } from '@/lib/types'; + +const STATUS_BADGE: Record = { + active: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20', + reactivated: 'bg-amber-500/10 text-amber-600 border-amber-500/20', + dropped: 'bg-slate-500/10 text-slate-600 border-slate-500/20', +}; + +const STATUS_LABEL: Record = { + active: 'Active', + reactivated: 'Reactivated', + dropped: 'Dropped', +}; + +interface ProjectsStatusTableProps { + projects: ProjectLifecycleSummary[]; +} + +export function ProjectsStatusTable({ projects }: ProjectsStatusTableProps) { + return ( + + + Projects + + +
+ + + + + + + + + + + + {projects.map((p) => ( + + + + + + + + ))} + {projects.length === 0 && ( + + + + )} + +
ProjectStatusSessionsFirst SeenLast Seen
+
{p.name}
+
{p.path}
+
+ + {STATUS_LABEL[p.status]} + + {p.session_count}{p.first_seen}{p.last_seen}
+ No project history yet. Sync sessions to see projects. +
+
+
+
+ ); +} diff --git a/dashboard/src/hooks/index.ts b/dashboard/src/hooks/index.ts index fb574af7..8d02dc3a 100644 --- a/dashboard/src/hooks/index.ts +++ b/dashboard/src/hooks/index.ts @@ -1,8 +1,9 @@ export { useProjects, useProject } from './useProjects'; +export { useProjectsLifecycle } from './useProjectsLifecycle'; export { useSessions, useSession, useSessionMutation, useDeleteSession, useDeletedSessionCount } from './useSessions'; export { useInsights, useDeleteInsight } from './useInsights'; export { useMessages } from './useMessages'; -export { useDashboardStats } from './useAnalytics'; +export { useDashboardStats, useActivity } from './useAnalytics'; export { useAnalyzeSession } from './useAnalysis'; export { useLlmConfig, useSaveLlmConfig } from './useConfig'; export { useExportMarkdown } from './useExport'; diff --git a/dashboard/src/hooks/useAnalytics.ts b/dashboard/src/hooks/useAnalytics.ts index 1ee7e57c..d17ca7e2 100644 --- a/dashboard/src/hooks/useAnalytics.ts +++ b/dashboard/src/hooks/useAnalytics.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { fetchDashboardStats } from '@/lib/api'; +import { fetchDashboardStats, fetchActivity } from '@/lib/api'; type Range = '7d' | '30d' | '90d' | 'all'; @@ -10,3 +10,13 @@ export function useDashboardStats(range: Range = '7d') { refetchInterval: 60_000, }); } + +// All-time per-day activity series. Range-independent: the Activity chart slices +// the window client-side so the composite score keeps a fixed all-time reference. +export function useActivity() { + return useQuery({ + queryKey: ['analytics', 'activity'], + queryFn: () => fetchActivity().then((r) => r.days), + refetchInterval: 60_000, + }); +} diff --git a/dashboard/src/hooks/useProjectsLifecycle.ts b/dashboard/src/hooks/useProjectsLifecycle.ts new file mode 100644 index 00000000..c8d15ff6 --- /dev/null +++ b/dashboard/src/hooks/useProjectsLifecycle.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchProjectsLifecycle } from '@/lib/api'; + +// All-time weekly project lifecycle series + status table. No range switcher — +// the chart always shows full history, so this is a single fixed query. +export function useProjectsLifecycle() { + return useQuery({ + queryKey: ['analytics', 'projects-lifecycle'], + queryFn: () => fetchProjectsLifecycle(), + refetchInterval: 60_000, + }); +} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index be502d68..fffe1372 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -2,7 +2,7 @@ // Base URL is relative in production (SPA served by the same server). // In Vite dev mode, the proxy forwards /api -> localhost:7890. -import type { Project, Session, Message, Insight, DashboardStats, LLMConfig, ExportTemplate, FacetRow } from '@/lib/types'; +import type { Project, Session, Message, Insight, DashboardStats, ActivityDay, LLMConfig, ExportTemplate, FacetRow, ProjectsLifecycleResponse } from '@/lib/types'; const BASE = '/api'; @@ -134,6 +134,14 @@ export function fetchDashboardStats(range: '7d' | '30d' | '90d' | 'all' = '7d') return request<{ range: string; stats: DashboardStats }>(`/analytics/dashboard?range=${range}`); } +export function fetchActivity() { + return request<{ days: ActivityDay[] }>(`/analytics/activity`); +} + +export function fetchProjectsLifecycle() { + return request(`/analytics/projects-lifecycle`); +} + // ── Analysis (Phase 4) ──────────────────────────────────────────────────────── interface AnalysisApiResult { diff --git a/dashboard/src/lib/constants/colors.ts b/dashboard/src/lib/constants/colors.ts index 919a841b..f37fb088 100644 --- a/dashboard/src/lib/constants/colors.ts +++ b/dashboard/src/lib/constants/colors.ts @@ -121,10 +121,29 @@ export const CHART_COLORS = { sessions: '#3b82f6', // blue-500 insights: '#22c55e', // green-500 }, + // Activity chart metric selector — per-metric accent + stacked composite layers + activityMetrics: { + composite: '#6366f1', // indigo-500 + cost: '#10b981', // emerald-500 + tokens: '#3b82f6', // blue-500 + tool_calls: '#f59e0b', // amber-500 + messages: '#ec4899', // pink-500 + projects: '#a855f7', // purple-500 + sessions: '#64748b', // slate-500 + cognitive_load: '#ef4444', // red-500 — context-switching intensity + }, // Top projects bar chart projects: { sessions: '#3b82f6', // blue-500 }, + // Project lifecycle chart — cumulative area layers + weekly start/drop event bars + projectLifecycle: { + active: '#10b981', // emerald-500 + reactivated: '#f59e0b', // amber-500 + dropped: '#94a3b8', // slate-400 + started: '#3b82f6', // blue-500 — weekly started/reactivated event bar + dropped_event: '#ef4444', // red-500 — weekly dropped event bar + }, // Model distribution pie chart models: ['#3b82f6', '#a855f7', '#22c55e', '#f59e0b', '#f43f5e', '#06b6d4'], // Cost chart diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index 8792ed59..038f7931 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -125,6 +125,60 @@ export interface DailyStats { estimated_cost_usd?: number; } +// Per-day activity series from /api/analytics/activity. +// All-time, gap-filled (zero-filled) calendar days. Powers the Activity chart's +// metric selector and composite score. Metric defs match the CLI dashboard: +// tokens = input + output + cache_creation + cache_read +// cost = SUM(estimated_cost_usd) +export interface ActivityDay { + date: string; // YYYY-MM-DD (local calendar day, from started_at) + sessions: number; + projects: number; // distinct projects touched that day + messages: number; + tool_calls: number; + tokens: number; + cost: number; + // Cognitive load: integral of (concurrent warm sessions × concurrent warm + // projects²) over the day's active minutes. A human turn keeps a session "warm" + // for ~15 min; cross-project concurrency compounds steeply (S×P²). Idle sessions + // cool off and add nothing — captures context-switching cost, not raw volume. + cognitive_load: number; + peak_sessions: number; // max concurrent warm sessions in any single minute + peak_projects: number; // max concurrent warm projects in any single minute +} + +// Per-ISO-week lifecycle series from /api/analytics/projects-lifecycle. +// All-time, gap-filled (zero-filled) weeks from the first session ever to the +// current week. A "logical project" collapses duplicate `projects` rows for +// the same folder (path-hash vs git-remote id sources, git worktrees) and +// excludes any project with fewer than 2 sessions. A project is "dropped" +// once 60 days pass with no session; "reactivated" once it comes back after +// having dropped at least once before. +export interface ProjectLifecycleWeek { + week: string; // YYYY-MM-DD (ISO week start, Monday) + active: number; // cumulative projects currently active, never dropped + reactivated: number; // cumulative projects currently active, dropped before + dropped: number; // cumulative projects currently dropped (60+ days idle) + started: number; // projects that started or reactivated this week + newly_dropped: number; // projects that crossed into dropped this week +} + +export type ProjectLifecycleStatus = 'active' | 'reactivated' | 'dropped'; + +export interface ProjectLifecycleSummary { + name: string; + path: string; // representative path (from the most-active grouped row) + first_seen: string; // YYYY-MM-DD + last_seen: string; // YYYY-MM-DD + session_count: number; // summed across all grouped raw project rows + status: ProjectLifecycleStatus; +} + +export interface ProjectsLifecycleResponse { + weeks: ProjectLifecycleWeek[]; + projects: ProjectLifecycleSummary[]; +} + /** * Safely parse a JSON-encoded string field from the SQLite API response. * Returns defaultValue if the field is null, empty, or invalid JSON. diff --git a/dashboard/src/pages/DashboardPage.tsx b/dashboard/src/pages/DashboardPage.tsx index 7b6f8152..c0bd8155 100644 --- a/dashboard/src/pages/DashboardPage.tsx +++ b/dashboard/src/pages/DashboardPage.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { Link } from 'react-router'; -import { useDashboardStats } from '@/hooks/useAnalytics'; +import { useDashboardStats, useActivity } from '@/hooks/useAnalytics'; import { useSessions } from '@/hooks/useSessions'; import { useInsights } from '@/hooks/useInsights'; import { useProjects } from '@/hooks/useProjects'; @@ -12,7 +12,6 @@ import { StatsHeroSkeleton } from '@/components/skeletons/StatsHeroSkeleton'; import { ErrorCard } from '@/components/ErrorCard'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; -import type { DailyStats } from '@/lib/types'; import { Sparkles, ArrowRight } from 'lucide-react'; type DashboardRange = '7d' | '30d' | '90d' | 'all'; @@ -28,6 +27,7 @@ export default function DashboardPage() { const [range, setRange] = useState('7d'); const { data: dashStats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useDashboardStats(range); + const { data: activityDays = [], isLoading: activityLoading } = useActivity(); const { data: sessions = [], isLoading: sessionsLoading, isError: sessionsError, refetch: refetchSessions } = useSessions({ limit: 500 }); const { data: insights = [], isLoading: insightsLoading } = useInsights(); const { data: projects = [] } = useProjects(); @@ -44,35 +44,6 @@ export default function DashboardPage() { const analyzedSessionIds = new Set(insights.map((i) => i.session_id)); const unanalyzedSessions = sessions.filter((s) => !analyzedSessionIds.has(s.id)); - // Build daily stats for activity chart - const dailyStats: DailyStats[] = useMemo(() => { - const now = Date.now(); - const rangeDays = range === '7d' ? 7 : range === '30d' ? 30 : range === '90d' ? 90 : Infinity; - const cutoff = rangeDays === Infinity ? 0 : now - rangeDays * 86_400_000; - - const grouped: Record = {}; - for (const s of sessions) { - if (new Date(s.started_at).getTime() < cutoff) continue; - const date = s.started_at.slice(0, 10); - if (!grouped[date]) grouped[date] = { session_count: 0, insight_count: 0 }; - grouped[date].session_count++; - } - for (const i of insights) { - if (new Date(i.timestamp).getTime() < cutoff) continue; - const date = i.timestamp.slice(0, 10); - if (!grouped[date]) grouped[date] = { session_count: 0, insight_count: 0 }; - grouped[date].insight_count++; - } - return Object.entries(grouped) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, counts]) => ({ - date, - session_count: counts.session_count, - message_count: 0, - insight_count: counts.insight_count, - })); - }, [sessions, insights, range]); - // Compute stats for hero — all from dashStats (range-filtered) const totalTokens = dashStats ? (dashStats.total_input_tokens ?? 0) + @@ -153,7 +124,12 @@ export default function DashboardPage() { ) : (
- +
)} diff --git a/dashboard/src/pages/ProjectsPage.test.tsx b/dashboard/src/pages/ProjectsPage.test.tsx new file mode 100644 index 00000000..d93ea552 --- /dev/null +++ b/dashboard/src/pages/ProjectsPage.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import ProjectsPage from './ProjectsPage'; +import type { useProjectsLifecycle } from '@/hooks/useProjectsLifecycle'; + +vi.mock('@/hooks/useProjectsLifecycle', () => ({ + useProjectsLifecycle: vi.fn(), +})); + +import { useProjectsLifecycle as mockedHook } from '@/hooks/useProjectsLifecycle'; +const mockUseProjectsLifecycle = vi.mocked(mockedHook); + +type HookReturn = ReturnType; + +describe('ProjectsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows loading skeletons while data is loading', () => { + mockUseProjectsLifecycle.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + refetch: vi.fn(), + } as unknown as HookReturn); + + render(); + expect(screen.getByText('Projects')).toBeInTheDocument(); + }); + + it('shows an error card with retry when the fetch fails', () => { + const refetch = vi.fn(); + mockUseProjectsLifecycle.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + refetch, + } as unknown as HookReturn); + + render(); + expect(screen.getByText(/failed to load project lifecycle data/i)).toBeInTheDocument(); + }); + + it('renders the chart and status table once data loads', () => { + mockUseProjectsLifecycle.mockReturnValue({ + data: { + weeks: [{ week: '2026-01-05', active: 1, reactivated: 0, dropped: 0, started: 1, newly_dropped: 0 }], + projects: [ + { + name: 'har-cleaner', + path: '/repos/har-cleaner', + first_seen: '2026-01-01', + last_seen: '2026-01-05', + session_count: 3, + status: 'active', + }, + ], + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + } as unknown as HookReturn); + + render(); + expect(screen.getByText('Project Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('har-cleaner')).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/pages/ProjectsPage.tsx b/dashboard/src/pages/ProjectsPage.tsx new file mode 100644 index 00000000..742e6c10 --- /dev/null +++ b/dashboard/src/pages/ProjectsPage.tsx @@ -0,0 +1,48 @@ +import { useProjectsLifecycle } from '@/hooks/useProjectsLifecycle'; +import { ProjectsLifecycleChart } from '@/components/projects/ProjectsLifecycleChart'; +import { ProjectsStatusTable } from '@/components/projects/ProjectsStatusTable'; +import { ErrorCard } from '@/components/ErrorCard'; +import { Skeleton } from '@/components/ui/skeleton'; + +export default function ProjectsPage() { + const { data, isLoading, isError, refetch } = useProjectsLifecycle(); + + if (isError && !isLoading) { + return ( +
+
+

Projects

+

See which projects you're actively working on — and which have gone quiet

+
+ +
+ ); + } + + if (isLoading) { + return ( +
+
+

Projects

+

See which projects you're actively working on — and which have gone quiet

+
+ + +
+ ); + } + + const weeks = data?.weeks ?? []; + const projects = data?.projects ?? []; + + return ( +
+
+

Projects

+

See which projects you're actively working on — and which have gone quiet

+
+ + +
+ ); +} diff --git a/server/src/routes/analytics.test.ts b/server/src/routes/analytics.test.ts index bea49335..635ce2a3 100644 --- a/server/src/routes/analytics.test.ts +++ b/server/src/routes/analytics.test.ts @@ -1,6 +1,12 @@ import Database from 'better-sqlite3'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { runMigrations } from '@code-insights/cli/db/schema'; +import { + normalizeProjectPath, + buildLogicalProjects, + computeLifecycle, + buildWeeklySeries, +} from './analytics.js'; // ────────────────────────────────────────────────────── // Module-scoped mutable DB reference for mocking. @@ -29,10 +35,256 @@ function initTestDb(): Database.Database { return db; } +function seedProject(id: string, name: string, path: string) { + testDb.prepare(` + INSERT INTO projects (id, name, path, last_activity, session_count) + VALUES (?, ?, ?, datetime('now'), 1) + `).run(id, name, path); +} + +let sessionCounter = 0; +function seedSession(projectId: string, startedAt: string) { + const id = `s-${++sessionCounter}`; + testDb.prepare(` + INSERT INTO sessions (id, project_id, project_name, project_path, started_at, ended_at, message_count) + VALUES (?, ?, 'p', '/p', ?, ?, 1) + `).run(id, projectId, startedAt, startedAt); + return id; +} + // ────────────────────────────────────────────────────── // Tests // ────────────────────────────────────────────────────── +describe('normalizeProjectPath', () => { + it('normalizes URL-encoded and Windows-style paths for the same folder identically', () => { + const encoded = normalizeProjectPath('/c%3A/Users/dev/Repos/har-cleaner'); + const windows = normalizeProjectPath('C:\\Users\\dev\\Repos\\har-cleaner'); + expect(encoded).toBe(windows); + }); + + it('strips a trailing worktree segment so it points at the parent repo path', () => { + const stripped = normalizeProjectPath('C:\\Users\\dev\\Repos\\ubt-maven\\.claude\\worktrees\\keen-davinci-c306f0'); + const parent = normalizeProjectPath('C:\\Users\\dev\\Repos\\ubt-maven'); + expect(stripped).toBe(parent); + }); + + it('leaves an already-normalized POSIX path unchanged', () => { + expect(normalizeProjectPath('/home/dev/repos/zoom-scheduler')).toBe('/home/dev/repos/zoom-scheduler'); + }); + + it('trims a trailing slash', () => { + expect(normalizeProjectPath('/home/dev/repos/zoom-scheduler/')).toBe('/home/dev/repos/zoom-scheduler'); + }); +}); + +describe('buildLogicalProjects', () => { + it('groups raw rows by normalized path alone and unions their sessions', () => { + const projects = [ + { id: 'p1', name: 'har-cleaner', path: '/c%3A/Users/dev/Repos/har-cleaner' }, + { id: 'p2', name: 'har-cleaner', path: 'C:\\Users\\dev\\Repos\\har-cleaner' }, + ]; + const sessions = new Map([ + ['p1', [1000, 2000]], + ['p2', [3000]], + ]); + const result = buildLogicalProjects(projects, sessions); + expect(result).toHaveLength(1); + expect(result[0].sessionTimestamps).toEqual([1000, 2000, 3000]); + }); + + it('excludes logical projects with fewer than 2 total sessions', () => { + const projects = [{ id: 'p1', name: 'lonely', path: '/lonely' }]; + const sessions = new Map([['p1', [1000]]]); + expect(buildLogicalProjects(projects, sessions)).toEqual([]); + }); + + it('folds a worktree row into its parent repo, preferring the parent even when the worktree has more sessions', () => { + const projects = [ + { id: 'p1', name: 'ubt-maven', path: 'C:\\Repos\\ubt-maven' }, + { id: 'p2', name: 'keen-davinci-c306f0', path: 'C:\\Repos\\ubt-maven\\.claude\\worktrees\\keen-davinci-c306f0' }, + ]; + const sessions = new Map([ + ['p1', [1000, 2000]], + ['p2', [3000, 4000, 5000]], + ]); + const result = buildLogicalProjects(projects, sessions); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('ubt-maven'); + expect(result[0].path).toBe('C:\\Repos\\ubt-maven'); + expect(result[0].sessionTimestamps).toEqual([1000, 2000, 3000, 4000, 5000]); + }); + + it('folds a worktree row (name="optimistic-mclean-1ce467") into its parent repo row (name="ubt-tool-hub")', () => { + const projects = [ + { id: 'p1', name: 'optimistic-mclean-1ce467', path: 'C:\\Repos\\ubt-tool-hub\\.claude\\worktrees\\optimistic-mclean-1ce467' }, + { id: 'p2', name: 'ubt-tool-hub', path: 'C:\\Repos\\ubt-tool-hub' }, + ]; + const sessions = new Map([ + ['p1', [1000, 2000]], + ['p2', [3000]], + ]); + const result = buildLogicalProjects(projects, sessions); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('ubt-tool-hub'); + expect(result[0].path).toBe('C:\\Repos\\ubt-tool-hub'); + expect(result[0].sessionTimestamps).toEqual([1000, 2000, 3000]); + }); + + it('falls back to the highest-session-count row when every row in the group is a worktree path', () => { + const projects = [ + { id: 'p1', name: 'dazzling-jones-2f03f7', path: 'C:\\Repos\\x\\.claude\\worktrees\\dazzling-jones-2f03f7' }, + { id: 'p2', name: 'zen-pike-499d41', path: 'C:\\Repos\\x\\.claude\\worktrees\\zen-pike-499d41' }, + ]; + const sessions = new Map([ + ['p1', [1000]], + ['p2', [2000, 3000]], + ]); + const result = buildLogicalProjects(projects, sessions); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('zen-pike-499d41'); + }); + + it('picks the raw path from the grouped row with the most sessions as the representative', () => { + const projects = [ + { id: 'p1', name: 'x', path: '/legacy/path/x' }, + { id: 'p2', name: 'x', path: '/legacy/path/x/' }, // normalizes to same key (trailing slash trimmed) + ]; + const sessions = new Map([ + ['p1', [1000]], + ['p2', [2000, 3000, 4000]], + ]); + const result = buildLogicalProjects(projects, sessions); + expect(result).toHaveLength(1); + expect(result[0].path).toBe('/legacy/path/x/'); + }); +}); + +describe('computeLifecycle', () => { + const DAY = 86_400_000; + + it('a single segment within the inactivity window stays active', () => { + const now = 50 * DAY; // 30 days after the last session — within the 60-day window + const timestamps = [10 * DAY, 20 * DAY]; + const lc = computeLifecycle(timestamps, now); + expect(lc.status).toBe('active'); + expect(lc.events).toHaveLength(1); + expect(lc.events[0].type).toBe('started'); + }); + + it('a gap over 60 days followed by a recent session becomes reactivated', () => { + const now = 200 * DAY; + const timestamps = [10 * DAY, 20 * DAY, 190 * DAY]; // gap of 170 days + const lc = computeLifecycle(timestamps, now); + expect(lc.status).toBe('reactivated'); + expect(lc.events.map((e) => e.type)).toEqual(['started', 'dropped', 'reactivated']); + }); + + it('a project whose last session is over 60 days before now is dropped', () => { + const now = 200 * DAY; + const timestamps = [10 * DAY, 20 * DAY]; // last session 180 days before now + const lc = computeLifecycle(timestamps, now); + expect(lc.status).toBe('dropped'); + expect(lc.events.map((e) => e.type)).toEqual(['started', 'dropped']); + }); + + it('a project exactly at the 60-day boundary (not over) is not dropped', () => { + const now = 20 * DAY + 60 * DAY; // exactly 60 days after last session + const timestamps = [10 * DAY, 20 * DAY]; + const lc = computeLifecycle(timestamps, now); + expect(lc.status).toBe('active'); + expect(lc.events).toHaveLength(1); + }); +}); + +describe('buildWeeklySeries', () => { + const DAY = 86_400_000; + const WEEK = 7 * DAY; + // A real Monday 00:00 UTC — mondayOfIsoWeek(now) must resolve to itself for + // these fixtures to line up with event weeks that are also Monday-aligned. + const MONDAY = Date.UTC(2024, 0, 1); + + it('returns an empty series when there are no lifecycles', () => { + expect(buildWeeklySeries([], Date.now())).toEqual([]); + }); + + it('accumulates active count from the started week through the current week', () => { + const startWeek = MONDAY; + const now = startWeek + 3 * WEEK; + const rows = buildWeeklySeries( + [{ key: 'a', events: [{ week: startWeek, type: 'started' }] }], + now + ); + expect(rows).toHaveLength(4); // weeks 0..3 inclusive + expect(rows.every((r) => r.active === 1)).toBe(true); + expect(rows[0].started).toBe(1); + expect(rows.slice(1).every((r) => r.started === 0)).toBe(true); + }); + + it('moves a project from active to dropped to reactivated across the correct weeks', () => { + const w0 = MONDAY; + const w1 = MONDAY + 1 * WEEK; + const w2 = MONDAY + 2 * WEEK; + const now = w2; + const rows = buildWeeklySeries( + [ + { + key: 'a', + events: [ + { week: w0, type: 'started' }, + { week: w1, type: 'dropped' }, + { week: w2, type: 'reactivated' }, + ], + }, + ], + now + ); + expect(rows.find((r) => r.week === new Date(w0).toISOString().slice(0, 10))).toMatchObject({ + active: 1, + dropped: 0, + reactivated: 0, + }); + expect(rows.find((r) => r.week === new Date(w1).toISOString().slice(0, 10))).toMatchObject({ + active: 0, + dropped: 1, + newly_dropped: 1, + }); + expect(rows.find((r) => r.week === new Date(w2).toISOString().slice(0, 10))).toMatchObject({ + active: 0, + dropped: 0, + reactivated: 1, + started: 1, + }); + }); + + it('decrements the reactivated bucket (not active) when a previously-reactivated project drops again', () => { + const w0 = MONDAY; + const w1 = MONDAY + 1 * WEEK; // dropped + const w2 = MONDAY + 2 * WEEK; // reactivated + const w3 = MONDAY + 3 * WEEK; // dropped again + const rows = buildWeeklySeries( + [ + { + key: 'a', + events: [ + { week: w0, type: 'started' }, + { week: w1, type: 'dropped' }, + { week: w2, type: 'reactivated' }, + { week: w3, type: 'dropped' }, + ], + }, + ], + w3 + ); + expect(rows.find((r) => r.week === new Date(w3).toISOString().slice(0, 10))).toMatchObject({ + active: 0, + reactivated: 0, + dropped: 1, + newly_dropped: 1, + }); + }); +}); + describe('Analytics routes', () => { beforeEach(() => { testDb = initTestDb(); @@ -70,6 +322,147 @@ describe('Analytics routes', () => { }); }); + describe('GET /api/analytics/activity', () => { + it('returns an empty array when there are no sessions', async () => { + const app = createApp(); + const res = await app.request('/api/analytics/activity'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.days).toEqual([]); + }); + + it('gap-fills calendar days between the first and last session and includes cognitive load', async () => { + seedProject('p1', 'gappy', '/gappy'); + const sid = seedSession('p1', '2026-01-01T10:00:00Z'); + seedSession('p1', '2026-01-03T10:00:00Z'); + // A human message on day 1 gives computeCognitiveLoad something to bucket. + testDb.prepare(` + INSERT INTO messages (id, session_id, type, content, timestamp) + VALUES ('m1', ?, 'user', 'hello', '2026-01-01T10:00:00Z') + `).run(sid); + + const app = createApp(); + const res = await app.request('/api/analytics/activity'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.days).toHaveLength(3); // Jan 1, 2 (gap-filled), 3 + expect(body.days.map((d: { date: string }) => d.date)).toEqual(['2026-01-01', '2026-01-02', '2026-01-03']); + expect(body.days[0].sessions).toBe(1); + expect(body.days[1].sessions).toBe(0); + expect(body.days[0].cognitive_load).toBeGreaterThan(0); + expect(body.days[1].cognitive_load).toBe(0); + }); + }); + + describe('GET /api/analytics/projects-lifecycle', () => { + it('returns empty weeks/projects for an empty DB', async () => { + const app = createApp(); + const res = await app.request('/api/analytics/projects-lifecycle'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.weeks).toEqual([]); + expect(body.projects).toEqual([]); + }); + + it('excludes logical projects with fewer than 2 sessions', async () => { + seedProject('p1', 'lonely', '/lonely'); + seedSession('p1', '2026-01-05T00:00:00Z'); + + const app = createApp(); + const res = await app.request('/api/analytics/projects-lifecycle'); + const body = await res.json(); + expect(body.projects).toEqual([]); + }); + + it('collapses path-hash/git-remote duplicate rows for the same folder into one logical project', async () => { + // Same name + same folder, recorded under two different project_id_source rows — + // one URL-encoded Windows path, one native Windows path. They normalize identically. + seedProject('p1', 'har-cleaner', '/c%3A/Users/dev/Repos/har-cleaner'); + seedProject('p2', 'har-cleaner', 'C:\\Users\\dev\\Repos\\har-cleaner'); + seedSession('p1', '2026-01-05T00:00:00Z'); + seedSession('p2', '2026-01-06T00:00:00Z'); + + const app = createApp(); + const res = await app.request('/api/analytics/projects-lifecycle'); + const body = await res.json(); + expect(body.projects).toHaveLength(1); + expect(body.projects[0].name).toBe('har-cleaner'); + expect(body.projects[0].session_count).toBe(2); + }); + + it('folds a worktree row into its parent repo, preferring the human-chosen name', async () => { + seedProject('p1', 'ubt-maven', 'C:\\Users\\dev\\Repos\\ubt-maven'); + seedProject('p2', 'keen-davinci-c306f0', 'C:\\Users\\dev\\Repos\\ubt-maven\\.claude\\worktrees\\keen-davinci-c306f0'); + seedSession('p1', '2026-01-01T00:00:00Z'); + seedSession('p1', '2026-01-02T00:00:00Z'); + seedSession('p2', '2026-01-03T00:00:00Z'); + seedSession('p2', '2026-01-04T00:00:00Z'); + + const app = createApp(); + const res = await app.request('/api/analytics/projects-lifecycle'); + const body = await res.json(); + expect(body.projects).toHaveLength(1); + expect(body.projects[0].name).toBe('ubt-maven'); + expect(body.projects[0].session_count).toBe(4); + }); + + it('marks a project dropped once its last session is more than 60 days old', async () => { + seedProject('p1', 'stale', '/stale'); + const oldStart = new Date(Date.now() - 100 * 86_400_000).toISOString(); + const oldEnd = new Date(Date.now() - 90 * 86_400_000).toISOString(); + seedSession('p1', oldStart); + seedSession('p1', oldEnd); + + const app = createApp(); + const res = await app.request('/api/analytics/projects-lifecycle'); + const body = await res.json(); + expect(body.projects).toHaveLength(1); + expect(body.projects[0].status).toBe('dropped'); + + const totalStarted = body.weeks.reduce((s: number, w: { started: number }) => s + w.started, 0); + const totalDropped = body.weeks.reduce((s: number, w: { newly_dropped: number }) => s + w.newly_dropped, 0); + expect(totalStarted).toBe(1); + expect(totalDropped).toBe(1); + expect(body.weeks[body.weeks.length - 1].dropped).toBe(1); + expect(body.weeks[body.weeks.length - 1].active).toBe(0); + }); + + it('marks a project reactivated after a >60 day gap followed by a recent session', async () => { + seedProject('p1', 'comeback', '/comeback'); + const first = new Date(Date.now() - 200 * 86_400_000).toISOString(); + const second = new Date(Date.now() - 190 * 86_400_000).toISOString(); + const reactivation = new Date(Date.now() - 1 * 86_400_000).toISOString(); + seedSession('p1', first); + seedSession('p1', second); + seedSession('p1', reactivation); + + const app = createApp(); + const res = await app.request('/api/analytics/projects-lifecycle'); + const body = await res.json(); + expect(body.projects).toHaveLength(1); + expect(body.projects[0].status).toBe('reactivated'); + + const lastWeek = body.weeks[body.weeks.length - 1]; + expect(lastWeek.reactivated).toBe(1); + expect(lastWeek.active).toBe(0); + expect(lastWeek.dropped).toBe(0); + }); + + it('a still-active project with all sessions within 60 days stays active, never dropped', async () => { + seedProject('p1', 'humming', '/humming'); + seedSession('p1', new Date(Date.now() - 10 * 86_400_000).toISOString()); + seedSession('p1', new Date(Date.now() - 5 * 86_400_000).toISOString()); + + const app = createApp(); + const res = await app.request('/api/analytics/projects-lifecycle'); + const body = await res.json(); + expect(body.projects[0].status).toBe('active'); + const lastWeek = body.weeks[body.weeks.length - 1]; + expect(lastWeek.active).toBe(1); + expect(lastWeek.dropped).toBe(0); + }); + }); + describe('GET /api/analytics/usage', () => { it('returns null stats when no usage data exists', async () => { const app = createApp(); diff --git a/server/src/routes/analytics.ts b/server/src/routes/analytics.ts index 1e145d16..18ec8148 100644 --- a/server/src/routes/analytics.ts +++ b/server/src/routes/analytics.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono'; import { getDb } from '@code-insights/cli/db/client'; +import { mondayOfIsoWeek } from './shared-aggregation.js'; const app = new Hono(); @@ -53,6 +54,410 @@ app.get('/dashboard', (c) => { return c.json({ range, stats }); }); +// Cognitive-load model: a human turn keeps its session "warm" for ATTENTION_MIN +// minutes. While warm, the session contributes to concurrent multitasking. Load +// at any minute = (concurrent warm sessions) × (concurrent warm projects)² — the +// projects term is squared so juggling across multiple projects compounds far +// faster than stacking sessions within one project. Daily load integrates that +// over the day's active minutes (1-minute resolution). Truly idle sessions emit +// no human turns, so they go cold and add nothing — matching the intuition that +// a long unattended session is cheap, but rapid switching is expensive. +const ATTENTION_MIN = 15; +const MS_PER_MIN = 60_000; + +interface CognitiveDay { + cognitive_load: number; // Σ (sessions × projects²) over active minutes + peak_sessions: number; // max concurrent warm sessions in any minute + peak_projects: number; // max concurrent warm projects in any minute +} + +function computeCognitiveLoad(db: ReturnType): Map { + // Genuine human turns only: type='user' AND non-empty content. (type='user' + // with empty content is an automated tool-result message, not a human pulse.) + const pulses = db.prepare(` + SELECT m.timestamp AS t, m.session_id AS sid, s.project_id AS pid + FROM messages m + JOIN sessions s ON s.id = m.session_id + WHERE m.type = 'user' AND m.content <> '' AND s.deleted_at IS NULL + ORDER BY m.timestamp + `).all() as Array<{ t: string; sid: string; pid: string }>; + + // Spread each pulse across the warm window into 1-minute buckets, tracking the + // distinct sessions and projects live in each minute. + const buckets = new Map; projects: Set }>(); + for (const p of pulses) { + const startMin = Math.floor(new Date(p.t).getTime() / MS_PER_MIN); + for (let k = 0; k < ATTENTION_MIN; k++) { + const mi = startMin + k; + let b = buckets.get(mi); + if (!b) { b = { sessions: new Set(), projects: new Set() }; buckets.set(mi, b); } + b.sessions.add(p.sid); + b.projects.add(p.pid); + } + } + + const byDate = new Map(); + for (const [mi, b] of buckets) { + const date = new Date(mi * MS_PER_MIN).toISOString().slice(0, 10); + const s = b.sessions.size; + const pr = b.projects.size; + const d = byDate.get(date) ?? { cognitive_load: 0, peak_sessions: 0, peak_projects: 0 }; + d.cognitive_load += s * pr * pr; + if (s > d.peak_sessions) d.peak_sessions = s; + if (pr > d.peak_projects) d.peak_projects = pr; + byDate.set(date, d); + } + return byDate; +} + +// Per-day activity series (all-time, gap-filled) for the Activity chart. +// One row per calendar day from the first recorded session to the last, with +// zero-filled gaps so the timeline has no holes. Metric definitions match the +// CLI dashboard: tokens = input+output+cache_creation+cache_read. +app.get('/activity', (c) => { + const db = getDb(); + const rows = db.prepare(` + SELECT substr(started_at, 1, 10) AS date, + COUNT(*) AS sessions, + COUNT(DISTINCT project_id) AS projects, + COALESCE(SUM(message_count), 0) AS messages, + COALESCE(SUM(tool_call_count), 0) AS tool_calls, + COALESCE(SUM( + COALESCE(total_input_tokens, 0) + COALESCE(total_output_tokens, 0) + + COALESCE(cache_creation_tokens, 0) + COALESCE(cache_read_tokens, 0) + ), 0) AS tokens, + COALESCE(SUM(estimated_cost_usd), 0) AS cost + FROM sessions + WHERE deleted_at IS NULL AND started_at IS NOT NULL AND started_at <> '' + GROUP BY date + ORDER BY date + `).all() as Array<{ + date: string; + sessions: number; + projects: number; + messages: number; + tool_calls: number; + tokens: number; + cost: number; + }>; + + if (rows.length === 0) return c.json({ days: [] }); + + const cognitive = computeCognitiveLoad(db); + const zeroCog: CognitiveDay = { cognitive_load: 0, peak_sessions: 0, peak_projects: 0 }; + + // Gap-fill every calendar day between the first and last recorded day. + const byDate = new Map(rows.map((r) => [r.date, r])); + const days = []; + const oneDay = 86_400_000; + const start = new Date(`${rows[0].date}T00:00:00Z`).getTime(); + const end = new Date(`${rows[rows.length - 1].date}T00:00:00Z`).getTime(); + for (let t = start; t <= end; t += oneDay) { + const date = new Date(t).toISOString().slice(0, 10); + const r = byDate.get(date); + const cog = cognitive.get(date) ?? zeroCog; + days.push( + r + ? { ...r, tokens: Math.round(r.tokens), cost: Math.round(r.cost * 100) / 100, ...cog } + : { date, sessions: 0, projects: 0, messages: 0, tool_calls: 0, tokens: 0, cost: 0, ...cog } + ); + } + + return c.json({ days }); +}); + +// ============================================================ +// Projects lifecycle +// ============================================================ +// A logical project can be recorded as several `projects` rows: the same +// folder gets re-hashed under path-hash vs git-remote id sources, and Git +// worktrees (a `.claude/worktrees/` path segment) get their own row. +// We collapse those into one "logical project" before computing lifecycle. + +const INACTIVITY_DAYS = 60; +const INACTIVITY_MS = INACTIVITY_DAYS * 86_400_000; +const WEEK_MS = 7 * 86_400_000; + +type LifecycleStatus = 'active' | 'reactivated' | 'dropped'; +type LifecycleEventType = 'started' | 'reactivated' | 'dropped'; + +interface RawProjectRow { + id: string; + name: string; + path: string; +} + +/** + * Normalize a project path for identity matching: URL-decode, backslashes to + * forward slashes, lowercase a leading drive letter (and drop any leading + * slash in front of it, since URL-encoded Windows paths often carry one — + * e.g. "/c%3A/Users/..." vs "C:\Users\..." must normalize identically), and + * fold a trailing worktree segment back onto its parent repo path. + */ +export function normalizeProjectPath(rawPath: string): string { + let p = rawPath; + try { + p = decodeURIComponent(p); + } catch { + // Not valid percent-encoding — use the raw path as-is. + } + p = p.replace(/\\/g, '/'); + const driveMatch = p.match(/^\/?([A-Za-z]):(.*)$/); + if (driveMatch) { + p = `${driveMatch[1].toLowerCase()}:${driveMatch[2]}`; + } + p = p.replace(/\/\.claude\/worktrees\/[^/]+\/?$/, ''); + p = p.replace(/\/+$/, ''); + return p; +} + +interface LogicalProject { + key: string; + name: string; + path: string; // representative raw path (from the most-active, non-worktree grouped row) + sessionTimestamps: number[]; // ms epoch, ascending +} + +const WORKTREE_SEGMENT_RE = /\/\.claude\/worktrees\/[^/]+\/?$/; + +/** True if `rawPath` points inside a `.claude/worktrees/` folder. */ +function isWorktreePath(rawPath: string): boolean { + return WORKTREE_SEGMENT_RE.test(rawPath.replace(/\\/g, '/')); +} + +/** + * Group raw `projects` rows into logical projects by normalized path alone — + * NOT name, since a Git worktree's raw row carries the worktree's own + * auto-generated folder name (e.g. "keen-davinci-c306f0"), not its parent + * repo's name, even after its path is folded back onto the parent's path. + * Unions session timestamps across the group and drops any logical project + * with fewer than 2 total sessions (never counts as a "start"). + */ +export function buildLogicalProjects( + projects: RawProjectRow[], + sessionTimestampsByProjectId: Map +): LogicalProject[] { + const groups = new Map(); // normalized path -> raw ids + for (const p of projects) { + const key = normalizeProjectPath(p.path); + let ids = groups.get(key); + if (!ids) { + ids = []; + groups.set(key, ids); + } + ids.push(p.id); + } + + const rawById = new Map(projects.map((p) => [p.id, p])); + const sessionCount = (id: string) => sessionTimestampsByProjectId.get(id)?.length ?? 0; + + const logical: LogicalProject[] = []; + for (const [key, rawIds] of groups) { + const timestamps: number[] = []; + for (const rawId of rawIds) { + timestamps.push(...(sessionTimestampsByProjectId.get(rawId) ?? [])); + } + if (timestamps.length < 2) continue; + timestamps.sort((a, b) => a - b); + + // Display name/path: prefer the most-active row whose original path is + // NOT a worktree path (the parent repo's own row, if one was ever + // synced); only fall back to a worktree row if every row in the group + // is one (e.g. the user has only ever worked from worktrees of this repo). + const rows = rawIds.map((id) => rawById.get(id)).filter((r): r is RawProjectRow => r !== undefined); + const nonWorktreeRows = rows.filter((r) => !isWorktreePath(r.path)); + const pool = nonWorktreeRows.length > 0 ? nonWorktreeRows : rows; + const repRaw = pool.reduce((best, r) => (sessionCount(r.id) > sessionCount(best.id) ? r : best)); + + logical.push({ key, name: repRaw.name, path: repRaw.path, sessionTimestamps: timestamps }); + } + return logical; +} + +interface LifecycleEvent { + week: number; // ms epoch of the Monday this event fires on + type: LifecycleEventType; +} + +interface ProjectLifecycle { + events: LifecycleEvent[]; + status: LifecycleStatus; + firstSeen: number; + lastSeen: number; +} + +/** + * Walk a project's sorted session timestamps, splitting into segments + * wherever consecutive sessions are more than INACTIVITY_DAYS apart. The + * first segment starts "active"; every later segment follows a >60d gap, so + * it starts "reactivated". A segment ends in a "dropped" event 60 days after + * its last session if that gap is followed by another segment (always true, + * by construction) or — for the final segment — if `now` is past it. + */ +export function computeLifecycle(timestamps: number[], nowMs: number): ProjectLifecycle { + const segments: Array<{ start: number; end: number }> = []; + let segStart = timestamps[0]; + let segEnd = timestamps[0]; + for (let i = 1; i < timestamps.length; i++) { + const gap = timestamps[i] - timestamps[i - 1]; + if (gap > INACTIVITY_MS) { + segments.push({ start: segStart, end: segEnd }); + segStart = timestamps[i]; + } + segEnd = timestamps[i]; + } + segments.push({ start: segStart, end: segEnd }); + + const events: LifecycleEvent[] = []; + let status: LifecycleStatus = 'active'; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const isLast = i === segments.length - 1; + const startType: LifecycleEventType = i === 0 ? 'started' : 'reactivated'; + events.push({ week: mondayOfIsoWeek(new Date(seg.start)).getTime(), type: startType }); + + const gapAfter = isLast ? nowMs - seg.end : segments[i + 1].start - seg.end; + if (gapAfter > INACTIVITY_MS) { + const dropDate = seg.end + INACTIVITY_MS; + events.push({ week: mondayOfIsoWeek(new Date(dropDate)).getTime(), type: 'dropped' }); + if (isLast) status = 'dropped'; + } else if (isLast) { + status = i === 0 ? 'active' : 'reactivated'; + } + } + + return { events, status, firstSeen: timestamps[0], lastSeen: timestamps[timestamps.length - 1] }; +} + +interface WeekRow { + week: string; // YYYY-MM-DD (Monday) + active: number; + reactivated: number; + dropped: number; + started: number; + newly_dropped: number; +} + +/** + * Sweep every project's lifecycle events in week order, maintaining running + * cumulative counts per bucket. Gap-filled week-by-week (like /activity + * gap-fills days) from the first event's week through the current week. + */ +export function buildWeeklySeries( + lifecycles: Array<{ key: string; events: LifecycleEvent[] }>, + nowMs: number +): WeekRow[] { + const eventsByWeek = new Map>(); + let minWeek = Infinity; + for (const lc of lifecycles) { + for (const e of lc.events) { + if (e.week < minWeek) minWeek = e.week; + let arr = eventsByWeek.get(e.week); + if (!arr) { + arr = []; + eventsByWeek.set(e.week, arr); + } + arr.push({ key: lc.key, type: e.type }); + } + } + if (minWeek === Infinity) return []; + + const currentWeek = mondayOfIsoWeek(new Date(nowMs)).getTime(); + const state = new Map(); + let active = 0; + let reactivated = 0; + let dropped = 0; + const rows: WeekRow[] = []; + + for (let w = minWeek; w <= currentWeek; w += WEEK_MS) { + const weekEvents = eventsByWeek.get(w) ?? []; + let started = 0; + let newlyDropped = 0; + for (const ev of weekEvents) { + if (ev.type === 'started') { + active++; + state.set(ev.key, 'active'); + started++; + } else if (ev.type === 'reactivated') { + dropped--; + reactivated++; + state.set(ev.key, 'reactivated'); + started++; + } else { + const prev = state.get(ev.key); + if (prev === 'active') active--; + else if (prev === 'reactivated') reactivated--; + dropped++; + state.set(ev.key, 'dropped'); + newlyDropped++; + } + } + rows.push({ + week: new Date(w).toISOString().slice(0, 10), + active, + reactivated, + dropped, + started, + newly_dropped: newlyDropped, + }); + } + return rows; +} + +// Cumulative weekly lifecycle (started/reactivated/dropped) for every logical +// project since the first-ever session, plus a current-status summary table. +app.get('/projects-lifecycle', (c) => { + const db = getDb(); + + const rawProjects = db.prepare(`SELECT id, name, path FROM projects`).all() as RawProjectRow[]; + const sessionRows = db.prepare(` + SELECT project_id, started_at FROM sessions + WHERE deleted_at IS NULL AND started_at IS NOT NULL AND started_at <> '' + `).all() as Array<{ project_id: string; started_at: string }>; + + const timestampsByProjectId = new Map(); + for (const r of sessionRows) { + const ts = new Date(r.started_at).getTime(); + if (Number.isNaN(ts)) continue; + let arr = timestampsByProjectId.get(r.project_id); + if (!arr) { + arr = []; + timestampsByProjectId.set(r.project_id, arr); + } + arr.push(ts); + } + + const logicalProjects = buildLogicalProjects(rawProjects, timestampsByProjectId); + if (logicalProjects.length === 0) { + return c.json({ weeks: [], projects: [] }); + } + + const nowMs = Date.now(); + const lifecycles = logicalProjects.map((lp) => ({ + key: lp.key, + ...computeLifecycle(lp.sessionTimestamps, nowMs), + })); + const lifecycleByKey = new Map(lifecycles.map((l) => [l.key, l])); + + const weeks = buildWeeklySeries(lifecycles, nowMs); + + const projectsOut = logicalProjects + .map((lp) => { + const lc = lifecycleByKey.get(lp.key)!; + return { + name: lp.name, + path: lp.path, + first_seen: new Date(lc.firstSeen).toISOString().slice(0, 10), + last_seen: new Date(lc.lastSeen).toISOString().slice(0, 10), + session_count: lp.sessionTimestamps.length, + status: lc.status, + }; + }) + .sort((a, b) => b.last_seen.localeCompare(a.last_seen)); + + return c.json({ weeks, projects: projectsOut }); +}); + // Global cumulative usage stats app.get('/usage', (c) => { const db = getDb(); diff --git a/server/src/routes/shared-aggregation.ts b/server/src/routes/shared-aggregation.ts index 7d472755..f7eeb832 100644 --- a/server/src/routes/shared-aggregation.ts +++ b/server/src/routes/shared-aggregation.ts @@ -61,6 +61,18 @@ export function formatIsoWeek(monday: Date): string { return `${year}-W${String(weekNum).padStart(2, '0')}`; } +/** + * Returns the Monday 00:00 UTC that starts the ISO week containing `date`. + * Used to bucket arbitrary timestamps into ISO week keys for gap-filled series. + */ +export function mondayOfIsoWeek(date: Date): Date { + const day = date.getUTCDay(); // 0=Sun, 1=Mon, ..., 6=Sat + const daysToMonday = day === 0 ? 6 : day - 1; + const monday = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + monday.setUTCDate(monday.getUTCDate() - daysToMonday); + return monday; +} + export function buildPeriodFilter(period: string): string | null { const now = new Date(); if (period === '7d') return new Date(now.getTime() - 7 * 86400000).toISOString();