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
@@ -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,