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

Configure Feed

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

Merge pull request 'feat: grid CF rendering, form analytics, footnotes, suggestions, PPTX notes (0.41.0)' (#376) from feat/grid-cf-render-analytics-katex into main

scott e9226d9a 78d6235a

+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 + });