From d21382ee6b20f74ff988007f4ef4efc81a9efc93 Mon Sep 17 00:00:00 2001 From: Prisha Jain Date: Sun, 2 Aug 2026 16:54:27 +0530 Subject: [PATCH 001/145] integrated ai powered project roadmap --- backend/Input_validators/ValidateRoadmap.js | 136 +++++ backend/controllers/roadmapController.js | 284 +++++++++ backend/models/RoadmapProject.js | 143 +++++ backend/package-lock.json | 61 -- backend/routes/roadmapRoutes.js | 28 + backend/server.js | 2 + frontend/src/App.jsx | 9 + frontend/src/components/Layouts/Sidebar.jsx | 3 +- .../pages/ProjectRoadmap/ProjectRoadmap.jsx | 415 +++++++++++++ .../components/RoadmapDashboard.jsx | 138 +++++ .../components/RoadmapDetail.jsx | 573 ++++++++++++++++++ .../components/RoadmapQuestionnaire.jsx | 187 ++++++ .../ProjectRoadmap/utils/exportRoadmap.jsx | 283 +++++++++ .../pages/ProjectRoadmap/utils/roadmapAi.jsx | 209 +++++++ frontend/src/utils/apiPaths.js | 8 + 15 files changed, 2417 insertions(+), 62 deletions(-) create mode 100644 backend/Input_validators/ValidateRoadmap.js create mode 100644 backend/controllers/roadmapController.js create mode 100644 backend/models/RoadmapProject.js create mode 100644 backend/routes/roadmapRoutes.js create mode 100644 frontend/src/pages/ProjectRoadmap/ProjectRoadmap.jsx create mode 100644 frontend/src/pages/ProjectRoadmap/components/RoadmapDashboard.jsx create mode 100644 frontend/src/pages/ProjectRoadmap/components/RoadmapDetail.jsx create mode 100644 frontend/src/pages/ProjectRoadmap/components/RoadmapQuestionnaire.jsx create mode 100644 frontend/src/pages/ProjectRoadmap/utils/exportRoadmap.jsx create mode 100644 frontend/src/pages/ProjectRoadmap/utils/roadmapAi.jsx diff --git a/backend/Input_validators/ValidateRoadmap.js b/backend/Input_validators/ValidateRoadmap.js new file mode 100644 index 00000000..1c1b891a --- /dev/null +++ b/backend/Input_validators/ValidateRoadmap.js @@ -0,0 +1,136 @@ +const { z } = require("zod"); +const { handleValidationError } = require("./ValidateQuestions"); + +// ── Base field schemas (NO defaults here) ────────────────── +// Shared by both the create and update schemas below. Defaults are only +// applied when building createRoadmapSchema. If defaults were baked in here, +// `.partial()` on the update schema would NOT stop them from firing: Zod's +// `.default()` fills in a value any time a key is missing/undefined, +// regardless of whether the field is optional/partial. That previously +// caused every field the client didn't send on a PUT (e.g. a single-section +// regenerate request, which only sends the one changed key) to be reset to +// its empty default ([]/{}), and the controller then wrote those empty +// defaults over the rest of the saved roadmap — wiping it out. +const answersFields = z + .object({ + targetAudience: z.string().max(1000), + problemSolved: z.string().max(1000), + appType: z.string().max(200), + mvpFeatures: z.string().max(2000), + techPreferences: z.string().max(1000), + designStyle: z.string().max(1000), + accessibilityBranding: z.string().max(1000), + timeline: z.string().max(200), + }) + .partial(); + +const checklistItemFields = z.object({ + id: z.string().min(1), + title: z.string().min(1).max(300), + completed: z.boolean().optional(), + notes: z.string().max(2000).optional(), +}); + +const milestoneFields = z.object({ + id: z.string().min(1), + title: z.string().min(1).max(300), + description: z.string().max(2000).optional(), + order: z.number().optional(), + completed: z.boolean().optional(), + status: z.enum(["todo", "in-progress", "done"]).optional(), + notes: z.string().max(2000).optional(), + subtasks: z.array(checklistItemFields).optional(), +}); + +const techStackFields = z + .object({ + frontend: z.array(z.string()), + backend: z.array(z.string()), + database: z.array(z.string()), + other: z.array(z.string()), + }) + .partial(); + +const featurePrioritizationFields = z + .object({ + mvp: z.array(z.string()), + future: z.array(z.string()), + }) + .partial(); + +// ── Create schema: every field optional-with-default ────── +const createRoadmapSchema = z.object({ + projectIdea: z.string().min(1, "Project idea is required").max(500), + answers: answersFields.optional().default({}), + overview: z.string().max(5000).optional().default(""), + techStack: techStackFields.optional().default({}), + uiUxRecommendations: z.array(z.string()).optional().default([]), + milestones: z.array(milestoneFields).optional().default([]), + featurePrioritization: featurePrioritizationFields.optional().default({}), + databaseApiSuggestions: z.array(z.string()).optional().default([]), + deploymentRecommendations: z.array(z.string()).optional().default([]), + testingChecklist: z.array(checklistItemFields).optional().default([]), + lastStep: z.number().optional().default(0), +}); + +// ── Update schema: any subset of fields, no defaults ──────── +// Built from the same base field schemas so validation stays in sync with +// create, but nothing here has `.default()` — so a field the client doesn't +// send is simply absent from the parsed result, and the controller (which +// only assigns `req.body[field]` when it's `!== undefined`) leaves the +// existing saved value untouched instead of overwriting it with an empty one. +const updateRoadmapSchema = z.object({ + projectIdea: z.string().min(1, "Project idea is required").max(500).optional(), + answers: answersFields.optional(), + overview: z.string().max(5000).optional(), + techStack: techStackFields.optional(), + uiUxRecommendations: z.array(z.string()).optional(), + milestones: z.array(milestoneFields).optional(), + featurePrioritization: featurePrioritizationFields.optional(), + databaseApiSuggestions: z.array(z.string()).optional(), + deploymentRecommendations: z.array(z.string()).optional(), + testingChecklist: z.array(checklistItemFields).optional(), + lastStep: z.number().optional(), +}); + +const toggleTaskSchema = z.object({ + type: z.enum(["milestone", "subtask", "testing"]), + milestoneId: z.string().optional(), + taskId: z.string().optional(), + completed: z.boolean().optional(), + status: z.enum(["todo", "in-progress", "done"]).optional(), + notes: z.string().max(2000).optional(), +}); + +const validateCreateRoadmap = (req, res, next) => { + try { + req.body = createRoadmapSchema.parse(req.body); + next(); + } catch (error) { + return handleValidationError(res, error); + } +}; + +const validateUpdateRoadmap = (req, res, next) => { + try { + req.body = updateRoadmapSchema.parse(req.body); + next(); + } catch (error) { + return handleValidationError(res, error); + } +}; + +const validateToggleTask = (req, res, next) => { + try { + req.body = toggleTaskSchema.parse(req.body); + next(); + } catch (error) { + return handleValidationError(res, error); + } +}; + +module.exports = { + validateCreateRoadmap, + validateUpdateRoadmap, + validateToggleTask, +}; \ No newline at end of file diff --git a/backend/controllers/roadmapController.js b/backend/controllers/roadmapController.js new file mode 100644 index 00000000..ccd207a9 --- /dev/null +++ b/backend/controllers/roadmapController.js @@ -0,0 +1,284 @@ +const RoadmapProject = require("../models/RoadmapProject"); + +const MAX_SAVED_ROADMAPS_PER_USER = 30; + +/** + * @desc Save a newly AI-generated project roadmap + * @route POST /api/roadmaps + * @access Private + */ +const createRoadmap = async (req, res) => { + try { + const userId = req.user._id; + + const existingCount = await RoadmapProject.countDocuments({ userId }); + if (existingCount >= MAX_SAVED_ROADMAPS_PER_USER) { + return res.status(400).json({ + success: false, + message: `You can save up to ${MAX_SAVED_ROADMAPS_PER_USER} roadmaps. Delete an existing one to add a new project.`, + }); + } + + const roadmap = new RoadmapProject({ + ...req.body, + userId, + }); + roadmap.recomputeProgress(); + await roadmap.save(); + + return res.status(201).json({ + success: true, + message: "Roadmap saved to your account", + roadmap, + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to save roadmap", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Get all saved roadmaps for the authenticated user (dashboard summary) + * @route GET /api/roadmaps + * @access Private + */ +const getUserRoadmaps = async (req, res) => { + try { + const userId = req.user._id; + const roadmaps = await RoadmapProject.find({ userId }) + .sort({ updatedAt: -1 }) + .select( + "projectIdea overview progressPercent status milestones testingChecklist createdAt updatedAt lastStep" + ); + + const summaries = roadmaps.map((r) => ({ + _id: r._id, + projectIdea: r.projectIdea, + overview: r.overview, + progressPercent: r.progressPercent, + status: r.status, + milestoneCount: r.milestones.length, + completedMilestoneCount: r.milestones.filter((m) => m.completed).length, + lastStep: r.lastStep, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })); + + return res.status(200).json({ + success: true, + count: summaries.length, + roadmaps: summaries, + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to fetch roadmaps", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Get a single roadmap in full detail + * @route GET /api/roadmaps/:id + * @access Private + */ +const getRoadmapById = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + + const roadmap = await RoadmapProject.findOne({ _id: id, userId }); + if (!roadmap) { + return res.status(404).json({ + success: false, + message: "Roadmap not found", + }); + } + + return res.status(200).json({ success: true, roadmap }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to fetch roadmap", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Update a roadmap - edit milestones, swap in a regenerated AI section, + * rename the project, or update the questionnaire answers. + * @route PUT /api/roadmaps/:id + * @access Private + */ +const updateRoadmap = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + + const roadmap = await RoadmapProject.findOne({ _id: id, userId }); + if (!roadmap) { + return res.status(404).json({ + success: false, + message: "Roadmap not found", + }); + } + + const editableFields = [ + "projectIdea", + "answers", + "overview", + "techStack", + "uiUxRecommendations", + "milestones", + "featurePrioritization", + "databaseApiSuggestions", + "deploymentRecommendations", + "testingChecklist", + "lastStep", + ]; + + editableFields.forEach((field) => { + if (req.body[field] !== undefined) { + roadmap[field] = req.body[field]; + } + }); + + roadmap.recomputeProgress(); + await roadmap.save(); + + return res.status(200).json({ + success: true, + message: "Roadmap updated", + roadmap, + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to update roadmap", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Toggle completion (and/or update notes) of a milestone, subtask, or + * testing-checklist item, then recompute overall progress. + * @route PATCH /api/roadmaps/:id/tasks + * @access Private + */ +const toggleTask = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + const { type, milestoneId, taskId, completed, status, notes } = req.body; + + const roadmap = await RoadmapProject.findOne({ _id: id, userId }); + if (!roadmap) { + return res.status(404).json({ + success: false, + message: "Roadmap not found", + }); + } + + if (type === "milestone") { + const milestone = roadmap.milestones.find((m) => m.id === milestoneId); + if (!milestone) { + return res.status(404).json({ success: false, message: "Milestone not found" }); + } + if (completed !== undefined) milestone.completed = completed; + if (notes !== undefined) milestone.notes = notes; + if (status !== undefined) { + milestone.status = status; + milestone.completed = status === "done"; + } + // If a milestone with subtasks is toggled directly, cascade to subtasks + if ( + (completed !== undefined || status !== undefined) && + milestone.subtasks?.length > 0 + ) { + const nowCompleted = status !== undefined ? status === "done" : completed; + milestone.subtasks.forEach((s) => (s.completed = nowCompleted)); + } + } else if (type === "subtask") { + const milestone = roadmap.milestones.find((m) => m.id === milestoneId); + if (!milestone) { + return res.status(404).json({ success: false, message: "Milestone not found" }); + } + const subtask = milestone.subtasks.find((s) => s.id === taskId); + if (!subtask) { + return res.status(404).json({ success: false, message: "Subtask not found" }); + } + if (completed !== undefined) subtask.completed = completed; + if (notes !== undefined) subtask.notes = notes; + } else if (type === "testing") { + const item = roadmap.testingChecklist.find((t) => t.id === taskId); + if (!item) { + return res.status(404).json({ success: false, message: "Checklist item not found" }); + } + if (completed !== undefined) item.completed = completed; + if (notes !== undefined) item.notes = notes; + } + + roadmap.markModified("milestones"); + roadmap.markModified("testingChecklist"); + roadmap.recomputeProgress(); + await roadmap.save(); + + return res.status(200).json({ + success: true, + message: "Progress updated", + roadmap, + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to update task", + error: "A server error occurred", + }); + } +}; + +/** + * @desc Delete a saved roadmap + * @route DELETE /api/roadmaps/:id + * @access Private + */ +const deleteRoadmap = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user._id; + + const roadmap = await RoadmapProject.findOneAndDelete({ _id: id, userId }); + if (!roadmap) { + return res.status(404).json({ + success: false, + message: "Roadmap not found", + }); + } + + return res.status(200).json({ + success: true, + message: "Roadmap deleted successfully", + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Failed to delete roadmap", + error: "A server error occurred", + }); + } +}; + +module.exports = { + createRoadmap, + getUserRoadmaps, + getRoadmapById, + updateRoadmap, + toggleTask, + deleteRoadmap, +}; \ No newline at end of file diff --git a/backend/models/RoadmapProject.js b/backend/models/RoadmapProject.js new file mode 100644 index 00000000..d145c2ea --- /dev/null +++ b/backend/models/RoadmapProject.js @@ -0,0 +1,143 @@ +const mongoose = require("mongoose"); + +// A single checklist-style item (used for subtasks and testing checklist items) +const ChecklistItemSchema = new mongoose.Schema( + { + id: { type: String, required: true }, + title: { type: String, required: true, trim: true }, + completed: { type: Boolean, default: false }, + notes: { type: String, default: "", trim: true }, + }, + { _id: false } +); + +const MilestoneSchema = new mongoose.Schema( + { + id: { type: String, required: true }, + title: { type: String, required: true, trim: true }, + description: { type: String, default: "", trim: true }, + order: { type: Number, default: 0 }, + completed: { type: Boolean, default: false }, + // Drives the Kanban board column. Auto-derived from subtasks when present, + // otherwise toggled manually by the user. + status: { + type: String, + enum: ["todo", "in-progress", "done"], + default: "todo", + }, + notes: { type: String, default: "", trim: true }, + subtasks: { type: [ChecklistItemSchema], default: [] }, + }, + { _id: false } +); + +const roadmapProjectSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + projectIdea: { + type: String, + required: [true, "Project idea is required"], + trim: true, + }, + + // Answers collected from the follow-up questionnaire + answers: { + targetAudience: { type: String, default: "", trim: true }, + problemSolved: { type: String, default: "", trim: true }, + appType: { type: String, default: "", trim: true }, + mvpFeatures: { type: String, default: "", trim: true }, + techPreferences: { type: String, default: "", trim: true }, + designStyle: { type: String, default: "", trim: true }, + accessibilityBranding: { type: String, default: "", trim: true }, + timeline: { type: String, default: "", trim: true }, + }, + + // AI-generated roadmap content + overview: { type: String, default: "", trim: true }, + techStack: { + frontend: { type: [String], default: [] }, + backend: { type: [String], default: [] }, + database: { type: [String], default: [] }, + other: { type: [String], default: [] }, + }, + uiUxRecommendations: { type: [String], default: [] }, + milestones: { type: [MilestoneSchema], default: [] }, + featurePrioritization: { + mvp: { type: [String], default: [] }, + future: { type: [String], default: [] }, + }, + databaseApiSuggestions: { type: [String], default: [] }, + deploymentRecommendations: { type: [String], default: [] }, + testingChecklist: { type: [ChecklistItemSchema], default: [] }, + + // Progress + workflow metadata + progressPercent: { type: Number, default: 0, min: 0, max: 100 }, + status: { + type: String, + enum: ["planning", "in-progress", "completed"], + default: "planning", + }, + lastStep: { type: Number, default: 0 }, // resume-from-where-you-left-off pointer for the questionnaire + }, + { timestamps: true } +); + +/** + * Recompute progressPercent from milestone subtask + testing checklist completion. + * Called before saving whenever tasks are toggled. + */ +roadmapProjectSchema.methods.recomputeProgress = function () { + let total = 0; + let done = 0; + + this.milestones.forEach((m) => { + if (m.subtasks && m.subtasks.length > 0) { + m.subtasks.forEach((s) => { + total += 1; + if (s.completed) done += 1; + }); + } else { + total += 1; + if (m.completed) done += 1; + } + }); + + this.testingChecklist.forEach((t) => { + total += 1; + if (t.completed) done += 1; + }); + + this.progressPercent = total === 0 ? 0 : Math.round((done / total) * 100); + + // Keep milestone.completed / status in sync with its subtasks + this.milestones.forEach((m) => { + if (m.subtasks && m.subtasks.length > 0) { + const doneCount = m.subtasks.filter((s) => s.completed).length; + m.completed = doneCount === m.subtasks.length; + if (m.completed) m.status = "done"; + else if (doneCount > 0) m.status = "in-progress"; + else if (m.status === "done") m.status = "todo"; + } else { + m.completed = m.status === "done"; + } + }); + + if (this.progressPercent === 100 && total > 0) { + this.status = "completed"; + } else if (this.progressPercent > 0) { + this.status = "in-progress"; + } else { + this.status = "planning"; + } + + return this.progressPercent; +}; + +roadmapProjectSchema.index({ userId: 1, updatedAt: -1 }); + +module.exports = mongoose.model("RoadmapProject", roadmapProjectSchema); \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 6d7068aa..d3e6d694 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -2633,67 +2633,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/mongoose/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mongoose/node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mongoose/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/mongoose/node_modules/mongodb": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", diff --git a/backend/routes/roadmapRoutes.js b/backend/routes/roadmapRoutes.js new file mode 100644 index 00000000..a885ad19 --- /dev/null +++ b/backend/routes/roadmapRoutes.js @@ -0,0 +1,28 @@ +const express = require("express"); +const router = express.Router(); +const { protect } = require("../middlewares/authMiddleware"); +const { + validateCreateRoadmap, + validateUpdateRoadmap, + validateToggleTask, +} = require("../Input_validators/ValidateRoadmap"); +const { + createRoadmap, + getUserRoadmaps, + getRoadmapById, + updateRoadmap, + toggleTask, + deleteRoadmap, +} = require("../controllers/roadmapController"); + +// All roadmap routes require authentication +router.use(protect); + +router.post("/", validateCreateRoadmap, createRoadmap); +router.get("/", getUserRoadmaps); +router.get("/:id", getRoadmapById); +router.put("/:id", validateUpdateRoadmap, updateRoadmap); +router.patch("/:id/tasks", validateToggleTask, toggleTask); +router.delete("/:id", deleteRoadmap); + +module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 7b5f8f16..e1e74e78 100644 --- a/backend/server.js +++ b/backend/server.js @@ -140,6 +140,8 @@ const coursesRoutes = require("./routes/coursesRoutes"); app.use("/api/courses", generalLimiter, coursesRoutes); const flashcardRoutes = require("./routes/flashcardRoutes"); app.use("/api/flashcards", generalLimiter, flashcardRoutes); +const roadmapRoutes = require("./routes/roadmapRoutes"); +app.use("/api/roadmaps", generalLimiter, roadmapRoutes); app.use("/uploads", express.static(path.join(__dirname, "uploads"), {})); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cd4c355e..6d386fa4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -32,6 +32,7 @@ import ResumeAnalyzer from "./pages/ResumeBuilder/ResumeAnalyzer"; import InterviewExperiences from "./pages/InterviewExperiences/InterviewExperiences"; import TermsandConditions from "./pages/Terms/TermsandConditions"; import ProjectIdeas from "./pages/ProjectIdeas/ProjectIdeas"; +import ProjectRoadmap from "./pages/ProjectRoadmap/ProjectRoadmap"; import RepositoryHive from "./pages/OpenSource/RepositoryHive"; import OSSBlog from "./pages/OpenSource/OSSBlog"; import OpenSourceEvents from "./pages/OpenSource/OpenSourceEvents"; @@ -303,6 +304,14 @@ const App = () => { } /> + + + + } + /> questionnaire -> preview (unsaved AI output) -> detail (saved & persisted) +const ProjectRoadmap = () => { + const { user } = useContext(UserContext); + + const [mode, setMode] = useState("dashboard"); + const [roadmaps, setRoadmaps] = useState([]); + const [loadingList, setLoadingList] = useState(false); + + const [idea, setIdea] = useState(""); + const [answers, setAnswers] = useState({}); + const [step, setStep] = useState(0); + const [generating, setGenerating] = useState(false); + + const [activeRoadmap, setActiveRoadmap] = useState(null); // preview (unsaved) or saved detail + const [isSaved, setIsSaved] = useState(false); + const [saving, setSaving] = useState(false); + const [regeneratingSection, setRegeneratingSection] = useState(null); + const [savingPdf, setSavingPdf] = useState(false); + + // ── Fetch saved roadmaps ────────────────────────────── + const fetchRoadmaps = async () => { + if (!user) { + setRoadmaps([]); + return; + } + setLoadingList(true); + try { + const res = await axiosInstance.get(API_PATHS.ROADMAP.GET_ALL); + setRoadmaps(res.data?.roadmaps || []); + } catch (err) { + console.error("Failed to load roadmaps", err); + } finally { + setLoadingList(false); + } + }; + + useEffect(() => { + fetchRoadmaps(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user]); + + // ── Start a new roadmap ──────────────────────────────── + const handleCreateNew = () => { + setIdea(""); + setAnswers({}); + setStep(0); + setActiveRoadmap(null); + setIsSaved(false); + setMode("questionnaire"); + }; + + // ── Generate roadmap via AI ──────────────────────────── + const handleGenerate = async (finalIdea, finalAnswers) => { + setGenerating(true); + try { + const prompt = buildRoadmapPrompt(finalIdea, finalAnswers); + const res = await fetch(`${BASE_URL}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt, systemInstruction: ROADMAP_SYSTEM_INSTRUCTION }), + }); + if (!res.ok) throw new Error(`Server error ${res.status}`); + const data = await res.json(); + const parsed = tryParseJSON(data.text || ""); + if (!parsed) throw new Error("The AI returned an unexpected format. Please try again."); + + const normalized = normalizeGeneratedRoadmap(parsed); + setActiveRoadmap({ + projectIdea: finalIdea, + answers: finalAnswers, + progressPercent: 0, + status: "planning", + ...normalized, + }); + setIsSaved(false); + setMode("preview"); + } catch (err) { + console.error("Roadmap generation error:", err); + toast.error(err.message || "Failed to generate roadmap. Please try again."); + } finally { + setGenerating(false); + } + }; + + // ── Save the previewed (unsaved) roadmap ─────────────── + const handleSaveRoadmap = async () => { + if (!user) { + toast.error("Log in to save your roadmap to your account."); + return; + } + setSaving(true); + try { + const res = await axiosInstance.post(API_PATHS.ROADMAP.CREATE, activeRoadmap); + setActiveRoadmap(res.data.roadmap); + setIsSaved(true); + toast.success("Roadmap saved to your account"); + fetchRoadmaps(); + setMode("detail"); + } catch (err) { + console.error("Save roadmap error:", err); + toast.error(err.response?.data?.message || "Failed to save roadmap"); + } finally { + setSaving(false); + } + }; + + // ── Open a saved roadmap from the dashboard ──────────── + const handleOpenRoadmap = async (id) => { + try { + const res = await axiosInstance.get(API_PATHS.ROADMAP.GET_ONE(id)); + setActiveRoadmap(res.data.roadmap); + setIsSaved(true); + setMode("detail"); + } catch (err) { + toast.error("Failed to load roadmap"); + } + }; + + const handleDeleteRoadmap = async (id) => { + if (!window.confirm("Delete this roadmap? This can't be undone.")) return; + try { + await axiosInstance.delete(API_PATHS.ROADMAP.DELETE(id)); + toast.success("Roadmap deleted"); + if (activeRoadmap?._id === id) { + setMode("dashboard"); + setActiveRoadmap(null); + } + fetchRoadmaps(); + } catch (err) { + toast.error("Failed to delete roadmap"); + } + }; + + // ── Task / progress mutations (saved roadmaps only) ──── + const patchTask = async (payload) => { + if (!isSaved || !activeRoadmap?._id) { + toast.error("Save this roadmap first to edit milestones and the testing checklist."); + return; + } + try { + const res = await axiosInstance.patch(API_PATHS.ROADMAP.TOGGLE_TASK(activeRoadmap._id), payload); + setActiveRoadmap(res.data.roadmap); + } catch (err) { + toast.error("Failed to update progress"); + } + }; + + const handleToggleSubtask = (milestoneId, taskId, completed) => + patchTask({ type: "subtask", milestoneId, taskId, completed }); + + const handleToggleMilestone = (milestoneId, completed) => + patchTask({ type: "milestone", milestoneId, completed }); + + const handleSaveSubtaskNotes = (milestoneId, taskId, notes) => + patchTask({ type: "subtask", milestoneId, taskId, notes }); + + const handleMoveMilestoneStatus = (milestoneId, status) => + patchTask({ type: "milestone", milestoneId, status }); + + const handleToggleTesting = (taskId, completed) => + patchTask({ type: "testing", taskId, completed }); + + // ── Edit milestone title/description ─────────────────── + const handleEditMilestone = async (milestoneId, { title, description }) => { + const updatedMilestones = activeRoadmap.milestones.map((m) => + m.id === milestoneId ? { ...m, title, description } : m + ); + if (!isSaved) { + setActiveRoadmap((prev) => ({ ...prev, milestones: updatedMilestones })); + return; + } + try { + const res = await axiosInstance.put(API_PATHS.ROADMAP.UPDATE(activeRoadmap._id), { + milestones: updatedMilestones, + }); + setActiveRoadmap(res.data.roadmap); + toast.success("Milestone updated"); + } catch (err) { + toast.error("Failed to update milestone"); + } + }; + + // ── Regenerate a single section via AI ───────────────── + const SECTION_KEY_TO_SCHEMA = { + techStack: "techStack", + uiUxRecommendations: "uiUxRecommendations", + featurePrioritization: "featurePrioritization", + databaseApiSuggestions: "databaseApiSuggestions", + deploymentRecommendations: "deploymentRecommendations", + testingChecklist: "testingChecklist", + }; + + const handleRegenerateSection = async (sectionKey, sectionLabel) => { + setRegeneratingSection(sectionKey); + try { + const currentValue = activeRoadmap[sectionKey]; + const prompt = buildRegeneratePrompt( + activeRoadmap.projectIdea, + activeRoadmap.answers || {}, + sectionKey, + sectionLabel, + currentValue + ); + const res = await fetch(`${BASE_URL}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt, systemInstruction: ROADMAP_SYSTEM_INSTRUCTION }), + }); + if (!res.ok) throw new Error(`Server error ${res.status}`); + const data = await res.json(); + const parsed = tryParseJSON(data.text || ""); + if (!parsed || parsed[sectionKey] === undefined) { + throw new Error("The AI returned an unexpected format."); + } + + let newValue = parsed[sectionKey]; + // testingChecklist needs ids added back in + if (sectionKey === "testingChecklist" && Array.isArray(newValue)) { + newValue = newValue.map((t) => ({ + id: `test_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + title: typeof t === "string" ? t : t.title || "", + completed: false, + notes: "", + })); + } + + if (!isSaved) { + setActiveRoadmap((prev) => ({ ...prev, [sectionKey]: newValue })); + } else { + const res2 = await axiosInstance.put(API_PATHS.ROADMAP.UPDATE(activeRoadmap._id), { + [sectionKey]: newValue, + }); + setActiveRoadmap(res2.data.roadmap); + } + toast.success(`${sectionLabel} regenerated`); + } catch (err) { + console.error("Regenerate section error:", err); + toast.error(err.message || "Failed to regenerate section"); + } finally { + setRegeneratingSection(null); + } + }; + + // ── Export ────────────────────────────────────────────── + const handleExportMarkdown = () => exportRoadmapAsMarkdown(activeRoadmap); + + const handleExportPDF = async () => { + setSavingPdf(true); + try { + await exportRoadmapAsPDF(activeRoadmap); + } catch (err) { + console.error("Export PDF error:", err); + toast.error(err.message || "Failed to export PDF"); + } finally { + setSavingPdf(false); + } + }; + + // ── Render ────────────────────────────────────────────── + return ( +
+
+ {mode !== "detail" && ( +
+
+
+ +
+
+

+ Project Development Roadmap Assistant +

+

+ Describe your project idea and answer a few quick questions — the AI will map + out your tech stack, milestones, and a full launch-ready roadmap. +

+
+
+ {!user && mode === "dashboard" && ( +
+ + Log in to save roadmaps, track progress, and resume planning later. +
+ )} +
+ )} + + {mode === "dashboard" && ( + + )} + + {mode === "questionnaire" && ( + + )} + + {mode === "preview" && activeRoadmap && ( +
+
+
+ + This roadmap isn't saved yet — save it to track progress and edit later. +
+
+ + + +
+
+ + { + setActiveRoadmap(null); + setMode("dashboard"); + }} + onToggleSubtask={handleToggleSubtask} + onToggleMilestone={handleToggleMilestone} + onSaveSubtaskNotes={handleSaveSubtaskNotes} + onEditMilestone={handleEditMilestone} + onMoveMilestoneStatus={handleMoveMilestoneStatus} + onToggleTesting={handleToggleTesting} + onRegenerateSection={handleRegenerateSection} + onExportMarkdown={handleExportMarkdown} + onExportPDF={handleExportPDF} + onDelete={() => { + setActiveRoadmap(null); + setMode("dashboard"); + }} + /> +
+ )} + + {mode === "detail" && activeRoadmap && ( + { + setActiveRoadmap(null); + setMode("dashboard"); + fetchRoadmaps(); + }} + onToggleSubtask={handleToggleSubtask} + onToggleMilestone={handleToggleMilestone} + onSaveSubtaskNotes={handleSaveSubtaskNotes} + onEditMilestone={handleEditMilestone} + onMoveMilestoneStatus={handleMoveMilestoneStatus} + onToggleTesting={handleToggleTesting} + onRegenerateSection={handleRegenerateSection} + onExportMarkdown={handleExportMarkdown} + onExportPDF={handleExportPDF} + onDelete={() => handleDeleteRoadmap(activeRoadmap._id)} + /> + )} +
+
+ ); +}; + +export default ProjectRoadmap; \ No newline at end of file diff --git a/frontend/src/pages/ProjectRoadmap/components/RoadmapDashboard.jsx b/frontend/src/pages/ProjectRoadmap/components/RoadmapDashboard.jsx new file mode 100644 index 00000000..79b2d2cc --- /dev/null +++ b/frontend/src/pages/ProjectRoadmap/components/RoadmapDashboard.jsx @@ -0,0 +1,138 @@ +import React from "react"; +import { Plus, Map, Trash2, ChevronRight, CircleCheckBig, Circle } from "lucide-react"; + +const StatusBadge = ({ status }) => { + const map = { + planning: "bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300", + "in-progress": "bg-amber-100 dark:bg-amber-500/10 text-amber-700 dark:text-amber-400", + completed: "bg-emerald-100 dark:bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", + }; + const label = { + planning: "Planning", + "in-progress": "In Progress", + completed: "Completed", + }; + return ( + + {label[status] || "Planning"} + + ); +}; + +const RoadmapCard = ({ roadmap, onOpen, onDelete }) => ( +
onOpen(roadmap._id)} + className="group flex flex-col bg-white dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-2xl p-5 space-y-4 cursor-pointer hover:border-violet-400 dark:hover:border-violet-500/50 transition-all" + > +
+
+
+ +
+
+

