Время: один пояс приложения из .env и на сервере, и на фронте

APP_TZ_OFFSET_HOURS влиял только на «дату игры», а фронт показывал время в
захардкоженных +3 — при другом значении дата и время партии противоречили друг
другу. Теперь /api/auth/config отдаёт tz_offset_hours, App.tsx выставляет его в
format.ts до первой отрисовки страниц, и в нём же показывается всё время и
вводятся даты объявлений (запасное значение +3, если конфиг недоступен). Пояс
устройства не учитывается — решение владельца. Валидатор ограничивает смещение
−12..14. #68

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
2026-09-19 03:05:30 +03:00
co-authored by Claude Opus 5
parent f7f74fee8c
commit 8d4f7a95df
8 changed files with 74 additions and 13 deletions
+19 -1
View File
@@ -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 <Spinner />;
return <>{children}</>;
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<RouterProvider router={router} />
<AppTimeZone>
<RouterProvider router={router} />
</AppTimeZone>
</ToastProvider>
</QueryClientProvider>
);
+2
View File
@@ -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: {
+17 -9
View File
@@ -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;
}
// Значение <input type="datetime-local"> — «настенное» время в том же поясе +3, что и всё
// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер.
// Значение <input type="datetime-local"> — «настенное» время в том же поясе приложения, что
// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
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();
}