mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
feat(insight): update insight template and app to React, enhance export functionality
This commit is contained in:
parent
2931e75a17
commit
6cb0bb078c
5 changed files with 472 additions and 329 deletions
|
|
@ -5,8 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path, { dirname } from 'path';
|
||||||
import { dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import type { InsightData } from '../types/StaticInsightTypes.js';
|
import type { InsightData } from '../types/StaticInsightTypes.js';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,27 +25,22 @@
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-center">
|
<!-- React App Mount Point -->
|
||||||
<button
|
<div id="react-root"></div>
|
||||||
id="export-btn"
|
|
||||||
class="group inline-flex items-center gap-2 rounded-full bg-slate-900 px-5 py-3 text-sm font-semibold text-white shadow-soft transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-400 hover:-translate-y-[1px] hover:shadow-lg active:translate-y-[1px]"
|
|
||||||
onclick="handleExport()"
|
|
||||||
>
|
|
||||||
Export as Image
|
|
||||||
<span class="text-slate-200 transition group-hover:translate-x-0.5">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- React CDN -->
|
||||||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
|
||||||
<!-- CDN Libraries -->
|
<!-- CDN Libraries -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||||
|
|
||||||
<!-- Application Data -->
|
<!-- Application Data -->
|
||||||
<script>
|
<script type="text/babel">
|
||||||
window.INSIGHT_DATA = {{DATA_PLACEHOLDER}};
|
window.INSIGHT_DATA = {{DATA_PLACEHOLDER}};
|
||||||
{{SCRIPTS_PLACEHOLDER}}
|
{{SCRIPTS_PLACEHOLDER}}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,284 +1,295 @@
|
||||||
// Native JavaScript implementation of the insight app
|
/* eslint-disable react/prop-types */
|
||||||
// This replaces the React-based App.tsx functionality
|
/* eslint-disable no-undef */
|
||||||
|
// React-based implementation of the insight app
|
||||||
|
// Converts the vanilla JavaScript implementation to React
|
||||||
|
|
||||||
let hourChartInstance = null;
|
const { useState, useRef, useEffect } = React;
|
||||||
|
|
||||||
// Initialize the app when DOM is loaded
|
// Main App Component
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
function InsightApp({ data }) {
|
||||||
initializeApp();
|
if (!data) {
|
||||||
});
|
return (
|
||||||
|
<div className="text-center text-slate-600">
|
||||||
|
No insight data available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function initializeApp() {
|
return (
|
||||||
const insights = window.INSIGHT_DATA;
|
<div>
|
||||||
|
<DashboardCards insights={data} />
|
||||||
|
<HeatmapSection heatmap={data.heatmap} />
|
||||||
|
<TokenUsageSection tokenUsage={data.tokenUsage} />
|
||||||
|
<AchievementsSection achievements={data.achievements} />
|
||||||
|
<ExportButton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!insights) {
|
// Dashboard Cards Component
|
||||||
showError('No insight data available');
|
function DashboardCards({ insights }) {
|
||||||
return;
|
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 (
|
||||||
|
<div className="grid gap-4 md:grid-cols-3 md:gap-6">
|
||||||
|
<StreakCard
|
||||||
|
currentStreak={insights.currentStreak}
|
||||||
|
longestStreak={insights.longestStreak}
|
||||||
|
cardClass={cardClass}
|
||||||
|
captionClass={captionClass}
|
||||||
|
/>
|
||||||
|
<ActiveHoursChart
|
||||||
|
activeHours={insights.activeHours}
|
||||||
|
cardClass={cardClass}
|
||||||
|
sectionTitleClass={sectionTitleClass}
|
||||||
|
/>
|
||||||
|
<WorkSessionCard
|
||||||
|
longestWorkDuration={insights.longestWorkDuration}
|
||||||
|
longestWorkDate={insights.longestWorkDate}
|
||||||
|
latestActiveTime={insights.latestActiveTime}
|
||||||
|
cardClass={cardClass}
|
||||||
|
sectionTitleClass={sectionTitleClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streak Card Component
|
||||||
|
function StreakCard({ currentStreak, longestStreak, cardClass, captionClass }) {
|
||||||
|
return (
|
||||||
|
<div className={`${cardClass} h-full`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className={captionClass}>Current Streak</p>
|
||||||
|
<p className="mt-1 text-4xl font-bold text-slate-900">
|
||||||
|
{currentStreak}
|
||||||
|
<span className="ml-2 text-base font-semibold text-slate-500">
|
||||||
|
days
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700">
|
||||||
|
Longest {longestStreak}d
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active Hours Chart Component
|
||||||
|
function ActiveHoursChart({ activeHours, cardClass, sectionTitleClass }) {
|
||||||
|
const chartRef = useRef(null);
|
||||||
|
const chartInstance = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chartInstance.current) {
|
||||||
|
chartInstance.current.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the main content
|
const canvas = chartRef.current;
|
||||||
createInsightContent(insights);
|
|
||||||
|
|
||||||
// Initialize charts
|
|
||||||
initializeHourChart(insights);
|
|
||||||
|
|
||||||
// Initialize heatmap
|
|
||||||
initializeHeatmap(insights);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createInsightContent(insights) {
|
|
||||||
const container = document.getElementById('container');
|
|
||||||
const contentDiv = container.querySelector('.mx-auto');
|
|
||||||
|
|
||||||
// Find the header and content placeholder
|
|
||||||
const header = contentDiv.querySelector('header');
|
|
||||||
const contentPlaceholder = contentDiv.querySelector('[data-placeholder="content"]');
|
|
||||||
|
|
||||||
// If placeholder doesn't exist, create content after header
|
|
||||||
if (!contentPlaceholder) {
|
|
||||||
const content = createMainContent(insights);
|
|
||||||
header.insertAdjacentHTML('afterend', content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMainContent(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 `
|
|
||||||
<div class="grid gap-4 md:grid-cols-3 md:gap-6">
|
|
||||||
<div class="${cardClass} h-full">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="${captionClass}">Current Streak</p>
|
|
||||||
<p class="mt-1 text-4xl font-bold text-slate-900">
|
|
||||||
${insights.currentStreak}
|
|
||||||
<span class="ml-2 text-base font-semibold text-slate-500">
|
|
||||||
days
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="rounded-full bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700">
|
|
||||||
Longest ${insights.longestStreak}d
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="${cardClass} h-full">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="${sectionTitleClass}">Active Hours</h3>
|
|
||||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
|
||||||
24h
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 h-56 w-full">
|
|
||||||
<canvas id="hour-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="${cardClass} h-full space-y-3">
|
|
||||||
<h3 class="${sectionTitleClass}">Work Session</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-3 text-sm text-slate-700">
|
|
||||||
<div class="rounded-xl bg-slate-50 px-3 py-2">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
Longest
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-lg font-semibold text-slate-900">
|
|
||||||
${insights.longestWorkDuration}m
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl bg-slate-50 px-3 py-2">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
Date
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-lg font-semibold text-slate-900">
|
|
||||||
${insights.longestWorkDate || '-'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 rounded-xl bg-slate-50 px-3 py-2">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
Last Active
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-lg font-semibold text-slate-900">
|
|
||||||
${insights.latestActiveTime || '-'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="${cardClass} mt-4 space-y-4 md:mt-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="${sectionTitleClass}">Activity Heatmap</h3>
|
|
||||||
<span class="text-xs font-semibold text-slate-500">
|
|
||||||
Past year
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="heatmap-container">
|
|
||||||
<div id="heatmap" class="min-w-[720px] rounded-xl border border-slate-100 bg-white/70 p-4 shadow-inner shadow-slate-100">
|
|
||||||
<!-- Heatmap will be inserted here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="${cardClass} mt-4 md:mt-6">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<h3 class="${sectionTitleClass}">Token Usage</h3>
|
|
||||||
<div class="grid grid-cols-3 gap-3">
|
|
||||||
<div class="rounded-xl bg-slate-50 px-4 py-3">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
Input
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-2xl font-bold text-slate-900">
|
|
||||||
${calculateTotalTokens(insights.tokenUsage, 'input').toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl bg-slate-50 px-4 py-3">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
Output
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-2xl font-bold text-slate-900">
|
|
||||||
${calculateTotalTokens(insights.tokenUsage, 'output').toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl bg-slate-50 px-4 py-3">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
||||||
Total
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-2xl font-bold text-slate-900">
|
|
||||||
${calculateTotalTokens(insights.tokenUsage, 'total').toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="${cardClass} mt-4 space-y-4 md:mt-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="${sectionTitleClass}">Achievements</h3>
|
|
||||||
<span class="text-xs font-semibold text-slate-500">
|
|
||||||
${insights.achievements.length} total
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
${insights.achievements.length === 0 ?
|
|
||||||
'<p class="text-sm text-slate-600">No achievements yet. Keep coding!</p>' :
|
|
||||||
`<div class="divide-y divide-slate-200">
|
|
||||||
${insights.achievements.map(achievement => `
|
|
||||||
<div class="flex flex-col gap-1 py-3 text-left">
|
|
||||||
<span class="text-base font-semibold text-slate-900">
|
|
||||||
${achievement.name}
|
|
||||||
</span>
|
|
||||||
<p class="text-sm text-slate-600">
|
|
||||||
${achievement.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateTotalTokens(tokenUsage, type) {
|
|
||||||
return Object.values(tokenUsage).reduce((acc, usage) => acc + usage[type], 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeHourChart(insights) {
|
|
||||||
const canvas = document.getElementById('hour-chart');
|
|
||||||
if (!canvas || !window.Chart) return;
|
if (!canvas || !window.Chart) return;
|
||||||
|
|
||||||
// Destroy existing chart if it exists
|
|
||||||
if (hourChartInstance) {
|
|
||||||
hourChartInstance.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = Array.from({ length: 24 }, (_, i) => `${i}:00`);
|
const labels = Array.from({ length: 24 }, (_, i) => `${i}:00`);
|
||||||
const data = labels.map((_, i) => insights.activeHours[i] || 0);
|
const data = labels.map((_, i) => activeHours[i] || 0);
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
hourChartInstance = new Chart(ctx, {
|
chartInstance.current = new Chart(ctx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Activity per Hour',
|
label: 'Activity per Hour',
|
||||||
data,
|
data,
|
||||||
backgroundColor: 'rgba(52, 152, 219, 0.7)',
|
backgroundColor: 'rgba(52, 152, 219, 0.7)',
|
||||||
borderColor: 'rgba(52, 152, 219, 1)',
|
borderColor: 'rgba(52, 152, 219, 1)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
options: {
|
plugins: {
|
||||||
indexAxis: 'y',
|
legend: {
|
||||||
responsive: true,
|
display: false,
|
||||||
maintainAspectRatio: false,
|
},
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
beginAtZero: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (chartInstance.current) {
|
||||||
|
chartInstance.current.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [activeHours]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${cardClass} h-full`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className={sectionTitleClass}>Active Hours</h3>
|
||||||
|
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||||
|
24h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 h-56 w-full">
|
||||||
|
<canvas ref={chartRef} className="w-full h-56" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeHeatmap(insights) {
|
// Work Session Card Component
|
||||||
const heatmapContainer = document.getElementById('heatmap');
|
function WorkSessionCard({
|
||||||
if (!heatmapContainer) return;
|
longestWorkDuration,
|
||||||
|
longestWorkDate,
|
||||||
// Create a simple SVG heatmap
|
latestActiveTime,
|
||||||
const svg = createHeatmapSVG(insights.heatmap);
|
cardClass,
|
||||||
heatmapContainer.innerHTML = svg;
|
sectionTitleClass,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`${cardClass} h-full space-y-3`}>
|
||||||
|
<h3 className={sectionTitleClass}>Work Session</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm text-slate-700">
|
||||||
|
<div className="rounded-xl bg-slate-50 px-3 py-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Longest
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||||
|
{longestWorkDuration}m
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-50 px-3 py-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Date
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||||
|
{longestWorkDate || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 rounded-xl bg-slate-50 px-3 py-2">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Last Active
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||||
|
{latestActiveTime || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHeatmapSVG(heatmapData) {
|
// Heatmap Section Component
|
||||||
const width = 1000;
|
function HeatmapSection({ heatmap }) {
|
||||||
const height = 150;
|
const cardClass = 'glass-card p-6';
|
||||||
const cellSize = 14;
|
const sectionTitleClass =
|
||||||
const cellPadding = 2;
|
'text-lg font-semibold tracking-tight text-slate-900';
|
||||||
|
|
||||||
const today = new Date();
|
return (
|
||||||
const oneYearAgo = new Date(today);
|
<div className={`${cardClass} mt-4 space-y-4 md:mt-6`}>
|
||||||
oneYearAgo.setFullYear(today.getFullYear() - 1);
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className={sectionTitleClass}>Activity Heatmap</h3>
|
||||||
|
<span className="text-xs font-semibold text-slate-500">Past year</span>
|
||||||
|
</div>
|
||||||
|
<div className="heatmap-container">
|
||||||
|
<div className="min-w-[720px] rounded-xl border border-slate-100 bg-white/70 p-4 shadow-inner shadow-slate-100">
|
||||||
|
<ActivityHeatmap heatmapData={heatmap} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate all dates for the past year
|
// Activity Heatmap Component
|
||||||
const dates = [];
|
function ActivityHeatmap({ heatmapData }) {
|
||||||
const currentDate = new Date(oneYearAgo);
|
const width = 1000;
|
||||||
while (currentDate <= today) {
|
const height = 150;
|
||||||
dates.push(new Date(currentDate));
|
const cellSize = 14;
|
||||||
currentDate.setDate(currentDate.getDate() + 1);
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate max value for color scaling
|
const weeksInYear = Math.ceil(dates.length / 7);
|
||||||
const maxValue = Math.max(...Object.values(heatmapData));
|
const startX = 50;
|
||||||
const colorLevels = [0, 2, 4, 10, 20];
|
const startY = 20;
|
||||||
const colors = ['#e2e8f0', '#a5d8ff', '#74c0fc', '#339af0', '#1c7ed6'];
|
|
||||||
|
|
||||||
function getColor(value) {
|
const months = [
|
||||||
if (value === 0) return colors[0];
|
'Jan',
|
||||||
for (let i = colorLevels.length - 1; i >= 1; i--) {
|
'Feb',
|
||||||
if (value >= colorLevels[i]) return colors[i];
|
'Mar',
|
||||||
}
|
'Apr',
|
||||||
return colors[1];
|
'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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let svg = `<svg class="heatmap-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`;
|
return (
|
||||||
|
<svg
|
||||||
// Calculate grid dimensions
|
className="heatmap-svg"
|
||||||
const weeksInYear = Math.ceil(dates.length / 7);
|
width={width}
|
||||||
const startX = 50;
|
height={height}
|
||||||
const startY = 20;
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
>
|
||||||
dates.forEach((date, index) => {
|
{/* Render heatmap cells */}
|
||||||
|
{dates.map((date, index) => {
|
||||||
const week = Math.floor(index / 7);
|
const week = Math.floor(index / 7);
|
||||||
const day = index % 7;
|
const day = index % 7;
|
||||||
|
|
||||||
|
|
@ -289,76 +300,211 @@ function createHeatmapSVG(heatmapData) {
|
||||||
const value = heatmapData[dateKey] || 0;
|
const value = heatmapData[dateKey] || 0;
|
||||||
const color = getColor(value);
|
const color = getColor(value);
|
||||||
|
|
||||||
svg += `<rect class="heatmap-day"
|
return (
|
||||||
x="${x}" y="${y}"
|
<rect
|
||||||
width="${cellSize}" height="${cellSize}"
|
key={dateKey}
|
||||||
rx="2"
|
className="heatmap-day"
|
||||||
fill="${color}"
|
x={x}
|
||||||
data-date="${dateKey}"
|
y={y}
|
||||||
data-count="${value}">
|
width={cellSize}
|
||||||
<title>${dateKey}: ${value} activities</title>
|
height={cellSize}
|
||||||
</rect>`;
|
rx="2"
|
||||||
});
|
fill={color}
|
||||||
|
data-date={dateKey}
|
||||||
|
data-count={value}
|
||||||
|
>
|
||||||
|
<title>
|
||||||
|
{dateKey}: {value} activities
|
||||||
|
</title>
|
||||||
|
</rect>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
// Add month labels
|
{/* Render month labels */}
|
||||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
{monthLabels.map((label, index) => (
|
||||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
<text key={index} x={label.x} y="15" fontSize="12" fill="#64748b">
|
||||||
let currentMonth = oneYearAgo.getMonth();
|
{label.text}
|
||||||
let monthX = startX;
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
for (let week = 0; week < weeksInYear; week++) {
|
{/* Render legend */}
|
||||||
const weekDate = new Date(oneYearAgo);
|
<text x={startX} y={height - 40} fontSize="12" fill="#64748b">
|
||||||
weekDate.setDate(weekDate.getDate() + week * 7);
|
Less
|
||||||
|
</text>
|
||||||
if (weekDate.getMonth() !== currentMonth) {
|
{colors.map((color, index) => {
|
||||||
currentMonth = weekDate.getMonth();
|
|
||||||
svg += `<text x="${monthX}" y="15" font-size="12" fill="#64748b">${months[currentMonth]}</text>`;
|
|
||||||
monthX = startX + week * (cellSize + cellPadding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add legend
|
|
||||||
const legendY = height - 30;
|
|
||||||
svg += '<text x="' + startX + '" y="' + (legendY - 10) + '" font-size="12" fill="#64748b">Less</text>';
|
|
||||||
|
|
||||||
colors.forEach((color, index) => {
|
|
||||||
const legendX = startX + 40 + index * (cellSize + 2);
|
const legendX = startX + 40 + index * (cellSize + 2);
|
||||||
svg += `<rect x="${legendX}" y="${legendY}" width="10" height="10" rx="2" fill="${color}"></rect>`;
|
return (
|
||||||
});
|
<rect
|
||||||
|
key={index}
|
||||||
svg += '<text x="' + (startX + 40 + colors.length * (cellSize + 2) + 5) + '" y="' + (legendY + 9) + '" font-size="12" fill="#64748b">More</text>';
|
x={legendX}
|
||||||
|
y={height - 30}
|
||||||
svg += '</svg>';
|
width="10"
|
||||||
return svg;
|
height="10"
|
||||||
|
rx="2"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<text
|
||||||
|
x={startX + 40 + colors.length * (cellSize + 2) + 5}
|
||||||
|
y={height - 21}
|
||||||
|
fontSize="12"
|
||||||
|
fill="#64748b"
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export functionality
|
// Token Usage Section Component
|
||||||
function handleExport() {
|
function TokenUsageSection({ tokenUsage }) {
|
||||||
|
const cardClass = 'glass-card p-6';
|
||||||
|
const sectionTitleClass =
|
||||||
|
'text-lg font-semibold tracking-tight text-slate-900';
|
||||||
|
|
||||||
|
function calculateTotalTokens(tokenUsage, type) {
|
||||||
|
return Object.values(tokenUsage).reduce(
|
||||||
|
(acc, usage) => acc + usage[type],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${cardClass} mt-4 md:mt-6`}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className={sectionTitleClass}>Token Usage</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<TokenUsageCard
|
||||||
|
label="Input"
|
||||||
|
value={calculateTotalTokens(tokenUsage, 'input').toLocaleString()}
|
||||||
|
/>
|
||||||
|
<TokenUsageCard
|
||||||
|
label="Output"
|
||||||
|
value={calculateTotalTokens(tokenUsage, 'output').toLocaleString()}
|
||||||
|
/>
|
||||||
|
<TokenUsageCard
|
||||||
|
label="Total"
|
||||||
|
value={calculateTotalTokens(tokenUsage, 'total').toLocaleString()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token Usage Card Component
|
||||||
|
function TokenUsageCard({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-slate-50 px-4 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Achievements Section Component
|
||||||
|
function AchievementsSection({ achievements }) {
|
||||||
|
const cardClass = 'glass-card p-6';
|
||||||
|
const sectionTitleClass =
|
||||||
|
'text-lg font-semibold tracking-tight text-slate-900';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${cardClass} mt-4 space-y-4 md:mt-6`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className={sectionTitleClass}>Achievements</h3>
|
||||||
|
<span className="text-xs font-semibold text-slate-500">
|
||||||
|
{achievements.length} total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{achievements.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
No achievements yet. Keep coding!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-200">
|
||||||
|
{achievements.map((achievement, index) => (
|
||||||
|
<AchievementItem key={index} achievement={achievement} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Achievement Item Component
|
||||||
|
function AchievementItem({ achievement }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 py-3 text-left">
|
||||||
|
<span className="text-base font-semibold text-slate-900">
|
||||||
|
{achievement.name}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-slate-600">{achievement.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export Button Component
|
||||||
|
function ExportButton() {
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
const container = document.getElementById('container');
|
const container = document.getElementById('container');
|
||||||
const button = document.getElementById('export-btn');
|
|
||||||
|
|
||||||
if (!container || !window.html2canvas) {
|
if (!container || !window.html2canvas) {
|
||||||
alert('Export functionality is not available.');
|
alert('Export functionality is not available.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.style.display = 'none';
|
setIsExporting(true);
|
||||||
|
|
||||||
html2canvas(container, {
|
try {
|
||||||
|
const canvas = await html2canvas(container, {
|
||||||
scale: 2,
|
scale: 2,
|
||||||
useCORS: true,
|
useCORS: true,
|
||||||
logging: false,
|
logging: false,
|
||||||
}).then(function(canvas) {
|
});
|
||||||
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();
|
|
||||||
|
|
||||||
button.style.display = 'block';
|
const imgData = canvas.toDataURL('image/png');
|
||||||
}).catch(function(error) {
|
const link = document.createElement('a');
|
||||||
console.error('Error capturing image:', error);
|
link.href = imgData;
|
||||||
alert('Failed to export image. Please try again.');
|
link.download = `qwen-insights-${new Date().toISOString().slice(0, 10)}.png`;
|
||||||
button.style.display = 'block';
|
link.click();
|
||||||
});
|
} catch (error) {
|
||||||
}
|
console.error('Export error:', error);
|
||||||
|
alert('Failed to export image. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="group inline-flex items-center gap-2 rounded-full bg-slate-900 px-5 py-3 text-sm font-semibold text-white shadow-soft transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-400 hover:-translate-y-[1px] hover:shadow-lg active:translate-y-[1px] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isExporting ? 'Exporting...' : 'Export as Image'}
|
||||||
|
<span className="text-slate-200 transition group-hover:translate-x-0.5">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,4 @@ export interface StaticInsightTemplateData {
|
||||||
data: InsightData;
|
data: InsightData;
|
||||||
scripts: string;
|
scripts: string;
|
||||||
generatedTime: string;
|
generatedTime: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ function copyFilesRecursive(source, target, rootSourceDir) {
|
||||||
const normalizedPath = relativePath.replace(/\\/g, '/');
|
const normalizedPath = relativePath.replace(/\\/g, '/');
|
||||||
const isLocaleJs =
|
const isLocaleJs =
|
||||||
ext === '.js' && normalizedPath.startsWith('i18n/locales/');
|
ext === '.js' && normalizedPath.startsWith('i18n/locales/');
|
||||||
if (extensionsToCopy.includes(ext) || isLocaleJs) {
|
const isInsightTemplate = normalizedPath.startsWith(
|
||||||
|
'services/insight/templates/',
|
||||||
|
);
|
||||||
|
if (extensionsToCopy.includes(ext) || isLocaleJs || isInsightTemplate) {
|
||||||
fs.copyFileSync(sourcePath, targetPath);
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue