diff --git a/docs/tasks/TASK_datetime-utc-consistency.md b/docs/tasks/TASK_datetime-utc-consistency.md
new file mode 100644
index 0000000..f52103f
--- /dev/null
+++ b/docs/tasks/TASK_datetime-utc-consistency.md
@@ -0,0 +1,198 @@
+# Задача: единообразие времени (UTC) по всей группе сервисов `pcp`
+
+> Документ-ТЗ для кодинг-агента. Раздел 0 — промпт для запуска (скопировать в агента).
+> Разделы 1–9 — инструкция, которую агент читает в репозитории и которой следует.
+> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`.
+
+---
+
+## 0. Промпт для запуска агента (copy-paste)
+
+```
+Ты работаешь в монорепозитории observatio-terrae (pcp).
+
+Задача: провести аудит и привести к единообразию работу со временем (датами) по всей
+группе сервисов pcp. Целевое соглашение: ВСЁ ВРЕМЯ — UTC, представление на API не зависит
+от часового пояса машины-клиента или сервера.
+
+Перед началом обязательно прочитай:
+1. /AGENTS.md — правила работы, стиль, формат финального ответа.
+2. /docs/tasks/TASK_datetime-utc-consistency.md — это ТЗ (данный файл).
+3. Эталонную реализацию на фронте: services/pcp-tgu-ops-ui/src/utils/utcTime.ts
+ и историю правок в pcp-tgu-ops-ui (вкладки «Планы», «Редактор», «Заявки», «Карта 2D»).
+
+Это в первую очередь АУДИТ + точечные правки, а не массовый рефактор. Сначала инвентаризация
+(раздел 4), затем фиксы по чек-листу (раздел 5) с приоритетом на места, где сдвиг времени
+виден пользователю или уходит на бэкенд при записи. Backend (Kotlin) трогать только там, где
+сериализация реально неоднозначна (раздел 6) — сериализационные правки сопровождать тестом.
+
+Не ломай чужие контракты: если меняешь формат на проводе — обнови потребителей и тесты.
+После каждого модуля прогоняй его тесты/сборку. В конце дай отчёт по формату из AGENTS.md
+плюс заполненную таблицу инвентаризации (раздел 4) и список расхождений.
+```
+
+---
+
+## 1. Контекст и зачем это
+
+Время в системе хранится и передаётся как **UTC**, но представление на границах API
+неоднородно: часть сервисов отдаёт naive `LocalDateTime` (ISO без зоны, напр.
+`"2026-05-29T01:00:00"`, по соглашению — UTC), часть — `OffsetDateTime`/`Instant`
+(ISO с зоной, напр. `"2026-05-29T01:00:00Z"`). Эта разница уже привела к багу во фронте:
+
+- `pcp-tgu-service` отдаёт планы как naive `LocalDateTime`.
+- Фронт парсил такие строки через `new Date(s)` / `Date.parse(s)`, а JS трактует строку
+ **без зоны как локальное время браузера** → сдвиг на офсет (для МСК — +3 ч).
+- Одни вкладки показывали через `toLocaleString` (пояс браузера), другие — в UTC →
+ одно и то же время выглядело по-разному на разных экранах и у разных операторов.
+- В пути **записи** заявки оператор, вводя `01:00`, отправлял на бэк `22:00`.
+
+Корень — не «UTC против местного», а **несогласованность парсинга/форматирования** и
+**неоднородность формата на проводе между сервисами**. Эта задача делает всё единообразным.
+
+## 2. Целевое соглашение (canonical)
+
+1. **Всё время — UTC.** На любом API времена представляют момент в UTC.
+2. **Формат на проводе — ISO-8601 с явной зоной `Z`** (предпочтительно). Где сервис
+ исторически отдаёт naive `LocalDateTime`, это допустимо **только** при железном правиле
+ «naive = UTC», но такой эндпоинт — кандидат на миграцию к `Z` (раздел 6).
+3. **Фронт:**
+ - парсинг строк бэка — только через `src/utils/utcTime.ts` (`parseUtc`); никаких
+ «голых» `new Date(string)` / `Date.parse(string)`;
+ - отображение — только UTC (`formatUtcDateTime/Date/Time`, либо `toLocale*` с
+ `timeZone: "UTC"`); никакого `toLocaleString` без `timeZone`;
+ - `` — значение через `toUtcInputValue`, чтение обратно
+ через `parseUtc`; отправка на offset-бэк — через `toUtcIso`.
+4. **Никакой неявной локализации** под пояс машины ни на фронте, ни в логах/ответах бэка.
+
+## 3. Эталон (уже сделано — переиспользовать, не переделывать)
+
+В `pcp-tgu-ops-ui` это соглашение уже внедрено и может служить образцом:
+
+- `src/utils/utcTime.ts` — `parseUtc`, `formatUtcDateTime/Date/Time`, `toUtcInputValue`,
+ `toUtcIso`. **Единая точка** парсинга/форматирования.
+- Переведены вкладки «Планы», «Редактор», «Заявки», «Карта 2D» (источник `startMs/endMs`
+ в `tguTimelineMapper`, пикеры диапазона, детали заявок, путь создания заявки).
+- Нюанс: `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) — `parseUtc` их тоже
+ корректно обрабатывает (зона есть → не добавляет `Z`), а запись идёт через `toUtcIso`.
+
+Любой новый код во фронте обязан следовать этому слою.
+
+## 4. Инвентаризация (заполнить в отчёте)
+
+Стартовый срез (тип `LocalDateTime` vs `OffsetDateTime/ZonedDateTime/Instant` в файлах
+`dto|controller|api|response|model`); агент должен уточнить и довести до пер-эндпоинтной
+картины «что именно отдаётся наружу»:
+
+| Сервис | Преобладает на границе API | Действие |
+|---|---|---|
+| pcp-tgu-service | naive `LocalDateTime` | задокументировать «=UTC», кандидат на `Z` |
+| pcp-request-service | `OffsetDateTime` (`Z`) | эталон, проверить полноту |
+| pcp-dynamic-plan-service | naive `LocalDateTime` (+1 offset-файл) | проверить смешение |
+| pcp-complex-mission-service | naive `LocalDateTime` (+2 offset) | проверить смешение |
+| pcp-ui-service | naive `LocalDateTime` (+1 offset) | проверить (агрегатор/BFF?) |
+| slots-service | naive `LocalDateTime` | задокументировать «=UTC» |
+| pcp-coverage-scheme-service | naive `LocalDateTime` | задокументировать «=UTC» |
+| pcp-ballistics-service | naive `LocalDateTime` | задокументировать «=UTC» |
+| pcp-mission-planing-service | naive `LocalDateTime` | задокументировать «=UTC» |
+| pcp-route-processing-service | нет времени на границе (проверить) | — |
+| pcp-satellite-catalog-service | нет (проверить) | — |
+| pcp-stations-service | нет (проверить) | — |
+| tle-monitoring-service | нет (проверить) | — |
+
+Колонки итоговой таблицы: **сервис · эндпоинт/поле · тип · формат на проводе (пример) ·
+naive? · потребители · вердикт (ok / задокументировать / мигрировать на `Z`)**.
+
+Полезно: во всех проверенных сервисах Jackson настроен с `JavaTimeModule` и
+`disable(WRITE_DATES_AS_TIMESTAMPS)` → времена сериализуются как ISO-строки (не числа),
+поэтому `LocalDateTime` уходит без зоны, `OffsetDateTime`/`Instant` — с `Z`/офсетом.
+
+## 5. Фронт: инвариант и фиксы (`pcp-tgu-ops-ui`)
+
+Эталон внедрён, но нужно **закрепить как инвариант** и проверить, что ничего не утекло:
+
+1. Найти нарушителей (раздел 7, паттерн A). Любой «голый» разбор/формат строки времени
+ бэка чинить через `utils/utcTime`.
+2. Добавить **защиту от регрессий** — на выбор:
+ - юнит-тест-«страж», который грепает `src/` и падает на запрещённых паттернах
+ (`new Date()`, `Date.parse(`, `toLocaleString(` без `timeZone`,
+ `formatDateTimeLocal`); **или**
+ - ESLint-правило (`no-restricted-syntax`) с тем же смыслом.
+3. Прогнать `npm run test` и `npm run build` в модуле.
+
+## 6. Backend: когда и что править
+
+По умолчанию — **не менять формат на проводе** (это контракт). Действия:
+
+1. **Документировать инвариант.** Для сервисов с naive `LocalDateTime`: явно зафиксировать
+ «все времена на API — UTC» (KDoc у DTO + запись в `docs/standards`, раздел 8). Это дёшево
+ и безопасно, и снимает неоднозначность для будущих клиентов.
+2. **Чинить реальные баги.** Если найдётся место, где сервис:
+ - кладёт в naive-поле **локальное** время сервера (`LocalDateTime.now()` вместо
+ `LocalDateTime.now(ZoneOffset.UTC)` / `Instant.now()`), или
+ - конвертирует под зону JVM при чтении/записи —
+ это **баг**: исправить на UTC, добавить тест.
+3. **Миграция к явному `Z` (опционально, по согласованию).** Перевод naive `LocalDateTime`
+ на `OffsetDateTime`/`Instant` (UTC) на границе — самый чистый итог, но это смена контракта:
+ делать только с обновлением потребителей (на фронте `parseUtc` уже совместим с обоими) и
+ сериализационным тестом. Оформить как отдельные under-задачи per-service, не «большим взрывом».
+4. **Тест формата.** Для каждого сервиса, отдающего время, иметь тест, фиксирующий формат на
+ проводе (есть `Z` / нет зоны) — чтобы контракт не «уехал» молча.
+
+## 7. Как искать нарушителей (детект-паттерны)
+
+**A. Фронт (`services/pcp-tgu-ops-ui/src`), запрещено вне `utils/utcTime.ts`:**
+
+```bash
+# разбор строки как локального времени
+grep -rn "new Date([a-zA-Z].*)\.getTime\|Date\.parse(" src --include=*.ts --include=*.tsx | grep -v utcTime
+# показ в поясе браузера
+grep -rn "toLocaleString\|toLocaleDateString\|toLocaleTimeString" src --include=*.ts --include=*.tsx | grep -v 'timeZone: "UTC"'
+# локальные хелперы пикеров
+grep -rn "formatDateTimeLocal" src
+# локальные компоненты даты
+grep -rn "getFullYear()\|getMonth()\|getDate()\|getHours()\|getMinutes()" src --include=*.ts --include=*.tsx | grep -v getUTC
+```
+
+**B. Backend (Kotlin) — потенциально локальное «сейчас» в UTC-полях:**
+
+```bash
+# now() без UTC-зоны
+grep -rn "LocalDateTime\.now()\|LocalDate\.now()\|LocalTime\.now()" services/*/src/main
+# конвертации под зону системы
+grep -rn "atZone(\|ZoneId\.systemDefault\|toLocalDateTime()\|systemDefault()" services/*/src/main
+# типы времени на границе (для инвентаризации)
+grep -rn "LocalDateTime\|OffsetDateTime\|ZonedDateTime\|Instant" services/*/src/main | grep -iE "dto|controller|response"
+```
+
+Каждое попадание классифицировать: **ок / задокументировать / починить / мигрировать**.
+
+## 8. Зафиксировать стандарт
+
+Создать `docs/standards/DATETIME_UTC.md` (на него ссылаться из KDoc/комментариев):
+
+- соглашение из раздела 2 (всё UTC; формат на проводе; правила фронта);
+- таблица состояния сервисов (итог раздела 4);
+- правило для нового кода: фронт — только `utils/utcTime`; бэк — UTC-`now`,
+ предпочтительно `Z` на новых API.
+
+При наличии общего чек-листа в `docs/standards/CHECKLIST.md` — добавить пункт
+«Время на API — UTC; фронт парсит/форматирует через utcTime».
+
+## 9. Definition of Done
+
+- [ ] Заполнена пер-эндпоинтная таблица инвентаризации (раздел 4) по всем 13 сервисам.
+- [ ] Фронт: нарушителей по паттернам раздела 7A не осталось; добавлена защита от регрессий
+ (тест-страж или ESLint-правило); `npm run test` и `npm run build` зелёные.
+- [ ] Backend: задокументирован инвариант «API = UTC» у сервисов с naive `LocalDateTime`;
+ найденные реальные баги локального `now`/конвертаций исправлены с тестами.
+- [ ] Создан `docs/standards/DATETIME_UTC.md`; обновлён `CHECKLIST.md` (если есть).
+- [ ] Список расхождений и предложенные under-задачи на миграцию к `Z` (если такие нужны)
+ приведены в финальном отчёте (формат — `AGENTS.md`).
+
+### Не делать
+
+- Массовую смену формата на проводе без обновления потребителей и тестов.
+- Изменение семантики хранения в БД (там уже UTC) — только представление/парсинг.
+- Локализацию под пояс машины «чтобы было привычнее» — это и есть источник проблемы.
+```
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx
index 706c160..f10280b 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx
@@ -1,5 +1,6 @@
import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "./model/mapTypes";
+import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
@@ -95,18 +96,18 @@ export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, on
}
function formatDateTime(value: string): string {
- return new Date(value).toLocaleString("ru-RU");
+ return formatUtcDateTime(parseUtc(value));
}
function formatDateRange(begin: string, end: string): string {
- const b = new Date(begin);
- const e = new Date(end);
+ const b = new Date(parseUtc(begin));
+ const e = new Date(parseUtc(end));
const sameDay =
- b.getFullYear() === e.getFullYear() &&
- b.getMonth() === e.getMonth() &&
- b.getDate() === e.getDate();
- const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" };
- const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" };
+ b.getUTCFullYear() === e.getUTCFullYear() &&
+ b.getUTCMonth() === e.getUTCMonth() &&
+ b.getUTCDate() === e.getUTCDate();
+ const dateOpts: Intl.DateTimeFormatOptions = { timeZone: "UTC", day: "2-digit", month: "2-digit" };
+ const timeOpts: Intl.DateTimeFormatOptions = { timeZone: "UTC", hour: "2-digit", minute: "2-digit" };
const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`;
const endStr = sameDay
? b.toLocaleTimeString("ru-RU", timeOpts)
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx
index 0ae04cc..ecf26e4 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx
@@ -3,6 +3,7 @@ import { filterPlatforms, platformLabel } from "../tgu-planning/tguTimelineMappe
import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapLayerAvailability } from "./Tgu2DMapLayers";
import type { RequestsState } from "./Tgu2DMapTab";
+import { parseUtc } from "../../utils/utcTime";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -46,7 +47,7 @@ export function Tgu2DMapSidebar({
const quickToday = () => {
const now = new Date();
- const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
+ const fromMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
onRequestsQuickRange(fromMs, fromMs + DAY_MS);
};
@@ -60,8 +61,8 @@ export function Tgu2DMapSidebar({
onRequestsQuickRange(fromMs, fromMs + 30 * DAY_MS);
};
- const requestsFromMs = new Date(requestsState.fromValue).getTime();
- const requestsToMs = new Date(requestsState.toValue).getTime();
+ const requestsFromMs = parseUtc(requestsState.fromValue);
+ const requestsToMs = parseUtc(requestsState.toValue);
const requestsRangeInvalid =
!Number.isFinite(requestsFromMs) ||
!Number.isFinite(requestsToMs) ||
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx
index fb6e699..b9b5637 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx
@@ -15,6 +15,7 @@ import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
+import { parseUtc, toUtcInputValue } from "../../utils/utcTime";
type Tgu2DMapTabProps = {
range: TimelineRange;
@@ -37,8 +38,8 @@ export type RequestsState = {
function buildInitialRequestsState(range: TimelineRange): RequestsState {
return {
- fromValue: formatDateTimeLocal(range.fromMs),
- toValue: formatDateTimeLocal(range.toMs),
+ fromValue: toUtcInputValue(range.fromMs),
+ toValue: toUtcInputValue(range.toMs),
items: [],
loading: false,
loadedCount: 0,
@@ -77,8 +78,8 @@ export function Tgu2DMapTab({
const mapInvalidRange = invalidRange || mapInterval.tooLarge;
const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
- const fromMs = new Date(fromValue).getTime();
- const toMs = new Date(toValue).getTime();
+ const fromMs = parseUtc(fromValue);
+ const toMs = parseUtc(toValue);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
@@ -123,8 +124,8 @@ export function Tgu2DMapTab({
}, []);
const setRequestsQuickRange = useCallback((fromMs: number, toMs: number) => {
- const fromValue = formatDateTimeLocal(fromMs);
- const toValue = formatDateTimeLocal(toMs);
+ const fromValue = toUtcInputValue(fromMs);
+ const toValue = toUtcInputValue(toMs);
setRequestsState((current) => ({ ...current, fromValue, toValue }));
void loadRequests(fromValue, toValue);
}, [loadRequests]);
@@ -296,6 +297,7 @@ export function Tgu2DMapTab({
function formatDate(ms: number): string {
return new Date(ms).toLocaleString("ru-RU", {
+ timeZone: "UTC",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
@@ -303,16 +305,6 @@ function formatDate(ms: number): string {
});
}
-function formatDateTimeLocal(ms: number): string {
- const date = new Date(ms);
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, "0");
- const day = String(date.getDate()).padStart(2, "0");
- const hours = String(date.getHours()).padStart(2, "0");
- const minutes = String(date.getMinutes()).padStart(2, "0");
- return `${year}-${month}-${day}T${hours}:${minutes}`;
-}
-
function layerLabel(layer: Tgu2DMapLayerKey): string {
switch (layer) {
case "tracks":
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx
index 7712ec7..78f23e3 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx
@@ -2,6 +2,7 @@ import type { TguPlanDecision } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
+import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
type TguPlanDetailsProps = {
plan?: TguPlanUi;
@@ -90,7 +91,7 @@ function Detail({ label, value, mono }: { label: string; value?: string; mono?:
}
function formatDateTime(value: string): string {
- return new Date(value).toLocaleString("ru-RU");
+ return formatUtcDateTime(parseUtc(value));
}
function formatDuration(ms: number): string {
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx
index 456067d..7a95b6f 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx
@@ -17,13 +17,15 @@ import {
mapPlans,
operationalSpacecraftIds
} from "./tguTimelineMapper";
+import { parseUtc, toUtcInputValue } from "../../utils/utcTime";
const DAY_MS = 24 * 60 * 60 * 1000;
+const MINUTE_MS = 60 * 1000;
export function TguPlanningPage() {
const initialRange = useMemo(defaultRange, []);
- const [fromValue, setFromValue] = useState(formatDateTimeLocal(initialRange.fromMs));
- const [toValue, setToValue] = useState(formatDateTimeLocal(initialRange.toMs));
+ const [fromValue, setFromValue] = useState(toUtcInputValue(initialRange.fromMs));
+ const [toValue, setToValue] = useState(toUtcInputValue(initialRange.toMs));
const [appliedRange, setAppliedRange] = useState(initialRange);
const [rawPlatforms, setRawPlatforms] = useState([]);
const [plans, setPlans] = useState([]);
@@ -45,8 +47,8 @@ export function TguPlanningPage() {
const parsedInputRange = useMemo(
() => ({
- fromMs: new Date(fromValue).getTime(),
- toMs: new Date(toValue).getTime()
+ fromMs: parseUtc(fromValue),
+ toMs: parseUtc(toValue)
}),
[fromValue, toValue]
);
@@ -133,8 +135,8 @@ export function TguPlanningPage() {
const quickRange = (hours: number) => {
const fromMs = startOfCurrentDayMs();
const toMs = fromMs + hours * 60 * 60 * 1000;
- setFromValue(formatDateTimeLocal(fromMs));
- setToValue(formatDateTimeLocal(toMs));
+ setFromValue(toUtcInputValue(fromMs));
+ setToValue(toUtcInputValue(toMs));
setAppliedRange({ fromMs, toMs });
setPageError(undefined);
setDecisionNotice(undefined);
@@ -253,23 +255,13 @@ export function TguPlanningPage() {
}
function defaultRange(): TimelineRange {
- const fromMs = startOfCurrentDayMs();
- return { fromMs, toMs: fromMs + DAY_MS };
+ const now = Date.now();
+ return { fromMs: now - 30 * MINUTE_MS, toMs: now + 3 * DAY_MS };
}
function startOfCurrentDayMs(): number {
const now = new Date();
- return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
-}
-
-function formatDateTimeLocal(ms: number): string {
- const date = new Date(ms);
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, "0");
- const day = String(date.getDate()).padStart(2, "0");
- const hours = String(date.getHours()).padStart(2, "0");
- const minutes = String(date.getMinutes()).padStart(2, "0");
- return `${year}-${month}-${day}T${hours}:${minutes}`;
+ return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
}
function historyDaysForRange(range: TimelineRange): number {
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx
index 1f4271b..495e614 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx
@@ -13,6 +13,7 @@ import {
} from "./tguTimelineLayout";
import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
+import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
type TguTimelineProps = {
rows: TimelineRow[];
@@ -193,13 +194,13 @@ function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePl
}
function formatTickDate(ms: number): string {
- return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
+ return formatUtcDate(ms);
}
function formatTickTime(ms: number): string {
- return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
+ return formatUtcTime(ms);
}
function formatDateTime(value: string): string {
- return new Date(value).toLocaleString("ru-RU");
+ return formatUtcDateTime(parseUtc(value));
}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts
index f8e7da6..1839f85 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts
@@ -1,5 +1,6 @@
import type { TguPlan, TguPlatform } from "../../model/tguTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes";
+import { parseUtc } from "../../utils/utcTime";
const PROBLEM_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS"]);
@@ -26,8 +27,8 @@ export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[
return plans
.map((plan) => {
- const startMs = new Date(plan.startTime).getTime();
- const endMs = new Date(plan.endTime).getTime();
+ const startMs = parseUtc(plan.startTime);
+ const endMs = parseUtc(plan.endTime);
const platform = platformByNorad.get(plan.spacecraftId);
return {
...plan,
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx
index d1957d6..3945378 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx
@@ -6,6 +6,7 @@ import type {
RsaParams,
RsaResultType
} from "../../model/requestTypes";
+import { parseUtc } from "../../utils/utcTime";
export type RequestFormSubmit = {
name: string;
@@ -85,8 +86,8 @@ export function RequestCreatePanel({
const [rsaResolution, setRsaResolution] = useState("1");
const importanceNum = Number(importance);
- const beginMs = new Date(begin).getTime();
- const endMs = new Date(end).getTime();
+ const beginMs = parseUtc(begin);
+ const endMs = parseUtc(end);
const kppList = parseKpp(kpp);
const kppValid = kppList !== null;
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx
index d50252a..93d2298 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
+import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
type Props = {
requests: RequestAtPoint[];
@@ -154,7 +155,7 @@ export function RequestDetailsPanel({
}
function formatDateTime(value: string): string {
- return new Date(value).toLocaleString("ru-RU");
+ return formatUtcDateTime(parseUtc(value));
}
function surveyTypeLabel(surveyType: string): string {
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx
index 62b15de..2656678 100644
--- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx
@@ -9,6 +9,7 @@ import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes";
import { generateUuid } from "../../utils/uuid";
+import { parseUtc, toUtcInputValue, toUtcIso } from "../../utils/utcTime";
import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel";
import { RequestDetailsPanel } from "./RequestDetailsPanel";
import { polygonToWkt } from "./requestGeometry";
@@ -35,8 +36,8 @@ type PanelMode = "none" | "details" | "create";
function buildInitialLoadState(range: TimelineRange): RequestsLoadState {
return {
- fromValue: formatDateTimeLocal(range.fromMs),
- toValue: formatDateTimeLocal(range.toMs),
+ fromValue: toUtcInputValue(range.fromMs),
+ toValue: toUtcInputValue(range.toMs),
items: [],
loading: false,
loadedCount: 0,
@@ -73,8 +74,8 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
}, []);
const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
- const fromMs = new Date(fromValue).getTime();
- const toMs = new Date(toValue).getTime();
+ const fromMs = parseUtc(fromValue);
+ const toMs = parseUtc(toValue);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
@@ -202,8 +203,8 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
name: form.name,
geometry,
importance: form.importance,
- beginDateTime: new Date(form.beginDateTime).toISOString(),
- endDateTime: new Date(form.endDateTime).toISOString(),
+ beginDateTime: toUtcIso(form.beginDateTime),
+ endDateTime: toUtcIso(form.endDateTime),
kpp: form.kpp,
highPriorityTransmit: form.highPriorityTransmit,
optics: form.optics,
@@ -242,8 +243,8 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
[closeAll, loadRequests, requestsState.fromValue, requestsState.toValue]
);
- const fromMs = new Date(requestsState.fromValue).getTime();
- const toMs = new Date(requestsState.toValue).getTime();
+ const fromMs = parseUtc(requestsState.fromValue);
+ const toMs = parseUtc(requestsState.toValue);
const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs;
const hasPanel = panelMode !== "none";
@@ -409,13 +410,3 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
);
}
-
-function formatDateTimeLocal(ms: number): string {
- const date = new Date(ms);
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, "0");
- const day = String(date.getDate()).padStart(2, "0");
- const hours = String(date.getHours()).padStart(2, "0");
- const minutes = String(date.getMinutes()).padStart(2, "0");
- return `${year}-${month}-${day}T${hours}:${minutes}`;
-}
diff --git a/services/pcp-tgu-ops-ui/src/utils/utcTime.ts b/services/pcp-tgu-ops-ui/src/utils/utcTime.ts
new file mode 100644
index 0000000..117c03e
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/utils/utcTime.ts
@@ -0,0 +1,51 @@
+/**
+ * Единый разбор и форматирование времени в UTC.
+ *
+ * Бэкенд (Kotlin `LocalDateTime`) отдаёт время как ISO-строку без зоны
+ * (например `"2026-05-29T01:00:00"`) и по соглашению системы это UTC.
+ * Конструктор `Date`/`Date.parse` трактует такую строку как локальное время
+ * браузера, поэтому naive-строки нужно парсить явно как UTC, а отображать —
+ * с `timeZone: "UTC"`. Весь интерфейс ТГУ работает в UTC.
+ */
+
+/** Разбирает ISO-строку как UTC: если зоны нет — добавляет `Z`. */
+export function parseUtc(value: string): number {
+ const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(value);
+ return Date.parse(hasZone ? value : `${value}Z`);
+}
+
+/** Дата-время в UTC, формат ru-RU. */
+export function formatUtcDateTime(ms: number): string {
+ return new Date(ms).toLocaleString("ru-RU", {
+ timeZone: "UTC",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+}
+
+/** Дата (дд.мм) в UTC. */
+export function formatUtcDate(ms: number): string {
+ return new Date(ms).toLocaleDateString("ru-RU", { timeZone: "UTC", day: "2-digit", month: "2-digit" });
+}
+
+/** Время (чч:мм) в UTC. */
+export function formatUtcTime(ms: number): string {
+ return new Date(ms).toLocaleTimeString("ru-RU", { timeZone: "UTC", hour: "2-digit", minute: "2-digit" });
+}
+
+/** Значение для `` в UTC (`YYYY-MM-DDTHH:mm`). */
+export function toUtcInputValue(ms: number): string {
+ return new Date(ms).toISOString().slice(0, 16);
+}
+
+/**
+ * Naive-строку (из `datetime-local`) трактует как UTC и возвращает ISO с `Z`.
+ * Для бэкендов с `OffsetDateTime` на границе API (напр. pcp-request-service):
+ * пользователь вводит время по UTC, отправляем его же как UTC-смещение.
+ */
+export function toUtcIso(value: string): string {
+ return new Date(parseUtc(value)).toISOString();
+}