+ {roadmap.projectIdea} +

+

+ {roadmap.overview || "No overview yet"} +

+
+
+ +
+ +
+
+ Progress + {roadmap.progressPercent || 0}% +
+
+
+
+
+ +
+ + + {roadmap.completedMilestoneCount || 0}/{roadmap.milestoneCount || 0} milestones + +
+ +
+ Open roadmap +
+
+); + +const RoadmapDashboard = ({ roadmaps, loading, onOpen, onDelete, onCreateNew }) => { + return ( +
+
+
+

+ Your Saved Roadmaps +

+

+ Resume planning, track progress, or start a new project. +

+
+ +
+ + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : roadmaps.length === 0 ? ( +
+
+ +
+

+ No roadmaps yet +

+

+ Describe a project idea and let the AI assistant build you a full development roadmap. +

+ +
+ ) : ( +
+ {roadmaps.map((r) => ( + + ))} +
+ )} +
+ ); +}; + +export default RoadmapDashboard; \ No newline at end of file diff --git a/frontend/src/pages/ProjectRoadmap/components/RoadmapDetail.jsx b/frontend/src/pages/ProjectRoadmap/components/RoadmapDetail.jsx new file mode 100644 index 00000000..9cd880a8 --- /dev/null +++ b/frontend/src/pages/ProjectRoadmap/components/RoadmapDetail.jsx @@ -0,0 +1,573 @@ +import React, { useState } from "react"; +import { + ArrowLeft, + Layers, + Palette, + ListChecks, + Database, + Rocket, + ClipboardCheck, + ChevronDown, + ChevronRight, + RefreshCw, + Download, + FileDown, + Trash2, + Kanban, + Rows3, + Pencil, + Check, + X, + StickyNote, + Loader2, +} from "lucide-react"; + +// ───────────────────────────────────────────────────────── +// Shared section wrapper w/ regenerate button +// ───────────────────────────────────────────────────────── +const Section = ({ icon: Icon, title, sectionKey, onRegenerate, regenerating, children }) => ( +
+
+
+
+ +
+

{title}

+
+ {onRegenerate && ( + + )} +
+ {children} +
+); + +const Pill = ({ children }) => ( + + {children} + +); + +const BulletList = ({ items }) => ( +
    + {(items || []).map((item, i) => ( +
  • + + {item} +
  • + ))} +
+); + +// ───────────────────────────────────────────────────────── +// Subtask row (checklist view) +// ───────────────────────────────────────────────────────── +const SubtaskRow = ({ subtask, onToggle, onSaveNotes }) => { + const [showNotes, setShowNotes] = useState(!!subtask.notes); + const [noteDraft, setNoteDraft] = useState(subtask.notes || ""); + + return ( +
+
+ + + {subtask.title} + + +
+ {showNotes && ( +
+