Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ jobs:
cd frontend
npx tsc --noEmit
# Missing translation keys and unwrapped strings are invisible to tsc, eslint and vitest,
# because t() takes a plain string. Without this step they only surface as raw key paths
# in front of a user.
- name: i18n check
run: |
cd frontend
npm run i18n:check
frontend-test:
name: Frontend Tests
runs-on: ubuntu-latest
Expand Down
24 changes: 24 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,27 @@ repos:
language: system
files: ^frontend/.*\.(ts|tsx)$
pass_filenames: false

# t() takes a plain string, so a missing translation key type-checks, lints and tests
# clean, then renders as a raw key path to the user. These three gates are the only
# thing that catches it.
- id: i18n-keys
name: i18n keys resolve
entry: bash -c 'cd frontend && node scripts/i18n-keys.mjs'
language: system
files: ^frontend/(app|components|lib|messages)/
pass_filenames: false

- id: i18n-parity
name: i18n locale parity
entry: bash -c 'cd frontend && node scripts/i18n-check.mjs'
language: system
files: ^frontend/messages/
pass_filenames: false

- id: i18n-scan
name: i18n no hardcoded strings
entry: bash -c 'cd frontend && node scripts/i18n-scan.mjs'
language: system
files: ^frontend/(app|components|lib)/.*\.tsx?$
pass_filenames: false
56 changes: 56 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,62 @@ export function ItemCard({ item, onSelect }: ItemCardProps) {
}
```

## Internationalization

Wardrowbe ships in 8 languages. **Every user-visible string must go through `next-intl`.** A PR that
hardcodes English will be blocked by CI.

Translation files live in `frontend/messages/<locale>/<namespace>.json`, split by feature area.
`en` is the source of truth; every other locale must have exactly the same key set.

```typescript
import { useTranslations } from 'next-intl';

