Internationalisation and time zones
Who is this for
Section titled “Who is this for”Developers who add a language or a text, and anyone preparing an event for an audience that does not read Russian.
- Landing: three languages — Russian, Uzbek (Latin script), English — under
/ru,/uz,/en. There is no page without a language prefix. - A visitor’s browser language never selects Uzbek.
/uzis reached only through the language switch or a direct link. Whether/uzis public at all is one flag,uzPublished. - Product (bot, kiosk, admin panel, lead cards, alerts): Russian only. The kiosk has a ready hook for more
languages (
?lang=), but only therutext set exists. - The landing’s mock-ups show the bot and kiosk in the page’s language. That is a picture of a localised product, not the product as it is today.
- Time zones: the database stores UTC. Lead cards, reminders and the digest use Tashkent time (fixed UTC+5). The daily image quota is counted in Pacific-time days.
Landing
Section titled “Landing”Locales and routing
Section titled “Locales and routing”Defined in
src/i18n/config.ts and
src/proxy.ts.
- Locales:
ru,uz,en, in the order the switch shows them. Default:ru. - Every page lives under a locale segment, so
<html lang>is always a real value. - The proxy handles every request path that has no locale prefix, except
_next,apiand paths containing a dot (static files). It redirects to/<locale><path>.
flowchart TD
R["Request without a locale prefix"] --> CK{"Cookie NEXT_LOCALE is a published locale?"}
CK -- yes --> RC["Redirect to the cookie's locale"]
CK -- no --> AL{"Accept-Language header"}
AL -- "missing, empty or a wildcard" --> RU["Redirect to /ru"]
AL -- "first match by quality is a CIS language" --> RU
AL -- "first match by quality is English" --> EN["Redirect to /en"]
AL -- "only other languages" --> EN
Rules of localeFromAcceptLanguage:
| Header says | Result |
|---|---|
Nothing, empty, or * |
ru |
A language from the list ru, uz, uk, be, kk, ky, tg, tk, az, hy ranks first |
ru |
| English ranks first | en |
| Only other languages | en |
Uzbek, in any form (uz, uz-UZ, uz-Latn-UZ, uz-Cyrl) |
ru — never uz |
Quality values are honoured: en;q=0.3, kk;q=0.9 resolves to ru. Only the primary subtag is compared.
The cookie NEXT_LOCALE is written by the language switch
(LocaleSwitch.tsx),
lasts one year and stores only the language code. A cookie naming an unpublished language is ignored by the proxy.
The switch replaces the first path segment, keeps the scroll position and the URL fragment, and cross-fades with a
view transition where the browser supports it (not under reduced motion).
The uzPublished flag
Section titled “The uzPublished flag”SITE.locales.uzPublished in
src/content/site.ts is
true at the verified commit.
uzPublished = true |
uzPublished = false |
|
|---|---|---|
/uz opens by direct link |
Yes | Yes (for the proofreader) |
UZ in the language switch |
Yes | Only while the visitor is on /uz |
hreflang alternates |
ru, uz, en, x-default → /en |
ru, en, x-default → /en |
| Sitemap entry | Yes | No |
Robots meta on /uz |
Indexable | noindex, nofollow |
NEXT_LOCALE=uz cookie honoured on / |
Yes | No |
The comment next to the flag states that a native-speaker proofread of the Uzbek dictionary is still pending, and the dictionary file carries notes to that effect. Published does not mean proofread.
Dictionaries
Section titled “Dictionaries”- Files:
en.ts,ru.ts,uz.ts. - The English dictionary defines the TypeScript type
Dictionary; the other two must satisfy it (src/i18n/index.ts). A missing key is a compile error. - The repository rule is: no text hard-coded in components. Names and links that are the same in every language
live in
src/content/site.ts. - Each language is written as an original, not as a translation of another.
- Form validation returns error codes; each dictionary maps a code to its own sentence (
form.errors). The server uses the same codes. - Client components receive only the dictionary slice they need.
- Open Graph locale per language:
ru_RU,uz_UZ,en_US.
Form of address
Section titled “Form of address”| Part of the page | Russian | Uzbek | English |
|---|---|---|---|
| The page itself (hero, sections, FAQ, form) — speaks to the organiser | Formal «вы» | «siz» | “you” |
| Mock-ups of the bot and kiosk — the product speaks to a guest | Informal «ты», as the real product does | «siz» | — |
Typographic rules for Uzbek
Section titled “Typographic rules for Uzbek”Uzbek Latin uses two different apostrophe-like letters. The dictionary must use the right code point for each.
| Letter | Correct character | Wrong |
|---|---|---|
oʻ, gʻ |
U+02BB (modifier letter turned comma) | ASCII ', typographic ‘ ’, backtick, U+02BC |
| Tutuq belgisi (glottal stop mark) | U+02BC (modifier letter apostrophe) | U+02BB anywhere except after o or g |
One more rule applies to all three languages: a dash is never preceded by a breakable space. The space before —
must be a non-breaking space, so a dash cannot start a line.
Layout consequence recorded in the landing’s CLAUDE.md: Uzbek is the longest language overall and sets the width of
the hero column, the header slots and the call-to-action; the Russian headline is the longest headline and sets the
headline’s maximum font size.
src/i18n/dictionaries.test.ts
runs under npm test:
| Test | Guarantees |
|---|---|
Same keys and array lengths in ru, uz, en |
No language is missing a string |
| No empty string | — |
| Forbidden words | No promise the product cannot keep and no name that must not appear on the page |
| Numbers | The only numbers in the copy are 12 (stickers), 72 (hours) and 3D; a sample phone number in the form is the single exception |
| Uzbek apostrophes | The code-point rules above |
| Dash binding | No breakable space before a dash |
| Form of address | The table above, per key group |
Accept-Language |
The routing table above, including “never uz” |
uzPublished both ways |
Switch, hreflang and sitemap follow the flag |
npm run audit checks the rendered pages for all three languages: headline and subtitle line counts at four widths,
header geometry that must not shift between languages, and the language switch keeping the scroll position.
Product
Section titled “Product”Languages in use
Section titled “Languages in use”| Surface | Language | Where the text lives |
|---|---|---|
| Bot messages to guests | Russian, informal | bot/texts.py, plus a few strings inline in handlers/start.py and handlers/consent.py |
| Kiosk screens | Russian | TEXTS.ru in bot/kiosk/kiosk.js |
| Outfit card labels | Russian | bot/catalog.py |
| API error details | Russian (for example сессия не готова к фото — “session is not ready for a photo”) |
bot/api.py |
| Sticker pack title | Russian: AI стикер пак @<username> |
services/telegram_stickers.pack_title |
| Lead cards, status labels, buttons, command replies, reminders, digest | Russian | services/leads.py, services/leads_schedule.py, handlers/leads.py |
| Fallback lead message from the landing | Russian, whatever the page language was | Landing src/lib/requestMessage.ts |
| Admin alerts and admin commands | Russian | services/alerts.py, worker.py, delivery.py |
| Admin panel | Russian | bot/admin/index.html |
| Log lines | English markers, Russian text | Everywhere |
| Prompts sent to the AI provider | English | services/ai_pipeline.py, services/appearance.py, services/consistency.py |
The bot does not read the guest’s Telegram language. A lead card shows the language of the page the request came
from (ru, uz or en) as data, but the card itself is always Russian.
The kiosk’s ?lang= hook
Section titled “The kiosk’s ?lang= hook”kiosk.js keeps all texts in one object keyed by language code and picks the set from the query string:
?lang=<code>selectsTEXTS[<code>]if that key exists; otherwiseru.- Elements carry
data-t/data-t-altattributes and are filled byapplyTexts(), which also sets<html lang>. - Only
ruis defined.?lang=uztherefore shows Russian today.
To add a kiosk language: add one more key with the same fields to TEXTS and open the kiosk with ?lang=<code>. The
markup does not change. Two things stay Russian even then, because they come from the server: outfit card labels
(GET /templates) and the maintenance message in the API’s 503 body (the kiosk shows its own maintenance text,
so the second one is not visible to guests).
What a second product language would need
Section titled “What a second product language would need”Derived from the table above; none of this exists in code:
- A language choice for the bot (per guest or per event) and a second set in
texts.py, including the inline strings in the handlers. - A
TEXTSset for the kiosk and translated outfit labels in the catalogue. - A decision about the pack title, which is currently a fixed Russian phrase.
The landing’s CLAUDE.md lists product localisation as a known risk to resolve before the first event for a
non-Russian-speaking audience.
Time zones
Section titled “Time zones”| What | Time zone | Where |
|---|---|---|
All timestamps in SQLite (created_at, status_at, ts, …) |
UTC, ISO 8601 with offset | db.now_iso |
Lead card time, status history, CSV export, /stickers_stats header |
Tashkent, fixed UTC+5, no daylight saving | services/leads.TASHKENT, tashkent() |
“Today” in /stickers_stats |
Tashkent calendar day | services/leads.periods |
| Reminder window (Mon–Fri 09:00–21:00) and digest (10:00, catch-up to 12:00) | Tashkent | services/leads_schedule |
| “Spent today” in the admin panel | Local day by DISPLAY_UTC_OFFSET_HOURS (default 5) |
services/admin_data.local_day_start_utc |
| Daily image-request counter and its warning | Pacific time, because the provider resets its daily quota at Pacific midnight. Daylight saving is computed in code from the US rule, without a time-zone database | services/usage.pacific_day_start_utc |
Days in tools/cost_report.py |
Pacific time | Same function |
| Database backup timer | 03:30 UTC | deploy/systemd/sticker-backup.timer |
| Backup log lines | UTC | deploy/backup.sh |
| Application log timestamps | Server local time | Python logging default |
Landing submitted_at and the fallback message’s time line |
UTC (toISOString) |
Landing deliverRequest.ts, requestMessage.ts |
Two practical consequences:
- A lead submitted at 23:30 Tashkent time belongs to “today” in
/stickers_statsand to the next morning’s digest window (10:00 to 10:00). - The image quota day does not line up with the event day. In Tashkent the Pacific day changes in the early afternoon, at 12:00 or 13:00 depending on US daylight saving (UTC+5 minus UTC−7 or UTC−8).
The Tashkent offset is hard-coded for leads and configurable only for the admin panel. An event in another time zone would need a code change for the leads schedule.
Where to go next
Section titled “Where to go next”- i18n and copy (developer guide) — how to edit landing texts safely.
- Landing component — where the dictionaries are used.
- Kiosk component — screens and query parameters.
- Reminders and digest — the schedule that depends on Tashkent time.
- Known gaps — product localisation and the pending Uzbek proofread.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.