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.
ci / server (push) Failing after 3m33s
ci / client (push) Successful in 15s

This commit is contained in:
Leonid Pershin
2026-08-18 12:43:14 +03:00
parent b9ddc018d3
commit cc9efca891
24 changed files with 640 additions and 140 deletions
+2 -2
View File
@@ -9,8 +9,8 @@
<body>
<main id="app"></main>
<footer id="status">
<span data-status="connection">подключение…</span>
<span data-status="ping">-- мс</span>
<span data-status="connection"></span>
<span data-status="ping"></span>
</footer>
<script type="module" src="/src/main.ts"></script>
</body>
+15 -1
View File
@@ -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));
+55 -32
View File
@@ -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 `<input type="date">` and `<input type="time">` values it needs. */
+75
View File
@@ -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);
}
@@ -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');
});
});
+137
View File
@@ -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<Locale, Messages> = { ru, en };
export type MessageKey = keyof Messages;
export function t(key: MessageKey, vars?: Record<string, string | number>): 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<Locale, PluralForms> = {
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;
}
}
+35 -11
View File
@@ -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<ConnectionStatus, string> = {
connecting: 'подключение…',
connected: 'сервер на связи',
reconnecting: 'переподключение…',
closed: 'соединение закрыто',
const STATUS_KEYS: Record<ConnectionStatus, MessageKey> = {
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<HTMLElement>('[data-status="connection"]');
const pingLabel = document.querySelector<HTMLElement>('[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();
+4 -2
View File
@@ -34,8 +34,10 @@ export async function fetchSchools(): Promise<SchoolsResponse> {
return request<SchoolsResponse>('/api/schools');
}
export async function fetchRandomName(): Promise<string> {
const response = await request<{ name: string }>('/api/schools/random-name');
export async function fetchRandomName(lang: string): Promise<string> {
const response = await request<{ name: string }>(
`/api/schools/random-name?lang=${encodeURIComponent(lang)}`,
);
return response.name;
}
+28
View File
@@ -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 {
+3 -2
View File
@@ -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<boolean> {
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<boolean> {
el('button', {
class: 'button',
type: 'button',
text: options.cancelLabel ?? 'Отмена',
text: options.cancelLabel ?? t('cancel'),
onClick: () => modal.close(false),
}),
confirmButton,
+15 -14
View File
@@ -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<School
const nameInput = el('input', { class: 'input', type: 'text' });
nameInput.maxLength = 40;
nameInput.placeholder = 'Название школы';
nameInput.placeholder = t('schoolNamePlaceholder');
nameInput.required = true;
const dateInput = el('input', { class: 'input', type: 'date' });
@@ -37,11 +38,11 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const randomButton = el('button', {
class: 'button',
type: 'button',
text: 'Случайное',
title: 'Придумать название',
text: t('randomName'),
title: t('randomNameTitle'),
});
const submitButton = el('button', { class: 'button button--primary', type: 'submit', text: 'Создать' });
const submitButton = el('button', { class: 'button button--primary', type: 'submit', text: t('create') });
const form = el(
'form',
@@ -49,20 +50,20 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
el(
'label',
{ class: 'field' },
el('span', { class: 'field__label', text: 'Название' }),
el('span', { class: 'field__label', text: t('schoolName') }),
el('div', { class: 'field__row' }, nameInput, randomButton),
),
el(
'div',
{ class: 'field' },
el('span', { class: 'field__label', text: 'Начало игры' }),
el('span', { class: 'field__label', text: t('gameStart') }),
el('div', { class: 'field__row' }, dateInput, timeInput),
),
error,
el(
'div',
{ class: 'dialog__actions' },
el('button', { class: 'button', type: 'button', text: 'Отмена', onClick: () => 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<School
nameInput.value = name;
error.hidden = true;
})
.catch(() => showError('Не удалось получить название с сервера.'))
.catch(() => showError(t('randomNameFailed')))
.finally(() => setBusy(false));
});
@@ -104,7 +105,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const startDate = fromDateAndTimeInputs(dateInput.value, timeInput.value);
if (startDate === null) {
showError('Укажите дату и время начала.');
showError(t('startDateRequired'));
return;
}
@@ -118,23 +119,23 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
});
});
modal.element.append(el('h2', { class: 'dialog__title', text: 'Новая школа' }), form);
modal.element.append(el('h2', { class: 'dialog__title', text: t('newSchool') }), form);
return modal.open(nameInput);
}
function describe(reason: unknown): string {
if (!(reason instanceof ApiError)) {
return 'Сервер недоступен. Попробуйте ещё раз.';
return t('serverUnavailable');
}
switch (reason.code) {
case 'school-limit-reached':
return 'Достигнут лимит школ — удалите одну, чтобы создать новую.';
return t('errorSchoolLimit');
case 'invalid-name':
return 'Название должно быть от 1 до 40 символов.';
return t('errorInvalidName');
case 'invalid-start-date':
return 'Дата начала вне допустимого диапазона.';
return t('errorInvalidStartDate');
default:
return reason.message;
}
+24 -8
View File
@@ -1,5 +1,6 @@
import { CLOCK_SPEEDS, type ClockMessage } from '../net/protocol.ts';
import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
import { t } from '../i18n/strings.ts';
import type { School } from '../net/api.ts';
import { el } from './dom.ts';
@@ -17,14 +18,18 @@ const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
*/
export class GameScreen {
private readonly root = el('section', { class: 'screen game' });
private readonly backButton = el('button', { class: 'button', type: 'button' });
private readonly schoolName = el('h1', { class: 'screen__title' });
private readonly time = el('p', { class: 'clock__time', text: '--:--' });
private readonly date = el('p', { class: 'clock__date' });
private readonly weekday = el('p', { class: 'clock__weekday' });
private readonly emptyHint = el('p', { class: 'hint' });
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
private readonly speedButtons: HTMLButtonElement[];
private running = false;
private lastGameTime: Date | null = null;
private lastSpeedIndex = 0;
constructor(options: GameScreenOptions) {
this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
@@ -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);
+39
View File
@@ -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<Locale, string> = { 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<Locale, HTMLButtonElement>();
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;
}
+47 -24
View File
@@ -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<typeof setInterval> | 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<void> {
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<void> {
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 'школ';
}
+20 -15
View File
@@ -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;
+7 -2
View File
@@ -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<string>());
var command = new GameCommand.SuggestName(ParseNameLanguage(lang), NewCompletion<string>());
commands.Enqueue(command);
var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
@@ -85,6 +85,11 @@ internal static class SchoolEndpoints
private static TaskCompletionSource<T> NewCompletion<T>() =>
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<string, object?>
{
+3 -1
View File
@@ -1,3 +1,5 @@
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>
@@ -13,7 +15,7 @@ internal abstract record GameCommand
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
internal sealed record SuggestName(TaskCompletionSource<string> Result) : GameCommand;
internal sealed record SuggestName(SchoolNameLanguage Language, TaskCompletionSource<string> Result) : GameCommand;
/// <summary>A connection starts watching a school; its calendar starts running.</summary>
internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand;
+1 -1
View File
@@ -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:
+55 -14
View File
@@ -1,5 +1,12 @@
namespace HSchool.Simulation;
/// <summary>Which word list the random-name button draws from.</summary>
public enum SchoolNameLanguage
{
Russian = 0,
English = 1,
}
/// <summary>
/// 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 <paramref name="taken"/>. Falls back to a numbered name so the
/// button always produces something, even when the pool is exhausted.
/// </summary>
public string Next(IEnumerable<string> taken)
public string Next(IEnumerable<string> taken, SchoolNameLanguage language = SchoolNameLanguage.Russian)
{
var catalog = language == SchoolNameLanguage.English ? English : Russian;
var used = new HashSet<string>(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}";
}
+2 -1
View File
@@ -100,7 +100,8 @@ public sealed class SchoolRegistry : IDisposable
}
/// <summary>A name the player has not used yet, for the "random" button in the creation form.</summary>
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);
/// <summary>Trims, strips control characters and enforces the length limit.</summary>
public static bool TryNormalizeName(string? name, out string normalized)