Fix comparison badge to update in real-time across components
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 35s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

- Move comparison state from hook to shared context provider
- All components now share the same state instance
- Badge count updates immediately when schools are added/removed
- Add key prop to badge to re-trigger animation on count change
- Add storage event listener for cross-tab synchronization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-02-03 14:26:23 +00:00
parent 18964a34a2
commit 200fccb615
4 changed files with 103 additions and 59 deletions

View File

@@ -48,7 +48,9 @@ export function Navigation() {
> >
Compare Compare
{selectedSchools.length > 0 && ( {selectedSchools.length > 0 && (
<span className={styles.badge}>{selectedSchools.length}</span> <span key={selectedSchools.length} className={styles.badge}>
{selectedSchools.length}
</span>
)} )}
</Link> </Link>
<Link <Link

View File

@@ -18,6 +18,7 @@ interface ComparisonContextType {
clearAll: () => void; clearAll: () => void;
isSelected: (urn: number) => boolean; isSelected: (urn: number) => boolean;
canAddMore: boolean; canAddMore: boolean;
isInitialized: boolean;
mutate: () => void; mutate: () => void;
} }

View File

@@ -1,18 +1,98 @@
/** /**
* ComparisonProvider * ComparisonProvider
* Provides comparison state to all components * Provides shared comparison state to all components
*/ */
'use client'; 'use client';
import { useComparison } from '@/hooks/useComparison'; import { useState, useEffect, useCallback } from 'react';
import { getFromLocalStorage, setToLocalStorage } from '@/lib/utils';
import type { School } from '@/lib/types';
import { ComparisonContext } from './ComparisonContext'; import { ComparisonContext } from './ComparisonContext';
const STORAGE_KEY = 'selectedSchools';
const MAX_SCHOOLS = 5;
export function ComparisonProvider({ children }: { children: React.ReactNode }) { export function ComparisonProvider({ children }: { children: React.ReactNode }) {
const comparisonState = useComparison(); const [selectedSchools, setSelectedSchools] = useState<School[]>([]);
const [isInitialized, setIsInitialized] = useState(false);
// Load from localStorage on mount
useEffect(() => {
const stored = getFromLocalStorage<School[]>(STORAGE_KEY, []);
setSelectedSchools(stored);
setIsInitialized(true);
}, []);
// Save to localStorage when schools change
useEffect(() => {
if (isInitialized) {
setToLocalStorage(STORAGE_KEY, selectedSchools);
}
}, [selectedSchools, isInitialized]);
// Listen for storage changes from other tabs
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === STORAGE_KEY && e.newValue) {
try {
const parsed = JSON.parse(e.newValue);
setSelectedSchools(parsed);
} catch {
// Ignore parse errors
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
const addSchool = useCallback((school: School) => {
setSelectedSchools((prev) => {
if (prev.some((s) => s.urn === school.urn)) {
return prev;
}
if (prev.length >= MAX_SCHOOLS) {
alert(`Maximum ${MAX_SCHOOLS} schools can be compared`);
return prev;
}
return [...prev, school];
});
}, []);
const removeSchool = useCallback((urn: number) => {
setSelectedSchools((prev) => prev.filter((s) => s.urn !== urn));
}, []);
const clearAll = useCallback(() => {
setSelectedSchools([]);
}, []);
const isSelected = useCallback(
(urn: number) => selectedSchools.some((s) => s.urn === urn),
[selectedSchools]
);
// Placeholder mutate - actual SWR mutate is in useComparison hook
const mutate = useCallback(() => {}, []);
return ( return (
<ComparisonContext.Provider value={comparisonState}> <ComparisonContext.Provider
value={{
selectedSchools,
comparisonData: null,
isLoading: false,
error: null,
addSchool,
removeSchool,
clearAll,
isSelected,
canAddMore: selectedSchools.length < MAX_SCHOOLS,
isInitialized,
mutate,
}}
>
{children} {children}
</ComparisonContext.Provider> </ComparisonContext.Provider>
); );

View File

@@ -1,35 +1,25 @@
/** /**
* Custom hook for managing school comparison state * Custom hook for managing school comparison state
* Uses shared context for real-time updates across components
*/ */
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { fetcher } from '@/lib/api'; import { fetcher } from '@/lib/api';
import { getFromLocalStorage, setToLocalStorage } from '@/lib/utils'; import { useComparisonContext } from '@/context/ComparisonContext';
import type { School, ComparisonResponse } from '@/lib/types'; import type { ComparisonResponse } from '@/lib/types';
const STORAGE_KEY = 'selectedSchools';
const MAX_SCHOOLS = 5;
export function useComparison() { export function useComparison() {
const [selectedSchools, setSelectedSchools] = useState<School[]>([]); const {
const [isInitialized, setIsInitialized] = useState(false); selectedSchools,
addSchool,
// Load from localStorage on mount removeSchool,
useEffect(() => { clearAll,
const stored = getFromLocalStorage<School[]>(STORAGE_KEY, []); isSelected,
setSelectedSchools(stored); canAddMore,
setIsInitialized(true); isInitialized,
}, []); } = useComparisonContext();
// Save to localStorage when schools change
useEffect(() => {
if (isInitialized) {
setToLocalStorage(STORAGE_KEY, selectedSchools);
}
}, [selectedSchools, isInitialized]);
// Fetch comparison data for selected schools // Fetch comparison data for selected schools
const urns = selectedSchools.map((s) => s.urn).join(','); const urns = selectedSchools.map((s) => s.urn).join(',');
@@ -38,40 +28,10 @@ export function useComparison() {
fetcher, fetcher,
{ {
revalidateOnFocus: false, revalidateOnFocus: false,
dedupingInterval: 10000, // 10 seconds dedupingInterval: 10000,
} }
); );
const addSchool = useCallback((school: School) => {
setSelectedSchools((prev) => {
// Check if already selected
if (prev.some((s) => s.urn === school.urn)) {
return prev;
}
// Check max limit
if (prev.length >= MAX_SCHOOLS) {
alert(`Maximum ${MAX_SCHOOLS} schools can be compared`);
return prev;
}
return [...prev, school];
});
}, []);
const removeSchool = useCallback((urn: number) => {
setSelectedSchools((prev) => prev.filter((s) => s.urn !== urn));
}, []);
const clearAll = useCallback(() => {
setSelectedSchools([]);
}, []);
const isSelected = useCallback(
(urn: number) => selectedSchools.some((s) => s.urn === urn),
[selectedSchools]
);
return { return {
selectedSchools, selectedSchools,
comparisonData: data?.comparison, comparisonData: data?.comparison,
@@ -81,7 +41,8 @@ export function useComparison() {
removeSchool, removeSchool,
clearAll, clearAll,
isSelected, isSelected,
canAddMore: selectedSchools.length < MAX_SCHOOLS, canAddMore,
isInitialized,
mutate, mutate,
}; };
} }