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(sheets): gallery and calendar alternate views (#74, #75)' (#131) from feat/gallery-calendar-views into main

scott 8db8b73b 7c0f6000

+600
+180
src/sheets/calendar-view.ts
··· 1 + /** 2 + * Calendar View — rows displayed on a monthly calendar by date column. 3 + * 4 + * Pure logic module: date extraction, calendar grid, event placement. 5 + * DOM rendering handled in the sheets UI layer. 6 + */ 7 + 8 + export interface CalendarEvent { 9 + rowIndex: number; 10 + title: string; 11 + date: Date; 12 + fields: { label: string; value: string }[]; 13 + } 14 + 15 + export interface CalendarDay { 16 + date: Date; 17 + inMonth: boolean; 18 + events: CalendarEvent[]; 19 + } 20 + 21 + export interface CalendarConfig { 22 + /** Column index containing dates */ 23 + dateCol: number; 24 + /** Column index for event title */ 25 + titleCol: number; 26 + /** Additional column indices to show */ 27 + fieldCols: number[]; 28 + } 29 + 30 + export interface CalendarMonth { 31 + year: number; 32 + month: number; // 0-11 33 + days: CalendarDay[]; 34 + /** Events that couldn't be placed (invalid dates) */ 35 + unplaced: CalendarEvent[]; 36 + } 37 + 38 + /** 39 + * Parse a cell value as a Date. Returns null if not parseable. 40 + */ 41 + export function parseDate(value: unknown): Date | null { 42 + if (!value) return null; 43 + const str = String(value).trim(); 44 + if (!str) return null; 45 + 46 + const d = new Date(str); 47 + if (isNaN(d.getTime())) return null; 48 + return d; 49 + } 50 + 51 + /** 52 + * Extract calendar events from sheet data. 53 + */ 54 + export function extractEvents( 55 + cellValues: Map<string, unknown>, 56 + rowCount: number, 57 + config: CalendarConfig, 58 + colToLetter: (col: number) => string, 59 + colHeaders: string[], 60 + ): CalendarEvent[] { 61 + const events: CalendarEvent[] = []; 62 + 63 + for (let r = 1; r <= rowCount; r++) { 64 + const dateCellId = `${colToLetter(config.dateCol)}${r}`; 65 + const dateRaw = cellValues.get(dateCellId); 66 + const date = parseDate(dateRaw); 67 + if (!date) continue; 68 + 69 + const titleCellId = `${colToLetter(config.titleCol)}${r}`; 70 + const titleRaw = cellValues.get(titleCellId); 71 + const title = titleRaw ? String(titleRaw).trim() : `Row ${r}`; 72 + 73 + const fields: CalendarEvent['fields'] = []; 74 + for (const colIdx of config.fieldCols) { 75 + const cellId = `${colToLetter(colIdx)}${r}`; 76 + const val = cellValues.get(cellId); 77 + fields.push({ 78 + label: colHeaders[colIdx] || colToLetter(colIdx), 79 + value: val !== null && val !== undefined ? String(val) : '', 80 + }); 81 + } 82 + 83 + events.push({ rowIndex: r, title, date, fields }); 84 + } 85 + 86 + return events; 87 + } 88 + 89 + /** 90 + * Build a calendar month grid (6 weeks x 7 days). 91 + */ 92 + export function buildCalendarMonth( 93 + year: number, 94 + month: number, 95 + events: CalendarEvent[], 96 + ): CalendarMonth { 97 + const firstDay = new Date(year, month, 1); 98 + const startDow = firstDay.getDay(); // 0=Sun 99 + const daysInMonth = new Date(year, month + 1, 0).getDate(); 100 + 101 + // Build 6-week grid starting from Sunday 102 + const days: CalendarDay[] = []; 103 + const gridStart = new Date(year, month, 1 - startDow); 104 + const unplaced: CalendarEvent[] = []; 105 + 106 + for (let i = 0; i < 42; i++) { 107 + const date = new Date(gridStart); 108 + date.setDate(gridStart.getDate() + i); 109 + const inMonth = date.getMonth() === month && date.getFullYear() === year; 110 + 111 + days.push({ date, inMonth, events: [] }); 112 + } 113 + 114 + // Place events on their dates 115 + for (const event of events) { 116 + const eventDate = event.date; 117 + const placed = days.find(d => 118 + d.date.getFullYear() === eventDate.getFullYear() && 119 + d.date.getMonth() === eventDate.getMonth() && 120 + d.date.getDate() === eventDate.getDate(), 121 + ); 122 + 123 + if (placed) { 124 + placed.events.push(event); 125 + } else { 126 + unplaced.push(event); 127 + } 128 + } 129 + 130 + return { year, month, days, unplaced }; 131 + } 132 + 133 + /** 134 + * Navigate to the previous month. 135 + */ 136 + export function prevMonth(year: number, month: number): { year: number; month: number } { 137 + if (month === 0) return { year: year - 1, month: 11 }; 138 + return { year, month: month - 1 }; 139 + } 140 + 141 + /** 142 + * Navigate to the next month. 143 + */ 144 + export function nextMonth(year: number, month: number): { year: number; month: number } { 145 + if (month === 11) return { year: year + 1, month: 0 }; 146 + return { year, month: month + 1 }; 147 + } 148 + 149 + /** 150 + * Get events for a specific date. 151 + */ 152 + export function getEventsForDate( 153 + calendar: CalendarMonth, 154 + date: Date, 155 + ): CalendarEvent[] { 156 + const day = calendar.days.find(d => 157 + d.date.getFullYear() === date.getFullYear() && 158 + d.date.getMonth() === date.getMonth() && 159 + d.date.getDate() === date.getDate(), 160 + ); 161 + return day ? day.events : []; 162 + } 163 + 164 + /** 165 + * Count total events in a calendar month. 166 + */ 167 + export function countMonthEvents(calendar: CalendarMonth): number { 168 + return calendar.days.reduce((sum, d) => sum + d.events.length, 0); 169 + } 170 + 171 + /** 172 + * Format month header string. 173 + */ 174 + export function formatMonthHeader(year: number, month: number): string { 175 + const months = [ 176 + 'January', 'February', 'March', 'April', 'May', 'June', 177 + 'July', 'August', 'September', 'October', 'November', 'December', 178 + ]; 179 + return `${months[month]} ${year}`; 180 + }
+143
src/sheets/gallery-view.ts
··· 1 + /** 2 + * Gallery View — card layout showing each row as a visual card. 3 + * 4 + * Pure logic module: card construction, layout, pagination. 5 + * DOM rendering handled in the sheets UI layer. 6 + */ 7 + 8 + export interface GalleryCard { 9 + rowIndex: number; 10 + title: string; 11 + coverValue: string; 12 + fields: { label: string; value: string }[]; 13 + } 14 + 15 + export interface GalleryConfig { 16 + /** Column index to use as card title */ 17 + titleCol: number; 18 + /** Column index for cover/image field (optional) */ 19 + coverCol: number | null; 20 + /** Column indices to display as fields */ 21 + fieldCols: number[]; 22 + /** Cards per row in the grid */ 23 + cardsPerRow: number; 24 + } 25 + 26 + export interface GalleryPage { 27 + cards: GalleryCard[]; 28 + page: number; 29 + totalPages: number; 30 + totalCards: number; 31 + } 32 + 33 + /** 34 + * Build gallery cards from sheet data. 35 + */ 36 + export function buildGalleryCards( 37 + cellValues: Map<string, unknown>, 38 + rowCount: number, 39 + config: GalleryConfig, 40 + colToLetter: (col: number) => string, 41 + colHeaders: string[], 42 + ): GalleryCard[] { 43 + const cards: GalleryCard[] = []; 44 + 45 + for (let r = 1; r <= rowCount; r++) { 46 + const titleCellId = `${colToLetter(config.titleCol)}${r}`; 47 + const titleRaw = cellValues.get(titleCellId); 48 + const title = titleRaw ? String(titleRaw).trim() : `Row ${r}`; 49 + 50 + // Skip rows with empty title (likely empty rows) 51 + if (!titleRaw && !hasAnyData(cellValues, r, config, colToLetter)) continue; 52 + 53 + let coverValue = ''; 54 + if (config.coverCol !== null) { 55 + const coverCellId = `${colToLetter(config.coverCol)}${r}`; 56 + const raw = cellValues.get(coverCellId); 57 + coverValue = raw ? String(raw) : ''; 58 + } 59 + 60 + const fields: GalleryCard['fields'] = []; 61 + for (const colIdx of config.fieldCols) { 62 + const cellId = `${colToLetter(colIdx)}${r}`; 63 + const val = cellValues.get(cellId); 64 + fields.push({ 65 + label: colHeaders[colIdx] || colToLetter(colIdx), 66 + value: val !== null && val !== undefined ? String(val) : '', 67 + }); 68 + } 69 + 70 + cards.push({ rowIndex: r, title, coverValue, fields }); 71 + } 72 + 73 + return cards; 74 + } 75 + 76 + /** 77 + * Check if a row has any data in the configured columns. 78 + */ 79 + function hasAnyData( 80 + cellValues: Map<string, unknown>, 81 + row: number, 82 + config: GalleryConfig, 83 + colToLetter: (col: number) => string, 84 + ): boolean { 85 + const cols = [config.titleCol, ...config.fieldCols]; 86 + if (config.coverCol !== null) cols.push(config.coverCol); 87 + 88 + return cols.some(col => { 89 + const cellId = `${colToLetter(col)}${row}`; 90 + const val = cellValues.get(cellId); 91 + return val !== null && val !== undefined && val !== ''; 92 + }); 93 + } 94 + 95 + /** 96 + * Paginate gallery cards. 97 + */ 98 + export function paginateCards( 99 + cards: GalleryCard[], 100 + page: number, 101 + pageSize: number, 102 + ): GalleryPage { 103 + const totalCards = cards.length; 104 + const totalPages = Math.max(1, Math.ceil(totalCards / pageSize)); 105 + const safePage = Math.max(1, Math.min(page, totalPages)); 106 + const start = (safePage - 1) * pageSize; 107 + const end = Math.min(start + pageSize, totalCards); 108 + 109 + return { 110 + cards: cards.slice(start, end), 111 + page: safePage, 112 + totalPages, 113 + totalCards, 114 + }; 115 + } 116 + 117 + /** 118 + * Compute grid layout dimensions. 119 + */ 120 + export function computeGridLayout( 121 + cardsPerRow: number, 122 + totalCards: number, 123 + ): { columns: number; rows: number } { 124 + const columns = Math.max(1, Math.min(cardsPerRow, totalCards)); 125 + const rows = Math.ceil(totalCards / columns); 126 + return { columns, rows }; 127 + } 128 + 129 + /** 130 + * Filter gallery cards by a search query across title and fields. 131 + */ 132 + export function filterGalleryCards( 133 + cards: GalleryCard[], 134 + query: string, 135 + ): GalleryCard[] { 136 + if (!query.trim()) return cards; 137 + 138 + const lower = query.toLowerCase(); 139 + return cards.filter(card => { 140 + if (card.title.toLowerCase().includes(lower)) return true; 141 + return card.fields.some(f => f.value.toLowerCase().includes(lower)); 142 + }); 143 + }
+157
tests/calendar-view.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + parseDate, 4 + extractEvents, 5 + buildCalendarMonth, 6 + prevMonth, 7 + nextMonth, 8 + getEventsForDate, 9 + countMonthEvents, 10 + formatMonthHeader, 11 + type CalendarConfig, 12 + } from '../src/sheets/calendar-view.js'; 13 + 14 + function colToLetter(col: number): string { 15 + return String.fromCharCode(65 + col); 16 + } 17 + 18 + function makeCells(obj: Record<string, unknown>): Map<string, unknown> { 19 + return new Map(Object.entries(obj)); 20 + } 21 + 22 + const HEADERS = ['Date', 'Event', 'Location']; 23 + const CONFIG: CalendarConfig = { dateCol: 0, titleCol: 1, fieldCols: [2] }; 24 + 25 + describe('Calendar View', () => { 26 + describe('parseDate', () => { 27 + it('parses ISO date string', () => { 28 + const d = parseDate('2026-03-15T12:00:00'); 29 + expect(d).not.toBeNull(); 30 + expect(d!.getFullYear()).toBe(2026); 31 + expect(d!.getMonth()).toBe(2); // March 32 + expect(d!.getDate()).toBe(15); 33 + }); 34 + 35 + it('returns null for invalid date', () => { 36 + expect(parseDate('not-a-date')).toBeNull(); 37 + }); 38 + 39 + it('returns null for empty values', () => { 40 + expect(parseDate('')).toBeNull(); 41 + expect(parseDate(null)).toBeNull(); 42 + expect(parseDate(undefined)).toBeNull(); 43 + }); 44 + }); 45 + 46 + describe('extractEvents', () => { 47 + it('extracts events from rows with valid dates', () => { 48 + const cells = makeCells({ 49 + A1: '2026-03-10', B1: 'Meeting', C1: 'Room A', 50 + A2: '2026-03-15', B2: 'Lunch', C2: 'Cafe', 51 + A3: 'not-a-date', B3: 'Skipped', C3: '', 52 + }); 53 + const events = extractEvents(cells, 3, CONFIG, colToLetter, HEADERS); 54 + expect(events).toHaveLength(2); 55 + expect(events[0].title).toBe('Meeting'); 56 + expect(events[1].title).toBe('Lunch'); 57 + }); 58 + 59 + it('includes field data', () => { 60 + const cells = makeCells({ A1: '2026-03-10', B1: 'Meeting', C1: 'Room A' }); 61 + const events = extractEvents(cells, 1, CONFIG, colToLetter, HEADERS); 62 + expect(events[0].fields[0].label).toBe('Location'); 63 + expect(events[0].fields[0].value).toBe('Room A'); 64 + }); 65 + 66 + it('returns empty for no valid dates', () => { 67 + const cells = makeCells({ A1: 'text', B1: 'Nope' }); 68 + expect(extractEvents(cells, 1, CONFIG, colToLetter, HEADERS)).toEqual([]); 69 + }); 70 + }); 71 + 72 + describe('buildCalendarMonth', () => { 73 + it('builds a 42-day grid (6 weeks)', () => { 74 + const cal = buildCalendarMonth(2026, 2, []); // March 2026 75 + expect(cal.days).toHaveLength(42); 76 + }); 77 + 78 + it('marks in-month days correctly', () => { 79 + const cal = buildCalendarMonth(2026, 2, []); // March 2026 80 + const inMonthDays = cal.days.filter(d => d.inMonth); 81 + expect(inMonthDays).toHaveLength(31); // March has 31 days 82 + }); 83 + 84 + it('places events on correct dates', () => { 85 + const events = [{ 86 + rowIndex: 1, 87 + title: 'Meeting', 88 + date: new Date(2026, 2, 15), 89 + fields: [], 90 + }]; 91 + const cal = buildCalendarMonth(2026, 2, events); 92 + const march15 = cal.days.find(d => 93 + d.date.getMonth() === 2 && d.date.getDate() === 15, 94 + ); 95 + expect(march15!.events).toHaveLength(1); 96 + expect(march15!.events[0].title).toBe('Meeting'); 97 + }); 98 + 99 + it('puts out-of-range events in unplaced', () => { 100 + const events = [{ 101 + rowIndex: 1, 102 + title: 'Wrong Month', 103 + date: new Date(2026, 5, 1), // June 104 + fields: [], 105 + }]; 106 + const cal = buildCalendarMonth(2026, 2, events); // March 107 + expect(cal.unplaced).toHaveLength(1); 108 + }); 109 + }); 110 + 111 + describe('prevMonth / nextMonth', () => { 112 + it('navigates backward', () => { 113 + expect(prevMonth(2026, 3)).toEqual({ year: 2026, month: 2 }); 114 + expect(prevMonth(2026, 0)).toEqual({ year: 2025, month: 11 }); 115 + }); 116 + 117 + it('navigates forward', () => { 118 + expect(nextMonth(2026, 2)).toEqual({ year: 2026, month: 3 }); 119 + expect(nextMonth(2026, 11)).toEqual({ year: 2027, month: 0 }); 120 + }); 121 + }); 122 + 123 + describe('getEventsForDate', () => { 124 + it('returns events for a specific date', () => { 125 + const events = [{ 126 + rowIndex: 1, title: 'Event', date: new Date(2026, 2, 20), fields: [], 127 + }]; 128 + const cal = buildCalendarMonth(2026, 2, events); 129 + const found = getEventsForDate(cal, new Date(2026, 2, 20)); 130 + expect(found).toHaveLength(1); 131 + }); 132 + 133 + it('returns empty for date with no events', () => { 134 + const cal = buildCalendarMonth(2026, 2, []); 135 + expect(getEventsForDate(cal, new Date(2026, 2, 10))).toEqual([]); 136 + }); 137 + }); 138 + 139 + describe('countMonthEvents', () => { 140 + it('counts all placed events', () => { 141 + const events = [ 142 + { rowIndex: 1, title: 'A', date: new Date(2026, 2, 10), fields: [] }, 143 + { rowIndex: 2, title: 'B', date: new Date(2026, 2, 15), fields: [] }, 144 + { rowIndex: 3, title: 'C', date: new Date(2026, 2, 15), fields: [] }, 145 + ]; 146 + const cal = buildCalendarMonth(2026, 2, events); 147 + expect(countMonthEvents(cal)).toBe(3); 148 + }); 149 + }); 150 + 151 + describe('formatMonthHeader', () => { 152 + it('formats month and year', () => { 153 + expect(formatMonthHeader(2026, 0)).toBe('January 2026'); 154 + expect(formatMonthHeader(2026, 11)).toBe('December 2026'); 155 + }); 156 + }); 157 + });
+120
tests/gallery-view.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + buildGalleryCards, 4 + paginateCards, 5 + computeGridLayout, 6 + filterGalleryCards, 7 + type GalleryConfig, 8 + } from '../src/sheets/gallery-view.js'; 9 + 10 + function colToLetter(col: number): string { 11 + return String.fromCharCode(65 + col); 12 + } 13 + 14 + function makeCells(obj: Record<string, unknown>): Map<string, unknown> { 15 + return new Map(Object.entries(obj)); 16 + } 17 + 18 + const HEADERS = ['Name', 'Image', 'Category', 'Price']; 19 + const CONFIG: GalleryConfig = { 20 + titleCol: 0, 21 + coverCol: 1, 22 + fieldCols: [2, 3], 23 + cardsPerRow: 3, 24 + }; 25 + 26 + const CELLS = makeCells({ 27 + A1: 'Widget', B1: 'img1.png', C1: 'Tools', D1: '29.99', 28 + A2: 'Gadget', B2: 'img2.png', C2: 'Electronics', D2: '49.99', 29 + A3: 'Doohickey', B3: '', C3: 'Tools', D3: '9.99', 30 + }); 31 + 32 + describe('Gallery View', () => { 33 + describe('buildGalleryCards', () => { 34 + it('builds cards from sheet data', () => { 35 + const cards = buildGalleryCards(CELLS, 3, CONFIG, colToLetter, HEADERS); 36 + expect(cards).toHaveLength(3); 37 + expect(cards[0].title).toBe('Widget'); 38 + expect(cards[0].coverValue).toBe('img1.png'); 39 + expect(cards[0].fields).toHaveLength(2); 40 + }); 41 + 42 + it('includes field labels from headers', () => { 43 + const cards = buildGalleryCards(CELLS, 3, CONFIG, colToLetter, HEADERS); 44 + expect(cards[0].fields[0].label).toBe('Category'); 45 + expect(cards[0].fields[1].label).toBe('Price'); 46 + }); 47 + 48 + it('skips completely empty rows', () => { 49 + const cells = makeCells({ A1: 'Item' }); // row 2 has nothing 50 + const cards = buildGalleryCards(cells, 2, CONFIG, colToLetter, HEADERS); 51 + expect(cards).toHaveLength(1); 52 + }); 53 + 54 + it('handles no cover column', () => { 55 + const noCoverConfig: GalleryConfig = { ...CONFIG, coverCol: null }; 56 + const cards = buildGalleryCards(CELLS, 3, noCoverConfig, colToLetter, HEADERS); 57 + expect(cards[0].coverValue).toBe(''); 58 + }); 59 + 60 + it('returns empty for no data', () => { 61 + expect(buildGalleryCards(new Map(), 0, CONFIG, colToLetter, HEADERS)).toEqual([]); 62 + }); 63 + }); 64 + 65 + describe('paginateCards', () => { 66 + const cards = buildGalleryCards(CELLS, 3, CONFIG, colToLetter, HEADERS); 67 + 68 + it('returns correct page', () => { 69 + const page = paginateCards(cards, 1, 2); 70 + expect(page.cards).toHaveLength(2); 71 + expect(page.page).toBe(1); 72 + expect(page.totalPages).toBe(2); 73 + expect(page.totalCards).toBe(3); 74 + }); 75 + 76 + it('returns last page with remaining cards', () => { 77 + const page = paginateCards(cards, 2, 2); 78 + expect(page.cards).toHaveLength(1); 79 + }); 80 + 81 + it('clamps page number', () => { 82 + expect(paginateCards(cards, 0, 2).page).toBe(1); 83 + expect(paginateCards(cards, 99, 2).page).toBe(2); 84 + }); 85 + }); 86 + 87 + describe('computeGridLayout', () => { 88 + it('computes columns and rows', () => { 89 + expect(computeGridLayout(3, 9)).toEqual({ columns: 3, rows: 3 }); 90 + expect(computeGridLayout(3, 7)).toEqual({ columns: 3, rows: 3 }); 91 + }); 92 + 93 + it('handles fewer cards than columns', () => { 94 + expect(computeGridLayout(4, 2)).toEqual({ columns: 2, rows: 1 }); 95 + }); 96 + 97 + it('handles zero cards', () => { 98 + expect(computeGridLayout(3, 0).rows).toBe(0); 99 + }); 100 + }); 101 + 102 + describe('filterGalleryCards', () => { 103 + const cards = buildGalleryCards(CELLS, 3, CONFIG, colToLetter, HEADERS); 104 + 105 + it('filters by title', () => { 106 + const filtered = filterGalleryCards(cards, 'widget'); 107 + expect(filtered).toHaveLength(1); 108 + expect(filtered[0].title).toBe('Widget'); 109 + }); 110 + 111 + it('filters by field value', () => { 112 + const filtered = filterGalleryCards(cards, 'Tools'); 113 + expect(filtered).toHaveLength(2); 114 + }); 115 + 116 + it('returns all for empty query', () => { 117 + expect(filterGalleryCards(cards, '')).toHaveLength(3); 118 + }); 119 + }); 120 + });