Skip to content
Open
3 changes: 3 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,6 +19,7 @@ const ROUTE_TITLES: Record<string, string> = {
'/sessions': 'Sessions',
'/insights': 'Insights',
'/analytics': 'Analytics',
'/projects': 'Projects',
'/patterns': 'Patterns',
'/export': 'Export',
'/journal': 'Journal',
Expand Down Expand Up @@ -72,6 +74,7 @@ export default function App() {
<Route path="/sessions/:id" element={<SessionDetailPage />} />
<Route path="/insights" element={<InsightsPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/patterns" element={<PatternsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} />
Expand Down
415 changes: 328 additions & 87 deletions dashboard/src/components/dashboard/DashboardActivityChart.tsx

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions dashboard/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Github,
Sparkles,
Search,
FolderKanban,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Expand All @@ -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 },
Expand Down
36 changes: 36 additions & 0 deletions dashboard/src/components/projects/ProjectsLifecycleChart.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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(<ProjectsLifecycleChart weeks={[]} isLoading />);
expect(screen.getByText(/loading project lifecycle/i)).toBeInTheDocument();
});

it('shows an empty-state message when there are no weeks', () => {
render(<ProjectsLifecycleChart weeks={[]} />);
expect(screen.getByText(/no project history yet/i)).toBeInTheDocument();
});

it('renders the chart title and legend when data is present', () => {
render(<ProjectsLifecycleChart weeks={[makeWeek(), makeWeek({ week: '2026-01-12' })]} />);
expect(screen.getByText('Project Lifecycle')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByText('Reactivated')).toBeInTheDocument();
expect(screen.getByText('Dropped')).toBeInTheDocument();
});
});
191 changes: 191 additions & 0 deletions dashboard/src/components/projects/ProjectsLifecycleChart.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-lg border bg-popover px-3 py-2 text-popover-foreground shadow-md">
<div className="mb-1.5 text-xs font-medium text-muted-foreground">{row.label}</div>
<div className="space-y-0.5">
{buckets.map((b) => (
<div key={b.label} className="flex items-center justify-between gap-4 text-xs">
<span className="flex items-center gap-1.5 text-muted-foreground">
<span className="h-2 w-2 rounded-[2px]" style={{ backgroundColor: b.color }} />
{b.label}
</span>
<span className="font-medium tabular-nums">{b.value}</span>
</div>
))}
</div>
{(row.started > 0 || row.newly_dropped > 0) && (
<div className="mt-1.5 space-y-0.5 border-t pt-1.5 text-[11px]">
{row.started > 0 && (
<div className="flex items-center justify-between gap-4">
<span style={{ color: C.started }}>Started / reactivated this week</span>
<span className="font-medium text-foreground">{row.started}</span>
</div>
)}
{row.newly_dropped > 0 && (
<div className="flex items-center justify-between gap-4">
<span style={{ color: C.dropped_event }}>Dropped this week</span>
<span className="font-medium text-foreground">{row.newly_dropped}</span>
</div>
)}
</div>
)}
</div>
);
}

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 (
<Card>
<CardHeader className="space-y-0.5 pb-1">
<CardTitle className="text-sm font-medium">Project Lifecycle</CardTitle>
<p className="text-[11px] text-muted-foreground">
Cumulative active / reactivated / dropped projects since your first session · full history
</p>
</CardHeader>
<CardContent>
<div className="h-[240px]">
{isLoading || chartData.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{isLoading ? 'Loading project lifecycle…' : 'No project history yet'}
</p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} className="stroke-muted" />
<XAxis
dataKey="label"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
interval={Math.max(0, Math.ceil(chartData.length / 10) - 1)}
/>
<YAxis
yAxisId="left"
domain={[0, Math.ceil(maxCumulative * 1.08) || 1]}
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
width={32}
/>
<YAxis
yAxisId="right"
orientation="right"
domain={[-maxDelta - 1, maxDelta + 1]}
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
width={28}
/>
<Tooltip content={<LifecycleTooltip />} cursor={{ fill: 'currentColor', opacity: 0.05 }} />
<Area
yAxisId="left"
type="monotone"
dataKey="active"
stackId="lifecycle"
stroke={C.active}
fill={C.active}
fillOpacity={0.7}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="reactivated"
stackId="lifecycle"
stroke={C.reactivated}
fill={C.reactivated}
fillOpacity={0.7}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="dropped"
stackId="lifecycle"
stroke={C.dropped}
fill={C.dropped}
fillOpacity={0.5}
/>
<Bar yAxisId="right" dataKey="started" fill={C.started} maxBarSize={5} />
<Bar yAxisId="right" dataKey="newly_dropped_neg" fill={C.dropped_event} maxBarSize={5} />
</ComposedChart>
</ResponsiveContainer>
)}
</div>
{chartData.length > 0 && (
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
{[
{ 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) => (
<span key={l.label} className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-[2px]" style={{ backgroundColor: l.color }} />
{l.label}
</span>
))}
</div>
)}
</CardContent>
</Card>
);
}
36 changes: 36 additions & 0 deletions dashboard/src/components/projects/ProjectsStatusTable.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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(<ProjectsStatusTable projects={[]} />);
expect(screen.getByText(/no project history yet/i)).toBeInTheDocument();
});

it('renders a row per project with name, status badge, and session count', () => {
render(
<ProjectsStatusTable
projects={[makeProject(), makeProject({ name: 'zoom-scheduler', status: 'dropped', session_count: 2 })]}
/>
);
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();
});
});
70 changes: 70 additions & 0 deletions dashboard/src/components/projects/ProjectsStatusTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { ProjectLifecycleStatus, ProjectLifecycleSummary } from '@/lib/types';

const STATUS_BADGE: Record<ProjectLifecycleStatus, string> = {
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<ProjectLifecycleStatus, string> = {
active: 'Active',
reactivated: 'Reactivated',
dropped: 'Dropped',
};

interface ProjectsStatusTableProps {
projects: ProjectLifecycleSummary[];
}

export function ProjectsStatusTable({ projects }: ProjectsStatusTableProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Projects</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-3 text-left font-medium">Project</th>
<th className="py-3 text-left font-medium">Status</th>
<th className="py-3 text-right font-medium">Sessions</th>
<th className="py-3 text-right font-medium">First Seen</th>
<th className="py-3 text-right font-medium">Last Seen</th>
</tr>
</thead>
<tbody>
{projects.map((p) => (
<tr key={`${p.name}:${p.path}`} className="border-b last:border-0">
<td className="py-3">
<div className="font-medium">{p.name}</div>
<div className="max-w-md truncate text-xs text-muted-foreground">{p.path}</div>
</td>
<td className="py-3">
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[p.status]}`}
>
{STATUS_LABEL[p.status]}
</span>
</td>
<td className="py-3 text-right">{p.session_count}</td>
<td className="py-3 text-right text-muted-foreground">{p.first_seen}</td>
<td className="py-3 text-right text-muted-foreground">{p.last_seen}</td>
</tr>
))}
{projects.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-sm text-muted-foreground">
No project history yet. Sync sessions to see projects.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
3 changes: 2 additions & 1 deletion dashboard/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading