veza/apps/web/src/components/OfflineIndicator.tsx
senke 8e9ee2f3a5 fix: stabilize builds, tests, and lint across all stacks
Complete stabilization pass bringing all 3 stacks to green:

Frontend (apps/web/):
- Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks
- Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified)
- Rename 306 story imports from @storybook/react to @storybook/react-vite
- Fix conditional hook call in useMediaQuery.ts useIsTablet
- Move useQuery to top of LoginPage.tsx component
- Remove useless try/catch in GearFormModal.tsx
- Fix stale closure in ResetPasswordPage.tsx handleChange
- Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio)
  no-ops since global StorybookDecorator already provides these — prevents
  nested Router / duplicate provider crashes in vitest-browser
- Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile)
- Update i18n initialization in test setup (await init before changeLanguage)
- Update ~30 test assertions from English to French to match i18n translations
- Update test assertions to match SUMI V3 design changes (shadow vs border)
- Fix remaining story type errors (PlayerError, PlaylistBatchActions,
  TrackFilters, VirtualizedChatMessages)

Backend (veza-backend-api/):
- Fix response_test.go RespondWithAppError signature (2 args, not 3)
- Fix TestErrorContractAuthEndpoints expected error codes
  (ErrCodeUnauthorized vs ErrCodeInvalidCredentials)
- Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup
- Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold
  (needs 5 unique users, not 1)
- Replace NOW() PostgreSQL function with time.Now() parameter in marketplace
  service for SQLite test compatibility
- Add missing AutoMigrate entries in marketplace_test.go
  (ProductImage, ProductPreview, ProductLicense, ProductReview)

Results:
- Frontend TypeCheck: 617 errors -> 0 errors
- Frontend ESLint: 349 errors -> 0 errors
- Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing)
- Backend go vet: 1 error -> 0 errors
- Backend tests: 5 failing -> all 13 packages passing
- Rust: 150/150 tests passing (unchanged)
- Storybook audit: 0 errors across 1244 stories

Triage report: docs/TRIAGE_REPORT.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:48:07 +02:00

