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: 4 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,7 @@
## 2025-06-17 - [Accessible Checkboxes and Consistent Prop Spreading]
**Learning:** Enhancing base form components like `CustomCheckbox` with standardized `required` indicators (visual asterisk and `aria-required`) and grid support (`s`, `m`, `l`, `xl`, `offset`) improves both accessibility and developer productivity. Ensuring that `...props` are consistently applied to the inner `input` regardless of the presence of a wrapper `div` maintains a predictable component API.
**Action:** Always provide visual and semantic cues for mandatory fields and ensure consistent prop-spreading behavior in multi-layered components.

## 2025-06-27 - [Keyboard Shortcuts and Discoverability in Session Summaries]
**Learning:** For terminal screens like session summaries, adding keyboard shortcuts (Enter for repetition, 'i' for navigation) significantly streamlines the user loop. Discoverability is key: using bracketed visual hints like `[Enter]` and `[i]` directly in button labels, paired with state-aware shortcuts (e.g., only enabling 'r' for review if errors exist), creates a powerful yet accessible interface.
**Action:** Implement keyboard shortcuts for primary actions on all summary and high-traffic screens, ensuring they are discoverable via visual hints and accessible via ARIA labels.
49 changes: 36 additions & 13 deletions src/v2/pages/V2SessionSummary.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useHistory, useLocation, Link } from 'react-router-dom';
import LeaderboardService from '../../services/LeaderboardService';
import Auth from '../../modules/Auth';
Expand Down Expand Up @@ -87,17 +87,37 @@ const V2SessionSummary = () => {
return 'var(--md-sys-color-error)';
};

const handleNewSession = () => {
const handleNewSession = useCallback(() => {
history.push('/practica');
};
}, [history]);

const handleGoToDashboard = () => {
const handleGoToDashboard = useCallback(() => {
history.push('/dashboard');
};
}, [history]);

const handleReviewMistakes = () => {
const handleReviewMistakes = useCallback(() => {
history.push('/errores');
};
}, [history]);

// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;

const key = e.key.toLowerCase();

if (key === 'enter') {
handleNewSession();
} else if (key === 'i') {
handleGoToDashboard();
} else if (key === 'r' && totalQuestions - correctAnswers > 0) {
handleReviewMistakes();
}
};

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleNewSession, handleGoToDashboard, handleReviewMistakes, totalQuestions, correctAnswers]);

return (
<div className='v2-page-medium v2-text-center'>
Expand Down Expand Up @@ -208,32 +228,35 @@ const V2SessionSummary = () => {
className='v2-btn-tonal v2-btn-h-56'
onClick={handleGoToDashboard}
style={{ padding: '0 32px' }}
aria-label='Volver al inicio'
aria-label='Volver al inicio (atajo: i)'
title='Atajo: i'
>
<i className='material-icons' aria-hidden='true'>home</i>
Inicio
Inicio <span className='v2-opacity-50' style={{ fontSize: '0.8em', marginLeft: '4px' }}>[i]</span>
</button>

{totalQuestions - correctAnswers > 0 && (
<button
className='v2-btn-tonal v2-btn-h-56'
onClick={handleReviewMistakes}
style={{ padding: '0 32px' }}
aria-label='Revisar errores'
aria-label='Revisar errores (atajo: r)'
title='Atajo: r'
>
<i className='material-icons' aria-hidden='true'>error_outline</i>
Revisar Errores
Revisar Errores <span className='v2-opacity-50' style={{ fontSize: '0.8em', marginLeft: '4px' }}>[r]</span>
</button>
)}

<button
className='v2-btn-filled v2-btn-h-56'
onClick={handleNewSession}
style={{ padding: '0 32px' }}
aria-label='Nueva sesión de práctica'
aria-label='Nueva sesión de práctica (atajo: Enter)'
title='Atajo: Enter'
>
<i className='material-icons' aria-hidden='true'>refresh</i>
Nueva Sesión
Nueva Sesión <span className='v2-opacity-50' style={{ fontSize: '0.8em', marginLeft: '4px' }}>[Enter]</span>
</button>
</div>

Expand Down
150 changes: 150 additions & 0 deletions src/v2/pages/V2SessionSummary.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2SessionSummary from './V2SessionSummary';
import LeaderboardService from '../../services/LeaderboardService';
import Auth from '../../modules/Auth';

// Use vi.hoisted for variables used in vi.mock
const { mockHistoryPush } = vi.hoisted(() => ({
mockHistoryPush: vi.fn()
}));

// Mock services
vi.mock('../../services/LeaderboardService', () => ({
default: {
getTopUsers: vi.fn().mockResolvedValue({ data: [] })
}
}));

vi.mock('../../modules/Auth', () => ({
default: {
getUserInfo: vi.fn().mockReturnValue({ id: '123', name: 'Test User' })
}
}));

// Mock react-router-dom hooks
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useHistory: () => ({
push: mockHistoryPush
}),
useLocation: vi.fn().mockReturnValue({
state: {
totalQuestions: 10,
correctAnswers: 8,
xpEarned: 400,
timeElapsed: 120,
userId: '123'
}
})
};
});

