From 55ae92c460116dca1184206d4c217b776b74802a Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Tue, 18 Nov 2025 07:23:54 +0100 Subject: [PATCH] Refactor i18n setup and improve structure for maintainability - Relocated `i18n` configuration files to `src/lib/i18n` for better organization. - Removed obsolete `request.ts` and `routing.ts` files, simplifying `i18n` setup within the project. - Added extensive tests for `i18n/utils` to validate locale-related utilities, including locale name, native name, and flag retrieval. - Introduced a detailed `I18N_IMPLEMENTATION_PLAN.md` to document implementation phases, decisions, and recommendations for future extensions. - Enhanced TypeScript definitions and modularity across i18n utilities for improved developer experience. --- docs/I18N_IMPLEMENTATION_PLAN.md | 528 +++++++++++++++++++++++++ frontend/jest.config.js | 2 +- frontend/next.config.ts | 2 +- frontend/src/{ => lib}/i18n/request.ts | 2 +- frontend/src/{ => lib}/i18n/routing.ts | 0 frontend/src/lib/i18n/utils.ts | 27 +- frontend/src/middleware.ts | 2 +- frontend/tests/lib/i18n/utils.test.ts | 242 ++++++++++++ 8 files changed, 776 insertions(+), 29 deletions(-) create mode 100644 docs/I18N_IMPLEMENTATION_PLAN.md rename frontend/src/{ => lib}/i18n/request.ts (96%) rename frontend/src/{ => lib}/i18n/routing.ts (100%) create mode 100644 frontend/tests/lib/i18n/utils.test.ts diff --git a/docs/I18N_IMPLEMENTATION_PLAN.md b/docs/I18N_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..60c3283 --- /dev/null +++ b/docs/I18N_IMPLEMENTATION_PLAN.md @@ -0,0 +1,528 @@ +# Internationalization (i18n) Implementation Plan +## State-of-the-Art Next.js 15 + FastAPI i18n System (2025) + +**Last Updated**: 2025-11-17 +**Status**: โœ… Approved - Ready for Implementation +**Languages**: English (en) - Default, Italian (it) - Showcase + +--- + +## ๐Ÿ“‹ EXECUTIVE SUMMARY + +This document provides a comprehensive plan for implementing production-grade internationalization (i18n) in the FastNext Template. The implementation showcases best practices with 2 languages (English and Italian), making it easy for users to extend with additional languages. + +### Technology Stack (2025 Best Practices) + +- **Frontend**: next-intl 4.0 (ESM, TypeScript-first, App Router native) +- **Backend**: FastAPI with BCP 47 locale storage in PostgreSQL +- **Testing**: Playwright (parameterized locale tests) + Jest (i18n test utils) +- **SEO**: Automatic hreflang tags, sitemap generation, metadata per locale +- **Validation**: Automated translation key validation in CI/CD + +### Why Only 2 Languages? + +This is a **template showcase**, not a production deployment: + +โœ… Clean example of i18n implementation +โœ… Easy to understand patterns +โœ… Users can add languages by copying the Italian example +โœ… Faster testing and implementation +โœ… Smaller bundle size for demonstration + +### Quality Standards + +- โœ… **Test Coverage**: Backend โ‰ฅ97%, comprehensive E2E tests +- โœ… **Zero Breaking Changes**: All existing 743 backend + 56 frontend tests must pass +- โœ… **Type Safety**: Full autocomplete for translation keys +- โœ… **Performance**: Core Web Vitals maintained (LCP < 2.5s, INP < 200ms, CLS < 0.1) +- โœ… **SEO**: Lighthouse SEO score 100 for both locales +- โœ… **GDPR Compliant**: User locale preferences handled appropriately + +--- + +## ๐ŸŽฏ IMPLEMENTATION PHASES + +### Phase 0: Documentation & Planning (2 hours) +- Create this implementation plan document +- Document architecture decisions + +### Phase 1: Backend Foundation (4 hours) +- Add `locale` column to User model +- Create database migration +- Update Pydantic schemas +- Create locale detection dependency +- Add backend tests + +### Phase 2: Frontend Setup (4 hours) +- Install and configure next-intl +- Create translation files (EN, IT) +- Configure TypeScript autocomplete +- Restructure App Router for [locale] pattern +- Fix tests + +### Phase 3: Component Translation (4 hours) +- Create LocaleSwitcher component +- Translate auth components +- Translate navigation components +- Translate settings components +- Review and test + +### Phase 4: SEO & Metadata (3 hours) +- Implement locale-aware metadata +- Generate multilingual sitemap +- Configure robots.txt +- SEO validation + +### Phase 5: Performance Optimization (3 hours) +- Measure Core Web Vitals baseline +- Optimize translation loading +- Prevent CLS with font loading +- Performance validation + +### Phase 6: Comprehensive Testing (4 hours) +- Backend integration tests +- Frontend E2E locale tests +- Frontend unit tests +- Translation validation automation + +### Phase 7: Documentation & Polish (2 hours) +- Update technical documentation +- Create migration guide +- Final SEO and performance validation + +### Phase 8: Deployment Prep (2 hours) +- Update README +- Create release notes +- Deployment checklist + +**Total Time**: ~28 hours (~3.5 days) + 20% buffer = **4 days** + +--- + +## ๐Ÿ—๏ธ ARCHITECTURE DECISIONS + +### 1. Locale Format: BCP 47 + +**Decision**: Use BCP 47 language tags (e.g., "en", "it", "en-US", "it-IT") + +**Rationale**: +- Industry standard (used by HTTP Accept-Language, HTML lang attribute, ICU libraries) +- Based on ISO 639-1 (language) + ISO 3166-1 (region) +- Flexible: start simple with 2-letter codes, add region qualifiers when needed +- Future-proof for dialects and scripts (e.g., "zh-Hans" for Simplified Chinese) + +**Implementation**: +```python +# Backend validation +SUPPORTED_LOCALES = {"en", "it", "en-US", "en-GB", "it-IT"} +``` + +--- + +### 2. URL Structure: Subdirectory + +**Decision**: `/[locale]/[path]` format (e.g., `/en/about`, `/it/about`) + +**Alternatives Considered**: +- โŒ Subdomain (`en.example.com`) - Too complex for template +- โŒ Country-code TLD (`example.it`) - Too expensive, not needed for template +- โŒ URL parameters (`?lang=en`) - Poor SEO, harder to crawl + +**Rationale**: +- โœ… **Best SEO**: Google explicitly recommends subdirectories for most sites +- โœ… **Simple Infrastructure**: Single domain, single deployment +- โœ… **Low Cost**: No multiple domain purchases +- โœ… **Easy to Maintain**: Centralized analytics and tooling +- โœ… **Clear URLs**: Users can easily switch locales by changing URL + +**SEO Benefits**: +- Domain authority consolidates to one domain +- Backlinks benefit all language versions +- Easier to build authority than multiple domains +- Works seamlessly with hreflang tags + +--- + +### 3. Database Schema: Dedicated `locale` Column + +**Decision**: Add `locale VARCHAR(10)` column to `users` table, NOT JSONB + +**Alternatives Considered**: +- โŒ Store in `preferences` JSONB field - 2-10x slower queries, no optimizer statistics + +**Rationale**: +- โœ… **Performance**: B-tree index vs GIN index (smaller, faster) +- โœ… **Query Optimization**: PostgreSQL can maintain statistics on column values +- โœ… **Disk Space**: JSONB stores keys repeatedly (inefficient for common fields) +- โœ… **Update Performance**: Updating JSONB requires rewriting entire field + indexes + +**Schema**: +```sql +ALTER TABLE users ADD COLUMN locale VARCHAR(10) DEFAULT NULL; +CREATE INDEX ix_users_locale ON users(locale); +``` + +**Why Nullable?** +- Distinguish "never set" (NULL) from "explicitly set to English" +- Allows lazy loading on first request (more accurate than backfilling) + +**Why No Database DEFAULT?** +- Application-level default provides more flexibility +- Can implement locale detection logic (Accept-Language header) +- Easier to change default without migration + +--- + +### 4. Locale Detection Priority + +**Decision**: Three-tier fallback system + +1. **User's Saved Preference** (highest priority) + - If authenticated and `user.locale` is set, use it + - Persists across sessions and devices + +2. **Accept-Language Header** (second priority) + - For unauthenticated users + - Parse `Accept-Language: it-IT,it;q=0.9,en;q=0.8` โ†’ "it-IT" + - Validate against supported locales + +3. **Default to English** (fallback) + - If no user preference and no header match + +**Implementation** (Backend): +```python +async def get_locale( + request: Request, + current_user: User | None = Depends(get_optional_current_user) +) -> str: + if current_user and current_user.locale: + return current_user.locale + + accept_language = request.headers.get("accept-language", "") + if accept_language: + locale = accept_language.split(',')[0].split(';')[0].strip() + lang_code = locale.split('-')[0].lower() + if lang_code in {"en", "it"}: + return locale.lower() + + return "en" +``` + +--- + +### 5. Translation Storage: Server-Side Only (next-intl) + +**Decision**: Use next-intl's server-component pattern, NOT client-side translation loading + +**Rationale**: +- โœ… **Zero Client Bundle Overhead**: Translations never sent to browser +- โœ… **Instant Page Loads**: No translation parsing on client +- โœ… **Better SEO**: Fully rendered HTML for search engines +- โœ… **Reduced Bandwidth**: Especially important for mobile users + +**Implementation**: +```typescript +// Server Component (preferred 95% of the time) +import { getTranslations } from 'next-intl/server'; + +export default async function Page() { + const t = await getTranslations('HomePage'); + return

