Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 69 additions & 0 deletions app/src/components/intelligence/MemoryTimelinePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { computeTimeline } from '../../lib/memory/memoryTimeline';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import MemoryTimelinePanel from './MemoryTimelinePanel';

const NOW = 1_700_000_000;

function utc(year: number, month: number, day = 1): number {
return Math.floor(Date.UTC(year, month - 1, day) / 1000);
}

function rel(updatedAt: number): GraphRelation {
return {
namespace: 'n',
subject: 'You',
predicate: 'p',
object: 'x',
attrs: {},
updatedAt,
evidenceCount: 1,
orderIndex: null,
documentIds: [],
chunkIds: [],
};
}

const report = computeTimeline(
[rel(utc(2023, 1, 10)), rel(utc(2023, 1, 20)), rel(utc(2023, 3, 5))],
NOW
);

describe('<MemoryTimelinePanel />', () => {
it('renders the loading skeleton', () => {
render(<MemoryTimelinePanel report={null} loading />);
expect(screen.getByTestId('memory-timeline-loading')).toBeInTheDocument();
});

it('renders the empty state when there are no facts', () => {
render(<MemoryTimelinePanel report={computeTimeline([], NOW)} />);
expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument();
});

it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<MemoryTimelinePanel report={null} error="graph unavailable" onRetry={onRetry} />);
expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});

it('renders summary tiles, the busiest caption, and per-month bars', () => {
render(<MemoryTimelinePanel report={report} />);
expect(screen.getByText('Facts')).toBeInTheDocument();
expect(screen.getByText('Active months')).toBeInTheDocument();
expect(screen.getByText('Last 30 days')).toBeInTheDocument();
expect(screen.getByText('Facts learned per month')).toBeInTheDocument();
expect(screen.getByText('2023-01')).toBeInTheDocument();
expect(screen.getByText('2023-03')).toBeInTheDocument();
expect(screen.getByText('Busiest: 2023-01 (2)')).toBeInTheDocument();
});

it('notes undated facts when present', () => {
const withUndated = computeTimeline([rel(utc(2023, 5, 1)), rel(0), rel(0)], NOW);
render(<MemoryTimelinePanel report={withUndated} />);
expect(screen.getByText('2 fact(s) have no recorded date.')).toBeInTheDocument();
});
});
175 changes: 175 additions & 0 deletions app/src/components/intelligence/MemoryTimelinePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Memory Timeline — presentational view. Pure: renders the per-month fact
* histogram + summary tiles. No data fetching, no clock, no RNG.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { TimelineReport } from '../../lib/memory/memoryTimeline';

const MAX_BARS = 24;

interface MemoryTimelinePanelProps {
report: TimelineReport | null;
loading?: boolean;
error?: string | null;
onRetry?: () => void;
}

const MemoryTimelinePanel = ({ report, loading, error, onRetry }: MemoryTimelinePanelProps) => {
const { t } = useT();

const intro = (
<div
role="note"
className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200">
<p className="font-medium mb-1">{t('memoryTimeline.title')}</p>
<p>{t('memoryTimeline.intro')}</p>
</div>
);

if (loading) {
return (
<div className="space-y-4">
{intro}
<div
className="space-y-3"
role="status"
aria-label={t('memoryTimeline.loading')}
data-testid="memory-timeline-loading">
<div className="grid gap-2 sm:grid-cols-3">
{[0, 1, 2].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-16"
/>
))}
</div>
{[0, 1, 2, 3].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-6"
/>
))}
</div>
</div>
);
}

if (error) {
return (
<div className="space-y-4">
{intro}
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 p-4 text-center">
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
{t('memoryTimeline.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
{t('memoryTimeline.retry')}
</button>
)}
</div>
</div>
);
}

if (!report || (report.total === 0 && report.undated === 0)) {
return (
<div className="space-y-4">
{intro}
<div className="py-8 text-center">
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('memoryTimeline.empty')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('memoryTimeline.emptyHint')}
</p>
</div>
</div>
);
}

const maxCount = report.busiest?.count ?? 1;
const shown = report.buckets.slice(-MAX_BARS);
const truncated = report.buckets.length > MAX_BARS;

