From 72a7a854d090e92739f4ed639aba189e284a913d Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Mon, 6 Apr 2026 11:42:22 +0530 Subject: [PATCH] feat(analytics): add writing analytics with readability scores and vocabulary analysis Amit Haridas --- src/analytics/analytics-panel.js | 112 +++++++++++++++++++++++++ src/analytics/writing-analytics.js | 127 +++++++++++++++++++++++++++++ src/renderer.js | 8 ++ src/styles-modern.css | 26 ++++++ 4 files changed, 273 insertions(+) create mode 100644 src/analytics/analytics-panel.js create mode 100644 src/analytics/writing-analytics.js diff --git a/src/analytics/analytics-panel.js b/src/analytics/analytics-panel.js new file mode 100644 index 0000000..180a923 --- /dev/null +++ b/src/analytics/analytics-panel.js @@ -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 = ` +
+
+

Writing Analytics

+ +
+
+
+

Readability

+
+ Flesch Reading Ease + ${metrics.fleschEase}${metrics.readabilityLabel} +
+
+ Grade Level + ${metrics.fleschGrade} +
+
+
+
+
+ +
+

Timing

+
+ Reading Time + ~${metrics.readingTime} min +
+
+ Speaking Time + ~${metrics.speakingTime} min +
+
+ +
+

Structure

+
+ Sentences + ${metrics.sentenceCount} • Paragraphs: ${metrics.paragraphCount} +
+
+ Avg Sentence + ${metrics.avgSentenceLength} words +
+ ${metrics.longestSentenceLength > 0 ? ` +
+ Longest (${metrics.longestSentenceLength} words) + ${escapeHtml(metrics.longestSentence)} +
` : ''} +
+ +
+

Vocabulary

+
+ Unique + ${metrics.uniqueWordCount} / ${metrics.wordCount}${metrics.lexicalDiversity}% +
+ ${metrics.topWords.length > 0 ? ` +
+ ${metrics.topWords.map(w => { + const scale = 13 + Math.round((w.count / maxCount) * 3); + return `${escapeHtml(w.word)}${w.count}`; + }).join('')} +
` : ''} +
+
+
+ `; + + 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 }; diff --git a/src/analytics/writing-analytics.js b/src/analytics/writing-analytics.js new file mode 100644 index 0000000..8b6ca08 --- /dev/null +++ b/src/analytics/writing-analytics.js @@ -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 }; diff --git a/src/renderer.js b/src/renderer.js index dd6cd9a..cc59c2b 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -28,6 +28,8 @@ function getPrintPreview() { if (!_PrintPreview) _PrintPreview = require('./prin function getCreateWelcomeContent() { if (!_createWelcomeContent) _createWelcomeContent = require('./welcome').createWelcomeContent; return _createWelcomeContent; } let _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 marked.use(markedHighlight({ @@ -1663,6 +1665,7 @@ document.addEventListener('DOMContentLoaded', () => { commandPalette.register('Insert Link', '', () => tabManager.wrapSelection('[', '](url)')); commandPalette.register('Insert Image', '', () => tabManager.wrapSelection('![', '](image.jpg)')); commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle()); + commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager)); // Keyboard shortcuts document.addEventListener('keydown', (e) => { @@ -1676,6 +1679,11 @@ document.addEventListener('DOMContentLoaded', () => { e.preventDefault(); zenMode.toggle(); } + // Ctrl+Shift+A — Writing Analytics + if (e.ctrlKey && e.shiftKey && e.key === 'A') { + e.preventDefault(); + getShowAnalyticsModal()(tabManager); + } // Escape — Exit Zen Mode if (e.key === 'Escape' && zenMode.active) { zenMode.deactivate(); diff --git a/src/styles-modern.css b/src/styles-modern.css index e4eebb2..54727e3 100644 --- a/src/styles-modern.css +++ b/src/styles-modern.css @@ -2346,3 +2346,29 @@ body[class*="dark"] .breadcrumb-bar { font-size: 14px; 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); }