Skip to content

Commit d1b5e1d

Browse files
pfillion42claude
andcommitted
feat: add timeline view and quality voting system
Timeline: GET /api/memories/timeline groups memories by day with optional type/tags filters. Frontend page with vertical chronological axis. Quality: POST /api/memories/:hash/rate for thumbs up/down voting (+/-0.1 quality_score, clamped 0-1). QualityVoter component integrated in MemoryDetail and MemoryList. 171 tests (111 server + 60 client), all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3675091 commit d1b5e1d

15 files changed

Lines changed: 862 additions & 5 deletions

File tree

DEVLOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Journal de Developpement
22

3+
## 2026-02-14 - Sprint 6 : Timeline et score de qualite
4+
5+
### Sprint 6.1 - Vue timeline
6+
- **Backend** : `GET /api/memories/timeline` - groupement par jour via `substr(created_at_iso, 0, 10)`, filtres type/tags optionnels
7+
- **Frontend** : Page `/timeline` avec axe chronologique vertical, groupes par date, badges type, TagBadge, liens vers detail
8+
- **Hook** : useTimeline() avec React Query
9+
- **Navigation** : lien "Timeline" entre Dashboard et Memoires
10+
11+
### Sprint 6.2 - Score de qualite
12+
- **Backend** : `POST /api/memories/:hash/rate` - rating +1/-1, modifie quality_score dans metadata JSON (+/-0.1, clamp 0-1, arrondi 10 decimales)
13+
- **Frontend** : QualityVoter (thumbs up/down, affichage score, animation flash couleur, mode compact)
14+
- **Integration** : remplace QualityIndicator dans MemoryDetail, nouvelle colonne qualite dans MemoryList
15+
- **Hook** : useRateMemory dans useMutations.ts
16+
17+
### Resultats
18+
- 15 nouveaux tests backend (8 timeline + 9 rate, certains couvrent les 2) = 111 serveur
19+
- 13 nouveaux tests frontend (8 timeline + 5 quality voter) = 60 client
20+
- **171 tests total**, tous verts, lint propre
21+
- Equipe 2 agents paralleles (backend-dev + frontend-dev)
22+
23+
---
24+
325
## 2026-02-14 - Sprint 5.3 : Detection de doublons
426

527
### Backend

PLAN.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,21 @@ avec une interface web moderne et une API backend. Interfacable avec Claude.
3636
- Backend : GET /api/memories/duplicates?threshold=0.85, Union-Find clustering, KNN vec0
3737
- Frontend : Page /duplicates, slider seuil (0.7-1.0), groupes avec apercu, actions Garder/Ignorer
3838

39+
## Sprint 6 - Timeline et qualite
40+
41+
### 6.1 Vue timeline - COMPLETE
42+
- Backend : GET /api/memories/timeline (groupement par jour, filtres type/tags)
43+
- Frontend : Page /timeline avec axe chronologique vertical, groupes par date, badges et liens
44+
45+
### 6.2 Score de qualite - COMPLETE
46+
- Backend : POST /api/memories/:hash/rate (rating +1/-1, quality_score +/-0.1, clamp 0-1)
47+
- Frontend : QualityVoter (thumbs up/down, animation flash, mode compact), integre dans MemoryDetail et MemoryList
48+
3949
## Backlog - Fonctionnalites futures
4050

4151
### Exploration et comprehension
4252
- [ ] Projection 2D des embeddings (t-SNE/UMAP) - vue espace vectoriel complet
4353
- [ ] Clustering automatique - grouper par proximite semantique
44-
- [ ] Vue timeline - chronologie visuelle des memoires
4554

4655
### Navigation et UX
4756
- [ ] Raccourcis clavier (j/k navigation, / recherche, e editer)
@@ -51,7 +60,6 @@ avec une interface web moderne et une API backend. Interfacable avec Claude.
5160
### Gestion et maintenance
5261
- [ ] Gestion globale des tags - renommer, fusionner, supprimer un tag partout
5362
- [ ] Memoires obsoletes - identifier et suggerer nettoyage (jamais accedees, anciennes)
54-
- [ ] Score de qualite - interface vote thumbs up/down, tri par qualite
5563

