feat(analytics): add writing analytics with readability scores and vocabulary analysis

Amit Haridas
This commit is contained in:
2026-04-06 11:42:22 +05:30
parent 50d0638c5c
commit 72a7a854d0
4 changed files with 273 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
/**
* Writing Analytics Panel — modal overlay displaying analytics dashboard
*/
const { analyze } = require('./writing-analytics');
function showAnalyticsModal(tabManager) {
const existing = document.getElementById('analytics-modal');
if (existing) existing.remove();
const content = tabManager.getEditorContent();
const metrics = analyze(content);
const overlay = document.createElement('div');
overlay.id = 'analytics-modal';
overlay.className = 'analytics-overlay';
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
overlay.innerHTML = `
<div class="analytics-modal">
<div class="analytics-header">
<h2>Writing Analytics</h2>
<button class="analytics-close" title="Close">&times;</button>
</div>
<div class="analytics-body">
<div class="analytics-section">
<h3>Readability</h3>
<div class="analytics-row">
<span class="analytics-label">Flesch Reading Ease</span>
<span class="analytics-value">${metrics.fleschEase}<small>${metrics.readabilityLabel}</small></span>
</div>
<div class="analytics-row">
<span class="analytics-label">Grade Level</span>
<span class="analytics-value">${metrics.fleschGrade}</span>
</div>
<div class="readability-meter">
<div class="readability-fill" style="width: ${Math.max(0, Math.min(100, metrics.fleschEase))}%"></div>
</div>
</div>
<div class="analytics-section">
<h3>Timing</h3>
<div class="analytics-row">
<span class="analytics-label">Reading Time</span>
<span class="analytics-value">~${metrics.readingTime} min</span>
</div>
<div class="analytics-row">
<span class="analytics-label">Speaking Time</span>
<span class="analytics-value">~${metrics.speakingTime} min</span>
</div>
</div>
<div class="analytics-section">
<h3>Structure</h3>
<div class="analytics-row">
<span class="analytics-label">Sentences</span>
<span class="analytics-value">${metrics.sentenceCount} &bull; Paragraphs: ${metrics.paragraphCount}</span>
</div>
<div class="analytics-row">
<span class="analytics-label">Avg Sentence</span>
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
</div>
${metrics.longestSentenceLength > 0 ? `
<div class="analytics-row analytics-longest">
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
</div>` : ''}
</div>
<div class="analytics-section">
<h3>Vocabulary</h3>
<div class="analytics-row">
<span class="analytics-label">Unique</span>
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
</div>
${metrics.topWords.length > 0 ? `
<div class="word-cloud">
${metrics.topWords.map(w => {
const scale = 13 + Math.round((w.count / maxCount) * 3);
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
}).join('')}
</div>` : ''}
</div>
</div>
</div>
`;
const closeBtn = overlay.querySelector('.analytics-close');
closeBtn.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
const escHandler = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
document.body.appendChild(overlay);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
module.exports = { showAnalyticsModal };
+127
View File
@@ -0,0 +1,127 @@
/**
* Writing Analytics — pure computation engine
* No DOM dependencies. Exported analyze(text) returns a metrics object.
*/
const STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
'could', 'should', 'to', 'of', 'in', 'for', 'on', 'with',
'at', 'by', 'from', 'as', 'and', 'or', 'but', 'if', 'it',
'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my',
'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her',
'they', 'them', 'their', 'not', 'no', 'so', 'than', 'too',
'very', 'also', 'just', 'about', 'up', 'out', 'what', 'which', 'who'
]);
function countSyllables(word) {
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
word = word.replace(/^y/, '');
return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
}
function extractWords(text) {
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
}
function getReadabilityLabel(score) {
if (score >= 90) return 'Very Easy';
if (score >= 70) return 'Easy';
if (score >= 50) return 'Standard';
if (score >= 30) return 'Difficult';
return 'Very Difficult';
}
function analyze(text) {
if (!text || !text.trim()) {
return {
wordCount: 0,
sentenceCount: 0,
paragraphCount: 0,
fleschEase: 0,
fleschGrade: 0,
readabilityLabel: 'N/A',
readingTime: 0,
speakingTime: 0,
uniqueWordCount: 0,
lexicalDiversity: 0,
avgSentenceLength: 0,
longestSentence: '',
longestSentenceLength: 0,
topWords: []
};
}
const words = extractWords(text);
const wordCount = words.length;
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(Boolean);
const sentenceCount = Math.max(sentences.length, 1);
const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
const paragraphCount = Math.max(paragraphs.length, 1);
let totalSyllables = 0;
for (const w of words) {
totalSyllables += countSyllables(w);
}
const fleschEase = Math.round((206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10) / 10;
const fleschGrade = Math.round((0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10) / 10;
const readabilityLabel = getReadabilityLabel(fleschEase);
const readingTime = Math.ceil(wordCount / 200);
const speakingTime = Math.ceil(wordCount / 130);
const uniqueWords = new Set(words.map(w => w.toLowerCase()));
const uniqueWordCount = uniqueWords.size;
const lexicalDiversity = wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
let longestSentence = '';
let longestSentenceLength = 0;
for (const s of sentences) {
const sWords = extractWords(s);
if (sWords.length > longestSentenceLength) {
longestSentenceLength = sWords.length;
longestSentence = s.trim();
}
}
if (longestSentence.length > 80) {
longestSentence = longestSentence.substring(0, 80) + '...';
}
const wordFreq = {};
for (const w of words) {
const lower = w.toLowerCase();
if (!STOP_WORDS.has(lower) && lower.length > 1) {
wordFreq[lower] = (wordFreq[lower] || 0) + 1;
}
}
const topWords = Object.entries(wordFreq)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([word, count]) => ({ word, count }));
return {
wordCount,
sentenceCount,
paragraphCount,
fleschEase,
fleschGrade,
readabilityLabel,
readingTime,
speakingTime,
uniqueWordCount,
lexicalDiversity,
avgSentenceLength,
longestSentence,
longestSentenceLength,
topWords
};
}
module.exports = { analyze };
+8
View File
@@ -28,6 +28,8 @@ function getPrintPreview() { if (!_PrintPreview) _PrintPreview = require('./prin
function getCreateWelcomeContent() { if (!_createWelcomeContent) _createWelcomeContent = require('./welcome').createWelcomeContent; return _createWelcomeContent; } function getCreateWelcomeContent() { if (!_createWelcomeContent) _createWelcomeContent = require('./welcome').createWelcomeContent; return _createWelcomeContent; }
let _ZenMode; let _ZenMode;
function getZenMode() { if (!_ZenMode) _ZenMode = require('./zen-mode').ZenMode; return _ZenMode; } function getZenMode() { if (!_ZenMode) _ZenMode = require('./zen-mode').ZenMode; return _ZenMode; }
let _showAnalyticsModal;
function getShowAnalyticsModal() { if (!_showAnalyticsModal) _showAnalyticsModal = require('./analytics/analytics-panel').showAnalyticsModal; return _showAnalyticsModal; }
// Configure marked with highlight extension // Configure marked with highlight extension
marked.use(markedHighlight({ marked.use(markedHighlight({
@@ -1663,6 +1665,7 @@ document.addEventListener('DOMContentLoaded', () => {
commandPalette.register('Insert Link', '', () => tabManager.wrapSelection('[', '](url)')); commandPalette.register('Insert Link', '', () => tabManager.wrapSelection('[', '](url)'));
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection('![', '](image.jpg)')); commandPalette.register('Insert Image', '', () => tabManager.wrapSelection('![', '](image.jpg)'));
commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle()); commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle());
commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager));
// Keyboard shortcuts // Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
@@ -1676,6 +1679,11 @@ document.addEventListener('DOMContentLoaded', () => {
e.preventDefault(); e.preventDefault();
zenMode.toggle(); zenMode.toggle();
} }
// Ctrl+Shift+A — Writing Analytics
if (e.ctrlKey && e.shiftKey && e.key === 'A') {
e.preventDefault();
getShowAnalyticsModal()(tabManager);
}
// Escape — Exit Zen Mode // Escape — Exit Zen Mode
if (e.key === 'Escape' && zenMode.active) { if (e.key === 'Escape' && zenMode.active) {
zenMode.deactivate(); zenMode.deactivate();
+26
View File
@@ -2346,3 +2346,29 @@ body[class*="dark"] .breadcrumb-bar {
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
} }
/* Writing Analytics Modal */
.analytics-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; z-index:10000; animation:analyticsFadeIn 0.2s ease; }
.analytics-modal { background:var(--bg-primary,#fff); border-radius:12px; width:520px; max-height:80vh; overflow-y:auto; box-shadow:0 20px 60px rgba(0,0,0,0.3); animation:analyticsSlideUp 0.3s ease; }
.analytics-header { display:flex; justify-content:space-between; align-items:center; padding:20px 24px; border-bottom:1px solid var(--border-color,#e5e7eb); }
.analytics-header h2 { margin:0; font-size:18px; font-weight:600; color:var(--text-primary); }
.analytics-close { background:none; border:none; font-size:24px; cursor:pointer; color:var(--text-muted); padding:4px 8px; border-radius:4px; }
.analytics-close:hover { background:var(--bg-tertiary); color:var(--text-primary); }
.analytics-body { padding:16px 24px 24px; }
.analytics-section { margin-bottom:20px; }
.analytics-section h3 { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-muted,#9ca3af); margin:0 0 10px; }
.analytics-row { display:flex; justify-content:space-between; align-items:baseline; padding:6px 0; font-size:14px; }
.analytics-label { color:var(--text-secondary,#6b7280); }
.analytics-value { font-weight:500; color:var(--text-primary); }
.analytics-value small { color:var(--text-muted); font-weight:400; margin-left:6px; }
.readability-meter { height:4px; background:var(--bg-tertiary,#f3f4f6); border-radius:2px; margin-top:8px; overflow:hidden; }
.readability-fill { height:100%; background:linear-gradient(90deg,#ef4444,#f59e0b,#10b981); border-radius:2px; transition:width 0.5s ease; }
.word-cloud { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
.word-tag { background:var(--bg-tertiary,#f3f4f6); padding:4px 10px; border-radius:12px; font-size:13px; color:var(--text-secondary); }
.word-tag small { opacity:0.5; font-size:10px; margin-left:2px; }
.analytics-longest { flex-direction:column; gap:4px; }
.analytics-sentence-preview { font-style:italic; font-size:13px; color:var(--text-secondary); font-weight:400; }
@keyframes analyticsFadeIn { from{opacity:0} to{opacity:1} }
@keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} }
body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); }
body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }