From 395101578832fdf82a705605df73a40c7c4d10a5 Mon Sep 17 00:00:00 2001 From: AaronPlave Date: Fri, 2 Jan 2026 08:57:40 -0800 Subject: [PATCH] WIP change plan time --- src/components/plan/PlanForm.svelte | 149 ++++++++++++++++--- src/components/timeline/LayerDiscrete.svelte | 5 +- src/routes/plans/+page.svelte | 20 +-- src/routes/plans/[id]/+page.svelte | 21 ++- src/stores/__mocks__/plan.mock.ts | 3 - src/stores/activities.ts | 20 ++- src/stores/plan.ts | 39 +++-- src/types/plan.ts | 2 +- src/utilities/activities.test.ts | 4 +- src/utilities/activities.ts | 26 +++- src/utilities/gql.ts | 2 + src/utilities/plan.ts | 40 ++++- 12 files changed, 248 insertions(+), 83 deletions(-) diff --git a/src/components/plan/PlanForm.svelte b/src/components/plan/PlanForm.svelte index 4a801061f9..5a0d5c7cc9 100644 --- a/src/components/plan/PlanForm.svelte +++ b/src/components/plan/PlanForm.svelte @@ -22,15 +22,17 @@ import type { PlanSnapshot as PlanSnapshotType } from '../../types/plan-snapshot'; import type { PlanTagsInsertInput, Tag, TagsChangeEvent } from '../../types/tags'; import effects from '../../utilities/effects'; + import { showConfirmModal } from '../../utilities/modal'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; - import { exportPlan } from '../../utilities/plan'; - import { convertDoyToYmd, formatDate, getShortISOForDate } from '../../utilities/time'; + import { computeDurationString, computePlanTimeUpdate, exportPlan } from '../../utilities/plan'; + import { convertDoyToYmd, formatDate, getDoyTime, getShortISOForDate } from '../../utilities/time'; import { tooltip } from '../../utilities/tooltip'; import { removeQueryParam, setQueryParam } from '../../utilities/url'; import { required, unique } from '../../utilities/validators'; import Collapse from '../Collapse.svelte'; import Loading from '../Loading.svelte'; + import DatePickerField from '../form/DatePickerField.svelte'; import Field from '../form/Field.svelte'; import Input from '../form/Input.svelte'; import CardList from '../ui/CardList.svelte'; @@ -63,19 +65,28 @@ ), ]); let planExporting: boolean = false; - let planStartTime: string = ''; - let planEndTime: string = ''; + let durationString: string = 'None'; + + $: startTimeField = field('', [required, $plugins.time.primary.validate]); + $: endTimeField = field('', [required, $plugins.time.primary.validate]); $: permissionError = $planReadOnly ? PlanStatusMessages.READ_ONLY : 'You do not have permission to edit this plan.'; $: if (plan && plan.model) { hasCreateSnapshotPermission = featurePermissions.planSnapshot.canCreate(user, plan, plan.model) && !$planReadOnly; - planStartTime = formatDate(new Date(plan.start_time), $plugins.time.primary.format); - const endTime = convertDoyToYmd(plan.end_time_doy); - if (endTime) { - planEndTime = formatDate(new Date(endTime), $plugins.time.primary.format); - } else { - planEndTime = ''; + // Initialize start time field from plan + const formattedStartTime = formatDate(new Date(plan.start_time), $plugins.time.primary.format); + if ($startTimeField.value !== formattedStartTime && !$startTimeField.dirty) { + startTimeField.validateAndSet(formattedStartTime); + } + // Initialize end time field from plan + const endTimeYmd = convertDoyToYmd(plan.end_time_doy); + if (endTimeYmd) { + const formattedEndTime = formatDate(new Date(endTimeYmd), $plugins.time.primary.format); + if ($endTimeField.value !== formattedEndTime && !$endTimeField.dirty) { + endTimeField.validateAndSet(formattedEndTime); + } } + updateDurationString(); } $: { if (plan && user) { @@ -171,6 +182,73 @@ } } + function updateDurationString() { + const startTimeMs = $plugins.time.primary.parse($startTimeField.value)?.getTime(); + const endTimeMs = $plugins.time.primary.parse($endTimeField.value)?.getTime(); + durationString = computeDurationString(startTimeMs, endTimeMs, $startTimeField.valid && $endTimeField.valid); + } + + async function onStartTimeChanged() { + updateDurationString(); + await updatePlanTimes(); + } + + async function onEndTimeChanged() { + updateDurationString(); + await updatePlanTimes(); + } + + function revertTimeFields() { + if (!plan) { + return; + } + // Revert start time to original plan value + const originalStartTime = formatDate(new Date(plan.start_time), $plugins.time.primary.format); + startTimeField.validateAndSet(originalStartTime); + + // Revert end time to original plan value + const endTimeYmd = convertDoyToYmd(plan.end_time_doy); + if (endTimeYmd) { + const originalEndTime = formatDate(new Date(endTimeYmd), $plugins.time.primary.format); + endTimeField.validateAndSet(originalEndTime); + } + + updateDurationString(); + } + + async function updatePlanTimes() { + if (!plan || !$startTimeField.dirtyAndValid || !$endTimeField.dirtyAndValid) { + return; + } + + const startTimeDate = $plugins.time.primary.parse($startTimeField.value); + const endTimeDate = $plugins.time.primary.parse($endTimeField.value); + if (!startTimeDate || !endTimeDate) { + return; + } + + const { confirm } = await showConfirmModal( + 'Update', + 'Are you sure you want to change the time range of this plan? This may affect previous simulations and plan snapshots.', + 'Confirm Plan Time Change', + false, + 'Cancel', + ); + + if (!confirm) { + revertTimeFields(); + return; + } + + const startTimeDoy = getDoyTime(startTimeDate); + const endTimeDoy = getDoyTime(endTimeDate); + + const planTimeUpdate = computePlanTimeUpdate(startTimeDoy, endTimeDoy); + if (planTimeUpdate) { + await effects.updatePlan(plan, planTimeUpdate, user); + } + } + async function onExportPlan() { if (plan && !planExporting && activityDirectivesMap) { planExporting = true; @@ -266,20 +344,45 @@ id="modelVersion" /> + + - - - - - - + + diff --git a/src/components/timeline/LayerDiscrete.svelte b/src/components/timeline/LayerDiscrete.svelte index c433f7e736..4e975144fb 100644 --- a/src/components/timeline/LayerDiscrete.svelte +++ b/src/components/timeline/LayerDiscrete.svelte @@ -33,9 +33,7 @@ import { isDeleteEvent } from '../../utilities/keyboardEvents'; import { getActivityDirectiveStartTimeMs, - getDoyTime, getIntervalUnixEpochTime, - getUnixEpochTime, getUnixEpochTimeFromInterval, } from '../../utilities/time'; import { @@ -118,7 +116,6 @@ let dragActivityDirectiveActive: ActivityDirective | null = null; let dragStartX: number | null = null; let minRectSize: number = 4; - let planStartTimeMs: number; let quadtreeActivityDirectives: Quadtree; let quadtreeSpans: Quadtree; let quadtreeExternalEvents: Quadtree; @@ -145,8 +142,8 @@ $: canvasHeightDpr = drawHeight * dpr; $: canvasWidthDpr = drawWidth * dpr; $: rowHeight = discreteOptions.height; + $: planStartTimeMs = planStartTimeYmd ? new Date(planStartTimeYmd).getTime() : 0; $: timelineLocked = timelineLockStatus === TimelineLockStatus.Locked; - $: planStartTimeMs = getUnixEpochTime(getDoyTime(new Date(planStartTimeYmd))); // the following are NOT mutually exclusive. $: canDrawActivities = diff --git a/src/routes/plans/+page.svelte b/src/routes/plans/+page.svelte index 5876eaecc9..ce807a1225 100644 --- a/src/routes/plans/+page.svelte +++ b/src/routes/plans/+page.svelte @@ -45,7 +45,7 @@ import { parseJSONStream } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; - import { exportPlan, isDeprecatedPlanTransfer } from '../../utilities/plan'; + import { computeDurationString, exportPlan, isDeprecatedPlanTransfer } from '../../utilities/plan'; import { convertDoyToYmd, convertUsToDurationString, @@ -538,21 +538,9 @@ } function updateDurationString() { - if ($startTimeField.valid && $endTimeField.valid) { - let startTimeMs = $plugins.time.primary.parse($startTimeField.value)?.getTime(); - let endTimeMs = $plugins.time.primary.parse($endTimeField.value)?.getTime(); - if (typeof startTimeMs === 'number' && typeof endTimeMs === 'number') { - durationString = convertUsToDurationString((endTimeMs - startTimeMs) * 1000); - - if (!durationString) { - durationString = 'None'; - } - } else { - durationString = 'Invalid'; - } - } else { - durationString = 'None'; - } + const startTimeMs = $plugins.time.primary.parse($startTimeField.value)?.getTime(); + const endTimeMs = $plugins.time.primary.parse($endTimeField.value)?.getTime(); + durationString = computeDurationString(startTimeMs, endTimeMs, $startTimeField.valid && $endTimeField.valid); } async function parsePlanFileStream(stream: ReadableStream) { diff --git a/src/routes/plans/[id]/+page.svelte b/src/routes/plans/[id]/+page.svelte index 590f28b5ed..ad334ab6b4 100644 --- a/src/routes/plans/[id]/+page.svelte +++ b/src/routes/plans/[id]/+page.svelte @@ -83,14 +83,13 @@ maxTimeRange, plan, planDatasets, - planEndTimeMs, planId, planModelActivityTypes, planModelId, planReadOnly, planReadOnlyMergeRequest, planReadOnlySnapshot, - planStartTimeMs, + planStartTimeYmd, planTags, resetPlanStores, viewTimeRange, @@ -158,7 +157,6 @@ } from '../../../utilities/simulation'; import { getHumanReadableStatus, statusColors } from '../../../utilities/status'; import { pluralize } from '../../../utilities/text'; - import { getUnixEpochTime } from '../../../utilities/time'; import { showSuccessToast } from '../../../utilities/toast'; import { tooltip } from '../../../utilities/tooltip'; import { getSearchParameterNumber, removeQueryParam, setQueryParam } from '../../../utilities/url'; @@ -288,9 +286,6 @@ } $: if (data.initialPlan) { $initialPlan = data.initialPlan; - $planEndTimeMs = getUnixEpochTime(data.initialPlan.end_time_doy); - $planStartTimeMs = getUnixEpochTime(data.initialPlan.start_time_doy); - $maxTimeRange = { end: $planEndTimeMs, start: $planStartTimeMs }; $simulationDatasetId = -1; const querySimulationDatasetId = $page.url.searchParams.get(SearchParameters.SIMULATION_DATASET_ID); @@ -386,7 +381,7 @@ initializeView({ ...data.initialView }); } - $: if ($initialPlan && $planDatasets) { + $: if ($planId > -1 && $planStartTimeYmd && $planDatasets) { const datasetNames = []; for (const dataset of $planDatasets) { @@ -403,9 +398,9 @@ $externalResources = []; effects .getResourcesExternal( - $initialPlan.id, + $planId, $simulationDatasetId > -1 ? $simulationDatasetId : null, - $initialPlan.start_time, + $planStartTimeYmd, data.user, resourcesExternalAbortController.signal, ) @@ -422,7 +417,11 @@ selectActivity(null, null); } - $: if ($initialPlan && $simulationDataset !== null && getSimulationStatus($simulationDataset) === Status.Complete) { + $: if ( + $planStartTimeYmd && + $simulationDataset !== null && + getSimulationStatus($simulationDataset) === Status.Complete + ) { const datasetId = $simulationDataset.dataset_id; simulationDataAbortController?.abort(); simulationDataAbortController = new AbortController(); @@ -430,7 +429,7 @@ effects .getSpans( datasetId, - $simulationDataset.simulation_start_time ?? $initialPlan.start_time, + $simulationDataset.simulation_start_time ?? $planStartTimeYmd, data.user, simulationDataAbortController.signal, ) diff --git a/src/stores/__mocks__/plan.mock.ts b/src/stores/__mocks__/plan.mock.ts index 760eb81ef9..37f957f9c6 100644 --- a/src/stores/__mocks__/plan.mock.ts +++ b/src/stores/__mocks__/plan.mock.ts @@ -73,9 +73,6 @@ export function resetPlanStores() { createPlanError.set(null); creatingPlan.set(false); initialPlan.set(null); - planEndTimeMs.set(0); - planStartTimeMs.set(0); - maxTimeRange.set({ end: 0, start: 0 }); viewTimeRange.set({ end: 0, start: 0 }); } diff --git a/src/stores/activities.ts b/src/stores/activities.ts index fdeee8f129..412e2bd3bf 100644 --- a/src/stores/activities.ts +++ b/src/stores/activities.ts @@ -10,7 +10,7 @@ import type { DefaultEffectiveArguments, DefaultEffectiveArgumentsMap } from '.. import type { SpanId } from '../types/simulation'; import { computeActivityDirectivesMap } from '../utilities/activities'; import gql from '../utilities/gql'; -import { initialPlan, planId } from './plan'; +import { planEndTimeDoy, planId, planStartTimeYmd } from './plan'; import { planSnapshotActivityDirectives, planSnapshotId } from './planSnapshots'; import { selectedSpanId, spansMap, spanUtilityMaps } from './simulation'; import { gqlSubscribable } from './subscribable'; @@ -67,24 +67,34 @@ export const activityArgumentDefaultsMap: Readable ); export const activityDirectivesMap = derived( - [activityDirectivesDB, planSnapshotId, planSnapshotActivityDirectives, initialPlan, spansMap, spanUtilityMaps], + [ + activityDirectivesDB, + planSnapshotId, + planSnapshotActivityDirectives, + planStartTimeYmd, + planEndTimeDoy, + spansMap, + spanUtilityMaps, + ], ([ $activityDirectivesDB, $planSnapshotId, $planSnapshotActivityDirectives, - $initialPlan, + $planStartTimeYmd, + $planEndTimeDoy, $spansMap, $spanUtilityMaps, ]) => { if (!$activityDirectivesDB || !$spansMap) { return null; } - if ($initialPlan === null) { + if (!$planStartTimeYmd) { return {}; } return computeActivityDirectivesMap( $planSnapshotId !== null ? $planSnapshotActivityDirectives : $activityDirectivesDB || [], - $initialPlan, + $planStartTimeYmd, + $planEndTimeDoy, $spansMap, $spanUtilityMaps, ); diff --git a/src/stores/plan.ts b/src/stores/plan.ts index 00a9d69d37..624c9833eb 100644 --- a/src/stores/plan.ts +++ b/src/stores/plan.ts @@ -5,6 +5,7 @@ import type { PlanDataset } from '../types/simulation'; import type { Tag } from '../types/tags'; import type { TimeRange } from '../types/timeline'; import gql from '../utilities/gql'; +import { getDoyTime, getDoyTimeFromInterval, getUnixEpochTime } from '../utilities/time'; import { gqlSubscribable } from './subscribable'; /* Writeable. */ @@ -25,12 +26,6 @@ export const createPlanError: Writable = writable(null); export const creatingPlan: Writable = writable(false); -export const planEndTimeMs: Writable = writable(0); - -export const planStartTimeMs: Writable = writable(0); - -export const maxTimeRange: Writable = writable({ end: 0, start: 0 }); - export const viewTimeRange: Writable = writable({ end: 0, start: 0 }); /* "plan" store dependencies */ @@ -46,16 +41,43 @@ export const plan: Readable = derived([initialPlan, planMetadata], if (!$initialPlan) { return null; } - return { + + const newPlan: Plan = { ...$initialPlan, ...($planMetadata || {}), }; + + // Recompute start_time and start_time_doy since these may have changed in planMetadata update + newPlan.start_time_doy = getDoyTime(new Date(newPlan.start_time)); + newPlan.end_time_doy = getDoyTimeFromInterval(newPlan.start_time, newPlan.duration); + + return newPlan; }); export const planModelId: Readable = derived(plan, $plan => $plan?.model?.id ?? -1); export const planModelRevision: Readable = derived(plan, $plan => $plan?.model?.revision ?? -1); +export const planStartTimeYmd: Readable = derived(plan, $plan => $plan?.start_time ?? ''); + +export const planEndTimeDoy: Readable = derived(plan, $plan => $plan?.end_time_doy ?? ''); + +export const planStartTimeMs: Readable = derived(plan, $plan => + $plan?.start_time_doy ? getUnixEpochTime($plan?.start_time_doy) : 0, +); + +export const planEndTimeMs: Readable = derived(plan, $plan => + $plan?.end_time_doy ? getUnixEpochTime($plan?.end_time_doy) : 0, +); + +export const maxTimeRange: Readable = derived( + [planStartTimeMs, planEndTimeMs], + ([$planStartTimeMs, $planEndTimeMs]) => ({ + end: $planEndTimeMs, + start: $planStartTimeMs, + }), +); + /* Other Subscriptions. */ export const planModelActivityTypes = gqlSubscribable( @@ -123,9 +145,6 @@ export function resetPlanStores() { createPlanError.set(null); creatingPlan.set(false); initialPlan.set(null); - planEndTimeMs.set(0); - planStartTimeMs.set(0); - maxTimeRange.set({ end: 0, start: 0 }); viewTimeRange.set({ end: 0, start: 0 }); } diff --git a/src/types/plan.ts b/src/types/plan.ts index 87b57ee30b..bd51cd0426 100644 --- a/src/types/plan.ts +++ b/src/types/plan.ts @@ -121,7 +121,7 @@ export type DeprecatedPlanTransfer = Omit; export type PlanSlim = Pick< diff --git a/src/utilities/activities.test.ts b/src/utilities/activities.test.ts index c08a57e486..d80d84212a 100644 --- a/src/utilities/activities.test.ts +++ b/src/utilities/activities.test.ts @@ -529,9 +529,11 @@ describe('updateAnchorStartOffset', () => { spanIdToDirectiveIdMap, }; + const testPlan = getTestPlan(); const activityDirectivesMap = computeActivityDirectivesMap( getTestActivityDirectivesDB(), - getTestPlan(), + testPlan.start_time, + testPlan.end_time_doy, spans, spanUtilityMaps, ); diff --git a/src/utilities/activities.ts b/src/utilities/activities.ts index b725e387ba..0e9c670b97 100644 --- a/src/utilities/activities.ts +++ b/src/utilities/activities.ts @@ -134,7 +134,8 @@ export enum ActivityDeletionAction { export function computeActivityDirectivesMap( activityDirectiveDBs: ActivityDirectiveDB[], - plan: Plan, + planStartTimeYmd: string, + planEndTimeDoy: string, spansMap: SpansMap, spanUtilityMaps: SpanUtilityMaps, ) { @@ -148,7 +149,8 @@ export function computeActivityDirectivesMap( preprocessActivityDirectiveDB( activityDirectiveDB, directiveDBMap, - plan, + planStartTimeYmd, + planEndTimeDoy, spansMap, spanUtilityMaps, cachedStartTimes, @@ -160,17 +162,18 @@ export function computeActivityDirectivesMap( export function preprocessActivityDirectiveDB( activityDirectiveDB: ActivityDirectiveDB, activityDirectivesMap: ActivityDirectivesMap, - plan: Plan, + planStartTimeYmd: string, + planEndTimeDoy: string, spansMap: SpansMap, spanUtilityMaps: SpanUtilityMaps, cachedStartTimes = {}, ): ActivityDirective { let start_time_ms = -1; - if (plan && typeof plan.start_time === 'string') { + if (planStartTimeYmd) { start_time_ms = getActivityDirectiveStartTimeMs( activityDirectiveDB.id, - plan.start_time, - plan.end_time_doy, + planStartTimeYmd, + planEndTimeDoy, activityDirectivesMap, spansMap, spanUtilityMaps, @@ -313,7 +316,13 @@ export function addAbsoluteTimeToRevision( spansMap: SpansMap, spanUtilityMaps: SpanUtilityMaps, ): ActivityDirectiveRevision { - const activityDirectivesMap = computeActivityDirectivesMap(activitiesDirectivesDB, plan, spansMap, spanUtilityMaps); + const activityDirectivesMap = computeActivityDirectivesMap( + activitiesDirectivesDB, + plan.start_time, + plan.end_time_doy, + spansMap, + spanUtilityMaps, + ); //Temporarily overlay the currentActivity with the revision const tempDirectivesMap: ActivityDirectivesMap = { ...activityDirectivesMap, @@ -387,7 +396,8 @@ export function packActivityDirectivesInPlan( const activityDirectivesMap = computeActivityDirectivesMap( activitiesDirectivesDB, - sourcePlan, + sourcePlan.start_time, + sourcePlan.end_time_doy, spansMap, spanUtilityMaps, ); diff --git a/src/utilities/gql.ts b/src/utilities/gql.ts index 241289e9a9..2ce06c4d65 100644 --- a/src/utilities/gql.ts +++ b/src/utilities/gql.ts @@ -3062,6 +3062,8 @@ const gql = { subscription SubPlanMetadata($planId: Int!) { plan_metadata: ${Queries.PLAN}(id: $planId) { id + start_time + duration model: mission_model { id jar_id diff --git a/src/utilities/plan.ts b/src/utilities/plan.ts index 289aa0071a..8eeb7dc7d6 100644 --- a/src/utilities/plan.ts +++ b/src/utilities/plan.ts @@ -12,7 +12,45 @@ import type { import type { Simulation } from '../types/simulation'; import effects from './effects'; import { downloadJSON, unique } from './generic'; -import { convertDoyToYmd, switchISOTimezoneRepresentation } from './time'; +import { convertDoyToYmd, convertUsToDurationString, getIntervalFromDoyRange, switchISOTimezoneRepresentation } from './time'; + +/** + * Computes a human-readable duration string from start and end times. + * Returns 'None' if fields are invalid or times can't be parsed, 'Invalid' if parsing fails. + */ +export function computeDurationString( + startTimeMs: number | null | undefined, + endTimeMs: number | null | undefined, + areFieldsValid: boolean, +): string { + if (!areFieldsValid) { + return 'None'; + } + if (typeof startTimeMs === 'number' && typeof endTimeMs === 'number') { + const duration = convertUsToDurationString((endTimeMs - startTimeMs) * 1000); + return duration || 'None'; + } + return 'Invalid'; +} + +/** + * Computes the plan update payload for a time change. + * Returns the start_time (as ISO string) and duration (as Postgres interval). + */ +export function computePlanTimeUpdate( + startTimeDoy: string, + endTimeDoy: string, +): { duration: string; start_time: string } | null { + const startTimeYmd = convertDoyToYmd(startTimeDoy); + if (!startTimeYmd) { + return null; + } + const duration = getIntervalFromDoyRange(startTimeDoy, endTimeDoy); + return { + duration, + start_time: startTimeYmd, + }; +} export async function getPlanForTransfer( plan: Plan | PlanSlim,