Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/hooks/useCountUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ export function useCountUp(
value: string | number,
duration = 400,
): string | number {
const [display, setDisplay] = useState<string | number>('0');
const [display, setDisplay] = useState<string | number>(value);
const rafRef = useRef<number | null>(null);
// Tracks the last numeric value shown on screen (updated every frame)
const lastNumericRef = useRef<number>(0);
const lastNumericRef = useRef<number>(parseNumericValue(value) ?? 0);

useEffect(() => {
const target = parseNumericValue(value);
Expand Down
17 changes: 8 additions & 9 deletions src/screens/Home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string>();
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.
Expand Down
17 changes: 1 addition & 16 deletions tests/unit/hooks/useCountUp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
77 changes: 77 additions & 0 deletions tests/unit/screens/Home/HomeScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof useProjects>);
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<typeof useObservations>);
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(<HomeScreen />);
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: [
Expand Down
Loading