export function ItemCard({ item }: ItemCardProps) {
const t = useTranslations('wardrobe');
const tc = useTranslations('common');

return (
<div>
<h3>{t('card.title')}</h3>
<p>{t('card.wornCount', { count: item.wear_count })}</p>
<button aria-label={tc('delete')}>{tc('delete')}</button>
</div>
);
}
```

Rules:

- Add new keys to `frontend/messages/en/<namespace>.json` only. Other locales are filled in by
translators; an untranslated key falls back to its English text rather than breaking the page.
- Reuse `common` for generic UI verbs (save, cancel, delete, loading) and `constants` for domain
vocabulary (clothing types, colors, occasions). Do not re-declare them in a feature namespace.
- Never build a sentence by concatenating fragments around a value. Use one ICU message:
`t('feelsLike', { temp })`, not `Feels {temp}`.
- Use ICU plurals for anything count-dependent:
`"itemCount": "{count, plural, one {{count} item} other {{count} items}}"`.
- `placeholder`, `title`, `aria-label`, `alt` and every `toast.*()` argument are user-visible too.

Before pushing:

```bash
cd frontend
npm run i18n:check
```

That runs three gates, none of which `tsc`, ESLint or Vitest can replace, because `t()` takes a
plain string and a missing key type-checks perfectly:

| Gate | Catches |
|------|---------|
| `i18n:keys` | `t()` calls referencing keys absent from the `en` catalog |
| `i18n:parity` | locales missing keys, or ICU placeholders dropped in translation |
| `i18n:scan` | hardcoded user-visible strings in JSX, attributes and toasts |

Adding a language: add it to `SUPPORTED_LOCALES` in `frontend/lib/i18n/locales.ts`, add the same
list to `backend/app/utils/locale.py`, and create `frontend/messages/<locale>/`.

## Project Structure

### Backend
Expand Down
12 changes: 12 additions & 0 deletions backend/app/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from app.models.user import User
from app.services.user_service import UserService
from app.utils.auth import get_current_user
from app.utils.locale import SUPPORTED_LOCALES, is_supported_locale

router = APIRouter(prefix="/users/me", tags=["Users"])

Expand All @@ -23,6 +24,7 @@ class UserProfileResponse(BaseModel):
display_name: str
avatar_url: str | None = None
timezone: str
locale: str
location_lat: float | None = None
location_lon: float | None = None
location_name: str | None = None
Expand All @@ -35,6 +37,7 @@ class UserProfileResponse(BaseModel):
class UserProfileUpdate(BaseModel):
display_name: str | None = None
timezone: str | None = None
locale: str | None = None
location_lat: Decimal | None = None
location_lon: Decimal | None = None
location_name: str | None = None
Expand All @@ -56,6 +59,14 @@ async def update_profile(
) -> UserProfileResponse:
update_data = data.model_dump(exclude_unset=True)

# update_data is applied with a blanket setattr below, so an unsupported locale
# must be rejected here to prevent it reaching the column.
if "locale" in update_data and not is_supported_locale(update_data["locale"]):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"locale must be one of: {', '.join(SUPPORTED_LOCALES)}",
)

if "body_measurements" in update_data and update_data["body_measurements"] is not None:
numeric_keys = {"chest", "waist", "hips", "inseam", "height", "weight"}
for key, value in update_data["body_measurements"].items():
Expand All @@ -82,6 +93,7 @@ def _user_response(user: User) -> UserProfileResponse:
display_name=user.display_name,
avatar_url=user.avatar_url,
timezone=user.timezone,
locale=user.locale,
location_lat=float(user.location_lat) if user.location_lat else None,
location_lon=float(user.location_lon) if user.location_lon else None,
location_name=user.location_name,
Expand Down
4 changes: 4 additions & 0 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.database import Base
from app.utils.locale import DEFAULT_LOCALE

if TYPE_CHECKING:
from app.models.family import Family
Expand All @@ -36,6 +37,9 @@ class User(Base):
avatar_url: Mapped[str | None] = mapped_column(String(500))
role: Mapped[str] = mapped_column(String(20), default="member")
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
locale: Mapped[str] = mapped_column(
String(10), default=DEFAULT_LOCALE, server_default=DEFAULT_LOCALE, nullable=False
)

# Location for weather
location_lat: Mapped[Decimal | None] = mapped_column(Numeric(10, 8))
Expand Down
4 changes: 4 additions & 0 deletions backend/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@

from pydantic import BaseModel, ConfigDict, EmailStr, Field

from app.utils.locale import DEFAULT_LOCALE


class UserBase(BaseModel):
email: EmailStr
display_name: str = Field(..., min_length=1, max_length=100)
avatar_url: str | None = None
timezone: str = Field(default="UTC", max_length=50)
locale: str = Field(default=DEFAULT_LOCALE, max_length=10)
location_lat: Decimal | None = Field(None, ge=-90, le=90)
location_lon: Decimal | None = Field(None, ge=-180, le=180)
location_name: str | None = Field(None, max_length=100)
Expand All @@ -23,6 +26,7 @@ class UserUpdate(BaseModel):
display_name: str | None = Field(None, min_length=1, max_length=100)
avatar_url: str | None = None
timezone: str | None = Field(None, max_length=50)
locale: str | None = Field(None, max_length=10)
location_lat: Decimal | None = Field(None, ge=-90, le=90)
location_lon: Decimal | None = Field(None, ge=-180, le=180)
location_name: str | None = Field(None, max_length=100)
Expand Down
1 change: 1 addition & 0 deletions backend/app/services/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ async def create(self, user_data: UserCreate) -> User:
display_name=user_data.display_name,
avatar_url=user_data.avatar_url,
timezone=user_data.timezone,
locale=user_data.locale,
location_lat=user_data.location_lat,
location_lon=user_data.location_lon,
location_name=user_data.location_name,
Expand Down
19 changes: 19 additions & 0 deletions backend/app/utils/locale.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
DEFAULT_LOCALE = "en"

# Order is the order the UI language picker renders; keep it stable.
SUPPORTED_LOCALES: tuple[str, ...] = (
"en",
"zh-CN",
"zh-TW",
"ko",
"ja",
"fr",
"de",
"it",
)

_SUPPORTED_LOCALE_SET = frozenset(SUPPORTED_LOCALES)


def is_supported_locale(value: object) -> bool:
return isinstance(value, str) and value in _SUPPORTED_LOCALE_SET
37 changes: 37 additions & 0 deletions backend/migrations/versions/d4e5f6a7b8c9_add_user_locale.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""add locale to users

Persists the UI language server-side so the choice follows a user across devices
instead of living only in a browser cookie. Existing rows are backfilled to 'en'
via the server_default.

Revision ID: d4e5f6a7b8c9
Revises: c1a2b3d4e5f6
Create Date: 2026-07-29 10:00:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "d4e5f6a7b8c9"
down_revision: str | None = "c1a2b3d4e5f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"locale",
sa.String(length=10),
server_default="en",
nullable=False,
),
)


def downgrade() -> None:
op.drop_column("users", "locale")
85 changes: 85 additions & 0 deletions backend/tests/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,91 @@ async def test_update_user_location(self, client: AsyncClient, test_user, auth_h
assert float(data["location_lon"]) == pytest.approx(-74.0060, rel=1e-4)


class TestUserLocale:
@pytest.mark.asyncio
async def test_default_locale_is_en(self, client: AsyncClient, test_user, auth_headers):
response = await client.get("/api/v1/users/me", headers=auth_headers)
assert response.status_code == 200
assert response.json()["locale"] == "en"

@pytest.mark.asyncio
async def test_update_locale(self, client: AsyncClient, test_user, auth_headers):
response = await client.patch(
"/api/v1/users/me",
json={"locale": "zh-CN"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["locale"] == "zh-CN"

response = await client.get("/api/v1/users/me", headers=auth_headers)
assert response.status_code == 200
assert response.json()["locale"] == "zh-CN"

@pytest.mark.asyncio
@pytest.mark.parametrize("locale", ["en", "zh-CN", "zh-TW", "ko", "ja", "fr", "de", "it"])
async def test_all_supported_locales_accepted(
self, client: AsyncClient, test_user, auth_headers, locale
):
response = await client.patch(
"/api/v1/users/me",
json={"locale": locale},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["locale"] == locale

@pytest.mark.asyncio
@pytest.mark.parametrize("locale", ["xx", "", "en-US-posix", "x" * 11, "EN", "en_US", None])
async def test_unsupported_locale_rejected(
self, client: AsyncClient, test_user, auth_headers, locale
):
response = await client.patch(
"/api/v1/users/me",
json={"locale": locale},
headers=auth_headers,
)
assert response.status_code == 422

response = await client.get("/api/v1/users/me", headers=auth_headers)
assert response.json()["locale"] == "en"

@pytest.mark.asyncio
async def test_update_locale_with_other_field(
self, client: AsyncClient, test_user, auth_headers
):
response = await client.patch(
"/api/v1/users/me",
json={"locale": "ja", "display_name": "Locale User"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["locale"] == "ja"
assert data["display_name"] == "Locale User"

@pytest.mark.asyncio
async def test_omitting_locale_preserves_existing(
self, client: AsyncClient, test_user, auth_headers
):
await client.patch("/api/v1/users/me", json={"locale": "de"}, headers=auth_headers)

response = await client.patch(
"/api/v1/users/me",
json={"display_name": "Still German"},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["display_name"] == "Still German"
assert data["locale"] == "de"

@pytest.mark.asyncio
async def test_update_locale_unauthorized(self, client: AsyncClient):
response = await client.patch("/api/v1/users/me", json={"locale": "fr"})
assert response.status_code == 401


class TestOnboarding:
"""Tests for onboarding completion endpoint."""

Expand Down
1 change: 1 addition & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/messages ./messages

# Entrypoint drops to nextjs (remapped to PUID/PGID when set); when the
# container is started as a non-root uid it execs directly
Expand Down
Loading
Loading