Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
aa9423b
fix(serializer): overwrite import now clears old data before re-popul…
kevintseng Apr 18, 2026
2c4c527
fix(serializer): apply namespace filter at query level, not post-filter
kevintseng Apr 18, 2026
7cbc6c0
feat(core): add neural embedding module (@xenova/transformers, 384-dim)
kevintseng Apr 18, 2026
33e631d
feat(core): integrate neural embeddings into remember and recall
kevintseng Apr 18, 2026
89b58c3
feat(dashboard): add Graph and Lessons tabs with i18n support
kevintseng Apr 18, 2026
a272465
feat: v3.2.0 — neural embeddings, data integrity fixes, dashboard Gra…
kevintseng Apr 18, 2026
bd7e0e4
Merge branch 'main' into develop
kevintseng Apr 18, 2026
1117d0d
feat(dashboard): implement Precision Engineer design system
kevintseng Apr 18, 2026
d3576df
feat(dashboard): add feedback widget with i18n + restore interaction …
kevintseng Apr 18, 2026
40120da
style(design): fix touch targets and search empty state
kevintseng Apr 18, 2026
cbbf42c
feat(api): add /v1/analytics endpoint with health score, timeline, va…
kevintseng Apr 18, 2026
50cfbc4
feat(dashboard): add AnalyticsData type for /v1/analytics endpoint
kevintseng Apr 18, 2026
faf8dae
feat(dashboard): add HealthScore component with factor bars
kevintseng Apr 18, 2026
91b3ddf
feat(dashboard): add ValueMetrics component with coverage bars
kevintseng Apr 18, 2026
e30ebce
feat(dashboard): add CleanupSuggestions component with archive actions
kevintseng Apr 18, 2026
45efb65
feat(dashboard): add MemoryTimeline canvas sparkline component
kevintseng Apr 18, 2026
9c4be09
feat(dashboard): rewrite AnalyticsTab with insights + add i18n for al…
kevintseng Apr 18, 2026
c37af66
test(analytics): add tests for health score, stale detection, timelin…
kevintseng Apr 18, 2026
12457c9
docs: add /v1/analytics endpoint to architecture and API reference
kevintseng Apr 18, 2026
170c136
fix(dashboard): align frontend types with actual /v1/analytics response
kevintseng Apr 18, 2026
157b933
fix(dashboard): use correct i18n key for lessons applied label
kevintseng Apr 18, 2026
05d3784
feat(dashboard): rewrite GraphTab with type filter, search, ego graph…
kevintseng Apr 18, 2026
1c29d06
fix(dashboard): fix GraphTab blank canvas race condition
kevintseng Apr 18, 2026
1f61bac
fix(dashboard): add cooling to force simulation — graph settles after…
kevintseng Apr 18, 2026
3de124c
fix: address code review findings — staleEntities SQL + type safety
kevintseng Apr 18, 2026
0c02013
fix: eliminate all `as any` type casts in dashboard
kevintseng Apr 18, 2026
aac394b
fix: address Codex review findings — datetime, hit-testing, coverage
kevintseng Apr 18, 2026
4e31323
chore: add PRE_RELEASE_CHECKLIST.md to .gitignore
kevintseng Apr 19, 2026
2f2730c
Merge branch 'main' into develop
kevintseng Apr 19, 2026
15670fe
feat(api): add /v1/patterns endpoint for user work pattern analysis
kevintseng Apr 19, 2026
be3e2d1
feat(dashboard): add Your Patterns section to Analytics with i18n
kevintseng Apr 19, 2026
684d1a1
feat: add user patterns analysis — MCP tool + dashboard + API
kevintseng Apr 19, 2026
16d29ae
docs: add user_patterns tool and /v1/patterns endpoint (8 MCP tools, …
kevintseng Apr 19, 2026
55b29bb
fix: extract shared patterns computation, fix error handling, add tests
kevintseng Apr 19, 2026
1c02b66
fix: address Codex review — session types, AUTO_TYPES, localtime
kevintseng Apr 19, 2026
ca5c6d9
fix(security): parameterize SQL, add security headers, update vulnera…
kevintseng Apr 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 51 additions & 32 deletions dashboard/src/components/AnalyticsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,71 +1,90 @@
import { useState, useEffect, useCallback } from 'preact/hooks';
import { api, type StatsData, type AnalyticsData } from '../lib/api';
import { api, type StatsData, type AnalyticsData, type PatternsData } from '../lib/api';
import { HealthScore } from './HealthScore';
import { MemoryTimeline } from './MemoryTimeline';
import { ValueMetrics } from './ValueMetrics';
import { CleanupSuggestions } from './CleanupSuggestions';
import { UserPatterns } from './UserPatterns';
import { t } from '../lib/i18n';

export function AnalyticsTab() {
const [stats, setStats] = useState<StatsData | null>(null);
const [analytics, setAnalytics] = useState<AnalyticsData | null>(null);
const [patterns, setPatterns] = useState<PatternsData | null>(null);
const [loading, setLoading] = useState(true);

const loadData = useCallback(() => {
setLoading(true);
Promise.all([
api<StatsData>('GET', '/v1/stats'),
api<AnalyticsData>('GET', '/v1/analytics'),
]).then(([s, a]) => {
api<StatsData>('GET', '/v1/stats').catch(() => null),
api<AnalyticsData>('GET', '/v1/analytics').catch(() => null),
api<PatternsData>('GET', '/v1/patterns').catch(() => null),
]).then(([s, a, p]) => {
setStats(s);
setAnalytics(a);
setPatterns(p);
}).finally(() => setLoading(false));
}, []);

useEffect(() => { loadData(); }, [loadData]);

if (loading) return <div class="empty"><div class="loading" /></div>;
if (!stats || !analytics) return <div class="error-box">{t('common.error')}: Failed to load analytics</div>;
if (!stats && !analytics) return <div class="error-box">{t('common.error')}: Failed to load analytics</div>;

return (
<div>
{/* Row 1: Stats overview */}
<div class="stats-row">
<div class="stat"><div class="stat-val">{stats.totalEntities.toLocaleString()}</div><div class="stat-lbl">{t('analytics.totalMemories')}</div></div>
<div class="stat"><div class="stat-val">{stats.totalObservations.toLocaleString()}</div><div class="stat-lbl">{t('analytics.knowledgeFacts')}</div></div>
<div class="stat"><div class="stat-val">{stats.totalRelations.toLocaleString()}</div><div class="stat-lbl">{t('analytics.connections')}</div></div>
<div class="stat"><div class="stat-val">{stats.totalTags.toLocaleString()}</div><div class="stat-lbl">{t('analytics.topics')}</div></div>
</div>
{stats && (
<div class="stats-row">
<div class="stat"><div class="stat-val">{stats.totalEntities.toLocaleString()}</div><div class="stat-lbl">{t('analytics.totalMemories')}</div></div>
<div class="stat"><div class="stat-val">{stats.totalObservations.toLocaleString()}</div><div class="stat-lbl">{t('analytics.knowledgeFacts')}</div></div>
<div class="stat"><div class="stat-val">{stats.totalRelations.toLocaleString()}</div><div class="stat-lbl">{t('analytics.connections')}</div></div>
<div class="stat"><div class="stat-val">{stats.totalTags.toLocaleString()}</div><div class="stat-lbl">{t('analytics.topics')}</div></div>
</div>
)}

{/* Row 2: Health Score */}
<HealthScore score={analytics.healthScore} factors={analytics.healthFactors} />
{analytics && <HealthScore score={analytics.healthScore} factors={analytics.healthFactors} />}

{/* Row 3: Memory Timeline */}
<div style={{ marginTop: 8 }}>
<MemoryTimeline data={analytics.timeline} />
</div>
{analytics && (
<div style={{ marginTop: 8 }}>
<MemoryTimeline data={analytics.timeline} />
</div>
)}

{/* Row 4: Value Metrics */}
<div style={{ marginTop: 8 }}>
<ValueMetrics
totalRecalls={analytics.valueMetrics.totalRecalls}
lessonsWithWarnings={analytics.valueMetrics.lessonsWithWarnings}
lessonCount={analytics.valueMetrics.lessonCount}
typeDistribution={analytics.valueMetrics.typeDistribution}
/>
</div>
{analytics && (
<div style={{ marginTop: 8 }}>
<ValueMetrics
totalRecalls={analytics.valueMetrics.totalRecalls}
lessonsWithWarnings={analytics.valueMetrics.lessonsWithWarnings}
lessonCount={analytics.valueMetrics.lessonCount}
typeDistribution={analytics.valueMetrics.typeDistribution}
/>
</div>
)}

{/* Row 5: Cleanup Suggestions */}
<div style={{ marginTop: 8 }}>
<CleanupSuggestions
staleEntities={analytics.cleanup.staleEntities}
duplicateCandidates={analytics.cleanup.duplicateCandidates}
onRefresh={loadData}
/>
</div>
{analytics && (
<div style={{ marginTop: 8 }}>
<CleanupSuggestions
staleEntities={analytics.cleanup.staleEntities}
duplicateCandidates={analytics.cleanup.duplicateCandidates}
onRefresh={loadData}
/>
</div>
)}

{/* Row 6: Topics cloud (kept from original) */}
{(() => {
{/* Row 6: User Patterns */}
{patterns && (
<div style={{ marginTop: 8 }}>
<UserPatterns data={patterns} />
</div>
)}

{/* Row 7: Topics cloud (kept from original) */}
{stats && (() => {
const internalPrefixes = ['auto_saved', 'auto-tracked', 'session_end', 'session:', 'source:', 'scope:', 'date:', 'urgency:'];
const userTags = stats.tagDistribution.filter(tg =>
!internalPrefixes.some(p => tg.tag.startsWith(p)) &&
Expand Down
239 changes: 239 additions & 0 deletions dashboard/src/components/UserPatterns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import type { PatternsData } from '../lib/api';
import { t } from '../lib/i18n';

interface Props {
data: PatternsData;
}

export function UserPatterns({ data }: Props) {
const { workSchedule, toolPreferences, focusAreas, workflow, strengths, learningAreas } = data;

// Build hour heatmap data (0-23)
const hourMap = new Map<number, number>();
for (const h of workSchedule.hourDistribution) hourMap.set(h.hour, h.count);
const maxHourCount = Math.max(1, ...workSchedule.hourDistribution.map((h) => h.count));

// Find peak hours (top 3)
const peakHours = [...workSchedule.hourDistribution]
.sort((a, b) => b.count - a.count)
.slice(0, 3)
.map((h) => `${h.hour.toString().padStart(2, '0')}:00`);

// Find busiest days (top 2)
const busiestDays = [...workSchedule.dayDistribution]
.sort((a, b) => b.count - a.count)
.slice(0, 2)
.map((d) => d.day);

return (
<div class="card">
<div class="card-title">{t('patterns.title')}</div>

{/* Work Schedule Heatmap */}
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text-3)',
textTransform: 'uppercase',
letterSpacing: '.06em',
marginBottom: 8,
}}>
{t('patterns.workSchedule')}
</div>
<div style={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{Array.from({ length: 24 }, (_, hour) => {
const count = hourMap.get(hour) || 0;
const intensity = count / maxHourCount;
return (
<div
key={hour}
title={`${hour.toString().padStart(2, '0')}:00 — ${count}`}
style={{
width: 18,
height: 18,
borderRadius: 'var(--radius-xs)',
background: intensity > 0
? `rgba(0, 214, 180, ${0.1 + intensity * 0.7})`
: 'var(--bg-0)',
border: '1px solid var(--border-subtle)',
fontSize: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: intensity > 0.5 ? 'var(--bg-0)' : 'var(--text-3)',
fontFamily: 'var(--mono)',
}}
>
{hour}
</div>
);
})}
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6, fontFamily: 'var(--mono)' }}>
{t('patterns.peakHours')}: {peakHours.join(', ')}
{busiestDays.length > 0 && (
<span> · {t('patterns.busiestDays')}: {busiestDays.join(', ')}</span>
)}
</div>
</div>

{/* Top Tools */}
{toolPreferences.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text-3)',
textTransform: 'uppercase',
letterSpacing: '.06em',
marginBottom: 8,
}}>
{t('patterns.tools')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{toolPreferences.slice(0, 10).map((tp) => (
<span
key={tp.tool}
class="tag"
style={{ fontSize: 11, padding: '2px 8px' }}
>
{tp.tool} <span style={{ opacity: 0.5 }}>({tp.sessions})</span>
</span>
))}
</div>
</div>
)}

{/* Workflow Stats */}
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text-3)',
textTransform: 'uppercase',
letterSpacing: '.06em',
marginBottom: 8,
}}>
{t('patterns.workflow')}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div class="stat" style={{ padding: 12 }}>
<div class="stat-val" style={{ fontSize: 18 }}>
{workflow.avgSessionMinutes > 0 ? `${Math.round(workflow.avgSessionMinutes)}m` : '—'}
</div>
<div class="stat-lbl">{t('patterns.avgSession')}</div>
</div>
<div class="stat" style={{ padding: 12 }}>
<div class="stat-val" style={{ fontSize: 18 }}>
{workflow.totalSessions > 0 ? workflow.commitsPerSession.toFixed(1) : '—'}
</div>
<div class="stat-lbl">{t('patterns.commitsPerSession')}</div>
</div>
</div>
</div>

{/* Focus Areas */}
{focusAreas.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text-3)',
textTransform: 'uppercase',
letterSpacing: '.06em',
marginBottom: 8,
}}>
{t('patterns.focus')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{focusAreas.slice(0, 12).map((fa) => (
<span
key={fa.type}
class="tag"
style={{ fontSize: 11, padding: '2px 8px' }}
>
{fa.type} <span style={{ opacity: 0.5 }}>({fa.count})</span>
</span>
))}
</div>
</div>
)}

