diff --git a/packages/cli/src/services/insight/generators/TemplateRenderer.ts b/packages/cli/src/services/insight/generators/TemplateRenderer.ts index 23ce8bb12..ca7509433 100644 --- a/packages/cli/src/services/insight/generators/TemplateRenderer.ts +++ b/packages/cli/src/services/insight/generators/TemplateRenderer.ts @@ -48,12 +48,23 @@ export class TemplateRenderer { } private async loadScripts(): Promise { - const scriptsPath = path.join( - this.templateDir, - 'scripts', - 'insight-app.js', + const componentsDir = path.join(this.templateDir, 'scripts', 'components'); + + const componentFiles = [ + 'utils.js', + 'Header.js', + 'Qualitative.js', + 'Charts.js', + 'App.js', + ]; + + const scripts = await Promise.all( + componentFiles.map((file) => + fs.readFile(path.join(componentsDir, file), 'utf-8'), + ), ); - return await fs.readFile(scriptsPath, 'utf-8'); + + return scripts.join('\n\n'); } // Render the complete HTML file diff --git a/packages/cli/src/services/insight/templates/scripts/components/App.js b/packages/cli/src/services/insight/templates/scripts/components/App.js new file mode 100644 index 000000000..e6e351fb3 --- /dev/null +++ b/packages/cli/src/services/insight/templates/scripts/components/App.js @@ -0,0 +1,131 @@ +/* eslint-disable react/jsx-no-undef */ +/* eslint-disable react/prop-types */ +/* eslint-disable no-undef */ + +// Main App Component +function InsightApp({ data }) { + if (!data) { + return ( +
+ No insight data available +
+ ); + } + + // Calculate date range + const heatmapKeys = Object.keys(data.heatmap || {}); + let dateRangeStr = ''; + if (heatmapKeys.length > 0) { + const dates = heatmapKeys.map((d) => new Date(d)); + const minDate = new Date(Math.min(...dates)); + const maxDate = new Date(Math.max(...dates)); + const formatDate = (d) => d.toISOString().split('T')[0]; + dateRangeStr = `${formatDate(minDate)} to ${formatDate(maxDate)}`; + } + + return ( +
+
+ + {data.qualitative && ( + <> + + + + )} + + + + + + {data.qualitative && ( + <> + + + )} + + + + {data.qualitative && ( + <> + + + )} + + {data.qualitative && ( + <> + + + + + + + )} + + +
+ ); +} + +// Export Button Component +function ExportButton() { + const [isExporting, setIsExporting] = useState(false); + + const handleExport = async () => { + const container = document.getElementById('container'); + + if (!container || !window.html2canvas) { + alert('Export functionality is not available.'); + return; + } + + setIsExporting(true); + + try { + const canvas = await html2canvas(container, { + scale: 2, + useCORS: true, + logging: false, + }); + + const imgData = canvas.toDataURL('image/png'); + const link = document.createElement('a'); + link.href = imgData; + link.download = `qwen-insights-${new Date().toISOString().slice(0, 10)}.png`; + link.click(); + } catch (error) { + console.error('Export error:', error); + alert('Failed to export image. Please try again.'); + } finally { + setIsExporting(false); + } + }; + + return ( +
+ +
+ ); +} + +// App Initialization - Mount React app when DOM is ready +const container = document.getElementById('react-root'); +if (container && window.INSIGHT_DATA && window.ReactDOM) { + const root = ReactDOM.createRoot(container); + root.render(React.createElement(InsightApp, { data: window.INSIGHT_DATA })); +} else { + console.error('Failed to mount React app:', { + container: !!container, + data: !!window.INSIGHT_DATA, + ReactDOM: !!window.ReactDOM, + }); +} diff --git a/packages/cli/src/services/insight/templates/scripts/components/Charts.js b/packages/cli/src/services/insight/templates/scripts/components/Charts.js new file mode 100644 index 000000000..836e072b8 --- /dev/null +++ b/packages/cli/src/services/insight/templates/scripts/components/Charts.js @@ -0,0 +1,339 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable react/prop-types */ +/* eslint-disable no-undef */ + +// ----------------------------------------------------------------------------- +// Existing Components +// ----------------------------------------------------------------------------- + +// Dashboard Cards Component +function DashboardCards({ insights }) { + const cardClass = 'glass-card p-6'; + const sectionTitleClass = + 'text-lg font-semibold tracking-tight text-slate-900'; + const captionClass = 'text-sm font-medium text-slate-500'; + + return ( +
+ + + +
+ ); +} + +// Streak Card Component +function StreakCard({ currentStreak, longestStreak, cardClass, captionClass }) { + return ( +
+
+
+

Current Streak

+

+ {currentStreak} + + days + +

