vibeglish 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,710 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>VibeGlish Dashboard</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117;
10
+ --surface: #161b22;
11
+ --surface2: #21262d;
12
+ --border: #30363d;
13
+ --text: #e6edf3;
14
+ --text-dim: #8b949e;
15
+ --green: #3fb950;
16
+ --green-bg: rgba(63,185,80,0.15);
17
+ --red: #f85149;
18
+ --red-bg: rgba(248,81,73,0.15);
19
+ --yellow: #d29922;
20
+ --yellow-bg: rgba(210,153,34,0.15);
21
+ --blue: #58a6ff;
22
+ --purple: #bc8cff;
23
+ --radius: 8px;
24
+ }
25
+ * { margin: 0; padding: 0; box-sizing: border-box; }
26
+ body {
27
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ line-height: 1.6;
31
+ min-height: 100vh;
32
+ }
33
+ a { color: var(--blue); text-decoration: none; }
34
+
35
+ /* Layout */
36
+ .header {
37
+ padding: 16px 24px;
38
+ border-bottom: 1px solid var(--border);
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ }
43
+ .header h1 { font-size: 20px; font-weight: 600; }
44
+ .header h1 span { color: var(--blue); }
45
+ nav { display: flex; gap: 4px; }
46
+ nav a {
47
+ padding: 8px 16px;
48
+ border-radius: var(--radius);
49
+ color: var(--text-dim);
50
+ font-size: 14px;
51
+ transition: all 0.2s;
52
+ }
53
+ nav a:hover { background: var(--surface2); color: var(--text); }
54
+ nav a.active { background: var(--surface2); color: var(--text); font-weight: 500; }
55
+ .container { max-width: 960px; margin: 0 auto; padding: 24px; }
56
+ .page { display: none; }
57
+ .page.active { display: block; }
58
+
59
+ /* Cards */
60
+ .stats-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
61
+ .stat-card {
62
+ background: var(--surface);
63
+ border: 1px solid var(--border);
64
+ border-radius: var(--radius);
65
+ padding: 20px;
66
+ text-align: center;
67
+ }
68
+ .stat-card .number { font-size: 36px; font-weight: 700; color: var(--blue); }
69
+ .stat-card .label { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
70
+
71
+ /* Timeline */
72
+ .entry-card {
73
+ background: var(--surface);
74
+ border: 1px solid var(--border);
75
+ border-radius: var(--radius);
76
+ margin-bottom: 12px;
77
+ overflow: hidden;
78
+ display: flex;
79
+ }
80
+ .entry-score-bar {
81
+ width: 4px;
82
+ flex-shrink: 0;
83
+ }
84
+ .score-high { background: var(--green); }
85
+ .score-mid { background: var(--yellow); }
86
+ .score-low { background: var(--red); }
87
+ .entry-body { padding: 16px; flex: 1; min-width: 0; }
88
+ .entry-meta {
89
+ display: flex;
90
+ justify-content: space-between;
91
+ align-items: center;
92
+ margin-bottom: 8px;
93
+ font-size: 12px;
94
+ color: var(--text-dim);
95
+ }
96
+ .entry-score-badge {
97
+ padding: 2px 8px;
98
+ border-radius: 12px;
99
+ font-weight: 600;
100
+ font-size: 12px;
101
+ }
102
+ .entry-original, .entry-corrected {
103
+ padding: 8px 12px;
104
+ border-radius: 6px;
105
+ font-size: 14px;
106
+ margin-bottom: 6px;
107
+ word-break: break-word;
108
+ }
109
+ .entry-original { background: var(--surface2); }
110
+ .entry-corrected { background: var(--green-bg); border-left: 3px solid var(--green); }
111
+ .diff-del { background: var(--red-bg); color: var(--red); text-decoration: line-through; padding: 1px 3px; border-radius: 3px; }
112
+ .diff-ins { background: var(--green-bg); color: var(--green); padding: 1px 3px; border-radius: 3px; }
113
+ .entry-issues { margin-top: 8px; }
114
+ .entry-issues summary {
115
+ cursor: pointer;
116
+ font-size: 13px;
117
+ color: var(--text-dim);
118
+ padding: 4px 0;
119
+ }
120
+ .issue-item {
121
+ background: var(--surface2);
122
+ padding: 10px 12px;
123
+ border-radius: 6px;
124
+ margin-top: 6px;
125
+ font-size: 13px;
126
+ }
127
+ .issue-type {
128
+ display: inline-block;
129
+ font-size: 11px;
130
+ padding: 1px 6px;
131
+ border-radius: 4px;
132
+ background: var(--purple);
133
+ color: var(--bg);
134
+ font-weight: 600;
135
+ margin-right: 6px;
136
+ }
137
+ .issue-rule { color: var(--text-dim); margin-top: 4px; font-style: italic; }
138
+ .clean-badge {
139
+ background: var(--green-bg);
140
+ color: var(--green);
141
+ padding: 4px 12px;
142
+ border-radius: 6px;
143
+ font-size: 13px;
144
+ }
145
+
146
+ /* Charts */
147
+ .chart-container {
148
+ background: var(--surface);
149
+ border: 1px solid var(--border);
150
+ border-radius: var(--radius);
151
+ padding: 20px;
152
+ margin-bottom: 16px;
153
+ }
154
+ .chart-container h3 { font-size: 14px; margin-bottom: 12px; color: var(--text-dim); }
155
+ .chart-controls { display: flex; gap: 8px; margin-bottom: 16px; }
156
+ .chart-btn {
157
+ background: var(--surface2);
158
+ border: 1px solid var(--border);
159
+ color: var(--text-dim);
160
+ padding: 4px 12px;
161
+ border-radius: 6px;
162
+ cursor: pointer;
163
+ font-size: 12px;
164
+ }
165
+ .chart-btn.active { background: var(--blue); color: var(--bg); border-color: var(--blue); }
166
+
167
+ /* Mistakes */
168
+ .tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
169
+ .tab-btn {
170
+ background: var(--surface);
171
+ border: 1px solid var(--border);
172
+ color: var(--text-dim);
173
+ padding: 6px 14px;
174
+ border-radius: 6px;
175
+ cursor: pointer;
176
+ font-size: 13px;
177
+ }
178
+ .tab-btn.active { background: var(--blue); color: var(--bg); border-color: var(--blue); }
179
+ .mistake-pattern {
180
+ background: var(--surface);
181
+ border: 1px solid var(--border);
182
+ border-radius: var(--radius);
183
+ padding: 16px;
184
+ margin-bottom: 12px;
185
+ }
186
+ .mistake-count { float: right; color: var(--yellow); font-weight: 600; }
187
+ .mistake-examples { margin-top: 10px; }
188
+ .mistake-example {
189
+ font-size: 13px;
190
+ padding: 6px 10px;
191
+ background: var(--surface2);
192
+ border-radius: 6px;
193
+ margin-top: 6px;
194
+ }
195
+ .search-box {
196
+ width: 100%;
197
+ padding: 10px 14px;
198
+ background: var(--surface);
199
+ border: 1px solid var(--border);
200
+ border-radius: var(--radius);
201
+ color: var(--text);
202
+ font-size: 14px;
203
+ margin-bottom: 16px;
204
+ outline: none;
205
+ }
206
+ .search-box:focus { border-color: var(--blue); }
207
+
208
+ /* Achievements */
209
+ .ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
210
+ .ach-card {
211
+ background: var(--surface);
212
+ border: 1px solid var(--border);
213
+ border-radius: var(--radius);
214
+ padding: 16px;
215
+ transition: all 0.2s;
216
+ }
217
+ .ach-card.locked { opacity: 0.5; }
218
+ .ach-card .ach-icon { font-size: 28px; margin-bottom: 8px; }
219
+ .ach-card .ach-name { font-weight: 600; font-size: 15px; }
220
+ .ach-card .ach-desc { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
221
+ .ach-card .ach-date { font-size: 11px; color: var(--green); margin-top: 8px; }
222
+ .progress-bar {
223
+ height: 4px;
224
+ background: var(--surface2);
225
+ border-radius: 2px;
226
+ margin-top: 8px;
227
+ overflow: hidden;
228
+ }
229
+ .progress-bar-fill { height: 100%; background: var(--blue); border-radius: 2px; transition: width 0.3s; }
230
+
231
+ /* Empty state */
232
+ .empty-state {
233
+ text-align: center;
234
+ padding: 60px 20px;
235
+ color: var(--text-dim);
236
+ }
237
+ .empty-state h2 { font-size: 24px; margin-bottom: 8px; color: var(--text); }
238
+
239
+ /* Date picker */
240
+ .date-nav {
241
+ display: flex;
242
+ align-items: center;
243
+ gap: 12px;
244
+ margin-bottom: 20px;
245
+ }
246
+ .date-nav button {
247
+ background: var(--surface2);
248
+ border: 1px solid var(--border);
249
+ color: var(--text);
250
+ padding: 6px 12px;
251
+ border-radius: 6px;
252
+ cursor: pointer;
253
+ }
254
+ .date-nav span { font-size: 15px; font-weight: 500; }
255
+
256
+ @media (max-width: 640px) {
257
+ .stats-row { grid-template-columns: 1fr; }
258
+ .ach-grid { grid-template-columns: 1fr; }
259
+ .header { flex-direction: column; gap: 12px; }
260
+ }
261
+ </style>
262
+ </head>
263
+ <body>
264
+
265
+ <div class="header">
266
+ <h1>Vibe<span>Glish</span></h1>
267
+ <nav>
268
+ <a href="#today" class="active" data-page="today">Today</a>
269
+ <a href="#trends" data-page="trends">Trends</a>
270
+ <a href="#mistakes" data-page="mistakes">Mistakes</a>
271
+ <a href="#achievements" data-page="achievements">Achievements</a>
272
+ </nav>
273
+ </div>
274
+
275
+ <div class="container">
276
+ <!-- Today Page -->
277
+ <div id="page-today" class="page active"></div>
278
+
279
+ <!-- Trends Page -->
280
+ <div id="page-trends" class="page"></div>
281
+
282
+ <!-- Mistakes Page -->
283
+ <div id="page-mistakes" class="page"></div>
284
+
285
+ <!-- Achievements Page -->
286
+ <div id="page-achievements" class="page"></div>
287
+ </div>
288
+
289
+ <script>
290
+ (function() {
291
+ let DATA = null;
292
+ let currentDate = new Date().toISOString().slice(0, 10);
293
+
294
+ // Navigation
295
+ document.querySelectorAll('nav a').forEach(a => {
296
+ a.addEventListener('click', e => {
297
+ e.preventDefault();
298
+ const page = a.dataset.page;
299
+ location.hash = page;
300
+ switchPage(page);
301
+ });
302
+ });
303
+
304
+ function switchPage(page) {
305
+ document.querySelectorAll('nav a').forEach(a => a.classList.toggle('active', a.dataset.page === page));
306
+ document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === 'page-' + page));
307
+ if (page === 'today') renderToday();
308
+ if (page === 'trends') renderTrends();
309
+ if (page === 'mistakes') renderMistakes();
310
+ if (page === 'achievements') renderAchievements();
311
+ }
312
+
313
+ // Load data
314
+ async function loadData() {
315
+ try {
316
+ const res = await fetch('/api/data');
317
+ DATA = await res.json();
318
+ const hash = location.hash.slice(1) || 'today';
319
+ switchPage(hash);
320
+ } catch (err) {
321
+ document.getElementById('page-today').innerHTML = `
322
+ <div class="empty-state">
323
+ <h2>Unable to load data</h2>
324
+ <p>${err.message}</p>
325
+ </div>`;
326
+ }
327
+ }
328
+
329
+ // ===== Today =====
330
+ function renderToday() {
331
+ const container = document.getElementById('page-today');
332
+ if (!DATA || DATA.days.length === 0) {
333
+ container.innerHTML = `
334
+ <div class="empty-state">
335
+ <h2>No data yet</h2>
336
+ <p>Start using Claude Code — your prompts will be captured automatically.</p>
337
+ <p style="margin-top:12px">Then run <code>vibeglish review</code> to get AI corrections.</p>
338
+ </div>`;
339
+ return;
340
+ }
341
+
342
+ const dayData = DATA.days.find(d => d.date === currentDate) || DATA.days[DATA.days.length - 1];
343
+ if (!dayData) { container.innerHTML = '<div class="empty-state"><h2>No data for this date</h2></div>'; return; }
344
+
345
+ const entries = dayData.entries || [];
346
+ const avgScore = dayData.stats?.avg_score || 0;
347
+ const cleanCount = entries.filter(e => e.is_clean).length;
348
+ const cleanPct = entries.length > 0 ? ((cleanCount / entries.length) * 100).toFixed(1) : 0;
349
+
350
+ let html = `
351
+ <div class="date-nav">
352
+ <button onclick="navDate(-1)">&larr;</button>
353
+ <span>${dayData.date}</span>
354
+ <button onclick="navDate(1)">&rarr;</button>
355
+ </div>
356
+ <div class="stats-row">
357
+ <div class="stat-card">
358
+ <div class="number">${avgScore.toFixed(0)}</div>
359
+ <div class="label">Average Score</div>
360
+ </div>
361
+ <div class="stat-card">
362
+ <div class="number">${entries.length}</div>
363
+ <div class="label">Prompts Reviewed</div>
364
+ </div>
365
+ <div class="stat-card">
366
+ <div class="number">${cleanPct}%</div>
367
+ <div class="label">Clean Prompts</div>
368
+ </div>
369
+ </div>`;
370
+
371
+ for (const entry of entries) {
372
+ const scoreClass = entry.score >= 80 ? 'score-high' : entry.score >= 60 ? 'score-mid' : 'score-low';
373
+ const scoreColor = entry.score >= 80 ? 'var(--green)' : entry.score >= 60 ? 'var(--yellow)' : 'var(--red)';
374
+ const time = entry.ts ? new Date(entry.ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : '';
375
+
376
+ if (entry.is_clean) {
377
+ html += `
378
+ <div class="entry-card">
379
+ <div class="entry-score-bar score-high"></div>
380
+ <div class="entry-body">
381
+ <div class="entry-meta">
382
+ <span>${time} · ${entry.project || ''}</span>
383
+ <span class="clean-badge">Clean!</span>
384
+ </div>
385
+ <div class="entry-original">${escapeHtml(entry.original)}</div>
386
+ </div>
387
+ </div>`;
388
+ continue;
389
+ }
390
+
391
+ const diffHtml = wordDiff(entry.original, entry.corrected || entry.original);
392
+ const issues = entry.issues || [];
393
+
394
+ html += `
395
+ <div class="entry-card">
396
+ <div class="entry-score-bar ${scoreClass}"></div>
397
+ <div class="entry-body">
398
+ <div class="entry-meta">
399
+ <span>${time} · ${entry.project || ''}</span>
400
+ <span class="entry-score-badge" style="background:${scoreColor}20;color:${scoreColor}">${entry.score}</span>
401
+ </div>
402
+ <div class="entry-original">${escapeHtml(entry.original)}</div>
403
+ <div class="entry-corrected">${diffHtml}</div>
404
+ ${issues.length > 0 ? `
405
+ <details class="entry-issues">
406
+ <summary>${issues.length} correction(s)</summary>
407
+ ${issues.map(i => `
408
+ <div class="issue-item">
409
+ <span class="issue-type">${i.type}</span>
410
+ <span class="diff-del">${escapeHtml(i.original)}</span> &rarr;
411
+ <span class="diff-ins">${escapeHtml(i.corrected)}</span>
412
+ <div class="issue-rule">${escapeHtml(i.rule || '')}</div>
413
+ </div>`).join('')}
414
+ </details>` : ''}
415
+ </div>
416
+ </div>`;
417
+ }
418
+
419
+ container.innerHTML = html;
420
+ }
421
+
422
+ window.navDate = function(delta) {
423
+ if (!DATA || DATA.days.length === 0) return;
424
+ const dates = DATA.days.map(d => d.date).sort();
425
+ const idx = dates.indexOf(currentDate);
426
+ if (idx === -1) {
427
+ currentDate = dates[dates.length - 1];
428
+ } else {
429
+ const newIdx = Math.max(0, Math.min(dates.length - 1, idx + delta));
430
+ currentDate = dates[newIdx];
431
+ }
432
+ renderToday();
433
+ };
434
+
435
+ // ===== Trends =====
436
+ let trendsRange = 30;
437
+
438
+ function renderTrends() {
439
+ const container = document.getElementById('page-trends');
440
+ if (!DATA || DATA.days.length === 0) {
441
+ container.innerHTML = '<div class="empty-state"><h2>No data yet</h2></div>';
442
+ return;
443
+ }
444
+
445
+ const days = DATA.days.slice(-trendsRange);
446
+
447
+ let html = `
448
+ <div class="chart-controls">
449
+ <button class="chart-btn ${trendsRange===7?'active':''}" onclick="setRange(7)">7 Days</button>
450
+ <button class="chart-btn ${trendsRange===30?'active':''}" onclick="setRange(30)">30 Days</button>
451
+ <button class="chart-btn ${trendsRange===9999?'active':''}" onclick="setRange(9999)">All</button>
452
+ </div>`;
453
+
454
+ // Score trend chart (SVG)
455
+ html += `<div class="chart-container"><h3>Daily Average Score</h3>${buildLineChart(days.map(d => ({
456
+ label: d.date.slice(5),
457
+ value: d.stats.avg_score
458
+ })), 100)}</div>`;
459
+
460
+ // Prompt count chart
461
+ html += `<div class="chart-container"><h3>Daily Prompt Count</h3>${buildBarChart(days.map(d => ({
462
+ label: d.date.slice(5),
463
+ value: d.stats.total_reviewed
464
+ })))}</div>`;
465
+
466
+ // Issue type trend
467
+ const issueTypes = ['grammar', 'vocabulary', 'spelling', 'punctuation', 'style', 'word_order'];
468
+ const colors = ['#f85149', '#58a6ff', '#3fb950', '#d29922', '#bc8cff', '#f0883e'];
469
+ html += `<div class="chart-container"><h3>Error Types Over Time</h3>${buildStackedChart(days, issueTypes, colors)}</div>`;
470
+
471
+ container.innerHTML = html;
472
+ }
473
+
474
+ window.setRange = function(r) { trendsRange = r; renderTrends(); };
475
+
476
+ function buildLineChart(data, maxVal) {
477
+ if (data.length === 0) return '';
478
+ const w = 900, h = 200, pad = 40;
479
+ const stepX = (w - pad * 2) / Math.max(data.length - 1, 1);
480
+ const points = data.map((d, i) => `${pad + i * stepX},${h - pad - (d.value / maxVal) * (h - pad * 2)}`).join(' ');
481
+
482
+ let labels = '';
483
+ const step = Math.max(1, Math.floor(data.length / 10));
484
+ for (let i = 0; i < data.length; i += step) {
485
+ labels += `<text x="${pad + i * stepX}" y="${h - 8}" fill="var(--text-dim)" font-size="10" text-anchor="middle">${data[i].label}</text>`;
486
+ }
487
+
488
+ // Grid lines
489
+ let grid = '';
490
+ for (let v = 0; v <= maxVal; v += 20) {
491
+ const y = h - pad - (v / maxVal) * (h - pad * 2);
492
+ grid += `<line x1="${pad}" y1="${y}" x2="${w-pad}" y2="${y}" stroke="var(--border)" stroke-dasharray="4"/>`;
493
+ grid += `<text x="${pad-5}" y="${y+4}" fill="var(--text-dim)" font-size="10" text-anchor="end">${v}</text>`;
494
+ }
495
+
496
+ return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto">
497
+ ${grid}
498
+ <polyline points="${points}" fill="none" stroke="var(--blue)" stroke-width="2"/>
499
+ ${data.map((d,i) => `<circle cx="${pad + i * stepX}" cy="${h - pad - (d.value / maxVal) * (h - pad * 2)}" r="3" fill="var(--blue)"><title>${d.label}: ${d.value.toFixed(1)}</title></circle>`).join('')}
500
+ ${labels}
501
+ </svg>`;
502
+ }
503
+
504
+ function buildBarChart(data) {
505
+ if (data.length === 0) return '';
506
+ const w = 900, h = 160, pad = 40;
507
+ const maxVal = Math.max(...data.map(d => d.value), 1);
508
+ const barW = Math.max(4, (w - pad * 2) / data.length * 0.7);
509
+ const gap = (w - pad * 2) / data.length;
510
+
511
+ let bars = '';
512
+ const step = Math.max(1, Math.floor(data.length / 10));
513
+ for (let i = 0; i < data.length; i++) {
514
+ const barH = (data[i].value / maxVal) * (h - pad * 2);
515
+ bars += `<rect x="${pad + i * gap + (gap - barW) / 2}" y="${h - pad - barH}" width="${barW}" height="${barH}" fill="var(--blue)" rx="2"><title>${data[i].label}: ${data[i].value}</title></rect>`;
516
+ if (i % step === 0) {
517
+ bars += `<text x="${pad + i * gap + gap/2}" y="${h-8}" fill="var(--text-dim)" font-size="10" text-anchor="middle">${data[i].label}</text>`;
518
+ }
519
+ }
520
+
521
+ return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto">${bars}</svg>`;
522
+ }
523
+
524
+ function buildStackedChart(days, types, colors) {
525
+ if (days.length === 0) return '';
526
+ const w = 900, h = 200, pad = 40;
527
+
528
+ // Legend
529
+ let legend = '<div style="display:flex;gap:16px;flex-wrap:wrap;margin-top:8px">';
530
+ types.forEach((t, i) => {
531
+ legend += `<span style="font-size:12px;color:${colors[i]}">&#9632; ${t}</span>`;
532
+ });
533
+ legend += '</div>';
534
+
535
+ // Compute stacked values
536
+ const stacked = days.map(d => {
537
+ const counts = {};
538
+ types.forEach(t => counts[t] = d.stats?.issue_breakdown?.[t] || 0);
539
+ return counts;
540
+ });
541
+ const maxTotal = Math.max(...stacked.map(s => types.reduce((sum, t) => sum + s[t], 0)), 1);
542
+ const gap = (w - pad * 2) / Math.max(days.length, 1);
543
+
544
+ let svg = '';
545
+ for (let i = 0; i < days.length; i++) {
546
+ let y = h - pad;
547
+ for (let j = 0; j < types.length; j++) {
548
+ const barH = (stacked[i][types[j]] / maxTotal) * (h - pad * 2);
549
+ svg += `<rect x="${pad + i * gap + 1}" y="${y - barH}" width="${Math.max(gap - 2, 2)}" height="${barH}" fill="${colors[j]}" opacity="0.8"/>`;
550
+ y -= barH;
551
+ }
552
+ }
553
+
554
+ return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto">${svg}</svg>${legend}`;
555
+ }
556
+
557
+ // ===== Mistakes =====
558
+ let mistakeTab = 'grammar';
559
+
560
+ function renderMistakes() {
561
+ const container = document.getElementById('page-mistakes');
562
+ if (!DATA || DATA.days.length === 0) {
563
+ container.innerHTML = '<div class="empty-state"><h2>No data yet</h2></div>';
564
+ return;
565
+ }
566
+
567
+ const types = ['grammar', 'vocabulary', 'spelling', 'punctuation', 'style', 'word_order'];
568
+
569
+ // Aggregate issues by type and rule
570
+ const patterns = {};
571
+ for (const day of DATA.days) {
572
+ for (const entry of day.entries || []) {
573
+ for (const issue of entry.issues || []) {
574
+ const key = `${issue.type}::${issue.rule || issue.corrected}`;
575
+ if (!patterns[key]) {
576
+ patterns[key] = { type: issue.type, rule: issue.rule, count: 0, examples: [] };
577
+ }
578
+ patterns[key].count++;
579
+ if (patterns[key].examples.length < 3) {
580
+ patterns[key].examples.push({ original: issue.original, corrected: issue.corrected });
581
+ }
582
+ }
583
+ }
584
+ }
585
+
586
+ const filtered = Object.values(patterns)
587
+ .filter(p => p.type === mistakeTab)
588
+ .sort((a, b) => b.count - a.count);
589
+
590
+ let html = `
591
+ <input class="search-box" type="text" placeholder="Search mistakes..." oninput="filterMistakes(this.value)">
592
+ <div class="tabs">
593
+ ${types.map(t => `<button class="tab-btn ${t===mistakeTab?'active':''}" onclick="setMistakeTab('${t}')">${t}</button>`).join('')}
594
+ </div>
595
+ <div id="mistake-list">`;
596
+
597
+ for (const p of filtered) {
598
+ html += `
599
+ <div class="mistake-pattern" data-search="${escapeHtml((p.rule || '') + ' ' + p.examples.map(e => e.original + ' ' + e.corrected).join(' ')).toLowerCase()}">
600
+ <span class="mistake-count">${p.count} times</span>
601
+ <strong>${escapeHtml(p.rule || 'Unknown pattern')}</strong>
602
+ <div class="mistake-examples">
603
+ ${p.examples.map(e => `
604
+ <div class="mistake-example">
605
+ <span class="diff-del">${escapeHtml(e.original)}</span> &rarr;
606
+ <span class="diff-ins">${escapeHtml(e.corrected)}</span>
607
+ </div>`).join('')}
608
+ </div>
609
+ </div>`;
610
+ }
611
+
612
+ if (filtered.length === 0) {
613
+ html += `<div class="empty-state"><p>No ${mistakeTab} errors found. Great job!</p></div>`;
614
+ }
615
+
616
+ html += '</div>';
617
+ container.innerHTML = html;
618
+ }
619
+
620
+ window.setMistakeTab = function(t) { mistakeTab = t; renderMistakes(); };
621
+ window.filterMistakes = function(q) {
622
+ const query = q.toLowerCase();
623
+ document.querySelectorAll('.mistake-pattern').forEach(el => {
624
+ el.style.display = el.dataset.search.includes(query) ? '' : 'none';
625
+ });
626
+ };
627
+
628
+ // ===== Achievements =====
629
+ function renderAchievements() {
630
+ const container = document.getElementById('page-achievements');
631
+ if (!DATA) {
632
+ container.innerHTML = '<div class="empty-state"><h2>No data yet</h2></div>';
633
+ return;
634
+ }
635
+
636
+ const achs = DATA.achievements || [];
637
+ let html = '<div class="ach-grid">';
638
+
639
+ for (const a of achs) {
640
+ const pct = a.target ? Math.min(100, Math.round((a.progress / a.target) * 100)) : 0;
641
+ html += `
642
+ <div class="ach-card ${a.unlocked ? '' : 'locked'}">
643
+ <div class="ach-icon">${a.icon}</div>
644
+ <div class="ach-name">${escapeHtml(a.name)}</div>
645
+ <div class="ach-desc">${escapeHtml(a.description)}</div>
646
+ ${a.unlocked
647
+ ? `<div class="ach-date">Unlocked: ${a.unlockedAt ? new Date(a.unlockedAt).toLocaleDateString('zh-CN') : ''}</div>`
648
+ : `<div class="progress-bar"><div class="progress-bar-fill" style="width:${pct}%"></div></div>
649
+ <div style="font-size:11px;color:var(--text-dim);margin-top:4px">${a.progress} / ${a.target}</div>`
650
+ }
651
+ </div>`;
652
+ }
653
+
654
+ html += '</div>';
655
+ container.innerHTML = html;
656
+ }
657
+
658
+ // ===== Utils =====
659
+ function escapeHtml(s) {
660
+ if (!s) return '';
661
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
662
+ }
663
+
664
+ function wordDiff(a, b) {
665
+ if (!a || !b) return escapeHtml(b || a || '');
666
+ const wordsA = a.split(/(\s+)/);
667
+ const wordsB = b.split(/(\s+)/);
668
+
669
+ // Simple LCS-based word diff
670
+ const m = wordsA.length, n = wordsB.length;
671
+ // For performance, if too long just show corrected
672
+ if (m > 200 || n > 200) return escapeHtml(b);
673
+
674
+ const dp = Array.from({length: m+1}, () => new Array(n+1).fill(0));
675
+ for (let i = 1; i <= m; i++) {
676
+ for (let j = 1; j <= n; j++) {
677
+ dp[i][j] = wordsA[i-1] === wordsB[j-1] ? dp[i-1][j-1]+1 : Math.max(dp[i-1][j], dp[i][j-1]);
678
+ }
679
+ }
680
+
681
+ const result = [];
682
+ let i = m, j = n;
683
+ while (i > 0 || j > 0) {
684
+ if (i > 0 && j > 0 && wordsA[i-1] === wordsB[j-1]) {
685
+ result.unshift(escapeHtml(wordsA[i-1]));
686
+ i--; j--;
687
+ } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
688
+ result.unshift(`<span class="diff-ins">${escapeHtml(wordsB[j-1])}</span>`);
689
+ j--;
690
+ } else {
691
+ result.unshift(`<span class="diff-del">${escapeHtml(wordsA[i-1])}</span>`);
692
+ i--;
693
+ }
694
+ }
695
+
696
+ return result.join('');
697
+ }
698
+
699
+ // Hash routing
700
+ window.addEventListener('hashchange', () => {
701
+ const page = location.hash.slice(1) || 'today';
702
+ switchPage(page);
703
+ });
704
+
705
+ // Init
706
+ loadData();
707
+ })();
708
+ </script>
709
+ </body>
710
+ </html>