From 2389d4f2972a7a1be3e13ec378e58befdc998ffe Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Thu, 11 Jun 2026 20:44:16 +0530 Subject: [PATCH] feat: add writing analytics dashboard and daily word goal tracking --- scripts/verify-open-export.mjs | 148 ++++++++++ src/main/menu/items.js | 9 +- src/preload.js | 1 + src/renderer/components/modals/ModalLayer.tsx | 3 + .../modals/WritingAnalyticsDialog.tsx | 276 ++++++++++++++++++ src/renderer/components/sidebar/Sidebar.tsx | 135 +++++---- src/renderer/components/sidebar/Snippets.tsx | 241 +++++++++++++++ src/renderer/components/sidebar/Templates.tsx | 60 ++++ .../lib/commands/register-menu-commands.ts | 34 +++ src/renderer/lib/ipc.ts | 5 + src/renderer/lib/validators.ts | 1 + src/renderer/lib/writing-analytics.ts | 143 +++++++++ src/renderer/stores/app-store.ts | 3 +- src/renderer/stores/editor-store.ts | 10 + src/renderer/stores/file-store.ts | 54 +++- src/renderer/test/setup.ts | 7 +- .../modals/WritingAnalyticsDialog.test.tsx | 73 +++++ tests/component/sidebar/Sidebar.test.tsx | 8 +- tests/unit/stores/editor-store.test.ts | 11 + 19 files changed, 1161 insertions(+), 61 deletions(-) create mode 100644 scripts/verify-open-export.mjs create mode 100644 src/renderer/components/modals/WritingAnalyticsDialog.tsx create mode 100644 src/renderer/components/sidebar/Snippets.tsx create mode 100644 src/renderer/components/sidebar/Templates.tsx create mode 100644 src/renderer/lib/writing-analytics.ts create mode 100644 tests/component/modals/WritingAnalyticsDialog.test.tsx diff --git a/scripts/verify-open-export.mjs b/scripts/verify-open-export.mjs new file mode 100644 index 0000000..ff04ecf --- /dev/null +++ b/scripts/verify-open-export.mjs @@ -0,0 +1,148 @@ +import { _electron as electron } from 'playwright-core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const APP_DIR = '/home/amith/apps/markdown-converter'; +const electronBin = path.join(APP_DIR, 'node_modules/electron/dist/electron'); + +const testMd = '/tmp/verify-test.md'; +const outputDocx = '/tmp/verify-output.docx'; +const outputHtml = '/tmp/verify-output.html'; + +// Cleanup previous outputs +if (fs.existsSync(testMd)) fs.unlinkSync(testMd); +if (fs.existsSync(outputDocx)) fs.unlinkSync(outputDocx); +if (fs.existsSync(outputHtml)) fs.unlinkSync(outputHtml); + +// Write test Markdown file +fs.writeFileSync( + testMd, + '# Test Document\n\nThis is a verification test for opening and exporting.\n\n- Point A\n- Point B\n' +); + +console.log('Launching Electron...'); +const app = await electron.launch({ + executablePath: electronBin, + args: [ + '--no-sandbox', + '--disable-gpu', + '--disable-software-rasterizer', + '--disable-dev-shm-usage', + '.', + ], + env: { + ...process.env, + DISPLAY: ':0', + ELECTRON_DISABLE_SANDBOX: '1', + VITE_DEV_SERVER_URL: 'http://localhost:5173', + }, + cwd: APP_DIR, +}); + +app.process().stdout.on('data', (data) => console.log('[MAIN-OUT]', data.toString().trim())); +app.process().stderr.on('data', (data) => console.log('[MAIN-ERR]', data.toString().trim())); + +const win = await app.firstWindow(); +await win.waitForLoadState('domcontentloaded'); +await win.waitForSelector('.cm-editor, [role="toolbar"]', { timeout: 10000 }); +console.log('App loaded.'); + +// Dismiss welcome wizard if present +const wizardCount = await win.locator('[data-testid="first-run-wizard"]').count(); +if (wizardCount > 0) { + console.log('Dismissing first run wizard...'); + await win.click('[data-testid="first-run-wizard"] >> text=Skip'); + await new Promise((r) => setTimeout(r, 300)); +} + +// Stub dialog.showSaveDialogSync in main process to automatically return output paths +await app.evaluate(({ dialog }, { outputDocx, outputHtml }) => { + dialog.showSaveDialogSync = (window, options) => { + const filters = options?.filters || []; + if (filters.some(f => f.extensions.includes('docx'))) { + return outputDocx; + } + return outputHtml; + }; +}, { outputDocx, outputHtml }); + +// Simulate opening the test Md file by sending IPC from main +console.log('Opening test markdown file...'); +const fileContent = fs.readFileSync(testMd, 'utf-8'); +await app.evaluate(({ BrowserWindow }, { filePath, content }) => { + const wins = BrowserWindow.getAllWindows(); + const main = wins.find((w) => !w.isDestroyed()); + if (!main) throw new Error('No main window'); + main.webContents.send('file-opened', { path: filePath, content }); +}, { filePath: testMd, content: fileContent }); + +// Wait for editor to display the content +await win.waitForFunction( + (content) => { + const editor = document.querySelector('.cm-content'); + return editor && editor.textContent.includes('Test Document'); + }, + fileContent, + { timeout: 5000 } +); +console.log('File successfully opened in editor.'); + +// Let's wait a moment for currentFile synchronization to trigger in the main process +await new Promise((r) => setTimeout(r, 500)); + + +// Trigger DOCX Export (calls performExportWithOptions under the hood) +console.log('Exporting to DOCX...'); +await win.evaluate(() => { + window.electronAPI.export.withOptions('docx', {}); +}); + +// Wait for file to be written to disk +let docxExported = false; +for (let i = 0; i < 20; i++) { + if (fs.existsSync(outputDocx) && fs.statSync(outputDocx).size > 0) { + docxExported = true; + break; + } + await new Promise((r) => setTimeout(r, 250)); +} + +if (docxExported) { + console.log('✅ DOCX exported successfully.'); +} else { + console.error('❌ DOCX export failed (file not created or empty).'); +} + +// Wait 2.5 seconds to bypass the conversion rate limiter (2000ms debounce) +console.log('Waiting for rate limiter...'); +await new Promise((r) => setTimeout(r, 2500)); + +// Trigger HTML Export +console.log('Exporting to HTML...'); +await win.evaluate(() => { + window.electronAPI.export.withOptions('html', {}); +}); + +let htmlExported = false; +for (let i = 0; i < 20; i++) { + if (fs.existsSync(outputHtml) && fs.statSync(outputHtml).size > 0) { + htmlExported = true; + break; + } + await new Promise((r) => setTimeout(r, 250)); +} + +if (htmlExported) { + console.log('✅ HTML exported successfully.'); +} else { + console.error('❌ HTML export failed (file not created or empty).'); +} + +await app.close(); +console.log('Verification completed.'); + +if (docxExported && htmlExported) { + process.exit(0); +} else { + process.exit(1); +} diff --git a/src/main/menu/items.js b/src/main/menu/items.js index 49d2cc5..d61eb55 100644 --- a/src/main/menu/items.js +++ b/src/main/menu/items.js @@ -221,13 +221,20 @@ function viewItems(mainWindow) { accelerator: 'CmdOrCtrl+Shift+V', click: () => mainWindow.webContents.send('toggle-preview') }, + { + label: 'Writing Analytics', + accelerator: 'CmdOrCtrl+Shift+A', + click: () => mainWindow.webContents.send('show-analytics-dialog') + }, // NOTE: Command Palette removed — handled by useCommandStore { type: 'separator' }, { label: 'Sidebar', submenu: [ { label: 'Files', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer') }, - { label: 'Outline', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') }, + { label: 'Outline', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'outline') }, + { label: 'Snippets', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets') }, + { label: 'Templates', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates') }, { label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') } ] }, diff --git a/src/preload.js b/src/preload.js index 5da0805..1a12b3c 100644 --- a/src/preload.js +++ b/src/preload.js @@ -246,6 +246,7 @@ const ALLOWED_RECEIVE_CHANNELS = [ // Updater 'updater:status', + 'show-analytics-dialog', // File dialog / directory listing 'list-directory', diff --git a/src/renderer/components/modals/ModalLayer.tsx b/src/renderer/components/modals/ModalLayer.tsx index afc032e..515739d 100644 --- a/src/renderer/components/modals/ModalLayer.tsx +++ b/src/renderer/components/modals/ModalLayer.tsx @@ -12,6 +12,7 @@ import { SettingsSheet } from './SettingsSheet'; import { TableGeneratorDialog } from './TableGeneratorDialog'; import { WelcomeDialog } from './WelcomeDialog'; import { WordExportDialog } from './WordExportDialog'; +import { WritingAnalyticsDialog } from './WritingAnalyticsDialog'; export function ModalLayer() { const modal = useAppStore((s) => s.modal); @@ -44,5 +45,7 @@ export function ModalLayer() { return ; case 'crashReports': return ; + case 'writing-analytics': + return ; } } \ No newline at end of file diff --git a/src/renderer/components/modals/WritingAnalyticsDialog.tsx b/src/renderer/components/modals/WritingAnalyticsDialog.tsx new file mode 100644 index 0000000..4f3bc3d --- /dev/null +++ b/src/renderer/components/modals/WritingAnalyticsDialog.tsx @@ -0,0 +1,276 @@ +import { useState, useEffect } from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useAppStore } from '@/stores/app-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useEditorStore } from '@/stores/editor-store'; +import { analyzeText } from '@/lib/writing-analytics'; +import { BookOpen, Clock, FileText, BarChart2, Target, Award, Sparkles, Mic } from 'lucide-react'; + +export function WritingAnalyticsDialog() { + const closeModal = useAppStore((s) => s.closeModal); + const isOpen = useAppStore((s) => s.modal.kind) === 'writing-analytics'; + const { dailyGoal, setSetting } = useSettingsStore(); + const activeId = useEditorStore((s) => s.activeId); + const buffer = activeId ? useEditorStore((s) => s.buffers.get(activeId)) : null; + const content = buffer?.content || ''; + + const metrics = analyzeText(content); + const percentage = dailyGoal > 0 ? Math.round((metrics.wordCount / dailyGoal) * 100) : 0; + const isGoalReached = metrics.wordCount >= dailyGoal && dailyGoal > 0; + + // Track celebration state to trigger an animation on goal completion + const [celebrated, setCelebrated] = useState(false); + + useEffect(() => { + if (isGoalReached && !celebrated) { + setCelebrated(true); + } else if (!isGoalReached && celebrated) { + setCelebrated(false); + } + }, [isGoalReached, celebrated]); + + // Find max count for word cloud scaling + const maxWordCount = metrics.topWords.length > 0 ? Math.max(...metrics.topWords.map((w) => w.count)) : 1; + + // Helper to determine color coding for Flesch Readability Ease + const getReadabilityColor = (score: number) => { + if (score >= 70) return 'text-[#1a7a56]'; // Easy / Very Easy + if (score >= 50) return 'text-amber-500'; // Standard + return 'text-rose-500'; // Difficult / Very Difficult + }; + + return ( + !o && closeModal()}> + + +
+ + Writing Analytics +
+ + Real-time readability metrics, vocabulary analysis, and progress towards your daily word goal. + +
+ +
+ {/* Left Column: Readability, Structure, Timing */} +
+ {/* Readability Card */} +
+
+ +

