diff --git a/.gitignore b/.gitignore
index 6333616a..c0d7393d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -92,3 +92,7 @@ docs/guides/
# === AI tool caches ===
.codex/
+.gstack/
+
+# === Local project docs (not shipped) ===
+PRE_RELEASE_CHECKLIST.md
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 00000000..7fbf2733
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,172 @@
+# Design System — MeMesh
+
+## Product Context
+- **What this is:** Universal AI memory layer. SQLite-based knowledge graph with FTS5 + neural embeddings.
+- **Who it's for:** Developers using AI coding assistants (Claude Code, etc.)
+- **Space/industry:** AI development tools, knowledge management
+- **Project type:** Developer dashboard (7-tab data tool) + marketing showcase
+
+## Aesthetic Direction
+- **Direction:** Precision Engineer
+- **Decoration level:** Minimal — no grain, no glow, no blur. Clean borders and color do all the work.
+- **Mood:** Professional, trustworthy, sharp. The tool that makes you feel like an expert. Every pixel has a purpose.
+- **Reference sites:** Linear (gold standard for dev tools), Warp (terminal aesthetic), OpenMemory (competitor)
+
+## Typography
+- **Display/Hero:** Satoshi 700 — geometric but warm, sharper than Inter, better character at small sizes. letter-spacing: -0.03em
+- **Body:** Satoshi 400/500 — same family throughout, weight differentiates hierarchy
+- **UI/Labels:** Satoshi 600 — uppercase, letter-spacing: 0.04em for section labels; 0.08em for smallest labels
+- **Data/Tables:** Geist Mono 400/500 — Vercel-made, tabular-nums native, pairs with Satoshi's geometry
+- **Code:** Geist Mono 400
+- **Loading:** Google Fonts CDN (`family=Satoshi:wght@400;500;600;700` + `family=Geist+Mono:wght@400;500;600`)
+- **Scale:**
+ - Hero: 32px / 700 / -0.03em
+ - Title: 24px / 700 / -0.02em
+ - Heading: 18px / 600 / -0.01em
+ - Subheading: 15px / 600
+ - Body: 14px / 400 / line-height 1.55
+ - Small: 13px / 400
+ - Caption: 12px / 500
+ - Micro: 11px / 400
+ - Label: 10px / 600 / uppercase / 0.08em
+
+## Color
+
+### Dark Mode (default)
+- **Approach:** Restrained — one accent + neutrals. Color is rare and meaningful.
+
+| Token | Value | Usage |
+|-------|-------|-------|
+| `--bg-0` | `#080A0C` | Page background |
+| `--bg-1` | `#0D1014` | Header, nav, modals |
+| `--bg-2` | `#14181D` | Cards, inputs, stat blocks |
+| `--bg-card` | `rgba(20,24,29,0.9)` | Memory cards (slight transparency) |
+| `--bg-hover` | `#1A1F26` | Hover states |
+| `--bg-input` | `#0D1014` | Input fields |
+| `--border` | `rgba(0,214,180,0.08)` | Default borders |
+| `--border-hover` | `rgba(0,214,180,0.20)` | Active/hover borders |
+| `--border-focus` | `#00D6B4` | Focus ring |
+| `--border-subtle` | `rgba(0,214,180,0.04)` | Table row separators |
+| `--text-0` | `#F0F2F4` | Primary text, headings |
+| `--text-1` | `#B8BEC6` | Body text |
+| `--text-2` | `#7A828E` | Secondary, metadata |
+| `--text-3` | `#4A5260` | Muted, placeholders, labels |
+| `--accent` | `#00D6B4` | Primary accent (cyan/teal) |
+| `--accent-soft` | `rgba(0,214,180,0.08)` | Accent backgrounds |
+| `--accent-hover` | `#00F0CA` | Accent hover state |
+| `--accent-dim` | `#009E86` | Accent on light backgrounds |
+| `--success` | `#00D6B4` | Same as accent |
+| `--success-soft` | `rgba(0,214,180,0.08)` | Success backgrounds |
+| `--danger` | `#FF6B6B` | Errors, destructive actions |
+| `--danger-soft` | `rgba(255,107,107,0.08)` | Danger backgrounds |
+| `--warning` | `#FFB84D` | Warnings, stale indicators |
+| `--warning-soft` | `rgba(255,184,77,0.08)` | Warning backgrounds |
+| `--info` | `#60A5FA` | Informational |
+| `--info-soft` | `rgba(96,165,250,0.08)` | Info backgrounds |
+
+### Light Mode
+- **Strategy:** Reduce accent saturation, darken for contrast. Backgrounds flip to warm whites.
+
+| Token | Value |
+|-------|-------|
+| `--bg-0` | `#F8F9FA` |
+| `--bg-1` | `#FFFFFF` |
+| `--bg-2` | `#F0F1F3` |
+| `--bg-hover` | `#E8EAED` |
+| `--text-0` | `#111317` |
+| `--text-1` | `#3D4450` |
+| `--text-2` | `#6B7280` |
+| `--text-3` | `#9CA3AF` |
+| `--accent` | `#009E86` |
+| `--danger` | `#DC2626` |
+| `--warning` | `#D97706` |
+| `--info` | `#2563EB` |
+
+## Spacing
+- **Base unit:** 4px
+- **Density:** Compact — developers don't like wasted space
+- **Scale:**
+ - `--sp-1`: 2px
+ - `--sp-2`: 4px
+ - `--sp-3`: 8px
+ - `--sp-4`: 12px
+ - `--sp-5`: 16px
+ - `--sp-6`: 20px
+ - `--sp-7`: 24px
+ - `--sp-8`: 32px
+ - `--sp-9`: 48px
+
+## Layout
+- **Approach:** Grid-disciplined — strict columns, predictable alignment
+- **Max content width:** 1100px
+- **Page padding:** 24px
+- **Grid:** 4-column for stats, auto-fit for responsive
+- **Border radius:**
+ - `--radius-xs`: 4px (tags, small elements)
+ - `--radius-sm`: 6px (inputs, buttons)
+ - `--radius`: 8px (cards, panels)
+ - `--radius-full`: 9999px (badges, pills)
+
+## Motion
+- **Approach:** Minimal-functional — only transitions, no animations except loading spinner
+- **Easing:** `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out for all interactions)
+- **Duration:** 150ms universal. Loading spinner: 600ms linear.
+- **What transitions:** border-color, background, color, opacity, box-shadow
+- **What does NOT animate:** layout, position, size (no entrance animations, no scroll effects)
+
+## Component Patterns
+
+### Buttons
+- **Primary:** solid accent bg, dark text. Hover: lighten.
+- **Secondary:** bg-2, border, text-1. Hover: border darkens, bg shifts.
+- **Ghost:** transparent, text-2. Hover: bg-hover.
+- **Danger:** transparent, danger text, danger border at 20%. Hover: danger-soft bg.
+- **Sizes:** sm (5px 10px, 11px), default (9px 16px, 12px), lg (12px 24px, 14px)
+
+### Inputs
+- Focus: accent border, no box-shadow glow (clean, not flashy)
+- Error: danger border
+- Disabled: 40% opacity
+- Select: custom chevron SVG, no native appearance
+
+### Cards (Memory entities)
+- bg-card with 1px border. Hover: border-hover.
+- Head: entity name (600, text-0) + type badge (mono, accent on accent-soft)
+- Body: observation text (13px, text-1)
+- Footer: tags (mono, 10px) + score (mono, accent, right-aligned)
+
+### Table
+- Sticky header: bg-2, uppercase 10px labels
+- Row hover: bg-hover
+- Mono columns for numeric data (tabular-nums)
+- Last row: no bottom border
+
+### Badges
+- Pill shape (radius-full), 11px font, 500 weight
+- Color variants: accent, success, danger, warning, info, neutral
+
+### Tags
+- Small pills (radius-xs), 10px mono, bg-2 with subtle border
+- For entity categorization, not status
+
+## Font Blacklist
+Never use as primary: Inter, Roboto, Arial, Helvetica, Open Sans, Lato, Montserrat, Poppins
+
+## Anti-patterns
+- No purple/violet gradients
+- No backdrop-filter blur (except modals overlay if needed)
+- No grain or noise textures
+- No glow effects on hover
+- No entrance animations
+- No centered stat cards (left-align values)
+- No decorative elements that don't serve function
+
+## Decisions Log
+| Date | Decision | Rationale |
+|------|----------|-----------|
+| 2026-04-18 | Initial design system: Precision Engineer | Competitive research showed all AI tools converge on zinc+blue+Inter. Cyan accent + Satoshi differentiates while staying professional. |
+| 2026-04-18 | Chose Satoshi over Inter | Inter is the most overused font in developer tools. Satoshi has similar geometric bones but sharper, better weight distribution at small sizes. |
+| 2026-04-18 | Chose Geist Mono over JetBrains Mono | Better tabular-nums support, pairs with Satoshi's geometry. Vercel ecosystem alignment. |
+| 2026-04-18 | Cyan #00D6B4 over blue #3b82f6 | 90% of dark dashboards use blue-500. Cyan is more distinctive, evokes graph/data visualization, better contrast on dark backgrounds. |
+| 2026-04-18 | 150ms universal transition | Fast enough to feel instant, slow enough to be perceived. No per-component timing variations. |
+| 2026-04-18 | Compact 4px spacing | Target users are developers who prefer data density over whitespace. |
diff --git a/dashboard/index.html b/dashboard/index.html
index cd9ac2f9..fe887d40 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -6,7 +6,9 @@
MeMesh LLM Memory — Dashboard
-
+
+
+
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index 6839a8c2..6d475f01 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -7,6 +7,7 @@ import { AnalyticsTab } from './components/AnalyticsTab';
import { SettingsTab } from './components/SettingsTab';
import { GraphTab } from './components/GraphTab';
import { LessonsTab } from './components/LessonsTab';
+import { FeedbackWidget } from './components/FeedbackWidget';
import { api, type HealthData } from './lib/api';
import { initLocale, t } from './lib/i18n';
@@ -52,12 +53,7 @@ export function App() {
{tab === 'Manage' && }
{tab === 'Settings' && }
-
+
);
}
diff --git a/dashboard/src/components/AnalyticsTab.tsx b/dashboard/src/components/AnalyticsTab.tsx
index d4651eff..87f572ed 100644
--- a/dashboard/src/components/AnalyticsTab.tsx
+++ b/dashboard/src/components/AnalyticsTab.tsx
@@ -1,44 +1,35 @@
-import { useState, useEffect } from 'preact/hooks';
-import { api, type StatsData, type Entity } from '../lib/api';
+import { useState, useEffect, useCallback } from 'preact/hooks';
+import { api, type StatsData, type AnalyticsData } from '../lib/api';
+import { HealthScore } from './HealthScore';
+import { MemoryTimeline } from './MemoryTimeline';
+import { ValueMetrics } from './ValueMetrics';
+import { CleanupSuggestions } from './CleanupSuggestions';
import { t } from '../lib/i18n';
export function AnalyticsTab() {
const [stats, setStats] = useState(null);
- const [entities, setEntities] = useState([]);
+ const [analytics, setAnalytics] = useState(null);
const [loading, setLoading] = useState(true);
- useEffect(() => {
+ const loadData = useCallback(() => {
+ setLoading(true);
Promise.all([
api('GET', '/v1/stats'),
- api('GET', '/v1/entities?limit=500&status=all'),
- ]).then(([s, e]) => {
+ api('GET', '/v1/analytics'),
+ ]).then(([s, a]) => {
setStats(s);
- setEntities(e || []);
+ setAnalytics(a);
}).finally(() => setLoading(false));
}, []);
- if (loading) return ;
- if (!stats) return {t('common.error')}: Failed to load analytics
;
-
- const now = Date.now();
- const weekMs = 7 * 24 * 60 * 60 * 1000;
- const monthMs = 30 * 24 * 60 * 60 * 1000;
+ useEffect(() => { loadData(); }, [loadData]);
- const thisWeek = entities.filter((e) => now - new Date(e.created_at).getTime() < weekMs).length;
- const stale = entities.filter((e) => {
- if (e.archived || e.status === 'archived') return false;
- if (!e.last_accessed_at) return true;
- return now - new Date(e.last_accessed_at).getTime() > monthMs;
- }).length;
- const archivedCount = entities.filter((e) => e.archived || e.status === 'archived').length;
- const topRecalled = entities
- .filter((e) => (e.access_count || 0) > 0)
- .sort((a, b) => (b.access_count || 0) - (a.access_count || 0))
- .slice(0, 5);
+ if (loading) return ;
+ if (!stats || !analytics) return {t('common.error')}: Failed to load analytics
;
return (
- {/* Stats row */}
+ {/* Row 1: Stats overview */}
{stats.totalEntities.toLocaleString()}
{t('analytics.totalMemories')}
{stats.totalObservations.toLocaleString()}
{t('analytics.knowledgeFacts')}
@@ -46,59 +37,42 @@ export function AnalyticsTab() {
{stats.totalTags.toLocaleString()}
{t('analytics.topics')}
- {/* Insights */}
-
-
{t('analytics.insights')}
-
- 📝
- {t('analytics.thisWeek')}
- {thisWeek} {t('analytics.new')}
-
-
- 💤
- {t('analytics.stale')}
- {stale}
-
-
- 📦
- {t('analytics.archivedLabel')}
- {archivedCount}
-
+ {/* Row 2: Health Score */}
+
+
+ {/* Row 3: Memory Timeline */}
+
+
+
+
+ {/* Row 4: Value Metrics */}
+
+
- {/* Top recalled — show meaningful preview, not raw commit hashes */}
- {topRecalled.length > 0 && (
-
-
{t('analytics.mostRecalled')}
- {topRecalled.map((e, i) => {
- // Find the most meaningful observation (skip raw commit/session metadata)
- const skipPrefixes = ['Commit:', '[SESSION]', 'Branch:', 'Diff stats:', 'Details:', '[WORK]', '[FOCUS]', '[SUMMARY]', 'Session edited', 'Total tool calls', 'Duration:'];
- const preview = (e.observations || []).find(o =>
- !skipPrefixes.some(p => o.startsWith(p)) && o.length > 10
- ) || e.observations?.[0] || e.name;
- const truncated = preview.length > 80 ? preview.slice(0, 80) + '…' : preview;
- return (
-
- #{i + 1}
-
- {truncated}
-
- {e.access_count}×
-
- );
- })}
-
- )}
+ {/* Row 5: Cleanup Suggestions */}
+
+
+
- {/* Topics — filter out internal/system tags, show only user-meaningful ones */}
+ {/* Row 6: Topics cloud (kept from original) */}
{(() => {
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)) &&
- !/^\d{4}-\d{2}-\d{2}/.test(tg.tag) // filter date-only tags like "2026-03-26"
+ !/^\d{4}-\d{2}-\d{2}/.test(tg.tag)
);
return userTags.length > 0 ? (
-
+
{t('analytics.topics')}
{userTags.slice(0, 30).map((tg) => (
diff --git a/dashboard/src/components/CleanupSuggestions.tsx b/dashboard/src/components/CleanupSuggestions.tsx
new file mode 100644
index 00000000..f8c7f2c5
--- /dev/null
+++ b/dashboard/src/components/CleanupSuggestions.tsx
@@ -0,0 +1,144 @@
+import { useState } from 'preact/hooks';
+import { api } from '../lib/api';
+import { t } from '../lib/i18n';
+
+interface StaleEntity {
+ id: number;
+ name: string;
+ type: string;
+ confidence: number;
+ days_unused: number;
+}
+
+interface Props {
+ staleEntities: StaleEntity[];
+ duplicateCandidates: Array<{ name1: string; name2: string; type: string }>;
+ onRefresh: () => void;
+}
+
+export function CleanupSuggestions({ staleEntities, duplicateCandidates, onRefresh }: Props) {
+ const [archiving, setArchiving] = useState
>(() => new Set());
+
+ async function handleArchive(entity: StaleEntity) {
+ setArchiving((prev) => new Set(prev).add(entity.id));
+ try {
+ await api('POST', '/v1/forget', { name: entity.name });
+ onRefresh();
+ } catch {
+ // Remove from archiving set on failure so button re-enables
+ setArchiving((prev) => {
+ const next = new Set(prev);
+ next.delete(entity.id);
+ return next;
+ });
+ }
+ }
+
+ const isEmpty = staleEntities.length === 0 && duplicateCandidates.length === 0;
+
+ return (
+
+ {isEmpty && (
+
+ {"✓"}
+ {t('cleanup.allClean')}
+
+ )}
+
+ {staleEntities.length > 0 && (
+
+
+ {t('cleanup.staleLabel')}
+
+ {staleEntities.map((entity) => (
+
+
+
+ {entity.name}
+
+
+ {entity.type} · confidence {Math.round(entity.confidence * 100)}% · {entity.days_unused}d unused
+
+
+
+
+ ))}
+
+ )}
+
+ {duplicateCandidates.length > 0 && (
+
0 ? 16 : 0 }}>
+
+ {t('cleanup.duplicateLabel')}
+
+ {duplicateCandidates.map((dup, i) => (
+
+
+ {dup.name1}
+
+ ↔
+
+ {dup.name2}
+
+ {dup.type}
+
+ ))}
+
+ {t('cleanup.consolidateHint')}
+
+
+ )}
+
+ );
+}
diff --git a/dashboard/src/components/FeedbackWidget.tsx b/dashboard/src/components/FeedbackWidget.tsx
new file mode 100644
index 00000000..d0e90c91
--- /dev/null
+++ b/dashboard/src/components/FeedbackWidget.tsx
@@ -0,0 +1,89 @@
+import { useState, useRef, useEffect } from 'preact/hooks';
+import { t } from '../lib/i18n';
+import type { HealthData } from '../lib/api';
+
+const TYPES = ['bug', 'feature', 'question'] as const;
+type FeedbackType = typeof TYPES[number];
+
+const TYPE_I18N_KEYS: Record = {
+ bug: 'feedback.bug',
+ feature: 'feedback.feature',
+ question: 'feedback.question',
+};
+
+export function FeedbackWidget({ health }: { health: HealthData | null }) {
+ const [open, setOpen] = useState(false);
+ const [fbType, setFbType] = useState('bug');
+ const [desc, setDesc] = useState('');
+ const [includeSys, setIncludeSys] = useState(true);
+ const panelRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e: MouseEvent) => {
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, [open]);
+
+ const submit = () => {
+ if (!desc.trim()) return;
+ const labels = `feedback,from-dashboard,${fbType}`;
+ let body = desc.trim();
+ if (includeSys && health) {
+ body += `\n\n---\n**System Info**\n- Version: ${health.version}\n- Entities: ${health.entity_count}\n- Platform: ${navigator.platform}\n- User Agent: ${navigator.userAgent}`;
+ }
+ const typeLabel = t(TYPE_I18N_KEYS[fbType]);
+ const url = `https://github.com/PCIRCLE-AI/memesh-llm-memory/issues/new?title=${encodeURIComponent(`[${typeLabel}] `)}&body=${encodeURIComponent(body)}&labels=${encodeURIComponent(labels)}`;
+ window.open(url, '_blank');
+ setDesc('');
+ setOpen(false);
+ };
+
+ return (
+ <>
+
+ {open && (
+
+
{t('feedback.title')}
+
+ {TYPES.map((type) => (
+
+ ))}
+
+
+ )}
+ >
+ );
+}
diff --git a/dashboard/src/components/GraphTab.tsx b/dashboard/src/components/GraphTab.tsx
index 19f85631..34ee89ef 100644
--- a/dashboard/src/components/GraphTab.tsx
+++ b/dashboard/src/components/GraphTab.tsx
@@ -1,8 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'preact/hooks';
-import { fetchGraph, type GraphData } from '../lib/api';
+import { fetchGraph, type GraphData, type Entity } from '../lib/api';
import { t } from '../lib/i18n';
-interface Node {
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+interface GNode {
id: string;
type: string;
x: number;
@@ -10,61 +14,149 @@ interface Node {
vx: number;
vy: number;
radius: number;
+ recency: number; // 0.15–1.0
+ isOrphan: boolean;
+ lastDate: string; // ISO string for tooltip age
}
-interface Edge {
+interface GEdge {
from: string;
to: string;
type: string;
}
+/* ------------------------------------------------------------------ */
+/* Constants */
+/* ------------------------------------------------------------------ */
+
const TYPE_COLORS: Record = {
- decision: '#3b82f6',
- pattern: '#22c55e',
- lesson_learned: '#f97316',
- commit: '#8b5cf6',
- 'session-insight': '#6b7280',
+ decision: '#00D6B4',
+ pattern: '#60A5FA',
+ lesson_learned: '#FFB84D',
+ commit: '#A78BFA',
+ 'session-insight': '#7A828E',
+ session_keypoint: '#4ADE80',
+ session_identity: '#F472B6',
+ workflow_checkpoint: '#38BDF8',
+ feature: '#FB923C',
+ bug_fix: '#F87171',
+ concept: '#00D6B4',
+ tool: '#818CF8',
+ person: '#E879F9',
+ note: '#94A3B8',
};
-const DEFAULT_COLOR = '#94a3b8';
+const DEFAULT_COLOR = '#B8BEC6';
function getColor(type: string): string {
return TYPE_COLORS[type] || DEFAULT_COLOR;
}
+/** Compute recency (0.15–1.0) from a date string. */
+function computeRecency(dateStr: string | undefined): number {
+ if (!dateStr) return 0.15;
+ const ageMs = Date.now() - new Date(dateStr).getTime();
+ return Math.max(0.15, 1 - Math.min(1, ageMs / (30 * 86400000)));
+}
+
+/** Format age for tooltip: "today", "3d ago", "2w ago", "45d ago". */
+function formatAge(dateStr: string): string {
+ const ageMs = Date.now() - new Date(dateStr).getTime();
+ const days = Math.floor(ageMs / 86400000);
+ if (days < 1) return 'today';
+ if (days < 7) return `${days}d ago`;
+ if (days < 30) return `${Math.floor(days / 7)}w ago`;
+ return `${days}d ago`;
+}
+
+declare global {
+ interface Window { __graphReheat?: () => void; }
+}
+
+const CANVAS_HEIGHT = 500;
+const CLICK_THRESHOLD = 4; // px — drag vs click detection
+
+/* ------------------------------------------------------------------ */
+/* Component */
+/* ------------------------------------------------------------------ */
+
export function GraphTab() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
+
+ // UI state
+ const [typeFilters, setTypeFilters] = useState>({});
+ const [searchQuery, setSearchQuery] = useState('');
+ const [egoNodeId, setEgoNodeId] = useState(null);
+
+ // Refs for canvas animation loop
const canvasRef = useRef(null);
- const nodesRef = useRef([]);
- const edgesRef = useRef([]);
+ const nodesRef = useRef([]);
+ const edgesRef = useRef([]);
const animRef = useRef(0);
- const dragRef = useRef<{ node: Node | null; offsetX: number; offsetY: number }>({ node: null, offsetX: 0, offsetY: 0 });
- const hoverRef = useRef(null);
- const tooltipRef = useRef<{ x: number; y: number; node: Node | null }>({ x: 0, y: 0, node: null });
+ const dragRef = useRef<{
+ node: GNode | null;
+ offsetX: number;
+ offsetY: number;
+ startX: number;
+ startY: number;
+ dragged: boolean;
+ }>({ node: null, offsetX: 0, offsetY: 0, startX: 0, startY: 0, dragged: false });
+ const hoverRef = useRef(null);
+ const tooltipRef = useRef<{ x: number; y: number; node: GNode | null }>({
+ x: 0,
+ y: 0,
+ node: null,
+ });
+ const canvasWidthRef = useRef(800);
+
+ // Keep latest state in refs so the animation closure can read them
+ const typeFiltersRef = useRef(typeFilters);
+ const searchQueryRef = useRef(searchQuery);
+ const egoNodeIdRef = useRef(egoNodeId);
+ useEffect(() => { typeFiltersRef.current = typeFilters; }, [typeFilters]);
+ useEffect(() => { searchQueryRef.current = searchQuery; }, [searchQuery]);
+ useEffect(() => { egoNodeIdRef.current = egoNodeId; }, [egoNodeId]);
+ /* ----- data fetch ----- */
useEffect(() => {
fetchGraph()
- .then(setData)
+ .then((d) => {
+ setData(d);
+ // Init type filters: all checked
+ const types: Record = {};
+ d.entities.forEach((e) => { types[e.type] = true; });
+ setTypeFilters(types);
+ })
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
- // Build nodes and edges when data arrives
+ /* ----- build graph & start simulation ----- */
useEffect(() => {
- if (!data) return;
+ if (!data || loading) return;
const canvas = canvasRef.current;
if (!canvas) return;
const w = canvas.parentElement?.clientWidth || 800;
- const h = 500;
- canvas.width = w * window.devicePixelRatio;
- canvas.height = h * window.devicePixelRatio;
+ const h = CANVAS_HEIGHT;
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
+ canvasWidthRef.current = w;
+
+ // Build node set + compute connected set for orphan detection
+ const connectedNodes = new Set();
+ data.relations.forEach((r) => {
+ connectedNodes.add(r.from);
+ connectedNodes.add(r.to);
+ });
- const nodeMap = new Map();
- data.entities.forEach((e) => {
+ const nodeMap = new Map();
+ data.entities.forEach((e: Entity) => {
+ const lastDate = e.last_accessed_at || e.created_at;
nodeMap.set(e.name, {
id: e.name,
type: e.type,
@@ -73,32 +165,84 @@ export function GraphTab() {
vx: 0,
vy: 0,
radius: 6,
+ recency: computeRecency(lastDate),
+ isOrphan: !connectedNodes.has(e.name),
+ lastDate,
});
});
nodesRef.current = Array.from(nodeMap.values());
- edgesRef.current = data.relations.filter((r) => nodeMap.has(r.from) && nodeMap.has(r.to));
+ edgesRef.current = data.relations.filter(
+ (r) => nodeMap.has(r.from) && nodeMap.has(r.to),
+ );
+
+ /* ---------- visibility helpers (read from refs) ---------- */
+ const isNodeVisible = (n: GNode): boolean => {
+ const filters = typeFiltersRef.current;
+ if (filters[n.type] === false) return false;
+
+ const egoId = egoNodeIdRef.current;
+ if (egoId) {
+ if (n.id === egoId) return true;
+ // 1-degree neighbor?
+ const isNeighbor = edgesRef.current.some(
+ (e) =>
+ (e.from === egoId && e.to === n.id) ||
+ (e.to === egoId && e.from === n.id),
+ );
+ return isNeighbor;
+ }
+ return true;
+ };
+
+ const isEdgeVisible = (e: GEdge): boolean => {
+ const fromNode = nodeMap.get(e.from);
+ const toNode = nodeMap.get(e.to);
+ if (!fromNode || !toNode) return false;
+ return isNodeVisible(fromNode) && isNodeVisible(toNode);
+ };
+
+ const isSearchMatch = (n: GNode): boolean => {
+ const q = searchQueryRef.current.toLowerCase();
+ if (!q) return false;
+ return n.id.toLowerCase().includes(q);
+ };
+
+ /* ---------- simulation loop ---------- */
+ let alpha = 1.0; // cooling factor: 1.0 = hot, 0 = frozen
+ const alphaDecay = 0.005; // how fast it cools per frame
+ const alphaMin = 0.001; // stop physics below this
+
+ // Expose reheat function for drag/filter changes
+ const reheat = () => { alpha = 0.3; };
+ window.__graphReheat = reheat;
- // Start simulation
const simulate = () => {
const nodes = nodesRef.current;
const edges = edgesRef.current;
- const dpr = window.devicePixelRatio;
const ctx = canvas.getContext('2d');
if (!ctx) return;
+ const curDpr = window.devicePixelRatio || 1;
- // Force simulation step
+ // Cool down
+ alpha = Math.max(alphaMin, alpha - alphaDecay);
+
+ // Physics constants (scaled by alpha)
const damping = 0.85;
- const repulsion = 2000;
+ const repulsion = 2000 * alpha;
const springLen = 80;
- const springK = 0.02;
- const centerForce = 0.005;
+ const springK = 0.02 * alpha;
+ const centerForce = 0.005 * alpha;
+ const largeN = nodes.length > 200;
- // Repulsion between all nodes
+ // Repulsion between ALL nodes (physics runs on full set for stability)
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
- const dist = Math.sqrt(dx * dx + dy * dy) || 1;
+ const distSq = dx * dx + dy * dy;
+ // Performance: skip distant pairs for large graphs
+ if (largeN && distSq > 90000) continue; // 300px
+ const dist = Math.sqrt(distSq) || 1;
const force = repulsion / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
@@ -135,78 +279,165 @@ export function GraphTab() {
n.vy += (cy - n.y) * centerForce;
}
- // Apply velocities with damping
+ // Apply velocities
for (const n of nodes) {
if (dragRef.current.node === n) continue;
n.vx *= damping;
n.vy *= damping;
+ // Freeze when nearly stopped
+ if (Math.abs(n.vx) < 0.01 && Math.abs(n.vy) < 0.01) { n.vx = 0; n.vy = 0; }
n.x += n.vx;
n.y += n.vy;
- // Clamp to bounds
n.x = Math.max(20, Math.min(w - 20, n.x));
n.y = Math.max(20, Math.min(h - 20, n.y));
}
- // Render
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ // --- Auto-center on single search match ---
+ const q = searchQueryRef.current.toLowerCase();
+ if (q) {
+ const matches = nodes.filter((n) => n.id.toLowerCase().includes(q));
+ if (matches.length === 1) {
+ const target = matches[0];
+ const shiftX = cx - target.x;
+ const shiftY = cy - target.y;
+ // Smoothly shift all nodes
+ const ease = 0.05;
+ for (const n of nodes) {
+ n.x += shiftX * ease;
+ n.y += shiftY * ease;
+ }
+ }
+ }
+
+ /* ---------- render ---------- */
+ ctx.setTransform(curDpr, 0, 0, curDpr, 0, 0);
ctx.clearRect(0, 0, w, h);
+ // Collect visible edges
+ const visibleEdges = edges.filter(isEdgeVisible);
+ const showEdgeLabels = visibleEdges.length < 30;
+
// Draw edges
- ctx.lineWidth = 1;
- ctx.strokeStyle = 'rgba(113, 113, 122, 0.3)';
- ctx.font = '9px system-ui, sans-serif';
- ctx.fillStyle = 'rgba(113, 113, 122, 0.5)';
- for (const edge of edges) {
+ for (const edge of visibleEdges) {
const a = nodeById.get(edge.from);
const b = nodeById.get(edge.to);
if (!a || !b) continue;
+ const edgeAlpha = Math.min(a.recency, b.recency) * 0.6;
+ ctx.globalAlpha = edgeAlpha;
+ ctx.strokeStyle = 'rgba(0, 214, 180, 0.4)';
+ ctx.lineWidth = 1;
+ ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
- // Edge label
- const mx = (a.x + b.x) / 2;
- const my = (a.y + b.y) / 2;
- ctx.fillText(edge.type, mx + 2, my - 2);
+
+ if (showEdgeLabels) {
+ const mx = (a.x + b.x) / 2;
+ const my = (a.y + b.y) / 2;
+ ctx.font = '9px Satoshi, system-ui, sans-serif';
+ ctx.fillStyle = '#7A828E';
+ ctx.fillText(edge.type, mx + 2, my - 2);
+ }
}
+ ctx.globalAlpha = 1;
+
+ // Collect visible nodes
+ const visibleNodes = nodes.filter(isNodeVisible);
+
// Draw nodes
- for (const n of nodes) {
- const isHovered = hoverRef.current === n;
- const r = isHovered ? 9 : n.radius;
+ const hoveredNode = hoverRef.current;
+ for (const n of visibleNodes) {
+ const isHovered = hoveredNode === n;
+ const matched = isSearchMatch(n);
+ const isFocusCenter = egoNodeIdRef.current === n.id;
+ const r = isFocusCenter ? 10 : isHovered ? 9 : n.radius;
+
+ // Recency alpha: full for hovered/matched
+ const alpha = isHovered || matched ? 1.0 : n.recency;
+ ctx.globalAlpha = alpha;
+
+ // Node fill
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = getColor(n.type);
ctx.fill();
- if (isHovered) {
+
+ // Orphan dashed border
+ if (n.isOrphan) {
+ ctx.setLineDash([3, 3]);
+ ctx.strokeStyle = '#4A5260';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // Search match glow ring
+ if (matched) {
+ ctx.globalAlpha = 1;
+ ctx.strokeStyle = '#00F0CA';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(n.x, n.y, r + 3, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+
+ // Hover ring
+ if (isHovered && !matched) {
+ ctx.globalAlpha = 1;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.stroke();
}
- // Node label
- ctx.fillStyle = '#d4d4d8';
- ctx.font = '10px system-ui, sans-serif';
- const label = n.id.length > 20 ? n.id.slice(0, 18) + '...' : n.id;
- ctx.fillText(label, n.x + r + 4, n.y + 3);
+
+ // Node labels: only show for hovered, focused, or matched nodes
+ const showLabel = isHovered || matched || isFocusCenter;
+ if (showLabel) {
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = '#B8BEC6';
+ ctx.font = '10px Satoshi, system-ui, sans-serif';
+ const label =
+ matched || isFocusCenter
+ ? n.id
+ : n.id.length > 20
+ ? n.id.slice(0, 18) + '...'
+ : n.id;
+ ctx.fillText(label, n.x + r + 4, n.y + 3);
+ }
}
+ ctx.globalAlpha = 1;
+
// Tooltip
const tip = tooltipRef.current;
- if (tip.node) {
+ if (tip.node && isNodeVisible(tip.node)) {
const tx = tip.x + 12;
const ty = tip.y - 10;
- const text = `${tip.node.id} (${tip.node.type})`;
- const textW = ctx.measureText(text).width;
- ctx.fillStyle = 'rgba(15, 15, 18, 0.92)';
- ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)';
+ const name = tip.node.id;
+ const typeTxt = tip.node.type;
+ const ageTxt = formatAge(tip.node.lastDate);
+ const line1 = name;
+ const line2 = `${typeTxt} | ${ageTxt}`;
+ ctx.font = '11px Satoshi, system-ui, sans-serif';
+ const w1 = ctx.measureText(line1).width;
+ const w2 = ctx.measureText(line2).width;
+ const boxW = Math.max(w1, w2) + 12;
+ const boxH = 34;
+ ctx.fillStyle = 'rgba(13, 16, 20, 0.92)';
+ ctx.strokeStyle = 'rgba(0, 214, 180, 0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
- ctx.roundRect(tx - 4, ty - 12, textW + 8, 18, 4);
+ ctx.roundRect(tx - 4, ty - 18, boxW, boxH, 4);
ctx.fill();
ctx.stroke();
- ctx.fillStyle = '#fafafa';
- ctx.font = '11px system-ui, sans-serif';
- ctx.fillText(text, tx, ty);
+ ctx.fillStyle = '#F0F2F4';
+ ctx.fillText(line1, tx, ty - 4);
+ ctx.fillStyle = '#7A828E';
+ ctx.font = '10px Geist Mono, JetBrains Mono, monospace';
+ ctx.fillText(line2, tx, ty + 10);
}
animRef.current = requestAnimationFrame(simulate);
@@ -214,13 +445,26 @@ export function GraphTab() {
animRef.current = requestAnimationFrame(simulate);
return () => cancelAnimationFrame(animRef.current);
- }, [data]);
+ }, [data, loading]);
- const findNodeAt = useCallback((mx: number, my: number): Node | null => {
- for (const n of nodesRef.current) {
+ /* ---------- hit-test (only visible nodes) ---------- */
+ const findNodeAt = useCallback((mx: number, my: number): GNode | null => {
+ const nodes = nodesRef.current;
+ const filters = typeFiltersRef.current;
+ const egoId = egoNodeIdRef.current;
+ for (let i = nodes.length - 1; i >= 0; i--) {
+ const n = nodes[i];
+ // Skip hidden nodes
+ if (filters[n.type] === false) continue;
+ if (egoId && n.id !== egoId) {
+ const isNeighbor = edgesRef.current.some(
+ (e) => (e.from === egoId && e.to === n.id) || (e.to === egoId && e.from === n.id),
+ );
+ if (!isNeighbor) continue;
+ }
const dx = n.x - mx;
const dy = n.y - my;
- if (dx * dx + dy * dy < 144) return n; // 12px radius hit area
+ if (dx * dx + dy * dy < 144) return n; // 12px hit area
}
return null;
}, []);
@@ -232,42 +476,129 @@ export function GraphTab() {
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}, []);
- const onMouseDown = useCallback((e: MouseEvent) => {
- const pos = getCanvasPos(e);
- const node = findNodeAt(pos.x, pos.y);
- if (node) {
- dragRef.current = { node, offsetX: pos.x - node.x, offsetY: pos.y - node.y };
- }
- }, [findNodeAt, getCanvasPos]);
-
- const onMouseMove = useCallback((e: MouseEvent) => {
- const pos = getCanvasPos(e);
- if (dragRef.current.node) {
- dragRef.current.node.x = pos.x - dragRef.current.offsetX;
- dragRef.current.node.y = pos.y - dragRef.current.offsetY;
- dragRef.current.node.vx = 0;
- dragRef.current.node.vy = 0;
- }
- const node = findNodeAt(pos.x, pos.y);
- hoverRef.current = node;
- tooltipRef.current = node ? { x: pos.x, y: pos.y, node } : { x: 0, y: 0, node: null };
- const canvas = canvasRef.current;
- if (canvas) canvas.style.cursor = node ? 'grab' : 'default';
- }, [findNodeAt, getCanvasPos]);
+ /* ---------- mouse handlers ---------- */
+ const onMouseDown = useCallback(
+ (e: MouseEvent) => {
+ const pos = getCanvasPos(e);
+ const node = findNodeAt(pos.x, pos.y);
+ if (node) window.__graphReheat?.();
+ dragRef.current = {
+ node: node || null,
+ offsetX: node ? pos.x - node.x : 0,
+ offsetY: node ? pos.y - node.y : 0,
+ startX: pos.x,
+ startY: pos.y,
+ dragged: false,
+ };
+ },
+ [findNodeAt, getCanvasPos],
+ );
- const onMouseUp = useCallback(() => {
- dragRef.current = { node: null, offsetX: 0, offsetY: 0 };
+ const onMouseMove = useCallback(
+ (e: MouseEvent) => {
+ const pos = getCanvasPos(e);
+ const drag = dragRef.current;
+ if (drag.node) {
+ const dx = pos.x - drag.startX;
+ const dy = pos.y - drag.startY;
+ if (dx * dx + dy * dy > CLICK_THRESHOLD * CLICK_THRESHOLD) {
+ drag.dragged = true;
+ }
+ drag.node.x = pos.x - drag.offsetX;
+ drag.node.y = pos.y - drag.offsetY;
+ drag.node.vx = 0;
+ drag.node.vy = 0;
+ }
+ const node = findNodeAt(pos.x, pos.y);
+ hoverRef.current = node;
+ tooltipRef.current = node
+ ? { x: pos.x, y: pos.y, node }
+ : { x: 0, y: 0, node: null };
+ const canvas = canvasRef.current;
+ if (canvas) {
+ if (node) {
+ canvas.style.cursor = egoNodeIdRef.current ? 'pointer' : 'grab';
+ } else {
+ canvas.style.cursor = 'default';
+ }
+ }
+ },
+ [findNodeAt, getCanvasPos],
+ );
+
+ const onMouseUp = useCallback(
+ (e: MouseEvent) => {
+ const drag = dragRef.current;
+ const pos = getCanvasPos(e);
+ if (drag.node && !drag.dragged) {
+ // It's a click — toggle ego mode
+ setEgoNodeId((prev) => (prev === drag.node!.id ? null : drag.node!.id));
+ } else if (!drag.node && !drag.dragged) {
+ // Click on empty canvas — exit ego mode
+ const nodeAtPos = findNodeAt(pos.x, pos.y);
+ if (!nodeAtPos) {
+ setEgoNodeId(null);
+ }
+ }
+ dragRef.current = {
+ node: null,
+ offsetX: 0,
+ offsetY: 0,
+ startX: 0,
+ startY: 0,
+ dragged: false,
+ };
+ },
+ [findNodeAt, getCanvasPos],
+ );
+
+ const onMouseLeave = useCallback(() => {
+ dragRef.current = {
+ node: null,
+ offsetX: 0,
+ offsetY: 0,
+ startX: 0,
+ startY: 0,
+ dragged: false,
+ };
+ hoverRef.current = null;
+ tooltipRef.current = { x: 0, y: 0, node: null };
}, []);
+ /* ---------- derived data for render ---------- */
if (loading) return ;
if (error) return {t('common.error')}: {error}
;
if (!data) return {t('common.error')}: No data
;
+ // Type counts
const typeGroups = new Map();
- data.entities.forEach((e) => typeGroups.set(e.type, (typeGroups.get(e.type) || 0) + 1));
+ data.entities.forEach((e) =>
+ typeGroups.set(e.type, (typeGroups.get(e.type) || 0) + 1),
+ );
+
+ // Orphan count
+ const connectedSet = new Set();
+ data.relations.forEach((r) => {
+ connectedSet.add(r.from);
+ connectedSet.add(r.to);
+ });
+ const orphanCount = data.entities.filter((e) => !connectedSet.has(e.name)).length;
+
+ // Search match count
+ const matchCount = searchQuery
+ ? data.entities.filter((e) =>
+ e.name.toLowerCase().includes(searchQuery.toLowerCase()),
+ ).length
+ : 0;
+
+ // Ego node name for banner
+ const egoEntity = egoNodeId
+ ? data.entities.find((e) => e.name === egoNodeId)
+ : null;
return (
+ {/* Stats row: 3 cards */}
{data.entities.length.toLocaleString()}
@@ -277,24 +608,176 @@ export function GraphTab() {
{data.relations.length.toLocaleString()}
{t('graph.relations')}
+
+
{orphanCount.toLocaleString()}
+
{t('graph.orphans')}
+
+
-
-
{t('tab.graph')}
- {Array.from(typeGroups.entries()).map(([type, count]) => (
-
-
- {type} ({count})
+ {/* Row 1: Title + type filter checkboxes */}
+
+
+ {t('tab.graph')}
+
+ {Array.from(typeGroups.entries()).map(([type, count]) => {
+ const checked = typeFilters[type] !== false;
+ const color = getColor(type);
+ return (
+
+ );
+ })}
+
+
+ {/* Row 2: Search input + match count */}
+
+ setSearchQuery((e.target as HTMLInputElement).value)}
+ style={{
+ width: 260,
+ padding: '4px 8px',
+ background: '#080A0C',
+ border: '1px solid rgba(0, 214, 180, 0.08)',
+ borderRadius: 4,
+ color: '#F0F2F4',
+ fontSize: 12,
+ fontFamily: 'Satoshi, system-ui, sans-serif',
+ outline: 'none',
+ }}
+ />
+ {searchQuery && (
+
+ {matchCount} {t('graph.matches')}
- ))}
+ )}
+
+ {/* Row 3: Ego mode banner (only when active) */}
+ {egoEntity && (
+
+
+ {t('graph.focusMode')}:
+
+ {egoEntity.name}
+
+
+ )}
+
+ {/* Row 4: Click hint (only when NOT in ego mode) */}
+ {!egoNodeId && (
+
+ {t('graph.clickHint')}
+
+ )}
+
+ {/* Canvas */}
diff --git a/dashboard/src/components/HealthScore.tsx b/dashboard/src/components/HealthScore.tsx
new file mode 100644
index 00000000..e0ddc603
--- /dev/null
+++ b/dashboard/src/components/HealthScore.tsx
@@ -0,0 +1,118 @@
+import { t } from '../lib/i18n';
+import type { HealthFactor } from '../lib/api';
+
+interface Props {
+ score: number; // 0-100
+ factors: { activity: HealthFactor; quality: HealthFactor; freshness: HealthFactor; lessons: HealthFactor };
+}
+
+function scoreColor(value: number): string {
+ if (value >= 80) return 'var(--success)';
+ if (value >= 60) return 'var(--accent)';
+ if (value >= 40) return 'var(--warning)';
+ return 'var(--danger)';
+}
+
+function scoreLabel(value: number): string {
+ if (value >= 80) return t('health.excellent');
+ if (value >= 60) return t('health.good');
+ if (value >= 40) return t('health.fair');
+ return t('health.poor');
+}
+
+const factorKeys = ['activity', 'quality', 'freshness', 'lessons'] as const;
+
+export function HealthScore({ score, factors }: Props) {
+ const color = scoreColor(score);
+
+ return (
+
+
{t('health.title')}
+
+ {/* Left: circular score */}
+
+
+
+ {score}
+
+
+
+ {scoreLabel(score)}
+
+
+
+ {/* Right: factor bars */}
+
+ {factorKeys.map((key) => {
+ const factor = factors[key];
+ const pct = Math.round((factor.score / factor.weight) * 100);
+ const barColor = scoreColor(pct);
+ return (
+
+
+
+ {t(`health.${key}`)}
+
+
+ {pct}%
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/dashboard/src/components/LessonsTab.tsx b/dashboard/src/components/LessonsTab.tsx
index 60e59310..73731027 100644
--- a/dashboard/src/components/LessonsTab.tsx
+++ b/dashboard/src/components/LessonsTab.tsx
@@ -42,9 +42,9 @@ function parseLesson(entity: Entity): ParsedLesson {
}
const SEVERITY_COLORS: Record
= {
- critical: '#ef4444',
- major: '#f97316',
- minor: '#3b82f6',
+ critical: '#FF6B6B',
+ major: '#FFB84D',
+ minor: '#60A5FA',
};
export function LessonsTab() {
@@ -116,25 +116,25 @@ export function LessonsTab() {
{lesson.error && (
-
{t('lessons.error')}
+
{t('lessons.error')}
{lesson.error}
)}
{lesson.rootCause && (
-
{t('lessons.rootCause')}
+
{t('lessons.rootCause')}
{lesson.rootCause}
)}
{lesson.fix && (
-
{t('lessons.fix')}
+
{t('lessons.fix')}
{lesson.fix}
)}
{lesson.prevention && (
-
{t('lessons.prevention')}
+
{t('lessons.prevention')}
{lesson.prevention}
)}
diff --git a/dashboard/src/components/MemoryTimeline.tsx b/dashboard/src/components/MemoryTimeline.tsx
new file mode 100644
index 00000000..842b0a53
--- /dev/null
+++ b/dashboard/src/components/MemoryTimeline.tsx
@@ -0,0 +1,186 @@
+import { useRef, useEffect } from 'preact/hooks';
+import { t } from '../lib/i18n';
+
+export interface TimelineEntry {
+ date: string;
+ created: number;
+ recalled: number;
+}
+
+interface MemoryTimelineProps {
+ data: TimelineEntry[];
+}
+
+const PAD_TOP = 8;
+const PAD_RIGHT = 8;
+const PAD_BOTTOM = 20;
+const PAD_LEFT = 8;
+const CANVAS_HEIGHT = 120;
+
+const BAR_FILL = 'rgba(0, 214, 180, 0.3)';
+const LINE_STROKE = '#00D6B4';
+const LINE_WIDTH = 1.5;
+const LABEL_COLOR = '#4A5260';
+const LABEL_FONT = '9px Satoshi, system-ui, sans-serif';
+
+function drawTimeline(
+ canvas: HTMLCanvasElement,
+ data: TimelineEntry[],
+): void {
+ const dpr = window.devicePixelRatio || 1;
+ const rect = canvas.getBoundingClientRect();
+ const cssW = rect.width;
+ const cssH = CANVAS_HEIGHT;
+
+ canvas.width = cssW * dpr;
+ canvas.height = cssH * dpr;
+ canvas.style.width = `${cssW}px`;
+ canvas.style.height = `${cssH}px`;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ ctx.scale(dpr, dpr);
+
+ const chartW = cssW - PAD_LEFT - PAD_RIGHT;
+ const chartH = cssH - PAD_TOP - PAD_BOTTOM;
+
+ if (data.length === 0 || chartW <= 0 || chartH <= 0) return;
+
+ const maxCreated = Math.max(1, ...data.map((d) => d.created));
+ const maxRecalled = Math.max(1, ...data.map((d) => d.recalled));
+ const maxVal = Math.max(maxCreated, maxRecalled);
+
+ const barCount = data.length;
+ const gap = 1;
+ const barW = Math.max(1, (chartW - gap * (barCount - 1)) / barCount);
+
+ // -- Draw bars (created) --
+ ctx.fillStyle = BAR_FILL;
+ for (let i = 0; i < barCount; i++) {
+ const entry = data[i];
+ const barH = (entry.created / maxVal) * chartH;
+ const x = PAD_LEFT + i * (barW + gap);
+ const y = PAD_TOP + chartH - barH;
+ ctx.fillRect(x, y, barW, barH);
+ }
+
+ // -- Draw line (recalled) --
+ ctx.beginPath();
+ ctx.strokeStyle = LINE_STROKE;
+ ctx.lineWidth = LINE_WIDTH;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+
+ for (let i = 0; i < barCount; i++) {
+ const entry = data[i];
+ const x = PAD_LEFT + i * (barW + gap) + barW / 2;
+ const y = PAD_TOP + chartH - (entry.recalled / maxVal) * chartH;
+ if (i === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.stroke();
+
+ // -- X-axis labels every 7 days --
+ ctx.fillStyle = LABEL_COLOR;
+ ctx.font = LABEL_FONT;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+
+ for (let i = 0; i < barCount; i += 7) {
+ const entry = data[i];
+ // Format as MM-DD
+ const parts = entry.date.split('-');
+ const label = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : entry.date;
+ const x = PAD_LEFT + i * (barW + gap) + barW / 2;
+ const y = PAD_TOP + chartH + 4;
+ ctx.fillText(label, x, y);
+ }
+}
+
+export function MemoryTimeline({ data }: MemoryTimelineProps) {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ drawTimeline(canvas, data);
+
+ // Redraw on resize to keep canvas crisp
+ const onResize = () => drawTimeline(canvas, data);
+ window.addEventListener('resize', onResize);
+ return () => window.removeEventListener('resize', onResize);
+ }, [data]);
+
+ const totalCreated = data.reduce((sum, d) => sum + d.created, 0);
+ const totalRecalled = data.reduce((sum, d) => sum + d.recalled, 0);
+
+ return (
+
+
+
+ {t('timeline.title')}
+
+
+
+
+ {t('timeline.created')}
+
+ {totalCreated}
+
+
+
+
+ {t('timeline.recalled')}
+
+ {totalRecalled}
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard/src/components/SearchTab.tsx b/dashboard/src/components/SearchTab.tsx
index 82dd1ba3..dc8a6798 100644
--- a/dashboard/src/components/SearchTab.tsx
+++ b/dashboard/src/components/SearchTab.tsx
@@ -41,6 +41,14 @@ export function SearchTab() {
+ {!loading && results === null && !error && (
+
+
+ {t('search.hint')}
+
+
+ )}
+
{error &&
{error}
}
{loading &&
}
diff --git a/dashboard/src/components/SettingsTab.tsx b/dashboard/src/components/SettingsTab.tsx
index 37ce15c8..e28f3c05 100644
--- a/dashboard/src/components/SettingsTab.tsx
+++ b/dashboard/src/components/SettingsTab.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'preact/hooks';
import { api, type ConfigData } from '../lib/api';
-import { t, getLocale, setLocale, getLocales } from '../lib/i18n';
+import { t, getLocale, setLocale, getLocales, type Locale } from '../lib/i18n';
function capitalize(s: string): string {
if (!s) return s;
@@ -30,7 +30,7 @@ export function SettingsTab() {
setSaving(true);
setMsg('');
try {
- const llm: any = { provider };
+ const llm: { provider: string; model?: string; apiKey?: string } = { provider };
if (model) llm.model = model;
if (apiKey) llm.apiKey = apiKey;
await api('POST', '/v1/config', { llm });
@@ -122,7 +122,7 @@ export function SettingsTab() {