import { useIdentity, useCollection, useBroadcast, usePresence } from '@deplixo/sdk';
/* @component-map
* App — Main container, tab navigation [app.jsx]
* TypingTest — Core typing test interface with real-time WPM/accuracy [components/TypingTest.jsx]
* Results — Post-test results screen with detailed stats [components/Results.jsx]
* PersonalBests — Historical best records list [components/PersonalBests.jsx]
* @end-component-map */
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { TypingTest } from './components/TypingTest.jsx';
import { Results } from './components/Results.jsx';
import { PersonalBests } from './components/PersonalBests.jsx';
import { renderChart } from '@deplixo/sdk';
const DEFAULT_RACE_STATE = {
status: 'idle',
startedAt: null,
countdownEndsAt: null,
endedAt: null,
};
function HistoryChart({ records, currentUserId }) {
const canvasRef = useRef(null);
const chartData = useMemo(() => {
const items = Array.isArray(records) ? records : [];
const sorted = [...items]
.filter((record) => record && typeof record.wpm === 'number')
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const mine = currentUserId ? sorted.filter((record) => record.userId === currentUserId) : sorted;
const data = (mine.length ? mine : sorted).slice(-12);
return {
labels: data.map((record) => {
const d = new Date(record.createdAt || Date.now());
return `${d.getMonth() + 1}/${d.getDate()}`;
}),
values: data.map((record) => Number(record.wpm || 0)),
accuracy: data.map((record) => Number(record.accuracy || 0)),
count: data.length,
};
}, [records, currentUserId]);
useEffect(() => {
if (!canvasRef.current || !chartData.count) return;
renderChart(canvasRef.current, {
type: 'line',
data: {
labels: chartData.labels,
datasets: [
{
label: 'WPM',
data: chartData.values,
},
{
label: 'Accuracy %',
data: chartData.accuracy,
},
],
},
});
}, [chartData]);
if (!chartData.count) {
return
No history yet. Complete more tests to see your WPM trend.
;
}
return (
Progress over time
Last {chartData.count} runs
);
}
function App() {
const [view, setView] = useState('test');
const [lastResult, setLastResult] = useState(null);
const [activeTab, setActiveTab] = useState('test');
const { user: identityUser, loading: identityLoading } = useIdentity();
const [localUserId, setLocalUserId] = useState('');
const [selectedShareRecordId, setSelectedShareRecordId] = useState('');
const [shareFeedback, setShareFeedback] = useState('');
const { items: leaderboardItems, loading: leaderboardLoading, add: addLeaderboardRecord } = useCollection('typepulse_leaderboard', { personal: true });
const { items: raceRoomItems, loading: raceRoomsLoading, add: addRaceRoom, update: updateRaceRoom, remove: removeRaceRoom } = useCollection('typepulse_race_rooms', { personal: true });
const { items: raceParticipantItems, loading: raceParticipantsLoading, add: addRaceParticipant, update: updateRaceParticipant, remove: removeRaceParticipant } = useCollection('typepulse_race_participants', { personal: true });
const currentUserId = localUserId || (identityUser ? identityUser.id : '');
const currentUsername = identityUser?.name || identityUser?.username || identityUser?.email || 'Anonymous';
const presenceRoomId = 'typepulse-lobby';
const { users: lobbyUsers, update: updateLobbyPresence } = usePresence({ userId: currentUserId, username: currentUsername, roomId: presenceRoomId, status: 'online' });
useEffect(() => {
if (identityUser && identityUser.id && !localUserId) {
setLocalUserId(identityUser.id);
}
}, [identityUser, localUserId]);
useEffect(() => {
if (!currentUserId) return;
updateLobbyPresence({ userId: currentUserId, username: currentUsername, roomId: presenceRoomId, status: 'online' });
}, [currentUserId, currentUsername, presenceRoomId, updateLobbyPresence]);
const leaderboard = useMemo(() => {
const items = Array.isArray(leaderboardItems) ? leaderboardItems : [];
return [...items]
.filter((record) => record && typeof record.wpm === 'number')
.sort((a, b) => {
const wpmDiff = (b.wpm || 0) - (a.wpm || 0);
if (wpmDiff !== 0) return wpmDiff;
const accDiff = (b.accuracy || 0) - (a.accuracy || 0);
if (accDiff !== 0) return accDiff;
return new Date(b.createdAt || 0) - new Date(a.createdAt || 0);
})
.slice(0, 10);
}, [leaderboardItems]);
const personalBest = useMemo(() => {
if (!currentUserId) return null;
const mine = leaderboard.filter((record) => record.userId === currentUserId);
return mine[0] || null;
}, [leaderboard, currentUserId]);
const shareableRecords = useMemo(() => {
const items = Array.isArray(leaderboardItems) ? leaderboardItems : [];
return items
.filter((record) => record && record.userId === currentUserId && typeof record.wpm === 'number')
.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
}, [leaderboardItems, currentUserId]);
const selectedShareRecord = useMemo(() => {
if (!selectedShareRecordId) return shareableRecords[0] || null;
return shareableRecords.find((record) => record.id === selectedShareRecordId) || shareableRecords[0] || null;
}, [selectedShareRecordId, shareableRecords]);
const buildShareData = useCallback((record) => {
if (!record) return null;
const title = `TypePulse PB: ${Number(record.wpm || 0)} WPM`;
const text = `I just hit ${Number(record.wpm || 0)} WPM with ${Number(record.accuracy || 0)}% accuracy on TypePulse!`;
const url = typeof window !== 'undefined' ? window.location.href : '';
return {
title,
text,
url,
shareText: `${text}${url ? `\n${url}` : ''}`,
};
}, []);
const copyToClipboard = useCallback(async (text) => {
if (!text) return false;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
return true;
} catch {
return false;
}
}, []);
const handleShareRecord = useCallback(async (record) => {
const shareData = buildShareData(record);
if (!shareData) return;
const copied = await copyToClipboard(`${shareData.text}${shareData.url ? `\n${shareData.url}` : ''}`);
if (typeof navigator !== 'undefined' && navigator.share) {
try {
await navigator.share({
title: shareData.title,
text: shareData.text,
url: shareData.url || undefined,
});
setShareFeedback('Shared successfully.');
return;
} catch {
// User may cancel native share; fall back to copied message.
}
}
setShareFeedback(copied ? 'Share text copied to clipboard.' : 'Could not copy share text.');
}, [buildShareData, copyToClipboard]);
const handleCopyShareLink = useCallback(async (record) => {
const shareData = buildShareData(record);
if (!shareData) return;
const copied = await copyToClipboard(`${shareData.text}${shareData.url ? `\n${shareData.url}` : ''}`);
setShareFeedback(copied ? 'Share text copied to clipboard.' : 'Could not copy share text.');
}, [buildShareData, copyToClipboard]);
const rooms = useMemo(() => {
const items = Array.isArray(raceRoomItems) ? raceRoomItems : [];
return [...items].sort((a, b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0));
}, [raceRoomItems]);
const participants = useMemo(() => Array.isArray(raceParticipantItems) ? raceParticipantItems : [], [raceParticipantItems]);
const myRoom = useMemo(() => rooms.find((room) => room?.ownerId === currentUserId || room?.players?.some?.((p) => p.userId === currentUserId)) || null, [rooms, currentUserId]);
const myParticipant = useMemo(() => participants.find((p) => p?.userId === currentUserId && (!myRoom || p.roomId === myRoom.id)) || null, [participants, currentUserId, myRoom]);
const [roomCodeInput, setRoomCodeInput] = useState('');
const [raceDraft, setRaceDraft] = useState({
roomId: '',
roomCode: '',
status: 'idle',
progress: 0,
wpm: 0,
accuracy: 100,
finished: false,
opponentProgress: 0,
opponentName: '',
opponentConnected: false,
countdownEndsAt: null,
startedAt: null,
endedAt: null,
message: '',
});
const broadcastKey = useMemo(() => `typepulse-race-${raceDraft.roomCode || 'lobby'}`, [raceDraft.roomCode]);
const handleBroadcast = useCallback((data) => {
if (!data || data.roomId !== raceDraft.roomId) return;
if (data.type === 'sync') {
setRaceDraft((prev) => ({
...prev,
status: data.status || prev.status,
countdownEndsAt: data.countdownEndsAt ?? prev.countdownEndsAt,
startedAt: data.startedAt ?? prev.startedAt,
endedAt: data.endedAt ?? prev.endedAt,
message: data.message || prev.message,
}));
}
if (data.type === 'progress' && data.userId !== currentUserId) {
setRaceDraft((prev) => ({
...prev,
opponentProgress: Number(data.progress || 0),
opponentName: data.username || prev.opponentName,
opponentConnected: true,
}));
}
if (data.type === 'finished' && data.userId !== currentUserId) {
setRaceDraft((prev) => ({
...prev,
opponentProgress: 100,
opponentConnected: true,
message: data.message || prev.message,
}));
}
if (data.type === 'leave' && data.userId !== currentUserId) {
setRaceDraft((prev) => ({
...prev,
opponentConnected: false,
message: 'Opponent disconnected.',
}));
}
}, [currentUserId, raceDraft.roomId]);
const { send: sendRaceBroadcast } = useBroadcast(broadcastKey, handleBroadcast);
const { users: presenceUsers, update: updatePresence } = usePresence({ userId: currentUserId, username: currentUsername, roomId: raceDraft.roomId });
const activeOpponent = useMemo(() => {
const other = participants.find((p) => p?.roomId === raceDraft.roomId && p.userId !== currentUserId) || null;
return other;
}, [participants, raceDraft.roomId, currentUserId]);
const lobbyAvailableRooms = useMemo(() => {
const roomMap = new Map();
rooms.forEach((room) => {
if (!room?.id) return;
const roomParticipants = participants.filter((p) => p?.roomId === room.id);
roomMap.set(room.id, {
...room,
onlinePlayers: roomParticipants.filter((p) => p?.connected !== false).length,
totalPlayers: roomParticipants.length || (Array.isArray(room.players) ? room.players.length : 0),
});
});
return Array.from(roomMap.values());
}, [rooms, participants]);
const lobbyOnlineUsers = useMemo(() => {
const users = Array.isArray(lobbyUsers) ? lobbyUsers : [];
const unique = new Map();
users.forEach((u) => {
if (!u?.userId) return;
unique.set(u.userId, u);
});
return Array.from(unique.values());
}, [lobbyUsers]);
const currentRoomOccupancy = useMemo(() => {
if (!raceDraft.roomId) return null;
const room = rooms.find((r) => r.id === raceDraft.roomId) || myRoom;
const roomParticipants = participants.filter((p) => p?.roomId === raceDraft.roomId);
return {
online: roomParticipants.filter((p) => p?.connected !== false).length,
total: roomParticipants.length || (Array.isArray(room?.players) ? room.players.length : 0),
};
}, [raceDraft.roomId, rooms, participants, myRoom]);
useEffect(() => {
if (myRoom) {
setRaceDraft((prev) => ({
...prev,
roomId: myRoom.id || '',
roomCode: myRoom.code || '',
status: myRoom.status || 'idle',
countdownEndsAt: myRoom.countdownEndsAt || null,
startedAt: myRoom.startedAt || null,
endedAt: myRoom.endedAt || null,
}));
}
}, [myRoom]);
useEffect(() => {
if (!currentUserId || !raceDraft.roomId) return;
updatePresence({ userId: currentUserId, username: currentUsername, roomId: raceDraft.roomId });
}, [currentUserId, currentUsername, raceDraft.roomId, updatePresence]);
useEffect(() => {
if (!raceDraft.roomId || !currentUserId) return;
const opponentPresent = (presenceUsers || []).some((u) => u?.userId && u.userId !== currentUserId && u?.roomId === raceDraft.roomId);
setRaceDraft((prev) => ({ ...prev, opponentConnected: opponentPresent }));
}, [presenceUsers, raceDraft.roomId, currentUserId]);
useEffect(() => {
const syncTimer = setInterval(() => {
if (!raceDraft.countdownEndsAt || raceDraft.status !== 'countdown') return;
const remaining = new Date(raceDraft.countdownEndsAt).getTime() - Date.now();
if (remaining <= 0) {
setRaceDraft((prev) => ({ ...prev, status: 'racing', message: 'Go!' }));
}
}, 250);
return () => clearInterval(syncTimer);
}, [raceDraft.countdownEndsAt, raceDraft.status]);
const createRaceRoom = async () => {
if (!currentUserId) return;
const code = Math.random().toString(36).slice(2, 7).toUpperCase();
const room = {
code,
ownerId: currentUserId,
ownerName: currentUsername,
status: 'waiting',
players: [{ userId: currentUserId, username: currentUsername }],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...DEFAULT_RACE_STATE,
};
const created = await addRaceRoom(room);
if (created?.id) {
await addRaceParticipant({ roomId: created.id, userId: currentUserId, username: currentUsername, progress: 0, wpm: 0, accuracy: 100, connected: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
setRaceDraft((prev) => ({ ...prev, roomId: created.id, roomCode: code, status: 'waiting', message: 'Room created. Share the code to invite your opponent.' }));
}
};
const joinRaceRoom = async () => {
if (!currentUserId || !roomCodeInput.trim()) return;
const code = roomCodeInput.trim().toUpperCase();
const room = rooms.find((r) => String(r.code || '').toUpperCase() === code);
if (!room) {
setRaceDraft((prev) => ({ ...prev, message: 'Room not found.' }));
return;
}
const players = Array.isArray(room.players) ? room.players : [];
const nextRoom = {
...room,
status: 'waiting',
players: players.some((p) => p.userId === currentUserId) ? players : [...players, { userId: currentUserId, username: currentUsername }],
updatedAt: new Date().toISOString(),
};
await updateRaceRoom(room.id, nextRoom);
await addRaceParticipant({ roomId: room.id, userId: currentUserId, username: currentUsername, progress: 0, wpm: 0, accuracy: 100, connected: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
setRaceDraft((prev) => ({ ...prev, roomId: room.id, roomCode: code, status: 'waiting', message: 'Joined room. Wait for the host to start.' }));
};
const startRace = async () => {
if (!myRoom) return;
const countdownEndsAt = new Date(Date.now() + 3000).toISOString();
const startedAt = new Date(Date.now() + 3000).toISOString();
const nextRoom = { ...myRoom, status: 'countdown', countdownEndsAt, startedAt, updatedAt: new Date().toISOString() };
await updateRaceRoom(myRoom.id, nextRoom);
sendRaceBroadcast({ type: 'sync', roomId: myRoom.id, status: 'countdown', countdownEndsAt, startedAt, message: 'Race starting soon...' });
setRaceDraft((prev) => ({ ...prev, status: 'countdown', countdownEndsAt, startedAt, message: 'Race starting soon...' }));
};
const leaveRoom = async () => {
if (!raceDraft.roomId) return;
sendRaceBroadcast({ type: 'leave', roomId: raceDraft.roomId, userId: currentUserId });
await removeRaceParticipant?.(myParticipant?.id);
if (myRoom?.ownerId === currentUserId) {
await removeRaceRoom?.(myRoom.id);
}
setRaceDraft({ roomId: '', roomCode: '', status: 'idle', progress: 0, wpm: 0, accuracy: 100, finished: false, opponentProgress: 0, opponentName: '', opponentConnected: false, countdownEndsAt: null, startedAt: null, endedAt: null, message: '' });
};
const handleRaceProgress = useCallback((payload) => {
if (!raceDraft.roomId) return;
const next = {
...payload,
roomId: raceDraft.roomId,
userId: currentUserId,
username: currentUsername,
type: 'progress',
};
sendRaceBroadcast(next);
updateRaceParticipant?.(myParticipant?.id, { ...myParticipant, ...payload, connected: true, updatedAt: new Date().toISOString() });
setRaceDraft((prev) => ({ ...prev, ...payload, message: raceDraft.status === 'countdown' ? 'Waiting for race start...' : prev.message }));
}, [raceDraft.roomId, raceDraft.status, currentUserId, currentUsername, sendRaceBroadcast, updateRaceParticipant, myParticipant]);
const handleRaceComplete = useCallback(async (result) => {
setLastResult(result);
setView('results');
const record = {
userId: currentUserId || 'anonymous',
username: currentUsername,
wpm: Number(result?.wpm || 0),
accuracy: Number(result?.accuracy || 0),
errors: Number(result?.errors || 0),
charactersTyped: Number(result?.charactersTyped || 0),
timeSeconds: Number(result?.timeSeconds || 0),
rating: result?.rating || '',
createdAt: new Date().toISOString(),
};
try { await addLeaderboardRecord(record); } catch (error) { console.error('Failed to save leaderboard record:', error); }
if (raceDraft.roomId) {
sendRaceBroadcast({ type: 'finished', roomId: raceDraft.roomId, userId: currentUserId, username: currentUsername, message: 'Race finished.' });
setRaceDraft((prev) => ({ ...prev, finished: true, endedAt: new Date().toISOString(), message: 'Race finished.' }));
}
}, [addLeaderboardRecord, currentUserId, currentUsername, raceDraft.roomId, sendRaceBroadcast]);
const handleTestComplete = async (result) => {
if (raceDraft.roomId) {
await handleRaceComplete(result);
return;
}
setLastResult(result);
setView('results');
const record = {
userId: currentUserId || 'anonymous',
username: currentUsername,
wpm: Number(result?.wpm || 0),
accuracy: Number(result?.accuracy || 0),
errors: Number(result?.errors || 0),
charactersTyped: Number(result?.charactersTyped || 0),
timeSeconds: Number(result?.timeSeconds || 0),
rating: result?.rating || '',
createdAt: new Date().toISOString(),
};
try {
await addLeaderboardRecord(record);
} catch (error) {
console.error('Failed to save leaderboard record:', error);
}
};
const handleRestart = () => {
setLastResult(null);
setView('test');
setActiveTab('test');
};
const handleViewRecords = () => {
setActiveTab('records');
setView('test');
};
const handleTabChange = (tab) => {
setActiveTab(tab);
setView('test');
};
const roomPlayers = useMemo(() => participants.filter((p) => p?.roomId === raceDraft.roomId), [participants, raceDraft.roomId]);
return (
{view === 'results' && lastResult ? (
<>
>
) : activeTab === 'test' ? (
<>
Head-to-head Race Room
{raceDraft.roomId ? : null}
Lobby
{lobbyOnlineUsers.length} online
{lobbyOnlineUsers.length ? lobbyOnlineUsers.map((user) => (
{user.username || user.name || 'Anonymous'}
{user.roomId && user.roomId !== presenceRoomId ? In room : Available}
)) :
No other users are online yet.
}
Open rooms
{lobbyAvailableRooms.length ? lobbyAvailableRooms.map((room) => (
Room {room.code}
Host: {room.ownerName || 'Anonymous'}
{room.onlinePlayers}/{room.totalPlayers || 1} ready
)) :
No active rooms yet. Create one to start inviting players.
}
{!raceDraft.roomId ? (
) : (
Room: {raceDraft.roomCode || myRoom?.code}
Status: {raceDraft.status}
Opponent: {raceDraft.opponentName || activeOpponent?.username || (raceDraft.opponentConnected ? 'Connected' : 'Waiting...')}
Opponent progress: {Math.round(raceDraft.opponentProgress || 0)}%
{currentRoomOccupancy ?
Room occupancy: {currentRoomOccupancy.online}/{currentRoomOccupancy.total || 1} connected
: null}
{raceDraft.message || (raceDraft.status === 'waiting' ? 'Waiting for your opponent.' : '')}
{myRoom?.ownerId === currentUserId ?
: null}
)}
>
) : (
<>
Share a personal best
{shareFeedback ? {shareFeedback} : null}
{shareableRecords.length ? (
<>
{selectedShareRecord ? (
{buildShareData(selectedShareRecord)?.text}
{buildShareData(selectedShareRecord)?.url}
) : null}
>
) : (
No personal bests yet. Complete a typing test to unlock sharing.
)}
>
)}
);
}
export { App };
ReactDOM.createRoot(document.getElementById("root")).render();