Skip to content

Commit 26c293b

Browse files
pfillion42claude
andcommitted
refactor: replace quality +/- buttons with 5-star rating system
Stars are more intuitive than abstract increment buttons. Backend now accepts { score: 0-1 } for direct rating in addition to the existing { rating: 1|-1 }. Frontend shows clickable stars with hover preview, compact mode for list view. 175 tests (114 server + 61 client), all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d1b5e1d commit 26c293b

5 files changed

Lines changed: 135 additions & 92 deletions

File tree

Lines changed: 57 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useState } from 'react';
2-
import { QualityIndicator } from './QualityIndicator';
32
import { useRateMemory } from '../hooks/useMutations';
43

54
interface QualityVoterProps {
@@ -8,62 +7,76 @@ interface QualityVoterProps {
87
compact?: boolean;
98
}
109

10+
// Convertir score 0-1 en etoiles 1-5 (0.2=1, 0.4=2, ..., 1.0=5)
11+
function scoreToStars(score: number | null | undefined): number {
12+
if (score == null) return 0;
13+
return Math.max(0, Math.min(5, Math.round(score * 5)));
14+
}
15+
16+
// Convertir etoiles 1-5 en score 0-1
17+
function starsToScore(stars: number): number {
18+
return stars / 5;
19+
}
20+
1121
export function QualityVoter({ hash, score, compact = false }: QualityVoterProps) {
1222
const rateMutation = useRateMemory(hash);
13-
const [flashColor, setFlashColor] = useState<string | null>(null);
23+
const [hoverStar, setHoverStar] = useState<number>(0);
24+
const currentStars = scoreToStars(score);
1425

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);
26+
function handleClick(star: number) {
27+
rateMutation.mutate(starsToScore(star));
2028
}
2129

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-
};
30+
const starSize = compact ? '14px' : '18px';
3731

3832
return (
3933
<span
4034
style={{
4135
display: 'inline-flex',
4236
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',
37+
gap: '1px',
4838
}}
39+
onMouseLeave={() => setHoverStar(0)}
4940
>
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>
41+
{[1, 2, 3, 4, 5].map((star) => {
42+
const filled = hoverStar > 0 ? star <= hoverStar : star <= currentStars;
43+
return (
44+
<button
45+
key={star}
46+
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleClick(star); }}
47+
onMouseEnter={() => setHoverStar(star)}
48+
disabled={rateMutation.isPending}
49+
aria-label={`${star} etoile${star > 1 ? 's' : ''}`}
50+
title={`${star}/5`}
51+
style={{
52+
display: 'inline-flex',
53+
alignItems: 'center',
54+
justifyContent: 'center',
55+
background: 'none',
56+
border: 'none',
57+
cursor: 'pointer',
58+
padding: '0 1px',
59+
fontSize: starSize,
60+
color: filled ? 'var(--warning)' : 'var(--border-default)',
61+
transition: 'color 0.15s ease, transform 0.1s ease',
62+
transform: hoverStar === star ? 'scale(1.2)' : 'scale(1)',
63+
lineHeight: 1,
64+
}}
65+
>
66+
{filled ? '\u2605' : '\u2606'}
67+
</button>
68+
);
69+
})}
70+
{!compact && (
71+
<span style={{
72+
fontSize: '11px',
73+
color: 'var(--text-muted)',
74+
marginLeft: '6px',
75+
minWidth: '28px',
76+
}}>
77+
{currentStars > 0 ? `${currentStars}/5` : 'N/A'}
78+
</span>
79+
)}
6780
</span>
6881
);
6982
}

client/src/hooks/useMutations.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,20 @@ export function useDeleteMemory() {
4848
});
4949
}
5050