describe('V2SessionSummary', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders session statistics correctly', async () => {
render(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

// Wait for leaderboard loading to finish to avoid act() warnings
await waitFor(() => {
expect(screen.queryByText('...')).toBeNull();
});

expect(screen.getAllByText('80%')).toHaveLength(2);
expect(screen.getByText('+400')).toBeTruthy();
expect(screen.getByText('02:00')).toBeTruthy();
expect(screen.getByText('8/10')).toBeTruthy();
expect(screen.getByText('2/10')).toBeTruthy();
});

it('shows visual hints for keyboard shortcuts', async () => {
render(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

await waitFor(() => expect(screen.queryByText('...')).toBeNull());

expect(screen.getByText('[i]')).toBeTruthy();
expect(screen.getByText('[r]')).toBeTruthy();
expect(screen.getByText('[Enter]')).toBeTruthy();

expect(screen.getByLabelText(/Volver al inicio \(atajo: i\)/)).toBeTruthy();
expect(screen.getByLabelText(/Revisar errores \(atajo: r\)/)).toBeTruthy();
expect(screen.getByLabelText(/Nueva sesión de práctica \(atajo: Enter\)/)).toBeTruthy();
});

it('navigates to dashboard when "i" key is pressed', async () => {
render(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

await waitFor(() => expect(screen.queryByText('...')).toBeNull());

fireEvent.keyDown(window, { key: 'i' });
expect(mockHistoryPush).toHaveBeenCalledWith('/dashboard');
});

it('navigates to practice when "Enter" key is pressed', async () => {
render(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

await waitFor(() => expect(screen.queryByText('...')).toBeNull());

fireEvent.keyDown(window, { key: 'Enter' });
expect(mockHistoryPush).toHaveBeenCalledWith('/practica');
});

it('navigates to errors when "r" key is pressed', async () => {
render(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

await waitFor(() => expect(screen.queryByText('...')).toBeNull());

fireEvent.keyDown(window, { key: 'r' });
expect(mockHistoryPush).toHaveBeenCalledWith('/errores');
});

it('does not navigate to errors when "r" key is pressed and there are no errors', async () => {
const { useLocation } = await import('react-router-dom');
useLocation.mockReturnValue({
state: {
totalQuestions: 10,
correctAnswers: 10,
xpEarned: 500,
timeElapsed: 100,
userId: '123'
}
});

render(
<MemoryRouter>
<V2SessionSummary />
</MemoryRouter>
);

await waitFor(() => expect(screen.queryByText('...')).toBeNull());

fireEvent.keyDown(window, { key: 'r' });
expect(mockHistoryPush).not.toHaveBeenCalledWith('/errores');
});
});
Loading