+
+ + Longest {longestStreak}d + +
+
+ ); +} + +// Active Hours Chart Component +function ActiveHoursChart({ activeHours, cardClass, sectionTitleClass }) { + const chartRef = useRef(null); + const chartInstance = useRef(null); + + useEffect(() => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + + const canvas = chartRef.current; + if (!canvas || !window.Chart) return; + + const labels = Array.from({ length: 24 }, (_, i) => `${i}:00`); + const data = labels.map((_, i) => activeHours[i] || 0); + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + chartInstance.current = new Chart(ctx, { + type: 'bar', + data: { + labels, + datasets: [ + { + label: 'Activity per Hour', + data, + backgroundColor: 'rgba(52, 152, 219, 0.7)', + borderColor: 'rgba(52, 152, 219, 1)', + borderWidth: 1, + }, + ], + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + beginAtZero: true, + }, + }, + plugins: { + legend: { + display: false, + }, + }, + }, + }); + + return () => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + }; + }, [activeHours]); + + return ( +
+
+

Active Hours

+ + 24h + +
+
+ +
+
+ ); +} + +// Work Session Card Component +function WorkSessionCard({ + longestWorkDuration, + longestWorkDate, + latestActiveTime, + cardClass, + sectionTitleClass, +}) { + return ( +
+

Work Session

+
+
+

+ Longest +

+

+ {longestWorkDuration}m +

+
+
+

+ Date +

+

+ {longestWorkDate || '-'} +

+
+
+

+ Last Active +

+

+ {latestActiveTime || '-'} +

+
+
+
+ ); +} + +// Heatmap Section Component +function HeatmapSection({ heatmap }) { + const cardClass = 'glass-card p-6'; + const sectionTitleClass = + 'text-lg font-semibold tracking-tight text-slate-900'; + + return ( +
+
+

Activity Heatmap

+ Past year +
+
+
+ +
+
+
+ ); +} + +// Activity Heatmap Component +function ActivityHeatmap({ heatmapData }) { + const width = 1000; + const height = 150; + const cellSize = 14; + const cellPadding = 2; + + const today = new Date(); + const oneYearAgo = new Date(today); + oneYearAgo.setFullYear(today.getFullYear() - 1); + + // Generate all dates for the past year + const dates = []; + const currentDate = new Date(oneYearAgo); + while (currentDate <= today) { + dates.push(new Date(currentDate)); + currentDate.setDate(currentDate.getDate() + 1); + } + + const colorLevels = [0, 2, 4, 10, 20]; + const colors = ['#e2e8f0', '#a5d8ff', '#74c0fc', '#339af0', '#1c7ed6']; + + function getColor(value) { + if (value === 0) return colors[0]; + for (let i = colorLevels.length - 1; i >= 1; i--) { + if (value >= colorLevels[i]) return colors[i]; + } + return colors[1]; + } + + const weeksInYear = Math.ceil(dates.length / 7); + const startX = 50; + const startY = 20; + + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + // Generate month labels + const monthLabels = []; + let currentMonth = oneYearAgo.getMonth(); + let monthX = startX; + + for (let week = 0; week < weeksInYear; week++) { + const weekDate = new Date(oneYearAgo); + weekDate.setDate(weekDate.getDate() + week * 7); + + if (weekDate.getMonth() !== currentMonth) { + currentMonth = weekDate.getMonth(); + monthLabels.push({ + x: monthX, + text: months[currentMonth], + }); + monthX = startX + week * (cellSize + cellPadding); + } + } + + return ( + + {/* Render heatmap cells */} + {dates.map((date, index) => { + const week = Math.floor(index / 7); + const day = index % 7; + + const x = startX + week * (cellSize + cellPadding); + const y = startY + day * (cellSize + cellPadding); + + const dateKey = date.toISOString().split('T')[0]; + const value = heatmapData[dateKey] || 0; + const color = getColor(value); + + return ( + + + {dateKey}: {value} activities + + + ); + })} + + {/* Render month labels */} + {monthLabels.map((label, index) => ( + + {label.text} + + ))} + + {/* Render legend */} + + Less + + {colors.map((color, index) => { + const legendX = startX + 40 + index * (cellSize + 2); + return ( + + ); + })} + + More + + + ); +} diff --git a/packages/cli/src/services/insight/templates/scripts/components/Header.js b/packages/cli/src/services/insight/templates/scripts/components/Header.js new file mode 100644 index 000000000..4f4fb1816 --- /dev/null +++ b/packages/cli/src/services/insight/templates/scripts/components/Header.js @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable react/prop-types */ + +// Header Component +function Header({ data, dateRangeStr }) { + const { totalMessages, totalSessions } = data; + + return ( +
+

