feat: add new insight page with Vite setup

This commit is contained in:
DragonnZhang 2026-01-16 14:49:40 +08:00
parent 9ff4be1ae4
commit 22aa6656a4
22 changed files with 33986 additions and 412 deletions

View file

@ -28,6 +28,7 @@ export default tseslint.config(
'dist/**',
'docs-site/.next/**',
'docs-site/out/**',
'packages/cli/src/services/insight-page/**',
],
},
eslint.configs.recommended,

View file

@ -126,7 +126,7 @@
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint --fix --max-warnings 0"
"eslint --fix --max-warnings 0 --no-warn-ignored"
],
"*.{json,md}": [
"prettier --write"

View file

@ -0,0 +1,120 @@
# Qwen Code Insights Page
A React-based visualization dashboard for displaying coding activity insights and statistics.
## Development
This application consists of two parts:
1. **Backend (Express Server)**: Serves API endpoints and processes chat history data
2. **Frontend (Vite + React)**: Development server with HMR
### Running in Development Mode
You need to run both the backend and frontend servers:
**Terminal 1 - Backend Server (Port 3001):**
```bash
pnpm dev:server
```
**Terminal 2 - Frontend Dev Server (Port 3000):**
```bash
pnpm dev
```
Then open <http://localhost:3000> in your browser.
The Vite dev server will proxy `/api` requests to the backend server at port 3001.
### Building for Production
```bash
pnpm build
```
This compiles TypeScript and builds the React application. The output will be in the `dist/` directory.
In production, the Express server serves both the static files and API endpoints from a single port.
## Architecture
- **Frontend**: React + TypeScript + Vite + Chart.js
- **Backend**: Express + Node.js
- **Data Source**: JSONL chat history files from `~/.qwen/projects/*/chats/`
## Original Vite Template Info
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);
```

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qwen Code Insights</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,42 @@
{
"name": "insight-page",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:server": "BASE_DIR=$HOME/.qwen/projects PORT=3001 tsx ../insightServer.ts",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@uiw/react-heat-map": "^2.3.3",
"chart.js": "^4.5.1",
"html2canvas": "^1.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "npm:rolldown-vite@7.2.5"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,449 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import {
Chart,
LineController,
LineElement,
BarController,
BarElement,
CategoryScale,
LinearScale,
PointElement,
Legend,
Title,
Tooltip,
} from 'chart.js';
import type { ChartConfiguration } from 'chart.js';
import HeatMap from '@uiw/react-heat-map';
import html2canvas from 'html2canvas';
// Register Chart.js components
Chart.register(
LineController,
LineElement,
BarController,
BarElement,
CategoryScale,
LinearScale,
PointElement,
Legend,
Title,
Tooltip,
);
interface UsageMetadata {
input: number;
output: number;
total: number;
}
interface InsightData {
heatmap: { [date: string]: number };
tokenUsage: { [date: string]: UsageMetadata };
currentStreak: number;
longestStreak: number;
longestWorkDate: string | null;
longestWorkDuration: number;
activeHours: { [hour: number]: number };
latestActiveTime: string | null;
achievements: Array<{
id: string;
name: string;
description: string;
}>;
}
function App() {
const [insights, setInsights] = useState<InsightData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const hourChartRef = useRef<HTMLCanvasElement>(null);
const tokenChartRef = useRef<HTMLCanvasElement>(null);
const hourChartInstance = useRef<Chart | null>(null);
const tokenChartInstance = useRef<Chart | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Load insights data
useEffect(() => {
const loadInsights = async () => {
try {
setLoading(true);
const response = await fetch('/api/insights');
if (!response.ok) {
throw new Error('Failed to fetch insights');
}
const data: InsightData = await response.json();
setInsights(data);
setError(null);
} catch (err) {
setError((err as Error).message);
setInsights(null);
} finally {
setLoading(false);
}
};
loadInsights();
}, []);
// Create hour chart when insights change
useEffect(() => {
if (!insights || !hourChartRef.current) return;
// Destroy existing chart if it exists
if (hourChartInstance.current) {
hourChartInstance.current.destroy();
}
const labels = Array.from({ length: 24 }, (_, i) => `${i}:00`);
const data = labels.map((_, i) => insights.activeHours[i] || 0);
const ctx = hourChartRef.current.getContext('2d');
if (!ctx) return;
hourChartInstance.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,
},
},
} as ChartConfiguration['options'],
});
}, [insights]);
// Create token chart when insights change
useEffect(() => {
if (!insights || !tokenChartRef.current) return;
// Destroy existing chart if it exists
if (tokenChartInstance.current) {
tokenChartInstance.current.destroy();
}
const labels = Object.keys(insights.tokenUsage).slice(-15);
const inputData = labels.map(
(date) => insights.tokenUsage[date]?.input || 0,
);
const outputData = labels.map(
(date) => insights.tokenUsage[date]?.output || 0,
);
const totalData = labels.map(
(date) => insights.tokenUsage[date]?.total || 0,
);
const ctx = tokenChartRef.current.getContext('2d');
if (!ctx) return;
tokenChartInstance.current = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Input Tokens',
data: inputData,
borderColor: '#3498db',
backgroundColor: 'rgba(52, 152, 219, 0.1)',
tension: 0.1,
},
{
label: 'Output Tokens',
data: outputData,
borderColor: '#2ecc71',
backgroundColor: 'rgba(46, 204, 113, 0.1)',
tension: 0.1,
},
{
label: 'Total Tokens',
data: totalData,
borderColor: '#9b59b6',
backgroundColor: 'rgba(155, 89, 182, 0.1)',
tension: 0.1,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Token Usage Over Time',
},
},
scales: {
y: {
beginAtZero: true,
},
},
} as ChartConfiguration['options'],
});
}, [insights]);
const handleExport = async () => {
if (!containerRef.current) return;
try {
const button = document.getElementById('export-btn') as HTMLButtonElement;
button.style.display = 'none';
const canvas = await html2canvas(containerRef.current, {
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();
button.style.display = 'block';
} catch (err) {
console.error('Error capturing image:', err);
alert('Failed to export image. Please try again.');
}
};
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div className="glass-card px-8 py-6 text-center">
<h2 className="text-xl font-semibold text-slate-900">
Loading insights...
</h2>
<p className="mt-2 text-sm text-slate-600">
Fetching your coding patterns
</p>
</div>
</div>
);
}
if (error || !insights) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div className="glass-card px-8 py-6 text-center">
<h2 className="text-xl font-semibold text-rose-700">
Error loading insights
</h2>
<p className="mt-2 text-sm text-slate-600">
{error || 'Please try again later.'}
</p>
</div>
</div>
);
}
// Prepare heatmap data for react-heat-map
const heatmapData = Object.entries(insights.heatmap).map(([date, count]) => ({
date,
count,
}));
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="min-h-screen" ref={containerRef}>
<div className="mx-auto max-w-6xl px-6 py-10 md:py-12">
<header className="mb-8 space-y-3 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">
Insights
</p>
<h1 className="text-3xl font-semibold text-slate-900 md:text-4xl">
Qwen Code Insights
</h1>
<p className="text-sm text-slate-600">
Your personalized coding journey and patterns
</p>
</header>
<div className="grid gap-4 md:grid-cols-3 md:gap-6">
<div className={`${cardClass} h-full space-y-4`}>
<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">
{insights.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 {insights.longestStreak}d
</span>
</div>
<div className="rounded-xl bg-slate-900 px-4 py-3 text-white">
<div className="text-sm text-slate-200">Longest work session</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-2xl font-semibold">
{insights.longestWorkDuration}
</span>
<span className="text-sm text-slate-300">minutes</span>
</div>
</div>
</div>
<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={hourChartRef}></canvas>
</div>
</div>
<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">
{insights.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">
{insights.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">
{insights.latestActiveTime || '-'}
</p>
</div>
</div>
</div>
</div>
<div className={`${cardClass} mt-4 space-y-4 md:mt-6`}>
<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="overflow-x-auto">
<div className="min-w-[720px] rounded-xl border border-slate-100 bg-white/70 p-4 shadow-inner shadow-slate-100">
<HeatMap
value={heatmapData}
width={1000}
style={{ color: '#0f172a' } satisfies CSSProperties}
startDate={
new Date(new Date().setFullYear(new Date().getFullYear() - 1))
}
endDate={new Date()}
rectSize={14}
legendCellSize={12}
rectProps={{
rx: 2,
}}
panelColors={{
0: '#e2e8f0',
2: '#a5d8ff',
4: '#74c0fc',
10: '#339af0',
20: '#1c7ed6',
}}
/>
</div>
</div>
</div>
<div className={`${cardClass} mt-4 space-y-4 md:mt-6`}>
<div className="flex items-center justify-between">
<h3 className={sectionTitleClass}>Token Usage</h3>
<span className="text-xs font-semibold text-slate-500">
Recent 15 days
</span>
</div>
<div className="h-80 w-full">
<canvas ref={tokenChartRef}></canvas>
</div>
</div>
<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">
{insights.achievements.length} total
</span>
</div>
{insights.achievements.length === 0 ? (
<p className="text-sm text-slate-600">
No achievements yet. Keep coding!
</p>
) : (
<div className="divide-y divide-slate-200">
{insights.achievements.map((achievement) => (
<div
key={achievement.id}
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>
))}
</div>
)}
</div>
<div className="mt-6 flex justify-center">
<button
id="export-btn"
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]"
onClick={handleExport}
>
Export as Image
<span className="text-slate-200 transition group-hover:translate-x-0.5">
</span>
</button>
</div>
</div>
</div>
);
}
export default App;

View file

@ -0,0 +1,15 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 text-slate-900 antialiased;
}
}
@layer components {
.glass-card {
@apply rounded-2xl border border-slate-200 bg-white/80 shadow-soft backdrop-blur;
}
}

View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View file

@ -0,0 +1,18 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
boxShadow: {
soft: '0 10px 40px rgba(15, 23, 42, 0.08)',
},
borderRadius: {
xl: '1.25rem',
},
},
},
plugins: [],
};
export default config;

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qwen Code Insights</title>
<script type="module" crossorigin src="/assets/index-CjMrkCzb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Db2XRQoj.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: 'views',
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});

View file

@ -71,8 +71,14 @@ if (!BASE_DIR) {
process.exit(1);
}
// Serve static assets from the views/assets directory
app.use(
'/assets',
express.static(path.join(__dirname, 'insight-page', 'views', 'assets')),
);
app.get('/', (_req, res) => {
res.sendFile(path.join(__dirname, 'views', 'index.html'));
res.sendFile(path.join(__dirname, 'insight-page', 'views', 'index.html'));
});
// API endpoint to get insight data
@ -80,9 +86,6 @@ app.get('/api/insights', async (_req, res) => {
try {
debugLog('Received request for insights data');
const insights = await generateInsights(BASE_DIR);
debugLog(
`Returning insights data, heatmap size: ${Object.keys(insights.heatmap).length}`,
);
res.json(insights);
} catch (error) {
debugLog(`Error generating insights: ${error}`);
@ -131,9 +134,6 @@ async function generateInsights(baseDir: string): Promise<InsightData> {
for (const file of chatFiles) {
const filePath = path.join(chatsDir, file);
const records = await read<ChatRecord>(filePath);
debugLog(
`Processing file: ${filePath}, records count: ${records.length}`,
);
// Process each record
for (const record of records) {

View file

@ -1,404 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qwen Code Insights</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f7fa;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
}
h1 {
color: #2c3e50;
}
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 20px;
text-align: center;
}
.card h3 {
margin-top: 0;
color: #3498db;
}
.streaks {
display: flex;
justify-content: space-around;
margin: 20px 0;
}
.streak-box {
text-align: center;
}
.streak-value {
font-size: 2em;
font-weight: bold;
color: #2ecc71;
}
.heatmap-container {
height: 300px;
display: flex;
align-items: flex-end;
justify-content: space-around;
margin: 20px 0;
padding: 20px;
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.day-bar {
width: 12px;
background: #3498db;
margin: 0 2px;
border-radius: 3px 3px 0 0;
position: relative;
}
.day-label {
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
white-space: nowrap;
}
.achievement {
background: #f8f9fa;
border-left: 4px solid #3498db;
padding: 10px;
margin: 10px 0;
border-radius: 0 4px 4px 0;
}
.token-chart-container {
height: 300px;
}
.export-btn {
display: block;
margin: 20px auto;
padding: 10px 20px;
background: #3498db;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
.export-btn:hover {
background: #2980b9;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Qwen Code Insights</h1>
<p>Your personalized coding journey and patterns</p>
</header>
<div class="dashboard">
<div class="card">
<h3>Current Streak</h3>
<div class="streaks">
<div class="streak-box">
<div class="streak-value" id="current-streak">0</div>
<div>Days</div>
</div>
<div class="streak-box">
<div class="streak-value" id="longest-streak">0</div>
<div>Longest</div>
</div>
</div>
</div>
<div class="card">
<h3>Active Hours</h3>
<canvas id="hourChart" width="400" height="200"></canvas>
</div>
<div class="card">
<h3>Work Session</h3>
<div>Longest: <span id="longest-work-duration">0</span> min</div>
<div>Date: <span id="longest-work-date">-</span></div>
<div>Last Active: <span id="latest-active-time">-</span></div>
</div>
</div>
<div class="card">
<h3>Activity Heatmap</h3>
<div class="heatmap-container" id="heatmap-container">
<!-- Heatmap bars will be populated by JavaScript -->
</div>
</div>
<div class="card token-chart-container">
<h3>Token Usage</h3>
<canvas id="tokenChart"></canvas>
</div>
<div class="card">
<h3>Achievements</h3>
<div id="achievements-container">
<!-- Achievements will be populated by JavaScript -->
</div>
</div>
<button class="export-btn" id="export-btn">Export as Image</button>
</div>
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
<script>
// Fetch insights data from API
async function loadInsights() {
try {
const response = await fetch('/api/insights');
const data = await response.json();
console.log('🚀 ~ loadInsights ~ data:', data);
updateDashboard(data);
} catch (error) {
console.error('Error loading insights:', error);
document.body.innerHTML =
'<div class="container"><h2>Error loading insights</h2><p>Please try again later.</p></div>';
}
}
function updateDashboard(data) {
// Update streaks
document.getElementById('current-streak').textContent =
data.currentStreak;
document.getElementById('longest-streak').textContent =
data.longestStreak;
// Update work session data
document.getElementById('longest-work-duration').textContent =
data.longestWorkDuration;
document.getElementById('longest-work-date').textContent =
data.longestWorkDate || '-';
document.getElementById('latest-active-time').textContent =
data.latestActiveTime || '-';
// Create heatmap
createHeatmap(data.heatmap);
// Create token usage chart
createTokenChart(data.tokenUsage);
// Create hour activity chart
createHourChart(data.activeHours);
// Display achievements
displayAchievements(data.achievements);
}
function createHeatmap(heatmapData) {
const container = document.getElementById('heatmap-container');
container.innerHTML = ''; // Clear existing content
// Get the last 30 days of data
const dates = Object.keys(heatmapData).sort().slice(-30);
// Calculate max value for scaling
const maxValue = Math.max(...Object.values(heatmapData));
dates.forEach((date) => {
const value = heatmapData[date] || 0;
const height = maxValue > 0 ? (value / maxValue) * 200 : 0;
const barContainer = document.createElement('div');
barContainer.style.position = 'relative';
barContainer.style.height = '250px';
barContainer.style.display = 'flex';
barContainer.style.alignItems = 'flex-end';
const bar = document.createElement('div');
bar.className = 'day-bar';
bar.style.height = `${height}px`;
// Color intensity based on activity
const intensity = value / maxValue;
const r = 52;
const g = Math.floor(152 - 152 * intensity);
const b = Math.floor(100 + 100 * intensity);
bar.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
const label = document.createElement('div');
label.className = 'day-label';
label.textContent = new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
barContainer.appendChild(bar);
barContainer.appendChild(label);
container.appendChild(barContainer);
});
}
function createTokenChart(tokenData) {
const ctx = document.getElementById('tokenChart').getContext('2d');
// Prepare data for chart
const labels = Object.keys(tokenData).slice(-15); // Last 15 days
const inputData = labels.map((date) => tokenData[date]?.input || 0);
const outputData = labels.map((date) => tokenData[date]?.output || 0);
const totalData = labels.map((date) => tokenData[date]?.total || 0);
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Input Tokens',
data: inputData,
borderColor: '#3498db',
backgroundColor: 'rgba(52, 152, 219, 0.1)',
tension: 0.1,
},
{
label: 'Output Tokens',
data: outputData,
borderColor: '#2ecc71',
backgroundColor: 'rgba(46, 204, 113, 0.1)',
tension: 0.1,
},
{
label: 'Total Tokens',
data: totalData,
borderColor: '#9b59b6',
backgroundColor: 'rgba(155, 89, 182, 0.1)',
tension: 0.1,
},
],
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: 'Token Usage Over Time',
},
},
scales: {
y: {
beginAtZero: true,
},
},
},
});
}
function createHourChart(hourData) {
const ctx = document.getElementById('hourChart').getContext('2d');
// Prepare data for chart - use all 24 hours
const labels = Array.from({ length: 24 }, (_, i) => `${i}:00`);
const data = labels.map((_, i) => hourData[i] || 0);
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Activity per Hour',
data: data,
backgroundColor: 'rgba(52, 152, 219, 0.7)',
borderColor: 'rgba(52, 152, 219, 1)',
borderWidth: 1,
},
],
},
options: {
indexAxis: 'y',
scales: {
x: {
beginAtZero: true,
},
},
plugins: {
legend: {
display: false,
},
},
},
});
}
function displayAchievements(achievements) {
const container = document.getElementById('achievements-container');
if (achievements.length === 0) {
container.innerHTML = '<p>No achievements yet. Keep coding!</p>';
return;
}
container.innerHTML = '';
achievements.forEach((achievement) => {
const achievementEl = document.createElement('div');
achievementEl.className = 'achievement';
achievementEl.innerHTML = `
<strong>${achievement.name}</strong>
<p>${achievement.description}</p>
`;
container.appendChild(achievementEl);
});
}
// Export functionality
document
.getElementById('export-btn')
.addEventListener('click', function () {
// Hide the button temporarily
const exportBtn = document.getElementById('export-btn');
exportBtn.style.display = 'none';
// Use html2canvas to capture the dashboard
html2canvas(document.querySelector('.container'), {
scale: 2, // Higher resolution
useCORS: true,
logging: false,
})
.then((canvas) => {
// Convert canvas to data URL
const imgData = canvas.toDataURL('image/png');
// Create download link
const link = document.createElement('a');
link.href = imgData;
link.download = `qwen-insights-${new Date().toISOString().slice(0, 10)}.png`;
link.click();
// Show the button again
exportBtn.style.display = 'block';
})
.catch((err) => {
console.error('Error capturing image:', err);
alert('Failed to export image. Please try again.');
exportBtn.style.display = 'block';
});
});
// Load insights when page loads
window.onload = loadInsights;
</script>
</body>
</html>