39948-vm/frontend/docs/context-module.md
2026-07-03 16:11:24 +02:00

31 KiB
Raw Blame History

Frontend Context Module

Overview

The Context module provides React Context-based state management for cross-cutting concerns that need to be shared across the component tree without prop drilling. Currently, the module contains a single context for managing download/preload progress in the PWA offline system.

Location: frontend/src/context/

Total Files: 1 file (~256 LOC)


Architecture

frontend/src/context/
└── DownloadContext.tsx   (256 LOC)  # Download progress state management

Related Files:
├── lib/offline/DownloadEventBus.ts  (180 LOC)  # Event emitter for download events
├── hooks/usePreloadProgress.ts      (221 LOC)  # Hook-based alternative
├── config/offline.config.ts         (53 LOC)   # Event names, IndexedDB, cache settings
├── config/preload.config.ts         (95 LOC)   # Priority weights, auto-cleanup timeouts
├── types/offline.ts                 (195 LOC)  # Type definitions
│
└── components/Offline/
    ├── DownloadProgressPanel.tsx    (194 LOC)  # Progress UI with job list
    ├── OfflineStatusIndicator.tsx   (62 LOC)   # Status badge (online/offline/syncing)
    ├── OfflineToggle.tsx            (150 LOC)  # Download toggle with storage checks
    └── StorageUsageDisplay.tsx      (125 LOC)  # Storage quota UI with persistence

DownloadContext (DownloadContext.tsx)

Purpose: Manage download job state and progress for PWA asset preloading.

Lines: 256 LOC

Context State Interface

interface DownloadState {
  jobs: PreloadJob[];        // All tracked download jobs
  activeCount: number;       // Jobs currently downloading/queued
  completedCount: number;    // Jobs completed successfully
  errorCount: number;        // Jobs that failed
  totalProgress: number;     // Overall progress percentage (0-100)
  isDownloading: boolean;    // True if any job is active
}

Context Value Interface

interface DownloadContextValue extends DownloadState {
  clearJob: (id: string) => void;      // Remove a specific job
  clearAllCompleted: () => void;       // Clear all completed jobs
  clearAllErrors: () => void;          // Clear all errored jobs
  clearAll: () => void;                // Clear all jobs
}

PreloadJob Interface

interface PreloadJob {
  id: string;                          // Unique job identifier
  assetId: string;                     // Asset being downloaded
  url: string;                         // Download URL
  filename: string;                    // Display filename
  progress: number;                    // 0-100 percent
  status: PreloadJobStatus;            // 'queued' | 'downloading' | 'completed' | 'error' | 'paused'
  bytesLoaded: number;                 // Bytes downloaded
  totalBytes: number;                  // Total file size
  pageId?: string;                     // Associated tour page
  variantType?: AssetVariantType;      // thumbnail, preview, webp, etc.
  assetType?: AssetType;               // image, video, audio, transition
  error?: string;                      // Error message if failed
  addedAt: number;                     // Timestamp added to queue
  startedAt?: number;                  // Timestamp download started
  completedAt?: number;                // Timestamp completed
}

Provider Implementation

DownloadProvider Component

export function DownloadProvider({ children }: DownloadProviderProps) {
  const [jobs, setJobs] = useState<PreloadJob[]>([]);

  // Subscribe to DownloadEventBus events
  useEffect(() => {
    const handleStart = (data: PreloadStartEvent) => {
      // Create new job from event data
    };
    return downloadEventBus.on(OFFLINE_CONFIG.events.preloadStart, handleStart);
  }, []);

  // Similar subscriptions for progress, complete, error events...

  // Computed values with useMemo
  const value = useMemo<DownloadContextValue>(() => ({
    jobs,
    activeCount: jobs.filter(j => j.status === 'downloading' || j.status === 'queued').length,
    completedCount: jobs.filter(j => j.status === 'completed').length,
    errorCount: jobs.filter(j => j.status === 'error').length,
    totalProgress: calculateTotalProgress(jobs),
    isDownloading: activeCount > 0,
    clearJob,
    clearAllCompleted,
    clearAllErrors,
    clearAll,
  }), [jobs, clearJob, clearAllCompleted, clearAllErrors, clearAll]);

  return (
    <DownloadContext.Provider value={value}>
      {children}
    </DownloadContext.Provider>
  );
}

