From cc9efca891e1ca0d9d730b778049828ef6f88cde Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 18 Aug 2026 12:43:14 +0300 Subject: [PATCH] Enhance internationalization support by integrating language selection for school names, updating UI components for localization, and revising documentation. Implement locale-aware formatting for dates and strings, ensuring a seamless user experience in both Russian and English. Update tests to cover new language features. --- AGENTS.md | 12 +- README.md | 5 +- docs/protocol.md | 4 + src/HSchool.Client/index.html | 4 +- .../src/format/gameTime.test.ts | 16 +- src/HSchool.Client/src/format/gameTime.ts | 87 +++++++---- src/HSchool.Client/src/i18n/locale.ts | 75 ++++++++++ src/HSchool.Client/src/i18n/strings.test.ts | 41 ++++++ src/HSchool.Client/src/i18n/strings.ts | 137 ++++++++++++++++++ src/HSchool.Client/src/main.ts | 46 ++++-- src/HSchool.Client/src/net/api.ts | 6 +- src/HSchool.Client/src/style.css | 28 ++++ src/HSchool.Client/src/ui/confirmDialog.ts | 5 +- .../src/ui/createSchoolDialog.ts | 29 ++-- src/HSchool.Client/src/ui/gameScreen.ts | 32 +++- src/HSchool.Client/src/ui/localeSwitch.ts | 39 +++++ src/HSchool.Client/src/ui/mainMenu.ts | 71 ++++++--- src/HSchool.Client/src/ui/schoolCard.ts | 35 +++-- src/HSchool.Server/Api/SchoolEndpoints.cs | 9 +- src/HSchool.Server/Game/GameCommand.cs | 4 +- src/HSchool.Server/Game/GameLoopService.cs | 2 +- src/HSchool.Simulation/SchoolNameGenerator.cs | 69 +++++++-- src/HSchool.Simulation/SchoolRegistry.cs | 3 +- .../SchoolRegistryTests.cs | 21 ++- 24 files changed, 640 insertions(+), 140 deletions(-) create mode 100644 src/HSchool.Client/src/i18n/locale.ts create mode 100644 src/HSchool.Client/src/i18n/strings.test.ts create mode 100644 src/HSchool.Client/src/i18n/strings.ts create mode 100644 src/HSchool.Client/src/ui/localeSwitch.ts diff --git a/AGENTS.md b/AGENTS.md index 1264cef..2e1e2bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ way; this file is *how to work in them*. | what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` | | connection handling, the loop | `src/HSchool.Server` | | what runs locally | `src/HSchool.AppHost/AppHost.cs` | -| screens, dialogs, formatting | `src/HSchool.Client/src` | +| screens, dialogs, formatting, UI language | `src/HSchool.Client/src` | ## Commands @@ -85,10 +85,11 @@ say so explicitly in the change description. - `strict` is on, no `any`, no non-null `!` assertions. - Relative imports carry the `.ts` extension (bundler resolution is configured for it). -- Modules stay thin: `net/` speaks to the server, `ui/` renders, `format/` formats, `main.ts` - wires them together. +- Modules stay thin: `net/` speaks to the server, `ui/` renders, `format/` formats, `i18n/` + holds the RU/EN dictionaries, `main.ts` wires them together. - No framework, plain DOM. `ui/dom.ts` is the whole helper budget. -- UI strings are Russian; game dates are formatted through `format/gameTime.ts`, never inline. +- UI strings go through `t(...)` in `i18n/strings.ts`, never inline. Game dates go through + `format/gameTime.ts`, which follows the active locale. **Both** @@ -105,7 +106,8 @@ say so explicitly in the change description. - Those tests also share one server, so schools survive between them: start each test by clearing the list (`SchoolApiTests.ResetAsync`) instead of assuming it is empty. - The screens have no unit tests — a DOM environment would cost a dependency the project does not - have. Verify UI changes by running the app. + have. Verify UI changes by running the app. Dictionaries and date formatting are covered in + Vitest (`i18n/strings.test.ts`, `format/gameTime.test.ts`). ## Dependencies diff --git a/README.md b/README.md index 1a7a924..7842668 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ rather than guessing. ## What you can do - **Main menu** — every school as a card with its name and its current in-game date and time, - ticking live. Deleting one asks for confirmation first. + ticking live. Deleting one asks for confirmation first. The footer switches the UI between + Russian and English; the choice is remembered in the browser. - **Create a school** — type a name or roll a random one, pick a start date (3 April 2012, 06:00 by default). At six schools the create button is disabled and says why. - **Inside a school** — the date, time and weekday of the game calendar, play/pause and the @@ -81,7 +82,7 @@ dotnet test npm --prefix src/HSchool.Client test ``` -Vitest covers the client codec and the calendar formatting. +Vitest covers the client codec, the calendar formatting and the RU/EN dictionaries. ## Layout diff --git a/docs/protocol.md b/docs/protocol.md index 7def5f6..4c0cb88 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -44,6 +44,10 @@ Everything the main menu needs in one request. `{ "name": "Лицей «Северная»" }` — a suggestion that is not already taken. +Optional `?lang=en` draws from the English word list (`Northern Academy`); any other value, or +none, stays Russian. The client sends the active UI language. Names the player types are not +translated — they are saved as written. + ### `POST /api/schools` Body: `{ "name": "Гимназия №14", "startDate": "2012-04-03T06:00:00Z" }` diff --git a/src/HSchool.Client/index.html b/src/HSchool.Client/index.html index e2d4646..0973e5a 100644 --- a/src/HSchool.Client/index.html +++ b/src/HSchool.Client/index.html @@ -9,8 +9,8 @@
diff --git a/src/HSchool.Client/src/format/gameTime.test.ts b/src/HSchool.Client/src/format/gameTime.test.ts index 7f4f51e..2b98588 100644 --- a/src/HSchool.Client/src/format/gameTime.test.ts +++ b/src/HSchool.Client/src/format/gameTime.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getLocale, setLocale } from '../i18n/locale.ts'; import { formatGameDate, formatGameDateTime, @@ -10,6 +11,10 @@ import { // The default start of a new school: 3 April 2012, 06:00 — a Tuesday. const START = new Date(Date.UTC(2012, 3, 3, 6, 0, 0)); +const initial = getLocale(); + +beforeEach(() => setLocale('ru')); +afterEach(() => setLocale(initial)); describe('game time formatting', () => { it('shows the time of day in 24-hour form', () => { @@ -30,6 +35,15 @@ describe('game time formatting', () => { expect(formatGameDateTime(START)).toContain('06:00'); }); + it('formats the same instant in English when the locale is en', () => { + setLocale('en'); + + expect(formatGameWeekday(START)).toBe('Tuesday'); + expect(formatGameDate(START)).toContain('April'); + expect(formatGameDateTime(START)).toContain('03/04/2012'); + expect(formatGameTimeOfDay(START)).toBe('06:00'); + }); + it('reads the calendar in UTC, so the school day does not shift with the viewer', () => { // Just before midnight UTC: any local-time formatting would land on the 4th. const lateEvening = new Date(Date.UTC(2012, 3, 3, 23, 30, 0)); diff --git a/src/HSchool.Client/src/format/gameTime.ts b/src/HSchool.Client/src/format/gameTime.ts index 192818e..d465756 100644 --- a/src/HSchool.Client/src/format/gameTime.ts +++ b/src/HSchool.Client/src/format/gameTime.ts @@ -6,53 +6,76 @@ * whatever offset they happen to live in. */ -const LOCALE = 'ru-RU'; +import { intlTag, onLocaleChange } from '../i18n/locale.ts'; + const UTC = 'UTC'; -const dateFormat = new Intl.DateTimeFormat(LOCALE, { - timeZone: UTC, - day: 'numeric', - month: 'long', - year: 'numeric', +interface Formats { + readonly date: Intl.DateTimeFormat; + readonly time: Intl.DateTimeFormat; + readonly weekday: Intl.DateTimeFormat; + readonly short: Intl.DateTimeFormat; +} + +let cached: { tag: string; formats: Formats } | null = null; + +function formats(): Formats { + const tag = intlTag(); + if (cached?.tag === tag) { + return cached.formats; + } + + const next: Formats = { + date: new Intl.DateTimeFormat(tag, { + timeZone: UTC, + day: 'numeric', + month: 'long', + year: 'numeric', + }), + time: new Intl.DateTimeFormat(tag, { + timeZone: UTC, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }), + weekday: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'long' }), + short: new Intl.DateTimeFormat(tag, { + timeZone: UTC, + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }), + }; + + cached = { tag, formats: next }; + return next; +} + +onLocaleChange(() => { + cached = null; }); -const timeFormat = new Intl.DateTimeFormat(LOCALE, { - timeZone: UTC, - hour: '2-digit', - minute: '2-digit', - hour12: false, -}); - -const weekdayFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: UTC, weekday: 'long' }); - -const shortFormat = new Intl.DateTimeFormat(LOCALE, { - timeZone: UTC, - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false, -}); - -/** "3 апреля 2012 г." */ +/** "3 апреля 2012 г." / "3 April 2012" */ export function formatGameDate(date: Date): string { - return dateFormat.format(date); + return formats().date.format(date); } /** "06:00" */ export function formatGameTimeOfDay(date: Date): string { - return timeFormat.format(date); + return formats().time.format(date); } -/** "вторник" */ +/** "вторник" / "Tuesday" */ export function formatGameWeekday(date: Date): string { - return weekdayFormat.format(date); + return formats().weekday.format(date); } -/** "03.04.2012, 06:00" — the compact form the school cards use. */ +/** Compact form the school cards use — day-month-year in both languages. */ export function formatGameDateTime(date: Date): string { - return shortFormat.format(date); + return formats().short.format(date); } /** Splits an ISO instant into the `` and `` values it needs. */ diff --git a/src/HSchool.Client/src/i18n/locale.ts b/src/HSchool.Client/src/i18n/locale.ts new file mode 100644 index 0000000..ccd9f45 --- /dev/null +++ b/src/HSchool.Client/src/i18n/locale.ts @@ -0,0 +1,75 @@ +/** The two UI languages. English dates use en-GB so day-month-year matches the Russian order. */ +export type Locale = 'ru' | 'en'; + +export const LOCALES = ['ru', 'en'] as const; + +const STORAGE_KEY = 'h-school.locale'; + +let current: Locale = detect(); +const listeners = new Set<() => void>(); + +function detect(): Locale { + const stored = readStored(); + if (stored !== null) { + return stored; + } + + const language = typeof navigator === 'undefined' ? '' : navigator.language.toLowerCase(); + return language.startsWith('ru') ? 'ru' : 'en'; +} + +function readStored(): Locale | null { + try { + const value = globalThis.localStorage?.getItem(STORAGE_KEY); + return value === 'ru' || value === 'en' ? value : null; + } catch { + return null; + } +} + +function persist(locale: Locale): void { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, locale); + } catch { + // Private mode, or tests without a store — the in-memory value still changes. + } +} + +function applyToDocument(locale: Locale): void { + if (typeof document === 'undefined') { + return; + } + + document.documentElement.lang = locale; +} + +applyToDocument(current); + +export function getLocale(): Locale { + return current; +} + +/** BCP 47 tag for `Intl` formatters. */ +export function intlTag(locale: Locale = current): 'ru-RU' | 'en-GB' { + return locale === 'en' ? 'en-GB' : 'ru-RU'; +} + +export function setLocale(locale: Locale): void { + if (locale === current) { + return; + } + + current = locale; + persist(locale); + applyToDocument(locale); + + for (const listener of listeners) { + listener(); + } +} + +/** Returns an unsubscribe function. */ +export function onLocaleChange(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} diff --git a/src/HSchool.Client/src/i18n/strings.test.ts b/src/HSchool.Client/src/i18n/strings.test.ts new file mode 100644 index 0000000..117e602 --- /dev/null +++ b/src/HSchool.Client/src/i18n/strings.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getLocale, setLocale } from './locale.ts'; +import { schoolWord, t } from './strings.ts'; + +const initial = getLocale(); + +afterEach(() => setLocale(initial)); + +describe('t', () => { + it('returns the Russian string for the active locale', () => { + setLocale('ru'); + expect(t('createSchool')).toBe('Создать школу'); + }); + + it('returns the English string after a switch', () => { + setLocale('en'); + expect(t('createSchool')).toBe('Create school'); + }); + + it('interpolates placeholders', () => { + setLocale('en'); + expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.'); + }); +}); + +describe('schoolWord', () => { + it('uses the Russian one/few/many forms', () => { + setLocale('ru'); + expect(schoolWord(1)).toBe('школа'); + expect(schoolWord(2)).toBe('школы'); + expect(schoolWord(5)).toBe('школ'); + expect(schoolWord(21)).toBe('школа'); + }); + + it('uses the English one/other forms', () => { + setLocale('en'); + expect(schoolWord(1)).toBe('school'); + expect(schoolWord(2)).toBe('schools'); + expect(schoolWord(5)).toBe('schools'); + }); +}); diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts new file mode 100644 index 0000000..aa1140e --- /dev/null +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -0,0 +1,137 @@ +import { getLocale, intlTag, type Locale } from './locale.ts'; + +const ru = { + statusConnecting: 'подключение…', + statusConnected: 'сервер на связи', + statusReconnecting: 'переподключение…', + statusClosed: 'соединение закрыто', + pingMs: '{ms} мс', + pingPlaceholder: '-- мс', + language: 'Язык', + + schoolsTitle: 'Школы', + createSchool: 'Создать школу', + emptySchools: 'Пока ни одной школы. Создайте первую.', + loadSchoolsFailed: 'Не удалось загрузить список школ. Проверьте соединение с сервером.', + createDisabledTitle: 'Удалите одну из школ, чтобы создать новую', + schoolCount: 'Школ: {current} из {max}.', + schoolLimitReached: 'Достигнут лимит: {max} {schoolWord}. Удалите одну, чтобы создать новую.', + + deleteSchool: 'Удалить', + deleteSchoolTitle: 'Удалить школу?', + deleteSchoolMessage: '«{name}» будет удалена без возможности восстановления.', + deleteFailed: 'Не удалось удалить «{name}».', + paused: '⏸ на паузе', + + newSchool: 'Новая школа', + schoolName: 'Название', + schoolNamePlaceholder: 'Название школы', + randomName: 'Случайное', + randomNameTitle: 'Придумать название', + gameStart: 'Начало игры', + create: 'Создать', + cancel: 'Отмена', + confirm: 'Подтвердить', + randomNameFailed: 'Не удалось получить название с сервера.', + startDateRequired: 'Укажите дату и время начала.', + serverUnavailable: 'Сервер недоступен. Попробуйте ещё раз.', + errorSchoolLimit: 'Достигнут лимит школ — удалите одну, чтобы создать новую.', + errorInvalidName: 'Название должно быть от 1 до 40 символов.', + errorInvalidStartDate: 'Дата начала вне допустимого диапазона.', + + backToMenu: '← В главное меню', + emptySchoolHint: 'Школа пока пуста — здесь появится сама игра.', + pause: 'Пауза', + resume: 'Продолжить', +} as const; + +type Messages = { [K in keyof typeof ru]: string }; + +const en: Messages = { + statusConnecting: 'connecting…', + statusConnected: 'connected', + statusReconnecting: 'reconnecting…', + statusClosed: 'connection closed', + pingMs: '{ms} ms', + pingPlaceholder: '-- ms', + language: 'Language', + + schoolsTitle: 'Schools', + createSchool: 'Create school', + emptySchools: 'No schools yet. Create the first one.', + loadSchoolsFailed: 'Could not load the school list. Check the connection to the server.', + createDisabledTitle: 'Delete a school to create a new one', + schoolCount: 'Schools: {current} of {max}.', + schoolLimitReached: 'Limit reached: {max} {schoolWord}. Delete one to create another.', + + deleteSchool: 'Delete', + deleteSchoolTitle: 'Delete this school?', + deleteSchoolMessage: '"{name}" will be deleted permanently.', + deleteFailed: 'Could not delete "{name}".', + paused: '⏸ paused', + + newSchool: 'New school', + schoolName: 'Name', + schoolNamePlaceholder: 'School name', + randomName: 'Random', + randomNameTitle: 'Suggest a name', + gameStart: 'Game start', + create: 'Create', + cancel: 'Cancel', + confirm: 'Confirm', + randomNameFailed: 'Could not fetch a name from the server.', + startDateRequired: 'Enter a start date and time.', + serverUnavailable: 'The server is unavailable. Try again.', + errorSchoolLimit: 'School limit reached — delete one to create another.', + errorInvalidName: 'The name must be 1 to 40 characters.', + errorInvalidStartDate: 'The start date is outside the allowed range.', + + backToMenu: '← Main menu', + emptySchoolHint: 'The school is empty for now — the game itself will appear here.', + pause: 'Pause', + resume: 'Resume', +}; + +const catalogs: Record = { ru, en }; + +export type MessageKey = keyof Messages; + +export function t(key: MessageKey, vars?: Record): string { + let text: string = catalogs[getLocale()][key]; + + if (vars !== undefined) { + for (const [name, value] of Object.entries(vars)) { + text = text.replaceAll(`{${name}}`, String(value)); + } + } + + return text; +} + +interface PluralForms { + readonly one: string; + readonly few?: string; + readonly many?: string; + readonly other: string; +} + +const schoolWordForms: Record = { + ru: { one: 'школа', few: 'школы', many: 'школ', other: 'школ' }, + en: { one: 'school', other: 'schools' }, +}; + +export function schoolWord(count: number): string { + const forms = schoolWordForms[getLocale()]; + const rule = new Intl.PluralRules(intlTag()).select(count); + + switch (rule) { + case 'one': + return forms.one; + case 'few': + return forms.few ?? forms.other; + case 'many': + return forms.many ?? forms.other; + default: + return forms.other; + } +} diff --git a/src/HSchool.Client/src/main.ts b/src/HSchool.Client/src/main.ts index dc7f96b..d3f9908 100644 --- a/src/HSchool.Client/src/main.ts +++ b/src/HSchool.Client/src/main.ts @@ -1,23 +1,31 @@ import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts'; +import { onLocaleChange } from './i18n/locale.ts'; +import { t, type MessageKey } from './i18n/strings.ts'; import { GameScreen } from './ui/gameScreen.ts'; +import { localeSwitch } from './ui/localeSwitch.ts'; import { MainMenu } from './ui/mainMenu.ts'; import type { School } from './net/api.ts'; import './style.css'; -const STATUS_LABELS: Record = { - connecting: 'подключение…', - connected: 'сервер на связи', - reconnecting: 'переподключение…', - closed: 'соединение закрыто', +const STATUS_KEYS: Record = { + connecting: 'statusConnecting', + connected: 'statusConnected', + reconnecting: 'statusReconnecting', + closed: 'statusClosed', }; /** Wires the two screens to one WebSocket connection. */ function bootstrap(): void { const app = requireElement('#app'); + const footer = requireElement('#status'); const statusLabel = document.querySelector('[data-status="connection"]'); const pingLabel = document.querySelector('[data-status="ping"]'); + footer.prepend(localeSwitch()); + let openSchool: School | null = null; + let connectionStatus: ConnectionStatus = 'connecting'; + let lastPingMs: number | null = null; const menu = new MainMenu({ onOpenSchool: (school) => enterSchool(school) }); const game = new GameScreen({ @@ -28,9 +36,8 @@ function bootstrap(): void { const connection = new GameConnection(gameSocketUrl(), { onStatus: (status) => { - if (statusLabel !== null) { - statusLabel.textContent = STATUS_LABELS[status]; - } + connectionStatus = status; + paintChrome(); }, onClock: (clock) => { if (openSchool?.id === clock.schoolId) { @@ -44,12 +51,22 @@ function bootstrap(): void { } }, onLatency: (rttMs) => { - if (pingLabel !== null) { - pingLabel.textContent = `${Math.round(rttMs)} мс`; - } + lastPingMs = rttMs; + paintChrome(); }, }); + function paintChrome(): void { + if (statusLabel !== null) { + statusLabel.textContent = t(STATUS_KEYS[connectionStatus]); + } + + if (pingLabel !== null) { + pingLabel.textContent = + lastPingMs === null ? t('pingPlaceholder') : t('pingMs', { ms: Math.round(lastPingMs) }); + } + } + function enterSchool(school: School): void { openSchool = school; menu.stop(); @@ -69,6 +86,13 @@ function bootstrap(): void { menu.start(); } + onLocaleChange(() => { + paintChrome(); + menu.localize(); + game.localize(); + }); + + paintChrome(); connection.connect(); showMenu(); diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 6b9450f..178aca2 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -34,8 +34,10 @@ export async function fetchSchools(): Promise { return request('/api/schools'); } -export async function fetchRandomName(): Promise { - const response = await request<{ name: string }>('/api/schools/random-name'); +export async function fetchRandomName(lang: string): Promise { + const response = await request<{ name: string }>( + `/api/schools/random-name?lang=${encodeURIComponent(lang)}`, + ); return response.name; } diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 08fe13e..d824428 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -34,11 +34,39 @@ body { right: 16px; bottom: 12px; display: flex; + align-items: center; gap: 14px; font-size: 12px; color: var(--text-muted); } +.locale-switch { + display: flex; + gap: 4px; +} + +.locale-switch__button { + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 11px; + letter-spacing: 0.04em; + cursor: pointer; +} + +.locale-switch__button:hover { + border-color: var(--accent); + color: var(--text); +} + +.locale-switch__button[aria-pressed='true'] { + border-color: var(--accent); + color: var(--accent); +} + /* Screens */ .screen__header { diff --git a/src/HSchool.Client/src/ui/confirmDialog.ts b/src/HSchool.Client/src/ui/confirmDialog.ts index e27144e..f92b55d 100644 --- a/src/HSchool.Client/src/ui/confirmDialog.ts +++ b/src/HSchool.Client/src/ui/confirmDialog.ts @@ -1,3 +1,4 @@ +import { t } from '../i18n/strings.ts'; import { el } from './dom.ts'; import { Modal } from './modal.ts'; @@ -17,7 +18,7 @@ export function confirmDialog(options: ConfirmOptions): Promise { const confirmButton = el('button', { class: options.danger === true ? 'button button--danger' : 'button button--primary', type: 'button', - text: options.confirmLabel ?? 'Подтвердить', + text: options.confirmLabel ?? t('confirm'), onClick: () => modal.close(true), }); @@ -30,7 +31,7 @@ export function confirmDialog(options: ConfirmOptions): Promise { el('button', { class: 'button', type: 'button', - text: options.cancelLabel ?? 'Отмена', + text: options.cancelLabel ?? t('cancel'), onClick: () => modal.close(false), }), confirmButton, diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts index 50912ba..eb05eeb 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts @@ -1,5 +1,6 @@ import { ApiError, type School } from '../net/api.ts'; import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts'; +import { t } from '../i18n/strings.ts'; import { el } from './dom.ts'; import { Modal } from './modal.ts'; @@ -20,7 +21,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise modal.close(null) }), + el('button', { class: 'button', type: 'button', text: t('cancel'), onClick: () => modal.close(null) }), submitButton, ), ); @@ -92,7 +93,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise showError('Не удалось получить название с сервера.')) + .catch(() => showError(t('randomNameFailed'))) .finally(() => setBusy(false)); }); @@ -104,7 +105,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise @@ -36,15 +41,11 @@ export class GameScreen { }), ); + this.backButton.addEventListener('click', options.onLeave); this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running)); this.root.append( - el( - 'header', - { class: 'screen__header' }, - el('button', { class: 'button', type: 'button', text: '← В главное меню', onClick: options.onLeave }), - this.schoolName, - ), + el('header', { class: 'screen__header' }, this.backButton, this.schoolName), el( 'div', { class: 'clock' }, @@ -53,14 +54,27 @@ export class GameScreen { this.weekday, el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons), ), - el('p', { class: 'hint', text: 'Школа пока пуста — здесь появится сама игра.' }), + this.emptyHint, ); + + this.localize(); } get element(): HTMLElement { return this.root; } + localize(): void { + this.backButton.textContent = t('backToMenu'); + this.emptyHint.textContent = t('emptySchoolHint'); + + if (this.lastGameTime !== null) { + this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex); + } else { + this.playPauseButton.title = t('resume'); + } + } + /** Called when the screen opens, before the first clock frame arrives. */ show(school: School): void { this.schoolName.textContent = school.name; @@ -73,13 +87,15 @@ export class GameScreen { private applyClock(gameTime: Date, running: boolean, speedIndex: number): void { this.running = running; + this.lastGameTime = gameTime; + this.lastSpeedIndex = speedIndex; this.time.textContent = formatGameTimeOfDay(gameTime); this.date.textContent = formatGameDate(gameTime); this.weekday.textContent = formatGameWeekday(gameTime); this.playPauseButton.textContent = running ? '⏸' : '▶'; - this.playPauseButton.title = running ? 'Пауза' : 'Продолжить'; + this.playPauseButton.title = running ? t('pause') : t('resume'); this.speedButtons.forEach((button, index) => { button.classList.toggle('button--active', index === speedIndex); diff --git a/src/HSchool.Client/src/ui/localeSwitch.ts b/src/HSchool.Client/src/ui/localeSwitch.ts new file mode 100644 index 0000000..7a95fe5 --- /dev/null +++ b/src/HSchool.Client/src/ui/localeSwitch.ts @@ -0,0 +1,39 @@ +import { el } from './dom.ts'; +import { LOCALES, getLocale, setLocale, onLocaleChange, type Locale } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; + +const LABELS: Record = { ru: 'RU', en: 'EN' }; + +/** Compact RU/EN toggle for the status footer — always on screen, both menus. */ +export function localeSwitch(): HTMLElement { + const buttons = new Map(); + + const group = el('div', { class: 'locale-switch' }); + group.setAttribute('role', 'group'); + + for (const locale of LOCALES) { + const button = el('button', { + class: 'locale-switch__button', + type: 'button', + text: LABELS[locale], + onClick: () => setLocale(locale), + }); + button.setAttribute('lang', locale); + buttons.set(locale, button); + group.append(button); + } + + const sync = (): void => { + group.setAttribute('aria-label', t('language')); + const active = getLocale(); + + for (const [locale, button] of buttons) { + button.setAttribute('aria-pressed', locale === active ? 'true' : 'false'); + } + }; + + onLocaleChange(sync); + sync(); + + return group; +} diff --git a/src/HSchool.Client/src/ui/mainMenu.ts b/src/HSchool.Client/src/ui/mainMenu.ts index 36acd10..913f758 100644 --- a/src/HSchool.Client/src/ui/mainMenu.ts +++ b/src/HSchool.Client/src/ui/mainMenu.ts @@ -6,6 +6,8 @@ import { type School, type SchoolsResponse, } from '../net/api.ts'; +import { getLocale } from '../i18n/locale.ts'; +import { schoolWord, t } from '../i18n/strings.ts'; import { el } from './dom.ts'; import { confirmDialog } from './confirmDialog.ts'; import { createSchoolDialog } from './createSchoolDialog.ts'; @@ -26,12 +28,12 @@ const REFRESH_INTERVAL_MS = 1000; export class MainMenu { private readonly root = el('section', { class: 'screen menu' }); + private readonly title = el('h1', { class: 'screen__title' }); private readonly grid = el('div', { class: 'card-grid' }); - private readonly emptyHint = el('p', { class: 'hint', text: 'Пока ни одной школы. Создайте первую.' }); + private readonly emptyHint = el('p', { class: 'hint' }); private readonly createButton = el('button', { class: 'button button--primary', type: 'button', - text: 'Создать школу', }); private readonly limitHint = el('p', { class: 'hint' }); @@ -41,6 +43,7 @@ export class MainMenu { private state: SchoolsResponse | null = null; private refreshTimer: ReturnType | null = null; private busy = false; + private error: { key: 'loadSchoolsFailed' } | { key: 'deleteFailed'; name: string } | null = null; constructor(private readonly options: MainMenuOptions) { this.createButton.addEventListener('click', () => void this.openCreateDialog()); @@ -50,7 +53,7 @@ export class MainMenu { el( 'header', { class: 'screen__header' }, - el('h1', { class: 'screen__title', text: 'Школы' }), + this.title, el('div', { class: 'screen__actions' }, this.createButton), ), this.limitHint, @@ -58,12 +61,28 @@ export class MainMenu { this.emptyHint, this.grid, ); + + this.localize(); } get element(): HTMLElement { return this.root; } + /** Re-applies strings after a language switch. Cards stay in place so focus is not dropped. */ + localize(): void { + this.title.textContent = t('schoolsTitle'); + this.createButton.textContent = t('createSchool'); + this.emptyHint.textContent = t('emptySchools'); + + for (const card of this.cards.values()) { + card.localize(); + } + + this.paintError(); + this.render(); + } + /** Shows the menu: loads the list once, then keeps it fresh. */ start(): void { void this.refresh(); @@ -86,16 +105,30 @@ export class MainMenu { async refresh(): Promise { try { this.state = await fetchSchools(); - this.status.hidden = true; + this.error = null; } catch { - this.status.textContent = 'Не удалось загрузить список школ. Проверьте соединение с сервером.'; - this.status.hidden = false; + this.error = { key: 'loadSchoolsFailed' }; + this.paintError(); return; } + this.paintError(); this.render(); } + private paintError(): void { + if (this.error === null) { + this.status.hidden = true; + return; + } + + this.status.textContent = + this.error.key === 'loadSchoolsFailed' + ? t('loadSchoolsFailed') + : t('deleteFailed', { name: this.error.name }); + this.status.hidden = false; + } + private render(): void { const state = this.state; if (state === null) { @@ -104,11 +137,11 @@ export class MainMenu { const atLimit = state.schools.length >= state.maxSchools; this.createButton.toggleAttribute('disabled', atLimit || this.busy); - this.createButton.title = atLimit ? 'Удалите одну из школ, чтобы создать новую' : ''; + this.createButton.title = atLimit ? t('createDisabledTitle') : ''; this.limitHint.textContent = atLimit - ? `Достигнут лимит: ${state.maxSchools} ${plural(state.maxSchools)}. Удалите одну, чтобы создать новую.` - : `Школ: ${state.schools.length} из ${state.maxSchools}.`; + ? t('schoolLimitReached', { max: state.maxSchools, schoolWord: schoolWord(state.maxSchools) }) + : t('schoolCount', { current: state.schools.length, max: state.maxSchools }); this.emptyHint.hidden = state.schools.length > 0; @@ -141,9 +174,9 @@ export class MainMenu { private async confirmDelete(school: School): Promise { const confirmed = await confirmDialog({ - title: 'Удалить школу?', - message: `«${school.name}» будет удалена без возможности восстановления.`, - confirmLabel: 'Удалить', + title: t('deleteSchoolTitle'), + message: t('deleteSchoolMessage', { name: school.name }), + confirmLabel: t('deleteSchool'), danger: true, }); @@ -154,8 +187,7 @@ export class MainMenu { try { await deleteSchool(school.id); } catch { - this.status.textContent = `Не удалось удалить «${school.name}».`; - this.status.hidden = false; + this.error = { key: 'deleteFailed', name: school.name }; } await this.refresh(); @@ -171,7 +203,7 @@ export class MainMenu { try { await createSchoolDialog({ defaultStartDate: new Date(state.defaultStartDate), - suggestName: fetchRandomName, + suggestName: () => fetchRandomName(getLocale()), create: createSchool, }); } finally { @@ -181,12 +213,3 @@ export class MainMenu { await this.refresh(); } } - -function plural(count: number): string { - const remainderTen = count % 10; - const remainderHundred = count % 100; - - if (remainderTen === 1 && remainderHundred !== 11) return 'школа'; - if (remainderTen >= 2 && remainderTen <= 4 && (remainderHundred < 12 || remainderHundred > 14)) return 'школы'; - return 'школ'; -} diff --git a/src/HSchool.Client/src/ui/schoolCard.ts b/src/HSchool.Client/src/ui/schoolCard.ts index d57c153..072a549 100644 --- a/src/HSchool.Client/src/ui/schoolCard.ts +++ b/src/HSchool.Client/src/ui/schoolCard.ts @@ -1,5 +1,6 @@ import type { School } from '../net/api.ts'; import { formatGameDateTime } from '../format/gameTime.ts'; +import { t } from '../i18n/strings.ts'; import { el } from './dom.ts'; interface SchoolCardOptions { @@ -16,7 +17,11 @@ export class SchoolCard { private readonly title = el('h2', { class: 'card__title' }); private readonly time = el('p', { class: 'card__time' }); - private readonly pausedBadge = el('span', { class: 'card__badge', text: '⏸ на паузе' }); + private readonly pausedBadge = el('span', { class: 'card__badge' }); + private readonly deleteButton = el('button', { + class: 'button button--danger button--small', + type: 'button', + }); private school: School; @@ -25,23 +30,16 @@ export class SchoolCard { this.element.dataset['schoolId'] = String(school.id); this.element.tabIndex = 0; + this.deleteButton.addEventListener('click', (event) => { + // The whole card is clickable, so the delete button must not open the school too. + event.stopPropagation(); + options.onDelete(this.school); + }); + this.element.append( this.title, el('div', { class: 'card__meta' }, this.time, this.pausedBadge), - el( - 'div', - { class: 'card__actions' }, - el('button', { - class: 'button button--danger button--small', - type: 'button', - text: 'Удалить', - onClick: (event) => { - // The whole card is clickable, so the delete button must not open the school too. - event.stopPropagation(); - options.onDelete(this.school); - }, - }), - ), + el('div', { class: 'card__actions' }, this.deleteButton), ); this.element.addEventListener('click', () => options.onOpen(this.school)); @@ -52,9 +50,16 @@ export class SchoolCard { } }); + this.localize(); this.update(school); } + localize(): void { + this.deleteButton.textContent = t('deleteSchool'); + this.pausedBadge.textContent = t('paused'); + this.update(this.school); + } + update(school: School): void { this.school = school; diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 6166d12..f43c787 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -29,9 +29,9 @@ internal static class SchoolEndpoints }) .WithName("GetSchools"); - schools.MapGet("/random-name", async (GameCommandQueue commands, CancellationToken cancellationToken) => + schools.MapGet("/random-name", async (string? lang, GameCommandQueue commands, CancellationToken cancellationToken) => { - var command = new GameCommand.SuggestName(NewCompletion()); + var command = new GameCommand.SuggestName(ParseNameLanguage(lang), NewCompletion()); commands.Enqueue(command); var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); @@ -85,6 +85,11 @@ internal static class SchoolEndpoints private static TaskCompletionSource NewCompletion() => new(TaskCreationOptions.RunContinuationsAsynchronously); + private static SchoolNameLanguage ParseNameLanguage(string? lang) => + string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) + ? SchoolNameLanguage.English + : SchoolNameLanguage.Russian; + private static IResult Problem(int statusCode, string code, string detail) => Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary { diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 1deb67d..7361854 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -1,3 +1,5 @@ +using HSchool.Simulation; + namespace HSchool.Server.Game; /// @@ -13,7 +15,7 @@ internal abstract record GameCommand internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource Result) : GameCommand; - internal sealed record SuggestName(TaskCompletionSource Result) : GameCommand; + internal sealed record SuggestName(SchoolNameLanguage Language, TaskCompletionSource Result) : GameCommand; /// A connection starts watching a school; its calendar starts running. internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index f485306..82157f2 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -111,7 +111,7 @@ internal sealed class GameLoopService( break; case GameCommand.SuggestName suggest: - Complete(suggest.Result, _schools.SuggestName); + Complete(suggest.Result, () => _schools.SuggestName(suggest.Language)); break; case GameCommand.OpenSchool open: diff --git a/src/HSchool.Simulation/SchoolNameGenerator.cs b/src/HSchool.Simulation/SchoolNameGenerator.cs index 3ed77e9..0e6785d 100644 --- a/src/HSchool.Simulation/SchoolNameGenerator.cs +++ b/src/HSchool.Simulation/SchoolNameGenerator.cs @@ -1,5 +1,12 @@ namespace HSchool.Simulation; +/// Which word list the random-name button draws from. +public enum SchoolNameLanguage +{ + Russian = 0, + English = 1, +} + /// /// Suggestions for the "random name" button. Lives here rather than in the client because the /// server is the one that knows which names are already taken. @@ -8,13 +15,35 @@ public sealed class SchoolNameGenerator(Random? random = null) { private const int AttemptsBeforeNumbering = 24; - private static readonly string[] Kinds = ["Школа", "Гимназия", "Лицей", "Школа-интернат"]; + private readonly record struct Catalog( + string[] Kinds, + string[] Epithets, + string NumberSign, + bool NumberSpace, + bool EpithetFirst, + string FallbackKind); - private static readonly string[] Epithets = - [ - "Северная", "Приморская", "Заречная", "Нагорная", "Слободская", "Озёрная", - "Кленовая", "Рябиновая", "Солнечная", "Луговая", "Тихая", "Ясная", - ]; + private static readonly Catalog Russian = new( + ["Школа", "Гимназия", "Лицей", "Школа-интернат"], + [ + "Северная", "Приморская", "Заречная", "Нагорная", "Слободская", "Озёрная", + "Кленовая", "Рябиновая", "Солнечная", "Луговая", "Тихая", "Ясная", + ], + "№", + false, + false, + "Школа"); + + private static readonly Catalog English = new( + ["School", "Academy", "Grammar School", "High School"], + [ + "Northern", "Seaside", "Riverside", "Hillside", "Maple", "Rowan", + "Sunny", "Meadow", "Quiet", "Clear", "Oak", "Pine", + ], + "No.", + true, + true, + "School"); private readonly Random _random = random ?? Random.Shared; @@ -22,13 +51,14 @@ public sealed class SchoolNameGenerator(Random? random = null) /// A name that is not in . Falls back to a numbered name so the /// button always produces something, even when the pool is exhausted. /// - public string Next(IEnumerable taken) + public string Next(IEnumerable taken, SchoolNameLanguage language = SchoolNameLanguage.Russian) { + var catalog = language == SchoolNameLanguage.English ? English : Russian; var used = new HashSet(taken, StringComparer.OrdinalIgnoreCase); for (var attempt = 0; attempt < AttemptsBeforeNumbering; attempt++) { - var candidate = Compose(); + var candidate = Compose(catalog); if (used.Add(candidate)) { return candidate; @@ -37,7 +67,7 @@ public sealed class SchoolNameGenerator(Random? random = null) for (var number = 1; ; number++) { - var candidate = $"Школа №{number}"; + var candidate = Numbered(catalog, catalog.FallbackKind, number); if (!used.Contains(candidate)) { return candidate; @@ -45,13 +75,24 @@ public sealed class SchoolNameGenerator(Random? random = null) } } - private string Compose() + private string Compose(Catalog catalog) { - var kind = Kinds[_random.Next(Kinds.Length)]; + var kind = catalog.Kinds[_random.Next(catalog.Kinds.Length)]; // Half the names are numbered, half are named — both read like a real school. - return _random.Next(2) == 0 - ? $"{kind} №{_random.Next(1, 100)}" - : $"{kind} «{Epithets[_random.Next(Epithets.Length)]}»"; + if (_random.Next(2) == 0) + { + return Numbered(catalog, kind, _random.Next(1, 100)); + } + + var epithet = catalog.Epithets[_random.Next(catalog.Epithets.Length)]; + return catalog.EpithetFirst + ? $"{epithet} {kind}" + : $"{kind} «{epithet}»"; } + + private static string Numbered(Catalog catalog, string kind, int number) => + catalog.NumberSpace + ? $"{kind} {catalog.NumberSign} {number}" + : $"{kind} {catalog.NumberSign}{number}"; } diff --git a/src/HSchool.Simulation/SchoolRegistry.cs b/src/HSchool.Simulation/SchoolRegistry.cs index b6ff620..c65d38a 100644 --- a/src/HSchool.Simulation/SchoolRegistry.cs +++ b/src/HSchool.Simulation/SchoolRegistry.cs @@ -100,7 +100,8 @@ public sealed class SchoolRegistry : IDisposable } /// A name the player has not used yet, for the "random" button in the creation form. - public string SuggestName() => NameGenerator.Next(_schools.Select(school => school.Name)); + public string SuggestName(SchoolNameLanguage language = SchoolNameLanguage.Russian) => + NameGenerator.Next(_schools.Select(school => school.Name), language); /// Trims, strips control characters and enforces the length limit. public static bool TryNormalizeName(string? name, out string normalized) diff --git a/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs b/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs index a88297f..5f838d0 100644 --- a/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs +++ b/tests/HSchool.Simulation.Tests/SchoolRegistryTests.cs @@ -159,10 +159,25 @@ public class SchoolRegistryTests { var generator = new SchoolNameGenerator(new Random(1234)); - for (var i = 0; i < 200; i++) + foreach (var language in new[] { SchoolNameLanguage.Russian, SchoolNameLanguage.English }) { - var name = generator.Next([]); - Assert.InRange(name.Length, 1, School.MaxNameLength); + for (var i = 0; i < 200; i++) + { + var name = generator.Next([], language); + Assert.InRange(name.Length, 1, School.MaxNameLength); + } + } + } + + [Fact] + public void SuggestedEnglishNames_AreAscii() + { + var generator = new SchoolNameGenerator(new Random(1234)); + + for (var i = 0; i < 50; i++) + { + var name = generator.Next([], SchoolNameLanguage.English); + Assert.Matches("^[A-Za-z0-9 .]+$", name); } }