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
32 changes: 19 additions & 13 deletions src/components/timer/SessionTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useCallback, useEffect, useRef, useState } from 'react';
import { CheckCircle2, Pause, Play, RotateCcw, Timer as TimerIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';

export const DEFAULT_SESSION_SECONDS = 25 * 60;
export const TIMER_TICK_MS = 250;
Expand Down Expand Up @@ -31,6 +32,7 @@ export default function SessionTimer({
now = Date.now,
onSessionComplete,
}: SessionTimerProps) {
const { t } = useTranslation();
const sessionDurationMs = Math.max(1, durationSeconds) * 1_000;
const [remainingMs, setRemainingMs] = useState(sessionDurationMs);
const [status, setStatus] = useState<TimerStatus>('idle');
Expand Down Expand Up @@ -105,7 +107,11 @@ export default function SessionTimer({
const remainingSeconds = remainingMs / 1_000;
const progress = Math.min(100, Math.max(0, ((sessionDurationMs - remainingMs) / sessionDurationMs) * 100));
const isRunning = status === 'running';
const primaryLabel = status === 'paused' ? 'Resume audit' : status === 'completed' ? 'Start another audit' : 'Start audit';
const primaryLabel = status === 'paused'
? t('sessionTimer.resume')
: status === 'completed'
? t('sessionTimer.restart')
: t('sessionTimer.start');
const totalAuditedSeconds = sessions.reduce((total, session) => total + session.durationSeconds, 0);

return (
Expand All @@ -114,9 +120,9 @@ export default function SessionTimer({
<TimerIcon className="h-5 w-5 text-violet-600 dark:text-violet-400" aria-hidden="true" />
<div>
<h2 id="audit-session-timer-title" className="text-lg font-semibold text-slate-900 dark:text-white">
Audit focus timer
{t('sessionTimer.heading')}
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">One task. One focused session.</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{t('sessionTimer.description')}</p>
</div>
</div>

Expand All @@ -125,20 +131,20 @@ export default function SessionTimer({
className="font-mono text-5xl font-bold tabular-nums tracking-tight text-slate-900 dark:text-white"
role="timer"
aria-live="off"
aria-label={`${Math.ceil(remainingSeconds)} seconds remaining`}
aria-label={t('sessionTimer.remainingSeconds', { count: Math.ceil(remainingSeconds) })}
>
{formatAuditTime(remainingSeconds)}
</p>
<p className="mt-2 text-sm font-medium text-violet-700 dark:text-violet-300">
{status === 'running' && 'Focus session in progress'}
{status === 'paused' && 'Session paused'}
{status === 'idle' && 'Ready for a focused audit'}
{status === 'completed' && 'Session complete — time recorded'}
{status === 'running' && t('sessionTimer.running')}
{status === 'paused' && t('sessionTimer.paused')}
{status === 'idle' && t('sessionTimer.idle')}
{status === 'completed' && t('sessionTimer.completed')}
</p>
<div
className="mt-4 h-2 overflow-hidden rounded-full bg-violet-100 dark:bg-violet-950"
role="progressbar"
aria-label="Audit session progress"
aria-label={t('sessionTimer.progress')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress)}
Expand All @@ -157,13 +163,13 @@ export default function SessionTimer({
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-3 font-semibold text-white transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900"
>
{isRunning ? <Pause className="h-4 w-4" aria-hidden="true" /> : <Play className="h-4 w-4" aria-hidden="true" />}
{isRunning ? 'Pause audit' : primaryLabel}
{isRunning ? t('sessionTimer.pause') : primaryLabel}
</button>
<button
type="button"
onClick={reset}
disabled={status === 'idle'}
aria-label="Reset audit timer"
aria-label={t('sessionTimer.reset')}
className="rounded-xl border border-slate-200 bg-white px-4 text-slate-700 transition hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
Expand All @@ -175,9 +181,9 @@ export default function SessionTimer({
<CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />
<div>
<p className="font-semibold text-emerald-800 dark:text-emerald-300">
{sessions.length} {sessions.length === 1 ? 'session' : 'sessions'} recorded
{t('sessionTimer.sessionsRecorded', { count: sessions.length })}
</p>
<p className="text-emerald-700 dark:text-emerald-400">Total audit time: {formatAuditTime(totalAuditedSeconds)}</p>
<p className="text-emerald-700 dark:text-emerald-400">{t('sessionTimer.totalAudited', { duration: formatAuditTime(totalAuditedSeconds) })}</p>
</div>
</div>
)}
Expand Down
138 changes: 138 additions & 0 deletions src/components/timer/__tests__/SessionTimer.i18n.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals';
import { createInstance } from 'i18next';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import { resources, type SupportedLanguage } from '@/i18n/config';
import SessionTimer from '../SessionTimer';

const copy = [
{
language: 'en' as const,
heading: 'Audit focus timer',
description: 'One task. One focused session.',
start: 'Start audit', resume: 'Resume audit', pause: 'Pause audit',
restart: 'Start another audit', reset: 'Reset audit timer',
idle: 'Ready for a focused audit', running: 'Focus session in progress',
paused: 'Session paused', completed: 'Session complete \u2014 time recorded',
progress: 'Audit session progress',
remainingMany: '3 seconds remaining', remainingOne: '1 second remaining',
recordedOne: '1 session recorded', recordedMany: '2 sessions recorded',
totalOne: 'Total audit time: 00:03', totalMany: 'Total audit time: 00:06',
},
{
language: 'es' as const,
heading: 'Temporizador de concentraci\u00f3n para auditor\u00edas',
description: 'Una tarea. Una sesi\u00f3n de concentraci\u00f3n.',
start: 'Iniciar auditor\u00eda', resume: 'Reanudar auditor\u00eda', pause: 'Pausar auditor\u00eda',
restart: 'Iniciar otra auditor\u00eda', reset: 'Reiniciar temporizador de auditor\u00eda',
idle: 'Listo para una auditor\u00eda concentrada', running: 'Sesi\u00f3n de concentraci\u00f3n en curso',
paused: 'Sesi\u00f3n en pausa', completed: 'Sesi\u00f3n completada: tiempo registrado',
progress: 'Progreso de la sesi\u00f3n de auditor\u00eda',
remainingMany: 'Quedan 3 segundos', remainingOne: 'Queda 1 segundo',
recordedOne: '1 sesi\u00f3n registrada', recordedMany: '2 sesiones registradas',
totalOne: 'Tiempo total de auditor\u00eda: 00:03', totalMany: 'Tiempo total de auditor\u00eda: 00:06',
},
{
language: 'fr' as const,
heading: 'Minuteur de concentration pour les audits',
description: 'Une t\u00e2che. Une session de concentration.',
start: 'D\u00e9marrer l\u2019audit', resume: 'Reprendre l\u2019audit', pause: 'Mettre l\u2019audit en pause',
restart: 'D\u00e9marrer un autre audit', reset: 'R\u00e9initialiser le minuteur d\u2019audit',
idle: 'Pr\u00eat pour un audit concentr\u00e9', running: 'Session de concentration en cours',
paused: 'Session en pause', completed: 'Session termin\u00e9e : temps enregistr\u00e9',
progress: 'Progression de la session d\u2019audit',
remainingMany: '3 secondes restantes', remainingOne: '1 seconde restante',
recordedOne: '1 session enregistr\u00e9e', recordedMany: '2 sessions enregistr\u00e9es',
totalOne: 'Temps d\u2019audit total : 00:03', totalMany: 'Temps d\u2019audit total : 00:06',
},
];

async function renderTimer(language: SupportedLanguage, onSessionComplete = jest.fn()) {
// Use the actual dictionaries and translation hook, isolated from the global
// app instance. A mocked t(key) could hide missing translations entirely.
const i18n = createInstance();
await i18n.use(initReactI18next).init({
resources, lng: language, fallbackLng: false, initAsync: false,
interpolation: { escapeValue: false }, react: { useSuspense: false },
});
render(
<I18nextProvider i18n={i18n}>
<SessionTimer durationSeconds={3} onSessionComplete={onSessionComplete} />
</I18nextProvider>,
);
return i18n;
}

describe('SessionTimer translations (#159)', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
});
afterEach(() => {
cleanup();
jest.useRealTimers();
});

test.each(copy)('translates every state and accessible label in $language', async (labels) => {
const complete = jest.fn();
await renderTimer(labels.language, complete);
expect(screen.getByRole('heading', { name: labels.heading })).toBeTruthy();
expect(screen.getByText(labels.description)).toBeTruthy();
expect(screen.getByText(labels.idle)).toBeTruthy();
expect(screen.getByRole('timer').getAttribute('aria-label')).toBe(labels.remainingMany);
expect(screen.getByRole('progressbar', { name: labels.progress })).toBeTruthy();
expect(screen.getByRole('button', { name: labels.reset }).hasAttribute('disabled')).toBe(true);

fireEvent.click(screen.getByRole('button', { name: labels.start }));
expect(screen.getByText(labels.running)).toBeTruthy();
act(() => jest.advanceTimersByTime(2_000));
expect(screen.getByRole('timer').getAttribute('aria-label')).toBe(labels.remainingOne);
fireEvent.click(screen.getByRole('button', { name: labels.pause }));
expect(screen.getByText(labels.paused)).toBeTruthy();
act(() => jest.advanceTimersByTime(2_000));
expect(screen.getByRole('timer').textContent).toBe('00:01');
fireEvent.click(screen.getByRole('button', { name: labels.resume }));
act(() => jest.advanceTimersByTime(1_000));
expect(screen.getByText(labels.completed)).toBeTruthy();
expect(screen.getByText(labels.recordedOne)).toBeTruthy();
expect(screen.getByText(labels.totalOne)).toBeTruthy();
expect(complete).toHaveBeenCalledTimes(1);

fireEvent.click(screen.getByRole('button', { name: labels.restart }));
act(() => jest.advanceTimersByTime(3_000));
expect(screen.getByText(labels.recordedMany)).toBeTruthy();
expect(screen.getByText(labels.totalMany)).toBeTruthy();
expect(complete).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole('button', { name: labels.reset }));
expect(screen.getByText(labels.idle)).toBeTruthy();
expect(screen.getByText(labels.recordedMany)).toBeTruthy();
expect(screen.getByRole('timer').textContent).toBe('00:03');
expect(complete).toHaveBeenCalledTimes(2);
});

test('changes language live without restarting a running or paused session', async () => {
const [en, es, fr] = copy;
const complete = jest.fn();
const i18n = await renderTimer('en', complete);
fireEvent.click(screen.getByRole('button', { name: en.start }));
act(() => jest.advanceTimersByTime(2_000));
await act(async () => { await i18n.changeLanguage('es'); });
expect(screen.getByRole('timer').textContent).toBe('00:01');
expect(screen.getByRole('timer').getAttribute('aria-label')).toBe(es.remainingOne);
expect(screen.getByText(es.running)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: es.pause }));
await act(async () => { await i18n.changeLanguage('fr'); });
act(() => jest.advanceTimersByTime(5_000));
expect(screen.getByText(fr.paused)).toBeTruthy();
expect(screen.getByRole('timer').textContent).toBe('00:01');
expect(complete).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: fr.resume }));
act(() => jest.advanceTimersByTime(1_000));
expect(screen.getByText(fr.recordedOne)).toBeTruthy();
await act(async () => { await i18n.changeLanguage('en'); });
expect(screen.getByText(en.completed)).toBeTruthy();
expect(screen.getByText(en.recordedOne)).toBeTruthy();
expect(screen.getByText(en.totalOne)).toBeTruthy();
expect(complete).toHaveBeenCalledTimes(1);
});
});
19 changes: 19 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -534,5 +534,24 @@
"thresholdNotMet": "Threshold not met",
"simSuccess": "Simulation succeeded",
"simError": "Simulation failed"
},
"sessionTimer": {
"heading": "Audit focus timer",
"description": "One task. One focused session.",
"start": "Start audit",
"resume": "Resume audit",
"pause": "Pause audit",
"restart": "Start another audit",
"reset": "Reset audit timer",
"idle": "Ready for a focused audit",
"running": "Focus session in progress",
"paused": "Session paused",
"completed": "Session complete — time recorded",
"progress": "Audit session progress",
"remainingSeconds_one": "{{count}} second remaining",
"remainingSeconds_other": "{{count}} seconds remaining",
"sessionsRecorded_one": "{{count}} session recorded",
"sessionsRecorded_other": "{{count}} sessions recorded",
"totalAudited": "Total audit time: {{duration}}"
}
}
19 changes: 19 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -534,5 +534,24 @@
"thresholdNotMet": "Umbral no alcanzado",
"simSuccess": "Simulacion exitosa",
"simError": "Simulacion fallida"
},
"sessionTimer": {
"heading": "Temporizador de concentración para auditorías",
"description": "Una tarea. Una sesión de concentración.",
"start": "Iniciar auditoría",
"resume": "Reanudar auditoría",
"pause": "Pausar auditoría",
"restart": "Iniciar otra auditoría",
"reset": "Reiniciar temporizador de auditoría",
"idle": "Listo para una auditoría concentrada",
"running": "Sesión de concentración en curso",
"paused": "Sesión en pausa",
"completed": "Sesión completada: tiempo registrado",
"progress": "Progreso de la sesión de auditoría",
"remainingSeconds_one": "Queda {{count}} segundo",
"remainingSeconds_other": "Quedan {{count}} segundos",
"sessionsRecorded_one": "{{count}} sesión registrada",
"sessionsRecorded_other": "{{count}} sesiones registradas",
"totalAudited": "Tiempo total de auditoría: {{duration}}"
}
}
19 changes: 19 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -534,5 +534,24 @@
"thresholdNotMet": "Seuil non atteint",
"simSuccess": "Simulation reussie",
"simError": "Echec de simulation"
},
"sessionTimer": {
"heading": "Minuteur de concentration pour les audits",
"description": "Une tâche. Une session de concentration.",
"start": "Démarrer l’audit",
"resume": "Reprendre l’audit",
"pause": "Mettre l’audit en pause",
"restart": "Démarrer un autre audit",
"reset": "Réinitialiser le minuteur d’audit",
"idle": "Prêt pour un audit concentré",
"running": "Session de concentration en cours",
"paused": "Session en pause",
"completed": "Session terminée : temps enregistré",
"progress": "Progression de la session d’audit",
"remainingSeconds_one": "{{count}} seconde restante",
"remainingSeconds_other": "{{count}} secondes restantes",
"sessionsRecorded_one": "{{count}} session enregistrée",
"sessionsRecorded_other": "{{count}} sessions enregistrées",
"totalAudited": "Temps d’audit total : {{duration}}"
}
}