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 (
-
- );
- }
-
- 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}
-
-
-
-
- Précédent
-
-
- Suivant
-
-
-
-
- )}
- >
- )}
-
- );
-}
+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 (
+
+ );
+ }
+
+ 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}
+
+
+
+
+ Précédent
+
+
+ Suivant
+
+
+
+
+ );
+}
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,
+ };
+}