From 1ea128b60df027cbaffc90f9ed787ec4b491af3e Mon Sep 17 00:00:00 2001 From: senke Date: Fri, 6 Feb 2026 01:34:58 +0100 Subject: [PATCH] refactor(tracks): TrackHistory module, re-export, stories, tests - Module track-history: types, useTrackHistory, Header, Empty, ItemRow, Pagination, Skeleton, trackHistoryUtils - Re-export from TrackHistory.tsx - Stories: Default, Loading, Empty, Error (MSW) - Tests: mock @/features/tracks/services/trackHistoryService, formatHistoryDate defensive, pagination/error tests fixed Co-authored-by: Cursor --- .../components/TrackHistory.stories.tsx | 106 ++++-- .../tracks/components/TrackHistory.test.tsx | 70 ++-- .../tracks/components/TrackHistory.tsx | 328 +----------------- .../components/track-history/TrackHistory.tsx | 84 +++++ .../track-history/TrackHistoryEmpty.tsx | 10 + .../track-history/TrackHistoryHeader.tsx | 19 + .../track-history/TrackHistoryItemRow.tsx | 80 +++++ .../track-history/TrackHistoryPagination.tsx | 51 +++ .../track-history/TrackHistorySkeleton.tsx | 31 ++ .../tracks/components/track-history/index.ts | 8 + .../track-history/trackHistoryUtils.ts | 99 ++++++ .../tracks/components/track-history/types.ts | 15 + .../track-history/useTrackHistory.ts | 68 ++++ 13 files changed, 603 insertions(+), 366 deletions(-) create mode 100644 apps/web/src/features/tracks/components/track-history/TrackHistory.tsx create mode 100644 apps/web/src/features/tracks/components/track-history/TrackHistoryEmpty.tsx create mode 100644 apps/web/src/features/tracks/components/track-history/TrackHistoryHeader.tsx create mode 100644 apps/web/src/features/tracks/components/track-history/TrackHistoryItemRow.tsx create mode 100644 apps/web/src/features/tracks/components/track-history/TrackHistoryPagination.tsx create mode 100644 apps/web/src/features/tracks/components/track-history/TrackHistorySkeleton.tsx create mode 100644 apps/web/src/features/tracks/components/track-history/index.ts create mode 100644 apps/web/src/features/tracks/components/track-history/trackHistoryUtils.ts create mode 100644 apps/web/src/features/tracks/components/track-history/types.ts create mode 100644 apps/web/src/features/tracks/components/track-history/useTrackHistory.ts diff --git a/apps/web/src/features/tracks/components/TrackHistory.stories.tsx b/apps/web/src/features/tracks/components/TrackHistory.stories.tsx index 3b4971dc3..3a73a161c 100644 --- a/apps/web/src/features/tracks/components/TrackHistory.stories.tsx +++ b/apps/web/src/features/tracks/components/TrackHistory.stories.tsx @@ -1,32 +1,92 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { TrackHistory } from './TrackHistory'; +import { http, HttpResponse } from 'msw'; +import { TrackHistory, TrackHistorySkeleton } from './TrackHistory'; -// Ideally we mock the service, but here we might see a loading state if service fails. -// Since we can't easily mock module imports without specific addon configuration in this environment, -// we will accept the loading/error state as a valid visual test, OR rely on a mock service if one exists. -// However, looking at the code, TrackHistory isn't using React Query, it's using useEffect. -// So we can't seed it via QueryClient. -// We accepted that limitation for now or we build a mock if required. -// For now, let's render it - it will likely show "Loading" or "Error", which is still useful. - -const meta = { - title: 'Components/Features/Tracks/TrackHistory', - component: TrackHistory, - tags: ['autodocs'], - decorators: [ - (Story) => ( -
- -
- ), - ], -} satisfies Meta; +const meta: Meta = { + title: 'Components/Features/Tracks/TrackHistory', + component: TrackHistory, + parameters: { + layout: 'padded', + docs: { + description: { + component: + "Historique des modifications d'un track avec timeline et pagination.", + }, + }, + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; export default meta; type Story = StoryObj; export const Default: Story = { - args: { - trackId: 't1', + args: { trackId: 't1' }, + parameters: { + docs: { + description: { + story: 'MSW retourne 2 entrées (created, updated).', + }, }, + }, +}; + +export const Loading: Story = { + render: () => , + parameters: { + docs: { + description: { + story: 'Skeleton de la timeline (header + 3 lignes).', + }, + }, + }, +}; + +export const Empty: Story = { + args: { trackId: 'empty' }, + parameters: { + msw: { + handlers: [ + http.get('*/api/v1/tracks/empty/history', () => + HttpResponse.json({ + success: true, + data: { history: [], total: 0, limit: 50, offset: 0 }, + }), + ), + ], + }, + docs: { + description: { + story: "MSW retourne une liste vide.", + }, + }, + }, +}; + +export const Error: Story = { + args: { trackId: 'error' }, + parameters: { + msw: { + handlers: [ + http.get('*/api/v1/tracks/error/history', () => + HttpResponse.json( + { success: false, error: { message: 'Track introuvable' } }, + { status: 404 }, + ), + ), + ], + }, + docs: { + description: { + story: 'MSW retourne 404. Affichage Alert destructive.', + }, + }, + }, }; diff --git a/apps/web/src/features/tracks/components/TrackHistory.test.tsx b/apps/web/src/features/tracks/components/TrackHistory.test.tsx index 32b71b94d..db2e8c0a4 100644 --- a/apps/web/src/features/tracks/components/TrackHistory.test.tsx +++ b/apps/web/src/features/tracks/components/TrackHistory.test.tsx @@ -4,12 +4,25 @@ import { TrackHistory } from './TrackHistory'; import { getTrackHistory, TrackHistoryError, - TrackHistoryItem, -} from '../services/trackHistoryService'; +} from '@/features/tracks/services/trackHistoryService'; +import type { TrackHistory } from '@/features/tracks/services/trackHistoryService'; import { useToast } from '@/hooks/useToast'; -// Mock services -vi.mock('../services/trackHistoryService'); +// Mock services (path must match hook: track-history/useTrackHistory imports ../../services/trackHistoryService) +vi.mock('@/features/tracks/services/trackHistoryService', () => ({ + getTrackHistory: vi.fn(), + TrackHistoryError: class TrackHistoryError extends Error { + constructor( + message: string, + public code: string, + public retryable: boolean, + public originalError?: unknown, + ) { + super(message); + this.name = 'TrackHistoryError'; + } + }, +})); vi.mock('@/hooks/useToast'); describe('TrackHistory', () => { @@ -23,7 +36,7 @@ describe('TrackHistory', () => { vi.mocked(useToast).mockReturnValue(mockToast); }); - const mockHistoryItems: TrackHistoryItem[] = [ + const mockHistoryItems: TrackHistory[] = [ { id: 1, track_id: 123, @@ -120,14 +133,15 @@ describe('TrackHistory', () => { }); it('should display error message on load error', async () => { - const error = new TrackHistoryError('Failed to load', 'SERVER', true); - vi.mocked(getTrackHistory).mockRejectedValue(error); + const loadError = new TrackHistoryError('Failed to load', 'SERVER', true); + vi.mocked(getTrackHistory).mockImplementation(() => + Promise.reject(loadError), + ); - render(); + render(); - await waitFor(() => { - expect(screen.getByText('Failed to load')).toBeInTheDocument(); - }); + const message = await screen.findByText('Failed to load', {}, { timeout: 3000 }); + expect(message).toBeInTheDocument(); }); it('should display old and new values when available', async () => { @@ -147,7 +161,7 @@ describe('TrackHistory', () => { }); it('should handle pagination', async () => { - const largeHistory: TrackHistoryItem[] = Array.from( + const largeHistory: TrackHistory[] = Array.from( { length: 10 }, (_, i) => ({ id: i + 1, @@ -210,23 +224,37 @@ describe('TrackHistory', () => { }); it('should disable next button on last page', async () => { - vi.mocked(getTrackHistory).mockResolvedValue({ - history: mockHistoryItems.slice(0, 2), - total: 2, - limit: 2, - offset: 0, - }); + vi.mocked(getTrackHistory) + .mockResolvedValueOnce({ + history: mockHistoryItems.slice(0, 2), + total: 3, + limit: 2, + offset: 0, + }) + .mockResolvedValueOnce({ + history: mockHistoryItems.slice(2, 3), + total: 3, + limit: 2, + offset: 2, + }); render(); await waitFor(() => { - const nextButton = screen.getByText('Suivant'); - expect(nextButton).toBeDisabled(); + expect(screen.getByText(/Affichage 1 - 2 sur 3/)).toBeInTheDocument(); + }); + + const nextButton = screen.getByText('Suivant'); + fireEvent.click(nextButton); + + await waitFor(() => { + const nextButtonAfter = screen.getByText('Suivant'); + expect(nextButtonAfter).toBeDisabled(); }); }); it('should display all action types correctly', async () => { - const allActions: TrackHistoryItem[] = [ + const allActions: TrackHistory[] = [ { id: 1, track_id: 123, diff --git a/apps/web/src/features/tracks/components/TrackHistory.tsx b/apps/web/src/features/tracks/components/TrackHistory.tsx index b504a2ca6..aa20e7990 100644 --- a/apps/web/src/features/tracks/components/TrackHistory.tsx +++ b/apps/web/src/features/tracks/components/TrackHistory.tsx @@ -1,324 +1,8 @@ -import { useEffect, useState } from 'react'; -import { - getTrackHistory, - TrackHistory as TrackHistoryItem, - TrackHistoryError, - TrackHistoryAction, -} from '../services/trackHistoryService'; -import { LoadingSpinner } from '@/components/ui/loading-spinner'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; -import { - History, - Calendar, - Plus, - Edit, - Trash2, - Eye, - EyeOff, - RotateCcw, - ChevronLeft, - ChevronRight, -} from 'lucide-react'; - /** - * TrackHistory Component - * T0330: Composant pour afficher l'historique des modifications d'un track avec timeline + * TrackHistory — re-export from feature module. */ - -interface TrackHistoryProps { - trackId: string; - className?: string; - limit?: number; -} - -export function TrackHistory({ - trackId, - className, - limit = 50, -}: TrackHistoryProps) { - const [history, setHistory] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [total, setTotal] = useState(0); - const [currentOffset, setCurrentOffset] = useState(0); - const [currentLimit] = useState(limit); - - useEffect(() => { - loadHistory(); - }, [trackId, currentOffset]); - - const loadHistory = async () => { - setLoading(true); - setError(null); - try { - const data = await getTrackHistory(trackId, { - limit: currentLimit, - offset: currentOffset, - }); - setHistory(data.history); - setTotal(data.total); - } catch (err) { - if (err instanceof TrackHistoryError) { - setError(err.message); - } else { - setError("Impossible de charger l'historique"); - } - } finally { - setLoading(false); - } - }; - - const formatDate = (dateString: string): string => { - const date = new Date(dateString); - return new Intl.DateTimeFormat('fr-FR', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }).format(date); - }; - - const getActionIcon = (action: TrackHistoryAction) => { - switch (action) { - case 'created': - return Plus; - case 'updated': - return Edit; - case 'deleted': - return Trash2; - case 'published': - return Eye; - case 'unpublished': - return EyeOff; - case 'restored': - return RotateCcw; - default: - return History; - } - }; - - const getActionLabel = (action: TrackHistoryAction): string => { - switch (action) { - case 'created': - return 'Créé'; - case 'updated': - return 'Modifié'; - case 'deleted': - return 'Supprimé'; - case 'published': - return 'Publié'; - case 'unpublished': - return 'Dépublié'; - case 'restored': - return 'Restauré'; - default: - return action; - } - }; - - const getActionColor = (action: TrackHistoryAction): string => { - switch (action) { - case 'created': - return 'text-kodo-lime bg-kodo-lime/10'; - case 'updated': - return 'text-kodo-steel bg-kodo-steel/10'; - case 'deleted': - return 'text-kodo-red bg-kodo-red/10'; - case 'published': - return 'text-kodo-magenta bg-kodo-magenta/10'; - case 'unpublished': - return 'text-kodo-gold bg-kodo-gold/10'; - case 'restored': - return 'text-cyan-600 bg-cyan-50'; - default: - return 'text-kodo-content-dim bg-kodo-void'; - } - }; - - const parseValue = (value?: string): any => { - if (!value) return null; - try { - return JSON.parse(value); - } catch { - return value; - } - }; - - const formatValue = (value: any): string => { - if (value === null || value === undefined) return ''; - if (typeof value === 'string') return value; - if (typeof value === 'object') { - return JSON.stringify(value, null, 2); - } - return String(value); - }; - - const handlePreviousPage = () => { - if (currentOffset > 0) { - setCurrentOffset(Math.max(0, currentOffset - currentLimit)); - } - }; - - const handleNextPage = () => { - if (currentOffset + currentLimit < total) { - setCurrentOffset(currentOffset + currentLimit); - } - }; - - if (loading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
- - {error} - -
- ); - } - - const hasPreviousPage = currentOffset > 0; - const hasNextPage = currentOffset + currentLimit < total; - - return ( -
-
-
- -

- Historique des modifications -

- {total > 0 && ( - ({total}) - )} -
-
- - {history.length === 0 ? ( -
- -

Aucune modification enregistrée

-
- ) : ( - <> -
- {/* Timeline line */} -
- - {/* Timeline items */} -
- {history.map((item) => { - const Icon = getActionIcon(item.action); - const actionColor = getActionColor(item.action); - const oldValue = parseValue(item.old_value); - const newValue = parseValue(item.new_value); - - return ( -
- {/* Timeline dot */} -
- -
- - {/* Content */} -
-
-
- - {getActionLabel(item.action)} - - - #{item.id} - -
-
- - {formatDate(item.created_at)} -
-
- - {/* Values comparison */} - {(oldValue !== null || newValue !== null) && ( -
- {oldValue !== null && ( -
-
- Ancienne valeur: -
-
-                                {formatValue(oldValue)}
-                              
-
- )} - {newValue !== null && ( -
-
- Nouvelle valeur: -
-
-                                {formatValue(newValue)}
-                              
-
- )} -
- )} -
-
- ); - })} -
-
- - {/* Pagination */} - {total > currentLimit && ( -
-
- Affichage {currentOffset + 1} -{' '} - {Math.min(currentOffset + currentLimit, total)} sur {total} -
-
- - -
-
- )} - - )} -
- ); -} +export { + TrackHistory, + TrackHistorySkeleton, +} from './track-history'; +export type { TrackHistoryProps } from './track-history'; diff --git a/apps/web/src/features/tracks/components/track-history/TrackHistory.tsx b/apps/web/src/features/tracks/components/track-history/TrackHistory.tsx new file mode 100644 index 000000000..b29f4665f --- /dev/null +++ b/apps/web/src/features/tracks/components/track-history/TrackHistory.tsx @@ -0,0 +1,84 @@ +import { LoadingSpinner } from '@/components/ui/loading-spinner'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { cn } from '@/lib/utils'; +import { useTrackHistory } from './useTrackHistory'; +import { TrackHistoryHeader } from './TrackHistoryHeader'; +import { TrackHistoryEmpty } from './TrackHistoryEmpty'; +import { TrackHistoryItemRow } from './TrackHistoryItemRow'; +import { TrackHistoryPagination } from './TrackHistoryPagination'; +import type { TrackHistoryProps } from './types'; + +export function TrackHistory({ + trackId, + className, + limit = 50, +}: TrackHistoryProps) { + const { + history, + loading, + error, + total, + currentOffset, + limit: currentLimit, + hasPreviousPage, + hasNextPage, + handlePreviousPage, + handleNextPage, + } = useTrackHistory(trackId, limit); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + {error} + +
+ ); + } + + return ( +
+ + + {history.length === 0 ? ( + + ) : ( + <> +
+
+
+ {history.map((item) => ( + + ))} +
+
+ + {total > currentLimit && ( + + )} + + )} +
+ ); +} diff --git a/apps/web/src/features/tracks/components/track-history/TrackHistoryEmpty.tsx b/apps/web/src/features/tracks/components/track-history/TrackHistoryEmpty.tsx new file mode 100644 index 000000000..a6b8038fb --- /dev/null +++ b/apps/web/src/features/tracks/components/track-history/TrackHistoryEmpty.tsx @@ -0,0 +1,10 @@ +import { History } from 'lucide-react'; + +export function TrackHistoryEmpty() { + return ( +
+ +

Aucune modification enregistrée

+
+ ); +} diff --git a/apps/web/src/features/tracks/components/track-history/TrackHistoryHeader.tsx b/apps/web/src/features/tracks/components/track-history/TrackHistoryHeader.tsx new file mode 100644 index 000000000..1df8d5511 --- /dev/null +++ b/apps/web/src/features/tracks/components/track-history/TrackHistoryHeader.tsx @@ -0,0 +1,19 @@ +import { History } from 'lucide-react'; + +interface TrackHistoryHeaderProps { + total: number; +} + +export function TrackHistoryHeader({ total }: TrackHistoryHeaderProps) { + return ( +
+
+ +

Historique des modifications

+ {total > 0 && ( + ({total}) + )} +
+
+ ); +} diff --git a/apps/web/src/features/tracks/components/track-history/TrackHistoryItemRow.tsx b/apps/web/src/features/tracks/components/track-history/TrackHistoryItemRow.tsx new file mode 100644 index 000000000..a0803de5f --- /dev/null +++ b/apps/web/src/features/tracks/components/track-history/TrackHistoryItemRow.tsx @@ -0,0 +1,80 @@ +import { Calendar } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + getActionIcon, + getActionLabel, + getActionColor, + formatHistoryDate, + parseValue, + formatValue, +} from './trackHistoryUtils'; +import type { TrackHistoryItem } from '../../services/trackHistoryService'; + +interface TrackHistoryItemRowProps { + item: TrackHistoryItem; +} + +export function TrackHistoryItemRow({ item }: TrackHistoryItemRowProps) { + const Icon = getActionIcon(item.action); + const actionColor = getActionColor(item.action); + const oldValue = parseValue(item.old_value); + const newValue = parseValue(item.new_value); + + return ( +
+
+ +
+ +
+
+
+ + {getActionLabel(item.action)} + + #{item.id} +
+
+ + {formatHistoryDate(item.created_at)} +
+
+ + {(oldValue !== null || newValue !== null) && ( +
+ {oldValue !== null && ( +
+
+ Ancienne valeur: +
+
+                  {formatValue(oldValue)}
+                
+
+ )} + {newValue !== null && ( +
+
+ Nouvelle valeur: +
+
+                  {formatValue(newValue)}
+                
+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/features/tracks/components/track-history/TrackHistoryPagination.tsx b/apps/web/src/features/tracks/components/track-history/TrackHistoryPagination.tsx new file mode 100644 index 000000000..3ae10c86e --- /dev/null +++ b/apps/web/src/features/tracks/components/track-history/TrackHistoryPagination.tsx @@ -0,0 +1,51 @@ +import { Button } from '@/components/ui/button'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +interface TrackHistoryPaginationProps { + currentOffset: number; + limit: number; + total: number; + hasPreviousPage: boolean; + hasNextPage: boolean; + onPrevious: () => void; + onNext: () => void; +} + +export function TrackHistoryPagination({ + currentOffset, + limit, + total, + hasPreviousPage, + hasNextPage, + onPrevious, + onNext, +}: TrackHistoryPaginationProps) { + return ( +
+
+ Affichage {currentOffset + 1} -{' '} + {Math.min(currentOffset + limit, total)} sur {total} +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/features/tracks/components/track-history/TrackHistorySkeleton.tsx b/apps/web/src/features/tracks/components/track-history/TrackHistorySkeleton.tsx new file mode 100644 index 000000000..c17d051e8 --- /dev/null +++ b/apps/web/src/features/tracks/components/track-history/TrackHistorySkeleton.tsx @@ -0,0 +1,31 @@ +/** + * Skeleton for TrackHistory — header + timeline placeholders. + */ +export function TrackHistorySkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/features/tracks/components/track-history/index.ts b/apps/web/src/features/tracks/components/track-history/index.ts new file mode 100644 index 000000000..8266b1422 --- /dev/null +++ b/apps/web/src/features/tracks/components/track-history/index.ts @@ -0,0 +1,8 @@ +export { TrackHistory } from './TrackHistory'; +export { TrackHistorySkeleton } from './TrackHistorySkeleton'; +export { TrackHistoryHeader } from './TrackHistoryHeader'; +export { TrackHistoryEmpty } from './TrackHistoryEmpty'; +export { TrackHistoryItemRow } from './TrackHistoryItemRow'; +export { TrackHistoryPagination } from './TrackHistoryPagination'; +export { useTrackHistory } from './useTrackHistory'; +export type { TrackHistoryProps, TrackHistoryItem, TrackHistoryAction } from './types'; diff --git a/apps/web/src/features/tracks/components/track-history/trackHistoryUtils.ts b/apps/web/src/features/tracks/components/track-history/trackHistoryUtils.ts new file mode 100644 index 000000000..4a0fdad27 --- /dev/null +++ b/apps/web/src/features/tracks/components/track-history/trackHistoryUtils.ts @@ -0,0 +1,99 @@ +import { + History, + Calendar, + Plus, + Edit, + Trash2, + Eye, + EyeOff, + RotateCcw, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import type { TrackHistoryAction } from '../../services/trackHistoryService'; + +export function formatHistoryDate(dateString: string): string { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return dateString; + return new Intl.DateTimeFormat('fr-FR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date); +} + +export function getActionIcon(action: TrackHistoryAction): LucideIcon { + switch (action) { + case 'created': + return Plus; + case 'updated': + return Edit; + case 'deleted': + return Trash2; + case 'published': + return Eye; + case 'unpublished': + return EyeOff; + case 'restored': + return RotateCcw; + default: + return History; + } +} + +export function getActionLabel(action: TrackHistoryAction): string { + switch (action) { + case 'created': + return 'Créé'; + case 'updated': + return 'Modifié'; + case 'deleted': + return 'Supprimé'; + case 'published': + return 'Publié'; + case 'unpublished': + return 'Dépublié'; + case 'restored': + return 'Restauré'; + default: + return action; + } +} + +export function getActionColor(action: TrackHistoryAction): string { + switch (action) { + case 'created': + return 'text-kodo-lime bg-kodo-lime/10'; + case 'updated': + return 'text-kodo-steel bg-kodo-steel/10'; + case 'deleted': + return 'text-kodo-red bg-kodo-red/10'; + case 'published': + return 'text-kodo-magenta bg-kodo-magenta/10'; + case 'unpublished': + return 'text-kodo-gold bg-kodo-gold/10'; + case 'restored': + return 'text-cyan-600 bg-cyan-50'; + default: + return 'text-kodo-content-dim bg-kodo-void'; + } +} + +export function parseValue(value?: string): unknown { + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +export function formatValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'object') { + return JSON.stringify(value, null, 2); + } + return String(value); +} diff --git a/apps/web/src/features/tracks/components/track-history/types.ts b/apps/web/src/features/tracks/components/track-history/types.ts new file mode 100644 index 000000000..e60803266 --- /dev/null +++ b/apps/web/src/features/tracks/components/track-history/types.ts @@ -0,0 +1,15 @@ +/** + * TrackHistory — types. + */ +import type { + TrackHistory as TrackHistoryItem, + TrackHistoryAction, +} from '../../services/trackHistoryService'; + +export type { TrackHistoryItem, TrackHistoryAction }; + +export interface TrackHistoryProps { + trackId: string; + className?: string; + limit?: number; +} diff --git a/apps/web/src/features/tracks/components/track-history/useTrackHistory.ts b/apps/web/src/features/tracks/components/track-history/useTrackHistory.ts new file mode 100644 index 000000000..4c1fbef0b --- /dev/null +++ b/apps/web/src/features/tracks/components/track-history/useTrackHistory.ts @@ -0,0 +1,68 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + getTrackHistory, + TrackHistoryError, + type TrackHistory as TrackHistoryItem, +} from '../../services/trackHistoryService'; + +export function useTrackHistory(trackId: string, limit: number) { + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [total, setTotal] = useState(0); + const [currentOffset, setCurrentOffset] = useState(0); + + const loadHistory = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await getTrackHistory(trackId, { + limit, + offset: currentOffset, + }); + setHistory(data.history); + setTotal(data.total); + } catch (err) { + if (err instanceof TrackHistoryError) { + setError(err.message); + } else { + setError("Impossible de charger l'historique"); + } + } finally { + setLoading(false); + } + }, [trackId, currentOffset, limit]); + + useEffect(() => { + loadHistory(); + }, [loadHistory]); + + const hasPreviousPage = currentOffset > 0; + const hasNextPage = currentOffset + limit < total; + + const handlePreviousPage = useCallback(() => { + if (currentOffset > 0) { + setCurrentOffset((prev) => Math.max(0, prev - limit)); + } + }, [currentOffset, limit]); + + const handleNextPage = useCallback(() => { + if (currentOffset + limit < total) { + setCurrentOffset((prev) => prev + limit); + } + }, [currentOffset, limit, total]); + + return { + history, + loading, + error, + total, + currentOffset, + limit, + hasPreviousPage, + hasNextPage, + handlePreviousPage, + handleNextPage, + reload: loadHistory, + }; +}