{/* Strengths & Learning Areas */}
{(strengths.length > 0 || learningAreas.length > 0) && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{/* Strengths */}
<div>
<div style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text-3)',
textTransform: 'uppercase',
letterSpacing: '.06em',
marginBottom: 8,
}}>
{t('patterns.strengths')}
</div>
{strengths.slice(0, 5).map((s) => (
<div key={s.type} style={{ marginBottom: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3 }}>
<span style={{ fontSize: 12, color: 'var(--text-1)' }}>{s.type}</span>
<span style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--accent)' }}>
{Math.round(s.avgConfidence * 100)}%
</span>
</div>
<div style={{ height: 3, borderRadius: 2, background: 'var(--bg-0)' }}>
<div style={{
height: '100%',
width: `${Math.round(s.avgConfidence * 100)}%`,
borderRadius: 2,
background: 'rgba(0, 214, 180, 0.5)',
transition: 'width 600ms ease-out',
}} />
</div>
</div>
))}
{strengths.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--text-3)', fontStyle: 'italic' }}>—</div>
)}
</div>

{/* Learning Areas */}
<div>
<div style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text-3)',
textTransform: 'uppercase',
letterSpacing: '.06em',
marginBottom: 8,
}}>
{t('patterns.learning')}
</div>
{learningAreas.slice(0, 5).map((la) => (
<div
key={la.tag}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
fontSize: 12,
}}
>
<span style={{ color: 'var(--text-1)' }}>{la.tag}</span>
<span style={{ fontFamily: 'var(--mono)', fontSize: 11, color: 'var(--text-3)' }}>
{la.count}
</span>
</div>
))}
{learningAreas.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--text-3)', fontStyle: 'italic' }}>—</div>
)}
</div>
</div>
)}
</div>
);
}
12 changes: 12 additions & 0 deletions dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ export interface AnalyticsData {
};
}

export interface PatternsData {
workSchedule: {
hourDistribution: Array<{ hour: number; count: number }>;
dayDistribution: Array<{ day: string; dayNum: number; count: number }>;
};
toolPreferences: Array<{ tool: string; sessions: number }>;
focusAreas: Array<{ type: string; count: number }>;
workflow: { avgSessionMinutes: number; commitsPerSession: number; totalSessions: number; totalCommits: number };
strengths: Array<{ type: string; avgConfidence: number; count: number }>;
learningAreas: Array<{ tag: string; count: number }>;
}

export interface GraphData {
entities: Entity[];
relations: Array<{ from: string; to: string; type: string }>;
Expand Down
Loading
Loading