Skip to content

Internationalisation and time zones

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. /uz is reached only through the language switch or a direct link. Whether /uz is 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 the ru text 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.

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, api and 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).

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.

  • 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.
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» —

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.

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.

kiosk.js keeps all texts in one object keyed by language code and picks the set from the query string:

  • ?lang=<code> selects TEXTS[<code>] if that key exists; otherwise ru.
  • Elements carry data-t / data-t-alt attributes and are filled by applyTexts(), which also sets <html lang>.
  • Only ru is defined. ?lang=uz therefore 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).

Derived from the table above; none of this exists in code:

  1. 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.
  2. A TEXTS set for the kiosk and translated outfit labels in the catalogue.
  3. 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.

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_stats and 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.


Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.