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 'fix: critical bugs — share URL, form validation, hit testing, time formulas' (#324) from fix/critical-bugs-v2 into main

scott 08778cf2 4434132a

+243 -16
+1 -1
server/routes/api-v1.ts
··· 11 11 // the server can filter by type and return metadata for cross-doc linking) 12 12 router.get('/api/v1/documents', (req: Request, res: Response) => { 13 13 const { type, limit: lim, offset: off } = req.query; 14 - const validTypes = ['doc', 'sheet', 'form', 'slide', 'diagram']; 14 + const validTypes = ['doc', 'sheet', 'form', 'slide', 'diagram', 'calendar']; 15 15 let query = 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL'; 16 16 const params: unknown[] = []; 17 17
+1 -1
server/types.ts
··· 4 4 5 5 import type { Statement } from 'better-sqlite3'; 6 6 7 - export type DocType = 'doc' | 'sheet' | 'form' | 'slide' | 'diagram'; 7 + export type DocType = 'doc' | 'sheet' | 'form' | 'slide' | 'diagram' | 'calendar'; 8 8 9 9 export interface DocumentRow { 10 10 id: string;
+22 -3
src/diagrams/whiteboard-geometry.ts
··· 20 20 // --- Hit testing --- 21 21 22 22 /** 23 - * Hit-test: is a point inside a shape's bounding box? 23 + * Hit-test: is a point inside a shape's (possibly rotated) bounding box? 24 + * 25 + * When the shape has a non-zero `rotation` (radians), the test point is 26 + * rotated by -rotation around the shape's center into the shape's local 27 + * coordinate space before the axis-aligned bounds check. 24 28 */ 25 29 export function hitTestShape(shape: Shape, px: number, py: number): boolean { 26 - return px >= shape.x && px <= shape.x + shape.width && 27 - py >= shape.y && py <= shape.y + shape.height; 30 + const cx = shape.x + shape.width / 2; 31 + const cy = shape.y + shape.height / 2; 32 + 33 + let localX = px; 34 + let localY = py; 35 + 36 + if (shape.rotation) { 37 + const cos = Math.cos(-shape.rotation); 38 + const sin = Math.sin(-shape.rotation); 39 + const dx = px - cx; 40 + const dy = py - cy; 41 + localX = cx + dx * cos - dy * sin; 42 + localY = cy + dx * sin + dy * cos; 43 + } 44 + 45 + return localX >= shape.x && localX <= shape.x + shape.width && 46 + localY >= shape.y && localY <= shape.y + shape.height; 28 47 } 29 48 30 49 /**
+10
src/forms/form-builder.ts
··· 242 242 243 243 /** 244 244 * Validate an entire form submission. 245 + * 246 + * When `visibleQuestionIds` is provided, only questions in that set are 247 + * validated. Hidden questions (e.g. hidden by conditional logic) are 248 + * skipped even if they are marked as required. 245 249 */ 246 250 export function validateSubmission( 247 251 form: FormSchema, 248 252 answers: Map<string, unknown>, 253 + visibleQuestionIds?: Set<string> | string[], 249 254 ): Map<string, string> { 255 + const visibleSet = visibleQuestionIds 256 + ? (visibleQuestionIds instanceof Set ? visibleQuestionIds : new Set(visibleQuestionIds)) 257 + : null; 258 + 250 259 const errors = new Map<string, string>(); 251 260 for (const q of form.questions) { 261 + if (visibleSet && !visibleSet.has(q.id)) continue; 252 262 const error = validateAnswer(q, answers.get(q.id)); 253 263 if (error) errors.set(q.id, error); 254 264 }
+3 -3
src/lib/share-dialog.ts
··· 40 40 41 41 /** Build a share URL for a document. */ 42 42 export function buildShareUrl(baseUrl: string, docType: string, docId: string, keyString: string, mode: ShareMode): string { 43 - const url = `${baseUrl}/${docType}/${docId}#${keyString}`; 43 + const base = `${baseUrl}/${docType}/${docId}`; 44 44 if (mode === 'view') { 45 - return url + '?mode=view'; 45 + return `${base}?mode=view#${keyString}`; 46 46 } 47 - return url; 47 + return `${base}#${keyString}`; 48 48 } 49 49 50 50 /** Check if current URL indicates view-only mode. */
+27 -3
src/sheets/formula-date.ts
··· 15 15 case 'MONTH': { const md = new Date(args[0] as string | number | Date); return isNaN(md.getTime()) ? '#VALUE!' : md.getMonth() + 1; } 16 16 case 'DAY': { const dd = new Date(args[0] as string | number | Date); return isNaN(dd.getTime()) ? '#VALUE!' : dd.getDate(); } 17 17 18 - case 'HOUR': return new Date(args[0] as string | number | Date).getHours(); 19 - case 'MINUTE': return new Date(args[0] as string | number | Date).getMinutes(); 20 - case 'SECOND': return new Date(args[0] as string | number | Date).getSeconds(); 18 + case 'HOUR': { 19 + const hVal = args[0]; 20 + if (typeof hVal === 'string') { 21 + const hMatch = hVal.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); 22 + if (hMatch) return parseInt(hMatch[1], 10); 23 + } 24 + const hd = new Date(hVal as string | number | Date); 25 + return isNaN(hd.getTime()) ? '#VALUE!' : hd.getHours(); 26 + } 27 + case 'MINUTE': { 28 + const mVal = args[0]; 29 + if (typeof mVal === 'string') { 30 + const mMatch = mVal.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); 31 + if (mMatch) return parseInt(mMatch[2], 10); 32 + } 33 + const md = new Date(mVal as string | number | Date); 34 + return isNaN(md.getTime()) ? '#VALUE!' : md.getMinutes(); 35 + } 36 + case 'SECOND': { 37 + const sVal = args[0]; 38 + if (typeof sVal === 'string') { 39 + const sMatch = sVal.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/); 40 + if (sMatch) return sMatch[3] !== undefined ? parseInt(sMatch[3], 10) : 0; 41 + } 42 + const sd = new Date(sVal as string | number | Date); 43 + return isNaN(sd.getTime()) ? '#VALUE!' : sd.getSeconds(); 44 + } 21 45 case 'WEEKDAY': { 22 46 const wdDate = new Date(args[0] as string | number | Date); 23 47 const wdType = args[1] !== undefined ? toNum(args[1]) : 1;
+63
tests/diagrams-bugs.test.ts
··· 736 736 }); 737 737 738 738 // --------------------------------------------------------------------------- 739 + // hitTestShape rotation support 740 + // --------------------------------------------------------------------------- 741 + 742 + describe('hitTestShape with rotation', () => { 743 + it('unrotated hit still works (regression)', () => { 744 + const shape = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 100, height: 50, rotation: 0 }); 745 + expect(hitTestShape(shape, 50, 25)).toBe(true); 746 + expect(hitTestShape(shape, 101, 25)).toBe(false); 747 + }); 748 + 749 + it('45-degree rotated rect hit at visual corner', () => { 750 + // 100x100 rect centered at (100, 100), rotated 45 degrees 751 + // The visual top corner after rotation is at approximately (100, 100 - 70.7) 752 + // = (100, 29.3). A point near that visual corner should hit. 753 + const shape = makeShape({ 754 + id: 's1', kind: 'rectangle', 755 + x: 50, y: 50, width: 100, height: 100, 756 + rotation: Math.PI / 4, 757 + }); 758 + // Center of shape is (100, 100). The visual top vertex after 45deg rotation 759 + // of the corner (50,50) around center (100,100) is approx (100, 29.3). 760 + // A point at (100, 35) is inside the rotated diamond. 761 + expect(hitTestShape(shape, 100, 35)).toBe(true); 762 + }); 763 + 764 + it('miss at AABB-only corner of rotated rect', () => { 765 + // 100x100 rect at (50,50), center at (100, 100), rotated 45 degrees. 766 + // After rotation it becomes a diamond. The AABB corner (30, 30) would 767 + // be inside an unrotated AABB but is outside the actual rotated shape. 768 + const shape = makeShape({ 769 + id: 's1', kind: 'rectangle', 770 + x: 50, y: 50, width: 100, height: 100, 771 + rotation: Math.PI / 4, 772 + }); 773 + // Center of shape is at (100, 100). 774 + // (100, 100) center always hits 775 + expect(hitTestShape(shape, 100, 100)).toBe(true); 776 + // (30, 30) is in the axis-aligned bounding box but outside the rotated diamond. 777 + // Un-rotating (30,30) by -45deg around (100,100): dx=-70, dy=-70 778 + // localX = 100 + (-70*cos(-45) - (-70)*sin(-45)) = 100 + (-49.5 - 49.5) = 1 779 + // localY = 100 + (-70*sin(-45) + (-70)*cos(-45)) = 100 + (49.5 - 49.5) = 100 780 + // local (1, 100) is NOT inside [50..150, 50..150], so it misses. 781 + expect(hitTestShape(shape, 30, 30)).toBe(false); 782 + }); 783 + 784 + it('point inside 90-degree rotated wide rectangle', () => { 785 + // 200x50 rect at (0,0), center at (100, 25), rotated 90 degrees. 786 + // After rotation, it becomes a tall 50x200 shape visually. 787 + const shape = makeShape({ 788 + id: 's1', kind: 'rectangle', 789 + x: 0, y: 0, width: 200, height: 50, 790 + rotation: Math.PI / 2, 791 + }); 792 + // Center (100, 25). After 90deg rotation, the shape extends 793 + // vertically. A point at (100, -60) should be inside the rotated shape 794 + // (it's within the rotated height of 200). 795 + expect(hitTestShape(shape, 100, -60)).toBe(true); 796 + // A point far to the right should miss (width is only 50 after rotation) 797 + expect(hitTestShape(shape, 200, 25)).toBe(false); 798 + }); 799 + }); 800 + 801 + // --------------------------------------------------------------------------- 739 802 // COVERAGE GAP: snapPoint edge cases 740 803 // --------------------------------------------------------------------------- 741 804
+52
tests/form-builder.test.ts
··· 632 632 expect(errors.size).toBe(1); 633 633 expect(errors.has(form.questions[0].id)).toBe(true); 634 634 }); 635 + 636 + it('skips validation for hidden required fields', () => { 637 + let form = createForm('Test'); 638 + form = addQuestion(form, 'short_text', 'Visible', { required: true }); 639 + form = addQuestion(form, 'short_text', 'Hidden', { required: true }); 640 + const visibleIds = new Set([form.questions[0].id]); 641 + const answers = new Map<string, unknown>(); 642 + const errors = validateSubmission(form, answers, visibleIds); 643 + // Only the visible required field should produce an error 644 + expect(errors.size).toBe(1); 645 + expect(errors.has(form.questions[0].id)).toBe(true); 646 + expect(errors.has(form.questions[1].id)).toBe(false); 647 + }); 648 + 649 + it('visible required field fails without answer', () => { 650 + let form = createForm('Test'); 651 + form = addQuestion(form, 'short_text', 'Name', { required: true }); 652 + const visibleIds = new Set([form.questions[0].id]); 653 + const answers = new Map<string, unknown>(); 654 + const errors = validateSubmission(form, answers, visibleIds); 655 + expect(errors.size).toBe(1); 656 + expect(errors.get(form.questions[0].id)).toBe('This field is required'); 657 + }); 658 + 659 + it('hidden required field passes without answer', () => { 660 + let form = createForm('Test'); 661 + form = addQuestion(form, 'short_text', 'Name', { required: true }); 662 + const visibleIds = new Set<string>(); // nothing visible 663 + const answers = new Map<string, unknown>(); 664 + const errors = validateSubmission(form, answers, visibleIds); 665 + expect(errors.size).toBe(0); 666 + }); 667 + 668 + it('accepts array of visible IDs as well as Set', () => { 669 + let form = createForm('Test'); 670 + form = addQuestion(form, 'short_text', 'Q1', { required: true }); 671 + form = addQuestion(form, 'short_text', 'Q2', { required: true }); 672 + const visibleIds = [form.questions[0].id]; // array, not Set 673 + const answers = new Map<string, unknown>(); 674 + const errors = validateSubmission(form, answers, visibleIds); 675 + expect(errors.size).toBe(1); 676 + expect(errors.has(form.questions[0].id)).toBe(true); 677 + }); 678 + 679 + it('validates all questions when visibleQuestionIds is omitted (backward compat)', () => { 680 + let form = createForm('Test'); 681 + form = addQuestion(form, 'short_text', 'Q1', { required: true }); 682 + form = addQuestion(form, 'short_text', 'Q2', { required: true }); 683 + const answers = new Map<string, unknown>(); 684 + const errors = validateSubmission(form, answers); 685 + expect(errors.size).toBe(2); 686 + }); 635 687 }); 636 688 637 689 // --- questionCount / requiredCount ---
+43 -3
tests/formulas-expanded.test.ts
··· 426 426 // ─── Date/Time Functions ───────────────────────────────────────────────────── 427 427 428 428 describe('HOUR / MINUTE / SECOND', () => { 429 - it('extracts hour', () => { 429 + it('extracts hour from datetime', () => { 430 430 const cells = { A1: '2024-01-15T14:30:45' }; 431 431 expect(evalWith('HOUR(A1)', cells)).toBe(14); 432 432 }); 433 433 434 - it('extracts minute', () => { 434 + it('extracts minute from datetime', () => { 435 435 const cells = { A1: '2024-01-15T14:30:45' }; 436 436 expect(evalWith('MINUTE(A1)', cells)).toBe(30); 437 437 }); 438 438 439 - it('extracts second', () => { 439 + it('extracts second from datetime', () => { 440 440 const cells = { A1: '2024-01-15T14:30:45' }; 441 441 expect(evalWith('SECOND(A1)', cells)).toBe(45); 442 + }); 443 + 444 + it('HOUR parses pure time string HH:MM', () => { 445 + const cells = { A1: '14:30' }; 446 + expect(evalWith('HOUR(A1)', cells)).toBe(14); 447 + }); 448 + 449 + it('MINUTE parses pure time string HH:MM:SS', () => { 450 + const cells = { A1: '14:30:45' }; 451 + expect(evalWith('MINUTE(A1)', cells)).toBe(30); 452 + }); 453 + 454 + it('SECOND parses pure time string HH:MM:SS', () => { 455 + const cells = { A1: '14:30:45' }; 456 + expect(evalWith('SECOND(A1)', cells)).toBe(45); 457 + }); 458 + 459 + it('SECOND returns 0 for HH:MM without seconds', () => { 460 + const cells = { A1: '14:30' }; 461 + expect(evalWith('SECOND(A1)', cells)).toBe(0); 462 + }); 463 + 464 + it('HOUR returns error for empty string', () => { 465 + const cells = { A1: '' }; 466 + expect(evalWith('HOUR(A1)', cells)).toBe('#VALUE!'); 467 + }); 468 + 469 + it('MINUTE returns error for empty string', () => { 470 + const cells = { A1: '' }; 471 + expect(evalWith('MINUTE(A1)', cells)).toBe('#VALUE!'); 472 + }); 473 + 474 + it('SECOND returns error for empty string', () => { 475 + const cells = { A1: '' }; 476 + expect(evalWith('SECOND(A1)', cells)).toBe('#VALUE!'); 477 + }); 478 + 479 + it('HOUR handles single-digit hour time string', () => { 480 + const cells = { A1: '9:05' }; 481 + expect(evalWith('HOUR(A1)', cells)).toBe(9); 442 482 }); 443 483 }); 444 484
+21 -2
tests/share-dialog.test.ts
··· 18 18 expect(url).toBe('https://tools.example.com/doc/abc123#keyXYZ'); 19 19 }); 20 20 21 - it('builds view URL (appends ?mode=view)', () => { 21 + it('builds view URL with ?mode=view before #fragment', () => { 22 22 const url = buildShareUrl(base, 'doc', 'abc123', 'keyXYZ', 'view'); 23 - expect(url).toBe('https://tools.example.com/doc/abc123#keyXYZ?mode=view'); 23 + expect(url).toBe('https://tools.example.com/doc/abc123?mode=view#keyXYZ'); 24 24 }); 25 25 26 26 it('works with sheet doc type', () => { ··· 36 36 it('handles empty key string', () => { 37 37 const url = buildShareUrl(base, 'doc', 'd1', '', 'edit'); 38 38 expect(url).toBe('https://tools.example.com/doc/d1#'); 39 + }); 40 + 41 + it('view mode with empty key string puts query before hash', () => { 42 + const url = buildShareUrl(base, 'doc', 'd1', '', 'view'); 43 + expect(url).toBe('https://tools.example.com/doc/d1?mode=view#'); 44 + }); 45 + 46 + it('view mode URL query param is parseable by browsers', () => { 47 + const url = buildShareUrl(base, 'sheet', 's1', 'secretKey', 'view'); 48 + const parsed = new URL(url); 49 + expect(parsed.searchParams.get('mode')).toBe('view'); 50 + expect(parsed.hash).toBe('#secretKey'); 51 + }); 52 + 53 + it('edit mode URL has no query param', () => { 54 + const url = buildShareUrl(base, 'sheet', 's1', 'secretKey', 'edit'); 55 + const parsed = new URL(url); 56 + expect(parsed.searchParams.get('mode')).toBeNull(); 57 + expect(parsed.hash).toBe('#secretKey'); 39 58 }); 40 59 }); 41 60