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:
Дмитрий Соловьев
2026-06-02 16:53:48 +03:00
parent faf264e416
commit fb0332a030
12 changed files with 304 additions and 73 deletions
@@ -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();
}