From f3384b11ff7a20cafce9bb9e93c6d13dfd3a924f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:13:38 -0300 Subject: [PATCH] fix(home): count resolved observation categories --- src/hooks/useCountUp.ts | 4 +- src/screens/Home/HomeScreen.tsx | 17 +++-- tests/unit/hooks/useCountUp.test.ts | 17 +---- tests/unit/screens/Home/HomeScreen.test.tsx | 77 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 27 deletions(-) diff --git a/src/hooks/useCountUp.ts b/src/hooks/useCountUp.ts index e08cdaa5..7818ce2f 100644 --- a/src/hooks/useCountUp.ts +++ b/src/hooks/useCountUp.ts @@ -26,10 +26,10 @@ export function useCountUp( value: string | number, duration = 400, ): string | number { - const [display, setDisplay] = useState('0'); + const [display, setDisplay] = useState(value); const rafRef = useRef(null); // Tracks the last numeric value shown on screen (updated every frame) - const lastNumericRef = useRef(0); + const lastNumericRef = useRef(parseNumericValue(value) ?? 0); useEffect(() => { const target = parseNumericValue(value); diff --git a/src/screens/Home/HomeScreen.tsx b/src/screens/Home/HomeScreen.tsx index 54eb8d42..7360cd03 100644 --- a/src/screens/Home/HomeScreen.tsx +++ b/src/screens/Home/HomeScreen.tsx @@ -16,6 +16,7 @@ import { useShellSlot } from '@/components/layout/shell-slot'; import { Button } from '@/components/ui/button'; import { useAlerts } from '@/hooks/useAlerts'; import { useArchiveStatus } from '@/hooks/useArchiveStatus'; +import { useObservationCategoryMetadata } from '@/hooks/useObservationCategoryMetadata'; import { useObservationDisplayNames } from '@/hooks/useObservationDisplayNames'; import { useObservations } from '@/hooks/useObservations'; import { useProjectCoverage } from '@/hooks/useProjectCoverage'; @@ -413,19 +414,17 @@ function HomeScreen() { observations, state.selectedProjectId, ); + const { categories: observationCategories } = useObservationCategoryMetadata({ + observations, + projectLocalId: state.selectedProjectId, + projectRemoteId: selectedProject?.remoteId, + serverUrl: archiveServerUrl, + }); const alerts = useMemo(() => alertsQuery.data ?? [], [alertsQuery.data]); const tracks = useMemo(() => tracksQuery.data ?? [], [tracksQuery.data]); - // Derive unique tag categories from observations - const categoryCount = useMemo(() => { - const tagKeys = new Set(); - for (const obs of observations) { - const cat = obs.tags?.category; - if (typeof cat === 'string' && cat.trim()) tagKeys.add(cat.trim()); - } - return tagKeys.size; - }, [observations]); + const categoryCount = observationCategories.length; // Compute media counts from observation tags (values stored as strings). // Track counts come from first-class synced track records. diff --git a/tests/unit/hooks/useCountUp.test.ts b/tests/unit/hooks/useCountUp.test.ts index 04db526b..96b9bfa7 100644 --- a/tests/unit/hooks/useCountUp.test.ts +++ b/tests/unit/hooks/useCountUp.test.ts @@ -12,24 +12,9 @@ describe('useCountUp', () => { vi.useRealTimers(); }); - it('starts at 0 and animates to the target number', () => { + it('shows the initial target number immediately without a zero flash', () => { const { result } = renderHook(() => useCountUp(100, 400)); - // Initially starts at 0 - expect(result.current).toBe('0'); - - // Advance halfway through animation - act(() => { - vi.advanceTimersByTime(200); - }); - // Should be partway through (eased) - expect(result.current).not.toBe('0'); - expect(result.current).not.toBe(100); - - // Complete the animation - act(() => { - vi.advanceTimersByTime(200); - }); expect(result.current).toBe(100); }); diff --git a/tests/unit/screens/Home/HomeScreen.test.tsx b/tests/unit/screens/Home/HomeScreen.test.tsx index ade9f43e..19ac41f9 100644 --- a/tests/unit/screens/Home/HomeScreen.test.tsx +++ b/tests/unit/screens/Home/HomeScreen.test.tsx @@ -16,6 +16,7 @@ import { } from '@/components/layout/shell-slot'; import { useAlerts } from '@/hooks/useAlerts'; import { useArchiveStatus } from '@/hooks/useArchiveStatus'; +import { useObservationCategoryMetadata } from '@/hooks/useObservationCategoryMetadata'; import { useObservations } from '@/hooks/useObservations'; import { useProjectCoverage } from '@/hooks/useProjectCoverage'; import { useProjects } from '@/hooks/useProjects'; @@ -87,6 +88,10 @@ vi.mock('@/hooks/useObservations', () => ({ useObservations: vi.fn(), })); +vi.mock('@/hooks/useObservationCategoryMetadata', () => ({ + useObservationCategoryMetadata: vi.fn(), +})); + vi.mock('@/hooks/useAlerts', () => ({ useAlerts: vi.fn(), })); @@ -139,6 +144,9 @@ vi.mock('@/screens/Home/AreaMap', () => ({ const mockUseProjects = vi.mocked(useProjects); const mockUseProjectCoverage = vi.mocked(useProjectCoverage); const mockUseArchiveStatus = vi.mocked(useArchiveStatus); +const mockUseObservationCategoryMetadata = vi.mocked( + useObservationCategoryMetadata, +); const mockUseObservations = vi.mocked(useObservations); const mockUseAlerts = vi.mocked(useAlerts); const mockUseTracks = vi.mocked(useTracks); @@ -209,6 +217,11 @@ beforeEach(() => { mockUseProjectCoverage.mockReturnValue(defaultCoverageState); mockUseArchiveStatus.mockReturnValue(defaultArchiveStatus); + mockUseObservationCategoryMetadata.mockReturnValue({ + categories: [], + categoryByObservationId: new Map(), + displayNamesByObservationId: new Map(), + }); mockUseObservations.mockReturnValue({ data: [], isLoading: false, @@ -946,6 +959,70 @@ describe('HomeScreen', () => { }); }); + it('counts categories resolved through preset matching', async () => { + mockUseProjects.mockReturnValue({ + data: [ + { + localId: 'p1', + name: 'Preset Categories Project', + updatedAt: '2025-01-01T00:00:00.000Z', + }, + ], + isLoading: false, + isError: false, + error: null, + status: 'success', + } as unknown as ReturnType); + mockUseObservations.mockReturnValue({ + data: [ + { + localId: 'obs1', + createdAt: new Date().toISOString(), + tags: { type: 'forest' }, + }, + { + localId: 'obs2', + createdAt: new Date().toISOString(), + tags: { type: 'water' }, + }, + ], + isLoading: false, + isError: false, + error: null, + status: 'success', + } as unknown as ReturnType); + mockUseProjectCoverage.mockReturnValue({ + results: [makeResult('observed', 50000)], + isCalculating: false, + error: null, + }); + mockUseObservationCategoryMetadata.mockReturnValue({ + categories: [ + { id: 'forest', name: 'Forest' }, + { id: 'water', name: 'Water' }, + ], + categoryByObservationId: new Map(), + displayNamesByObservationId: new Map(), + }); + + renderWithShell(); + await waitForWorkspace('Preset Categories Project'); + + const categoriesCard = + screen.getByText('Categories').parentElement?.parentElement; + expect(categoriesCard).not.toBeNull(); + expect(within(categoriesCard!).getByText('2')).toBeInTheDocument(); + expect(mockUseObservationCategoryMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + observations: expect.arrayContaining([ + expect.objectContaining({ localId: 'obs1' }), + expect.objectContaining({ localId: 'obs2' }), + ]), + projectLocalId: 'p1', + }), + ); + }); + it('uses singular Observation label when count is 1', async () => { mockUseProjects.mockReturnValue({ data: [