Формат времени в UTC

This commit is contained in:
emelianov
2026-06-25 16:21:17 +03:00
parent d0ec0ef754
commit fe54c095ab
164 changed files with 1847 additions and 634 deletions
@@ -1,5 +1,5 @@
import { TguApiError } from '../model/tguTypes'
import { toLocalIso } from '../utils/localTime'
import { toUtcIso } from '../utils/localTime'
import { apiFetch } from './apiFetch'
const BASE = '/api/pcp-ballistics'
@@ -67,7 +67,7 @@ export type PointVisibilityParam = {
noradId: number
/** Идентификатор объекта (цели) из запроса. */
objectId: string
/** Момент видимости (локальное время, ISO без зоны). */
/** Момент видимости (UTC ISO). */
time: string
/** Номер витка. */
revolution: number
@@ -161,7 +161,7 @@ export async function fetchPointVisibility(
/** Целеуказание (момент/угол места/азимут/дальность) границы зоны радиовидимости. */
export type RvaTargetPosition = {
/** Момент (локальное время, ISO без зоны). */
/** Момент (UTC ISO). */
time: string
/** Угол места, град. */
elevation: number
@@ -233,13 +233,13 @@ export async function fetchSatelliteRva(
}
/**
* LocalDateTime без зоны (локальные настенные часы): YYYY-MM-DDTHH:mm:ss.
* UTC ISO date-time for API DTO/query params.
* @param ms Время в миллисекундах от эпохи.
*
* @returns Строка LocalDateTime без зоны.
* @returns Строка UTC date-time.
*/
function toLocalDateTime(ms: number): string {
return toLocalIso(ms)
return toUtcIso(ms)
}
function extractErrorText(body: string): string {
@@ -1,5 +1,5 @@
import { TguApiError } from '../model/tguTypes'
import { toLocalIso } from '../utils/localTime'
import { toUtcIso } from '../utils/localTime'
import { apiFetch } from './apiFetch'
/**
@@ -20,9 +20,9 @@ export const MAX_COMPLEX_PLAN_RANGE_MS = MAX_COMPLEX_PLAN_RANGE_DAYS * 24 * 60 *
export type ComplexPlanRunStatus = 'CREATED' | 'RUNNING' | 'COMPLETED' | 'FAILED'
export type RunComplexPlanParams = {
/** Начало интервала расчёта (эпоха-мс, локальное). */
/** Начало интервала расчёта (эпоха-мс, абсолютный момент). */
fromMs: number
/** Конец интервала расчёта (эпоха-мс, локальное). */
/** Конец интервала расчёта (эпоха-мс, абсолютный момент). */
toMs: number
/** NORAD-id КА, по которым считаем (все эксплуатируемые КА вкладки «Планы»). */
satelliteIds: number[]
@@ -82,7 +82,7 @@ export type ComplexPlanRunSummary = {
coveredAreaPercent: number
}
/** Режим съёмки/сброса из результата расчёта (SatelliteModeResponseDTO). Времена — локальные. */
/** Режим съёмки/сброса из результата расчёта (SatelliteModeResponseDTO). Времена приходят из API как UTC ISO и отображаются в локальной зоне браузера. */
export type ComplexPlanMode = {
id: number
satelliteId: number
@@ -115,8 +115,8 @@ export type ComplexPlanMode = {
*/
export async function runComplexPlan(params: RunComplexPlanParams): Promise<ComplexPlanRunResult> {
const body = {
intervalStart: toLocalIso(params.fromMs),
intervalEnd: toLocalIso(params.toMs),
intervalStart: toUtcIso(params.fromMs),
intervalEnd: toUtcIso(params.toMs),
satelliteIds: params.satelliteIds,
}
@@ -9,7 +9,7 @@ export type SurveyRouteAnchor = 'CENTERED' | 'FORWARD'
export type BuildSurveyRouteParams = {
/** Идентификатор КА (NORAD — тот же, что в obj-view/mpl-point). */
satelliteId: number
/** Момент видимости (локальное время, ISO без зоны). */
/** Момент видимости (UTC ISO). */
time: string
/** Длительность съёмки, секунды. */
durationSeconds: number
@@ -22,17 +22,17 @@ export type BuildSurveyRouteParams = {
/** Способ построения окна относительно момента видимости. */
anchor?: SurveyRouteAnchor
/**
* Начало интервала плана (локальное время, ISO без зоны). Передаётся при сохранении,
* Начало интервала плана (UTC ISO). Передаётся при сохранении,
* чтобы при автосоздании миссии/плана задать корректное окно (для пересечений/наложений).
*/
planStart?: string
/** Конец интервала плана (локальное время, ISO без зоны). См. planStart. */
/** Конец интервала плана (UTC ISO). См. planStart. */
planEnd?: string
}
export type SurveyRoute = {
satelliteId: number
/** Начало съёмки (локальное время, ISO без зоны). */
/** Начало съёмки (UTC ISO). */
timeStart: string
/** Длительность съёмки, секунды. */
duration: number
@@ -87,9 +87,9 @@ export type SaveDropRouteParams = {
station: string
/** Номер витка зоны радиовидимости. */
revolution: number
/** Начало зоны (локальное время, ISO без зоны). */
/** Начало зоны (UTC ISO). */
zoneStart: string
/** Конец зоны (локальное время, ISO без зоны). */
/** Конец зоны (UTC ISO). */
zoneStop: string
/** Идентификаторы режимов съёмки (survey_modes), сбрасываемых в этой зоне. */
surveyModeIds: number[]
@@ -168,7 +168,7 @@ export async function saveDropRoute(
return JSON.parse(text) as DropMode
}
/** Итог расчёта плана съёмки (ответ /surveys/calculate). Времена — локальные. */
/** Итог расчёта плана съёмки (ответ /surveys/calculate). Времена приходят из API как UTC ISO и отображаются UI в локальной зоне браузера. */
export type SurveyCalculationResult = {
missionId: string
completed: boolean
@@ -12,7 +12,7 @@ export type DraftMode = 'replace' | 'fork-forward'
export type CreateDraftRequest = {
mode: DraftMode
/** Начало окна целевой ЗРВ (naive, ISO без зоны, локальное). Обязательно для fork-forward. */
/** Начало окна целевой ЗРВ (UTC ISO). Обязательно для fork-forward. */
windowStart?: string
/** Конец окна целевой ЗРВ. Обязательно для fork-forward. */
windowStop?: string
@@ -54,7 +54,7 @@ export type ZrvWindowResponse = {
/**
* Запрос создания плана новой цепочки (вкладка «Планы»). Окно набирается из двух ЗРВ:
* `windowStart`/`windowKppId` — старт выбранной стартовой зоны, `windowStop` — конец конечной.
* Времена — naive (ISO без зоны, локальное), как ждёт `LocalDateTime` бэка. `basePlanId` — предыстория
* Времена отправляются в API как UTC ISO с `Z`. `basePlanId` — предыстория
* (история тянется) либо `null` (цепочка без истории, пустой план).
*/
export type CreateChainPlanRequest = {
@@ -20,7 +20,7 @@ import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector";
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { formatLocalDateTime, parseLocal, toLocalInputValue, toUtcIso } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume";
type TguComplexPlanTabProps = {
@@ -173,7 +173,7 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
let cancelled = false;
setRequestsLoading(true);
setRequestsError(undefined);
fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) })
fetchRequestsForMap({ endFrom: toUtcIso(fromMs), beginTo: toUtcIso(toMs) })
.then((result) => {
if (!cancelled) setRequestItems(result.items);
})
@@ -18,7 +18,7 @@ import {
import { abandonDraft, createDraft, fetchDraftPlans } from "../../api/tguPlanApi";
import { TguApiError } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { parseLocal, toLocalIso } from "../../utils/localTime";
import { parseLocal, toUtcIso } 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";
@@ -360,8 +360,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
useEffect(() => {
const controller = new AbortController();
void fetchRequestsForMap({
endFrom: toLocalIso(window.fromMs),
beginTo: toLocalIso(window.toMs)
endFrom: toUtcIso(window.fromMs),
beginTo: toUtcIso(window.toMs)
}).then((result) => {
if (!controller.signal.aborted) {
setRequestItems(result.items);
@@ -458,7 +458,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
void buildSurveyRoute({
satelliteId: numericNorad,
// Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность.
time: toLocalIso(routeWindow.startMs),
time: toUtcIso(routeWindow.startMs),
durationSeconds,
roll: routeRoll,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
@@ -617,9 +617,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
noradId: noradId ? Number(noradId) : 0,
stationId: Number.isFinite(stationNum) ? stationNum : 0,
revolution: work.revolution ?? 0,
onStart: { time: toLocalIso(work.startMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 },
onMaximum: { time: toLocalIso(midMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 },
onStop: { time: toLocalIso(work.endMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 },
onStart: { time: toUtcIso(work.startMs), elevation: 0, azimuth: 0, range: 0 },
onMaximum: { time: toUtcIso(midMs), elevation: 0, azimuth: 0, range: 0 },
onStop: { time: toUtcIso(work.endMs), elevation: 0, azimuth: 0, range: 0 },
duration: Math.max(1, (work.endMs - work.startMs) / 1000)
};
editDropPrefillRef.current = { zone, surveyIds: work.surveyIds ?? [] };
@@ -744,15 +744,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "SURVEY");
savedRoute = await saveSurveyRoute(draftId, {
satelliteId: numericNorad,
time: toLocalIso(routeWindow.startMs),
time: toUtcIso(routeWindow.startMs),
durationSeconds: routeDurationSeconds,
roll: routeRoll,
captureAngle: DEFAULT_CAPTURE_ANGLE_DEG,
revolution: selectedShootPass.revolution ?? 0,
anchor: "FORWARD",
// Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал.
planStart: toLocalIso(selectedPlan.startMs),
planEnd: toLocalIso(selectedPlan.endMs)
planStart: toUtcIso(selectedPlan.startMs),
planEnd: toUtcIso(selectedPlan.endMs)
});
setSurveyRoute(savedRoute);
// Ждём перечитывания planModes ДО снятия editingWorkId ниже: иначе на миг editingModeId
@@ -853,8 +853,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
zoneStop: selectedZone.onStop.time.slice(0, 19),
surveyModeIds: [...selectedDropSurveyIds],
// Окно выбранного плана — для автосоздания миссии/плана с корректным интервалом.
planStart: toLocalIso(selectedPlan.startMs),
planEnd: toLocalIso(selectedPlan.endMs)
planStart: toUtcIso(selectedPlan.startMs),
planEnd: toUtcIso(selectedPlan.endMs)
});
savedDropId = savedDrop.id;
savedDropVolumeMb = savedDrop.volumeMb;
@@ -13,7 +13,7 @@ import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
import { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { parseLocal, toLocalInputValue, toUtcIso } from "../../utils/localTime";
type Tgu2DMapTabProps = {
range: TimelineRange;
@@ -82,8 +82,8 @@ export function Tgu2DMapTab({
try {
const result = await fetchRequestsForMap(
{
endFrom: toLocalIso(fromMs),
beginTo: toLocalIso(toMs)
endFrom: toUtcIso(fromMs),
beginTo: toUtcIso(toMs)
},
(loaded, total) => {
setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total }));
@@ -22,10 +22,10 @@ describe("futureZrvOptions", () => {
]);
});
it("produces naive (no zone) start/stop strings", () => {
it("produces UTC 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.start).toBe(new Date(2026, 5, 3, 13, 0, 0).toISOString());
expect(option.stop).toBe(new Date(2026, 5, 3, 13, 10, 0).toISOString());
expect(option.kppId).toBe("KPP-1");
});
});
@@ -33,11 +33,11 @@ describe("futureZrvOptions", () => {
describe("buildChainPlanRequest", () => {
const start: ZrvOption = {
id: "a", startMs: Date.UTC(2026, 5, 3, 13, 0, 0), endMs: Date.UTC(2026, 5, 3, 13, 10, 0),
start: "2026-06-03T13:00:00", stop: "2026-06-03T13:10:00", kppId: "KPP-1"
start: "2026-06-03T13:00:00.000Z", stop: "2026-06-03T13:10:00.000Z", kppId: "KPP-1"
};
const end: ZrvOption = {
id: "b", startMs: Date.UTC(2026, 5, 3, 15, 0, 0), endMs: Date.UTC(2026, 5, 3, 15, 10, 0),
start: "2026-06-03T15:00:00", stop: "2026-06-03T15:10:00", kppId: "KPP-2"
start: "2026-06-03T15:00:00.000Z", stop: "2026-06-03T15:10:00.000Z", kppId: "KPP-2"
};
it("spans from start zone begin to end zone finish and keeps the start kpp", () => {
@@ -45,16 +45,16 @@ describe("buildChainPlanRequest", () => {
expect(req).toEqual({
spacecraftId: "25544",
basePlanId: "base-1",
windowStart: "2026-06-03T13:00:00",
windowStop: "2026-06-03T15:10:00",
windowStart: "2026-06-03T13:00:00.000Z",
windowStop: "2026-06-03T15:10:00.000Z",
windowKppId: "KPP-1"
});
});
it("allows a single-zone window (start == end zone)", () => {
const req = buildChainPlanRequest("25544", null, start, start);
expect(req.windowStart).toBe("2026-06-03T13:00:00");
expect(req.windowStop).toBe("2026-06-03T13:10:00");
expect(req.windowStart).toBe("2026-06-03T13:00:00.000Z");
expect(req.windowStop).toBe("2026-06-03T13:10:00.000Z");
expect(req.basePlanId).toBeNull();
});
@@ -1,9 +1,9 @@
import { parseLocal, toLocalIso } from "../../utils/localTime";
import { parseLocal, toUtcIso } from "../../utils/localTime";
import type { CreateChainPlanRequest, ZrvWindowResponse } from "../../api/tguPlanApi";
/**
* Опция выбора ЗРВ в диалоге «Создать план». Из стартовой зоны берутся начало окна и КПП, из
* конечной конец окна. `start`/`stop` naive (ISO без зоны, локальное), как ждёт `LocalDateTime` бэка;
* конечной конец окна. `start`/`stop` UTC ISO строки для API;
* `startMs`/`endMs` для сортировки/сравнения и подсветки на таймлайне.
*/
export type ZrvOption = {
@@ -11,9 +11,9 @@ export type ZrvOption = {
id: string;
startMs: number;
endMs: number;
/** Naive (локальное) начала зоны. */
/** UTC ISO начала зоны. */
start: string;
/** Naive (локальное) конца зоны. */
/** UTC ISO конца зоны. */
stop: string;
kppId: string;
};
@@ -28,8 +28,8 @@ export function futureZrvOptions(windows: ZrvWindowResponse[], nowMs: number): Z
id: `${zrv.kppId}|${zrv.startTime}|${zrv.endTime}`,
startMs,
endMs,
start: toNaive(startMs),
stop: toNaive(endMs),
start: toApiUtc(startMs),
stop: toApiUtc(endMs),
kppId: zrv.kppId
};
})
@@ -60,6 +60,6 @@ export function buildChainPlanRequest(
};
}
function toNaive(ms: number): string {
return toLocalIso(ms);
function toApiUtc(ms: number): string {
return toUtcIso(ms);
}
@@ -9,7 +9,7 @@ import type { RequestAtPoint } from "./RequestDetailsPanel";
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 { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { parseLocal, toLocalInputValue, toUtcIso } from "../../utils/localTime";
import { DateTime24Field } from "../../components/timeFields";
import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel";
import { RequestDetailsPanel } from "./RequestDetailsPanel";
@@ -82,7 +82,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 }));
try {
const result = await fetchRequestsForMap(
{ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) },
{ endFrom: toUtcIso(fromMs), beginTo: toUtcIso(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: toLocalIso(parseLocal(form.beginDateTime)),
endDateTime: toLocalIso(parseLocal(form.endDateTime)),
beginDateTime: toUtcIso(parseLocal(form.beginDateTime)),
endDateTime: toUtcIso(parseLocal(form.endDateTime)),
kpp: form.kpp,
highPriorityTransmit: form.highPriorityTransmit,
optics: form.optics,
@@ -23,7 +23,7 @@ import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toUtcIso } from "../../utils/localTime";
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
import { PassStepper } from "./PassStepper";
import { groupPasses } from "./slotPasses";
@@ -469,7 +469,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
setRequestsLoading(true);
setRequestsError(undefined);
try {
const result = await fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) });
const result = await fetchRequestsForMap({ endFrom: toUtcIso(fromMs), beginTo: toUtcIso(toMs) });
setRequestItems(result.items);
setRequestsTotal(result.totalItems);
setRequestsTruncated(result.truncated);
@@ -730,8 +730,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const result = await fetchRequestSlots(
{
requestId: selectedRequestId,
timeStart: toLocalIso(parsedRange.fromMs),
timeStop: toLocalIso(parsedRange.toMs),
timeStart: toUtcIso(parsedRange.fromMs),
timeStop: toUtcIso(parsedRange.toMs),
revSign: revSign || undefined,
// cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать
// покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов,
@@ -1,14 +1,16 @@
/**
* Единый разбор и форматирование времени в локальной зоне.
* Единые утилиты времени для UI.
*
* Бэкенд (Kotlin `LocalDateTime`) отдаёт время как ISO-строку без зоны
* (например `"2026-05-29T01:00:00"`) это настенные часы локальной зоны, в которой
* работают все сервисы, без UTC-семантики. Браузер по умолчанию трактует такую naive-строку
* как локальное время это нам и нужно: разбор, показ и ввод идут в зоне браузера, а
* сериализация назад отдаёт те же настенные часы naive-строкой без зоны.
* Контракт API после UTC-перевода:
* - backend/БД/DTO используют UTC;
* - API отдаёт ISO-8601 UTC с `Z`;
* - UI показывает время в часовом поясе браузера;
* - значения из локальных полей ввода перед отправкой в API конвертируются в UTC ISO.
*
* Строки с явной зоной (`...Z`/офсет, напр. валидность платформ из НСИ) парсятся как
* абсолютный момент и показываются в локальной зоне.
* Legacy-строки API без зоны всё ещё могут встретиться при неполностью обновлённых сервисах.
* В основном UI они обычно приходят из локальных input-полей, поэтому `parseLocal` сохраняет старое
* поведение: naive-строка разбирается как локальное время браузера. После полного перевода API
* новые ответы должны приходить с `Z` и будут разобраны как абсолютный UTC-момент.
*/
function pad(n: number): string {
@@ -60,10 +62,15 @@ export function toLocalInputValue(ms: number): string {
}
/**
* naive `LocalDateTime` без зоны (`YYYY-MM-DDTHH:mm:ss`) из локальных компонент эпохи-мс.
* Все бэкенды ожидают `LocalDateTime` без зоны настенные часы локальной зоны.
* Локальная ISO-строка без зоны (`YYYY-MM-DDTHH:mm:ss`) из локальных компонент.
* Оставлена для локальных UI-расчётов и полей времени, не использовать для отправки API DTO.
*/
export function toLocalIso(ms: number): string {
const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** UTC ISO-строка для DTO/query-параметров API. */
export function toUtcIso(ms: number): string {
return new Date(ms).toISOString();
}