5664
### Synchronisation et integration
5765
- [ ] Live reload - surveiller la DB SQLite, mise a jour temps reel
@@ -71,7 +79,7 @@ avec une interface web moderne et une API backend. Interfacable avec Claude.
7179
- Embedding : all-MiniLM-L6-v2 (384 dims, cosine distance)
7280
- Injection de dependance : `createMemoriesRouter(db)` pour faciliter les tests
7381
- Frontend : React Query + React Router, hooks custom, theme sombre via CSS custom properties
74-
- Navigation : / (Dashboard), /memories (MemoryList), /memories/:hash (MemoryDetail), /duplicates (Duplicates), /graph (GraphView)
82+
- Navigation : / (Dashboard), /timeline (Timeline), /memories (MemoryList), /memories/:hash (MemoryDetail), /duplicates (Duplicates), /graph (GraphView)
7583
- Embedder : @huggingface/transformers (all-MiniLM-L6-v2), injection de dependance pour tests
7684
- Graphe : react-force-graph-2d pour la visualisation force-directed
7785
- Mode read/write : configurable via MEMORY_DB_READONLY env var
@@ -88,3 +96,4 @@ avec une interface web moderne et une API backend. Interfacable avec Claude.
8896
| 2026-02-14 | Sprint 5.1 filtres avances | FilterPanel, query params backend, persistence URL, 100 tests verts |
8997
| 2026-02-14 | Sprint 5.2 operations en masse | Bulk delete/tag/type, BulkActionBar, Dashboard homepage, 126 tests verts |
9098
| 2026-02-14 | Sprint 5.3 detection doublons | Endpoint Union-Find, page /duplicates, slider seuil, 140 tests verts |
99+
| 2026-02-14 | Sprint 6 timeline + qualite | Timeline chronologique, QualityVoter thumbs up/down, 171 tests verts |

client/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MemoryDetail } from './pages/MemoryDetail';
55
import { Dashboard } from './pages/Dashboard';
66
import { GraphView } from './pages/GraphView';
77
import { Duplicates } from './pages/Duplicates';
8+
import { Timeline } from './pages/Timeline';
89
import { Logo } from './components/Logo';
910

1011
const queryClient = new QueryClient({
@@ -65,6 +66,9 @@ function App() {
6566
<NavLink to="/" end style={({ isActive }) => isActive ? activeStyle : navStyle}>
6667
Dashboard
6768
</NavLink>
69+
<NavLink to="/timeline" style={({ isActive }) => isActive ? activeStyle : navStyle}>
70+
Timeline
71+
</NavLink>
6872
<NavLink to="/memories" style={({ isActive }) => isActive ? activeStyle : navStyle}>
6973
Memoires
7074
</NavLink>
@@ -84,6 +88,7 @@ function App() {
8488
<main>
8589
<Routes>
8690
<Route path="/" element={<Dashboard />} />
91+
<Route path="/timeline" element={<Timeline />} />
8792
<Route path="/memories" element={<MemoryList />} />
8893
<Route path="/memories/:hash" element={<MemoryDetail />} />
8994
<Route path="/duplicates" element={<Duplicates />} />
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useState } from 'react';
2+
import { QualityIndicator } from './QualityIndicator';
3+
import { useRateMemory } from '../hooks/useMutations';
4+
5+
interface QualityVoterProps {
6+
hash: string;
7+
score?: number | null;
8+
compact?: boolean;
9+
}
10+
11+
export function QualityVoter({ hash, score, compact = false }: QualityVoterProps) {
12+
const rateMutation = useRateMemory(hash);
13+
const [flashColor, setFlashColor] = useState<string | null>(null);
14+
15+
function handleVote(vote: 'up' | 'down') {
16+
const color = vote === 'up' ? 'var(--success)' : 'var(--error)';
17+
setFlashColor(color);
18+
setTimeout(() => setFlashColor(null), 400);
19+
rateMutation.mutate(vote);
20+
}
21+
22+
const btnStyle: React.CSSProperties = {
23+
display: 'inline-flex',
24+
alignItems: 'center',
25+
justifyContent: 'center',
26+
width: compact ? '24px' : '28px',
27+
height: compact ? '24px' : '28px',
28+
fontSize: compact ? '12px' : '14px',
29+
border: '1px solid var(--border-default)',
30+
borderRadius: 'var(--radius-sm)',
31+
backgroundColor: 'var(--bg-elevated)',
32+
color: 'var(--text-secondary)',
33+
cursor: 'pointer',
34+
transition: 'all var(--transition-fast)',
35+
padding: 0,
36+
};
37+
38+
return (
39+
<span
40+
style={{
41+
display: 'inline-flex',
42+
alignItems: 'center',
43+
gap: compact ? '4px' : '6px',
44+
transition: 'background-color 0.3s ease',
45+
backgroundColor: flashColor ?? 'transparent',
46+
borderRadius: 'var(--radius-sm)',
47+
padding: '2px 4px',
48+
}}
49+
>
50+
<button
51+
onClick={(e) => { e.preventDefault(); handleVote('up'); }}
52+
disabled={rateMutation.isPending}
53+
aria-label="Vote up"
54+
style={btnStyle}
55+
>
56+
&#9650;
57+
</button>
58+
<QualityIndicator score={score} />
59+
<button
60+
onClick={(e) => { e.preventDefault(); handleVote('down'); }}
61+
disabled={rateMutation.isPending}
62+
aria-label="Vote down"
63+
style={btnStyle}
64+
>
65+
&#9660;
66+
</button>
67+
</span>
68+
);
69+
}

client/src/hooks/useMutations.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,25 @@ export function useDeleteMemory() {
4747
},
4848
});
4949
}
50+
51+
async function rateMemory(hash: string, vote: 'up' | 'down'): Promise<{ quality_score: number }> {
52+
const res = await fetch(`/api/memories/${hash}/rate`, {
53+
method: 'POST',
54+
headers: { 'Content-Type': 'application/json' },
55+
body: JSON.stringify({ vote }),
56+
});
57+
if (!res.ok) throw new Error('Erreur lors du vote');
58+
return res.json();
59+
}
60+
61+
export function useRateMemory(hash: string) {
62+
const queryClient = useQueryClient();
63+
return useMutation({
64+
mutationFn: (vote: 'up' | 'down') => rateMemory(hash, vote),
65+
onSuccess: () => {
66+
queryClient.invalidateQueries({ queryKey: ['memory', hash] });
67+
queryClient.invalidateQueries({ queryKey: ['memories'] });
68+
queryClient.invalidateQueries({ queryKey: ['timeline'] });
69+
},
70+
});
71+
}

