Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: grid CF rendering, form analytics, footnotes, suggestions, PPTX notes (0.41.0)

Sheets: render data bars and icon sets visually in grid cells. Data bars
show proportional fill overlays with bidirectional support. Icon sets
display emoji indicators (traffic lights, arrows, stars) by percentile.

Forms: response analytics module with per-question summaries, numeric
stats (min/max/mean/median), choice distributions, completion rate,
and responses-over-time timeline. Chart.js-compatible data output.

Docs: footnote footer section auto-renders below editor content with
numbered backrefs. Clicking a footnote number scrolls to the inline
marker. Updates on every editor transaction.

Docs: suggestions accept/reject panel. Scans for suggestion marks,
renders grouped list with accept/reject buttons per suggestion and
bulk accept-all/reject-all. Processes in reverse order for stability.

Slides: speaker notes now included in PPTX export. buildPptxNotesXml
generates valid notesSlide XML with proper paragraph splitting and
XML entity escaping.

Closes #626, #627, #628, #630, #631

+823 -4
+10
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.41.0] — 2026-04-14 11 + 12 + ### Added 13 + - Sheets: data bars and icon sets now render visually in grid cells (#626) 14 + - Forms: response analytics with distribution charts, numeric stats, completion rate, timeline (#627) 15 + - Docs: footnote footer section auto-renders below editor with numbered backrefs (#628) 16 + - Docs: suggestions accept/reject panel with individual and bulk operations (#631) 17 + - Slides: speaker notes now included in PPTX export via `buildPptxNotesXml` (#630) 18 + - CSS: footnote footer, suggestions panel, form analytics styles 19 + 10 20 ## [0.40.0] — 2026-04-14 11 21 12 22 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.40.0", 3 + "version": "0.41.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+51
src/css/app.css
··· 11828 11828 } 11829 11829 .batch-btn:hover { background: rgba(255,255,255,0.25); } 11830 11830 .batch-btn--danger:hover { background: rgba(220,50,50,0.6); } 11831 + 11832 + /* ── Footnote Footer ──────────────────────────────────────────── */ 11833 + 11834 + .footnote-footer { margin-top: 2rem; padding-top: 0; } 11835 + .footnote-divider { border: none; border-top: 1px solid var(--color-border); margin: 0 0 0.75rem 0; width: 40%; } 11836 + .footnote-list { margin: 0; padding-left: 1.5em; font-size: 0.82rem; color: var(--color-text-muted); line-height: 1.6; } 11837 + .footnote-entry { margin-bottom: 4px; } 11838 + .footnote-backref { color: var(--color-accent); text-decoration: none; font-weight: 600; cursor: pointer; } 11839 + .footnote-backref:hover { text-decoration: underline; } 11840 + .footnote-content { color: var(--color-text); } 11841 + .footnote-marker { cursor: help; color: var(--color-accent); font-weight: 600; } 11842 + .footnote-highlight { background: var(--color-teal-light); border-radius: 2px; transition: background 0.3s; } 11843 + 11844 + /* ── Suggestions Panel ────────────────────────────────────────── */ 11845 + 11846 + .suggestions-panel { padding: var(--space-sm); } 11847 + .suggestions-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-xs); } 11848 + .suggestions-count { font-size: 0.82rem; font-weight: 600; color: var(--color-text-muted); } 11849 + .suggestions-bulk { display: flex; gap: 4px; } 11850 + .suggestions-empty { font-size: 0.82rem; color: var(--color-text-muted); text-align: center; padding: var(--space-md) 0; } 11851 + .suggestions-list { display: flex; flex-direction: column; gap: 6px; } 11852 + 11853 + .suggestion-item { 11854 + padding: 8px 10px; 11855 + border-radius: var(--radius-sm, 4px); 11856 + border-left: 3px solid var(--color-border); 11857 + background: var(--color-surface); 11858 + } 11859 + .suggestion-item--insert { border-left-color: #2ecc71; } 11860 + .suggestion-item--delete { border-left-color: #e74c3c; } 11861 + .suggestion-meta { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 2px; } 11862 + .suggestion-type { font-weight: 600; } 11863 + .suggestion-item--insert .suggestion-type { color: #2ecc71; } 11864 + .suggestion-item--delete .suggestion-type { color: #e74c3c; } 11865 + .suggestion-preview { font-size: 0.82rem; margin-bottom: 4px; color: var(--color-text); } 11866 + .suggestion-actions { display: flex; gap: 4px; } 11867 + .suggestion-accept { background: #2ecc71; color: #fff; border: none; border-radius: 3px; padding: 2px 8px; font-size: 0.72rem; cursor: pointer; } 11868 + .suggestion-accept:hover { background: #27ae60; } 11869 + .suggestion-reject { background: #e74c3c; color: #fff; border: none; border-radius: 3px; padding: 2px 8px; font-size: 0.72rem; cursor: pointer; } 11870 + .suggestion-reject:hover { background: #c0392b; } 11871 + .suggestions-accept-all { background: #2ecc71; color: #fff; border: none; border-radius: 3px; padding: 3px 8px; font-size: 0.72rem; cursor: pointer; } 11872 + .suggestions-reject-all { background: #e74c3c; color: #fff; border: none; border-radius: 3px; padding: 3px 8px; font-size: 0.72rem; cursor: pointer; } 11873 + 11874 + /* ── Form Response Analytics ──────────────────────────────────── */ 11875 + 11876 + .analytics-summary { display: flex; gap: var(--space-md); flex-wrap: wrap; padding: var(--space-sm) 0; } 11877 + .analytics-stat { text-align: center; padding: var(--space-sm) var(--space-md); background: var(--color-surface); border-radius: var(--radius-sm, 4px); } 11878 + .analytics-stat-value { font-size: 1.5rem; font-weight: 700; color: var(--color-text); display: block; } 11879 + .analytics-stat-label { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; } 11880 + .analytics-chart { margin: var(--space-sm) 0; padding: var(--space-sm); background: var(--color-surface); border-radius: var(--radius-sm, 4px); } 11881 + .analytics-chart h4 { margin: 0 0 var(--space-xs); font-size: 0.85rem; }
+91
src/docs/footnote-footer.ts
··· 1 + /** 2 + * Footnote Footer — renders a footnote section at the bottom of the editor. 3 + * 4 + * Watches for footnote nodes in the document and maintains an auto-numbered 5 + * footer section below the editor content. Clicking a footnote number 6 + * scrolls to the corresponding marker in the text. 7 + */ 8 + 9 + import type { Editor } from '@tiptap/core'; 10 + import { getAllFootnotes } from './extensions/footnote.js'; 11 + 12 + /** 13 + * Mount a footnote footer section below the editor. 14 + * Returns a refresh function and a destroy function. 15 + */ 16 + export function mountFootnoteFooter( 17 + editor: Editor, 18 + container: HTMLElement, 19 + ): { refresh: () => void; destroy: () => void } { 20 + let footerEl: HTMLElement | null = null; 21 + 22 + function refresh() { 23 + const footnotes = getAllFootnotes(editor); 24 + 25 + if (footnotes.length === 0) { 26 + if (footerEl) { 27 + footerEl.remove(); 28 + footerEl = null; 29 + } 30 + return; 31 + } 32 + 33 + if (!footerEl) { 34 + footerEl = document.createElement('div'); 35 + footerEl.className = 'footnote-footer'; 36 + container.appendChild(footerEl); 37 + } 38 + 39 + let html = '<hr class="footnote-divider">'; 40 + html += '<ol class="footnote-list">'; 41 + for (const fn of footnotes) { 42 + html += `<li class="footnote-entry" data-footnote-id="${escapeAttr(fn.id)}">`; 43 + html += `<a class="footnote-backref" href="#" data-footnote-ref="${escapeAttr(fn.id)}" title="Jump to footnote">${fn.number}</a>. `; 44 + html += `<span class="footnote-content">${escapeHtml(fn.content)}</span>`; 45 + html += '</li>'; 46 + } 47 + html += '</ol>'; 48 + footerEl.innerHTML = html; 49 + 50 + // Wire backref click handlers 51 + footerEl.querySelectorAll('.footnote-backref').forEach(el => { 52 + el.addEventListener('click', (e) => { 53 + e.preventDefault(); 54 + const fnId = (el as HTMLElement).dataset.footnoteRef; 55 + if (!fnId) return; 56 + const marker = container.querySelector(`sup[data-footnote-id="${fnId}"]`); 57 + if (marker) { 58 + marker.scrollIntoView({ behavior: 'smooth', block: 'center' }); 59 + // Brief highlight 60 + marker.classList.add('footnote-highlight'); 61 + setTimeout(() => marker.classList.remove('footnote-highlight'), 1500); 62 + } 63 + }); 64 + }); 65 + } 66 + 67 + // Refresh on every transaction 68 + const handler = () => refresh(); 69 + editor.on('transaction', handler); 70 + 71 + // Initial render 72 + refresh(); 73 + 74 + function destroy() { 75 + editor.off('transaction', handler); 76 + if (footerEl) { 77 + footerEl.remove(); 78 + footerEl = null; 79 + } 80 + } 81 + 82 + return { refresh, destroy }; 83 + } 84 + 85 + function escapeHtml(s: string): string { 86 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 87 + } 88 + 89 + function escapeAttr(s: string): string { 90 + return s.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/&/g, '&amp;'); 91 + }
+213
src/docs/suggestions-panel.ts
··· 1 + /** 2 + * Suggestions Panel — accept/reject tracked changes in the document. 3 + * 4 + * Scans the TipTap document for suggestion marks (insert/delete), 5 + * groups them by suggestion ID, and renders a panel with accept/reject 6 + * buttons. Accepting an insert removes the mark; accepting a delete 7 + * removes the text. Rejecting does the opposite. 8 + */ 9 + 10 + import type { Editor } from '@tiptap/core'; 11 + 12 + export interface Suggestion { 13 + id: string; 14 + type: 'insert' | 'delete'; 15 + author: string; 16 + timestamp: string; 17 + text: string; 18 + from: number; 19 + to: number; 20 + } 21 + 22 + /** 23 + * Extract all suggestions from the editor document. 24 + */ 25 + export function extractSuggestions(editor: Editor): Suggestion[] { 26 + const suggestions: Suggestion[] = []; 27 + const seen = new Set<string>(); 28 + 29 + editor.state.doc.descendants((node, pos) => { 30 + for (const mark of node.marks) { 31 + if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { 32 + const id = mark.attrs.suggestionId; 33 + if (!id || seen.has(id)) continue; 34 + seen.add(id); 35 + suggestions.push({ 36 + id, 37 + type: mark.type.name === 'suggestionInsert' ? 'insert' : 'delete', 38 + author: mark.attrs.author || 'Unknown', 39 + timestamp: mark.attrs.timestamp || '', 40 + text: node.textContent, 41 + from: pos, 42 + to: pos + node.nodeSize, 43 + }); 44 + } 45 + } 46 + return true; 47 + }); 48 + 49 + return suggestions; 50 + } 51 + 52 + /** 53 + * Accept a suggestion. 54 + * - Insert: remove the mark (keep the text) 55 + * - Delete: remove the text 56 + */ 57 + export function acceptSuggestion(editor: Editor, suggestion: Suggestion): void { 58 + if (suggestion.type === 'insert') { 59 + // Keep text, remove the mark 60 + editor.chain() 61 + .setTextSelection({ from: suggestion.from, to: suggestion.to }) 62 + .unsetMark('suggestionInsert') 63 + .run(); 64 + } else { 65 + // Delete: remove the marked text 66 + editor.chain() 67 + .deleteRange({ from: suggestion.from, to: suggestion.to }) 68 + .run(); 69 + } 70 + } 71 + 72 + /** 73 + * Reject a suggestion. 74 + * - Insert: remove the text 75 + * - Delete: remove the mark (keep the text) 76 + */ 77 + export function rejectSuggestion(editor: Editor, suggestion: Suggestion): void { 78 + if (suggestion.type === 'insert') { 79 + // Remove the suggested insertion 80 + editor.chain() 81 + .deleteRange({ from: suggestion.from, to: suggestion.to }) 82 + .run(); 83 + } else { 84 + // Keep text, remove the strikethrough mark 85 + editor.chain() 86 + .setTextSelection({ from: suggestion.from, to: suggestion.to }) 87 + .unsetMark('suggestionDelete') 88 + .run(); 89 + } 90 + } 91 + 92 + /** 93 + * Accept all suggestions at once. 94 + */ 95 + export function acceptAllSuggestions(editor: Editor): void { 96 + // Process in reverse order to maintain position stability 97 + const suggestions = extractSuggestions(editor).reverse(); 98 + for (const s of suggestions) { 99 + acceptSuggestion(editor, s); 100 + } 101 + } 102 + 103 + /** 104 + * Reject all suggestions at once. 105 + */ 106 + export function rejectAllSuggestions(editor: Editor): void { 107 + const suggestions = extractSuggestions(editor).reverse(); 108 + for (const s of suggestions) { 109 + rejectSuggestion(editor, s); 110 + } 111 + } 112 + 113 + /** 114 + * Mount the suggestions panel into a container. 115 + */ 116 + export function mountSuggestionsPanel( 117 + editor: Editor, 118 + container: HTMLElement, 119 + ): { refresh: () => void; destroy: () => void } { 120 + let panelEl: HTMLElement | null = null; 121 + 122 + function refresh() { 123 + const suggestions = extractSuggestions(editor); 124 + 125 + if (suggestions.length === 0) { 126 + if (panelEl) { 127 + panelEl.innerHTML = '<p class="suggestions-empty">No pending suggestions</p>'; 128 + } 129 + return; 130 + } 131 + 132 + if (!panelEl) { 133 + panelEl = document.createElement('div'); 134 + panelEl.className = 'suggestions-panel'; 135 + container.appendChild(panelEl); 136 + } 137 + 138 + let html = '<div class="suggestions-header">'; 139 + html += `<span class="suggestions-count">${suggestions.length} suggestion${suggestions.length !== 1 ? 's' : ''}</span>`; 140 + html += '<div class="suggestions-bulk">'; 141 + html += '<button class="btn-sm suggestions-accept-all" id="btn-accept-all">Accept All</button>'; 142 + html += '<button class="btn-sm suggestions-reject-all" id="btn-reject-all">Reject All</button>'; 143 + html += '</div></div>'; 144 + 145 + html += '<div class="suggestions-list">'; 146 + for (const s of suggestions) { 147 + const timeStr = s.timestamp ? formatRelativeTime(s.timestamp) : ''; 148 + const typeLabel = s.type === 'insert' ? 'Added' : 'Deleted'; 149 + const typeClass = s.type === 'insert' ? 'suggestion-item--insert' : 'suggestion-item--delete'; 150 + const preview = s.text.length > 50 ? s.text.slice(0, 50) + '…' : s.text; 151 + 152 + html += `<div class="suggestion-item ${typeClass}" data-suggestion-id="${escapeAttr(s.id)}">`; 153 + html += `<div class="suggestion-meta"><span class="suggestion-type">${typeLabel}</span> by <strong>${escapeHtml(s.author)}</strong>${timeStr ? ' · ' + timeStr : ''}</div>`; 154 + html += `<div class="suggestion-preview">${escapeHtml(preview)}</div>`; 155 + html += '<div class="suggestion-actions">'; 156 + html += `<button class="btn-sm suggestion-accept" data-action="accept" data-sid="${escapeAttr(s.id)}">Accept</button>`; 157 + html += `<button class="btn-sm suggestion-reject" data-action="reject" data-sid="${escapeAttr(s.id)}">Reject</button>`; 158 + html += '</div></div>'; 159 + } 160 + html += '</div>'; 161 + 162 + panelEl.innerHTML = html; 163 + 164 + // Wire handlers 165 + panelEl.querySelector('#btn-accept-all')?.addEventListener('click', () => { 166 + acceptAllSuggestions(editor); 167 + refresh(); 168 + }); 169 + panelEl.querySelector('#btn-reject-all')?.addEventListener('click', () => { 170 + rejectAllSuggestions(editor); 171 + refresh(); 172 + }); 173 + panelEl.querySelectorAll('[data-action]').forEach(btn => { 174 + btn.addEventListener('click', () => { 175 + const sid = (btn as HTMLElement).dataset.sid; 176 + const action = (btn as HTMLElement).dataset.action; 177 + const s = suggestions.find(s => s.id === sid); 178 + if (!s) return; 179 + if (action === 'accept') acceptSuggestion(editor, s); 180 + else rejectSuggestion(editor, s); 181 + refresh(); 182 + }); 183 + }); 184 + } 185 + 186 + const handler = () => refresh(); 187 + editor.on('transaction', handler); 188 + refresh(); 189 + 190 + return { 191 + refresh, 192 + destroy: () => { 193 + editor.off('transaction', handler); 194 + if (panelEl) { panelEl.remove(); panelEl = null; } 195 + }, 196 + }; 197 + } 198 + 199 + function formatRelativeTime(ts: string): string { 200 + const diff = Date.now() - new Date(ts).getTime(); 201 + if (diff < 60000) return 'just now'; 202 + if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; 203 + if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; 204 + return Math.floor(diff / 86400000) + 'd ago'; 205 + } 206 + 207 + function escapeHtml(s: string): string { 208 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 209 + } 210 + 211 + function escapeAttr(s: string): string { 212 + return s.replace(/"/g, '&quot;').replace(/&/g, '&amp;'); 213 + }
+189
src/forms/response-analytics.ts
··· 1 + /** 2 + * Form Response Analytics — summary statistics and chart data from responses. 3 + * 4 + * Pure logic module: computes aggregates, distributions, and chart configs 5 + * from form responses. Rendering handled by the UI layer. 6 + */ 7 + 8 + import type { FormSchema, Question } from './form-builder.js'; 9 + import type { FormResponse } from './responses.js'; 10 + 11 + export interface QuestionSummary { 12 + questionId: string; 13 + label: string; 14 + type: string; 15 + responseCount: number; 16 + /** For choice questions: label → count */ 17 + distribution: Map<string, number>; 18 + /** For numeric questions */ 19 + numericStats: NumericStats | null; 20 + /** For text questions: response lengths */ 21 + avgLength: number; 22 + } 23 + 24 + export interface NumericStats { 25 + min: number; 26 + max: number; 27 + mean: number; 28 + median: number; 29 + sum: number; 30 + count: number; 31 + } 32 + 33 + export interface AnalyticsSummary { 34 + totalResponses: number; 35 + questionSummaries: QuestionSummary[]; 36 + /** Responses over time (date string → count) */ 37 + responsesOverTime: Map<string, number>; 38 + /** Completion rate: responses with all required questions answered */ 39 + completionRate: number; 40 + } 41 + 42 + /** 43 + * Compute analytics summary from form schema and responses. 44 + */ 45 + export function computeAnalytics(form: FormSchema, responses: FormResponse[]): AnalyticsSummary { 46 + const totalResponses = responses.length; 47 + const questionSummaries: QuestionSummary[] = []; 48 + 49 + for (const question of form.questions) { 50 + const summary = computeQuestionSummary(question, responses); 51 + questionSummaries.push(summary); 52 + } 53 + 54 + const responsesOverTime = computeResponsesOverTime(responses); 55 + const completionRate = computeCompletionRate(form, responses); 56 + 57 + return { totalResponses, questionSummaries, responsesOverTime, completionRate }; 58 + } 59 + 60 + /** 61 + * Compute summary for a single question. 62 + */ 63 + export function computeQuestionSummary(question: Question, responses: FormResponse[]): QuestionSummary { 64 + const answers: string[] = []; 65 + for (const r of responses) { 66 + const val = r.answers.get(question.id); 67 + if (val !== undefined && val !== null && val !== '') { 68 + answers.push(String(val)); 69 + } 70 + } 71 + 72 + const distribution = new Map<string, number>(); 73 + let numericStats: NumericStats | null = null; 74 + let avgLength = 0; 75 + 76 + const isChoice = ['single_choice', 'multiple_choice', 'dropdown'].includes(question.type); 77 + const isNumeric = ['number', 'rating', 'scale'].includes(question.type); 78 + 79 + if (isChoice) { 80 + // Count option selections 81 + for (const a of answers) { 82 + // Multiple choice may have comma-separated values 83 + const values = question.type === 'multiple_choice' ? a.split(',').map(s => s.trim()) : [a]; 84 + for (const v of values) { 85 + if (v) distribution.set(v, (distribution.get(v) || 0) + 1); 86 + } 87 + } 88 + // Ensure all options appear even with 0 count 89 + if (question.options) { 90 + for (const opt of question.options) { 91 + if (!distribution.has(opt.label)) distribution.set(opt.label, 0); 92 + } 93 + } 94 + } else if (isNumeric) { 95 + const nums = answers.map(Number).filter(n => !isNaN(n)); 96 + if (nums.length > 0) { 97 + nums.sort((a, b) => a - b); 98 + const sum = nums.reduce((a, b) => a + b, 0); 99 + numericStats = { 100 + min: nums[0]!, 101 + max: nums[nums.length - 1]!, 102 + mean: sum / nums.length, 103 + median: nums.length % 2 === 0 104 + ? (nums[nums.length / 2 - 1]! + nums[nums.length / 2]!) / 2 105 + : nums[Math.floor(nums.length / 2)]!, 106 + sum, 107 + count: nums.length, 108 + }; 109 + } 110 + } else { 111 + // Text questions — average response length 112 + if (answers.length > 0) { 113 + avgLength = answers.reduce((s, a) => s + a.length, 0) / answers.length; 114 + } 115 + } 116 + 117 + return { 118 + questionId: question.id, 119 + label: question.label || 'Untitled', 120 + type: question.type, 121 + responseCount: answers.length, 122 + distribution, 123 + numericStats, 124 + avgLength, 125 + }; 126 + } 127 + 128 + /** 129 + * Group responses by date. 130 + */ 131 + export function computeResponsesOverTime(responses: FormResponse[]): Map<string, number> { 132 + const counts = new Map<string, number>(); 133 + for (const r of responses) { 134 + if (!r.submittedAt) continue; 135 + const date = new Date(r.submittedAt).toISOString().split('T')[0]!; 136 + counts.set(date, (counts.get(date) || 0) + 1); 137 + } 138 + return counts; 139 + } 140 + 141 + /** 142 + * Compute completion rate (percentage of responses that answered all required questions). 143 + */ 144 + export function computeCompletionRate(form: FormSchema, responses: FormResponse[]): number { 145 + if (responses.length === 0) return 0; 146 + 147 + const requiredIds = form.questions 148 + .filter(q => q.required) 149 + .map(q => q.id); 150 + 151 + if (requiredIds.length === 0) return 100; 152 + 153 + let complete = 0; 154 + for (const r of responses) { 155 + const allAnswered = requiredIds.every(id => { 156 + const val = r.answers.get(id); 157 + return val !== undefined && val !== null && val !== ''; 158 + }); 159 + if (allAnswered) complete++; 160 + } 161 + 162 + return Math.round((complete / responses.length) * 100); 163 + } 164 + 165 + /** 166 + * Generate Chart.js-compatible bar chart data from a distribution. 167 + */ 168 + export function distributionToChartData( 169 + distribution: Map<string, number>, 170 + ): { labels: string[]; data: number[] } { 171 + const entries = [...distribution.entries()].sort((a, b) => b[1] - a[1]); 172 + return { 173 + labels: entries.map(([label]) => label), 174 + data: entries.map(([, count]) => count), 175 + }; 176 + } 177 + 178 + /** 179 + * Generate Chart.js-compatible line chart data from responses over time. 180 + */ 181 + export function timelineToChartData( 182 + timeline: Map<string, number>, 183 + ): { labels: string[]; data: number[] } { 184 + const entries = [...timeline.entries()].sort((a, b) => a[0].localeCompare(b[0])); 185 + return { 186 + labels: entries.map(([date]) => date), 187 + data: entries.map(([, count]) => count), 188 + }; 189 + }
+63 -2
src/sheets/grid-rendering.ts
··· 5 5 */ 6 6 7 7 import { cellId, colToLetter } from './formulas.js'; 8 - import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js'; 8 + import { evaluateRules, buildCfStyle, computeColorScale, computeDataBars, computeIconSets } from './conditional-format.js'; 9 + import type { DataBarResult, IconSetResult } from './types.js'; 9 10 import { getErrorInfo } from './error-tooltips.js'; 10 11 import { renderInteractiveCell } from './rich-cells.js'; 11 12 import { getStripedRowClass } from './cell-styles.js'; ··· 171 172 } 172 173 } 173 174 colorScaleStyles = computeColorScale(allCellValues, colorScaleRule); 175 + } 176 + 177 + // Data bars 178 + const dataBarRule = cfRules.find(r => r.type === 'dataBar'); 179 + let dataBarStyles: Map<string, DataBarResult> | null = null; 180 + if (dataBarRule) { 181 + const allCellValues = new Map<string, unknown>(); 182 + for (let r = 1; r <= rowCount; r++) { 183 + for (let c = 1; c <= colCount; c++) { 184 + const id = cellId(c, r); 185 + const cd = getCellData(id); 186 + if (cd) allCellValues.set(id, computeDisplayValue(id, cd)); 187 + } 188 + } 189 + dataBarStyles = computeDataBars(allCellValues, dataBarRule); 190 + } 191 + 192 + // Icon sets 193 + const iconSetRule = cfRules.find(r => r.type === 'iconSet'); 194 + let iconSetStyles: Map<string, IconSetResult> | null = null; 195 + if (iconSetRule) { 196 + const allCellValues = new Map<string, unknown>(); 197 + for (let r = 1; r <= rowCount; r++) { 198 + for (let c = 1; c <= colCount; c++) { 199 + const id = cellId(c, r); 200 + const cd = getCellData(id); 201 + if (cd) allCellValues.set(id, computeDisplayValue(id, cd)); 202 + } 203 + } 204 + iconSetStyles = computeIconSets(allCellValues, iconSetRule); 174 205 } 175 206 176 207 const allRowsToRender: number[] = []; ··· 299 330 const richHtml = renderInteractiveCell(cellData?.s?.cellType, cellData?.v ?? displayValue); 300 331 const cellContent = richHtml ?? escapeHtml(displayValue); 301 332 302 - tbodyHtml += '<div class="cell-display' + wrapClass + errClass + '"' + errData + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + cellContent + '</div>'; 333 + // Data bar overlay 334 + const dbResult = dataBarStyles?.get(id); 335 + let dataBarHtml = ''; 336 + if (dbResult) { 337 + const negCls = dbResult.negative ? ' cf-data-bar--negative' : (dataBarStyles!.size > 0 && [...dataBarStyles!.values()].some(v => v.negative) ? ' cf-data-bar--positive' : ' cf-data-bar--all-positive'); 338 + dataBarHtml = '<div class="cf-data-bar' + negCls + '" style="width:' + dbResult.barWidthPct.toFixed(1) + '%;background:' + dbResult.barColor + '"></div>'; 339 + } 340 + 341 + // Icon set prefix 342 + const isResult = iconSetStyles?.get(id); 343 + let iconHtml = ''; 344 + if (isResult) { 345 + iconHtml = '<span class="cf-icon-set">' + getIconChar(isResult.iconSetName, isResult.iconIndex) + '</span>'; 346 + } 347 + 348 + tbodyHtml += '<div class="cell-display' + wrapClass + errClass + '"' + errData + ' style="position:relative;' + getCellStyle(cellData, cfStyleStr) + '">' + dataBarHtml + iconHtml + cellContent + '</div>'; 303 349 } 304 350 305 351 if (validation && validation.type === 'list') { ··· 356 402 drawSparkline(canvasEl, val); 357 403 }); 358 404 } 405 + 406 + // --- Icon set character lookup --- 407 + 408 + const ICON_SETS: Record<string, string[]> = { 409 + traffic3: ['\u{1F534}', '\u{1F7E1}', '\u{1F7E2}'], // red, yellow, green circles 410 + arrows3: ['\u2B07', '\u27A1', '\u2B06'], // down, right, up arrows 411 + stars3: ['\u2606', '\u2BEA', '\u2605'], // empty, half, full star 412 + arrows4: ['\u2B07', '\u2198', '\u2197', '\u2B06'], // down, down-right, up-right, up 413 + arrows5: ['\u2B07', '\u2198', '\u27A1', '\u2197', '\u2B06'], // down, down-right, right, up-right, up 414 + }; 415 + 416 + function getIconChar(setName: string, index: number): string { 417 + const set = ICON_SETS[setName] || ICON_SETS['traffic3']!; 418 + return set[Math.min(index, set.length - 1)] || ''; 419 + }
+30 -1
src/slides/pptx-export.ts
··· 24 24 25 25 export interface ExportConfig { 26 26 format: 'pdf' | 'pptx' | 'png'; 27 - /** Include speaker notes (PDF only) */ 27 + /** Include speaker notes in export */ 28 28 includeNotes: boolean; 29 29 /** Slides to export (null = all) */ 30 30 slideRange: [number, number] | null; ··· 240 240 { format: 'png', label: 'PNG Images' }, 241 241 ]; 242 242 } 243 + 244 + /** 245 + * Build PPTX notesSlide XML for a slide's speaker notes. 246 + * Returns empty string if notes are empty or includeNotes is false. 247 + */ 248 + export function buildPptxNotesXml(notes: string, includeNotes: boolean): string { 249 + if (!includeNotes || !notes || !notes.trim()) return ''; 250 + 251 + const escaped = notes 252 + .replace(/&/g, '&amp;') 253 + .replace(/</g, '&lt;') 254 + .replace(/>/g, '&gt;') 255 + .replace(/"/g, '&quot;'); 256 + 257 + const paragraphs = escaped.split('\n').map(line => 258 + `<a:p><a:r><a:rPr lang="en-US" dirty="0"/><a:t>${line}</a:t></a:r></a:p>` 259 + ).join(''); 260 + 261 + return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> 262 + <p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" 263 + xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" 264 + xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"> 265 + <p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr> 266 + <p:grpSpPr/> 267 + <p:sp><p:nvSpPr><p:cNvPr id="2" name="Notes Placeholder"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr> 268 + <p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/>${paragraphs}</p:txBody></p:sp> 269 + </p:spTree></p:cSld> 270 + </p:notes>`; 271 + }
+40
tests/pptx-notes-export.test.ts
··· 1 + /** 2 + * Tests for PPTX speaker notes export. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { buildPptxNotesXml } from '../src/slides/pptx-export.js'; 6 + 7 + describe('buildPptxNotesXml', () => { 8 + it('generates valid XML for notes', () => { 9 + const xml = buildPptxNotesXml('Hello world', true); 10 + expect(xml).toContain('<?xml version="1.0"'); 11 + expect(xml).toContain('<p:notes'); 12 + expect(xml).toContain('Hello world'); 13 + }); 14 + 15 + it('returns empty when includeNotes is false', () => { 16 + expect(buildPptxNotesXml('Hello', false)).toBe(''); 17 + }); 18 + 19 + it('returns empty when notes are empty', () => { 20 + expect(buildPptxNotesXml('', true)).toBe(''); 21 + expect(buildPptxNotesXml(' ', true)).toBe(''); 22 + }); 23 + 24 + it('escapes XML special characters', () => { 25 + const xml = buildPptxNotesXml('A < B & C > D "E"', true); 26 + expect(xml).toContain('A &lt; B &amp; C &gt; D &quot;E&quot;'); 27 + }); 28 + 29 + it('splits multi-line notes into separate paragraphs', () => { 30 + const xml = buildPptxNotesXml('Line 1\nLine 2\nLine 3', true); 31 + const pCount = (xml.match(/<a:p>/g) || []).length; 32 + expect(pCount).toBe(3); 33 + }); 34 + 35 + it('wraps content in Notes Placeholder', () => { 36 + const xml = buildPptxNotesXml('Test', true); 37 + expect(xml).toContain('Notes Placeholder'); 38 + expect(xml).toContain('type="body"'); 39 + }); 40 + });
+135
tests/response-analytics.test.ts
··· 1 + /** 2 + * Tests for form response analytics. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { 6 + computeQuestionSummary, 7 + computeResponsesOverTime, 8 + computeCompletionRate, 9 + computeAnalytics, 10 + distributionToChartData, 11 + timelineToChartData, 12 + } from '../src/forms/response-analytics.js'; 13 + 14 + function makeResponses(answers: Array<Record<string, unknown>>) { 15 + return answers.map((a, i) => ({ 16 + formId: 'f1', 17 + answers: new Map(Object.entries(a)), 18 + submittedAt: Date.now() - i * 86400000, 19 + })); 20 + } 21 + 22 + describe('computeQuestionSummary', () => { 23 + it('computes distribution for choice questions', () => { 24 + const question = { id: 'q1', type: 'single_choice', label: 'Color', description: '', required: false, options: [{ id: 'o1', label: 'Red' }, { id: 'o2', label: 'Blue' }, { id: 'o3', label: 'Green' }] }; 25 + const responses = makeResponses([{ q1: 'Red' }, { q1: 'Blue' }, { q1: 'Red' }]); 26 + const summary = computeQuestionSummary(question as any, responses); 27 + expect(summary.distribution.get('Red')).toBe(2); 28 + expect(summary.distribution.get('Blue')).toBe(1); 29 + expect(summary.distribution.get('Green')).toBe(0); 30 + expect(summary.responseCount).toBe(3); 31 + }); 32 + 33 + it('computes numeric stats for number questions', () => { 34 + const question = { id: 'q1', type: 'number', label: 'Age', description: '', required: false, options: [] }; 35 + const responses = makeResponses([{ q1: '25' }, { q1: '30' }, { q1: '35' }, { q1: '40' }]); 36 + const summary = computeQuestionSummary(question as any, responses); 37 + expect(summary.numericStats).not.toBeNull(); 38 + expect(summary.numericStats!.min).toBe(25); 39 + expect(summary.numericStats!.max).toBe(40); 40 + expect(summary.numericStats!.mean).toBe(32.5); 41 + expect(summary.numericStats!.median).toBe(32.5); 42 + expect(summary.numericStats!.sum).toBe(130); 43 + }); 44 + 45 + it('computes average length for text questions', () => { 46 + const question = { id: 'q1', type: 'short_text', label: 'Name', description: '', required: false, options: [] }; 47 + const responses = makeResponses([{ q1: 'Alice' }, { q1: 'Bob' }]); 48 + const summary = computeQuestionSummary(question as any, responses); 49 + expect(summary.avgLength).toBe(4); // (5+3)/2 50 + }); 51 + 52 + it('skips empty answers', () => { 53 + const question = { id: 'q1', type: 'short_text', label: 'Name', description: '', required: false, options: [] }; 54 + const responses = makeResponses([{ q1: 'Alice' }, { q1: '' }, {}]); 55 + const summary = computeQuestionSummary(question as any, responses); 56 + expect(summary.responseCount).toBe(1); 57 + }); 58 + }); 59 + 60 + describe('computeResponsesOverTime', () => { 61 + it('groups responses by date', () => { 62 + const now = new Date('2026-04-14T12:00:00Z').getTime(); 63 + const responses = [ 64 + { formId: 'f1', answers: new Map(), submittedAt: now }, 65 + { formId: 'f1', answers: new Map(), submittedAt: now }, 66 + { formId: 'f1', answers: new Map(), submittedAt: now - 86400000 }, 67 + ]; 68 + const timeline = computeResponsesOverTime(responses); 69 + expect(timeline.get('2026-04-14')).toBe(2); 70 + expect(timeline.get('2026-04-13')).toBe(1); 71 + }); 72 + }); 73 + 74 + describe('computeCompletionRate', () => { 75 + it('returns 100% when all required answered', () => { 76 + const form = { id: 'f1', title: '', description: '', questions: [ 77 + { id: 'q1', type: 'short_text', label: '', description: '', required: true, options: [] }, 78 + ], targetSheetId: null, createdAt: 0, updatedAt: 0 }; 79 + const responses = makeResponses([{ q1: 'yes' }, { q1: 'no' }]); 80 + expect(computeCompletionRate(form as any, responses)).toBe(100); 81 + }); 82 + 83 + it('returns 50% when half are complete', () => { 84 + const form = { id: 'f1', title: '', description: '', questions: [ 85 + { id: 'q1', type: 'short_text', label: '', description: '', required: true, options: [] }, 86 + ], targetSheetId: null, createdAt: 0, updatedAt: 0 }; 87 + const responses = makeResponses([{ q1: 'yes' }, {}]); 88 + expect(computeCompletionRate(form as any, responses)).toBe(50); 89 + }); 90 + 91 + it('returns 100% when no required questions', () => { 92 + const form = { id: 'f1', title: '', description: '', questions: [ 93 + { id: 'q1', type: 'short_text', label: '', description: '', required: false, options: [] }, 94 + ], targetSheetId: null, createdAt: 0, updatedAt: 0 }; 95 + const responses = makeResponses([{}]); 96 + expect(computeCompletionRate(form as any, responses)).toBe(100); 97 + }); 98 + 99 + it('returns 0 for empty responses', () => { 100 + const form = { id: 'f1', title: '', description: '', questions: [], targetSheetId: null, createdAt: 0, updatedAt: 0 }; 101 + expect(computeCompletionRate(form as any, [])).toBe(0); 102 + }); 103 + }); 104 + 105 + describe('distributionToChartData', () => { 106 + it('converts distribution map to sorted labels and data', () => { 107 + const dist = new Map([['Red', 5], ['Blue', 3], ['Green', 8]]); 108 + const result = distributionToChartData(dist); 109 + expect(result.labels[0]).toBe('Green'); 110 + expect(result.data[0]).toBe(8); 111 + expect(result.labels).toHaveLength(3); 112 + }); 113 + }); 114 + 115 + describe('timelineToChartData', () => { 116 + it('sorts by date ascending', () => { 117 + const timeline = new Map([['2026-04-14', 3], ['2026-04-12', 1], ['2026-04-13', 2]]); 118 + const result = timelineToChartData(timeline); 119 + expect(result.labels).toEqual(['2026-04-12', '2026-04-13', '2026-04-14']); 120 + expect(result.data).toEqual([1, 2, 3]); 121 + }); 122 + }); 123 + 124 + describe('computeAnalytics', () => { 125 + it('produces full summary', () => { 126 + const form = { id: 'f1', title: 'Test', description: '', questions: [ 127 + { id: 'q1', type: 'single_choice', label: 'Color', description: '', required: true, options: [{ id: 'o1', label: 'Red' }] }, 128 + ], targetSheetId: null, createdAt: 0, updatedAt: 0 }; 129 + const responses = makeResponses([{ q1: 'Red' }, { q1: 'Red' }]); 130 + const analytics = computeAnalytics(form as any, responses); 131 + expect(analytics.totalResponses).toBe(2); 132 + expect(analytics.questionSummaries).toHaveLength(1); 133 + expect(analytics.completionRate).toBe(100); 134 + }); 135 + });