diff --git a/src/components/ui/__tests__/Toast.test.tsx b/src/components/ui/__tests__/Toast.test.tsx new file mode 100644 index 00000000..e860c276 --- /dev/null +++ b/src/components/ui/__tests__/Toast.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Toast } from '../Toast'; + +describe('Toast', () => { + it('renders the message', () => { + render( {}} />); + expect(screen.getByText('Hello world')).toBeInTheDocument(); + }); + + it('renders with info type by default', () => { + render( {}} />); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('renders with error type', () => { + render( {}} />); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('renders with success type', () => { + render( {}} />); + expect(screen.getByText('Saved!')).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + const onClose = jest.fn(); + render(); + fireEvent.click(screen.getByLabelText('Close notification')); + expect(onClose).toHaveBeenCalledTimes(0); // called after animation delay + }); +}); diff --git a/src/hooks/useAbortController.ts b/src/hooks/useAbortController.ts new file mode 100644 index 00000000..0b5e1415 --- /dev/null +++ b/src/hooks/useAbortController.ts @@ -0,0 +1,26 @@ +'use client'; + +import { useEffect, useRef, useCallback } from 'react'; + +/** + * Returns a stable getSignal() function. Each call to getSignal() aborts the + * previous signal and returns a fresh one, so in-flight requests from the last + * render are cancelled automatically. Everything is cleaned up on unmount. + */ +export function useAbortController() { + const controllerRef = useRef(null); + + const getSignal = useCallback((): AbortSignal => { + controllerRef.current?.abort(); + controllerRef.current = new AbortController(); + return controllerRef.current.signal; + }, []); + + useEffect(() => { + return () => { + controllerRef.current?.abort(); + }; + }, []); + + return { getSignal }; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..c54ae358 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,22 @@ +// Public type exports — import from '@/types' for all shared types. +export type { + ApiResponse, + PaginatedResponse, + SuccessResponse, + User, + AuthResponse, + Course, + VideoBookmark, + VideoNote, + UserProgress, + AnalyticsEventPayload, +} from './api'; + +export type { + UserRole, + Metric, + ChartConfig, + Widget, + DashboardLayout, + ExportOptions, +} from './analytics'; diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts new file mode 100644 index 00000000..2ec3a7da --- /dev/null +++ b/src/utils/dateUtils.ts @@ -0,0 +1,32 @@ +/** + * Locale-aware date formatting utilities using the built-in Intl.DateTimeFormat API. + * Pass an explicit locale to override; omit it to use the browser/system locale. + */ + +export function formatDate( + date: Date | string | number, + locale?: string, + options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }, +): string { + return new Intl.DateTimeFormat(locale, options).format(new Date(date)); +} + +export function formatShortDate(date: Date | string | number, locale?: string): string { + return formatDate(date, locale, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +export function formatTime(date: Date | string | number, locale?: string): string { + return new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format( + new Date(date), + ); +} + +export function formatRelative(date: Date | string | number, locale?: string): string { + const diff = Math.round((new Date(date).getTime() - Date.now()) / 1000); + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + + if (Math.abs(diff) < 60) return rtf.format(diff, 'second'); + if (Math.abs(diff) < 3600) return rtf.format(Math.round(diff / 60), 'minute'); + if (Math.abs(diff) < 86400) return rtf.format(Math.round(diff / 3600), 'hour'); + return rtf.format(Math.round(diff / 86400), 'day'); +}