client/src/hooks/useTimeline.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import type { TimelineResponse } from '../types';
3+
4+
async function fetchTimeline(): Promise<TimelineResponse> {
5+
const res = await fetch('/api/memories/timeline');
6+
if (!res.ok) throw new Error('Erreur lors du chargement de la timeline');
7+
return res.json();
8+
}
9+
10+
export function useTimeline() {
11+
return useQuery({
12+
queryKey: ['timeline'],
13+
queryFn: fetchTimeline,
14+
});
15+
}

client/src/pages/MemoryDetail.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
33
import { useMemory, useMemoryGraph } from '../hooks/useMemory';
44
import { useUpdateMemory, useDeleteMemory } from '../hooks/useMutations';
55
import { TagBadge } from '../components/TagBadge';
6-
import { QualityIndicator } from '../components/QualityIndicator';
6+
import { QualityVoter } from '../components/QualityVoter';
77

88
const btnBase: React.CSSProperties = {
99
padding: '6px 14px',
@@ -126,7 +126,7 @@ export function MemoryDetail() {
126126
</span>
127127
)
128128
)}
129-
<QualityIndicator score={qualityScore} />
129+
<QualityVoter hash={hash!} score={qualityScore} />
130130

131131
<div style={{ marginLeft: 'auto', display: 'flex', gap: '8px' }}>
132132
{editing ? (

client/src/pages/MemoryList.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FilterPanel } from '../components/FilterPanel';
99
import { TagBadge } from '../components/TagBadge';
1010
import { Pagination } from '../components/Pagination';
1111
import { BulkActionBar } from '../components/BulkActionBar';
12+
import { QualityVoter } from '../components/QualityVoter';
1213
import type { Memory, MemoryFilters } from '../types';
1314

1415
const PAGE_SIZE = 20;
@@ -228,6 +229,7 @@ export function MemoryList() {
228229
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Contenu</th>
229230
<th style={{ padding: '12px 16px', width: '90px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Type</th>
230231
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Tags</th>
232+
<th style={{ padding: '12px 16px', width: '110px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Qualite</th>
231233
{showSimilarity && <th style={{ padding: '12px 16px', width: '80px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Score</th>}
232234
<th style={{ padding: '12px 16px', width: '140px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Date</th>
233235
</tr>
@@ -281,6 +283,13 @@ export function MemoryList() {
281283
))}
282284
</div>
283285
</td>
286+
<td style={{ padding: '12px 16px' }}>
287+
<QualityVoter
288+
hash={m.content_hash}
289+
score={(m.metadata as Record<string, unknown> | null)?.quality_score as number | undefined}
290+
compact
291+
/>
292+
</td>
284293
{showSimilarity && (
285294
<td style={{ padding: '12px 16px', fontSize: '13px', fontWeight: 600, color: 'var(--success)' }}>
286295
{m.similarity != null ? `${(m.similarity * 100).toFixed(0)}%` : '--'}

client/src/pages/Timeline.tsx

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { Link } from 'react-router-dom';
2+
import { useTimeline } from '../hooks/useTimeline';
3+
import { TagBadge } from '../components/TagBadge';
4+
5+
export function Timeline() {
6+
const { data, isLoading, isError } = useTimeline();
7+
8+
if (isLoading) {
9+
return <p style={{ color: 'var(--text-muted)' }}>Chargement...</p>;
10+
}
11+
12+
if (isError) {
13+
return <p style={{ color: 'var(--error)' }}>Erreur lors du chargement de la timeline.</p>;
14+
}
15+
16+
if (!data || data.groups.length === 0) {
17+
return (
18+
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)' }}>
19+
<p>Aucune memoire dans la timeline.</p>
20+
</div>
21+
);
22+
}
23+
24+
return (
25+
<div>
26+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '24px' }}>
27+
<h2 style={{ fontSize: '18px', fontWeight: 700, margin: 0, color: 'var(--text-primary)' }}>
28+
Timeline
29+
</h2>
30+
<span style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
31+
{data.total} memoire{data.total > 1 ? 's' : ''}
32+
</span>
33+
</div>
34+
35+
<div style={{ position: 'relative', paddingLeft: '24px' }}>
36+
{/* Ligne verticale continue */}
37+
<div style={{
38+
position: 'absolute',
39+
left: '7px',
40+
top: '0',
41+
bottom: '0',
42+
width: '2px',
43+
backgroundColor: 'var(--border-default)',
44+
}} />
45+
46+
{data.groups.map((group) => (
47+
<div key={group.date} style={{ marginBottom: '32px', position: 'relative' }}>
48+
{/* Point sur la ligne */}
49+
<div style={{
50+
position: 'absolute',
51+
left: '-21px',
52+
top: '4px',
53+
width: '12px',
54+
height: '12px',
55+
borderRadius: '50%',
56+
backgroundColor: 'var(--accent-primary)',
57+
border: '2px solid var(--bg-base)',
58+
}} />
59+
60+
{/* Header date */}
61+
<div style={{
62+
fontSize: '14px',
63+
fontWeight: 600,
64+
color: 'var(--text-primary)',
65+
marginBottom: '12px',
66+
}}>
67+
{group.date}
68+
<span style={{
69+
marginLeft: '8px',
70+
fontSize: '12px',
71+
fontWeight: 400,
72+
color: 'var(--text-muted)',
73+
}}>
74+
({group.count})
75+
</span>
76+
</div>
77+
78+
{/* Memoires du groupe */}
79+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
80+
{group.memories.map((m) => (
81+
<Link
82+
key={m.content_hash}
83+
to={`/memories/${m.content_hash}`}
84+
style={{
85+
display: 'block',
86+
textDecoration: 'none',
87+
padding: '12px 16px',
88+
backgroundColor: 'var(--bg-surface)',
89+
borderRadius: 'var(--radius-md)',
90+
border: '1px solid var(--border-subtle)',
91+
transition: 'all var(--transition-fast)',
92+
}}
93+
>
94+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
95+
{m.memory_type && (
96+
<span style={{
97+
padding: '2px 8px',
98+
fontSize: '11px',
99+
fontWeight: 500,
100+
backgroundColor: 'var(--info-dim)',
101+
color: 'var(--info)',
102+
borderRadius: 'var(--radius-sm)',
103+
}}>
104+
{m.memory_type}
105+
</span>
106+
)}
107+
<span style={{ fontSize: '11px', color: 'var(--text-muted)', marginLeft: 'auto' }}>
108+
{new Date(m.created_at_iso).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })}
109+
</span>
110+
</div>
111+
<div style={{
112+
fontSize: '13px',
113+
color: 'var(--text-primary)',
114+
lineHeight: 1.5,
115+
marginBottom: m.tags.length > 0 ? '8px' : '0',
116+
}}>
117+
{m.content.length > 150 ? m.content.substring(0, 150) + '...' : m.content}
118+
</div>
119+
{m.tags.length > 0 && (
120+
<div>
121+
{m.tags.map((tag) => (
122+
<TagBadge key={tag} tag={tag} />
123+
))}
124+
</div>
125+
)}
126+
</Link>
127+
))}
128+
</div>
129+
</div>
130+
))}
131+
</div>
132+
</div>
133+
);
134+
}

0 commit comments

Comments
 (0)