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

1101 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
// 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)
```typescript
export function useDownloadContext(): DownloadContextValue {
const context = useContext(DownloadContext);
if (!context) {
throw new Error(
'useDownloadContext must be used within a DownloadProvider',
);
}
return context;
}
```
**Usage:**
```typescript
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)
```typescript
export function useDownloadContextOptional(): DownloadContextValue | null {
return useContext(DownloadContext);
}
```
**Usage:** For components that may render outside the provider:
```typescript
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`:
```typescript
// 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>
```
---
## Related: DownloadEventBus
The context subscribes to events from `DownloadEventBus`, a browser-native event emitter:
```typescript
// 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
```typescript
// 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',
},
// ...
};
```
---
## Related: usePreloadProgress Hook
An alternative hook-based approach that also subscribes to `DownloadEventBus`:
```typescript
// 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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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
```typescript
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:
```typescript
// 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
```typescript
type PreloadJobStatus =
| 'queued' // In queue, not yet started
| 'downloading' // Currently downloading
| 'completed' // Successfully completed
| 'error' // Failed with error
| 'paused'; // Paused by user
```
### AssetVariantType
```typescript
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
```typescript
type AssetType =
| 'image' // Image files
| 'video' // Video files
| 'audio' // Audio files
| 'transition' // Transition videos
| 'other'; // Other file types
```
---
## Event Payloads
```typescript
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
```typescript
// 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
```typescript
// 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
```typescript
// For global UI (headers, status bars)
const ctx = useDownloadContext();
// For isolated components
const progress = usePreloadProgress();
```
### 4. Clean Up Jobs
```typescript
// 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** | |
---
## Related Documentation
- [hooks-module.md](./hooks-module.md) - usePreloadProgress, useOfflineMode, useStorageQuota
- [lib-module.md](./lib-module.md) - DownloadEventBus, StorageManager
- [config-module.md](./config-module.md) - OFFLINE_CONFIG, PRELOAD_CONFIG
- [types-module.md](./types-module.md) - Offline types
- [components-module.md](./components-module.md) - Offline components
---
## Adding New Contexts
To add a new React Context:
### Step 1: Create Context File
```typescript
// 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
```typescript
import { MyProvider } from '../context/MyContext';
function MyApp({ Component, pageProps }) {
return (
<Provider store={store}>
<DownloadProvider>
<MyProvider>
{/* ... */}
</MyProvider>
</DownloadProvider>
</Provider>
);
}
```
### Step 3: Consume in Components
```typescript
import { useMyContext } from '@/context/MyContext';
function MyComponent() {
const { value, setValue } = useMyContext();
// ...
}
```