190 lines
8.2 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
import { offlineQueue } from '@/services/offlineQueue';
import { WifiOff, Loader2, List } from 'lucide-react';
import { hasRecentNetworkError } from '@/utils/networkErrorTracker';
import { OfflineQueueManager } from './OfflineQueueManager';
/**
* Composant pour afficher un indicateur de mode hors ligne avec nombre de requêtes en attente
*/
export function OfflineIndicator() {
const isOnline = useOnlineStatus();
const [queueSize, setQueueSize] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [hasNetworkError, setHasNetworkError] = useState(false);
const [showQueueManager, setShowQueueManager] = useState(false);
const [shouldShowSyncBar, setShouldShowSyncBar] = useState(false);
// Mettre à jour la taille de la file d'attente
useEffect(() => {
const updateQueueSize = () => {
const size = offlineQueue.getQueueSize();
// #region agent log
// fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OfflineIndicator.tsx:updateQueueSize',message:'Queue size updated',data:{queueSize:size,isOnline},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
setQueueSize(size);
};
// Mettre à jour immédiatement
updateQueueSize();
// Mettre à jour toutes les secondes
const interval = setInterval(updateQueueSize, 1000);
return () => clearInterval(interval);
}, []);
// Vérifier si la file est en cours de traitement
useEffect(() => {
if (isOnline && queueSize > 0) {
setIsProcessing(true);
// Vérifier périodiquement si le traitement est terminé
const checkProcessing = setInterval(() => {
const currentSize = offlineQueue.getQueueSize();
if (currentSize === 0) {
setIsProcessing(false);
clearInterval(checkProcessing);
}
}, 500);
return () => clearInterval(checkProcessing);
} else {
setIsProcessing(false);
return undefined;
}
}, [isOnline, queueSize]);
// Check for recent network errors
useEffect(() => {
const checkNetworkError = () => {
setHasNetworkError(hasRecentNetworkError());
};
// Check immediately
checkNetworkError();
// Check periodically (every 2 seconds) to update when error expires
const interval = setInterval(checkNetworkError, 2000);
return () => clearInterval(interval);
}, []);
// FIX: Délai avant d'afficher la barre de synchronisation pour éviter les flashs
useEffect(() => {
// #region agent log
// fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OfflineIndicator.tsx:useEffect',message:'Sync bar effect triggered',data:{isProcessing,queueSize,isOnline,shouldShowSyncBar},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
// #endregion
if (isProcessing && queueSize > 0 && isOnline) {
const timer = setTimeout(() => {
// #region agent log
// fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OfflineIndicator.tsx:setTimeout',message:'Setting shouldShowSyncBar to true',data:{queueSize,isProcessing},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
// #endregion
setShouldShowSyncBar(true);
}, 500);
return () => {
clearTimeout(timer);
setShouldShowSyncBar(false);
};
} else {
setShouldShowSyncBar(false);
return undefined;
}
}, [isProcessing, queueSize, isOnline]);
// Ne rien afficher si en ligne, aucune requête en attente, et pas d'erreur réseau récente
if (isOnline && queueSize === 0 && !isProcessing && !hasNetworkError) {
return null;
}
// Mode hors ligne ou erreur réseau récente
if (!isOnline || hasNetworkError) {
return (
<>
<div className="fixed top-0 left-0 right-0 bg-destructive/90 backdrop-blur-sm text-foreground px-4 py-2.5 text-sm z-50 flex items-center justify-center gap-2 shadow-[0_4px_12px_rgba(26,26,30,0.15)]">
<WifiOff className="w-4 h-4" />
<span>
Mode hors ligne
{queueSize > 0 && (
<span className="ml-2 font-semibold">
- {queueSize} {queueSize === 1 ? 'requête' : 'requêtes'} en
attente
</span>
)}
</span>
{queueSize > 0 && (
<button
onClick={() => setShowQueueManager(true)}
className="ml-3 px-2 py-1 bg-white/10 hover:bg-white/20 rounded border border-white/20 transition-colors flex items-center gap-1.5 text-xs font-medium"
title="View queued requests"
>
<List className="w-3.5 h-3.5" />
View Queue
</button>
)}
</div>
<OfflineQueueManager
open={showQueueManager}
onClose={() => setShowQueueManager(false)}
/>
</>
);
}
// En ligne mais traitement de la file en cours
// FIX: Ne pas afficher la barre si les requêtes sont rapidement traitées (moins de 500ms)
// Cela évite d'afficher la barre pour des requêtes qui sont déjà en cours de traitement
// #region agent log
if (isProcessing && queueSize > 0) {
// fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OfflineIndicator.tsx:123',message:'Checking sync bar display',data:{isProcessing,queueSize,shouldShowSyncBar,willShow:shouldShowSyncBar},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
}
// #endregion
if (isProcessing && queueSize > 0 && shouldShowSyncBar) {
return (
<>
<div className="fixed top-0 left-0 right-0 bg-primary/90 backdrop-blur-sm text-foreground px-4 py-2.5 text-sm z-50 flex items-center justify-center gap-2 shadow-[0_4px_12px_rgba(26,26,30,0.15)]">
<Loader2 className="w-4 h-4 animate-spin" />
<span>
Synchronisation en cours
{queueSize > 0 && (
<span className="ml-2 font-semibold">
- {queueSize} {queueSize === 1 ? 'requête' : 'requêtes'} restante
{queueSize > 1 ? 's' : ''}
</span>
)}
</span>
{queueSize > 0 && (
<>
<button
onClick={async () => {
// #region agent log
// fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OfflineIndicator.tsx:clearQueue',message:'User clicked clear queue',data:{queueSize},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
await offlineQueue.clearQueue();
setQueueSize(0);
}}
className="ml-2 px-2 py-1 bg-destructive/20 hover:bg-destructive/30 rounded border border-destructive/30 transition-colors flex items-center gap-1.5 text-xs font-medium"
title="Clear queued requests"
>
Clear Queue
</button>
<button
onClick={() => setShowQueueManager(true)}
className="ml-2 px-2 py-1 bg-background/20 hover:bg-background/30 rounded border border-border/30 transition-colors flex items-center gap-1.5 text-xs font-medium"
title="View queued requests"
>
<List className="w-3.5 h-3.5" />
View Queue
</button>
</>
)}
</div>
<OfflineQueueManager
open={showQueueManager}
onClose={() => setShowQueueManager(false)}
/>
</>
);
}
return null;
}