pcp: время — сквозной naive LocalDateTime как локальное настенное время (без UTC)

Отказ от остаточной UTC-семантики: naive LocalDateTime трактуется как
локальное время единой зоны всех сервисов.

Backend (границы внешних систем):
- VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase: ZoneOffset.UTC →
  ZoneId.systemDefault() для проекции внешних Instant/ISO-с-Z в naive.
- тесты границ перевёрнуты на systemDefault (под Europe/Moscow ждут +3).
- чистка UTC-формулировок в DTO-комментариях, мёртвых ссылок на
  docs/standards/DATETIME_UTC.md.

Frontend (pcp-tgu-ops-ui):
- utils/utcTime.ts → utils/localTime.ts: parseLocal/formatLocal*/toLocalIso/
  toLocalInputValue с локальной семантикой (без дописывания Z и timeZone:UTC).
- замена getUTC*/Date.UTC/toISOString-сериализации на локальные аналоги.
- убраны пользовательские подписи «UTC» в редакторе/таймлайне.
- удалён utcTime.guard.test.ts (форсил обратный UTC-инвариант); тест-фикстуры
  сделаны зоно-независимыми. tsc + vitest зелёные под TZ=UTC и TZ=Europe/Moscow.

Включает также прочие незакоммиченные правки рабочего дерева.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 09:50:39 +03:00
parent 5049395911
commit 294b17757d
123 changed files with 556 additions and 535 deletions
@@ -1,6 +1,6 @@
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
import { statusStyle } from "../tgu-planning/tguStatus";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime";
import { Time24Field } from "../../components/timeFields";
import type { RvaZone } from "../../api/ballisticsApi";
import type { SurveyRoute } from "../../api/missionPlanningApi";
@@ -50,7 +50,7 @@ type ShootPanelProps = {
selectedPass?: EditorVisibilityWindow;
modeId?: string;
modes: EditorMode[];
/** Окно строящегося маршрута (UTC, мс). undefined пока не выбран проход. */
/** Окно строящегося маршрута (локальное, мс). undefined пока не выбран проход. */
routeWindow?: { startMs: number; endMs: number };
/** Текущий крен (gamma) маршрута, градусы. */
routeRoll?: number;
@@ -105,7 +105,7 @@ type DropPanelProps = {
onSelectZone: (zone?: RvaZone) => void;
/** Название станции по её номеру (из каталога станций). */
stationName: (stationId: number) => string;
/** Время начала зоны в мс (UTC) — для подписи. */
/** Время начала зоны в мс (локальное) — для подписи. */
zoneStartMs: (zone: RvaZone) => number;
zoneStopMs: (zone: RvaZone) => number;
/** Съёмки-кандидаты для выбранной зоны (end < начало зоны). */
@@ -699,7 +699,7 @@ function surveyTypeLabel(surveyType: string): string {
}
function formatIso(iso: string): string {
return formatUtcDateTime(parseUtc(iso));
return formatLocalDateTime(parseLocal(iso));
}
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
@@ -747,7 +747,7 @@ function TimeStepper({
/**
* Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной
* ввод времени `чч:мм:сс` (UTC, без даты — дата берётся из текущего значения).
* ввод времени `чч:мм:сс` (локальное, без даты — дата берётся из текущего значения).
* Значение зажимается в [min, max].
*/
function RouteTimeStepper({
@@ -766,16 +766,16 @@ function RouteTimeStepper({
const clamp = (next: number) => Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, next));
const change = (deltaMs: number) => onChange(clamp(value + deltaMs));
const onTimeChange = (raw: string) => {
// Ввод — только время суток (ЧЧ:ММ:СС, UTC); день оставляем прежним.
const datePart = new Date(value).toISOString().slice(0, 10);
const ms = parseUtc(`${datePart}T${raw}`);
// Ввод — только время суток (ЧЧ:ММ:СС, локальное); день оставляем прежним.
const datePart = toLocalIso(value).slice(0, 10);
const ms = parseLocal(`${datePart}T${raw}`);
if (Number.isFinite(ms)) onChange(clamp(ms));
};
return (
<label className="tgu-editor-time-stepper tgu-editor-time-stepper--seconds">
<span>{label}</span>
<Time24Field withSeconds value={new Date(value).toISOString().slice(11, 19)} onChange={onTimeChange} />
<Time24Field withSeconds value={toLocalIso(value).slice(11, 19)} onChange={onTimeChange} />
<div className="tgu-editor-time-stepper__steps">
<button type="button" onClick={() => change(-30_000)}>30с</button>
<button type="button" onClick={() => change(-5_000)}>5с</button>
@@ -85,7 +85,7 @@ export function EditorTimeline({
<div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}>
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}>
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
Время (UTC)
Время
</div>
<div className="tgu-editor-timeline__axis" style={{ left: LABEL_WIDTH, width: bodyWidth }}>
{days.map((day) => (
@@ -206,7 +206,7 @@ export function EditorTimeline({
function buildDays(window: EditorTimelineWindow): number[] {
const result: number[] = [];
const start = new Date(window.fromMs);
let dayMs = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate());
let dayMs = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime();
while (dayMs <= window.toMs) {
result.push(dayMs);
dayMs += 24 * 60 * 60 * 1000;
@@ -9,14 +9,14 @@ type MapPassSelectorProps = {
onToChange: (idx: number) => void;
};
function utcHM(ms: number): string {
function localHM(ms: number): string {
const d = new Date(ms);
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function utcDM(ms: number): string {
function localDM(ms: number): string {
const d = new Date(ms);
return `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onToChange }: MapPassSelectorProps) {
@@ -27,7 +27,7 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo
const fromPass = passes[Math.max(0, Math.min(fromIndex - 1, n - 1))];
const toPass = passes[Math.max(0, Math.min(toIndex - 1, n - 1))];
const multiDay = utcDM(passes[0].startMs) !== utcDM(passes[n - 1].startMs);
const multiDay = localDM(passes[0].startMs) !== localDM(passes[n - 1].startMs);
const isFullRange = fromIndex === 1 && toIndex === n;
const fromRev = fromPass.revolution ?? fromIndex;
const toRev = toPass.revolution ?? toIndex;
@@ -76,10 +76,9 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo
<span className="map-pass-selector__title">Витки</span>
<span className="map-pass-selector__nums">{fromRev}{toRev}</span>
<span className="map-pass-selector__times">
{multiDay ? `${utcDM(fromPass.startMs)} ` : ""}{utcHM(fromPass.startMs)}
{multiDay ? `${localDM(fromPass.startMs)} ` : ""}{localHM(fromPass.startMs)}
{" "}
{multiDay ? `${utcDM(toPass.startMs)} ` : ""}{utcHM(toPass.startMs)}
{" UTC"}
{multiDay ? `${localDM(toPass.startMs)} ` : ""}{localHM(toPass.startMs)}
</span>
{!isFullRange && (
<button
@@ -14,7 +14,7 @@ import {
type SurveyRoute
} from "../../api/missionPlanningApi";
import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi";
import { parseUtc } from "../../utils/utcTime";
import { parseLocal, toLocalIso } from "../../utils/localTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
@@ -150,8 +150,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
.map(([revolution, pts], idx) => ({
index: idx + 1,
revolution,
startMs: parseUtc(pts[0].time),
endMs: parseUtc(pts[pts.length - 1].time),
startMs: parseLocal(pts[0].time),
endMs: parseLocal(pts[pts.length - 1].time),
}));
}, [flightLineData]);
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
@@ -177,7 +177,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [shootPasses, setShootPasses] = useState<EditorVisibilityWindow[]>([]);
const [shootPassesLoading, setShootPassesLoading] = useState(false);
const [shootPassesError, setShootPassesError] = useState<string>();
// Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, UTC).
// Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, локальное).
const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>();
// Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную.
const [routeRoll, setRouteRoll] = useState<number>();
@@ -275,8 +275,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
useEffect(() => {
const controller = new AbortController();
void fetchRequestsForMap({
endFrom: new Date(window.fromMs).toISOString().slice(0, 19),
beginTo: new Date(window.toMs).toISOString().slice(0, 19)
endFrom: toLocalIso(window.fromMs),
beginTo: toLocalIso(window.toMs)
}).then((result) => {
if (!controller.signal.aborted) {
setRequestItems(result.items);
@@ -365,7 +365,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
void buildSurveyRoute({
satelliteId: numericNorad,
// Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность.
time: new Date(routeWindow.startMs).toISOString().slice(0, 19),
time: toLocalIso(routeWindow.startMs),
durationSeconds,
roll: routeRoll,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
@@ -522,15 +522,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const draftId = await ensureDraft();
savedRoute = await saveSurveyRoute(draftId, {
satelliteId: numericNorad,
time: new Date(routeWindow.startMs).toISOString().slice(0, 19),
time: toLocalIso(routeWindow.startMs),
durationSeconds: routeDurationSeconds,
roll: routeRoll,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0,
anchor: "FORWARD",
// Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
planStart: toLocalIso(selectedPlan.startMs),
planEnd: toLocalIso(selectedPlan.endMs)
});
setSurveyRoute(savedRoute);
void refreshDraftModes(draftId);
@@ -614,8 +614,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
zoneStop: selectedZone.onStop.time.slice(0, 19),
surveyModeIds: [...selectedDropSurveyIds],
// Окно выбранного плана — для автосоздания миссии/плана с корректным интервалом.
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
planStart: toLocalIso(selectedPlan.startMs),
planEnd: toLocalIso(selectedPlan.endMs)
});
savedDropId = savedDrop.id;
// Перечитываем включения DRAFT (источник истины) и ревалидируем.
@@ -1051,7 +1051,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}
function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow {
const timeMs = parseUtc(param.time);
const timeMs = parseLocal(param.time);
const half = SHOOT_WINDOW_MS / 2;
return {
startMs: timeMs - half,
@@ -1088,11 +1088,12 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG
}
/**
* LocalDateTime без зоны от бэка трактуем как UTC (как и поле `time` в ballisticsApi):
* зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале.
* LocalDateTime без зоны от бэка трактуем как локальное настенное время (как и поле `time`
* в ballisticsApi): зоны ЗРВ приходят без таймзоны, времена съёмок — с offset (его учитываем);
* приводим к одной шкале в локальной зоне браузера.
*/
function localDateTimeMs(s: string): number {
return parseUtc(s);
return parseLocal(s);
}
function buildVisibilityTracks(
@@ -1208,7 +1209,7 @@ function buildEditorMapScene(
const startMs = localDateTimeMs(mode.timeStart);
const endMs = startMs + mode.duration * 1000;
const pts = flightLineData
.map((p) => ({ t: parseUtc(p.time), lat: p.lat, lon: p.long }))
.map((p) => ({ t: parseLocal(p.time), lat: p.lat, lon: p.long }))
.filter((p) => p.t >= startMs && p.t <= endMs)
.sort((a, b) => a.t - b.t)
.map((p) => ({ lat: p.lat, lon: p.lon }));
@@ -14,17 +14,17 @@ import {
} from "./editorDraft";
import type { PlanMode } from "./editorApi";
import type { DraftHistory } from "./model/editorTypes";
import { parseUtc } from "../../utils/utcTime";
import { parseLocal } from "../../utils/localTime";
const plan: TguPlanUi = {
planId: "plan-1",
spacecraftId: "56756",
startTime: "2026-05-29T01:00:00Z",
endTime: "2026-05-29T02:00:00Z",
startTime: "2026-05-29T01:00:00",
endTime: "2026-05-29T02:00:00",
kppId: "KPP-1",
status: "PLANNED",
startMs: parseUtc("2026-05-29T01:00:00Z"),
endMs: parseUtc("2026-05-29T02:00:00Z")
startMs: parseLocal("2026-05-29T01:00:00"),
endMs: parseLocal("2026-05-29T02:00:00")
};
describe("editorDraft", () => {
@@ -85,8 +85,8 @@ describe("editorDraft", () => {
id: "survey-11",
kind: "shoot",
label: "Съёмка · виток 4321",
startMs: parseUtc("2026-05-29T01:10:00Z"),
endMs: parseUtc("2026-05-29T01:10:00Z") + 180_000,
startMs: parseLocal("2026-05-29T01:10:00"),
endMs: parseLocal("2026-05-29T01:10:00") + 180_000,
origin: "plan",
planModeId: 11,
revolution: 4321,
@@ -100,8 +100,8 @@ describe("editorDraft", () => {
id: "drop-22",
kind: "downlink",
label: "Сброс · 102",
startMs: parseUtc("2026-05-29T01:40:00Z"),
endMs: parseUtc("2026-05-29T01:40:00Z") + 120_000,
startMs: parseLocal("2026-05-29T01:40:00"),
endMs: parseLocal("2026-05-29T01:40:00") + 120_000,
origin: "plan",
planModeId: 22,
revolution: 4322,
@@ -1,5 +1,5 @@
import type { TguPlanUi } from "../../model/timelineTypes";
import { parseUtc } from "../../utils/utcTime";
import { parseLocal } from "../../utils/localTime";
import type { PlanMode } from "./editorApi";
import type {
Draft,
@@ -64,7 +64,7 @@ export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
export function planModesToWorks(modes: PlanMode[]): EditorWork[] {
return modes.flatMap((mode): EditorWork[] => {
if (mode.timeStart == null) return [];
const startMs = parseUtc(mode.timeStart);
const startMs = parseLocal(mode.timeStart);
if (!Number.isFinite(startMs)) return [];
const endMs = startMs + Math.max(1, mode.duration) * 1000;
@@ -272,7 +272,7 @@ function readTime(record: Record<string, unknown>, key: string): number | undefi
return value;
}
if (typeof value === "string") {
const parsed = parseUtc(value);
const parsed = parseLocal(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
@@ -24,12 +24,12 @@ export function kindColor(kind: EditorWorkKind): string {
export function formatTime(ms: number): string {
const date = new Date(ms);
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
export function formatDate(ms: number): string {
const date = new Date(ms);
return `${pad(date.getUTCDate())} ${EDITOR_MONTHS[date.getUTCMonth()]}`;
return `${pad(date.getDate())} ${EDITOR_MONTHS[date.getMonth()]}`;
}
export function formatDateTime(ms: number): string {
@@ -1,6 +1,6 @@
import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "./model/mapTypes";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
@@ -96,18 +96,18 @@ export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, on
}
function formatDateTime(value: string): string {
return formatUtcDateTime(parseUtc(value));
return formatLocalDateTime(parseLocal(value));
}
function formatDateRange(begin: string, end: string): string {
const b = new Date(parseUtc(begin));
const e = new Date(parseUtc(end));
const b = new Date(parseLocal(begin));
const e = new Date(parseLocal(end));
const sameDay =
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" };
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" };
const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`;
const endStr = sameDay
? b.toLocaleTimeString("ru-RU", timeOpts)
@@ -3,7 +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";
import { parseLocal } from "../../utils/localTime";
import { DateTime24Field } from "../../components/timeFields";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -48,7 +48,7 @@ export function Tgu2DMapSidebar({
const quickToday = () => {
const now = new Date();
const fromMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
onRequestsQuickRange(fromMs, fromMs + DAY_MS);
};
@@ -62,8 +62,8 @@ export function Tgu2DMapSidebar({
onRequestsQuickRange(fromMs, fromMs + 30 * DAY_MS);
};
const requestsFromMs = parseUtc(requestsState.fromValue);
const requestsToMs = parseUtc(requestsState.toValue);
const requestsFromMs = parseLocal(requestsState.fromValue);
const requestsToMs = parseLocal(requestsState.toValue);
const requestsRangeInvalid =
!Number.isFinite(requestsFromMs) ||
!Number.isFinite(requestsToMs) ||
@@ -15,7 +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";
import { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
type Tgu2DMapTabProps = {
range: TimelineRange;
@@ -38,8 +38,8 @@ export type RequestsState = {
function buildInitialRequestsState(range: TimelineRange): RequestsState {
return {
fromValue: toUtcInputValue(range.fromMs),
toValue: toUtcInputValue(range.toMs),
fromValue: toLocalInputValue(range.fromMs),
toValue: toLocalInputValue(range.toMs),
items: [],
loading: false,
loadedCount: 0,
@@ -78,16 +78,16 @@ export function Tgu2DMapTab({
const mapInvalidRange = invalidRange || mapInterval.tooLarge;
const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
const fromMs = parseUtc(fromValue);
const toMs = parseUtc(toValue);
const fromMs = parseLocal(fromValue);
const toMs = parseLocal(toValue);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try {
const result = await fetchRequestsForMap(
{
endFrom: new Date(fromMs).toISOString().slice(0, 19),
beginTo: new Date(toMs).toISOString().slice(0, 19)
endFrom: toLocalIso(fromMs),
beginTo: toLocalIso(toMs)
},
(loaded, total) => {
setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }));
@@ -124,8 +124,8 @@ export function Tgu2DMapTab({
}, []);
const setRequestsQuickRange = useCallback((fromMs: number, toMs: number) => {
const fromValue = toUtcInputValue(fromMs);
const toValue = toUtcInputValue(toMs);
const fromValue = toLocalInputValue(fromMs);
const toValue = toLocalInputValue(toMs);
setRequestsState((current) => ({ ...current, fromValue, toValue }));
void loadRequests(fromValue, toValue);
}, [loadRequests]);
@@ -297,7 +297,6 @@ 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",
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TguPlanUi } from "../../model/timelineTypes";
import { DEFAULT_TGU_2D_MAP_LAYERS } from "./model/mapLayerTypes";
import { getTgu2DMapIntervalState } from "./tgu2DMapInterval";
import { parseUtc } from "../../utils/utcTime";
import { parseLocal } from "../../utils/localTime";
function plan(startTime: string, endTime: string): TguPlanUi {
return {
@@ -12,16 +12,16 @@ function plan(startTime: string, endTime: string): TguPlanUi {
endTime,
kppId: "KPP-1",
status: "PLANNED",
startMs: parseUtc(startTime),
endMs: parseUtc(endTime)
startMs: parseLocal(startTime),
endMs: parseLocal(endTime)
};
}
describe("tgu2DMapInterval", () => {
it("marks common map interval longer than 7 days as too large", () => {
const state = getTgu2DMapIntervalState({
fromMs: parseUtc("2026-05-29T00:00:00"),
toMs: parseUtc("2026-06-05T00:00:01")
fromMs: parseLocal("2026-05-29T00:00:00"),
toMs: parseLocal("2026-06-05T00:00:01")
});
expect(state.tooLarge).toBe(true);
@@ -31,8 +31,8 @@ describe("tgu2DMapInterval", () => {
const selectedPlan = plan("2026-05-29T01:00:00", "2026-06-07T02:00:00");
const state = getTgu2DMapIntervalState(
{
fromMs: parseUtc("2026-05-29T00:00:00"),
toMs: parseUtc("2026-06-10T00:00:00")
fromMs: parseLocal("2026-05-29T00:00:00"),
toMs: parseLocal("2026-06-10T00:00:00")
},
selectedPlan
);
@@ -7,7 +7,7 @@ import {
} from "../../api/tguPlanApi";
import type { TguPlanUi } from "../../model/timelineTypes";
import { TguApiError } from "../../model/tguTypes";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
type CreateChainPlanDialogProps = {
@@ -123,7 +123,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
<option value="">Без истории (новая цепочка с нуля)</option>
{baseOptions.map((plan) => (
<option key={plan.planId} value={plan.planId}>
{formatUtcDateTime(plan.startMs)} · {plan.kppId} · {plan.status}
{formatLocalDateTime(plan.startMs)} · {plan.kppId} · {plan.status}
</option>
))}
</select>
@@ -142,7 +142,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
<option value=""> выберите </option>
{zrvOptions.map((option) => (
<option key={option.id} value={option.id}>
{formatUtcDateTime(option.startMs)} · {option.kppId}
{formatLocalDateTime(option.startMs)} · {option.kppId}
</option>
))}
</select>
@@ -156,7 +156,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
.filter((option) => !start || option.endMs > start.startMs)
.map((option) => (
<option key={option.id} value={option.id}>
{formatUtcDateTime(option.endMs)} · {option.kppId}
{formatLocalDateTime(option.endMs)} · {option.kppId}
</option>
))}
</select>
@@ -186,7 +186,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
) : (
result.projectedChain.map((plan) => (
<li key={plan.planId}>
{formatUtcDateTime(parseUtc(plan.startTime))} {formatUtcDateTime(parseUtc(plan.endTime))} ·{" "}
{formatLocalDateTime(parseLocal(plan.startTime))} {formatLocalDateTime(parseLocal(plan.endTime))} ·{" "}
{plan.kppId}
</li>
))
@@ -2,7 +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";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
// Бэкенд (pcp-tgu-service) ждёт решения по плану только waiting-decision-grace-minutes
// минут после его startTime; затем IssueDuePlanWorker.tryAdvancePastUnconfirmedPlan
@@ -161,7 +161,7 @@ function Detail({ label, value, mono }: { label: string; value?: string; mono?:
}
function formatDateTime(value: string): string {
return formatUtcDateTime(parseUtc(value));
return formatLocalDateTime(parseLocal(value));
}
function formatDuration(ms: number): string {
@@ -27,15 +27,15 @@ import {
operationalSpacecraftIds,
pausedSpacecraftIds
} from "./tguTimelineMapper";
import { parseUtc, toUtcInputValue } from "../../utils/utcTime";
import { parseLocal, toLocalInputValue } from "../../utils/localTime";
const DAY_MS = 24 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
export function TguPlanningPage() {
const initialRange = useMemo(defaultRange, []);
const [fromValue, setFromValue] = useState(toUtcInputValue(initialRange.fromMs));
const [toValue, setToValue] = useState(toUtcInputValue(initialRange.toMs));
const [fromValue, setFromValue] = useState(toLocalInputValue(initialRange.fromMs));
const [toValue, setToValue] = useState(toLocalInputValue(initialRange.toMs));
const [appliedRange, setAppliedRange] = useState<TimelineRange>(initialRange);
const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]);
const [plans, setPlans] = useState<TguPlanUi[]>([]);
@@ -68,8 +68,8 @@ export function TguPlanningPage() {
const parsedInputRange = useMemo(
() => ({
fromMs: parseUtc(fromValue),
toMs: parseUtc(toValue)
fromMs: parseLocal(fromValue),
toMs: parseLocal(toValue)
}),
[fromValue, toValue]
);
@@ -158,8 +158,8 @@ export function TguPlanningPage() {
return {
spacecraftId: selectedSpacecraftId,
windows: zrvWindows.map((zrv) => ({
startMs: parseUtc(zrv.startTime),
endMs: parseUtc(zrv.endTime),
startMs: parseLocal(zrv.startTime),
endMs: parseLocal(zrv.endTime),
kppId: zrv.kppId
}))
};
@@ -221,8 +221,8 @@ export function TguPlanningPage() {
const quickRange = (hours: number) => {
const fromMs = startOfCurrentDayMs();
const toMs = fromMs + hours * 60 * 60 * 1000;
setFromValue(toUtcInputValue(fromMs));
setToValue(toUtcInputValue(toMs));
setFromValue(toLocalInputValue(fromMs));
setToValue(toLocalInputValue(toMs));
setAppliedRange({ fromMs, toMs });
setPageError(undefined);
setDecisionNotice(undefined);
@@ -442,7 +442,7 @@ function defaultRange(): TimelineRange {
function startOfCurrentDayMs(): number {
const now = new Date();
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
function historyDaysForRange(range: TimelineRange): number {
@@ -12,7 +12,7 @@ import {
} from "./tguTimelineLayout";
import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
import { formatLocalDate, formatLocalDateTime, formatLocalTime, parseLocal } from "../../utils/localTime";
export type ZrvWindow = { startMs: number; endMs: number; kppId: string };
@@ -196,7 +196,7 @@ export function TguTimeline({
onSelectZrv(overlapping, Math.max(0, idx));
}}
>
<title>ЗРВ {zrv.kppId} · {formatUtcDateTime(zrv.startMs)} {formatUtcDateTime(zrv.endMs)}</title>
<title>ЗРВ {zrv.kppId} · {formatLocalDateTime(zrv.startMs)} {formatLocalDateTime(zrv.endMs)}</title>
</rect>
);
})}
@@ -268,13 +268,13 @@ function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePl
}
function formatTickDate(ms: number): string {
return formatUtcDate(ms);
return formatLocalDate(ms);
}
function formatTickTime(ms: number): string {
return formatUtcTime(ms);
return formatLocalTime(ms);
}
function formatDateTime(value: string): string {
return formatUtcDateTime(parseUtc(value));
return formatLocalDateTime(parseLocal(value));
}
@@ -1,5 +1,5 @@
import type { ZrvWindow } from "./TguTimeline";
import { formatUtcDateTime } from "../../utils/utcTime";
import { formatLocalDateTime } from "../../utils/localTime";
type Props = {
windows: ZrvWindow[];
@@ -35,7 +35,7 @@ export function TguZrvPanel({ windows, selectedIndex, onSelect, onClose }: Props
<span className="tgu-map-request-row__body">
<span className="tgu-map-request-row__name">КПП {zrv.kppId}</span>
<span className="tgu-map-request-row__meta">
{formatUtcDateTime(zrv.startMs)} {formatUtcDateTime(zrv.endMs)}
{formatLocalDateTime(zrv.startMs)} {formatLocalDateTime(zrv.endMs)}
{" · "}
{formatDuration(zrv.endMs - zrv.startMs)}
</span>
@@ -52,10 +52,10 @@ export function TguZrvPanel({ windows, selectedIndex, onSelect, onClose }: Props
<dd className="mono">{selected.kppId}</dd>
<dt>Начало</dt>
<dd className="mono">{formatUtcDateTime(selected.startMs)}</dd>
<dd className="mono">{formatLocalDateTime(selected.startMs)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatUtcDateTime(selected.endMs)}</dd>
<dd className="mono">{formatLocalDateTime(selected.endMs)}</dd>
<dt>Длительность</dt>
<dd className="mono">{formatDuration(selected.endMs - selected.startMs)}</dd>
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
import type { ZrvWindowResponse } from "../../api/tguPlanApi";
const NOW = Date.UTC(2026, 5, 3, 12, 0, 0);
const NOW = new Date(2026, 5, 3, 12, 0, 0).getTime();
function zrv(startIso: string, endIso: string, kppId = "KPP-1"): ZrvWindowResponse {
return { startTime: startIso, endTime: endIso, kppId };
@@ -11,19 +11,19 @@ function zrv(startIso: string, endIso: string, kppId = "KPP-1"): ZrvWindowRespon
describe("futureZrvOptions", () => {
it("keeps only future zones and sorts by start", () => {
const windows = [
zrv("2026-06-03T15:00:00Z", "2026-06-03T15:10:00Z"),
zrv("2026-06-03T11:00:00Z", "2026-06-03T11:10:00Z"), // в прошлом → отбросить
zrv("2026-06-03T13:00:00Z", "2026-06-03T13:10:00Z")
zrv("2026-06-03T15:00:00", "2026-06-03T15:10:00"),
zrv("2026-06-03T11:00:00", "2026-06-03T11:10:00"), // в прошлом → отбросить
zrv("2026-06-03T13:00:00", "2026-06-03T13:10:00")
];
const options = futureZrvOptions(windows, NOW);
expect(options.map((o) => o.startMs)).toEqual([
Date.UTC(2026, 5, 3, 13, 0, 0),
Date.UTC(2026, 5, 3, 15, 0, 0)
new Date(2026, 5, 3, 13, 0, 0).getTime(),
new Date(2026, 5, 3, 15, 0, 0).getTime()
]);
});
it("produces naive UTC (no zone) start/stop strings", () => {
const [option] = futureZrvOptions([zrv("2026-06-03T13:00:00Z", "2026-06-03T13:10:00Z")], NOW);
it("produces naive (no zone) start/stop strings", () => {
const [option] = futureZrvOptions([zrv("2026-06-03T13:00:00", "2026-06-03T13:10:00")], NOW);
expect(option.start).toBe("2026-06-03T13:00:00");
expect(option.stop).toBe("2026-06-03T13:10:00");
expect(option.kppId).toBe("KPP-1");
@@ -1,9 +1,9 @@
import { parseUtc } from "../../utils/utcTime";
import { parseLocal, toLocalIso } from "../../utils/localTime";
import type { CreateChainPlanRequest, ZrvWindowResponse } from "../../api/tguPlanApi";
/**
* Опция выбора ЗРВ в диалоге «Создать план». Из стартовой зоны берутся начало окна и КПП, из
* конечной конец окна. `start`/`stop` naive UTC (ISO без зоны), как ждёт `LocalDateTime` бэка;
* конечной конец окна. `start`/`stop` naive (ISO без зоны, локальное), как ждёт `LocalDateTime` бэка;
* `startMs`/`endMs` для сортировки/сравнения и подсветки на таймлайне.
*/
export type ZrvOption = {
@@ -11,9 +11,9 @@ export type ZrvOption = {
id: string;
startMs: number;
endMs: number;
/** Naive UTC начала зоны. */
/** Naive (локальное) начала зоны. */
start: string;
/** Naive UTC конца зоны. */
/** Naive (локальное) конца зоны. */
stop: string;
kppId: string;
};
@@ -22,14 +22,14 @@ export type ZrvOption = {
export function futureZrvOptions(windows: ZrvWindowResponse[], nowMs: number): ZrvOption[] {
return windows
.map((zrv) => {
const startMs = parseUtc(zrv.startTime);
const endMs = parseUtc(zrv.endTime);
const startMs = parseLocal(zrv.startTime);
const endMs = parseLocal(zrv.endTime);
return {
id: `${zrv.kppId}|${zrv.startTime}|${zrv.endTime}`,
startMs,
endMs,
start: toNaiveUtc(startMs),
stop: toNaiveUtc(endMs),
start: toNaive(startMs),
stop: toNaive(endMs),
kppId: zrv.kppId
};
})
@@ -60,6 +60,6 @@ export function buildChainPlanRequest(
};
}
function toNaiveUtc(ms: number): string {
return new Date(ms).toISOString().slice(0, 19);
function toNaive(ms: number): string {
return toLocalIso(ms);
}
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
import { buildSequentialLinks, buildTimelineLayout, clipPlanToRange, timeToX } from "./tguTimelineLayout";
import { parseUtc } from "../../utils/utcTime";
import { parseLocal } from "../../utils/localTime";
const range = {
fromMs: parseUtc("2026-05-29T00:00:00"),
toMs: parseUtc("2026-05-30T00:00:00")
fromMs: parseLocal("2026-05-29T00:00:00"),
toMs: parseLocal("2026-05-30T00:00:00")
};
function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi {
@@ -16,14 +16,14 @@ function plan(id: string, startTime: string, endTime: string, spacecraftId = "56
endTime,
kppId: "KPP-1",
status: "PLANNED",
startMs: parseUtc(startTime),
endMs: parseUtc(endTime)
startMs: parseLocal(startTime),
endMs: parseLocal(endTime)
};
}
describe("tguTimelineLayout", () => {
it("maps time to x inside the selected interval", () => {
expect(timeToX(parseUtc("2026-05-29T12:00:00"), range, 1200)).toBe(600);
expect(timeToX(parseLocal("2026-05-29T12:00:00"), range, 1200)).toBe(600);
});
it("clips partially visible plans to the selected interval", () => {
@@ -31,7 +31,7 @@ describe("tguTimelineLayout", () => {
expect(clipped).toEqual({
startMs: range.fromMs,
endMs: parseUtc("2026-05-29T02:00:00")
endMs: parseLocal("2026-05-29T02:00:00")
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { isOperationalPlatform, operationalSpacecraftIds, pausedSpacecraftIds } from "./tguTimelineMapper";
import { toLocalIso } from "../../utils/localTime";
function platform(overrides: Partial<TguPlatform>): TguPlatform {
return { id: "x", noradId: 1, status: "OPERATIONAL", ...overrides };
@@ -11,8 +12,8 @@ function uiPlan(spacecraftId: string, status: TguPlanStatus, startMs: number): T
return {
planId: `${spacecraftId}-${status}-${startMs}`,
spacecraftId,
startTime: new Date(startMs).toISOString().slice(0, 19),
endTime: new Date(startMs + 3_600_000).toISOString().slice(0, 19),
startTime: toLocalIso(startMs),
endTime: toLocalIso(startMs + 3_600_000),
kppId: "KPP-1",
status,
startMs,
@@ -1,6 +1,6 @@
import type { TguPlan, TguPlatform } from "../../model/tguTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes";
import { parseUtc } from "../../utils/utcTime";
import { parseLocal } from "../../utils/localTime";
const PROBLEM_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS"]);
@@ -27,8 +27,8 @@ export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[
return plans
.map((plan) => {
const startMs = parseUtc(plan.startTime);
const endMs = parseUtc(plan.endTime);
const startMs = parseLocal(plan.startTime);
const endMs = parseLocal(plan.endTime);
const platform = platformByNorad.get(plan.spacecraftId);
return {
...plan,
@@ -6,7 +6,7 @@ import type {
RsaParams,
RsaResultType
} from "../../model/requestTypes";
import { parseUtc } from "../../utils/utcTime";
import { parseLocal } from "../../utils/localTime";
import { DateTime24Field } from "../../components/timeFields";
export type RequestFormSubmit = {
@@ -87,8 +87,8 @@ export function RequestCreatePanel({
const [rsaResolution, setRsaResolution] = useState("1");
const importanceNum = Number(importance);
const beginMs = parseUtc(begin);
const endMs = parseUtc(end);
const beginMs = parseLocal(begin);
const endMs = parseLocal(end);
const kppList = parseKpp(kpp);
const kppValid = kppList !== null;
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
type Props = {
requests: RequestAtPoint[];
@@ -155,7 +155,7 @@ export function RequestDetailsPanel({
}
function formatDateTime(value: string): string {
return formatUtcDateTime(parseUtc(value));
return formatLocalDateTime(parseLocal(value));
}
function surveyTypeLabel(surveyType: string): string {
@@ -9,7 +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 { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { DateTime24Field } from "../../components/timeFields";
import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel";
import { RequestDetailsPanel } from "./RequestDetailsPanel";
@@ -37,8 +37,8 @@ type PanelMode = "none" | "details" | "create";
function buildInitialLoadState(range: TimelineRange): RequestsLoadState {
return {
fromValue: toUtcInputValue(range.fromMs),
toValue: toUtcInputValue(range.toMs),
fromValue: toLocalInputValue(range.fromMs),
toValue: toLocalInputValue(range.toMs),
items: [],
loading: false,
loadedCount: 0,
@@ -75,14 +75,14 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
}, []);
const loadRequests = useCallback(async (fromValue: string, toValue: string) => {
const fromMs = parseUtc(fromValue);
const toMs = parseUtc(toValue);
const fromMs = parseLocal(fromValue);
const toMs = parseLocal(toValue);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try {
const result = await fetchRequestsForMap(
{ endFrom: new Date(fromMs).toISOString().slice(0, 19), beginTo: new Date(toMs).toISOString().slice(0, 19) },
{ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) },
(loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }))
);
setRequestsState((current) => ({
@@ -204,8 +204,8 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
name: form.name,
geometry,
importance: form.importance,
beginDateTime: toUtcIso(form.beginDateTime),
endDateTime: toUtcIso(form.endDateTime),
beginDateTime: toLocalIso(parseLocal(form.beginDateTime)),
endDateTime: toLocalIso(parseLocal(form.endDateTime)),
kpp: form.kpp,
highPriorityTransmit: form.highPriorityTransmit,
optics: form.optics,
@@ -244,8 +244,8 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
[closeAll, loadRequests, requestsState.fromValue, requestsState.toValue]
);
const fromMs = parseUtc(requestsState.fromValue);
const toMs = parseUtc(requestsState.toValue);
const fromMs = parseLocal(requestsState.fromValue);
const toMs = parseLocal(requestsState.toValue);
const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs;
const hasPanel = panelMode !== "none";