pcp-tgu-ops-ui: единый UTC во времени; дефолтный диапазон Планы
Время бэка (naive LocalDateTime и OffsetDateTime) разбиралось через new Date(string)/Date.parse, из-за чего naive-строки трактовались как локальное время браузера: вкладки расходились по времени, а путь создания заявки сдвигал введённое время на офсет. Ввёл единую точку utils/utcTime (parseUtc/formatUtc*/toUtcInputValue/toUtcIso) и перевёл на неё вкладки «Планы», «Заявки», «Карта 2D» — чтение, пикеры диапазона, создание заявки. Плюс на вкладке «Планы» график по умолчанию открывается на интервал сейчас−30 мин … сейчас+3 дня. docs/tasks/TASK_datetime-utc-consistency.md — ТЗ на сверку всей группы сервисов pcp на единообразие времени.
This commit is contained in:
@@ -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`;
|
||||
- `<input type="datetime-local">` — значение через `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(<string>)`, `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) — только представление/парсинг.
|
||||
- Локализацию под пояс машины «чтобы было привычнее» — это и есть источник проблемы.
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<TimelineRange>(initialRange);
|
||||
const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]);
|
||||
const [plans, setPlans] = useState<TguPlanUi[]>([]);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
/** Значение для `<input type="datetime-local">` в 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();
|
||||
}
|
||||
Reference in New Issue
Block a user