Event Handling

The provider subscribes to four events from DownloadEventBus:

┌─────────────────────────────────────────────────────────────┐
│                    Event Flow                               │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                 usePreloadOrchestrator                      │
│                 DownloadManager                             │
│                 (Emit events via downloadEventBus)          │
└─────────────────────────────────────────────────────────────┘
                              │
        ┌─────────────────────┼─────────────────────┐
        ▼                     ▼                     ▼
┌───────────────┐     ┌───────────────┐     ┌───────────────┐
│ preloadStart  │     │preloadProgress│     │preloadComplete│
│               │     │               │     │               │
│ Create new    │     │ Update        │     │ Mark complete │
│ job entry     │     │ progress %    │     │ Auto-remove   │
│               │     │ bytesLoaded   │     │ after 3s      │
└───────────────┘     └───────────────┘     └───────────────┘
        │                     │                     │
        └─────────────────────┼─────────────────────┘
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                    preloadError                             │
│                                                             │
│  Mark job as error, store error message                     │
│  Auto-remove after 10s                                      │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                 DownloadContext State                       │
│                                                             │
│  jobs[], activeCount, completedCount, errorCount,           │
│  totalProgress, isDownloading                               │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                 Consumer Components                         │
│                                                             │
│  DownloadProgressPanel, OfflineStatusIndicator, etc.        │
└─────────────────────────────────────────────────────────────┘

Event Handlers

// Handle preload start - create new job
const handleStart = (data: PreloadStartEvent) => {
  const newJob: PreloadJob = {
    id: data.jobId,
    assetId: data.assetId,
    url: data.url,
    filename: data.url.split('/').pop() || 'unknown',
    progress: 0,
    status: 'downloading',
    bytesLoaded: 0,
    totalBytes: 0,
    addedAt: Date.now(),
    startedAt: Date.now(),
  };

  setJobs((prev) => {
    // Avoid duplicates
    if (prev.some((j) => j.id === data.jobId)) {
      return prev;
    }
    return [...prev, newJob];
  });
};

// Handle preload progress - update existing job
const handleProgress = (data: PreloadProgressEvent) => {
  setJobs((prev) =>
    updateJobInArray(prev, data.jobId, {
      progress: data.progress,
      bytesLoaded: data.bytesLoaded,
      totalBytes: data.totalBytes,
      status: 'downloading',
    }),
  );
};

// Handle preload complete - mark done and auto-remove
const handleComplete = (data: PreloadCompleteEvent) => {
  setJobs((prev) =>
    updateJobInArray(prev, data.jobId, {
      status: 'completed',
      progress: 100,
      completedAt: Date.now(),
    }),
  );

  // Auto-remove after 3 seconds
  setTimeout(() => {
    setJobs((prev) => prev.filter((j) => j.id !== data.jobId));
  }, PRELOAD_CONFIG.autoRemove.completedMs);
};

// Handle preload error - mark failed and auto-remove
const handleError = (data: PreloadErrorEvent) => {
  setJobs((prev) =>
    updateJobInArray(prev, data.jobId, {
      status: 'error',
      error: data.error,
    }),
  );

  // Auto-remove after 10 seconds
  setTimeout(() => {
    setJobs((prev) => prev.filter((j) => j.id !== data.jobId));
  }, PRELOAD_CONFIG.autoRemove.errorMs);
};

Consumer Hooks

useDownloadContext (Required)

export function useDownloadContext(): DownloadContextValue {
  const context = useContext(DownloadContext);
  if (!context) {
    throw new Error(
      'useDownloadContext must be used within a DownloadProvider',
    );
  }
  return context;
}

Usage:

import { useDownloadContext } from '@/context/DownloadContext';