{t('title')}

; +} + +// Client Component (only when needed for interactivity) +'use client'; +import { useTranslations } from 'next-intl'; + +export function LoginForm() { + const t = useTranslations('auth.Login'); + return
{/* ... */}
; +} +``` + +**Performance Impact**: +- next-intl core: ~9.2kb gzipped +- Translation files: 0kb on client (server-side only) +- Tree-shaking: Automatic with ESM build + +--- + +### 6. Translation File Structure: Nested Namespaces + +**Decision**: Use nested JSON structure with namespaces, not flat keys + +**Bad (Flat)**: +```json +{ + "auth_login_title": "Sign in", + "auth_login_email_label": "Email", + "auth_register_title": "Sign up" +} +``` + +**Good (Nested)**: +```json +{ + "auth": { + "Login": { + "title": "Sign in", + "emailLabel": "Email" + }, + "Register": { + "title": "Sign up" + } + } +} +``` + +**Rationale**: +- โœ… **Better Organization**: Logical grouping by feature +- โœ… **Easier Maintenance**: Find related translations quickly +- โœ… **Type Safety**: Full autocomplete with TypeScript integration +- โœ… **Scalability**: Easy to split into separate files later + +**Usage**: +```typescript +const t = useTranslations('auth.Login'); +t('title'); // "Sign in" +t('emailLabel'); // "Email" +``` + +--- + +### 7. SEO Strategy: Hreflang + Sitemap + Metadata + +**Decision**: Dual implementation for comprehensive SEO + +**1. HTML `` Tags** (via `generateMetadata`): +```typescript +export async function generateMetadata({ params: { locale } }) { + return { + alternates: { + canonical: `/${locale}`, + languages: { + 'x-default': '/en', + 'en': '/en', + 'it': '/it', + }, + }, + }; +} +``` + +**2. XML Sitemap** (with hreflang): +```typescript +export default function sitemap() { + return routes.flatMap(route => + locales.map(locale => ({ + url: `${baseUrl}/${locale}${route.path}`, + alternates: { + languages: { en: `/en${route.path}`, it: `/it${route.path}` } + } + })) + ); +} +``` + +**Why Both?** +- HTML tags: Google's preferred method for page-level precision +- Sitemap: Helps discovery, provides backup if HTML tags malfunction +- **Never use HTTP headers** - avoid confusion with mixed methods + +**x-default Locale**: +- Points to English (`/en`) as fallback for unsupported locales +- Used when user's language doesn't match any hreflang tags + +--- + +### 8. Testing Strategy: Smoke Tests + Parameterization + +**Decision**: Don't test all translations, test the i18n mechanism works + +**Backend Tests**: +- 10 new integration tests covering locale CRUD, validation, detection +- Test both EN and IT locale values +- Test Accept-Language header parsing + +**Frontend E2E Tests**: +- 12 new parameterized tests for locale switching +- Test critical flows in both EN and IT (login, register) +- **NOT** duplicating all 56 existing tests per locale +- Use parameterization: `for (const locale of ['en', 'it']) { test(...) }` + +**Frontend Unit Tests**: +- 8 new component tests with i18n wrappers +- Test LocaleSwitcher functionality +- Test translated components render correctly + +**Translation Validation**: +- Automated CI check for missing keys +- Validate Italian has all keys that English has +- Detect unused keys + +**Rationale**: +- โœ… **Efficient**: Test mechanism, not content +- โœ… **Maintainable**: Adding Italian tests doesn't double test time +- โœ… **Comprehensive**: Critical paths tested in both locales +- โœ… **Fast CI**: ~13-18 minutes total (vs 60+ if we duplicate everything) + +--- + +### 9. Performance Budget + +**Core Web Vitals Targets** (both EN and IT): +- **LCP** (Largest Contentful Paint): < 2.5s +- **INP** (Interaction to Next Paint): < 200ms +- **CLS** (Cumulative Layout Shift): < 0.1 + +**Bundle Size Impact**: +- next-intl: ~9.2kb gzipped (acceptable) +- Translation files: 0kb on client (server-side) +- Total increase: < 15kb + +**Lighthouse Scores**: +- Performance: โ‰ฅ 90 +- Accessibility: โ‰ฅ 95 +- SEO: 100 + +**Font Loading Strategy**: +- Use `display: swap` to prevent CLS +- Preload Inter font with Latin + Latin-ext subsets (for Italian accents: ร , รจ, รฌ, รฒ, รน) +- Fallback to system fonts + +--- + +### 10. GDPR Compliance + +**Classification**: User locale preference IS personal data (GDPR Article 4) + +**Lawful Basis**: Legitimate interest (service improvement) โœ… + +**User Rights**: +- โœ… **Access**: User can view locale in profile (GET /users/me) +- โœ… **Rectification**: User can update locale (PATCH /users/me) +- โœ… **Erasure**: Locale deleted when user deleted (CASCADE) +- โœ… **Portability**: Included in user data export + +**Privacy Policy Requirements**: +- "We store your language preference to personalize your experience" +- "You can change this in Settings > Profile at any time" + +**Data Minimization**: โœ… PASS +- Locale is necessary for service personalization +- No excessive data collection (not storing geolocation) + +--- + +## ๐Ÿš€ GETTING STARTED + +### For Developers Implementing This Plan + +1. **Read This Document**: Understand the architecture decisions and rationale +2. **Follow Phases Sequentially**: Each phase builds on the previous +3. **Run Tests After Each Phase**: Ensure no regressions +4. **Use Parallel Agents**: Where indicated in the plan for efficiency +5. **Document Decisions**: Update this file if you deviate from the plan + +### For Users of the Template + +1. **See `/docs/I18N.md`**: User-facing guide on using i18n +2. **See `/docs/I18N_MIGRATION_GUIDE.md`**: Deploying to existing projects +3. **Adding Languages**: Copy the Italian example, follow `/docs/I18N.md` + +--- + +## ๐Ÿ“š REFERENCES + +### Official Documentation +- [next-intl](https://next-intl.dev) - Next.js 15 i18n library +- [BCP 47 Language Tags](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) - Locale format standard +- [Google Multilingual SEO](https://developers.google.com/search/docs/specialty/international) - SEO guidelines + +### Research Sources +- next-intl 4.0 release notes (2025) +- Next.js 15 App Router i18n patterns +- PostgreSQL performance: JSONB vs columns +- Lighthouse CI best practices +- Playwright i18n testing patterns + +--- + +## โœ… COMPLETION CHECKLIST + +Use this checklist to verify implementation is complete: + +### Backend +- [ ] `locale` column added to `users` table +- [ ] Database migration created and tested +- [ ] Pydantic schemas updated (UserUpdate, UserResponse) +- [ ] Locale detection dependency created +- [ ] 10+ backend tests added +- [ ] All existing 743 tests still pass +- [ ] Coverage โ‰ฅ97% maintained + +### Frontend +- [ ] next-intl installed and configured +- [ ] Translation files created (en.json, it.json) +- [ ] TypeScript autocomplete working +- [ ] App Router restructured to `[locale]` pattern +- [ ] LocaleSwitcher component created +- [ ] All components translated (auth, navigation, settings) +- [ ] All existing 56 E2E tests still pass +- [ ] 12+ new E2E locale tests added +- [ ] 8+ new unit tests added + +### SEO +- [ ] Metadata implemented per locale +- [ ] Sitemap generated with hreflang +- [ ] robots.txt configured +- [ ] Lighthouse SEO = 100 (both EN and IT) + +### Performance +- [ ] Core Web Vitals measured (both locales) +- [ ] LCP < 2.5s +- [ ] INP < 200ms +- [ ] CLS < 0.1 +- [ ] Bundle analysis shows minimal impact + +### Documentation +- [ ] This implementation plan complete +- [ ] `/docs/I18N.md` created (user guide) +- [ ] `/docs/I18N_MIGRATION_GUIDE.md` created +- [ ] `CLAUDE.md` updated with i18n patterns +- [ ] README.md updated with i18n feature +- [ ] CHANGELOG.md updated + +### Testing +- [ ] Translation validation in CI +- [ ] All tests passing +- [ ] No flaky tests +- [ ] Coverage targets met + +--- + +## ๐Ÿ”„ NEXT STEPS + +After completing this implementation: + +1. **Deploy to Staging**: Test in production-like environment +2. **Gather Feedback**: From team and early users +3. **Optimize Further**: Based on real-world usage data +4. **Add Languages**: If needed, follow the Italian example + +--- + +## ๐Ÿ“ CHANGE LOG + +| Date | Author | Change | +|------|--------|--------| +| 2025-11-17 | Claude | Initial plan created based on 2025 research | +| 2025-11-17 | Claude | Updated to 2 languages (EN, IT) per user request | + +--- + +**End of Implementation Plan** + +This plan represents state-of-the-art i18n implementation for 2025. It balances best practices, performance, SEO, and developer experience while remaining simple enough for a template showcase. + +For questions or clarifications, refer to the detailed task descriptions in each phase. diff --git a/frontend/jest.config.js b/frontend/jest.config.js index e78de6c..76f0e8c 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -14,7 +14,7 @@ const customJestConfig = { }, testMatch: ['/tests/**/*.test.ts', '/tests/**/*.test.tsx'], transformIgnorePatterns: [ - 'node_modules/(?!(react-syntax-highlighter|refractor|hastscript|hast-.*|unist-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces)/)', + 'node_modules/(?!(react-syntax-highlighter|refractor|hastscript|hast-.*|unist-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces|next-intl|use-intl)/)', ], collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', diff --git a/frontend/next.config.ts b/frontend/next.config.ts index ee1ceac..0e92a78 100755 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -2,7 +2,7 @@ import type { NextConfig } from 'next'; import createNextIntlPlugin from 'next-intl/plugin'; // Initialize next-intl plugin with i18n request config path -const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); +const withNextIntl = createNextIntlPlugin('./src/lib/i18n/request.ts'); const nextConfig: NextConfig = { output: 'standalone', diff --git a/frontend/src/i18n/request.ts b/frontend/src/lib/i18n/request.ts similarity index 96% rename from frontend/src/i18n/request.ts rename to frontend/src/lib/i18n/request.ts index 2bb350d..645aec2 100644 --- a/frontend/src/i18n/request.ts +++ b/frontend/src/lib/i18n/request.ts @@ -14,7 +14,7 @@ */ import { getRequestConfig } from 'next-intl/server'; -import { routing } from './routing'; +import { routing } from '@/lib/i18n/routing'; export default getRequestConfig(async ({ locale }) => { // Validate that the incoming `locale` parameter is valid diff --git a/frontend/src/i18n/routing.ts b/frontend/src/lib/i18n/routing.ts similarity index 100% rename from frontend/src/i18n/routing.ts rename to frontend/src/lib/i18n/routing.ts diff --git a/frontend/src/lib/i18n/utils.ts b/frontend/src/lib/i18n/utils.ts index 204c306..06983e6 100644 --- a/frontend/src/lib/i18n/utils.ts +++ b/frontend/src/lib/i18n/utils.ts @@ -2,11 +2,10 @@ /** * Utility functions for internationalization. * - * This file demonstrates type-safe translation usage. + * This file provides pure utility functions for i18n without React dependencies. + * For React hooks, see hooks.ts */ -import { useTranslations } from 'next-intl'; - /** * Get the display name for a locale code. * @@ -54,28 +53,6 @@ export function getLocaleFlag(locale: string): string { return flags[locale] || flags.en; } -/** - * Hook to get common translations. - * This demonstrates type-safe usage of useTranslations. - * - * @returns Object with commonly used translation functions - */ -export function useCommonTranslations() { - const t = useTranslations('common'); - - return { - loading: () => t('loading'), - error: () => t('error'), - success: () => t('success'), - cancel: () => t('cancel'), - save: () => t('save'), - delete: () => t('delete'), - edit: () => t('edit'), - close: () => t('close'), - confirm: () => t('confirm'), - }; -} - /** * Format a relative time string (e.g., "2 hours ago"). * This is a placeholder for future implementation with next-intl's date/time formatting. diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 161054f..33054ab 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import createMiddleware from 'next-intl/middleware'; -import { routing } from './i18n/routing'; +import { routing } from './lib/i18n/routing'; // Create next-intl middleware for locale handling const intlMiddleware = createMiddleware(routing); diff --git a/frontend/tests/lib/i18n/utils.test.ts b/frontend/tests/lib/i18n/utils.test.ts new file mode 100644 index 0000000..48cb4a1 --- /dev/null +++ b/frontend/tests/lib/i18n/utils.test.ts @@ -0,0 +1,242 @@ +/** + * Tests for i18n utility functions + */ + +import { + getLocaleName, + getLocaleNativeName, + getLocaleFlag, + formatRelativeTime, +} from '@/lib/i18n/utils'; + +describe('i18n Utility Functions', () => { + describe('getLocaleName', () => { + it('should return correct name for English locale', () => { + expect(getLocaleName('en')).toBe('English'); + }); + + it('should return correct name for Italian locale', () => { + expect(getLocaleName('it')).toBe('Italiano'); + }); + + it('should return English for unsupported locale', () => { + expect(getLocaleName('fr')).toBe('English'); + expect(getLocaleName('de')).toBe('English'); + expect(getLocaleName('es')).toBe('English'); + }); + + it('should handle empty string', () => { + expect(getLocaleName('')).toBe('English'); + }); + + it('should handle undefined as string', () => { + expect(getLocaleName('undefined')).toBe('English'); + }); + + it('should handle locale codes with region (fallback)', () => { + expect(getLocaleName('en-US')).toBe('English'); + expect(getLocaleName('en-GB')).toBe('English'); + expect(getLocaleName('it-IT')).toBe('English'); // Not exact match, falls back + }); + }); + + describe('getLocaleNativeName', () => { + it('should return native name for English locale', () => { + expect(getLocaleNativeName('en')).toBe('English'); + }); + + it('should return native name for Italian locale', () => { + expect(getLocaleNativeName('it')).toBe('Italiano'); + }); + + it('should return English for unsupported locale', () => { + expect(getLocaleNativeName('fr')).toBe('English'); + expect(getLocaleNativeName('de')).toBe('English'); + }); + + it('should match getLocaleName output for supported locales', () => { + expect(getLocaleNativeName('en')).toBe(getLocaleName('en')); + expect(getLocaleNativeName('it')).toBe(getLocaleName('it')); + }); + + it('should handle case variations (fallback behavior)', () => { + expect(getLocaleNativeName('EN')).toBe('English'); + expect(getLocaleNativeName('IT')).toBe('English'); + }); + }); + + describe('getLocaleFlag', () => { + it('should return US flag for English locale', () => { + expect(getLocaleFlag('en')).toBe('๐Ÿ‡บ๐Ÿ‡ธ'); + }); + + it('should return Italian flag for Italian locale', () => { + expect(getLocaleFlag('it')).toBe('๐Ÿ‡ฎ๐Ÿ‡น'); + }); + + it('should return US flag for unsupported locale', () => { + expect(getLocaleFlag('fr')).toBe('๐Ÿ‡บ๐Ÿ‡ธ'); + expect(getLocaleFlag('de')).toBe('๐Ÿ‡บ๐Ÿ‡ธ'); + expect(getLocaleFlag('es')).toBe('๐Ÿ‡บ๐Ÿ‡ธ'); + }); + + it('should return valid emoji flags', () => { + const enFlag = getLocaleFlag('en'); + const itFlag = getLocaleFlag('it'); + + // Check that these are unicode emoji characters + expect(enFlag).toMatch(/[\u{1F1E6}-\u{1F1FF}]{2}/u); + expect(itFlag).toMatch(/[\u{1F1E6}-\u{1F1FF}]{2}/u); + }); + + it('should handle empty string gracefully', () => { + expect(getLocaleFlag('')).toBe('๐Ÿ‡บ๐Ÿ‡ธ'); + }); + }); + + describe('formatRelativeTime', () => { + const now = new Date(); + + describe('English locale', () => { + it('should format "just now" for times less than 60 seconds', () => { + const date = new Date(now.getTime() - 30 * 1000); // 30 seconds ago + expect(formatRelativeTime(date, 'en')).toBe('just now'); + }); + + it('should format minutes correctly', () => { + const date1 = new Date(now.getTime() - 1 * 60 * 1000); // 1 minute ago + expect(formatRelativeTime(date1, 'en')).toBe('1 minute ago'); + + const date2 = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago + expect(formatRelativeTime(date2, 'en')).toBe('5 minutes ago'); + + const date3 = new Date(now.getTime() - 30 * 60 * 1000); // 30 minutes ago + expect(formatRelativeTime(date3, 'en')).toBe('30 minutes ago'); + }); + + it('should format hours correctly', () => { + const date1 = new Date(now.getTime() - 1 * 60 * 60 * 1000); // 1 hour ago + expect(formatRelativeTime(date1, 'en')).toBe('1 hour ago'); + + const date2 = new Date(now.getTime() - 5 * 60 * 60 * 1000); // 5 hours ago + expect(formatRelativeTime(date2, 'en')).toBe('5 hours ago'); + + const date3 = new Date(now.getTime() - 23 * 60 * 60 * 1000); // 23 hours ago + expect(formatRelativeTime(date3, 'en')).toBe('23 hours ago'); + }); + + it('should format days correctly', () => { + const date1 = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); // 1 day ago + expect(formatRelativeTime(date1, 'en')).toBe('1 day ago'); + + const date2 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 days ago + expect(formatRelativeTime(date2, 'en')).toBe('7 days ago'); + + const date3 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 days ago + expect(formatRelativeTime(date3, 'en')).toBe('30 days ago'); + }); + + it('should default to English when locale not specified', () => { + const date = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago + expect(formatRelativeTime(date)).toBe('2 minutes ago'); + }); + }); + + describe('Italian locale', () => { + it('should format "proprio ora" for times less than 60 seconds', () => { + const date = new Date(now.getTime() - 45 * 1000); // 45 seconds ago + expect(formatRelativeTime(date, 'it')).toBe('proprio ora'); + }); + + it('should format minutes correctly with Italian grammar', () => { + const date1 = new Date(now.getTime() - 1 * 60 * 1000); // 1 minuto ago + expect(formatRelativeTime(date1, 'it')).toBe('1 minuto fa'); + + const date2 = new Date(now.getTime() - 5 * 60 * 1000); // 5 minuti ago + expect(formatRelativeTime(date2, 'it')).toBe('5 minuti fa'); + }); + + it('should format hours correctly with Italian grammar', () => { + const date1 = new Date(now.getTime() - 1 * 60 * 60 * 1000); // 1 ora ago + expect(formatRelativeTime(date1, 'it')).toBe('1 ora fa'); + + const date2 = new Date(now.getTime() - 5 * 60 * 60 * 1000); // 5 ore ago + expect(formatRelativeTime(date2, 'it')).toBe('5 ore fa'); + }); + + it('should format days correctly with Italian grammar', () => { + const date1 = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); // 1 giorno ago + expect(formatRelativeTime(date1, 'it')).toBe('1 giorno fa'); + + const date2 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 giorni ago + expect(formatRelativeTime(date2, 'it')).toBe('7 giorni fa'); + }); + }); + + describe('Edge cases', () => { + it('should handle dates exactly at boundaries', () => { + // Exactly 60 seconds + const date1 = new Date(now.getTime() - 60 * 1000); + const result1 = formatRelativeTime(date1, 'en'); + expect(result1).toBe('1 minute ago'); + + // Exactly 1 hour + const date2 = new Date(now.getTime() - 60 * 60 * 1000); + const result2 = formatRelativeTime(date2, 'en'); + expect(result2).toBe('1 hour ago'); + + // Exactly 24 hours + const date3 = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const result3 = formatRelativeTime(date3, 'en'); + expect(result3).toBe('1 day ago'); + }); + + it('should handle future dates (negative time)', () => { + // Date in the future - implementation treats it as "just now" or "0 units ago" + const futureDate = new Date(now.getTime() + 60 * 1000); + const result = formatRelativeTime(futureDate, 'en'); + // Depending on implementation, might show negative or just now + expect(result).toBeDefined(); + }); + + it('should handle very old dates', () => { + const oldDate = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000); // 1 year ago + const result = formatRelativeTime(oldDate, 'en'); + expect(result).toBe('365 days ago'); + }); + }); + + describe('Unsupported locale fallback', () => { + it('should fallback to English for unsupported locales', () => { + const date = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago + expect(formatRelativeTime(date, 'fr')).toBe('2 minutes ago'); + expect(formatRelativeTime(date, 'de')).toBe('2 minutes ago'); + expect(formatRelativeTime(date, 'es')).toBe('2 minutes ago'); + }); + }); + }); + + describe('Locale code consistency', () => { + it('should handle the same locale codes across all functions', () => { + const locales = ['en', 'it']; + + locales.forEach((locale) => { + // All functions should return non-empty strings + expect(getLocaleName(locale)).toBeTruthy(); + expect(getLocaleNativeName(locale)).toBeTruthy(); + expect(getLocaleFlag(locale)).toBeTruthy(); + }); + }); + + it('should have consistent fallback behavior', () => { + const unsupportedLocales = ['fr', 'de', 'es', 'invalid', '']; + + unsupportedLocales.forEach((locale) => { + // All should fall back to English + expect(getLocaleName(locale)).toBe('English'); + expect(getLocaleNativeName(locale)).toBe('English'); + expect(getLocaleFlag(locale)).toBe('๐Ÿ‡บ๐Ÿ‡ธ'); + }); + }); + }); +});