feat(FN-1667): merge fusion/fn-1667 (auto-resolved)
- test(FN-1667): complete Step 4 — verify roadmap model - feat(FN-1667): complete Step 3 — document roadmap model - feat(FN-1667): complete Step 2 — add roadmap ordering helpers - feat(FN-1667): complete Step 1 — define roadmap contracts
This commit is contained in:
@@ -168,6 +168,38 @@ export {
|
||||
__resetSummarizeState,
|
||||
} from "./ai-summarize.js";
|
||||
|
||||
// ── Standalone Roadmap Model ───────────────────────────────────────────
|
||||
|
||||
export type {
|
||||
Roadmap,
|
||||
RoadmapMilestone,
|
||||
RoadmapFeature,
|
||||
RoadmapCreateInput,
|
||||
RoadmapUpdateInput,
|
||||
RoadmapMilestoneCreateInput,
|
||||
RoadmapMilestoneUpdateInput,
|
||||
RoadmapFeatureCreateInput,
|
||||
RoadmapFeatureUpdateInput,
|
||||
RoadmapMilestoneReorderInput,
|
||||
RoadmapFeatureReorderInput,
|
||||
RoadmapFeatureMoveInput,
|
||||
RoadmapFeatureMoveResult,
|
||||
RoadmapMilestoneWithFeatures,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapExportBundle,
|
||||
RoadmapFeatureSourceRef,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
RoadmapMissionPlanningMilestoneHandoff,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
} from "./roadmap-types.js";
|
||||
export {
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
applyRoadmapMilestoneReorder,
|
||||
normalizeRoadmapFeatureOrder,
|
||||
applyRoadmapFeatureReorder,
|
||||
moveRoadmapFeature,
|
||||
} from "./roadmap-ordering.js";
|
||||
|
||||
// ── Mission Hierarchy Types ────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
|
||||
252
packages/core/src/roadmap-ordering.test.ts
Normal file
252
packages/core/src/roadmap-ordering.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyRoadmapFeatureReorder,
|
||||
applyRoadmapMilestoneReorder,
|
||||
moveRoadmapFeature,
|
||||
normalizeRoadmapFeatureOrder,
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
} from "./roadmap-ordering.js";
|
||||
import type { RoadmapFeature, RoadmapMilestone } from "./roadmap-types.js";
|
||||
|
||||
function createMilestone(
|
||||
id: string,
|
||||
roadmapId: string,
|
||||
orderIndex: number,
|
||||
createdAt: string,
|
||||
): RoadmapMilestone {
|
||||
return {
|
||||
id,
|
||||
roadmapId,
|
||||
title: id,
|
||||
description: `${id} description`,
|
||||
orderIndex,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function createFeature(
|
||||
id: string,
|
||||
milestoneId: string,
|
||||
orderIndex: number,
|
||||
createdAt: string,
|
||||
): RoadmapFeature {
|
||||
return {
|
||||
id,
|
||||
milestoneId,
|
||||
title: id,
|
||||
description: `${id} description`,
|
||||
orderIndex,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe("roadmap-ordering", () => {
|
||||
describe("normalizeRoadmapMilestoneOrder", () => {
|
||||
it("repairs milestone ordering deterministically using createdAt and id tiebreakers", () => {
|
||||
const milestones = [
|
||||
createMilestone("RMS-C", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
|
||||
createMilestone("RMS-B", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createMilestone("RMS-A", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
const normalized = normalizeRoadmapMilestoneOrder(milestones);
|
||||
|
||||
expect(normalized.map((milestone) => milestone.id)).toEqual([
|
||||
"RMS-A",
|
||||
"RMS-B",
|
||||
"RMS-C",
|
||||
]);
|
||||
expect(normalized.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
|
||||
expect(milestones.map((milestone) => milestone.orderIndex)).toEqual([2, 1, 1]);
|
||||
});
|
||||
|
||||
it("rejects mixed-roadmap milestone scopes", () => {
|
||||
const milestones = [
|
||||
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createMilestone("RMS-2", "RM-2", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
expect(() => normalizeRoadmapMilestoneOrder(milestones)).toThrow(
|
||||
"Milestone RMS-2 does not belong to roadmap RM-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRoadmapMilestoneReorder", () => {
|
||||
it("reorders milestones and rewrites contiguous order indexes", () => {
|
||||
const milestones = [
|
||||
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createMilestone("RMS-3", "RM-1", 2, "2026-04-13T00:00:02.000Z"),
|
||||
];
|
||||
|
||||
const reordered = applyRoadmapMilestoneReorder(milestones, {
|
||||
roadmapId: "RM-1",
|
||||
orderedMilestoneIds: ["RMS-3", "RMS-1", "RMS-2"],
|
||||
});
|
||||
|
||||
expect(reordered.map((milestone) => milestone.id)).toEqual([
|
||||
"RMS-3",
|
||||
"RMS-1",
|
||||
"RMS-2",
|
||||
]);
|
||||
expect(reordered.map((milestone) => milestone.orderIndex)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("rejects duplicate milestone ids in reorder input", () => {
|
||||
const milestones = [
|
||||
createMilestone("RMS-1", "RM-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createMilestone("RMS-2", "RM-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
applyRoadmapMilestoneReorder(milestones, {
|
||||
roadmapId: "RM-1",
|
||||
orderedMilestoneIds: ["RMS-2", "RMS-2"],
|
||||
}),
|
||||
).toThrow("Duplicate milestone id in requested order: RMS-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeRoadmapFeatureOrder", () => {
|
||||
it("repairs feature ordering deterministically using createdAt and id tiebreakers", () => {
|
||||
const features = [
|
||||
createFeature("RF-C", "RMS-1", 3, "2026-04-13T00:00:03.000Z"),
|
||||
createFeature("RF-B", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createFeature("RF-A", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
const normalized = normalizeRoadmapFeatureOrder(features);
|
||||
|
||||
expect(normalized.map((feature) => feature.id)).toEqual([
|
||||
"RF-A",
|
||||
"RF-B",
|
||||
"RF-C",
|
||||
]);
|
||||
expect(normalized.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRoadmapFeatureReorder", () => {
|
||||
it("reorders features within a milestone and rewrites contiguous order indexes", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
|
||||
];
|
||||
|
||||
const reordered = applyRoadmapFeatureReorder(features, {
|
||||
roadmapId: "RM-1",
|
||||
milestoneId: "RMS-1",
|
||||
orderedFeatureIds: ["RF-2", "RF-3", "RF-1"],
|
||||
});
|
||||
|
||||
expect(reordered.map((feature) => feature.id)).toEqual([
|
||||
"RF-2",
|
||||
"RF-3",
|
||||
"RF-1",
|
||||
]);
|
||||
expect(reordered.map((feature) => feature.orderIndex)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("rejects partial feature reorder payloads", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
applyRoadmapFeatureReorder(features, {
|
||||
roadmapId: "RM-1",
|
||||
milestoneId: "RMS-1",
|
||||
orderedFeatureIds: ["RF-2"],
|
||||
}),
|
||||
).toThrow("Expected 2 feature ids but received 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveRoadmapFeature", () => {
|
||||
it("moves a feature across milestones and normalizes both milestone orders", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-SOURCE", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createFeature("RF-3", "RMS-TARGET", 0, "2026-04-13T00:00:02.000Z"),
|
||||
createFeature("RF-4", "RMS-TARGET", 1, "2026-04-13T00:00:03.000Z"),
|
||||
];
|
||||
|
||||
const result = moveRoadmapFeature(features, {
|
||||
roadmapId: "RM-1",
|
||||
featureId: "RF-2",
|
||||
fromMilestoneId: "RMS-SOURCE",
|
||||
toMilestoneId: "RMS-TARGET",
|
||||
targetOrderIndex: 1,
|
||||
});
|
||||
|
||||
expect(result.movedFeature).toMatchObject({
|
||||
id: "RF-2",
|
||||
milestoneId: "RMS-TARGET",
|
||||
orderIndex: 1,
|
||||
});
|
||||
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
|
||||
"RF-1",
|
||||
]);
|
||||
expect(result.sourceMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([0]);
|
||||
expect(result.targetMilestoneFeatures.map((feature) => feature.id)).toEqual([
|
||||
"RF-3",
|
||||
"RF-2",
|
||||
"RF-4",
|
||||
]);
|
||||
expect(result.targetMilestoneFeatures.map((feature) => feature.orderIndex)).toEqual([
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
]);
|
||||
expect(result.affectedFeatures).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("clamps same-milestone moves into range and returns a single normalized list", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
|
||||
createFeature("RF-3", "RMS-1", 2, "2026-04-13T00:00:02.000Z"),
|
||||
];
|
||||
|
||||
const result = moveRoadmapFeature(features, {
|
||||
roadmapId: "RM-1",
|
||||
featureId: "RF-1",
|
||||
fromMilestoneId: "RMS-1",
|
||||
toMilestoneId: "RMS-1",
|
||||
targetOrderIndex: 99,
|
||||
});
|
||||
|
||||
expect(result.sourceMilestoneFeatures.map((feature) => feature.id)).toEqual([
|
||||
"RF-2",
|
||||
"RF-3",
|
||||
"RF-1",
|
||||
]);
|
||||
expect(result.targetMilestoneFeatures).toEqual(result.sourceMilestoneFeatures);
|
||||
expect(result.movedFeature.orderIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects features outside the affected milestone scope", () => {
|
||||
const features = [
|
||||
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
|
||||
createFeature("RF-2", "RMS-OTHER", 0, "2026-04-13T00:00:01.000Z"),
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
moveRoadmapFeature(features, {
|
||||
roadmapId: "RM-1",
|
||||
featureId: "RF-1",
|
||||
fromMilestoneId: "RMS-SOURCE",
|
||||
toMilestoneId: "RMS-TARGET",
|
||||
targetOrderIndex: 0,
|
||||
}),
|
||||
).toThrow(
|
||||
"Feature RF-2 is outside the affected milestone scope (RMS-SOURCE → RMS-TARGET)",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
311
packages/core/src/roadmap-ordering.ts
Normal file
311
packages/core/src/roadmap-ordering.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type {
|
||||
RoadmapFeature,
|
||||
RoadmapFeatureMoveInput,
|
||||
RoadmapFeatureMoveResult,
|
||||
RoadmapFeatureReorderInput,
|
||||
RoadmapMilestone,
|
||||
RoadmapMilestoneReorderInput,
|
||||
} from "./roadmap-types.js";
|
||||
|
||||
/**
|
||||
* Pure ordering helpers for the standalone roadmap model.
|
||||
*
|
||||
* Ordering invariants enforced by this module:
|
||||
* - helpers operate on scoped arrays (single roadmap for milestones, single
|
||||
* milestone for feature reorders, source+target milestones for feature moves)
|
||||
* - normalized `orderIndex` values are always contiguous + 0-based
|
||||
* - when stored order data is conflicting, helpers repair deterministically via
|
||||
* `orderIndex ASC`, `createdAt ASC`, `id ASC`
|
||||
* - explicit reorder helpers reject partial or duplicate ID lists instead of
|
||||
* guessing user intent
|
||||
*/
|
||||
|
||||
interface OrderedEntity {
|
||||
id: string;
|
||||
orderIndex: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function compareOrderedEntities<T extends OrderedEntity>(a: T, b: T): number {
|
||||
if (a.orderIndex !== b.orderIndex) {
|
||||
return a.orderIndex - b.orderIndex;
|
||||
}
|
||||
|
||||
if (a.createdAt !== b.createdAt) {
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function clampInsertionIndex(targetIndex: number, length: number): number {
|
||||
if (!Number.isFinite(targetIndex)) {
|
||||
return length;
|
||||
}
|
||||
|
||||
const normalized = Math.trunc(targetIndex);
|
||||
if (normalized < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (normalized > length) {
|
||||
return length;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertScopedRoadmapMilestones(
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
roadmapId: string,
|
||||
): void {
|
||||
for (const milestone of milestones) {
|
||||
if (milestone.roadmapId !== roadmapId) {
|
||||
throw new Error(
|
||||
`Milestone ${milestone.id} does not belong to roadmap ${roadmapId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertScopedMilestoneFeatures(
|
||||
features: readonly RoadmapFeature[],
|
||||
milestoneId: string,
|
||||
): void {
|
||||
for (const feature of features) {
|
||||
if (feature.milestoneId !== milestoneId) {
|
||||
throw new Error(
|
||||
`Feature ${feature.id} does not belong to milestone ${milestoneId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertScopedMoveFeatures(
|
||||
features: readonly RoadmapFeature[],
|
||||
fromMilestoneId: string,
|
||||
toMilestoneId: string,
|
||||
): void {
|
||||
const validMilestoneIds = new Set([fromMilestoneId, toMilestoneId]);
|
||||
|
||||
for (const feature of features) {
|
||||
if (!validMilestoneIds.has(feature.milestoneId)) {
|
||||
throw new Error(
|
||||
`Feature ${feature.id} is outside the affected milestone scope (${fromMilestoneId} → ${toMilestoneId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactIdSet(
|
||||
entityLabel: string,
|
||||
actualIds: readonly string[],
|
||||
orderedIds: readonly string[],
|
||||
): void {
|
||||
const requestedIds = new Set<string>();
|
||||
|
||||
for (const id of orderedIds) {
|
||||
if (requestedIds.has(id)) {
|
||||
throw new Error(`Duplicate ${entityLabel} id in requested order: ${id}`);
|
||||
}
|
||||
requestedIds.add(id);
|
||||
}
|
||||
|
||||
if (actualIds.length !== orderedIds.length) {
|
||||
throw new Error(
|
||||
`Expected ${actualIds.length} ${entityLabel} ids but received ${orderedIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const actualIdSet = new Set(actualIds);
|
||||
|
||||
for (const id of orderedIds) {
|
||||
if (!actualIdSet.has(id)) {
|
||||
throw new Error(`${capitalize(entityLabel)} ${id} not found in scoped list`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of actualIds) {
|
||||
if (!requestedIds.has(id)) {
|
||||
throw new Error(`Missing ${entityLabel} id in requested order: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function assignContiguousOrder<T extends OrderedEntity>(items: readonly T[]): T[] {
|
||||
return items.map((item, orderIndex) => {
|
||||
if (item.orderIndex === orderIndex) {
|
||||
return { ...item };
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
orderIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs milestone ordering for a single roadmap scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export function normalizeRoadmapMilestoneOrder(
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
): RoadmapMilestone[] {
|
||||
if (milestones.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
assertScopedRoadmapMilestones(milestones, milestones[0].roadmapId);
|
||||
|
||||
return assignContiguousOrder(
|
||||
[...milestones].sort(compareOrderedEntities),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an explicit milestone reorder for a single roadmap scope.
|
||||
*
|
||||
* The caller must provide the complete milestone ID set exactly once. Partial
|
||||
* or duplicate lists are rejected to keep reorders deterministic.
|
||||
*/
|
||||
export function applyRoadmapMilestoneReorder(
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
input: RoadmapMilestoneReorderInput,
|
||||
): RoadmapMilestone[] {
|
||||
assertScopedRoadmapMilestones(milestones, input.roadmapId);
|
||||
|
||||
const normalized = normalizeRoadmapMilestoneOrder(milestones);
|
||||
const ids = normalized.map((milestone) => milestone.id);
|
||||
assertExactIdSet("milestone", ids, input.orderedMilestoneIds);
|
||||
|
||||
const byId = new Map(normalized.map((milestone) => [milestone.id, milestone]));
|
||||
return assignContiguousOrder(
|
||||
input.orderedMilestoneIds.map((id) => byId.get(id)!),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs feature ordering for a single milestone scope.
|
||||
*
|
||||
* Deterministic repair order is `orderIndex ASC`, `createdAt ASC`, then `id ASC`.
|
||||
*/
|
||||
export function normalizeRoadmapFeatureOrder(
|
||||
features: readonly RoadmapFeature[],
|
||||
): RoadmapFeature[] {
|
||||
if (features.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
assertScopedMilestoneFeatures(features, features[0].milestoneId);
|
||||
|
||||
return assignContiguousOrder(
|
||||
[...features].sort(compareOrderedEntities),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an explicit feature reorder for a single milestone scope.
|
||||
*
|
||||
* The caller must provide the complete feature ID set exactly once. Partial or
|
||||
* duplicate lists are rejected to keep reorder behavior deterministic.
|
||||
*/
|
||||
export function applyRoadmapFeatureReorder(
|
||||
features: readonly RoadmapFeature[],
|
||||
input: RoadmapFeatureReorderInput,
|
||||
): RoadmapFeature[] {
|
||||
assertScopedMilestoneFeatures(features, input.milestoneId);
|
||||
|
||||
const normalized = normalizeRoadmapFeatureOrder(features);
|
||||
const ids = normalized.map((feature) => feature.id);
|
||||
assertExactIdSet("feature", ids, input.orderedFeatureIds);
|
||||
|
||||
const byId = new Map(normalized.map((feature) => [feature.id, feature]));
|
||||
return assignContiguousOrder(
|
||||
input.orderedFeatureIds.map((id) => byId.get(id)!),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a feature within the affected milestone scope and deterministically
|
||||
* normalizes both source and destination order.
|
||||
*
|
||||
* For cross-milestone moves, pass the combined feature list from the source and
|
||||
* target milestones. For within-milestone moves, pass the current milestone's
|
||||
* feature list. `targetOrderIndex` is clamped into the destination range.
|
||||
*/
|
||||
export function moveRoadmapFeature(
|
||||
features: readonly RoadmapFeature[],
|
||||
input: RoadmapFeatureMoveInput,
|
||||
): RoadmapFeatureMoveResult {
|
||||
assertScopedMoveFeatures(features, input.fromMilestoneId, input.toMilestoneId);
|
||||
|
||||
const existingFeature = features.find((feature) => feature.id === input.featureId);
|
||||
if (!existingFeature) {
|
||||
throw new Error(`Feature ${input.featureId} not found in affected milestone scope`);
|
||||
}
|
||||
|
||||
if (existingFeature.milestoneId !== input.fromMilestoneId) {
|
||||
throw new Error(
|
||||
`Feature ${input.featureId} does not belong to milestone ${input.fromMilestoneId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceFeatures = normalizeRoadmapFeatureOrder(
|
||||
features.filter((feature) => feature.milestoneId === input.fromMilestoneId),
|
||||
);
|
||||
const sourceWithoutFeature = sourceFeatures.filter(
|
||||
(feature) => feature.id !== input.featureId,
|
||||
);
|
||||
|
||||
if (input.fromMilestoneId === input.toMilestoneId) {
|
||||
const insertionIndex = clampInsertionIndex(
|
||||
input.targetOrderIndex,
|
||||
sourceWithoutFeature.length,
|
||||
);
|
||||
const reordered = [...sourceWithoutFeature];
|
||||
reordered.splice(insertionIndex, 0, {
|
||||
...existingFeature,
|
||||
milestoneId: input.toMilestoneId,
|
||||
orderIndex: insertionIndex,
|
||||
});
|
||||
|
||||
const normalized = assignContiguousOrder(reordered);
|
||||
const movedFeature = normalized.find((feature) => feature.id === input.featureId)!;
|
||||
|
||||
return {
|
||||
movedFeature,
|
||||
affectedFeatures: normalized,
|
||||
sourceMilestoneFeatures: normalized,
|
||||
targetMilestoneFeatures: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
const targetFeatures = normalizeRoadmapFeatureOrder(
|
||||
features.filter((feature) => feature.milestoneId === input.toMilestoneId),
|
||||
);
|
||||
const insertionIndex = clampInsertionIndex(
|
||||
input.targetOrderIndex,
|
||||
targetFeatures.length,
|
||||
);
|
||||
const targetWithInsertedFeature = [...targetFeatures];
|
||||
targetWithInsertedFeature.splice(insertionIndex, 0, {
|
||||
...existingFeature,
|
||||
milestoneId: input.toMilestoneId,
|
||||
orderIndex: insertionIndex,
|
||||
});
|
||||
|
||||
const normalizedSource = assignContiguousOrder(sourceWithoutFeature);
|
||||
const normalizedTarget = assignContiguousOrder(targetWithInsertedFeature);
|
||||
const movedFeature = normalizedTarget.find((feature) => feature.id === input.featureId)!;
|
||||
|
||||
return {
|
||||
movedFeature,
|
||||
affectedFeatures: [...normalizedSource, ...normalizedTarget],
|
||||
sourceMilestoneFeatures: normalizedSource,
|
||||
targetMilestoneFeatures: normalizedTarget,
|
||||
};
|
||||
}
|
||||
310
packages/core/src/roadmap-types.ts
Normal file
310
packages/core/src/roadmap-types.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Standalone roadmap planning types.
|
||||
*
|
||||
* This model is intentionally separate from the mission hierarchy so roadmap
|
||||
* work can evolve independently of `MissionStore`/`MissionManager`.
|
||||
*
|
||||
* Core ordering invariants:
|
||||
* - milestone ordering is scoped to a single roadmap and must be contiguous + 0-based
|
||||
* - feature ordering is scoped to a single milestone and must be contiguous + 0-based
|
||||
* - cross-milestone feature moves must renumber both the source and target
|
||||
* milestone deterministically after the move
|
||||
* - whenever stored order data is incomplete or conflicting, consumers should
|
||||
* repair it using a stable tie-breaker (`createdAt`, then `id`, both ASC)
|
||||
*
|
||||
* These contracts are persistence-agnostic and UI-agnostic. They define the
|
||||
* canonical domain surface that downstream storage, API, and dashboard work use.
|
||||
*
|
||||
* @module roadmap-types
|
||||
*/
|
||||
|
||||
/**
|
||||
* A standalone roadmap container.
|
||||
*
|
||||
* Roadmaps do not reuse mission lifecycle or mission status concepts. They are
|
||||
* lightweight planning artifacts that own ordered milestones.
|
||||
*/
|
||||
export interface Roadmap {
|
||||
/** Unique identifier (for example `RM-01HXYZ...`) */
|
||||
id: string;
|
||||
/** Display title shown in roadmap lists and detail views */
|
||||
title: string;
|
||||
/** Optional long-form planning context for the roadmap */
|
||||
description?: string;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A milestone within a roadmap.
|
||||
*
|
||||
* `orderIndex` is the canonical persisted ordering field. It is always scoped to
|
||||
* the parent roadmap and must remain contiguous + 0-based after reorder flows.
|
||||
*/
|
||||
export interface RoadmapMilestone {
|
||||
/** Unique identifier (for example `RMS-01HXYZ...`) */
|
||||
id: string;
|
||||
/** Parent roadmap ID */
|
||||
roadmapId: string;
|
||||
/** Display title for the milestone */
|
||||
title: string;
|
||||
/** Optional description of the milestone's goals */
|
||||
description?: string;
|
||||
/** 0-based contiguous ordering within the roadmap */
|
||||
orderIndex: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature within a roadmap milestone.
|
||||
*
|
||||
* `orderIndex` is scoped to the parent milestone. Cross-milestone moves must
|
||||
* update `milestoneId` and then normalize both affected milestone lists back to
|
||||
* contiguous 0-based order.
|
||||
*/
|
||||
export interface RoadmapFeature {
|
||||
/** Unique identifier (for example `RF-01HXYZ...`) */
|
||||
id: string;
|
||||
/** Parent milestone ID */
|
||||
milestoneId: string;
|
||||
/** Display title for the feature */
|
||||
title: string;
|
||||
/** Optional description of the feature's intent */
|
||||
description?: string;
|
||||
/** 0-based contiguous ordering within the parent milestone */
|
||||
orderIndex: number;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── CRUD Input Types ────────────────────────────────────────────────
|
||||
|
||||
/** Input for creating a roadmap. */
|
||||
export interface RoadmapCreateInput {
|
||||
/** Display title of the roadmap (required) */
|
||||
title: string;
|
||||
/** Optional roadmap description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for updating roadmap metadata. Ordering is handled by dedicated move/reorder DTOs. */
|
||||
export interface RoadmapUpdateInput {
|
||||
/** Updated display title */
|
||||
title?: string;
|
||||
/** Updated roadmap description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for creating a milestone inside a roadmap. */
|
||||
export interface RoadmapMilestoneCreateInput {
|
||||
/** Display title of the milestone (required) */
|
||||
title: string;
|
||||
/** Optional milestone description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for updating milestone metadata. Ordering is handled separately. */
|
||||
export interface RoadmapMilestoneUpdateInput {
|
||||
/** Updated milestone title */
|
||||
title?: string;
|
||||
/** Updated milestone description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for creating a feature inside a milestone. */
|
||||
export interface RoadmapFeatureCreateInput {
|
||||
/** Display title of the feature (required) */
|
||||
title: string;
|
||||
/** Optional feature description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for updating feature metadata. Ordering is handled separately. */
|
||||
export interface RoadmapFeatureUpdateInput {
|
||||
/** Updated feature title */
|
||||
title?: string;
|
||||
/** Updated feature description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Ordering / Move Payload Types ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Explicit reorder payload for milestones within a roadmap.
|
||||
*
|
||||
* `orderedMilestoneIds` must contain the full set of milestone IDs for the
|
||||
* roadmap exactly once. Consumers should reject partial or duplicate lists.
|
||||
*/
|
||||
export interface RoadmapMilestoneReorderInput {
|
||||
/** Roadmap whose milestone sequence is being rewritten */
|
||||
roadmapId: string;
|
||||
/** Complete milestone ID sequence in final order */
|
||||
orderedMilestoneIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit reorder payload for features within a single milestone.
|
||||
*
|
||||
* `orderedFeatureIds` must contain the full set of feature IDs for the milestone
|
||||
* exactly once. The resulting `orderIndex` values must be normalized to 0-based
|
||||
* contiguous order.
|
||||
*/
|
||||
export interface RoadmapFeatureReorderInput {
|
||||
/** Parent roadmap for integrity validation */
|
||||
roadmapId: string;
|
||||
/** Milestone whose internal feature ordering is being rewritten */
|
||||
milestoneId: string;
|
||||
/** Complete feature ID sequence in final order */
|
||||
orderedFeatureIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit move payload for relocating a feature, including cross-milestone moves.
|
||||
*
|
||||
* `targetOrderIndex` is the desired insertion position in the destination
|
||||
* milestone before final normalization. Consumers should clamp out-of-range
|
||||
* values and must deterministically renumber both source and destination
|
||||
* milestones after the move.
|
||||
*/
|
||||
export interface RoadmapFeatureMoveInput {
|
||||
/** Parent roadmap for integrity validation */
|
||||
roadmapId: string;
|
||||
/** Feature being moved */
|
||||
featureId: string;
|
||||
/** Current milestone that owns the feature */
|
||||
fromMilestoneId: string;
|
||||
/** Destination milestone after the move */
|
||||
toMilestoneId: string;
|
||||
/** Requested insertion index in the destination milestone */
|
||||
targetOrderIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a feature move operation after deterministic renumbering.
|
||||
*
|
||||
* `affectedFeatures` contains the canonical post-move feature records for the
|
||||
* source and target milestones. When a feature is moved within the same
|
||||
* milestone, `sourceMilestoneFeatures` and `targetMilestoneFeatures` will be
|
||||
* the same normalized list.
|
||||
*/
|
||||
export interface RoadmapFeatureMoveResult {
|
||||
/** The moved feature after `milestoneId` and `orderIndex` updates */
|
||||
movedFeature: RoadmapFeature;
|
||||
/** Canonical post-move features for the affected milestone scope */
|
||||
affectedFeatures: RoadmapFeature[];
|
||||
/** Canonical feature list for the source milestone after the move */
|
||||
sourceMilestoneFeatures: RoadmapFeature[];
|
||||
/** Canonical feature list for the destination milestone after the move */
|
||||
targetMilestoneFeatures: RoadmapFeature[];
|
||||
}
|
||||
|
||||
// ── Composite Read Models ───────────────────────────────────────────
|
||||
|
||||
/** Milestone with all of its ordered features loaded. */
|
||||
export interface RoadmapMilestoneWithFeatures extends RoadmapMilestone {
|
||||
/** Features belonging to this milestone */
|
||||
features: RoadmapFeature[];
|
||||
}
|
||||
|
||||
/** Full roadmap hierarchy loaded in roadmap → milestone → feature order. */
|
||||
export interface RoadmapWithHierarchy extends Roadmap {
|
||||
/** Ordered milestones with ordered features */
|
||||
milestones: RoadmapMilestoneWithFeatures[];
|
||||
}
|
||||
|
||||
// ── Export / Handoff Contracts ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flat export payload for persistence, APIs, import/export, and sync jobs.
|
||||
*
|
||||
* This shape intentionally keeps entities separate so downstream persistence
|
||||
* layers can upsert by table/collection without first denormalizing a nested
|
||||
* hierarchy.
|
||||
*/
|
||||
export interface RoadmapExportBundle {
|
||||
/** Roadmap being exported */
|
||||
roadmap: Roadmap;
|
||||
/** Ordered milestones for the roadmap */
|
||||
milestones: RoadmapMilestone[];
|
||||
/** Ordered features for the roadmap's milestones */
|
||||
features: RoadmapFeature[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Source metadata carried forward when a roadmap feature is converted into a
|
||||
* task-planning input or other downstream artifact.
|
||||
*/
|
||||
export interface RoadmapFeatureSourceRef {
|
||||
/** Source roadmap ID */
|
||||
roadmapId: string;
|
||||
/** Source milestone ID */
|
||||
milestoneId: string;
|
||||
/** Source feature ID */
|
||||
featureId: string;
|
||||
/** Human-readable roadmap title for prompt context */
|
||||
roadmapTitle: string;
|
||||
/** Human-readable milestone title for prompt context */
|
||||
milestoneTitle: string;
|
||||
/** Canonical milestone order at handoff time */
|
||||
milestoneOrderIndex: number;
|
||||
/** Canonical feature order at handoff time */
|
||||
featureOrderIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handoff payload for converting a single roadmap feature into task planning
|
||||
* flows without coupling the task system to roadmap persistence details.
|
||||
*/
|
||||
export interface RoadmapFeatureTaskPlanningHandoff {
|
||||
/** Source lineage and ordering context */
|
||||
source: RoadmapFeatureSourceRef;
|
||||
/** Title to seed the downstream task or planning prompt */
|
||||
title: string;
|
||||
/** Optional description to seed the downstream task or planning prompt */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Source-preserving milestone payload used for mission conversion handoffs. */
|
||||
export interface RoadmapMissionPlanningMilestoneHandoff {
|
||||
/** Source roadmap milestone ID */
|
||||
sourceMilestoneId: string;
|
||||
/** Canonical milestone title */
|
||||
title: string;
|
||||
/** Optional milestone description */
|
||||
description?: string;
|
||||
/** Canonical milestone ordering within the roadmap */
|
||||
orderIndex: number;
|
||||
/** Ordered roadmap features that belong to this milestone */
|
||||
features: Array<{
|
||||
/** Source roadmap feature ID */
|
||||
sourceFeatureId: string;
|
||||
/** Canonical feature title */
|
||||
title: string;
|
||||
/** Optional feature description */
|
||||
description?: string;
|
||||
/** Canonical feature ordering within the milestone */
|
||||
orderIndex: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handoff payload for converting a standalone roadmap into mission planning
|
||||
* structures while preserving source IDs and deterministic order.
|
||||
*/
|
||||
export interface RoadmapMissionPlanningHandoff {
|
||||
/** Source roadmap ID */
|
||||
sourceRoadmapId: string;
|
||||
/** Canonical roadmap title */
|
||||
title: string;
|
||||
/** Optional roadmap description */
|
||||
description?: string;
|
||||
/** Ordered milestone breakdown captured at handoff time */
|
||||
milestones: RoadmapMissionPlanningMilestoneHandoff[];
|
||||
}
|
||||
Reference in New Issue
Block a user