51-
async function rateMemory(hash: string, vote: 'up' | 'down'): Promise<{ quality_score: number }> {
51+
async function rateMemory(hash: string, score: number): Promise<{ quality_score: number }> {
5252
const res = await fetch(`/api/memories/${hash}/rate`, {
5353
method: 'POST',
5454
headers: { 'Content-Type': 'application/json' },
55-
body: JSON.stringify({ vote }),
55+
body: JSON.stringify({ score }),
5656
});
57-
if (!res.ok) throw new Error('Erreur lors du vote');
57+
if (!res.ok) throw new Error('Erreur lors de la notation');
5858
return res.json();
5959
}
6060

6161
export function useRateMemory(hash: string) {
6262
const queryClient = useQueryClient();
6363
return useMutation({
64-
mutationFn: (vote: 'up' | 'down') => rateMemory(hash, vote),
64+
mutationFn: (score: number) => rateMemory(hash, score),
6565
onSuccess: () => {
6666
queryClient.invalidateQueries({ queryKey: ['memory', hash] });
6767
queryClient.invalidateQueries({ queryKey: ['memories'] });

client/tests/QualityVoter.test.tsx

Lines changed: 24 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
55
import { MemoryRouter } from 'react-router-dom';
66
import { QualityVoter } from '../src/components/QualityVoter';
77

8-
function renderVoter(props: { hash: string; score?: number | null }) {
8+
function renderVoter(props: { hash: string; score?: number | null; compact?: boolean }) {
99
const queryClient = new QueryClient({
1010
defaultOptions: { queries: { retry: false } },
1111
});
1212
return render(
1313
<QueryClientProvider client={queryClient}>
1414
<MemoryRouter>
15-
<QualityVoter hash={props.hash} score={props.score} />
15+
<QualityVoter hash={props.hash} score={props.score} compact={props.compact} />
1616
</MemoryRouter>
1717
</QueryClientProvider>
1818
);
@@ -23,56 +23,37 @@ beforeEach(() => {
2323
});
2424

2525
describe('QualityVoter', () => {
26-
it('affiche le score via QualityIndicator', () => {
27-
renderVoter({ hash: 'hash_aaa', score: 0.85 });
28-
expect(screen.getByText('85%')).toBeDefined();
26+
it('affiche 5 boutons etoiles', () => {
27+
renderVoter({ hash: 'hash_aaa', score: 0.6 });
28+
const buttons = screen.getAllByRole('button');
29+
expect(buttons).toHaveLength(5);
2930
});
3031

31-
it('affiche -- quand le score est null', () => {
32+
it('affiche le score N/A quand score est null', () => {
3233
renderVoter({ hash: 'hash_aaa', score: null });
33-
expect(screen.getByText('--')).toBeDefined();
34+
expect(screen.getByText('N/A')).toBeDefined();
3435
});
3536

36-
it('affiche les boutons vote up et vote down', () => {
37-
renderVoter({ hash: 'hash_aaa', score: 0.5 });
38-
expect(screen.getByRole('button', { name: /vote up/i })).toBeDefined();
39-
expect(screen.getByRole('button', { name: /vote down/i })).toBeDefined();
37+
it('affiche le score en etoiles (0.6 = 3/5)', () => {
38+
renderVoter({ hash: 'hash_aaa', score: 0.6 });
39+
expect(screen.getByText('3/5')).toBeDefined();
4040
});
4141

42-
it('appelle POST /api/memories/:hash/rate avec up au clic sur vote up', async () => {
43-
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
44-
ok: true,
45-
json: () => Promise.resolve({ quality_score: 0.9 }),
46-
} as Response);
47-
48-
renderVoter({ hash: 'hash_aaa', score: 0.5 });
49-
50-
const user = userEvent.setup();
51-
await user.click(screen.getByRole('button', { name: /vote up/i }));
52-
53-
await waitFor(() => {
54-
const calls = fetchSpy.mock.calls;
55-
const rateCall = calls.find((c) => {
56-
const url = typeof c[0] === 'string' ? c[0] : (c[0] as Request).url;
57-
return url.includes('/api/memories/hash_aaa/rate');
58-
});
59-
expect(rateCall).toBeDefined();
60-
const opts = rateCall![1] as RequestInit;
61-
expect(opts.method).toBe('POST');
62-
expect(JSON.parse(opts.body as string)).toEqual({ vote: 'up' });
63-
});
42+
it('affiche le score en etoiles (1.0 = 5/5)', () => {
43+
renderVoter({ hash: 'hash_aaa', score: 1.0 });
44+
expect(screen.getByText('5/5')).toBeDefined();
6445
});
6546

66-
it('appelle POST /api/memories/:hash/rate avec down au clic sur vote down', async () => {
47+
it('envoie score 0.6 au clic sur la 3e etoile', async () => {
6748
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
6849
ok: true,
69-
json: () => Promise.resolve({ quality_score: 0.3 }),
50+
json: () => Promise.resolve({ quality_score: 0.6 }),
7051
} as Response);
7152

72-
renderVoter({ hash: 'hash_aaa', score: 0.5 });
53+
renderVoter({ hash: 'hash_aaa', score: 0.4 });
7354

7455
const user = userEvent.setup();
75-
await user.click(screen.getByRole('button', { name: /vote down/i }));
56+
await user.click(screen.getByRole('button', { name: /3 etoiles/i }));
7657

7758
await waitFor(() => {
7859
const calls = fetchSpy.mock.calls;
@@ -83,7 +64,12 @@ describe('QualityVoter', () => {
8364
expect(rateCall).toBeDefined();
8465
const opts = rateCall![1] as RequestInit;
8566
expect(opts.method).toBe('POST');
86-
expect(JSON.parse(opts.body as string)).toEqual({ vote: 'down' });
67+
expect(JSON.parse(opts.body as string)).toEqual({ score: 0.6 });
8768
});
8869
});
70+
71+
it('masque le label score en mode compact', () => {
72+
renderVoter({ hash: 'hash_aaa', score: 0.8, compact: true });
73+
expect(screen.queryByText('4/5')).toBeNull();
74+
});
8975
});

server/src/routes/memories.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -567,12 +567,27 @@ export function createMemoriesRouter(db: DatabaseType, options: RouterOptions =
567567
});
568568
});
569569

570-
// POST /api/memories/:hash/rate - vote qualite thumbs up/down
570+
// POST /api/memories/:hash/rate - noter la qualite d'une memoire
571+
// Accepte { score: 0-1 } (valeur directe) ou { rating: 1|-1 } (increment)
571572
router.post('/memories/:hash/rate', (req: Request, res: Response) => {
572573
const { hash } = req.params;
573-
const { rating } = req.body;
574+
const { rating, score } = req.body;
574575

575-
if (rating !== 1 && rating !== -1) {
576+
// Validation : soit score (0-1), soit rating (1/-1)
577+
const hasScore = score !== undefined && score !== null;
578+
const hasRating = rating !== undefined && rating !== null;
579+
580+
if (!hasScore && !hasRating) {
581+
res.status(400).json({ error: 'Le champ score (0-1) ou rating (1/-1) est requis' });
582+
return;
583+
}
584+
585+
if (hasScore && (typeof score !== 'number' || score < 0 || score > 1)) {
586+
res.status(400).json({ error: 'Le champ score doit etre un nombre entre 0 et 1' });
587+
return;
588+
}
589+
590+
if (hasRating && rating !== 1 && rating !== -1) {
576591
res.status(400).json({ error: 'Le champ rating doit etre 1 ou -1' });
577592
return;
578593
}
@@ -596,9 +611,16 @@ export function createMemoriesRouter(db: DatabaseType, options: RouterOptions =
596611
// metadata invalide, initialiser a vide
597612
}
598613

599-
const currentScore = typeof metadata.quality_score === 'number' ? metadata.quality_score : 0.5;
600-
const delta = rating === 1 ? 0.1 : -0.1;
601-
const newScore = Math.min(1, Math.max(0, currentScore + delta));
614+
let newScore: number;
615+
if (hasScore) {
616+
// Score direct (etoiles : 1=0.2, 2=0.4, 3=0.6, 4=0.8, 5=1.0)
617+
newScore = score;
618+
} else {
619+
// Increment +/- 0.1
620+
const currentScore = typeof metadata.quality_score === 'number' ? metadata.quality_score : 0.5;
621+
const delta = rating === 1 ? 0.1 : -0.1;
622+
newScore = Math.min(1, Math.max(0, currentScore + delta));
623+
}
602624

603625
// Arrondir pour eviter les erreurs de virgule flottante
604626
metadata.quality_score = Math.round(newScore * 1e10) / 1e10;

server/tests/memories.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,28 @@ describe('POST /api/memories/:hash/rate', () => {
861861
expect(res.status).toBe(404);
862862
});
863863

864+
it('accepte un score direct (0-1) pour notation etoiles', async () => {
865+
const res = await request(app)
866+
.post('/api/memories/hash_aaa111/rate')
867+
.send({ score: 0.8 });
868+
expect(res.status).toBe(200);
869+
expect(res.body.quality_score).toBe(0.8);
870+
});
871+
872+
it('retourne 400 pour un score hors limites', async () => {
873+
const res = await request(app)
874+
.post('/api/memories/hash_aaa111/rate')
875+
.send({ score: 1.5 });
876+
expect(res.status).toBe(400);
877+
});
878+
879+
it('retourne 400 pour un score negatif', async () => {
880+
const res = await request(app)
881+
.post('/api/memories/hash_aaa111/rate')
882+
.send({ score: -0.1 });
883+
expect(res.status).toBe(400);
884+
});
885+
864886
it('met a jour updated_at apres un vote', async () => {
865887
const before = await request(app).get('/api/memories/hash_bbb222');
866888
const beforeUpdated = before.body.updated_at;

0 commit comments

Comments
 (0)