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(calendar): overlap layout, half-hour lines, auto-scroll, live now-line' (#309) from feat/calendar-polish-v2 into main

scott db45c88c c0522411

+109 -10
+109 -10
src/calendar/main.ts
··· 289 289 } 290 290 291 291 // --------------------------------------------------------------------------- 292 + // Overlap layout algorithm 293 + // --------------------------------------------------------------------------- 294 + 295 + interface LayoutSlot { 296 + event: CalendarEvent; 297 + col: number; // which sub-column (0-based) 298 + totalCols: number; // how many columns in this overlap group 299 + } 300 + 301 + function layoutOverlappingEvents(events: CalendarEvent[]): LayoutSlot[] { 302 + if (events.length === 0) return []; 303 + 304 + // Sort by start time, then by duration (longer first) 305 + const sorted = [...events].sort((a, b) => { 306 + const aStart = timeToMinutes(a.startTime); 307 + const bStart = timeToMinutes(b.startTime); 308 + if (aStart !== bStart) return aStart - bStart; 309 + const aEnd = a.endTime ? timeToMinutes(a.endTime) : aStart + 60; 310 + const bEnd = b.endTime ? timeToMinutes(b.endTime) : bStart + 60; 311 + return (bEnd - bStart) - (aEnd - aStart); // longer first 312 + }); 313 + 314 + const slots: LayoutSlot[] = []; 315 + // columns[i] = end time of the event in column i 316 + const columns: number[] = []; 317 + 318 + for (const evt of sorted) { 319 + const start = timeToMinutes(evt.startTime); 320 + const end = evt.endTime ? timeToMinutes(evt.endTime) : start + 60; 321 + 322 + // Find first column where this event doesn't overlap 323 + let placed = -1; 324 + for (let c = 0; c < columns.length; c++) { 325 + if ((columns[c] ?? Infinity) <= start) { 326 + placed = c; 327 + break; 328 + } 329 + } 330 + 331 + if (placed === -1) { 332 + placed = columns.length; 333 + columns.push(end); 334 + } else { 335 + columns[placed] = end; 336 + } 337 + 338 + slots.push({ event: evt, col: placed, totalCols: 0 }); 339 + } 340 + 341 + // Set totalCols for all slots (max column used + 1) 342 + const maxCol = columns.length; 343 + for (const slot of slots) { 344 + slot.totalCols = maxCol; 345 + } 346 + 347 + return slots; 348 + } 349 + 350 + // --------------------------------------------------------------------------- 292 351 // Month View 293 352 // --------------------------------------------------------------------------- 294 353 ··· 427 486 const todayClass = dayIsToday ? ' cal-day-today' : ''; 428 487 html += `<div class="cal-day-column${todayClass}" data-date="${day.dateStr}">`; 429 488 430 - // Hour rows (click targets) 489 + // Hour rows (click targets) with half-hour dividers 431 490 for (let h = 0; h < 24; h++) { 432 - html += `<div class="cal-hour-row" data-date="${day.dateStr}" data-hour="${h}" style="height:${HOUR_HEIGHT}px"></div>`; 491 + html += `<div class="cal-hour-row" data-date="${day.dateStr}" data-hour="${h}" style="height:${HOUR_HEIGHT}px">`; 492 + html += '<div class="cal-hour-row-half"></div>'; 493 + html += '</div>'; 433 494 } 434 495 435 - // Positioned timed events 436 - for (const evt of day.timed) { 496 + // Positioned timed events (with overlap layout) 497 + const slots = layoutOverlappingEvents(day.timed); 498 + for (const { event: evt, col, totalCols } of slots) { 437 499 const startMin = timeToMinutes(evt.startTime); 438 500 const endMin = evt.endTime ? timeToMinutes(evt.endTime) : startMin + 60; 439 501 const top = (startMin / 60) * HOUR_HEIGHT; 440 502 const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20); 441 - html += `<div class="cal-event-block" data-event-id="${escapeHtml(evt.id)}" style="top:${top}px;height:${height}px;--pill-color:${evt.color}">`; 503 + const leftPct = (col / totalCols) * 100; 504 + const widthPct = (1 / totalCols) * 100; 505 + html += `<div class="cal-event-block" data-event-id="${escapeHtml(evt.id)}" style="top:${top}px;height:${height}px;left:calc(${leftPct}% + 1px);width:calc(${widthPct}% - 2px);--pill-color:${evt.color}">`; 442 506 html += `<div class="cal-event-block-time">${formatTimeDisplay(evt.startTime)}\u2013${formatTimeDisplay(evt.endTime)}</div>`; 443 507 html += `<div class="cal-event-block-title">${escapeHtml(evt.title || 'Untitled')}</div>`; 444 508 html += '</div>'; ··· 458 522 html += '</div>'; // cal-week-body 459 523 html += '</div>'; // cal-week-grid 460 524 calendarGrid.innerHTML = html; 525 + 526 + // Auto-scroll to current time (or 8 AM if not today) 527 + requestAnimationFrame(() => { 528 + const body = calendarGrid.querySelector('.cal-week-body') as HTMLElement | null; 529 + if (!body) return; 530 + const now = new Date(); 531 + const scrollHour = isToday(state.currentDate) ? Math.max(now.getHours() - 1, 0) : 8; 532 + body.scrollTop = scrollHour * HOUR_HEIGHT; 533 + }); 461 534 } 462 535 463 536 // --------------------------------------------------------------------------- ··· 498 571 499 572 html += `<div class="cal-day-column" data-date="${dateStr}">`; 500 573 501 - // Hour rows 574 + // Hour rows with half-hour dividers 502 575 for (let h = 0; h < 24; h++) { 503 - html += `<div class="cal-hour-row" data-date="${dateStr}" data-hour="${h}" style="height:${HOUR_HEIGHT}px"></div>`; 576 + html += `<div class="cal-hour-row" data-date="${dateStr}" data-hour="${h}" style="height:${HOUR_HEIGHT}px">`; 577 + html += '<div class="cal-hour-row-half"></div>'; 578 + html += '</div>'; 504 579 } 505 580 506 - // Positioned timed events 507 - for (const evt of timed) { 581 + // Positioned timed events (with overlap layout) 582 + const timedSlots = layoutOverlappingEvents(timed); 583 + for (const { event: evt, col, totalCols } of timedSlots) { 508 584 const startMin = timeToMinutes(evt.startTime); 509 585 const endMin = evt.endTime ? timeToMinutes(evt.endTime) : startMin + 60; 510 586 const top = (startMin / 60) * HOUR_HEIGHT; 511 587 const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20); 512 - html += `<div class="cal-event-block" data-event-id="${escapeHtml(evt.id)}" style="top:${top}px;height:${height}px;--pill-color:${evt.color}">`; 588 + const leftPct = (col / totalCols) * 100; 589 + const widthPct = (1 / totalCols) * 100; 590 + html += `<div class="cal-event-block" data-event-id="${escapeHtml(evt.id)}" style="top:${top}px;height:${height}px;left:calc(${leftPct}% + 1px);width:calc(${widthPct}% - 2px);--pill-color:${evt.color}">`; 513 591 html += `<div class="cal-event-block-time">${formatTimeDisplay(evt.startTime)}\u2013${formatTimeDisplay(evt.endTime)}</div>`; 514 592 html += `<div class="cal-event-block-title">${escapeHtml(evt.title || 'Untitled')}</div>`; 515 593 html += '</div>'; ··· 527 605 html += '</div>'; // cal-day-body 528 606 html += '</div>'; // cal-day-grid 529 607 calendarGrid.innerHTML = html; 608 + 609 + // Auto-scroll to current time (or 8 AM if not today) 610 + requestAnimationFrame(() => { 611 + const body = calendarGrid.querySelector('.cal-day-body') as HTMLElement | null; 612 + if (!body) return; 613 + const now = new Date(); 614 + const scrollHour = isToday(state.currentDate) ? Math.max(now.getHours() - 1, 0) : 8; 615 + body.scrollTop = scrollHour * HOUR_HEIGHT; 616 + }); 530 617 } 531 618 532 619 // --------------------------------------------------------------------------- ··· 971 1058 } 972 1059 973 1060 init(); 1061 + 1062 + // Update now-line position every 60 seconds 1063 + setInterval(() => { 1064 + const nowLines = document.querySelectorAll('.cal-now-line'); 1065 + if (nowLines.length === 0) return; 1066 + const now = new Date(); 1067 + const nowMin = now.getHours() * 60 + now.getMinutes(); 1068 + const nowTop = (nowMin / 60) * HOUR_HEIGHT; 1069 + nowLines.forEach(line => { 1070 + (line as HTMLElement).style.top = `${nowTop}px`; 1071 + }); 1072 + }, 60_000);