veza/apps/web/src/components/OfflineQueueManager.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

227 lines
7.4 KiB
TypeScript

/**
* Offline Queue Manager Component
* Action 2.5.1.4: Add UI for offline queue management
*
* Displays queued requests and allows users to view details, remove requests, or clear the queue
*/
import { useEffect, useState } from 'react';
import { offlineQueue, type QueuedRequest } from '@/services/offlineQueue';
import { Dialog } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Trash2, X, Clock, AlertCircle, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { logger } from '@/utils/logger';
interface OfflineQueueManagerProps {
open: boolean;
onClose: () => void;
}
/**
* Format timestamp to readable date/time
*/
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleString();
}
/**
* Format request method and URL for display
*/
function formatRequest(request: QueuedRequest): string {
const method = request.config.method?.toUpperCase() || 'UNKNOWN';
const url = request.config.url || 'Unknown URL';
return `${method} ${url}`;
}
/**
* Get priority badge color
*/
function getPriorityColor(priority: QueuedRequest['priority']): string {
switch (priority) {
case 'high':
return 'bg-destructive/20 text-destructive border-destructive/30';
case 'normal':
return 'bg-muted/20 text-muted-foreground border-border/30';
case 'low':
return 'bg-muted/30 text-muted-foreground border-border/50';
default:
return 'bg-muted/30 text-muted-foreground border-border/50';
}
}
export function OfflineQueueManager({
open,
onClose,
}: OfflineQueueManagerProps) {
const [queue, setQueue] = useState<QueuedRequest[]>([]);
const [isRemoving, setIsRemoving] = useState<string | null>(null);
const [isClearing, setIsClearing] = useState(false);
// Update queue when dialog opens or periodically
useEffect(() => {
if (!open) return;
const updateQueue = () => {
setQueue(offlineQueue.getQueue());
};
// Update immediately
updateQueue();
// Update every second while dialog is open
const interval = setInterval(updateQueue, 1000);
return () => clearInterval(interval);
}, [open]);
const handleRemoveRequest = async (requestId: string) => {
setIsRemoving(requestId);
try {
await offlineQueue.removeRequest(requestId);
setQueue(offlineQueue.getQueue());
} catch (error) {
logger.error('Failed to remove request', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
requestId,
});
} finally {
setIsRemoving(null);
}
};
const handleClearQueue = async () => {
setIsClearing(true);
try {
await offlineQueue.clearQueue();
setQueue([]);
onClose();
} catch (error) {
logger.error('Failed to clear queue', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
} finally {
setIsClearing(false);
}
};
return (
<Dialog
open={open}
onClose={onClose}
title="Offline Queue Manager"
size="lg"
variant="info"
>
<div className="space-y-4">
{/* Queue Summary */}
<div className="flex items-center justify-between p-4 bg-card/50 rounded-lg shadow-[0_0_8px_rgba(26,26,30,0.05)]">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{queue.length === 0
? 'No queued requests'
: `${queue.length} ${queue.length === 1 ? 'request' : 'requests'} queued`}
</span>
</div>
{queue.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleClearQueue}
disabled={isClearing}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear All
</Button>
)}
</div>
{/* Queue List */}
{queue.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<CheckCircle2 className="w-12 h-12 mx-auto mb-4 text-primary/50" />
<p className="text-sm">All requests have been processed</p>
</div>
) : (
<div className="space-y-2 max-h-layout-list overflow-y-auto custom-scrollbar">
{queue.map((request) => (
<div
key={request.id}
className="p-4 bg-card/30 rounded-lg shadow-[0_0_8px_rgba(26,26,30,0.05)] hover:shadow-[0_0_12px_rgba(26,26,30,0.08)] transition-shadow"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
{/* Request Method and URL */}
<div className="flex items-center gap-2 mb-2">
<span className="font-mono text-sm font-semibold text-foreground truncate">
{formatRequest(request)}
</span>
</div>
{/* Metadata */}
<div className="flex items-center gap-4 flex-wrap text-xs text-muted-foreground">
{/* Priority Badge */}
<span
className={cn(
'px-2 py-0.5 rounded border',
getPriorityColor(request.priority),
)}
>
{request.priority}
</span>
{/* Timestamp */}
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTimestamp(request.timestamp)}
</span>
{/* Retry Count */}
{request.retryCount > 0 && (
<span className="flex items-center gap-1 text-destructive">
<AlertCircle className="w-3 h-3" />
{request.retryCount} retry
{request.retryCount > 1 ? 'ies' : ''}
</span>
)}
</div>
</div>
{/* Remove Button */}
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveRequest(request.id)}
disabled={isRemoving === request.id}
className="shrink-0"
>
{isRemoving === request.id ? (
<Clock className="w-4 h-4 animate-spin" />
) : (
<X className="w-4 h-4" />
)}
</Button>
</div>
</div>
))}
</div>
)}
{/* Info Message */}
{queue.length > 0 && (
<div className="p-4 bg-muted/10 shadow-[0_0_8px_rgba(26,26,30,0.05)] rounded-lg text-xs text-muted-foreground">
<p>
Queued requests will be automatically processed when you're back
online. You can remove individual requests or clear the entire
queue.
</p>
</div>
)}
</div>
</Dialog>
);
}