return (
<div className="space-y-4">
{intro}

{/* Summary tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('memoryTimeline.metricTotal'), value: report.total },
{ label: t('memoryTimeline.metricMonths'), value: report.buckets.length },
{ label: t('memoryTimeline.metricRecent'), value: report.recentCount },
].map(tile => (
<div
key={tile.label}
className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3">
<div className="text-[10px] uppercase tracking-wider text-stone-400 dark:text-neutral-500">
{tile.label}
</div>
<div className="text-lg font-semibold tabular-nums text-stone-900 dark:text-neutral-100">
{tile.value}
</div>
</div>
))}
</div>

{report.busiest && (
<p className="text-[11px] text-stone-500 dark:text-neutral-400 tabular-nums">
{t('memoryTimeline.busiestCaption')
.replace('{period}', report.busiest.period)
.replace('{count}', String(report.busiest.count))}
</p>
)}

{/* Per-month histogram */}
{shown.length > 0 && (
<section aria-labelledby="memory-timeline-heading" className="space-y-1">
<h3
id="memory-timeline-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('memoryTimeline.heading')}
</h3>
<ul className="space-y-1">
{shown.map(bucket => (
<li key={bucket.period} className="flex items-center gap-2 text-[11px] tabular-nums">
<span className="w-16 shrink-0 text-stone-400 dark:text-neutral-500">
{bucket.period}
</span>
<div className="flex-1 h-3 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full bg-primary-400/70"
style={{ width: `${(bucket.count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right text-stone-500 dark:text-neutral-400">
{bucket.count}
</span>
</li>
))}
</ul>
{truncated && (
<p className="text-center text-xs text-stone-400 dark:text-neutral-500">
{t('memoryTimeline.truncated')
.replace('{shown}', String(shown.length))
.replace('{total}', String(report.buckets.length))}
</p>
)}
</section>
)}

{report.undated > 0 && (
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
{t('memoryTimeline.undatedNote').replace('{count}', String(report.undated))}
Comment on lines +124 to +168

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm every memoryTimeline.* key in en.ts exists in each locale chunk.
EN=$(fd -t f 'en.ts' app/src/lib/i18n -x grep -hoE "'memoryTimeline\.[a-zA-Z]+'" {} | sort -u)
echo "Keys in en.ts:"; echo "$EN"
echo "--- Missing per locale ---"
for f in $(fd -e ts . app/src/lib/i18n/chunks); do
  for k in $EN; do
    grep -q "$k" "$f" || echo "$f missing $k"
  done
done

Repository: tinyhumansai/openhuman

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== en.ts: memoryTimeline section ==="
rg -n "memoryTimeline" app/src/lib/i18n/en.ts
# show a small window around the first occurrence
line=$(rg -n "memoryTimeline" app/src/lib/i18n/en.ts | head -n 1 | cut -d: -f1)
start=$((line-20)); end=$((line+80))
sed -n "${start},${end}p" app/src/lib/i18n/en.ts

echo
echo "=== chunks/en-2.ts: lines containing memoryTimeline ==="
rg -n "memoryTimeline" app/src/lib/i18n/chunks/en-2.ts || true
if rg -n "memoryTimeline" app/src/lib/i18n/chunks/en-2.ts >/dev/null; then
  line=$(rg -n "memoryTimeline" app/src/lib/i18n/chunks/en-2.ts | head -n 1 | cut -d: -f1)
  start=$((line-20)); end=$((line+80))
  sed -n "${start},${end}p" app/src/lib/i18n/chunks/en-2.ts
fi

echo
echo "=== chunks/ar-2.ts: lines containing memoryTimeline ==="
rg -n "memoryTimeline" app/src/lib/i18n/chunks/ar-2.ts || true
if rg -n "memoryTimeline" app/src/lib/i18n/chunks/ar-2.ts >/dev/null; then
  line=$(rg -n "memoryTimeline" app/src/lib/i18n/chunks/ar-2.ts | head -n 1 | cut -d: -f1)
  start=$((line-20)); end=$((line+80))
  sed -n "${start},${end}p" app/src/lib/i18n/chunks/ar-2.ts
fi

Repository: tinyhumansai/openhuman

Length of output: 5868


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Files in chunks containing 'memoryTimeline' ==="
rg -l "memoryTimeline" app/src/lib/i18n/chunks | sort || true

echo
echo "=== Preview app/src/lib/i18n/chunks/en-2.ts ==="
wc -l app/src/lib/i18n/chunks/en-2.ts
sed -n '1,200p' app/src/lib/i18n/chunks/en-2.ts

echo
echo "=== Preview app/src/lib/i18n/chunks/fr-2.ts ==="
wc -l app/src/lib/i18n/chunks/fr-2.ts
sed -n '1,160p' app/src/lib/i18n/chunks/fr-2.ts

Repository: tinyhumansai/openhuman

Length of output: 21102


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Files in chunks containing 'memoryTimeline' ==="
rg -l "memoryTimeline" app/src/lib/i18n/chunks | sort || true

echo
echo "=== Preview app/src/lib/i18n/chunks/en-2.ts ==="
wc -l app/src/lib/i18n/chunks/en-2.ts
sed -n '1,200p' app/src/lib/i18n/chunks/en-2.ts

echo
echo "=== Preview app/src/lib/i18n/chunks/fr-2.ts ==="
wc -l app/src/lib/i18n/chunks/fr-2.ts
sed -n '1,160p' app/src/lib/i18n/chunks/fr-2.ts

Repository: tinyhumansai/openhuman

Length of output: 21102


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== en-1.ts memoryTimeline keys ==="
rg -n "memoryTimeline\." app/src/lib/i18n/chunks/en-1.ts | head -n 50

echo
echo "=== ar-1.ts memoryTimeline keys (first 50) ==="
rg -n "memoryTimeline\." app/src/lib/i18n/chunks/ar-1.ts | head -n 50

echo
echo "=== Which chunk files contain ALL of the required keys from the component snippet? ==="
REQUIRED=(
  "memoryTimeline.busiestCaption"
  "memoryTimeline.undatedNote"
  "memoryTimeline.truncated"
  "memoryTimeline.heading"
)
for f in $(rg -l "memoryTimeline\." app/src/lib/i18n/chunks); do
  ok=1
  for k in "${REQUIRED[@]}"; do
    grep -q "'${k}'" "$f" || ok=0
  done
  if [ $ok -eq 1 ]; then
    echo "$f"
  fi
done | sort

echo
echo "=== For each locale chunk index, list chunk files that contain ANY memoryTimeline key ==="
for idx in 1 2 3 4 5; do
  echo "-- *-${idx}.ts --"
  rg -l "memoryTimeline\." app/src/lib/i18n/chunks/*-${idx}.ts 2>/dev/null | sed 's#.*/##' | head -n 50 || true
done

Repository: tinyhumansai/openhuman

Length of output: 2708


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== en-1.ts memoryTimeline keys ==="
rg -n "memoryTimeline\." app/src/lib/i18n/chunks/en-1.ts | head -n 50

echo
echo "=== ar-1.ts memoryTimeline keys (first 50) ==="
rg -n "memoryTimeline\." app/src/lib/i18n/chunks/ar-1.ts | head -n 50

echo
echo "=== Which chunk files contain ALL keys used by MemoryTimelinePanel ==="
REQUIRED=(
  "memoryTimeline.busiestCaption"
  "memoryTimeline.undatedNote"
  "memoryTimeline.truncated"
  "memoryTimeline.heading"
)
for f in $(rg -l "memoryTimeline\." app/src/lib/i18n/chunks); do
  ok=1
  for k in "${REQUIRED[@]}"; do
    grep -q "'${k}'" "$f" || ok=0
  done
  if [ $ok -eq 1 ]; then
    echo "$f"
  fi
done | sort

echo
echo "=== For each chunk index, which chunk files contain ANY memoryTimeline key ==="
for idx in 1 2 3 4 5; do
  echo "-- *-${idx}.ts --"
  rg -l "memoryTimeline\." app/src/lib/i18n/chunks/*-${idx}.ts 2>/dev/null | sed 's#.*/##' | sort | head -n 100 || true
done

Repository: tinyhumansai/openhuman

Length of output: 2681


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where locale chunks are loaded/merged
rg -n "chunks/.*-1|load.*chunk|TranslationMap|i18n/chunks" app/src/lib/i18n app/src/components app/src | head -n 80

echo
echo "=== Inspect I18nContext.tsx chunk loading ==="
rg -n "chunk" app/src/lib/i18n/I18nContext.* app/src/lib/i18n -S || true
ls app/src/lib/i18n

Repository: tinyhumansai/openhuman

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find i18n chunk loading/merge logic
rg -n "TranslationMap|i18n/chunks|chunks/|chunk" app/src/lib/i18n | head -n 120

echo
echo "=== Files under app/src/lib/i18n ==="
ls -la app/src/lib/i18n

echo
echo "=== Look for where chunks are imported/combined ==="
rg -n "import\\s+.*from\\s+['\"].*chunks/|Object\\.assign|merge|reduce|...chunks" app/src/lib/i18n | head -n 120

Repository: tinyhumansai/openhuman

Length of output: 19228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== hi.ts: how chunks are combined ==="
sed -n '1,80p' app/src/lib/i18n/hi.ts

echo
echo "=== ar.ts: how chunks are combined ==="
sed -n '1,80p' app/src/lib/i18n/ar.ts

echo
echo "=== I18nContext.tsx: how locale map is used ==="
rg -n "const \\w+Locale|getLocale|currentLocale|locale|t\\(|I18nContext|TranslationMap|translations" app/src/lib/i18n/I18nContext.tsx
sed -n '1,140p' app/src/lib/i18n/I18nContext.tsx

Repository: tinyhumansai/openhuman

Length of output: 7456


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== hi.ts: how chunks are combined ==="
sed -n '1,80p' app/src/lib/i18n/hi.ts

echo
echo "=== ar.ts: how chunks are combined ==="
sed -n '1,80p' app/src/lib/i18n/ar.ts

echo
echo "=== I18nContext.tsx: how locale map is used ==="
rg -n "useT\\(|t\\(|locale|TranslationMap|translations|I18nContext" app/src/lib/i18n/I18nContext.tsx
sed -n '1,220p' app/src/lib/i18n/I18nContext.tsx

Repository: tinyhumansai/openhuman

Length of output: 7456


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check placeholder parity for keys used by MemoryTimelinePanel in each locale chunk1.
LOCALES=$(rg -l "memoryTimeline\\.busiestCaption" app/src/lib/i18n/chunks/*-1.ts | xargs -n1 basename | sed 's/-1\.ts$//')
echo "Locales with memoryTimeline in chunk1: $LOCALES"

for f in app/src/lib/i18n/chunks/*-1.ts; do
  # busiestCaption
  line=$(rg -n "'memoryTimeline\\.busiestCaption'" "$f" | head -n 1 || true)
  if [ -z "$line" ]; then echo "$f missing busiestCaption"; continue; fi
  val=$(echo "$line" | sed -E "s/.*'memoryTimeline\\.busiestCaption':\\s*'([^']*)'.*/\\1/")
  for ph in '{period}' '{count}'; do
    echo "$val" | grep -q "$ph" || echo "$f busiestCaption missing $ph"
  done

  # undatedNote expects {count}
  line=$(rg -n "'memoryTimeline\\.undatedNote'" "$f" | head -n 1 || true)
  val=$(echo "$line" | sed -E "s/.*'memoryTimeline\\.undatedNote':\\s*'([^']*)'.*/\\1/")
  echo "$val" | grep -q '{count}' || echo "$f undatedNote missing {count}"

  # truncated expects {shown} and {total}
  line=$(rg -n "'memoryTimeline\\.truncated'" "$f" | head -n 1 || true)
  val=$(echo "$line" | sed -E "s/.*'memoryTimeline\\.truncated':\\s*'([^']*)'.*/\\1/")
  for ph in '{shown}' '{total}'; do
    echo "$val" | grep -q "$ph" || echo "$f truncated missing $ph"
  done
done

Repository: tinyhumansai/openhuman

Length of output: 202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check placeholder parity for keys used by MemoryTimelinePanel in each locale chunk1.
LOCALES=$(rg -l "memoryTimeline\\.busiestCaption" app/src/lib/i18n/chunks/*-1.ts | xargs -n1 basename | sed 's/-1\.ts$//')
echo "Locales with memoryTimeline in chunk1: $LOCALES"

for f in app/src/lib/i18n/chunks/*-1.ts; do
  # busiestCaption
  line=$(rg -n "'memoryTimeline\\.busiestCaption'" "$f" | head -n 1 || true)
  if [ -z "$line" ]; then echo "$f missing busiestCaption"; continue; fi
  val=$(echo "$line" | sed -E "s/.*'memoryTimeline\\.busiestCaption':\\s*'([^']*)'.*/\\1/")
  for ph in '{period}' '{count}'; do
    echo "$val" | grep -q "$ph" || echo "$f busiestCaption missing $ph"
  done

  # undatedNote expects {count}
  line=$(rg -n "'memoryTimeline\\.undatedNote'" "$f" | head -n 1 || true)
  val=$(echo "$line" | sed -E "s/.*'memoryTimeline\\.undatedNote':\\s*'([^']*)'.*/\\1/")
  echo "$val" | grep -q '{count}' || echo "$f undatedNote missing {count}"

  # truncated expects {shown} and {total}
  line=$(rg -n "'memoryTimeline\\.truncated'" "$f" | head -n 1 || true)
  val=$(echo "$line" | sed -E "s/.*'memoryTimeline\\.truncated':\\s*'([^']*)'.*/\\1/")
  for ph in '{shown}' '{total}'; do
    echo "$val" | grep -q "$ph" || echo "$f truncated missing $ph"
  done
done

Repository: tinyhumansai/openhuman

Length of output: 202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== memoryTimeline keys in pl chunk files ==="
rg -n "memoryTimeline\." app/src/lib/i18n/chunks/pl-*.ts || echo "No memoryTimeline keys found in pl chunks"

echo
echo "=== memoryTimeline keys in pl.ts (merged locale module) ==="
rg -n "memoryTimeline\." app/src/lib/i18n/pl.ts || echo "No memoryTimeline keys found in pl.ts"

echo
echo "=== Missing required keys per locale chunk1 file ==="
REQUIRED_KEYS=(
  "memoryTimeline.busiestCaption"
  "memoryTimeline.undatedNote"
  "memoryTimeline.truncated"
  "memoryTimeline.heading"
)

for f in app/src/lib/i18n/chunks/*-1.ts; do
  missing=()
  for k in "${REQUIRED_KEYS[@]}"; do
    grep -q "'${k}'" "$f" || missing+=("$k")
  done
  if [ ${`#missing`[@]} -ne 0 ]; then
    echo "$(basename "$f") missing: ${missing[*]}"
  fi
done | sort

Repository: tinyhumansai/openhuman

Length of output: 429


Fix i18n key parity for memoryTimeline.* in the Polish locale.

  • app/src/lib/i18n/chunks/pl-1.ts contains no memoryTimeline.* keys (missing memoryTimeline.heading, memoryTimeline.busiestCaption, memoryTimeline.undatedNote, memoryTimeline.truncated), and app/src/lib/i18n/pl.ts doesn’t add them—so Polish users fall back to English for this panel.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/components/intelligence/MemoryTimelinePanel.tsx` around lines 124 -
168, The Polish locale is missing the i18n keys used by MemoryTimeline
(memoryTimeline.heading, memoryTimeline.busiestCaption,
memoryTimeline.undatedNote, memoryTimeline.truncated); add Polish translations
for those keys to the Polish locale chunk and ensure they are exported/merged
into the Polish locale bundle so the app doesn't fall back to English, keeping
interpolation placeholders intact (e.g., {period}, {count}, {shown}, {total})
and matching the key names exactly.

</p>
)}
</div>
);
};

export default MemoryTimelinePanel;
65 changes: 65 additions & 0 deletions app/src/components/intelligence/MemoryTimelineTab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { computeTimeline } from '../../lib/memory/memoryTimeline';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import MemoryTimelineTab from './MemoryTimelineTab';

const mockLoadTimeline = vi.fn();
const mockLoadNamespaces = vi.fn();

vi.mock('../../services/api/memoryTimelineApi', () => ({
loadTimeline: (...args: unknown[]) => mockLoadTimeline(...args),
loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args),
}));

const NOW = 1_700_000_000;

function rel(updatedAt: number): GraphRelation {
return {
namespace: 'n',
subject: 'You',
predicate: 'p',
object: 'x',
attrs: {},
updatedAt,
evidenceCount: 1,
orderIndex: null,
documentIds: [],
chunkIds: [],
};
}

const report = computeTimeline([rel(Math.floor(Date.UTC(2023, 0, 10) / 1000))], NOW);

describe('<MemoryTimelineTab />', () => {
beforeEach(() => {
mockLoadTimeline.mockReset();
mockLoadNamespaces.mockReset();
mockLoadTimeline.mockResolvedValue(report);
mockLoadNamespaces.mockResolvedValue([]);
});

it('loads the timeline on mount and renders it', async () => {
render(<MemoryTimelineTab />);
expect(mockLoadTimeline).toHaveBeenCalledTimes(1);
expect(mockLoadTimeline.mock.calls[0][1]).toBeUndefined(); // (nowSeconds, undefined-namespace)
await waitFor(() => expect(screen.getByText('Facts learned per month')).toBeInTheDocument());
});

it('shows the namespace selector and re-queries on change', async () => {
mockLoadNamespaces.mockResolvedValueOnce(['work', 'personal']);
render(<MemoryTimelineTab />);
await waitFor(() => screen.getByRole('combobox'));
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'work' } });
await waitFor(() => expect(mockLoadTimeline).toHaveBeenCalledTimes(2));
expect(mockLoadTimeline.mock.calls[1][1]).toBe('work');
});

it('surfaces an error when the load fails', async () => {
mockLoadTimeline.mockReset();
mockLoadTimeline.mockRejectedValueOnce(new Error('graph unavailable'));
render(<MemoryTimelineTab />);
await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
});
});
82 changes: 82 additions & 0 deletions app/src/components/intelligence/MemoryTimelineTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Memory Timeline tab (container). Load-on-mount, namespace selector, and mints
* `nowSeconds` (in handlers, never during render) for the recency window.
* Delegates rendering to the pure <MemoryTimelinePanel>. Read-only.
*/
import { useCallback, useEffect, useRef, useState } from 'react';

import { useT } from '../../lib/i18n/I18nContext';
import type { TimelineReport } from '../../lib/memory/memoryTimeline';
import { loadNamespaces, loadTimeline } from '../../services/api/memoryTimelineApi';
import MemoryTimelinePanel from './MemoryTimelinePanel';

const nowSeconds = (): number => Math.floor(Date.now() / 1000);

const MemoryTimelineTab = () => {
const { t } = useT();
const [report, setReport] = useState<TimelineReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [namespace, setNamespace] = useState('');
// Monotonic token: ignore a response if a newer load has since started.
const latestRequestId = useRef(0);

const load = useCallback(async (ns: string) => {
const requestId = (latestRequestId.current += 1);
setLoading(true);
setError(null);
try {
const next = await loadTimeline(nowSeconds(), ns || undefined);
if (requestId !== latestRequestId.current) return;
setReport(next);
} catch (err) {
if (requestId !== latestRequestId.current) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (requestId === latestRequestId.current) setLoading(false);
}
}, []);

useEffect(() => {
loadNamespaces()
.then(setNamespaces)
.catch(() => setNamespaces([]));
void load('');
}, [load]);

const handleNamespace = (next: string): void => {
setNamespace(next);
void load(next);
};

return (
<div className="space-y-4">
{namespaces.length > 0 && (
<label className="flex items-center gap-2 text-xs text-stone-600 dark:text-neutral-300">
{t('memoryTimeline.namespaceLabel')}
<select
value={namespace}
onChange={e => handleNamespace(e.target.value)}
className="rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-sm text-stone-800 dark:text-neutral-100">
<option value="">{t('memoryTimeline.namespaceAll')}</option>
{namespaces.map(ns => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
</label>
)}

<MemoryTimelinePanel
report={report}
loading={loading}
error={error}
onRetry={() => void load(namespace)}
/>
</div>
);
};

export default MemoryTimelineTab;
Loading
Loading