Readability

+
+
+
+

Flesch Reading Ease

+

+ {metrics.fleschEase} +

+

+ {metrics.readabilityLabel} +

+
+
+

Grade Level

+

+ {metrics.fleschGrade} +

+

US school grade

+
+
+
+ + {/* Structure Card */} +
+
+ +

Structure

+
+
+
+

Words

+

{metrics.wordCount}

+
+
+

Sentences

+

{metrics.sentenceCount}

+
+
+

Paragraphs

+

{metrics.paragraphCount}

+
+
+
+
+ Average Sentence Length: + {metrics.avgSentenceLength} words +
+ {metrics.longestSentence && ( +
+

+ Longest Sentence ({metrics.longestSentenceLength} words): +

+

+ "{metrics.longestSentence}" +

+
+ )} +
+
+ + {/* Timing Card */} +
+
+ +

Timing

+
+
+
+
+ +
+
+

Reading Time

+

{metrics.readingTime} min

+
+
+
+
+ +
+
+

Speaking Time

+

{metrics.speakingTime} min

+
+
+
+
+
+ + {/* Right Column: Word Goal, Vocabulary */} +
+ {/* Word Goal Card */} +
+
+
+ {isGoalReached ? ( + + ) : ( + + )} +

Daily Word Goal

+
+
+ Target: + { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val > 0) { + setSetting('dailyGoal', val); + } + }} + className="h-7 w-20 text-xs px-2" + /> +
+
+ +
+
+ + {metrics.wordCount} / {dailyGoal} words + + + {percentage}% + +
+ +
+
+
+ + {isGoalReached && ( +
+ + Goal accomplished! Keep writing to set a new record. +
+ )} +
+
+ + {/* Vocabulary & Word Cloud Card */} +
+
+

