feat(FN-1674): add roadmap export and handoff system
- Add RoadmapStore with read APIs for project-scoped roadmap data - Add roadmap-handoff mapper to transform roadmap data for export - Add project-scoped handoff API route (/api/projects/:id/roadmap/handoff) - Add useRoadmaps hook for fetching and exposing roadmap data to components - Update RoadmapsView with export/handoff UX path and roadmap detail view - Add roadmap routes with project-scoped handoff endpoint - Add comprehensive tests for handoff mapper and roadmap routes - Update architecture.md and add dashboard-guide.md documentation
This commit is contained in:
@@ -200,6 +200,12 @@ export {
|
||||
applyRoadmapFeatureReorder,
|
||||
moveRoadmapFeature,
|
||||
} from "./roadmap-ordering.js";
|
||||
export {
|
||||
mapFeatureToTaskHandoff,
|
||||
mapRoadmapToMissionHandoff,
|
||||
mapRoadmapWithHierarchyToMissionHandoff,
|
||||
mapAllFeaturesToTaskHandoffs,
|
||||
} from "./roadmap-handoff.js";
|
||||
|
||||
// ── Mission Hierarchy Types ────────────────────────────────────────────
|
||||
|
||||
|
||||
445
packages/core/src/roadmap-handoff.test.ts
Normal file
445
packages/core/src/roadmap-handoff.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Tests for roadmap handoff mapping helpers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
mapFeatureToTaskHandoff,
|
||||
mapRoadmapToMissionHandoff,
|
||||
mapRoadmapWithHierarchyToMissionHandoff,
|
||||
mapAllFeaturesToTaskHandoffs,
|
||||
} from "./roadmap-handoff.js";
|
||||
import { normalizeRoadmapMilestoneOrder } from "./roadmap-ordering.js";
|
||||
import type {
|
||||
Roadmap,
|
||||
RoadmapMilestone,
|
||||
RoadmapFeature,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
} from "./roadmap-types.js";
|
||||
|
||||
// ── Test Fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
function createRoadmap(overrides: Partial<Roadmap> = {}): Roadmap {
|
||||
return {
|
||||
id: "RM-001",
|
||||
title: "Test Roadmap",
|
||||
description: "A test roadmap",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMilestone(id: string, roadmapId: string, orderIndex: number, overrides: Partial<RoadmapMilestone> = {}): RoadmapMilestone {
|
||||
return {
|
||||
id,
|
||||
roadmapId,
|
||||
title: `Milestone ${id}`,
|
||||
description: `Description for ${id}`,
|
||||
orderIndex,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createFeature(id: string, milestoneId: string, orderIndex: number, overrides: Partial<RoadmapFeature> = {}): RoadmapFeature {
|
||||
return {
|
||||
id,
|
||||
milestoneId,
|
||||
title: `Feature ${id}`,
|
||||
description: `Description for ${id}`,
|
||||
orderIndex,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests for mapFeatureToTaskHandoff ─────────────────────────────────────────
|
||||
|
||||
describe("mapFeatureToTaskHandoff", () => {
|
||||
it("maps a feature to a task planning handoff with all fields", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestone = createMilestone("MS-001", "RM-001", 0);
|
||||
const feature = createFeature("F-001", "MS-001", 0);
|
||||
|
||||
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
|
||||
|
||||
expect(handoff.title).toBe("Feature F-001");
|
||||
expect(handoff.description).toBe("Description for F-001");
|
||||
expect(handoff.source.roadmapId).toBe("RM-001");
|
||||
expect(handoff.source.milestoneId).toBe("MS-001");
|
||||
expect(handoff.source.featureId).toBe("F-001");
|
||||
expect(handoff.source.roadmapTitle).toBe("Test Roadmap");
|
||||
expect(handoff.source.milestoneTitle).toBe("Milestone MS-001");
|
||||
expect(handoff.source.milestoneOrderIndex).toBe(0);
|
||||
expect(handoff.source.featureOrderIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("handles features without descriptions", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestone = createMilestone("MS-001", "RM-001", 0);
|
||||
const feature = createFeature("F-001", "MS-001", 0, { description: undefined });
|
||||
|
||||
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
|
||||
|
||||
expect(handoff.title).toBe("Feature F-001");
|
||||
expect(handoff.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves exact IDs from source entities", () => {
|
||||
const roadmap = createRoadmap({ id: "RM-SPECIAL-123" });
|
||||
const milestone = createMilestone("RMS-SPECIAL-456", "RM-SPECIAL-123", 5);
|
||||
const feature = createFeature("RF-SPECIAL-789", "RMS-SPECIAL-456", 3);
|
||||
|
||||
const handoff = mapFeatureToTaskHandoff(roadmap, milestone, feature);
|
||||
|
||||
expect(handoff.source.roadmapId).toBe("RM-SPECIAL-123");
|
||||
expect(handoff.source.milestoneId).toBe("RMS-SPECIAL-456");
|
||||
expect(handoff.source.featureId).toBe("RF-SPECIAL-789");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tests for mapRoadmapToMissionHandoff ─────────────────────────────────────
|
||||
|
||||
describe("mapRoadmapToMissionHandoff", () => {
|
||||
it("maps a roadmap with milestones and features to mission handoff", () => {
|
||||
const roadmap = createRoadmap({ title: "Q1 Planning", description: "Quarterly goals" });
|
||||
const milestones = [
|
||||
createMilestone("MS-001", "RM-001", 0, { title: "Phase 1" }),
|
||||
createMilestone("MS-002", "RM-001", 1, { title: "Phase 2" }),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [
|
||||
createFeature("F-001", "MS-001", 0, { title: "Auth Feature" }),
|
||||
createFeature("F-002", "MS-001", 1, { title: "Dashboard Feature" }),
|
||||
]],
|
||||
["MS-002", [
|
||||
createFeature("F-003", "MS-002", 0, { title: "Reporting Feature" }),
|
||||
]],
|
||||
]);
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoff.sourceRoadmapId).toBe("RM-001");
|
||||
expect(handoff.title).toBe("Q1 Planning");
|
||||
expect(handoff.description).toBe("Quarterly goals");
|
||||
expect(handoff.milestones).toHaveLength(2);
|
||||
|
||||
// Verify milestone ordering
|
||||
expect(handoff.milestones[0].title).toBe("Phase 1");
|
||||
expect(handoff.milestones[0].orderIndex).toBe(0);
|
||||
expect(handoff.milestones[1].title).toBe("Phase 2");
|
||||
expect(handoff.milestones[1].orderIndex).toBe(1);
|
||||
|
||||
// Verify feature ordering within milestones
|
||||
expect(handoff.milestones[0].features).toHaveLength(2);
|
||||
expect(handoff.milestones[0].features[0].title).toBe("Auth Feature");
|
||||
expect(handoff.milestones[0].features[0].orderIndex).toBe(0);
|
||||
expect(handoff.milestones[0].features[1].title).toBe("Dashboard Feature");
|
||||
expect(handoff.milestones[0].features[1].orderIndex).toBe(1);
|
||||
|
||||
expect(handoff.milestones[1].features).toHaveLength(1);
|
||||
expect(handoff.milestones[1].features[0].title).toBe("Reporting Feature");
|
||||
});
|
||||
|
||||
it("handles empty milestones array", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, [], featuresByMilestoneId);
|
||||
|
||||
expect(handoff.sourceRoadmapId).toBe("RM-001");
|
||||
expect(handoff.milestones).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles milestones with empty features", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [
|
||||
createMilestone("MS-001", "RM-001", 0),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoff.milestones).toHaveLength(1);
|
||||
expect(handoff.milestones[0].features).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("normalizes deterministic ordering when order indices are out of sequence", () => {
|
||||
const roadmap = createRoadmap();
|
||||
// Simulate out-of-sequence order indices
|
||||
const milestones = [
|
||||
createMilestone("MS-001", "RM-001", 10), // Out of sequence
|
||||
createMilestone("MS-002", "RM-001", 5), // Out of sequence
|
||||
createMilestone("MS-003", "RM-001", 20), // Out of sequence
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
// Should be normalized to 0, 1, 2
|
||||
expect(handoff.milestones[0].orderIndex).toBe(0);
|
||||
expect(handoff.milestones[1].orderIndex).toBe(1);
|
||||
expect(handoff.milestones[2].orderIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tests for mapRoadmapWithHierarchyToMissionHandoff ────────────────────────
|
||||
|
||||
describe("mapRoadmapWithHierarchyToMissionHandoff", () => {
|
||||
it("maps RoadmapWithHierarchy to mission handoff", () => {
|
||||
const roadmapWithHierarchy: RoadmapWithHierarchy = {
|
||||
id: "RM-001",
|
||||
title: "Hierarchy Roadmap",
|
||||
description: "With full hierarchy",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
milestones: [
|
||||
{
|
||||
...createMilestone("MS-001", "RM-001", 0, { title: "Alpha Phase" }),
|
||||
features: [
|
||||
createFeature("F-001", "MS-001", 0, { title: "Alpha Feature 1" }),
|
||||
createFeature("F-002", "MS-001", 1, { title: "Alpha Feature 2" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
...createMilestone("MS-002", "RM-001", 1, { title: "Beta Phase" }),
|
||||
features: [
|
||||
createFeature("F-003", "MS-002", 0, { title: "Beta Feature" }),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const handoff = mapRoadmapWithHierarchyToMissionHandoff(roadmapWithHierarchy);
|
||||
|
||||
expect(handoff.sourceRoadmapId).toBe("RM-001");
|
||||
expect(handoff.title).toBe("Hierarchy Roadmap");
|
||||
expect(handoff.milestones).toHaveLength(2);
|
||||
expect(handoff.milestones[0].title).toBe("Alpha Phase");
|
||||
expect(handoff.milestones[0].features).toHaveLength(2);
|
||||
expect(handoff.milestones[1].title).toBe("Beta Phase");
|
||||
expect(handoff.milestones[1].features).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles empty milestone hierarchy", () => {
|
||||
const roadmapWithHierarchy: RoadmapWithHierarchy = {
|
||||
...createRoadmap(),
|
||||
milestones: [],
|
||||
};
|
||||
|
||||
const handoff = mapRoadmapWithHierarchyToMissionHandoff(roadmapWithHierarchy);
|
||||
|
||||
expect(handoff.milestones).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tests for mapAllFeaturesToTaskHandoffs ────────────────────────────────────
|
||||
|
||||
describe("mapAllFeaturesToTaskHandoffs", () => {
|
||||
it("flattens all features from a roadmap into individual handoffs", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [
|
||||
createMilestone("MS-001", "RM-001", 0),
|
||||
createMilestone("MS-002", "RM-001", 1),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [
|
||||
createFeature("F-001", "MS-001", 0),
|
||||
createFeature("F-002", "MS-001", 1),
|
||||
]],
|
||||
["MS-002", [
|
||||
createFeature("F-003", "MS-002", 0),
|
||||
]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(3);
|
||||
expect(handoffs[0].source.featureId).toBe("F-001");
|
||||
expect(handoffs[0].source.milestoneOrderIndex).toBe(0);
|
||||
expect(handoffs[0].source.featureOrderIndex).toBe(0);
|
||||
expect(handoffs[1].source.featureId).toBe("F-002");
|
||||
expect(handoffs[1].source.milestoneOrderIndex).toBe(0);
|
||||
expect(handoffs[1].source.featureOrderIndex).toBe(1);
|
||||
expect(handoffs[2].source.featureId).toBe("F-003");
|
||||
expect(handoffs[2].source.milestoneOrderIndex).toBe(1);
|
||||
expect(handoffs[2].source.featureOrderIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty array when no milestones exist", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, [], featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty array when milestones have no features", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [createMilestone("MS-001", "RM-001", 0)];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves feature titles and descriptions", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [createMilestone("MS-001", "RM-001", 0)];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [
|
||||
createFeature("F-001", "MS-001", 0, { title: "Core Feature", description: "Main functionality" }),
|
||||
createFeature("F-002", "MS-001", 1, { title: "Secondary Feature", description: undefined }),
|
||||
]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs[0].title).toBe("Core Feature");
|
||||
expect(handoffs[0].description).toBe("Main functionality");
|
||||
expect(handoffs[1].title).toBe("Secondary Feature");
|
||||
expect(handoffs[1].description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes ordering when feature order indices are out of sequence", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [createMilestone("MS-001", "RM-001", 0)];
|
||||
// Simulate out-of-sequence feature order indices
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [
|
||||
createFeature("F-001", "MS-001", 100),
|
||||
createFeature("F-002", "MS-001", 50),
|
||||
createFeature("F-003", "MS-001", 75),
|
||||
]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(3);
|
||||
// Should be normalized to 0, 1, 2
|
||||
expect(handoffs[0].source.featureOrderIndex).toBe(0);
|
||||
expect(handoffs[1].source.featureOrderIndex).toBe(1);
|
||||
expect(handoffs[2].source.featureOrderIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("skips features from unknown milestone IDs", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [createMilestone("MS-001", "RM-001", 0)];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [createFeature("F-001", "MS-001", 0)]],
|
||||
// MS-999 is not in milestones, so its features should be ignored
|
||||
["MS-999", [createFeature("F-999", "MS-999", 0)]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(1);
|
||||
expect(handoffs[0].source.featureId).toBe("F-001");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Deterministic Ordering Tests ───────────────────────────────────────────────
|
||||
|
||||
describe("deterministic ordering", () => {
|
||||
it("uses stable ordering when order indices are equal", () => {
|
||||
const roadmap = createRoadmap();
|
||||
// Same order index for all milestones - should sort by createdAt then id
|
||||
const rawMilestones = [
|
||||
createMilestone("MS-001", "RM-001", 0, { createdAt: "2024-01-01T00:00:00.000Z" }),
|
||||
createMilestone("MS-002", "RM-001", 0, { createdAt: "2024-01-01T00:00:00.000Z" }),
|
||||
createMilestone("MS-003", "RM-001", 0, { createdAt: "2024-01-02T00:00:00.000Z" }),
|
||||
];
|
||||
// Normalize before passing to handoff function (mirrors store behavior)
|
||||
const milestones = normalizeRoadmapMilestoneOrder(rawMilestones);
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
// MS-001 and MS-002 have same orderIndex and createdAt, so should sort by id
|
||||
// MS-003 has later createdAt
|
||||
expect(handoff.milestones[0].sourceMilestoneId).toBe("MS-001");
|
||||
expect(handoff.milestones[1].sourceMilestoneId).toBe("MS-002");
|
||||
expect(handoff.milestones[2].sourceMilestoneId).toBe("MS-003");
|
||||
});
|
||||
|
||||
it("produces consistent output across multiple calls with same input", () => {
|
||||
const roadmap = createRoadmap({ id: "RM-STABLE" });
|
||||
const milestones = [
|
||||
createMilestone("MS-001", "RM-STABLE", 1),
|
||||
createMilestone("MS-002", "RM-STABLE", 0),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-001", [createFeature("F-001", "MS-001", 1)]],
|
||||
["MS-002", [createFeature("F-002", "MS-002", 0)]],
|
||||
]);
|
||||
|
||||
const first = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
const second = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(first.milestones[0].sourceMilestoneId).toBe(second.milestones[0].sourceMilestoneId);
|
||||
expect(first.milestones[1].sourceMilestoneId).toBe(second.milestones[1].sourceMilestoneId);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Source Lineage Preservation Tests ─────────────────────────────────────────
|
||||
|
||||
describe("source lineage preservation", () => {
|
||||
it("preserves roadmap context in all feature handoffs", () => {
|
||||
const roadmap = createRoadmap({ id: "RM-LINEAGE", title: "Lineage Test" });
|
||||
const milestones = [createMilestone("MS-LINEAGE", "RM-LINEAGE", 0)];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-LINEAGE", [createFeature("F-LINEAGE", "MS-LINEAGE", 0)]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs[0].source.roadmapId).toBe("RM-LINEAGE");
|
||||
expect(handoffs[0].source.roadmapTitle).toBe("Lineage Test");
|
||||
});
|
||||
|
||||
it("preserves milestone context in all feature handoffs", () => {
|
||||
const roadmap = createRoadmap();
|
||||
const milestones = [
|
||||
createMilestone("MS-ALPHA", "RM-001", 0, { title: "Alpha Milestone" }),
|
||||
createMilestone("MS-BETA", "RM-001", 1, { title: "Beta Milestone" }),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-ALPHA", [createFeature("F-001", "MS-ALPHA", 0)]],
|
||||
["MS-BETA", [createFeature("F-002", "MS-BETA", 0)]],
|
||||
]);
|
||||
|
||||
const handoffs = mapAllFeaturesToTaskHandoffs(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoffs).toHaveLength(2);
|
||||
expect(handoffs[0].source.milestoneId).toBe("MS-ALPHA");
|
||||
expect(handoffs[0].source.milestoneTitle).toBe("Alpha Milestone");
|
||||
expect(handoffs[1].source.milestoneId).toBe("MS-BETA");
|
||||
expect(handoffs[1].source.milestoneTitle).toBe("Beta Milestone");
|
||||
});
|
||||
|
||||
it("mission handoff preserves source IDs on all entities", () => {
|
||||
const roadmap = createRoadmap({ id: "RM-MISSION" });
|
||||
const milestones = [
|
||||
createMilestone("MS-MISSION-1", "RM-MISSION", 0, { title: "First Phase" }),
|
||||
];
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>([
|
||||
["MS-MISSION-1", [
|
||||
createFeature("RF-MISSION-1", "MS-MISSION-1", 0, { title: "Mission Feature" }),
|
||||
]],
|
||||
]);
|
||||
|
||||
const handoff = mapRoadmapToMissionHandoff(roadmap, milestones, featuresByMilestoneId);
|
||||
|
||||
expect(handoff.sourceRoadmapId).toBe("RM-MISSION");
|
||||
expect(handoff.milestones[0].sourceMilestoneId).toBe("MS-MISSION-1");
|
||||
expect(handoff.milestones[0].features[0].sourceFeatureId).toBe("RF-MISSION-1");
|
||||
});
|
||||
});
|
||||
163
packages/core/src/roadmap-handoff.ts
Normal file
163
packages/core/src/roadmap-handoff.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Pure mapping helpers for converting roadmap hierarchy data into mission/task planning handoffs.
|
||||
*
|
||||
* These helpers are read-only transformations that preserve source lineage and deterministic
|
||||
* ordering without coupling to MissionStore or task persistence.
|
||||
*
|
||||
* @module roadmap-handoff
|
||||
*/
|
||||
|
||||
import type {
|
||||
Roadmap,
|
||||
RoadmapMilestone,
|
||||
RoadmapFeature,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapFeatureSourceRef,
|
||||
RoadmapMissionPlanningMilestoneHandoff,
|
||||
} from "./roadmap-types.js";
|
||||
|
||||
import {
|
||||
normalizeRoadmapMilestoneOrder,
|
||||
normalizeRoadmapFeatureOrder,
|
||||
} from "./roadmap-ordering.js";
|
||||
|
||||
/**
|
||||
* Build a source reference for a roadmap feature.
|
||||
*
|
||||
* Includes roadmap and milestone context for downstream planning prompts.
|
||||
*/
|
||||
function buildFeatureSourceRef(
|
||||
roadmap: Roadmap,
|
||||
milestone: RoadmapMilestone,
|
||||
feature: RoadmapFeature,
|
||||
): RoadmapFeatureSourceRef {
|
||||
return {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single roadmap feature into a task planning handoff payload.
|
||||
*
|
||||
* The handoff preserves source lineage for traceability and deterministic ordering
|
||||
* for consistent downstream processing.
|
||||
*/
|
||||
export function mapFeatureToTaskHandoff(
|
||||
roadmap: Roadmap,
|
||||
milestone: RoadmapMilestone,
|
||||
feature: RoadmapFeature,
|
||||
): RoadmapFeatureTaskPlanningHandoff {
|
||||
return {
|
||||
source: buildFeatureSourceRef(roadmap, milestone, feature),
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a full roadmap hierarchy into a mission planning handoff payload.
|
||||
*
|
||||
* The handoff preserves deterministic ordering by normalizing milestone and feature
|
||||
* order indices before building the payload.
|
||||
*
|
||||
* @param roadmap - The roadmap to convert
|
||||
* @param milestones - Ordered milestones (will be re-normalized for deterministic output)
|
||||
* @param featuresByMilestoneId - Features grouped by milestone ID
|
||||
* @returns Mission planning handoff payload
|
||||
*/
|
||||
export function mapRoadmapToMissionHandoff(
|
||||
roadmap: Roadmap,
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
featuresByMilestoneId: ReadonlyMap<string, readonly RoadmapFeature[]>,
|
||||
): RoadmapMissionPlanningHandoff {
|
||||
// Normalize milestone ordering deterministically
|
||||
const normalizedMilestones = normalizeRoadmapMilestoneOrder(milestones);
|
||||
|
||||
const milestoneHandoffs: RoadmapMissionPlanningMilestoneHandoff[] = normalizedMilestones.map((milestone) => {
|
||||
// Get features for this milestone and normalize their order
|
||||
const rawFeatures = featuresByMilestoneId.get(milestone.id) ?? [];
|
||||
const normalizedFeatures = normalizeRoadmapFeatureOrder(rawFeatures);
|
||||
|
||||
return {
|
||||
sourceMilestoneId: milestone.id,
|
||||
title: milestone.title,
|
||||
description: milestone.description,
|
||||
orderIndex: milestone.orderIndex,
|
||||
features: normalizedFeatures.map((feature) => ({
|
||||
sourceFeatureId: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
orderIndex: feature.orderIndex,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sourceRoadmapId: roadmap.id,
|
||||
title: roadmap.title,
|
||||
description: roadmap.description,
|
||||
milestones: milestoneHandoffs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a roadmap with full hierarchy into a mission planning handoff payload.
|
||||
*
|
||||
* Convenience overload that accepts the composite RoadmapWithHierarchy type.
|
||||
*/
|
||||
export function mapRoadmapWithHierarchyToMissionHandoff(
|
||||
roadmapWithHierarchy: RoadmapWithHierarchy,
|
||||
): RoadmapMissionPlanningHandoff {
|
||||
// Build features by milestone ID map
|
||||
const featuresByMilestoneId = new Map<string, readonly RoadmapFeature[]>();
|
||||
for (const milestone of roadmapWithHierarchy.milestones) {
|
||||
featuresByMilestoneId.set(milestone.id, milestone.features);
|
||||
}
|
||||
|
||||
return mapRoadmapToMissionHandoff(
|
||||
roadmapWithHierarchy,
|
||||
roadmapWithHierarchy.milestones,
|
||||
featuresByMilestoneId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all features from a roadmap into task planning handoff payloads.
|
||||
*
|
||||
* Flattens the roadmap hierarchy into individual feature handoffs, each preserving
|
||||
* source lineage and deterministic ordering.
|
||||
*
|
||||
* @param roadmap - The parent roadmap
|
||||
* @param milestones - Ordered milestones (will be re-normalized for deterministic output)
|
||||
* @param featuresByMilestoneId - Features grouped by milestone ID
|
||||
* @returns Array of feature task planning handoffs, ordered by milestone order then feature order
|
||||
*/
|
||||
export function mapAllFeaturesToTaskHandoffs(
|
||||
roadmap: Roadmap,
|
||||
milestones: readonly RoadmapMilestone[],
|
||||
featuresByMilestoneId: ReadonlyMap<string, readonly RoadmapFeature[]>,
|
||||
): RoadmapFeatureTaskPlanningHandoff[] {
|
||||
// Normalize milestone ordering deterministically
|
||||
const normalizedMilestones = normalizeRoadmapMilestoneOrder(milestones);
|
||||
|
||||
const handoffs: RoadmapFeatureTaskPlanningHandoff[] = [];
|
||||
|
||||
for (const milestone of normalizedMilestones) {
|
||||
const rawFeatures = featuresByMilestoneId.get(milestone.id) ?? [];
|
||||
const normalizedFeatures = normalizeRoadmapFeatureOrder(rawFeatures);
|
||||
|
||||
for (const feature of normalizedFeatures) {
|
||||
handoffs.push(mapFeatureToTaskHandoff(roadmap, milestone, feature));
|
||||
}
|
||||
}
|
||||
|
||||
return handoffs;
|
||||
}
|
||||
@@ -869,4 +869,64 @@ export class RoadmapStore extends EventEmitter<RoadmapStoreEvents> {
|
||||
description: feature.description,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mission planning handoff payload for a roadmap.
|
||||
*
|
||||
* Alias for getRoadmapMissionHandoff() for API consistency.
|
||||
* Converts the roadmap into a mission planning structure while preserving
|
||||
* source IDs and deterministic order.
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns The mission planning handoff payload
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
getMissionPlanningHandoff(roadmapId: string): RoadmapMissionPlanningHandoff {
|
||||
return this.getRoadmapMissionHandoff(roadmapId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all task planning handoff payloads for a roadmap.
|
||||
*
|
||||
* Returns a flat list of all feature handoffs in deterministic order
|
||||
* (milestone order index, then feature order index).
|
||||
*
|
||||
* @param roadmapId - Roadmap ID
|
||||
* @returns Array of task planning handoff payloads for all features
|
||||
* @throws Error if roadmap not found
|
||||
*/
|
||||
listFeatureTaskPlanningHandoffs(roadmapId: string): RoadmapFeatureTaskPlanningHandoff[] {
|
||||
// Validate roadmap exists
|
||||
const roadmap = this.getRoadmap(roadmapId);
|
||||
if (!roadmap) {
|
||||
throw new Error(`Roadmap ${roadmapId} not found`);
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(roadmapId);
|
||||
const handoffs: RoadmapFeatureTaskPlanningHandoff[] = [];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
const features = this.listFeatures(milestone.id);
|
||||
|
||||
for (const feature of features) {
|
||||
const source: RoadmapFeatureSourceRef = {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: milestone.id,
|
||||
featureId: feature.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneOrderIndex: milestone.orderIndex,
|
||||
featureOrderIndex: feature.orderIndex,
|
||||
};
|
||||
|
||||
handoffs.push({
|
||||
source,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return handoffs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4600,6 +4600,17 @@ export function getRoadmapFeatureHandoff(
|
||||
);
|
||||
}
|
||||
|
||||
/** Combined handoff response type for roadmap handoff endpoint */
|
||||
export interface RoadmapHandoffResponse {
|
||||
mission: RoadmapMissionPlanningHandoff;
|
||||
features: RoadmapFeatureTaskPlanningHandoff[];
|
||||
}
|
||||
|
||||
/** Get both mission and feature handoff payloads for a roadmap */
|
||||
export function fetchRoadmapHandoff(roadmapId: string, projectId?: string): Promise<RoadmapHandoffResponse> {
|
||||
return api<RoadmapHandoffResponse>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/handoff`, projectId));
|
||||
}
|
||||
|
||||
/** Response from milestone suggestion generation */
|
||||
export interface MilestoneSuggestionsResponse {
|
||||
suggestions: Array<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader } from "lucide-react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
|
||||
import type {
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
RoadmapMilestoneUpdateInput,
|
||||
RoadmapFeatureCreateInput,
|
||||
RoadmapFeatureUpdateInput,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
} from "@fusion/core";
|
||||
|
||||
export interface RoadmapsViewProps {
|
||||
@@ -64,6 +66,113 @@ interface CreateFormState {
|
||||
description: string;
|
||||
}
|
||||
|
||||
// ── Handoff Modal Types ─────────────────────────────────────────────
|
||||
|
||||
interface HandoffModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
roadmapId: string;
|
||||
roadmapTitle: string;
|
||||
handoffPayload: { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
onFetchHandoff: () => void;
|
||||
onCopyToClipboard: () => void;
|
||||
}
|
||||
|
||||
// ── Handoff Modal Component ─────────────────────────────────────────
|
||||
|
||||
function HandoffModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
roadmapTitle,
|
||||
handoffPayload,
|
||||
isLoading,
|
||||
error,
|
||||
onFetchHandoff,
|
||||
onCopyToClipboard,
|
||||
}: HandoffModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={onClose} role="presentation">
|
||||
<div className="modal modal-lg" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="handoff-modal-title">
|
||||
<div className="modal-header">
|
||||
<h2 id="handoff-modal-title">Export Roadmap: {roadmapTitle}</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close modal">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p className="text-muted" style={{ marginBottom: "var(--space-lg)" }}>
|
||||
Export roadmap data for use in mission and task planning flows.
|
||||
This is a read-only export — no missions or tasks will be created.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="form-error" style={{ marginBottom: "var(--space-lg)" }}>
|
||||
Error loading handoff data: {error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!handoffPayload && !isLoading && (
|
||||
<div style={{ textAlign: "center", padding: "var(--space-xl)" }}>
|
||||
<button className="btn btn-primary" onClick={onFetchHandoff}>
|
||||
<Download size={16} style={{ marginRight: "var(--space-sm)" }} />
|
||||
Load Handoff Data
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div style={{ textAlign: "center", padding: "var(--space-xl)" }}>
|
||||
<Loader size={24} className="spin" />
|
||||
<p style={{ marginTop: "var(--space-md)" }}>Loading handoff data...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{handoffPayload && (
|
||||
<>
|
||||
<div style={{ marginBottom: "var(--space-lg)" }}>
|
||||
<h3 style={{ marginBottom: "var(--space-sm)" }}>Mission Planning Handoff</h3>
|
||||
<div className="card" style={{ padding: "var(--space-md)" }}>
|
||||
<pre style={{ whiteSpace: "pre-wrap", fontSize: "12px", maxHeight: "200px", overflow: "auto" }}>
|
||||
{JSON.stringify(handoffPayload.mission, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "var(--space-lg)" }}>
|
||||
<h3 style={{ marginBottom: "var(--space-sm)" }}>
|
||||
Feature Task Planning Handoffs ({handoffPayload.features.length})
|
||||
</h3>
|
||||
<div className="card" style={{ padding: "var(--space-md)" }}>
|
||||
<pre style={{ whiteSpace: "pre-wrap", fontSize: "12px", maxHeight: "300px", overflow: "auto" }}>
|
||||
{JSON.stringify(handoffPayload.features, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<div className="modal-actions-left">
|
||||
{handoffPayload && (
|
||||
<button className="btn btn-sm" onClick={onCopyToClipboard}>
|
||||
<Copy size={14} style={{ marginRight: "var(--space-xs)" }} />
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions-right">
|
||||
<button className="btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Roadmap Item ─────────────────────────────────────────────────────
|
||||
|
||||
function RoadmapItem({
|
||||
@@ -72,12 +181,14 @@ function RoadmapItem({
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onExport,
|
||||
}: {
|
||||
roadmap: Roadmap;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onExport: () => void;
|
||||
}) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -95,6 +206,11 @@ function RoadmapItem({
|
||||
onDelete();
|
||||
};
|
||||
|
||||
const handleExportClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onExport();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`roadmaps-view__sidebar-item${isSelected ? " roadmaps-view__sidebar-item--active" : ""}`}
|
||||
@@ -112,6 +228,17 @@ function RoadmapItem({
|
||||
)}
|
||||
</div>
|
||||
<div className="roadmaps-view__sidebar-item-actions" onClick={handleEditClick} role="presentation">
|
||||
<span
|
||||
className="roadmaps-view__icon-btn"
|
||||
onClick={handleExportClick}
|
||||
role="button"
|
||||
title="Export roadmap"
|
||||
aria-label="Export roadmap"
|
||||
data-testid={`roadmap-export-${roadmap.id}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Download size={14} />
|
||||
</span>
|
||||
<span
|
||||
className="roadmaps-view__icon-btn"
|
||||
onClick={handleEditClick}
|
||||
@@ -1127,8 +1254,18 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
acceptFeatureSuggestion,
|
||||
acceptAllFeatureSuggestions,
|
||||
clearFeatureSuggestions,
|
||||
handoffPayload,
|
||||
isFetchingHandoff,
|
||||
handoffError,
|
||||
fetchHandoff,
|
||||
clearHandoff,
|
||||
} = useRoadmaps({ projectId });
|
||||
|
||||
// Handoff modal state
|
||||
const [handoffModalOpen, setHandoffModalOpen] = useState(false);
|
||||
const [handoffRoadmapId, setHandoffRoadmapId] = useState<string | null>(null);
|
||||
const [handoffRoadmapTitle, setHandoffRoadmapTitle] = useState<string>("");
|
||||
|
||||
// Goal prompt state for milestone suggestion generation
|
||||
const [goalPrompt, setGoalPrompt] = useState("");
|
||||
|
||||
@@ -1460,6 +1597,41 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
[deleteRoadmap, addToast]
|
||||
);
|
||||
|
||||
// Handoff handlers
|
||||
const handleOpenHandoffModal = useCallback((roadmapId: string, roadmapTitle: string) => {
|
||||
setHandoffRoadmapId(roadmapId);
|
||||
setHandoffRoadmapTitle(roadmapTitle);
|
||||
setHandoffModalOpen(true);
|
||||
// Clear any previous handoff data
|
||||
clearHandoff();
|
||||
}, [clearHandoff]);
|
||||
|
||||
const handleCloseHandoffModal = useCallback(() => {
|
||||
setHandoffModalOpen(false);
|
||||
setHandoffRoadmapId(null);
|
||||
setHandoffRoadmapTitle("");
|
||||
clearHandoff();
|
||||
}, [clearHandoff]);
|
||||
|
||||
const handleFetchHandoff = useCallback(() => {
|
||||
if (handoffRoadmapId) {
|
||||
fetchHandoff(handoffRoadmapId, {
|
||||
onError: (err) => addToast(`Failed to load handoff: ${err.message}`, "error"),
|
||||
});
|
||||
}
|
||||
}, [handoffRoadmapId, fetchHandoff, addToast]);
|
||||
|
||||
const handleCopyHandoffToClipboard = useCallback(() => {
|
||||
if (handoffPayload) {
|
||||
const data = JSON.stringify(handoffPayload, null, 2);
|
||||
navigator.clipboard.writeText(data).then(() => {
|
||||
addToast("Handoff data copied to clipboard", "success");
|
||||
}).catch(() => {
|
||||
addToast("Failed to copy to clipboard", "error");
|
||||
});
|
||||
}
|
||||
}, [handoffPayload, addToast]);
|
||||
|
||||
const handleCreateRoadmap = useCallback(
|
||||
async (input: RoadmapCreateInput) => {
|
||||
try {
|
||||
@@ -1758,6 +1930,7 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
onSelect={() => selectRoadmap(roadmap.id)}
|
||||
onEdit={() => handleStartRoadmapEdit(roadmap)}
|
||||
onDelete={() => handleDeleteRoadmap(roadmap.id)}
|
||||
onExport={() => handleOpenHandoffModal(roadmap.id, roadmap.title)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -2015,6 +2188,19 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Handoff export modal */}
|
||||
<HandoffModal
|
||||
isOpen={handoffModalOpen}
|
||||
onClose={handleCloseHandoffModal}
|
||||
roadmapId={handoffRoadmapId || ""}
|
||||
roadmapTitle={handoffRoadmapTitle}
|
||||
handoffPayload={handoffPayload}
|
||||
isLoading={isFetchingHandoff}
|
||||
error={handoffError}
|
||||
onFetchHandoff={handleFetchHandoff}
|
||||
onCopyToClipboard={handleCopyHandoffToClipboard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ vi.mock("lucide-react", () => ({
|
||||
X: (props: unknown) => <span data-testid="x-icon" {...props}>X</span>,
|
||||
GripVertical: (props: unknown) => <span data-testid="grip-icon" {...props}>Grip</span>,
|
||||
Sparkles: (props: unknown) => <span data-testid="sparkles-icon" {...props}>Sparkles</span>,
|
||||
Download: (props: unknown) => <span data-testid="download-icon" {...props}>Download</span>,
|
||||
Copy: (props: unknown) => <span data-testid="copy-icon" {...props}>Copy</span>,
|
||||
Loader: (props: unknown) => <span data-testid="loader-icon" {...props}>Loader</span>,
|
||||
}));
|
||||
|
||||
const mockRoadmaps: Roadmap[] = [
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
RoadmapFeatureCreateInput,
|
||||
RoadmapFeatureUpdateInput,
|
||||
RoadmapWithHierarchy,
|
||||
RoadmapMissionPlanningHandoff,
|
||||
RoadmapFeatureTaskPlanningHandoff,
|
||||
} from "@fusion/core";
|
||||
import * as api from "../api";
|
||||
|
||||
@@ -132,6 +134,18 @@ export interface UseRoadmapsResult {
|
||||
/** Clear pending feature suggestions for a specific milestone */
|
||||
clearFeatureSuggestions: (milestoneId: string) => void;
|
||||
|
||||
// Handoff / Export callbacks
|
||||
/** Current handoff payload (mission + feature handoffs) */
|
||||
handoffPayload: { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null;
|
||||
/** Whether handoff is currently being fetched */
|
||||
isFetchingHandoff: boolean;
|
||||
/** Error from the last handoff fetch attempt */
|
||||
handoffError: Error | null;
|
||||
/** Fetch handoff payload for a roadmap */
|
||||
fetchHandoff: (roadmapId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
||||
/** Clear the current handoff payload */
|
||||
clearHandoff: () => void;
|
||||
|
||||
/** Refresh all roadmaps */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
@@ -146,6 +160,11 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Handoff state
|
||||
const [handoffPayload, setHandoffPayload] = useState<{ mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null>(null);
|
||||
const [isFetchingHandoff, setIsFetchingHandoff] = useState(false);
|
||||
const [handoffError, setHandoffError] = useState<Error | null>(null);
|
||||
|
||||
// Ephemeral milestone suggestion state (in-memory only, not persisted)
|
||||
const [milestoneSuggestions, setMilestoneSuggestions] = useState<MilestoneSuggestion[]>([]);
|
||||
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
|
||||
@@ -167,18 +186,22 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
||||
// Project context version for stale-response protection
|
||||
const projectContextVersionRef = useRef(0);
|
||||
// Handoff fetch version for stale-response discard
|
||||
const handoffFetchVersionRef = useRef(0);
|
||||
// Refs to access latest state in callbacks
|
||||
const roadmapsRef = useRef(roadmaps);
|
||||
const selectedRoadmapIdRef = useRef(selectedRoadmapId);
|
||||
const milestonesRef = useRef(milestones);
|
||||
const featuresByMilestoneIdRef = useRef(featuresByMilestoneId);
|
||||
const projectIdRef = useRef(projectId);
|
||||
const handoffPayloadRef = useRef(handoffPayload);
|
||||
|
||||
roadmapsRef.current = roadmaps;
|
||||
selectedRoadmapIdRef.current = selectedRoadmapId;
|
||||
milestonesRef.current = milestones;
|
||||
featuresByMilestoneIdRef.current = featuresByMilestoneId;
|
||||
projectIdRef.current = projectId;
|
||||
handoffPayloadRef.current = handoffPayload;
|
||||
|
||||
// Clear selection and suggestions when project changes
|
||||
useEffect(() => {
|
||||
@@ -189,6 +212,9 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
setSelectedRoadmap(null);
|
||||
setMilestones([]);
|
||||
setFeaturesByMilestoneId({});
|
||||
// Clear handoff state
|
||||
setHandoffPayload(null);
|
||||
setHandoffError(null);
|
||||
// Clear ephemeral suggestion state
|
||||
setMilestoneSuggestions([]);
|
||||
setIsGeneratingSuggestions(false);
|
||||
@@ -1046,6 +1072,52 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Handoff / Export Functions ────────────────────────────────────────
|
||||
|
||||
const fetchHandoff = useCallback(async (
|
||||
roadmapId: string,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
const requestVersion = ++handoffFetchVersionRef.current;
|
||||
const requestProjectId = projectId; // Capture projectId at request time
|
||||
|
||||
setIsFetchingHandoff(true);
|
||||
setHandoffError(null);
|
||||
|
||||
try {
|
||||
const data = await api.fetchRoadmapHandoff(roadmapId, requestProjectId);
|
||||
|
||||
// Reject stale responses: check if project changed or version is stale
|
||||
if (handoffFetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
|
||||
return; // Stale response, discard
|
||||
}
|
||||
|
||||
setHandoffPayload(data);
|
||||
opts?.onSuccess?.();
|
||||
} catch (err) {
|
||||
// Reject stale errors: check if project changed or version is stale
|
||||
if (handoffFetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
|
||||
return; // Stale error, discard
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
setHandoffError(error);
|
||||
setHandoffPayload(null);
|
||||
opts?.onError?.(error);
|
||||
} finally {
|
||||
// Only clear loading if this is still the current request
|
||||
if (handoffFetchVersionRef.current === requestVersion) {
|
||||
setIsFetchingHandoff(false);
|
||||
}
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const clearHandoff = useCallback(() => {
|
||||
setHandoffPayload(null);
|
||||
setHandoffError(null);
|
||||
setIsFetchingHandoff(false);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await fetchRoadmaps();
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
@@ -1088,6 +1160,11 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
acceptFeatureSuggestion,
|
||||
acceptAllFeatureSuggestions,
|
||||
clearFeatureSuggestions,
|
||||
handoffPayload,
|
||||
isFetchingHandoff,
|
||||
handoffError,
|
||||
fetchHandoff,
|
||||
clearHandoff,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,6 +171,51 @@ function createMockRoadmapStore(): RoadmapStore {
|
||||
description: feature.description,
|
||||
};
|
||||
}),
|
||||
getMissionPlanningHandoff: vi.fn((roadmapId: string) => {
|
||||
const roadmap = roadmaps.get(roadmapId);
|
||||
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
|
||||
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
return {
|
||||
sourceRoadmapId: roadmap.id,
|
||||
title: roadmap.title,
|
||||
description: roadmap.description,
|
||||
milestones: ms.map((m) => {
|
||||
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
return {
|
||||
sourceMilestoneId: m.id,
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
orderIndex: m.orderIndex,
|
||||
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
listFeatureTaskPlanningHandoffs: vi.fn((roadmapId: string) => {
|
||||
const roadmap = roadmaps.get(roadmapId);
|
||||
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
|
||||
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
const handoffs = [];
|
||||
for (const m of ms) {
|
||||
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
for (const f of fs) {
|
||||
handoffs.push({
|
||||
source: {
|
||||
roadmapId: roadmap.id,
|
||||
milestoneId: m.id,
|
||||
featureId: f.id,
|
||||
roadmapTitle: roadmap.title,
|
||||
milestoneTitle: m.title,
|
||||
milestoneOrderIndex: m.orderIndex,
|
||||
featureOrderIndex: f.orderIndex,
|
||||
},
|
||||
title: f.title,
|
||||
description: f.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
return handoffs;
|
||||
}),
|
||||
} as unknown as RoadmapStore;
|
||||
}
|
||||
|
||||
@@ -190,6 +235,19 @@ describe("Roadmap Routes", () => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/roadmaps", createRoadmapRouter(mockStore));
|
||||
|
||||
// Add error handler for tests that check 404 responses
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (err instanceof ApiError) {
|
||||
res.status(err.statusCode).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
res.status(500).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -344,6 +402,68 @@ describe("Roadmap Routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/roadmaps/:roadmapId/handoff", () => {
|
||||
it("returns both mission and feature handoffs", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Combined Handoff" });
|
||||
const milestone1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
|
||||
const milestone2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 2" });
|
||||
const feature1 = mockRoadmapStore.createFeature(milestone1.id, { title: "Feature A" });
|
||||
const feature2 = mockRoadmapStore.createFeature(milestone2.id, { title: "Feature B" });
|
||||
|
||||
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify mission handoff structure
|
||||
expect(response.body.mission).toBeDefined();
|
||||
expect(response.body.mission.sourceRoadmapId).toBe(roadmap.id);
|
||||
expect(response.body.mission.title).toBe("Combined Handoff");
|
||||
expect(response.body.mission.milestones).toHaveLength(2);
|
||||
|
||||
// Verify feature handoffs structure
|
||||
expect(response.body.features).toBeDefined();
|
||||
expect(response.body.features).toHaveLength(2);
|
||||
expect(response.body.features[0].title).toBe("Feature A");
|
||||
expect(response.body.features[0].source.milestoneId).toBe(milestone1.id);
|
||||
expect(response.body.features[1].title).toBe("Feature B");
|
||||
expect(response.body.features[1].source.milestoneId).toBe(milestone2.id);
|
||||
});
|
||||
|
||||
it("returns empty features array when roadmap has no features", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Empty Handoff" });
|
||||
mockRoadmapStore.createMilestone(roadmap.id, { title: "Empty Phase" });
|
||||
|
||||
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.features).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 404 when roadmap not found", async () => {
|
||||
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff");
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 for cross-project isolation", async () => {
|
||||
// Create roadmap in default store
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Isolated Roadmap" });
|
||||
|
||||
// Mock a different project store that returns no roadmap
|
||||
mockGetOrCreateProjectStore.mockResolvedValueOnce({
|
||||
getRoadmapStore: vi.fn(() => ({
|
||||
getMissionPlanningHandoff: vi.fn(() => {
|
||||
throw new Error("Roadmap nonexistent not found");
|
||||
}),
|
||||
listFeatureTaskPlanningHandoffs: vi.fn(() => {
|
||||
throw new Error("Roadmap nonexistent not found");
|
||||
}),
|
||||
})),
|
||||
getRootDir: vi.fn(() => "/test/root"),
|
||||
});
|
||||
|
||||
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff?projectId=other-project");
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/roadmaps/:roadmapId/handoff/mission", () => {
|
||||
it("returns mission handoff payload", async () => {
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Mission Handoff", description: "Mission desc" });
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Roadmap REST API Routes
|
||||
*
|
||||
* Provides CRUD endpoints for standalone roadmaps, milestones, and features.
|
||||
* Also includes AI-powered suggestion endpoints for milestone and feature creation.
|
||||
* Also includes AI-powered suggestion endpoints for milestone and feature creation,
|
||||
* and read-only handoff endpoints for exporting roadmap data to mission/task planning.
|
||||
*
|
||||
* Endpoints:
|
||||
* - Roadmaps: GET /, POST /, GET /:id, PATCH /:id, DELETE /:id
|
||||
@@ -16,6 +17,9 @@
|
||||
* POST /features/:id/move
|
||||
* - Suggestions: POST /:roadmapId/suggestions/milestones,
|
||||
* POST /milestones/:milestoneId/suggestions/features
|
||||
* - Export/Handoff: GET /:roadmapId/export, GET /:roadmapId/handoff,
|
||||
* GET /:roadmapId/handoff/mission,
|
||||
* GET /:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
@@ -594,6 +598,36 @@ export function createRoadmapRouter(store: TaskStore): Router {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps/:roadmapId/handoff
|
||||
* Get both mission-oriented and task-oriented handoff payloads for the roadmap.
|
||||
*
|
||||
* This is a convenience endpoint that combines both handoff types in a single response.
|
||||
* Returns 404 if the roadmap is not found.
|
||||
*/
|
||||
router.get("/:roadmapId/handoff", async (req, res) => {
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
// Get both handoff types
|
||||
const missionHandoff = roadmapStore.getMissionPlanningHandoff(roadmapId);
|
||||
const featureHandoffs = roadmapStore.listFeatureTaskPlanningHandoffs(roadmapId);
|
||||
|
||||
res.json({
|
||||
mission: missionHandoff,
|
||||
features: featureHandoffs,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
// Handle not-found case from store methods
|
||||
if (err instanceof Error && err.message.includes("not found")) {
|
||||
throw notFound(err.message);
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to generate handoff");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/roadmaps/:roadmapId/handoff/mission
|
||||
* Get a mission planning handoff payload for the roadmap.
|
||||
@@ -603,10 +637,14 @@ export function createRoadmapRouter(store: TaskStore): Router {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const { roadmapId } = req.params;
|
||||
|
||||
const handoff = roadmapStore.getRoadmapMissionHandoff(roadmapId);
|
||||
const handoff = roadmapStore.getMissionPlanningHandoff(roadmapId);
|
||||
res.json(handoff);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
// Handle not-found case from store methods
|
||||
if (err instanceof Error && err.message.includes("not found")) {
|
||||
throw notFound(err.message);
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to generate mission handoff");
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user