+ Qwen Code Insights +

+

+ {totalMessages + ? `${totalMessages} messages across ${totalSessions} sessions` + : 'Your personalized coding journey and patterns'} + {dateRangeStr && ` | ${dateRangeStr}`} +

+
+ ); +} + +function StatsRow({ data }) { + const { + totalMessages = 0, + totalLinesAdded = 0, + totalLinesRemoved = 0, + totalFiles = 0, + // totalSessions = 0, + // totalHours = 0, + } = data; + + const heatmapKeys = Object.keys(data.heatmap || {}); + let daysSpan = 0; + if (heatmapKeys.length > 0) { + const dates = heatmapKeys.map((d) => new Date(d)); + const minDate = new Date(Math.min(...dates)); + const maxDate = new Date(Math.max(...dates)); + const diffTime = Math.abs(maxDate - minDate); + daysSpan = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + } + + const msgsPerDay = daysSpan > 0 ? Math.round(totalMessages / daysSpan) : 0; + + return ( +
+
+
{totalMessages}
+
Messages
+
+
+
+ +{totalLinesAdded}/-{totalLinesRemoved} +
+
Lines
+
+
+
{totalFiles}
+
Files
+
+
+
{daysSpan}
+
Days
+
+
+
{msgsPerDay}
+
Msgs/Day
+
+
+ ); +} diff --git a/packages/cli/src/services/insight/templates/scripts/components/Qualitative.js b/packages/cli/src/services/insight/templates/scripts/components/Qualitative.js new file mode 100644 index 000000000..825eacb6b --- /dev/null +++ b/packages/cli/src/services/insight/templates/scripts/components/Qualitative.js @@ -0,0 +1,409 @@ +/* eslint-disable react/jsx-no-undef */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable react/prop-types */ +/* eslint-disable no-undef */ + +// ----------------------------------------------------------------------------- +// Qualitative Insight Components +// ----------------------------------------------------------------------------- + +function AtAGlance({ qualitative }) { + const { atAGlance } = qualitative; + if (!atAGlance) return null; + + return ( +
+
At a Glance
+
+
+ What's working:{' '} + {atAGlance.whats_working} + + Impressive Things You Did → + +
+
+ What's hindering you:{' '} + {atAGlance.whats_hindering} + + Where Things Go Wrong → + +
+
+ Quick wins to try:{' '} + {atAGlance.quick_wins} + + Features to Try → + +
+
+ Ambitious workflows:{' '} + {atAGlance.ambitious_workflows} + + On the Horizon → + +
+
+
+ ); +} + +function NavToc() { + return ( + + ); +} + +function ProjectAreas({ qualitative }) { + const { projectAreas } = qualitative; + if (!Array.isArray(projectAreas?.areas) || !projectAreas.areas.length) + return null; + + return ( + <> +

+ What You Work On +

+
+ {projectAreas.areas.map((area, idx) => ( +
+
+ {area.name} + ~{area.session_count} sessions +
+
+ {area.description} +
+
+ ))} +
+ + ); +} + +function InteractionStyle({ qualitative }) { + const { interactionStyle } = qualitative; + if (!interactionStyle) return null; + + return ( + <> +

+ How You Use Qwen Code +

+
+

+ {interactionStyle.narrative} +

+ {interactionStyle.key_pattern && ( +
+ Key pattern:{' '} + {interactionStyle.key_pattern} +
+ )} +
+ + ); +} + +function ImpressiveWorkflows({ qualitative }) { + const { impressiveWorkflows } = qualitative; + if (!impressiveWorkflows) return null; + + return ( + <> +

+ Impressive Things You Did +

+ {impressiveWorkflows.intro && ( +

+ {impressiveWorkflows.intro} +

+ )} +
+ {Array.isArray(impressiveWorkflows.impressive_workflows) && + impressiveWorkflows.impressive_workflows.map((win, idx) => ( +
+
{win.title}
+
+ {win.description} +
+
+ ))} +
+ + ); +} + +function FrictionPoints({ qualitative }) { + const { frictionPoints } = qualitative; + if (!frictionPoints) return null; + + return ( + <> +

+ Where Things Go Wrong +

+ {frictionPoints.intro && ( +

+ {frictionPoints.intro} +

+ )} +
+ {Array.isArray(frictionPoints.categories) && + frictionPoints.categories.map((cat, idx) => ( +
+
{cat.category}
+
+ {cat.description} +
+ {Array.isArray(cat.examples) && cat.examples.length > 0 && ( +
    + {cat.examples.map((ex, i) => ( +
  • + {ex} +
  • + ))} +
+ )} +
+ ))} +
+ + ); +} + +// Qwen.md Additions Section Component +function QwenMdAdditionsSection({ additions }) { + const [checkedState, setCheckedState] = useState( + new Array(additions.length).fill(true), + ); + const [copiedAll, setCopiedAll] = useState(false); + + const handleCheckboxChange = (position) => { + const updatedCheckedState = checkedState.map((item, index) => + index === position ? !item : item, + ); + setCheckedState(updatedCheckedState); + }; + + const handleCopyAll = () => { + const textToCopy = additions + .filter((_, index) => checkedState[index]) + .map((item) => item.addition) + .join('\n\n'); + + if (!textToCopy) return; + + navigator.clipboard.writeText(textToCopy).then(() => { + setCopiedAll(true); + setTimeout(() => setCopiedAll(false), 2000); + }); + }; + + const checkedCount = checkedState.filter(Boolean).length; + + return ( +
+

