diff --git a/.env.example b/.env.example
index 0e1badd..9100191 100644
--- a/.env.example
+++ b/.env.example
@@ -135,7 +135,9 @@ BACKUP_PI_SSH=pi@192.168.1.10
BACKUP_PI_DIR=~/forbidden-stars
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
-# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
+# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
+# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
+# пояс устройства игрока не учитывается.
APP_TZ_OFFSET_HOURS=3
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index b91f37e..338cb3e 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -42,8 +42,9 @@ class Settings(BaseSettings):
# от этого зависят Secure-cookie и проверка секретов при старте.
local_public: str = "local"
- # Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
- # Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
+ # Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
+ # в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
+ # на фронте — оно приходит туда в GET /api/auth/config (#68).
app_tz_offset_hours: int = 3
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
@@ -139,6 +140,15 @@ class Settings(BaseSettings):
)
return value
+ @field_validator("app_tz_offset_hours")
+ @classmethod
+ def _known_tz_offset(cls, value: int) -> int:
+ """Реальные пояса — от −12 до +14: опечатка в .env — ошибка старта, а не время,
+ сдвинутое на сутки."""
+ if not -12 <= value <= 14:
+ raise ValueError(f"APP_TZ_OFFSET_HOURS={value} вне диапазона −12..14")
+ return value
+
@model_validator(mode="after")
def _forbid_default_secrets_when_published(self) -> "Settings":
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index a9fd310..63b5bb6 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -28,6 +28,7 @@ def auth_config() -> s.AuthConfig:
return s.AuthConfig(
methods=enabled_methods(),
telegram_bot_username=settings.telegram_bot_username,
+ tz_offset_hours=settings.app_tz_offset_hours,
)
diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py
index 5b21bd9..d746302 100644
--- a/backend/app/schemas/api.py
+++ b/backend/app/schemas/api.py
@@ -22,6 +22,9 @@ class AuthConfig(BaseModel):
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
methods: list[str] = []
telegram_bot_username: str | None = None
+ # Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
+ # показывает время всем игрокам — независимо от пояса устройства (#68).
+ tz_offset_hours: int
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py
index 8c87325..7175fc4 100644
--- a/backend/tests/test_config_security.py
+++ b/backend/tests/test_config_security.py
@@ -140,3 +140,20 @@ def test_unknown_app_env_rejected(app_env):
def test_app_env_case_insensitive():
s = config.Settings(app_env="Development")
assert s.is_development
+
+
+# ─── Пояс приложения (#68) ────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("offset", [-13, 15])
+def test_tz_offset_out_of_range_rejected(offset):
+ with pytest.raises(ValidationError, match="APP_TZ_OFFSET_HOURS"):
+ config.Settings(app_env="development", app_tz_offset_hours=offset)
+
+
+def test_auth_config_exposes_app_tz_offset(client, monkeypatch):
+ """Фронт показывает время в поясе приложения — смещение приходит из настроек."""
+ from app.routers import auth
+
+ monkeypatch.setattr(auth.settings, "app_tz_offset_hours", 5)
+ assert client.get("/api/auth/config").json()["tz_offset_hours"] == 5
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ebb3606..ae88693 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,15 +1,33 @@
import { QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/router";
+import { Spinner } from "./components/Spinner";
import { ToastProvider } from "./context/ToastContext";
+import { setAppTzOffsetHours } from "./domain/format";
+import { useAuthConfig } from "./hooks/auth";
+
+/**
+ * Пояс приложения (APP_TZ_OFFSET_HOURS) приходит с сервера и нужен до первой отрисовки:
+ * функции format.ts читают его синхронно (#68). Пока конфиг грузится — спиннер; если он
+ * недоступен (нет сети), рендерим с запасным поясом, чтобы не запереть приложение.
+ */
+function AppTimeZone({ children }: { children: ReactNode }) {
+ const { data, isPending } = useAuthConfig();
+ if (data) setAppTzOffsetHours(data.tz_offset_hours);
+ if (isPending) return ;
+ return <>{children}>;
+}
export function App() {
return (
-
+
+
+
);
diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts
index 651211e..705a381 100644
--- a/frontend/src/api/schema.d.ts
+++ b/frontend/src/api/schema.d.ts
@@ -1610,6 +1610,8 @@ export interface components {
methods: string[];
/** Telegram Bot Username */
telegram_bot_username?: string | null;
+ /** Tz Offset Hours */
+ tz_offset_hours: number;
};
/** Body_add_attachment_api_matches__match_id__attachments_post */
Body_add_attachment_api_matches__match_id__attachments_post: {
diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts
index 4c4ed53..ff31e5e 100644
--- a/frontend/src/domain/format.ts
+++ b/frontend/src/domain/format.ts
@@ -1,12 +1,19 @@
-// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера.
-const APP_TZ_OFFSET_MIN = 3 * 60;
+// Время показываем в поясе приложения — одном для всех, независимо от пояса браузера.
+// Смещение задаёт APP_TZ_OFFSET_HOURS на бэкенде (в нём же считается «дата игры»);
+// App.tsx получает его из /api/auth/config до первой отрисовки страниц (#68).
+// 3 ч (МСК) — запасное значение, если конфиг недоступен.
+let appTzOffsetMin = 3 * 60;
+
+export function setAppTzOffsetHours(hours: number): void {
+ appTzOffsetMin = hours * 60;
+}
const p2 = (n: number) => String(n).padStart(2, "0");
-// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
-// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
+// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
+// и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
function shifted(iso: string): Date {
- return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
+ return new Date(new Date(iso).getTime() + appTzOffsetMin * 60_000);
}
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
@@ -50,18 +57,19 @@ export function formatDate(iso: string | null | undefined): string {
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
}
-// Значение — «настенное» время в том же поясе +3, что и всё
-// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер.
+// Значение — «настенное» время в том же поясе приложения, что
+// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
export function toAppLocalInput(iso: string): string {
const d = shifted(iso);
return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
}
-// Обратно: «настенное» время +3 из datetime-local → момент ISO (UTC). null — поле пустое.
+// Обратно: «настенное» время пояса приложения из datetime-local → момент ISO (UTC).
+// null — поле пустое.
export function fromAppLocalInput(value: string): string | null {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
if (!m) return null;
- const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - APP_TZ_OFFSET_MIN * 60_000;
+ const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - appTzOffsetMin * 60_000;
return new Date(ms).toISOString();
}