Skip to content

Commit 0c2c786

Browse files
Merge pull request #160 from CSSLab/copilot/fix-153
Add realtime user indicator to home page
2 parents 7179cc3 + 20c1e7a commit 0c2c786

File tree

8 files changed

+238
-13
lines changed

8 files changed

+238
-13
lines changed

__tests__/api/active-users.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createMocks } from 'node-mocks-http'
2+
import handler from 'src/pages/api/active-users'
3+
4+
global.fetch = jest.fn()
5+
6+
describe('/api/active-users', () => {
7+
beforeEach(() => {
8+
jest.clearAllMocks()
9+
delete process.env.POSTHOG_PROJECT_ID
10+
delete process.env.POSTHOG_API_KEY
11+
})
12+
13+
it('should return 405 for non-GET requests', async () => {
14+
const { req, res } = createMocks({
15+
method: 'POST',
16+
})
17+
18+
await handler(req, res)
19+
20+
expect(res._getStatusCode()).toBe(405)
21+
const data = JSON.parse(res._getData())
22+
expect(data.success).toBe(false)
23+
expect(data.error).toBe('Method not allowed')
24+
})
25+
})
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { getActiveUserCount } from 'src/api/home/activeUsers'
2+
3+
// Mock fetch for API calls
4+
global.fetch = jest.fn()
5+
6+
describe('getActiveUserCount', () => {
7+
beforeEach(() => {
8+
jest.clearAllMocks()
9+
})
10+
11+
it('should return a positive number', async () => {
12+
// Mock successful API response
13+
;(fetch as jest.Mock).mockResolvedValueOnce({
14+
ok: true,
15+
json: async () => ({
16+
activeUsers: 15,
17+
success: true,
18+
}),
19+
})
20+
21+
const count = await getActiveUserCount()
22+
expect(count).toBeGreaterThanOrEqual(0)
23+
expect(Number.isInteger(count)).toBe(true)
24+
})
25+
26+
it('should call the internal API endpoint', async () => {
27+
// Mock successful API response
28+
;(fetch as jest.Mock).mockResolvedValueOnce({
29+
ok: true,
30+
json: async () => ({
31+
activeUsers: 10,
32+
success: true,
33+
}),
34+
})
35+
36+
const count = await getActiveUserCount()
37+
38+
expect(fetch).toHaveBeenCalledWith('/api/active-users')
39+
expect(count).toBe(10)
40+
})
41+
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"eslint-plugin-react": "^7.28.0",
6262
"jest": "^30.0.4",
6363
"jest-environment-jsdom": "^30.0.4",
64+
"node-mocks-http": "^1.17.2",
6465
"postcss": "^8.4.45",
6566
"prettier": "^3.4.2",
6667
"prettier-plugin-tailwindcss": "^0.6.6",

src/api/home/activeUsers.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Get the count of active users in the last 30 minutes
3+
* Calls our secure server-side API endpoint that handles PostHog integration
4+
*/
5+
export const getActiveUserCount = async (): Promise<number> => {
6+
try {
7+
const response = await fetch('/api/active-users')
8+
9+
const data = await response.json()
10+
11+
if (data.success && typeof data.activeUsers === 'number') {
12+
return data.activeUsers
13+
}
14+
} catch (error) {
15+
console.error('Failed to fetch active user count:', error)
16+
}
17+
18+
return 0
19+
}

src/api/home/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './home'
2+
export * from './activeUsers'

src/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './turing'
66
export * from './play'
77
export * from './profile'
88
export * from './opening'
9+
export { getActiveUserCount } from './home'

src/components/Home/HomeHero.tsx

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
BotOrNotIcon,
1616
} from 'src/components/Common/Icons'
1717
import { PlayType } from 'src/types'
18-
import { getGlobalStats } from 'src/api'
18+
import { getGlobalStats, getActiveUserCount } from 'src/api'
1919
import { AuthContext, ModalContext } from 'src/contexts'
2020
import { AnimatedNumber } from 'src/components/Common/AnimatedNumber'
2121

@@ -120,6 +120,7 @@ export const HomeHero: React.FC<Props> = ({ scrollHandler }: Props) => {
120120
puzzle_games_total: number
121121
turing_games_total: number
122122
}>()
123+
const [activeUsers, setActiveUsers] = useState<number>(0)
123124
const { setPlaySetupModalProps } = useContext(ModalContext)
124125
const { user, connectLichess } = useContext(AuthContext)
125126

@@ -130,11 +131,36 @@ export const HomeHero: React.FC<Props> = ({ scrollHandler }: Props) => {
130131
[setPlaySetupModalProps],
131132
)
132133

134+
// Fetch global stats and set up periodic updates
133135
useEffect(() => {
134-
;(async () => {
136+
const fetchGlobalStats = async () => {
135137
const data = await getGlobalStats()
136138
setGlobalStats(data)
137-
})()
139+
}
140+
141+
// Fetch immediately
142+
fetchGlobalStats()
143+
144+
// Update every 20 seconds
145+
const interval = setInterval(fetchGlobalStats, 20000)
146+
147+
return () => clearInterval(interval)
148+
}, [])
149+
150+
// Fetch active users count and set up periodic updates
151+
useEffect(() => {
152+
const fetchActiveUsers = async () => {
153+
const count = await getActiveUserCount()
154+
setActiveUsers(count)
155+
}
156+
157+
// Fetch immediately
158+
fetchActiveUsers()
159+
160+
// Update every 20 seconds
161+
const interval = setInterval(fetchActiveUsers, 20000)
162+
163+
return () => clearInterval(interval)
138164
}, [])
139165

140166
return (
@@ -227,28 +253,43 @@ export const HomeHero: React.FC<Props> = ({ scrollHandler }: Props) => {
227253
/>
228254
</div>
229255
</div>
230-
<motion.div className="grid grid-cols-3 gap-6 px-2 md:flex">
256+
<motion.div className="grid grid-cols-2 gap-6 px-2 md:flex md:gap-6">
257+
{activeUsers > 0 ? (
258+
<p className="text-center text-base text-primary/80">
259+
<AnimatedNumber
260+
value={activeUsers}
261+
className="font-bold text-human-2"
262+
/>{' '}
263+
users online
264+
</p>
265+
) : (
266+
<></>
267+
)}
231268
<p className="text-center text-base text-primary/80">
232269
<AnimatedNumber
233270
value={globalStats?.play_moves_total || 0}
234-
className="font-bold"
271+
className="font-bold text-human-2"
235272
/>{' '}
236273
moves played
237274
</p>
238275
<p className="text-center text-base text-primary/80">
239276
<AnimatedNumber
240277
value={globalStats?.puzzle_games_total || 0}
241-
className="font-bold"
278+
className="font-bold text-human-2"
242279
/>{' '}
243280
puzzle games solved
244281
</p>
245-
<p className="text-center text-base text-primary/80">
246-
<AnimatedNumber
247-
value={globalStats?.turing_games_total || 0}
248-
className="font-bold"
249-
/>{' '}
250-
turing games played
251-
</p>
282+
{activeUsers <= 0 ? (
283+
<p className="text-center text-base text-primary/80">
284+
<AnimatedNumber
285+
value={globalStats?.turing_games_total || 0}
286+
className="font-bold"
287+
/>{' '}
288+
turing games played
289+
</p>
290+
) : (
291+
<></>
292+
)}
252293
</motion.div>
253294
</div>
254295
</Fragment>

src/pages/api/active-users.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { NextApiRequest, NextApiResponse } from 'next'
2+
3+
type Data = {
4+
activeUsers: number
5+
success: boolean
6+
error?: string
7+
}
8+
9+
/**
10+
* API endpoint to get active user count from PostHog
11+
* This keeps the PostHog API key secure on the server side
12+
*/
13+
export default async function handler(
14+
req: NextApiRequest,
15+
res: NextApiResponse<Data>,
16+
) {
17+
if (req.method !== 'GET') {
18+
return res.status(405).json({
19+
activeUsers: 0,
20+
success: false,
21+
error: 'Method not allowed',
22+
})
23+
}
24+
25+
try {
26+
const activeUsers = await fetchActiveUsersFromPostHog()
27+
28+
if (activeUsers !== null) {
29+
return res.status(200).json({
30+
activeUsers,
31+
success: true,
32+
})
33+
}
34+
} catch (error) {
35+
console.error('Error in active-users API:', error)
36+
return res.status(500).json({
37+
activeUsers: 0,
38+
success: false,
39+
})
40+
}
41+
}
42+
43+
/**
44+
* Fetch active users from PostHog Insights API (server-side only)
45+
*/
46+
async function fetchActiveUsersFromPostHog(): Promise<number | null> {
47+
const posthogUrl = process.env.POSTHOG_URL || 'https://us.posthog.com'
48+
const projectId = process.env.POSTHOG_PROJECT_ID
49+
const personalApiKey = process.env.POSTHOG_API_KEY
50+
51+
const url = `${posthogUrl}/api/projects/${projectId}/query/`
52+
53+
const now = new Date()
54+
const thirtyMinutesAgo = new Date(
55+
now.getTime() - 30 * 60 * 1000,
56+
).toISOString()
57+
58+
try {
59+
const response = await fetch(url, {
60+
method: 'POST',
61+
headers: {
62+
'Content-Type': 'application/json',
63+
Authorization: `Bearer ${personalApiKey}`,
64+
},
65+
body: JSON.stringify({
66+
query: {
67+
kind: 'HogQLQuery',
68+
query: `
69+
SELECT count(DISTINCT person_id) as recent_users
70+
FROM events
71+
WHERE event = '$pageview'
72+
AND timestamp > toDateTime('${thirtyMinutesAgo}')
73+
`,
74+
},
75+
}),
76+
})
77+
78+
console.log(`
79+
SELECT count(DISTINCT person_id) as recent_users
80+
FROM events
81+
WHERE event = '$pageview'
82+
AND timestamp > TIMESTAMP '${thirtyMinutesAgo}'
83+
`)
84+
85+
if (!response.ok) {
86+
const errorText = await response.text()
87+
throw new Error(errorText)
88+
}
89+
90+
const data = await response.json()
91+
return data.results[0][0]
92+
} catch (error) {
93+
console.error('Error fetching recent users:', error)
94+
return null
95+
}
96+
}

0 commit comments

Comments
 (0)