diff --git a/frontend/__tests__/e2e/pages/About.spec.ts b/frontend/__tests__/e2e/pages/About.spec.ts
index f25f73f2fc..7fca8dfd21 100644
--- a/frontend/__tests__/e2e/pages/About.spec.ts
+++ b/frontend/__tests__/e2e/pages/About.spec.ts
@@ -77,7 +77,7 @@ test.describe('About Page', () => {
}
})
- test('displays animated counters with correct values', async ({ page }) => {
+ test('displays project statistics with correct values', async ({ page }) => {
await expect(page.getByText('1.2K+Contributors')).toBeVisible()
await expect(page.getByText('40+Open Issues')).toBeVisible()
await expect(page.getByText('60+Forks')).toBeVisible()
diff --git a/frontend/__tests__/unit/components/AnimatedCounter.test.tsx b/frontend/__tests__/unit/components/AnimatedCounter.test.tsx
deleted file mode 100644
index b12061aa03..0000000000
--- a/frontend/__tests__/unit/components/AnimatedCounter.test.tsx
+++ /dev/null
@@ -1,295 +0,0 @@
-import { render, screen, act } from '@testing-library/react'
-import '@testing-library/jest-dom'
-import AnimatedCounter from 'components/AnimatedCounter'
-
-jest.useFakeTimers()
-
-// Patch for performance.now() in jsdom test env
-beforeAll(() => {
- if (typeof performance.now !== 'function') {
- performance.now = jest.fn(() => Date.now())
- }
-})
-
-describe('AnimatedCounter', () => {
- afterEach(() => {
- jest.clearAllTimers()
- })
-
- describe('Renders successfully with minimal required props', () => {
- it('renders correctly with initial count 0', () => {
- render()
- const counter = screen.getByText('0')
- expect(counter).toBeInTheDocument()
- })
-
- it('renders with all props including className', () => {
- render()
- const element = screen.getByText('0')
- expect(element).toHaveClass('test-class')
- })
- })
-
- describe('Prop-based behavior – different props affect output', () => {
- it('renders with correct end value', () => {
- render()
- expect(screen.getByText('0')).toBeInTheDocument()
- })
-
- it('applies custom className when provided', () => {
- render()
- const element = screen.getByText('0')
- expect(element).toHaveClass('custom-counter')
- })
-
- it('renders without className when not provided', () => {
- render()
- const element = screen.getByText('0')
- expect(element).not.toHaveAttribute('class')
- })
- })
-
- describe('State changes / internal logic', () => {
- it('animates to the end value over duration', () => {
- render()
-
- // Advance time by 2 seconds within act()
- act(() => {
- jest.advanceTimersByTime(2000)
- })
-
- const counter = screen.getByText('1K')
- expect(counter).toBeInTheDocument()
- })
-
- it('updates count during animation', () => {
- render()
-
- // Advance time by 1 second (halfway through animation)
- act(() => {
- jest.advanceTimersByTime(1000)
- })
-
- // Should show intermediate value
- const displayedValue = Number.parseInt(screen.getByText(/\d+/).textContent || '0')
- expect(displayedValue).toBeGreaterThan(0)
- expect(displayedValue).toBeLessThanOrEqual(50)
- })
-
- it('stops at exact end value', () => {
- render()
-
- act(() => {
- jest.advanceTimersByTime(1000)
- })
-
- expect(screen.getByText('75')).toBeInTheDocument()
- })
- })
-
- describe('Default values and fallbacks', () => {
- it('handles zero end value', () => {
- render()
- expect(screen.getByText('0')).toBeInTheDocument()
- })
-
- it('handles negative end value', () => {
- render()
- expect(screen.getByText('0')).toBeInTheDocument()
- })
-
- it('handles very small duration', () => {
- render()
- expect(screen.getByText('0')).toBeInTheDocument()
- })
-
- it('handles very large duration', () => {
- render()
- expect(screen.getByText('0')).toBeInTheDocument()
- })
- })
-
- describe('Text and content rendering', () => {
- it('displays formatted numbers using millify', () => {
- render()
-
- act(() => {
- jest.advanceTimersByTime(1000)
- })
-
- expect(screen.getByText('1.2K')).toBeInTheDocument()
- })
-
- it('renders as span element', () => {
- render()
- const element = screen.getByText('0')
- expect(element.tagName).toBe('SPAN')
- })
- })
-
- describe('Handles edge cases and invalid inputs', () => {
- it('handles decimal end values', () => {
- render()
- expect(screen.getByText('0')).toBeInTheDocument()
- })
-
- it('handles very large end values', () => {
- render()
- expect(screen.getByText('0')).toBeInTheDocument()
- })
-
- it('handles zero duration gracefully', () => {
- render()
- expect(screen.getByText('0')).toBeInTheDocument()
- })
-
- it('handles negative duration gracefully', () => {
- render()
- expect(screen.getByText('0')).toBeInTheDocument()
- })
- })
-
- describe('DOM structure / classNames / styles', () => {
- it('renders with correct HTML structure', () => {
- render()
- const element = screen.getByText('0')
- expect(element).toBeInTheDocument()
- expect(element.tagName).toBe('SPAN')
- expect(element).toHaveClass('test-class')
- })
-
- it('applies multiple CSS classes when provided', () => {
- render()
- const element = screen.getByText('0')
- expect(element).toHaveClass('class1', 'class2')
- })
-
- it('handles empty className string', () => {
- render()
- const element = screen.getByText('0')
- expect(element).toHaveAttribute('class', '')
- })
- })
-
- describe('Animation behavior', () => {
- it('calls requestAnimationFrame during animation', () => {
- const requestAnimationFrameSpy = jest.spyOn(globalThis, 'requestAnimationFrame')
- render()
-
- expect(requestAnimationFrameSpy).toHaveBeenCalled()
- })
-
- it('renders final value correctly', () => {
- render()
-
- act(() => {
- jest.advanceTimersByTime(100)
- })
-
- expect(screen.getByText('100')).toBeInTheDocument()
- })
-
- it('updates count correctly', () => {
- render()
-
- act(() => {
- jest.advanceTimersByTime(1000)
- })
-
- expect(screen.getByText('10')).toBeInTheDocument()
- })
- })
-
- describe('Component lifecycle', () => {
- it('re-initializes animation when props change', () => {
- const { rerender } = render()
-
- // Wait for first animation to complete
- act(() => {
- jest.advanceTimersByTime(1000)
- })
-
- expect(screen.getByText('50')).toBeInTheDocument()
-
- // Change props
- rerender()
-
- // Should show the new end value after animation completes
- act(() => {
- jest.advanceTimersByTime(2000)
- })
-
- expect(screen.getByText('100')).toBeInTheDocument()
- })
-
- it('handles rapid prop changes gracefully', () => {
- const { rerender } = render()
-
- // Rapidly change props
- rerender()
- rerender()
- rerender()
-
- // Should not crash and should render
- expect(screen.getByText('0')).toBeInTheDocument()
- })
- })
-
- describe('Accessibility considerations', () => {
- it('renders content that can be read by screen readers', () => {
- render()
- const element = screen.getByText('0')
- expect(element).toBeInTheDocument()
- expect(element.textContent).toBeTruthy()
- })
-
- it('maintains semantic meaning of displayed numbers', () => {
- render()
- const element = screen.getByText('0')
- expect(element).toBeInTheDocument()
- // The number should be meaningful to screen readers
- expect(element.textContent).toMatch(/\d+/)
- })
- })
-
- describe('Event handling and user interactions', () => {
- it('responds to prop changes correctly', () => {
- const { rerender } = render()
-
- act(() => {
- jest.advanceTimersByTime(1000)
- })
-
- expect(screen.getByText('100')).toBeInTheDocument()
-
- // Change end value
- rerender()
-
- // Should show the new end value after animation completes
- act(() => {
- jest.advanceTimersByTime(1000)
- })
-
- expect(screen.getByText('200')).toBeInTheDocument()
- })
- })
-
- describe('Performance and optimization', () => {
- it('does not cause infinite re-renders', () => {
- const renderSpy = jest.fn()
- const TestWrapper = () => {
- renderSpy()
- return
- }
-
- render()
-
- act(() => {
- jest.advanceTimersByTime(1000)
- })
-
- // Should not have excessive render calls
- expect(renderSpy).toHaveBeenCalledTimes(1)
- })
- })
-})
diff --git a/frontend/__tests__/unit/pages/Home.test.tsx b/frontend/__tests__/unit/pages/Home.test.tsx
index 94e26deb09..3338d8c314 100644
--- a/frontend/__tests__/unit/pages/Home.test.tsx
+++ b/frontend/__tests__/unit/pages/Home.test.tsx
@@ -181,17 +181,6 @@ describe('Home', () => {
})
})
- test('renders AnimatedCounter components', async () => {
- render()
-
- await waitFor(() => {
- expect(screen.getByText('Active Projects')).toBeInTheDocument()
- expect(screen.getByText('Contributors')).toBeInTheDocument()
- expect(screen.getByText('Local Chapters')).toBeInTheDocument()
- expect(screen.getByText('Countries')).toBeInTheDocument()
- })
- })
-
test('handles missing data gracefully', async () => {
;(useQuery as unknown as jest.Mock).mockReturnValue({
data: mockGraphQLData,
@@ -271,18 +260,21 @@ describe('Home', () => {
]
const stats = mockGraphQLData.statsOverview
- await waitFor(() => {
- for (const header of headers) {
- expect(screen.getByText(header)).toBeInTheDocument()
- }
- })
+ const statTexts = [
+ millify(stats.activeProjectsStats) + '+',
+ millify(stats.activeChaptersStats) + '+',
+ millify(stats.contributorsStats) + '+',
+ millify(stats.countriesStats) + '+',
+ millify(stats.slackWorkspaceStats) + '+',
+ ]
- // Wait for animated counters to complete (2 seconds animation)
- // Note: The "+" is rendered separately from the number, so we check for the number only
await waitFor(
() => {
- for (const value of Object.values(stats)) {
- expect(screen.getByText(millify(value), { exact: false })).toBeInTheDocument()
+ for (const stat of statTexts) {
+ expect(screen.getByText(stat)).toBeInTheDocument()
+ }
+ for (const header of headers) {
+ expect(screen.getByText(header)).toBeInTheDocument()
}
},
{ timeout: 3000 }
diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx
index 798b9c20bb..ec38eb103b 100644
--- a/frontend/src/app/about/page.tsx
+++ b/frontend/src/app/about/page.tsx
@@ -14,6 +14,7 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Tooltip } from '@heroui/tooltip'
import upperFirst from 'lodash/upperFirst'
+import millify from 'millify'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useState } from 'react'
@@ -34,7 +35,6 @@ import {
projectStory,
} from 'utils/aboutData'
import AnchorTitle from 'components/AnchorTitle'
-import AnimatedCounter from 'components/AnimatedCounter'
import Leaders from 'components/Leaders'
import Markdown from 'components/MarkdownWrapper'
import SecondaryCard from 'components/SecondaryCard'
@@ -299,7 +299,7 @@ const About = () => {
-
+
+ {millify(Math.floor(stat.value / 10 || 0) * 10)}+
{stat.label}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 84c2301fed..a5f8ad210b 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -18,6 +18,7 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { addToast } from '@heroui/toast'
import upperFirst from 'lodash/upperFirst'
+import millify from 'millify'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { fetchAlgoliaData } from 'server/fetchAlgoliaData'
@@ -29,7 +30,6 @@ import type { MainPageData } from 'types/home'
import { formatDate, formatDateRange } from 'utils/dateFormatter'
import AnchorTitle from 'components/AnchorTitle'
-import AnimatedCounter from 'components/AnimatedCounter'
import CalendarButton from 'components/CalendarButton'
import ChapterMapWrapper from 'components/ChapterMapWrapper'
import LeadersList from 'components/LeadersList'
@@ -376,9 +376,7 @@ export default function Home() {
{counterData.map((stat) => (
-
+ {millify(stat.value)}+
{stat.label}
diff --git a/frontend/src/components/AnimatedCounter.tsx b/frontend/src/components/AnimatedCounter.tsx
deleted file mode 100644
index 41f9139802..0000000000
--- a/frontend/src/components/AnimatedCounter.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import millify from 'millify'
-import { useEffect, useRef, useState } from 'react'
-
-interface AnimatedCounterProps {
- className?: string
- duration: number
- end: number
-}
-
-export default function AnimatedCounter({ end, duration, className }: AnimatedCounterProps) {
- const [count, setCount] = useState(0)
- const countRef = useRef(count)
- const startTime = useRef(Date.now())
-
- useEffect(() => {
- const animate = () => {
- const now = Date.now()
- const progress = Math.min((now - startTime.current) / (duration * 1000), 1)
- const currentCount = Math.floor(progress * end)
-
- if (currentCount !== countRef.current) {
- setCount(currentCount)
- countRef.current = currentCount
- }
-
- if (progress < 1) {
- requestAnimationFrame(animate)
- }
- }
-
- requestAnimationFrame(animate)
- }, [end, duration])
-
- return
{millify(count)}
-}