function MyComponent() {
  const {
    jobs,
    activeCount,
    completedCount,
    totalProgress,
    isDownloading,
    clearJob,
    clearAllCompleted,
  } = useDownloadContext();

  return (
    <div>
      {isDownloading && <span>Downloading... {totalProgress}%</span>}
      <ul>
        {jobs.map(job => (
          <li key={job.id}>
            {job.filename}: {job.progress}%
            <button onClick={() => clearJob(job.id)}>×</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

useDownloadContextOptional (Safe)

export function useDownloadContextOptional(): DownloadContextValue | null {
  return useContext(DownloadContext);
}

Usage: For components that may render outside the provider:

function SafeComponent() {
  const downloadCtx = useDownloadContextOptional();

  // Works even outside DownloadProvider
  if (downloadCtx?.isDownloading) {
    return <span>Downloading...</span>;
  }
  return null;
}

Provider Setup

The provider is mounted at the application root in _app.tsx:

// pages/_app.tsx
import { DownloadProvider } from '../context/DownloadContext';

function MyApp({ Component, pageProps }: AppPropsWithLayout) {
  const getLayout = Component.getLayout || ((page) => page);

  return (
    <Provider store={store}>
      <DownloadProvider>
        {getLayout(
          <>
            <Head>
              {/* Meta tags, favicon, PWA manifest */}
            </Head>
            <ErrorBoundary>
              <Component {...pageProps} />
            </ErrorBoundary>
            <IntroGuide steps={steps} stepsEnabled={stepsEnabled} onExit={handleExit} />
          </>
        )}
      </DownloadProvider>
    </Provider>
  );
}

export default appWithTranslation(MyApp);

Provider Hierarchy

<Provider store={store}>           ← Redux
  <DownloadProvider>               ← React Context (download progress)
    {getLayout(                    ← Per-page layout (LayoutAuthenticated/LayoutGuest)
      <>
        <Head />                   ← Meta tags, PWA config
        <ErrorBoundary>
          <Component />            ← Page component
        </ErrorBoundary>
        <IntroGuide />             ← Onboarding tour
      </>
    )}
  </DownloadProvider>
</Provider>

The context subscribes to events from DownloadEventBus, a browser-native event emitter:

// lib/offline/DownloadEventBus.ts
class DownloadEventBusClass {
  private listeners: Map<string, Set<EventCallback<unknown>>> = new Map();

  on<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): () => void;
  off<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): void;
  emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void;
  once<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): () => void;
  removeAllListeners(event?: keyof EventMap): void;
  listenerCount(event: keyof EventMap): number;

  // Convenience methods
  emitPreloadStart(data: PreloadStartEvent): void;
  emitPreloadProgress(data: PreloadProgressEvent): void;
  emitPreloadComplete(data: PreloadCompleteEvent): void;
  emitPreloadError(data: PreloadErrorEvent): void;
  emitProjectProgress(data: ProjectDownloadProgressEvent): void;
  emitProjectComplete(data: ProjectDownloadCompleteEvent): void;
  emitQueueUpdate(): void;
}

export const downloadEventBus = new DownloadEventBusClass();

Event Types

// Defined in config/offline.config.ts
export const OFFLINE_CONFIG = {
  events: {
    preloadStart: 'asset-preload-start',
    preloadProgress: 'asset-preload-progress',
    preloadComplete: 'asset-preload-complete',
    preloadError: 'asset-preload-error',
    projectDownloadProgress: 'project-download-progress',
    projectDownloadComplete: 'project-download-complete',
    queueUpdate: 'queue-update',
  },
  // ...
};

An alternative hook-based approach that also subscribes to DownloadEventBus:

// hooks/usePreloadProgress.ts
export function usePreloadProgress(): UsePreloadProgressResult {
  const [jobs, setJobs] = useState<PreloadJob[]>([]);

  // Same event subscription pattern as DownloadContext
  useEffect(() => {
    return downloadEventBus.on(
      OFFLINE_CONFIG.events.preloadStart,
      handleStart,
    );
  }, []);

  // ... similar to DownloadContext

  return {
    jobs,
    activeCount,
    completedCount,
    errorCount,
    totalProgress,
    isActive,
    clearJob,
    clearAllCompleted,
    clearAllErrors,
  };
}

Context vs Hook Comparison

Aspect DownloadContext usePreloadProgress
State Sharing Shared across tree Per-component instance
Deduplication Single subscription Multiple subscriptions
Use Case Global UI (headers) Local UI (panels)
Performance Better for many consumers Better for single consumer
Coupling Requires Provider Standalone

Recommendation: Use DownloadContext for shared global state (status indicators in headers), use usePreloadProgress for isolated components (embedded progress panels).


Consumer Components

DownloadProgressPanel

Shows detailed download progress with job list:

import {
  mdiPause, mdiPlay, mdiClose, mdiCheck, mdiAlertCircle, mdiLoading,
} from '@mdi/js';
import Icon from '@mdi/react';
import { usePreloadProgress } from '../../hooks/usePreloadProgress';

interface DownloadProgressPanelProps {
  className?: string;
  maxItems?: number;
  showWhenEmpty?: boolean;
}

export function DownloadProgressPanel({
  className = '',
  maxItems = 5,
  showWhenEmpty = false,
}: DownloadProgressPanelProps) {
  const {
    jobs,
    activeCount,
    completedCount,
    errorCount,
    totalProgress,
    isActive,
    clearJob,
    clearAllCompleted,
    clearAllErrors,
  } = usePreloadProgress();

  if (!showWhenEmpty && jobs.length === 0) return null;

  const visibleJobs = jobs.slice(0, maxItems);
  const hiddenCount = jobs.length - maxItems;

  return (
    <div className={`bg-white border rounded-lg shadow-lg p-3 min-w-[280px] ${className}`}>
      {/* Header */}
      <div className='flex items-center justify-between mb-2 pb-2 border-b'>
        <span className='text-sm font-medium'>Downloads</span>
        {isActive && <span className='text-xs text-blue-600'>{activeCount} active</span>}
        {completedCount > 0 && (
          <button onClick={clearAllCompleted} className='text-xs text-gray-500'>
            Clear completed
          </button>
        )}
      </div>

      {/* Overall progress */}
      {isActive && (
        <div className='mb-3'>
          <div className='flex justify-between text-xs text-gray-500 mb-1'>
            <span>Overall progress</span>
            <span>{totalProgress}%</span>
          </div>
          <div className='w-full h-1.5 bg-gray-200 rounded-full overflow-hidden'>
            <div className='h-full bg-blue-500' style={{ width: `${totalProgress}%` }} />
          </div>
        </div>
      )}

      {/* Job list */}
      <div className='space-y-2'>
        {visibleJobs.map((job) => (
          <div key={job.id} className='flex items-center gap-2 p-2 bg-gray-50 rounded'>
            {/* Status icon */}
            {job.status === 'downloading' && (
              <Icon path={mdiLoading} size={0.7} className='text-blue-500 animate-spin' />
            )}
            {job.status === 'completed' && (
              <Icon path={mdiCheck} size={0.7} className='text-green-500' />
            )}
            {job.status === 'error' && (
              <Icon path={mdiAlertCircle} size={0.7} className='text-red-500' />
            )}

            {/* Info */}
            <div className='flex-1 min-w-0'>
              <div className='text-xs font-medium truncate'>{job.filename}</div>
              {job.status === 'downloading' && (
                <div className='w-full h-1 bg-gray-200 rounded-full mt-1'>
                  <div className='h-full bg-blue-500' style={{ width: `${job.progress}%` }} />
                </div>
              )}
            </div>

            {/* Close button */}
            <button onClick={() => clearJob(job.id)} className='p-0.5 text-gray-400'>
              <Icon path={mdiClose} size={0.5} />
            </button>
          </div>
        ))}

        {hiddenCount > 0 && (
          <div className='text-xs text-gray-500 text-center py-1'>
            +{hiddenCount} more items
          </div>
        )}
      </div>
    </div>
  );
}

OfflineStatusIndicator

Small badge showing sync status:

import { mdiCloudCheck, mdiCloudOffOutline, mdiCloudSync } from '@mdi/js';
import Icon from '@mdi/react';
import { useNetworkAware } from '../../hooks/useNetworkAware';
import { usePreloadProgress } from '../../hooks/usePreloadProgress';

interface OfflineStatusIndicatorProps {
  className?: string;
  showLabel?: boolean;
  projectId?: string | null;
}

export function OfflineStatusIndicator({
  className = '',
  showLabel = false,
}: OfflineStatusIndicatorProps) {
  const { networkInfo } = useNetworkAware();
  const { isActive, totalProgress } = usePreloadProgress();

  // Determine status
  let icon = mdiCloudCheck;
  let label = 'Online';
  let colorClass = 'text-green-500';
  let bgClass = 'bg-green-50';

  if (!networkInfo.isOnline) {
    icon = mdiCloudOffOutline;
    label = 'Offline';
    colorClass = 'text-gray-500';
    bgClass = 'bg-gray-100';
  } else if (isActive) {
    icon = mdiCloudSync;
    label = `Syncing ${totalProgress}%`;
    colorClass = 'text-blue-500';
    bgClass = 'bg-blue-50';
  }

  return (
    <div
      className={`flex items-center gap-1.5 px-2 py-1 rounded-full ${bgClass} ${className}`}
      title={label}
    >
      <Icon
        path={icon}
        size={0.6}
        className={`${colorClass} ${isActive ? 'animate-pulse' : ''}`}
      />
      {showLabel && (
        <span className={`text-xs font-medium ${colorClass}`}>{label}</span>
      )}
    </div>
  );
}

OfflineToggle

Toggle button for offline download:

import { mdiCloudDownload, mdiCloudCheck, mdiCloudOff, mdiDelete } from '@mdi/js';
import Icon from '@mdi/react';
import BaseButton from '../BaseButton';
import { useOfflineMode } from '../../hooks/useOfflineMode';
import { useStorageQuota } from '../../hooks/useStorageQuota';

interface OfflineToggleProps {
  projectId: string | null;
  projectSlug?: string;
  projectName?: string;
  className?: string;
  showLabel?: boolean;
  size?: 'small' | 'medium' | 'large';
}

export function OfflineToggle({
  projectId,
  projectSlug,
  projectName,
  className = '',
  showLabel = true,
  size = 'medium',
}: OfflineToggleProps) {
  const {
    isOfflineCapable,
    isDownloaded,
    isDownloading,
    status,
    progress,
    startDownload,
    pauseDownload,
    resumeDownload,
    cancelDownload,
    deleteOfflineData,
    estimatedSize,
    formatSize,
  } = useOfflineMode({ projectId, projectSlug, projectName });

  const { canStore, isWarning, isCritical } = useStorageQuota();

  if (!isOfflineCapable) return null;

  const handleClick = () => {
    if (isDownloaded) {
      if (confirm('Remove offline data for this project?')) {
        deleteOfflineData();
      }
    } else if (isDownloading) {
      pauseDownload();
    } else if (status === 'error') {
      resumeDownload();
    } else {
      if (isCritical) {
        alert('Storage space is critically low.');
        return;
      }
      startDownload();
    }
  };

  // Determine icon and label based on status
  let icon = mdiCloudDownload;
  let label = 'Download for offline';
  let color: 'info' | 'success' | 'danger' | 'warning' = 'info';

  if (isDownloaded) {
    icon = mdiCloudCheck;
    label = 'Available offline';
    color = 'success';
  } else if (isDownloading) {
    label = `Downloading ${progress}%`;
  } else if (status === 'error') {
    icon = mdiCloudOff;
    label = 'Retry download';
    color = 'danger';
  }

  return (
    <div className={`flex items-center gap-2 ${className}`}>
      <BaseButton
        small={size === 'small'}
        color={color}
        icon={icon}
        label={showLabel ? label : undefined}
        onClick={handleClick}
        disabled={!canStore(estimatedSize) && !isDownloaded}
      />
      {isDownloaded && (
        <button onClick={() => deleteOfflineData()} title='Remove offline data'>
          <Icon path={mdiDelete} size={0.7} />
        </button>
      )}
    </div>
  );
}

StorageUsageDisplay

Visual storage quota indicator:

import { mdiDatabase } from '@mdi/js';
import Icon from '@mdi/react';
import { useStorageQuota } from '../../hooks/useStorageQuota';

interface StorageUsageDisplayProps {
  className?: string;
  showDetails?: boolean;
  compact?: boolean;
}

export function StorageUsageDisplay({
  className = '',
  showDetails = true,
  compact = false,
}: StorageUsageDisplayProps) {
  const {
    usage,
    quota,
    percentUsed,
    isLoading,
    isWarning,
    isCritical,
    isPersisted,
    formatSize,
    requestPersistence,
  } = useStorageQuota();

  if (isLoading) {
    return <div className='animate-pulse h-4 bg-gray-200 rounded w-32' />;
  }

  let barColor = 'bg-blue-500';
  let textColor = 'text-gray-600';

  if (isCritical) {
    barColor = 'bg-red-500';
    textColor = 'text-red-600';
  } else if (isWarning) {
    barColor = 'bg-yellow-500';
    textColor = 'text-yellow-600';
  }

  return (
    <div className={`space-y-2 ${className}`}>
      <div className='flex items-center justify-between'>
        <div className='flex items-center gap-2'>
          <Icon path={mdiDatabase} size={0.7} className='text-gray-500' />
          <span className='text-sm font-medium text-gray-700'>Storage</span>
        </div>
        {showDetails && (
          <span className={`text-xs ${textColor}`}>
            {formatSize(usage)} / {formatSize(quota)}
          </span>
        )}
      </div>

      <div className='w-full h-2 bg-gray-200 rounded-full overflow-hidden'>
        <div
          className={`h-full ${barColor} transition-all duration-300`}
          style={{ width: `${Math.min(percentUsed, 100)}%` }}
        />
      </div>

      {showDetails && (
        <div className='flex items-center justify-between text-xs'>
          <span className={textColor}>
            {isCritical ? 'Storage critically low' :
             isWarning ? 'Storage running low' :
             `${Math.round(100 - percentUsed)}% available`}
          </span>
          {!isPersisted && (
            <button onClick={requestPersistence} className='text-blue-500'>
              Make persistent
            </button>
          )}
          {isPersisted && (
            <span className='text-green-600'>Persistent storage enabled</span>
          )}
        </div>
      )}
    </div>
  );
}

Progress Calculation

const calculateTotalProgress = (jobs: PreloadJob[]): number => {
  if (jobs.length === 0) return 0;

  const activeJobs = jobs.filter(
    (j) => j.status !== 'completed' && j.status !== 'error',
  );
  if (activeJobs.length === 0) return 100;

  const totalBytes = activeJobs.reduce((sum, j) => sum + j.totalBytes, 0);
  const loadedBytes = activeJobs.reduce((sum, j) => sum + j.bytesLoaded, 0);

  if (totalBytes === 0) {
    // Fallback to count-based progress
    const completedCount = jobs.filter((j) => j.status === 'completed').length;
    return Math.round((completedCount / jobs.length) * 100);
  }

  return Math.round((loadedBytes / totalBytes) * 100);
};

Auto-Cleanup

Jobs are automatically removed after completion/error:

// From PRELOAD_CONFIG
autoRemove: {
  completedMs: 3000,   // Remove completed jobs after 3 seconds
  errorMs: 10000,      // Remove error jobs after 10 seconds
}

This prevents the jobs array from growing indefinitely and provides a brief visual confirmation before cleanup.


Type Definitions

PreloadJobStatus

type PreloadJobStatus =
  | 'queued'       // In queue, not yet started
  | 'downloading'  // Currently downloading
  | 'completed'    // Successfully completed
  | 'error'        // Failed with error
  | 'paused';      // Paused by user

AssetVariantType

type AssetVariantType =
  | 'thumbnail'   // Small preview
  | 'preview'     // Medium preview
  | 'webp'        // WebP optimized
  | 'mp4_low'     // Low-res video
  | 'mp4_high'    // High-res video
  | 'original';   // Original file

AssetType

type AssetType =
  | 'image'       // Image files
  | 'video'       // Video files
  | 'audio'       // Audio files
  | 'transition'  // Transition videos
  | 'other';      // Other file types

Event Payloads

interface PreloadStartEvent {
  jobId: string;
  assetId: string;
  url: string;
}

interface PreloadProgressEvent {
  jobId: string;
  progress: number;      // 0-100
  bytesLoaded: number;
  totalBytes: number;
}

interface PreloadCompleteEvent {
  jobId: string;
  assetId: string;
}

interface PreloadErrorEvent {
  jobId: string;
  assetId: string;
  error: string;
}

interface ProjectDownloadProgressEvent {
  projectId: string;
  progress: number;
  downloadedAssets: number;
  totalAssets: number;
  downloadedBytes: number;
  totalBytes: number;
}

interface ProjectDownloadCompleteEvent {
  projectId: string;
}

Best Practices

1. Use Optional Hook Outside Provider

// Good - won't crash if outside provider
const ctx = useDownloadContextOptional();
if (ctx?.isDownloading) { ... }

// Bad - will crash if outside provider
const ctx = useDownloadContext();  // throws Error

2. Memoize Computed Values

// Good - values memoized in context
const { activeCount, totalProgress } = useDownloadContext();

// Avoid recomputing in consumers
const activeCount = useMemo(
  () => jobs.filter(j => j.status === 'downloading').length,
  [jobs]
);  // Already computed by context

3. Use Appropriate State Source

// For global UI (headers, status bars)
const ctx = useDownloadContext();

// For isolated components
const progress = usePreloadProgress();

4. Clean Up Jobs

// Clear completed jobs when user dismisses
<button onClick={clearAllCompleted}>Clear completed</button>

// Clear specific job
<button onClick={() => clearJob(job.id)}>×</button>

File Inventory

File LOC Purpose
DownloadContext.tsx 256 Context provider and hooks
DownloadEventBus.ts 180 Event emitter (related)
usePreloadProgress.ts 221 Hook alternative (related)
offline.ts (types) 195 Type definitions (related)
offline.config.ts 53 Event names, storage config (related)
preload.config.ts 95 Priority, auto-cleanup (related)
Core Context 256
With Related 1,000


Adding New Contexts

To add a new React Context:

Step 1: Create Context File

// context/MyContext.tsx
import React, { createContext, useContext, useState, type ReactNode } from 'react';

interface MyContextValue {
  value: string;
  setValue: (v: string) => void;
}

const MyContext = createContext<MyContextValue | null>(null);

export function MyProvider({ children }: { children: ReactNode }) {
  const [value, setValue] = useState('');

  return (
    <MyContext.Provider value={{ value, setValue }}>
      {children}
    </MyContext.Provider>
  );
}

export function useMyContext(): MyContextValue {
  const ctx = useContext(MyContext);
  if (!ctx) {
    throw new Error('useMyContext must be used within MyProvider');
  }
  return ctx;
}

export function useMyContextOptional(): MyContextValue | null {
  return useContext(MyContext);
}

Step 2: Add Provider to _app.tsx

import { MyProvider } from '../context/MyContext';

function MyApp({ Component, pageProps }) {
  return (
    <Provider store={store}>
      <DownloadProvider>
        <MyProvider>
          {/* ... */}
        </MyProvider>
      </DownloadProvider>
    </Provider>
  );
}

Step 3: Consume in Components

import { useMyContext } from '@/context/MyContext';

function MyComponent() {
  const { value, setValue } = useMyContext();
  // ...
}