Vocabulary & Top Words

+
+ Lexical Diversity: + {metrics.lexicalDiversity}% +
+
+ +
+ Unique words: {metrics.uniqueWordCount} /{' '} + {metrics.wordCount} ({metrics.wordCount > 0 ? Math.round((metrics.uniqueWordCount / metrics.wordCount) * 100) : 0}%) +
+ +
+

Word Frequency Cloud

+ {metrics.topWords.length === 0 ? ( +
+ Add more words to see vocabulary statistics +
+ ) : ( +
+ {metrics.topWords.map(({ word, count }) => { + // Normalize count relative to maximum frequency for font scaling + const scale = maxWordCount > 0 ? count / maxWordCount : 0.5; + const size = Math.round((0.8 + scale * 0.8) * 10) / 10; // 0.8rem to 1.6rem + const opacity = Math.round((0.5 + scale * 0.5) * 10) / 10; // 0.5 to 1.0 + + return ( + + {word} + + ({count}) + + + ); + })} +
+ )} +
+
+
+
+ +
+ +
+ +
+ ); +} diff --git a/src/renderer/components/sidebar/Sidebar.tsx b/src/renderer/components/sidebar/Sidebar.tsx index d29d248..d328eb9 100644 --- a/src/renderer/components/sidebar/Sidebar.tsx +++ b/src/renderer/components/sidebar/Sidebar.tsx @@ -5,6 +5,8 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { useFileStore } from '@/stores/file-store'; import { FileTree } from './FileTree'; import { Outline } from './Outline'; +import { Snippets } from './Snippets'; +import { Templates } from './Templates'; import { GitStatusPanel } from './GitStatusPanel'; export function Sidebar() { @@ -16,20 +18,20 @@ export function Sidebar() { } return ( -
- - - - - - + +
+ + + + + {!tree ? (
No folder opened @@ -40,55 +42,84 @@ export function Sidebar() { ) : ( )} - - - + + - - - - - - + + + + + - - - + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - + + +
{/* Hidden bridge: when `view.sidebarPanel` is dispatched, scroll the matching section into view. The hidden elements expose a hook for Playwright tests and the menu handler. */} -
+
); } diff --git a/src/renderer/components/sidebar/Snippets.tsx b/src/renderer/components/sidebar/Snippets.tsx new file mode 100644 index 0000000..79902b6 --- /dev/null +++ b/src/renderer/components/sidebar/Snippets.tsx @@ -0,0 +1,241 @@ +import { useState, useEffect } from 'react'; +import { Plus, Trash2, Play, Search, FileText } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from 'sonner'; +import { insertSnippet } from '@/lib/editor-commands'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; + +interface Snippet { + id: string; + name: string; + language: string; + code: string; +} + +export function Snippets() { + const [snippets, setSnippets] = useState([]); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(false); + + // Form states + const [name, setName] = useState(''); + const [language, setLanguage] = useState('text'); + const [code, setCode] = useState(''); + const [open, setOpen] = useState(false); + + const load = async () => { + setLoading(true); + try { + const list = await window.electronAPI.invoke('get-snippets'); + setSnippets(list || []); + } catch (err: any) { + console.error(err); + toast.error('Failed to load snippets'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(); + }, []); + + const handleAdd = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !code.trim()) { + toast.error('Name and Code are required'); + return; + } + const snippet: Snippet = { + id: Date.now().toString(), + name: name.trim(), + language: language.trim() || 'text', + code, + }; + try { + await window.electronAPI.invoke('save-snippet', snippet); + toast.success('Snippet added successfully'); + setOpen(false); + setName(''); + setLanguage('text'); + setCode(''); + load(); + } catch (err: any) { + console.error(err); + toast.error('Failed to save snippet'); + } + }; + + const handleDelete = async (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + try { + await window.electronAPI.invoke('delete-snippet', id); + toast.success('Snippet deleted'); + load(); + } catch (err: any) { + console.error(err); + toast.error('Failed to delete snippet'); + } + }; + + const handleInsert = (text: string) => { + const success = insertSnippet(text); + if (success) { + toast.success('Snippet inserted'); + } else { + toast.error('No active document open'); + } + }; + + const filtered = snippets.filter( + (s) => + s.name.toLowerCase().includes(search.toLowerCase()) || + (s.language || '').toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-7 h-8 text-xs bg-background/50 border-border/60 focus-visible:ring-brand/50" + /> +
+ + + + + +
+ + Add Snippet + +
+
+ + setName(e.target.value)} + placeholder="e.g. Code Block" + required + /> +
+
+ + setLanguage(e.target.value)} + placeholder="e.g. javascript, markdown" + /> +
+
+ +