feat: lazy load and retry

This commit is contained in:
JamesFlare1212
2025-05-13 15:57:41 -04:00
parent bc577501a2
commit de78a2b508
3 changed files with 160 additions and 42 deletions

View File

@@ -1,80 +1,128 @@
import React, { useState, useEffect, useCallback } from 'react';
// src/components/ClubCard.tsx
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { ClubBasic, ClubDetail } from '@types';
import { getClubDetails } from '@utils/apiService';
import activityDetailLimiter from '@utils/concurrencyLimiter';
interface ClubCardProps {
club: ClubBasic;
initialDetails?: ClubDetail | null;
onDetailsLoaded?: (clubId: string, details: ClubDetail) => void;
onViewMore: (clubDetail: ClubDetail) => void; // Callback to open modal in parent
onViewMore: (clubDetail: ClubDetail) => void;
}
const ClubCard: React.FC<ClubCardProps> = ({ club, initialDetails = null, onDetailsLoaded, onViewMore }) => {
const [details, setDetails] = useState<ClubDetail | null>(initialDetails);
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(!initialDetails);
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false); // Don't set to true initially if not visible
const [error, setError] = useState<string | null>(null);
const [isVisible, setIsVisible] = useState<boolean>(false);
const cardRef = useRef<HTMLDivElement | null>(null);
const fetchDetailsIfNeeded = useCallback(async () => {
if (details || !club.id) return; // Already have details or no ID
if (details || !club.id || !isVisible) return; // Already have details, no ID, or not visible
setIsLoadingDetails(true);
setError(null);
try {
const fetchedDetails = await getClubDetails(club.id);
// Run the API call through the concurrency limiter
const fetchedDetails = await activityDetailLimiter.run(() => getClubDetails(club.id));
setDetails(fetchedDetails);
if (onDetailsLoaded) {
onDetailsLoaded(club.id, fetchedDetails);
}
} catch (err) {
console.error(`Failed to fetch details for club ${club.id}:`, err);
setError('Could not load some details.'); // Less critical error now
} catch (err: any) {
console.error(`Failed to fetch details for club ${club.id} (concurrency controlled):`, err);
setError(`Could not load some details. ${err.message || ''}`); // Show more specific error
} finally {
setIsLoadingDetails(false);
}
}, [club.id, details, onDetailsLoaded]);
}, [club.id, details, onDetailsLoaded, isVisible]); // Add isVisible dependency
// Fetch details when component mounts if not already provided
// IntersectionObserver for lazy loading
useEffect(() => {
fetchDetailsIfNeeded();
}, [fetchDetailsIfNeeded]);
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(entry.target); // Stop observing once visible
}
},
{
rootMargin: '0px', // Can be adjusted, e.g., '100px' to load slightly before visible
threshold: 0.1 // Trigger when 10% of the element is visible
}
);
const handleViewMoreClick = () => {
if (details) {
onViewMore(details);
} else if (!isLoadingDetails) {
fetchDetailsIfNeeded().then(() => {
// Re-check details state after fetch attempt
if(details) onViewMore(details);
});
if (cardRef.current) {
observer.observe(cardRef.current);
}
return () => {
if (cardRef.current) {
observer.unobserve(cardRef.current);
}
};
}, []); // Run once on mount
// Fetch details when component becomes visible and details are needed
useEffect(() => {
if (isVisible && !initialDetails) { // Only fetch if visible and no initial details
fetchDetailsIfNeeded();
}
}, [isVisible, fetchDetailsIfNeeded, initialDetails]);
const handleViewMoreClick = async () => {
let currentDetails = details;
if (!currentDetails && !isLoadingDetails) {
// If details somehow weren't loaded and not currently loading, try again then open
// This attempt will also go through the limiter and retry logic
setError(null); // Clear previous error before manual retry via button
setIsLoadingDetails(true);
try {
currentDetails = await activityDetailLimiter.run(() => getClubDetails(club.id));
setDetails(currentDetails);
if (onDetailsLoaded && currentDetails) {
onDetailsLoaded(club.id, currentDetails);
}
} catch (err: any) {
console.error(`Manual fetch failed for club ${club.id}:`, err);
setError(`Failed to load details. ${err.message || ''}`);
} finally {
setIsLoadingDetails(false);
}
}
if (currentDetails) {
onViewMore(currentDetails);
} else {
// Optionally inform user that details couldn't be loaded even after click
console.warn("Details not available to show in modal for club:", club.id);
}
};
const renderDetailItem = (label: string, value: string | undefined | null) => {
if (isLoadingDetails && !details) return <p className="card-info-placeholder">{label}: Loading...</p>;
// Check specifically for the case where details ARE loaded but the value is missing/null/undefined
// Show "Loading..." only if it's visible and actively loading
if (isVisible && isLoadingDetails && !details) return <p className="card-info-placeholder">{label}: Loading...</p>;
if (details && !value) return <p className="card-info-placeholder">{label}: N/A</p>;
// If details are not loaded yet, and we are not actively loading, show N/A or nothing? Let's stick to Loading... or the value
if (!details && !isLoadingDetails) return <p className="card-info-placeholder">{label}: N/A</p> // Or maybe return null
// If we have a value (implies details are loaded)
if (!details && !isLoadingDetails && isVisible) return <p className="card-info-placeholder">{label}: N/A</p>;
if (!isVisible && !initialDetails) return <p className="card-info-placeholder">{label}: (Scroll to load)</p>; // Placeholder before visible
if (value) return <p><strong>{label}:</strong> {value}</p>;
// Fallback for the loading state (already handled above, but as safety)
return <p className="card-info-placeholder">{label}: Loading...</p>;
}
if (initialDetails && !value) return <p className="card-info-placeholder">{label}: N/A</p>; // For initialDetails case
return <p className="card-info-placeholder">{label}: N/A</p>; // Default placeholder
};
return (
<div className="card">
<div className="card" ref={cardRef}>
<div className="card-photo-container">
{/* === Updated img tag below === */}
<img
src={club.photo}
alt={club.name}
className="card-photo"
loading="lazy" // Defer loading until near viewport
decoding="async" // Hint to decode off main thread
fetchpriority="low" // Hint that it's not critical compared to other resources
loading="lazy"
decoding="async"
fetchpriority="low"
/>
{/* === End of updated img tag === */}
</div>
<div className="card-content">
<h3>{club.name}</h3>
@@ -88,9 +136,9 @@ const ClubCard: React.FC<ClubCardProps> = ({ club, initialDetails = null, onDeta
<button
onClick={handleViewMoreClick}
className="card-button"
disabled={isLoadingDetails && !details} // Disable if actively loading initial essential details
disabled={isLoadingDetails} // Disable if actively loading
>
{isLoadingDetails && !details ? 'Loading Details...' : 'View Full Details'}
{isLoadingDetails ? 'Loading...' : 'View Full Details'}
</button>
</div>
</div>

View File

@@ -1,3 +1,4 @@
// src/utils/apiService.ts
import type {
ApiClubListResponse,
ClubDetail,
@@ -10,19 +11,42 @@ import type {
const BASE_URL = 'https://dsas-cca.jamesflare.com/v1';
async function fetchWithErrorHandling<T>(url: string): Promise<T> {
// Helper function for delaying
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
async function fetchWithErrorHandling<T>(url: string, isRetryable: boolean = false, attempt: number = 1): Promise<T> {
const MAX_RETRY_ATTEMPTS_FOR_NON_PERSISTENT = 3; // Max retries for general errors if not persistent
const RETRY_DELAY_MS = 1000; // 1 second for persistent retry
try {
const response = await fetch(url);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "Failed to parse error response" }));
// For 4xx errors, typically don't retry indefinitely as it's a client/request issue
if (response.status >= 400 && response.status < 500 && isRetryable) {
console.warn(`API Client Error for ${url}: ${response.status}. Not retrying.`);
const errorData = await response.json().catch(() => ({ error: "Failed to parse client error response" }));
throw new Error(`API Client Error: ${response.status} ${response.statusText}. ${errorData?.error || ''}`);
}
// For 5xx server errors or network issues, retry is more appropriate
const errorData = await response.json().catch(() => ({ error: "Failed to parse server error response" }));
throw new Error(
`API Error: ${response.status} ${response.statusText}. ${errorData?.error || ''}`
);
}
return response.json() as Promise<T>;
} catch (error) {
console.error(`Failed to fetch from ${url}:`, error);
throw error; // Re-throw to be handled by the caller
} catch (error: any) {
if (isRetryable) {
console.warn(`Attempt ${attempt} failed for ${url}: ${error.message}. Retrying in ${RETRY_DELAY_MS / 1000}s...`);
await delay(RETRY_DELAY_MS);
return fetchWithErrorHandling<T>(url, isRetryable, attempt + 1); // Persistent retry
} else if (attempt < MAX_RETRY_ATTEMPTS_FOR_NON_PERSISTENT) {
// Non-persistent retry for general errors (e.g., initial list fetches)
console.warn(`Attempt ${attempt} failed for ${url}: ${error.message}. Retrying (non-persistent)...`);
await delay(RETRY_DELAY_MS * attempt); // Simple increasing backoff
return fetchWithErrorHandling<T>(url, isRetryable, attempt + 1);
}
console.error(`Failed to fetch from ${url} after multiple attempts:`, error);
throw error; // Re-throw to be handled by the caller after max retries or if not retryable
}
}
@@ -46,8 +70,10 @@ export async function filterClubs(params: {
return Object.entries(data).map(([id, club]) => ({ id, ...club }));
}
// Make getClubDetails retryable persistently
export async function getClubDetails(activityId: string): Promise<ClubDetail> {
return fetchWithErrorHandling<ClubDetail>(`${BASE_URL}/activity/${activityId}`);
// Pass `true` for isRetryable to enable persistent retries for this specific endpoint
return fetchWithErrorHandling<ClubDetail>(`${BASE_URL}/activity/${activityId}`, true);
}
export async function getAvailableCategories(): Promise<CategoryCount> {

View File

@@ -0,0 +1,44 @@
// src/utils/concurrencyLimiter.ts
type AsyncTask<T = any> = () => Promise<T>;
class ConcurrencyLimiter {
private limit: number;
private activeCount: number;
private queue: Array<{ task: AsyncTask; resolve: (value: any) => void; reject: (reason?: any) => void }>;
constructor(limit: number) {
this.limit = limit;
this.activeCount = 0;
this.queue = [];
}
private async next() {
if (this.activeCount >= this.limit || this.queue.length === 0) {
return;
}
this.activeCount++;
const { task, resolve, reject } = this.queue.shift()!;
try {
const result = await task();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.activeCount--;
this.next();
}
}
public run<T>(task: AsyncTask<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.next();
});
}
}
// Export a single instance
const activityDetailLimiter = new ConcurrencyLimiter(16); // As requested
export default activityDetailLimiter;