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(); +}