Suggested QWEN.md Additions

+

+ Just copy this into Qwen Code to add it to your QWEN.md. +

+ +
+ +
+ + {additions.map((item, idx) => ( +
+ handleCheckboxChange(idx)} + className="cmd-checkbox" + /> +
+ {item.addition} +
+ {item.why} +
+
+ +
+ ))} +
+ ); +} + +function Improvements({ qualitative }) { + const { improvements } = qualitative; + if (!improvements) return null; + + return ( + <> +

+ Existing QC Features to Try +

+ + {/* QWEN.md Additions */} + {Array.isArray(improvements.Qwen_md_additions) && + improvements.Qwen_md_additions.length > 0 && ( + + )} + +

+ Just copy this into Qwen Code and it'll set it up for you. +

+ + {/* Features to Try */} +
+ {Array.isArray(improvements.features_to_try) && + improvements.features_to_try.map((feat, idx) => ( +
+
{feat.feature}
+
+ {feat.one_liner} +
+
+ Why for you:{' '} + {feat.why_for_you} +
+
+
+
+ {feat.example_code} + +
+
+
+
+ ))} +
+ +

+ New Ways to Use Qwen Code +

+

+ Just copy this into Qwen Code and it'll walk you through it. +

+ +
+ {Array.isArray(improvements.usage_patterns) && + improvements.usage_patterns.map((pat, idx) => ( +
+
{pat.title}
+
+ {pat.suggestion} +
+
+ {pat.detail} +
+
+
Paste into Qwen Code:
+
+ {pat.copyable_prompt} + +
+
+
+ ))} +
+ + ); +} + +function FutureOpportunities({ qualitative }) { + const { futureOpportunities } = qualitative; + if (!futureOpportunities) return null; + + return ( + <> +

+ On the Horizon +

+ {futureOpportunities.intro && ( +

+ {futureOpportunities.intro} +

+ )} + +
+ {Array.isArray(futureOpportunities.opportunities) && + futureOpportunities.opportunities.map((opp, idx) => ( +
+
{opp.title}
+
+ {opp.whats_possible} +
+
+ Getting started:{' '} + {opp.how_to_try} +
+
+
Paste into Qwen Code:
+
+ {opp.copyable_prompt} + +
+
+
+ ))} +
+ + ); +} + +function MemorableMoment({ qualitative }) { + const { memorableMoment } = qualitative; + if (!memorableMoment) return null; + + return ( +
+
"{memorableMoment.headline}"
+
+ {memorableMoment.detail} +
+
+ ); +} diff --git a/packages/cli/src/services/insight/templates/scripts/components/utils.js b/packages/cli/src/services/insight/templates/scripts/components/utils.js new file mode 100644 index 000000000..75d371a4b --- /dev/null +++ b/packages/cli/src/services/insight/templates/scripts/components/utils.js @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable react/prop-types */ +/* eslint-disable no-undef */ + +const { useState, useRef, useEffect } = React; + +// Simple Markdown Parser Component +function MarkdownText({ children }) { + if (!children || typeof children !== 'string') return children; + + // Split by bold markers (**text**) + const parts = children.split(/(\*\*.*?\*\*)/g); + + return ( + <> + {parts.map((part, i) => { + if (part.startsWith('**') && part.endsWith('**') && part.length >= 4) { + return {part.slice(2, -2)}; + } + return part; + })} + + ); +} + +function CopyButton({ text, label = 'Copy' }) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( + + ); +} diff --git a/packages/cli/src/services/insight/templates/styles/base.css b/packages/cli/src/services/insight/templates/styles/base.css index 9097d400f..c541965c5 100644 --- a/packages/cli/src/services/insight/templates/styles/base.css +++ b/packages/cli/src/services/insight/templates/styles/base.css @@ -730,6 +730,7 @@ body { } .narrative p { + margin-top: 0; margin-bottom: 12px; font-size: 14px; color: #475569;