diff --git a/.gitignore b/.gitignore index b3ad9e33..8e81fb98 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ website/i18n/**/* package-lock.json .vercel .env +.env.local diff --git a/docs/product-roadmap.mdx b/docs/product-roadmap.mdx index 9fa2dd34..248c3e29 100644 --- a/docs/product-roadmap.mdx +++ b/docs/product-roadmap.mdx @@ -4,4 +4,6 @@ hide_title: true hide_table_of_contents: true --- - \ No newline at end of file +import LinearRoadmap from '@site/src/components/LinearRoadmap' + + diff --git a/docusaurus.config.js b/docusaurus.config.js index d5c596c4..7c0d11b7 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -377,6 +377,7 @@ const config = { 'The DevCycle documentation site includes guides and API documentation for the complete platform including the management dashboard, management APIs, SDKs, and more. If you need help along the way feel free to reach out to support and if you don’t have an account yet, you can create a free account now.', }, DEVCYCLE_CLIENT_SDK_KEY: process.env.DEVCYCLE_CLIENT_SDK_KEY, + LINEAR_API_KEY: process.env.LINEAR_API_KEY, }, url: process.env.CF_PAGES ? 'https://docs.devcycle.com' diff --git a/src/components/LinearRoadmap/LinearRoadmap.css b/src/components/LinearRoadmap/LinearRoadmap.css new file mode 100644 index 00000000..60758ca5 --- /dev/null +++ b/src/components/LinearRoadmap/LinearRoadmap.css @@ -0,0 +1,640 @@ +.roadmap-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + font-family: var(--ifm-font-family-base); +} + +.roadmap-header { + text-align: center; + margin-bottom: 3rem; +} + +.roadmap-header h1 { + color: var(--ifm-color-emphasis-800); + font-size: 2.5rem; + margin-bottom: 0.5rem; + font-weight: 700; +} + +.roadmap-header p { + color: var(--ifm-color-emphasis-600); + font-size: 1.125rem; + margin: 0; +} + +.roadmap-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; + margin-top: 2rem; +} + +@media (min-width: 768px) { + .roadmap-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +.roadmap-column { + background: #f8f9fa; + border-radius: 12px; + padding: 1.5rem; + min-height: 400px; + border: 1px solid #e9ecef; + transition: box-shadow 0.2s ease; +} + +.roadmap-column:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.column-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 2px solid var(--ifm-color-emphasis-200); +} + +.column-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--ifm-color-emphasis-800); +} + +.column-count { + background: var(--ifm-color-emphasis-300); + color: var(--ifm-color-emphasis-700); + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.875rem; + font-weight: 500; +} + +.column-planned .column-header { + border-bottom-color: #2563eb; +} + +.column-planned .column-title { + color: #2563eb; +} + +.column-in-progress .column-header { + border-bottom-color: #059669; +} + +.column-in-progress .column-title { + color: #059669; +} + +.column-completed .column-header { + border-bottom-color: #64748b; +} + +.column-completed .column-title { + color: #64748b; +} + +.column-content { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.initiative-card { + background: #ffffff; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 1rem; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.initiative-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-color: var(--ifm-color-primary); +} + +.initiative-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.initiative-title { + font-size: 1rem; + font-weight: 600; + margin: 0; + color: var(--ifm-color-emphasis-800); + line-height: 1.3; + flex: 1; +} + +.initiative-team { + background: var(--ifm-color-primary-lightest); + color: var(--ifm-color-primary-darkest); + padding: 0.125rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; +} + +.initiative-description { + color: var(--ifm-color-emphasis-600); + font-size: 0.875rem; + line-height: 1.4; + margin: 0 0 1rem 0; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.initiative-meta { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.initiative-date { + font-size: 0.8125rem; + color: var(--ifm-color-emphasis-600); + display: flex; + align-items: center; + gap: 0.25rem; +} + +.initiative-progress { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.progress-bar { + flex: 1; + height: 6px; + background: var(--ifm-color-emphasis-200); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient( + 90deg, + var(--ifm-color-primary), + var(--ifm-color-primary-dark) + ); + transition: width 0.3s ease; +} + +.progress-text { + font-size: 0.75rem; + font-weight: 600; + color: var(--ifm-color-primary); + min-width: 35px; + text-align: right; +} + +.initiative-projects { + font-size: 0.8125rem; + color: var(--ifm-color-emphasis-600); + font-weight: 500; +} + +.empty-column { + text-align: center; + color: var(--ifm-color-emphasis-500); + font-style: italic; + padding: 2rem 1rem; +} + +.loading, +.error { + text-align: center; + padding: 3rem 1rem; +} + +.loading { + color: var(--ifm-color-emphasis-600); + font-size: 1.125rem; +} + +.error { + color: var(--ifm-color-danger); +} + +.error h3 { + margin: 0 0 1rem 0; + color: var(--ifm-color-danger); +} + +.error p { + margin: 0; + color: var(--ifm-color-emphasis-600); +} + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: 2rem; + backdrop-filter: blur(4px); +} + +.modal-content { + background: #ffffff; + border-radius: 16px; + width: 100%; + max-width: 700px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4); + animation: modalSlideIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + border: 1px solid #e5e7eb; + display: flex; + flex-direction: column; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-30px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 2rem 2rem 1.5rem; + border-bottom: 1px solid #e5e7eb; + flex-shrink: 0; + background: #f8fafc; +} + +.modal-title-section { + flex: 1; + min-width: 0; +} + +.modal-title { + font-size: 1.75rem; + font-weight: 700; + color: #111827; + margin: 0 0 0.75rem 0; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.modal-status-badge { + padding: 0.5rem 1rem; + border-radius: 24px; + font-size: 0.85rem; + font-weight: 600; + display: inline-block; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.modal-status-planned { + background: #dbeafe; + color: #1e40af; +} + +.modal-status-in-progress { + background: #dcfce7; + color: #166534; +} + +.modal-status-completed { + background: #f1f5f9; + color: #475569; +} + +[data-theme='dark'] .modal-status-planned { + background: #1e3a8a; + color: #bfdbfe; +} + +[data-theme='dark'] .modal-status-in-progress { + background: #166534; + color: #bbf7d0; +} + +[data-theme='dark'] .modal-status-completed { + background: #475569; + color: #cbd5e1; +} + +.modal-close { + background: #f3f4f6; + border: 1px solid #d1d5db; + font-size: 1.25rem; + cursor: pointer; + color: #6b7280; + padding: 0; + margin-left: 1.5rem; + border-radius: 8px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s ease; +} + +.modal-close:hover { + background: #e5e7eb; + color: #374151; + border-color: #9ca3af; + transform: scale(1.05); +} + +.modal-body { + padding: 2rem; + flex: 1; + overflow-y: auto; + background: #ffffff; +} + +.modal-labels { + margin-bottom: 2rem; +} + +.modal-labels h4 { + margin: 0 0 1rem 0; + font-size: 0.875rem; + font-weight: 600; + color: #374151; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.label-list { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.modal-label { + padding: 0.5rem 1rem; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.modal-description { + margin-bottom: 2rem; +} + +.modal-description h4 { + margin: 0 0 1rem 0; + font-size: 0.875rem; + font-weight: 600; + color: #374151; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.modal-description p { + margin: 0; + line-height: 1.7; + color: #1f2937; + font-size: 1rem; +} + +.modal-details { + display: flex; + flex-direction: column; + gap: 1.5rem; + background: #f8fafc; + padding: 1.5rem; + border-radius: 12px; + border: 1px solid #e5e7eb; +} + +.detail-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.detail-item h4 { + margin: 0 0 0.75rem 0; + font-size: 0.8rem; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.detail-item p { + margin: 0; + color: #111827; + font-weight: 600; + font-size: 1rem; +} + +.modal-progress { + display: flex; + align-items: center; + gap: 1rem; +} + +.modal-progress .progress-bar { + flex: 1; + height: 10px; + background: #e5e7eb; + border-radius: 6px; + overflow: hidden; + border: 1px solid #d1d5db; +} + +.modal-progress .progress-fill { + height: 100%; + background: linear-gradient( + 90deg, + var(--ifm-color-primary), + var(--ifm-color-primary-dark) + ); + transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.modal-progress .progress-text { + font-size: 1rem; + font-weight: 700; + color: var(--ifm-color-primary); + min-width: 45px; + text-align: right; +} + +/* Dark mode adjustments */ +[data-theme='dark'] .roadmap-column { + background: var(--ifm-color-emphasis-200); + border-color: var(--ifm-color-emphasis-300); +} + +[data-theme='dark'] .initiative-card { + background: #1a202c; + border-color: #4b5563; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +[data-theme='dark'] .initiative-card:hover { + border-color: var(--ifm-color-primary-light); +} + +[data-theme='dark'] .column-count { + background: var(--ifm-color-emphasis-400); + color: var(--ifm-color-emphasis-100); +} + +/* Dark mode modal adjustments */ +[data-theme='dark'] .modal-overlay { + background: rgba(0, 0, 0, 0.85); +} + +[data-theme='dark'] .modal-content { + background: #1f2937; + border-color: #4b5563; +} + +[data-theme='dark'] .modal-header { + background: #111827; + border-bottom-color: #4b5563; +} + +[data-theme='dark'] .modal-body { + background: #1f2937; +} + +[data-theme='dark'] .modal-title { + color: #f9fafb; +} + +[data-theme='dark'] .modal-labels h4, +[data-theme='dark'] .modal-description h4 { + color: #d1d5db; +} + +[data-theme='dark'] .modal-description p { + color: #e5e7eb; +} + +[data-theme='dark'] .modal-details { + background: #111827; + border-color: #4b5563; +} + +[data-theme='dark'] .detail-item h4 { + color: #9ca3af; +} + +[data-theme='dark'] .detail-item p { + color: #f3f4f6; +} + +[data-theme='dark'] .modal-close { + background: #374151; + border-color: #4b5563; + color: #9ca3af; +} + +[data-theme='dark'] .modal-close:hover { + background: #4b5563; + color: #d1d5db; + border-color: #6b7280; +} + +[data-theme='dark'] .modal-progress .progress-bar { + background: #374151; + border-color: #4b5563; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .roadmap-container { + padding: 1rem; + } + + .roadmap-header h1 { + font-size: 2rem; + } + + .roadmap-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .roadmap-column { + padding: 1rem; + } + + .initiative-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .initiative-team { + align-self: flex-start; + } + + .modal-overlay { + padding: 1rem; + } + + .modal-content { + max-height: 90vh; + } + + .modal-header { + padding: 1.5rem 1.5rem 1rem; + } + + .modal-body { + padding: 1.5rem; + } + + .modal-title { + font-size: 1.375rem; + } + + .modal-close { + width: 36px; + height: 36px; + } + + .modal-details { + padding: 1rem; + } + + .detail-row { + grid-template-columns: 1fr; + gap: 1rem; + } +} diff --git a/src/components/LinearRoadmap/LinearRoadmap.jsx b/src/components/LinearRoadmap/LinearRoadmap.jsx new file mode 100644 index 00000000..e880990a --- /dev/null +++ b/src/components/LinearRoadmap/LinearRoadmap.jsx @@ -0,0 +1,505 @@ +import React, { useState, useEffect } from 'react' +import useDocusaurusContext from '@docusaurus/useDocusaurusContext' +import './LinearRoadmap.css' + +const LinearRoadmap = () => { + const [initiatives, setInitiatives] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedProject, setSelectedProject] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + + const { siteConfig } = useDocusaurusContext() + const LINEAR_API_KEY = siteConfig.customFields?.LINEAR_API_KEY + const LINEAR_API_URL = 'https://api.linear.app/graphql' + + const fetchInitiatives = async () => { + if (!LINEAR_API_KEY) { + setError( + 'An error occurred while fetching the roadmap projects. Please try again sooner or later.', + ) + setLoading(false) + return + } + + // First, find the 2025 Roadmap initiative + const findInitiativeQuery = ` + query { + initiatives(first: 20) { + nodes { + id + name + } + } + } + ` + + try { + // Find the 2025 Roadmap initiative ID + const findResponse = await fetch(LINEAR_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: LINEAR_API_KEY, + }, + body: JSON.stringify({ + query: findInitiativeQuery, + }), + }) + + const findData = await findResponse.json() + const roadmapInitiative = findData.data.initiatives.nodes.find( + (init) => init.name === '2025 Roadmap', + ) + + if (!roadmapInitiative) { + throw new Error('2025 Roadmap initiative not found') + } + + // Now fetch the full initiative with projects + const fullQuery = ` + query GetInitiativeProjects($id: String!) { + initiative(id: $id) { + id + name + description + projects { + nodes { + id + name + description + status { + name + type + } + state + progress + targetDate + completedAt + createdAt + teams { + nodes { + name + } + } + labels { + nodes { + name + color + parent { + name + } + } + } + } + } + } + } + ` + + // Fetch the full initiative with projects + const response = await fetch(LINEAR_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: LINEAR_API_KEY, + }, + body: JSON.stringify({ + query: fullQuery, + variables: { id: roadmapInitiative.id }, + }), + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + + if (data.errors) { + throw new Error(data.errors[0].message) + } + + // Set the projects from the 2025 Roadmap initiative as our "initiatives" for display + const projects = data.data.initiative.projects.nodes + + // Filter to only show projects with "Public Roadmap" label + const publicRoadmapProjects = projects.filter((project) => { + if (!project.labels || !project.labels.nodes) return false + return project.labels.nodes.some( + (label) => label.name.toLowerCase() === 'public roadmap', + ) + }) + + setInitiatives(publicRoadmapProjects) + setLoading(false) + } catch (err) { + console.error('Error fetching initiatives:', err) + setError(err.message) + setLoading(false) + } + } + + useEffect(() => { + fetchInitiatives() + }, []) + + const openModal = (project) => { + setSelectedProject(project) + setIsModalOpen(true) + } + + const closeModal = () => { + setSelectedProject(null) + setIsModalOpen(false) + } + + const categorizeProject = (project) => { + // Check if project is completed + if ( + project.completedAt || + (project.state && project.state.toLowerCase().includes('completed')) || + (project.state && project.state.toLowerCase().includes('complete')) || + (project.status && + project.status.name && + project.status.name.toLowerCase().includes('complete')) + ) { + return 'completed' + } + + // Check if project is currently active/in progress + if ( + project.state && + (project.state.toLowerCase().includes('started') || + project.state.toLowerCase().includes('progress') || + project.state.toLowerCase().includes('active') || + project.state.toLowerCase().includes('in progress')) + ) { + return 'in-progress' + } + + // Everything else is planned + return 'planned' + } + + // Sort planned projects to put items with target dates first + const sortPlannedProjects = (projects) => { + return projects.sort((a, b) => { + // Items with target dates come first + if (a.targetDate && !b.targetDate) return -1 + if (!a.targetDate && b.targetDate) return 1 + + // If both have target dates, sort by date (earliest first) + if (a.targetDate && b.targetDate) { + return new Date(a.targetDate) - new Date(b.targetDate) + } + + // If neither has target dates, maintain original order + return 0 + }) + } + + const groupedProjects = { + planned: sortPlannedProjects( + initiatives.filter((project) => categorizeProject(project) === 'planned'), + ), + inProgress: initiatives.filter( + (project) => categorizeProject(project) === 'in-progress', + ), + completed: initiatives + .filter((project) => categorizeProject(project) === 'completed') + .slice(0, 8), // Show more completed items + } + + const calculateProgress = (project) => { + if (!project.progress) return 0 + + // Linear returns progress as a decimal (0.0 to 1.0), convert to percentage + const progressValue = project.progress + + // If it's already a percentage (> 1), return as is + if (progressValue > 1) { + return Math.round(progressValue) + } + + // If it's a decimal (0.0 to 1.0), convert to percentage + return Math.round(progressValue * 100) + } + + const formatDate = (dateString) => { + if (!dateString) return null + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + } + + const getPlatformLabels = (project) => { + const labels = [] + // Projects have labels directly - filter to only show Platform labels, exclude Public Roadmap + if (project.labels && project.labels.nodes) { + project.labels.nodes.forEach((label) => { + // Skip the "Public Roadmap" label (used only for filtering) + if (label.name.toLowerCase() === 'public roadmap') { + return + } + + // Only include labels that are in the "Platforms" group + if (label.parent && label.parent.name.toLowerCase() === 'platforms') { + labels.push({ + name: label.name, + color: label.color, + }) + } + }) + } + return labels + } + + const ProjectCard = ({ project }) => { + const progress = calculateProgress(project) + const platformLabels = getPlatformLabels(project) + const projectCategory = categorizeProject(project) + const isInProgress = projectCategory === 'in-progress' + + return ( +
openModal(project)}> +
+

{project.name}

+ {platformLabels.length > 0 && ( +
+ {platformLabels.slice(0, 2).map((label) => ( + + {label.name} + + ))} + {platformLabels.length > 2 && ( + + +{platformLabels.length - 2} + + )} +
+ )} +
+ + {project.description && ( +

{project.description}

+ )} + +
+ {project.targetDate && projectCategory !== 'completed' && ( +
+ πŸ“… Target: {formatDate(project.targetDate)} +
+ )} + + {project.completedAt && ( +
+ βœ… Completed: {formatDate(project.completedAt)} +
+ )} + + {isInProgress && progress > 0 && ( +
+
+
+
+ {progress}% +
+ )} +
+
+ ) + } + + const ProjectModal = ({ project, isOpen, onClose }) => { + if (!isOpen || !project) return null + + const progress = calculateProgress(project) + const platformLabels = getPlatformLabels(project) + const projectCategory = categorizeProject(project) + const isInProgress = projectCategory === 'in-progress' + + return ( +
+
e.stopPropagation()}> +
+
+

{project.name}

+
+ {projectCategory === 'in-progress' + ? 'In Progress' + : projectCategory === 'planned' + ? 'Planned' + : 'Completed'} +
+
+ +
+ +
+ {platformLabels.length > 0 && ( +
+

Platform Labels

+
+ {platformLabels.map((label) => ( + + {label.name} + + ))} +
+
+ )} + + {project.description && ( +
+

Description

+

{project.description}

+
+ )} + +
+
+
+

Created

+

{formatDate(project.createdAt)}

+
+
+ + {project.targetDate && ( +
+

Target Date

+

πŸ“… {formatDate(project.targetDate)}

+
+ )} + + {project.completedAt && ( +
+

Completed

+

βœ… {formatDate(project.completedAt)}

+
+ )} + + {isInProgress && ( +
+

Progress

+
+
+
+
+ {progress}% +
+
+ )} + + {project.state && ( +
+

Current Status

+

{project.state}

+
+ )} +
+
+
+
+ ) + } + + const RoadmapColumn = ({ title, projects, className }) => ( +
+
+

{title}

+ ({projects.length}) +
+
+ {projects.map((project) => ( + + ))} + {projects.length === 0 && ( +
No projects
+ )} +
+
+ ) + + if (loading) { + return ( +
+
Loading roadmap...
+
+ ) + } + + if (error) { + return ( +
+
+

Error loading roadmap

+

{error}

+
+
+ ) + } + + return ( +
+
+

2025 Roadmap Projects

+

Projects from our 2025 Roadmap initiative organized by status

+
+ +
+ + + +
+ + +
+ ) +} + +export default LinearRoadmap diff --git a/src/components/LinearRoadmap/index.js b/src/components/LinearRoadmap/index.js new file mode 100644 index 00000000..392d4a61 --- /dev/null +++ b/src/components/LinearRoadmap/index.js @@ -0,0 +1 @@ +export { default } from './LinearRoadmap'