diff --git a/src/views/AgendaView.ts b/src/views/AgendaView.ts index a2e1110c..b02c1c78 100644 --- a/src/views/AgendaView.ts +++ b/src/views/AgendaView.ts @@ -1,4 +1,4 @@ -import { TFile, ItemView, WorkspaceLeaf, EventRef, Setting, Notice } from 'obsidian'; +import { TFile, ItemView, WorkspaceLeaf, EventRef, Setting, Notice, setIcon } from 'obsidian'; import { format, addDays, startOfWeek, endOfWeek, isSameDay } from 'date-fns'; import { formatDateForStorage, createUTCDateFromLocalCalendarDate, getTodayLocal, isTodayUTC, convertUTCToLocalCalendarDate } from '../utils/dateUtils'; import TaskNotesPlugin from '../main'; @@ -565,69 +565,79 @@ export class AgendaView extends ItemView { * Render grouped agenda using DOMReconciler for efficient updates */ private renderGroupedAgendaWithReconciler(container: HTMLElement, agendaData: Array<{date: Date, tasks: TaskInfo[], notes: NoteInfo[], ics: import('../types').ICSEvent[]}>) { - // Create flattened list of all items with their day grouping - const allItems: Array<{type: 'day-header' | 'task' | 'note' | 'ics', item: any, date: Date, dayKey: string}> = []; - + // Clear container and create day sections + container.empty(); + let hasAnyItems = false; agendaData.forEach(dayData => { const dateStr = formatDateForStorage(dayData.date); - + // Tasks are already filtered by FilterService, no need to re-filter const hasItems = dayData.tasks.length > 0 || dayData.notes.length > 0 || (this.showICSEvents && dayData.ics.length > 0); - + if (hasItems) { hasAnyItems = true; const dayKey = dateStr; - - // Add day header - allItems.push({ - type: 'day-header', - item: dayData, - date: dayData.date, - dayKey - }); - - // Add tasks (already filtered by FilterService) + const collapsedInitially = this.isDayCollapsed(dayKey); + + // Create day section (like task groups) + const daySection = container.createDiv({ cls: 'agenda-view__day-section task-group' }); + daySection.setAttribute('data-day', dayKey); + + // Create day header + const dayHeader = this.createDayHeader(dayData, dayKey); + daySection.appendChild(dayHeader); + + // Create items container + const itemsContainer = daySection.createDiv({ cls: 'agenda-view__day-items' }); + + // Apply initial collapsed state + if (collapsedInitially) { + daySection.addClass('is-collapsed'); + itemsContainer.style.display = 'none'; + } + + // Add click handlers for collapse/expand + this.addDayHeaderClickHandlers(dayHeader, daySection, itemsContainer, dayKey); + + // Collect items for this day + const dayItems: Array<{type: 'task' | 'note' | 'ics', item: any, date: Date}> = []; + + // Add tasks dayData.tasks.forEach(task => { - allItems.push({ - type: 'task', - item: task, - date: dayData.date, - dayKey - }); + dayItems.push({ type: 'task', item: task, date: dayData.date }); }); - + // Add notes dayData.notes.forEach(note => { - allItems.push({ - type: 'note', - item: note, - date: dayData.date, - dayKey - }); + dayItems.push({ type: 'note', item: note, date: dayData.date }); }); // Add ICS events if (this.showICSEvents) { dayData.ics.forEach(ics => { - allItems.push({ - type: 'ics', - item: ics, - date: dayData.date, - dayKey - }); + dayItems.push({ type: 'ics', item: ics, date: dayData.date }); }); } + + // Use DOMReconciler for this day's items + this.plugin.domReconciler.updateList( + itemsContainer, + dayItems, + (item) => `${item.type}-${(item.item as any).path || (item.item as any).id || 'unknown'}`, + (item) => this.createDayItemElement(item), + (element, item) => this.updateDayItemElement(element, item) + ); } }); - + if (!hasAnyItems) { container.empty(); const emptyMessage = container.createDiv({ cls: 'agenda-view__empty' }); new Setting(emptyMessage) .setName('No items scheduled') .setHeading(); - emptyMessage.createEl('p', { + emptyMessage.createEl('p', { text: 'No items scheduled for this period.', cls: 'agenda-view__empty-description' }); @@ -636,15 +646,6 @@ export class AgendaView extends ItemView { tipMessage.appendChild(document.createTextNode('Create tasks with due or scheduled dates, or add notes to see them here.')); return; } - - // Use DOMReconciler to update the list - this.plugin.domReconciler.updateList( - container, - allItems, - (item) => `${item.type}-${item.dayKey}-${item.type === 'day-header' ? item.dayKey : (item.item.path || (item.item as any).id || 'unknown')}`, - (item) => this.createAgendaItemElement(item), - (element, item) => this.updateAgendaItemElement(element, item) - ); } /** @@ -704,8 +705,27 @@ export class AgendaView extends ItemView { private createAgendaItemElement(item: {type: 'day-header' | 'task' | 'note' | 'ics', item: any, date: Date, dayKey: string}): HTMLElement { if (item.type === 'day-header') { const dayHeader = document.createElement('div'); - dayHeader.className = 'agenda-view__day-header'; - + dayHeader.className = 'agenda-view__day-header task-group-header'; + dayHeader.setAttribute('data-day', item.dayKey); + + // Create toggle button first (consistent with TaskList view) + const toggleBtn = dayHeader.createEl('button', { + cls: 'task-group-toggle', + attr: { 'aria-label': 'Toggle day' } + }); + try { + setIcon(toggleBtn, 'chevron-right'); + } catch (_) {} + const svg = toggleBtn.querySelector('svg'); + if (svg) { + svg.classList.add('chevron'); + svg.setAttr('width', '16'); + svg.setAttr('height', '16'); + } else { + toggleBtn.textContent = '▸'; + toggleBtn.addClass('chevron-text'); + } + const headerText = dayHeader.createDiv({ cls: 'agenda-view__day-header-text' }); // FIX: Convert UTC-anchored date to local calendar date for proper display formatting const displayDate = convertUTCToLocalCalendarDate(item.date); @@ -735,7 +755,7 @@ export class AgendaView extends ItemView { } dayHeader.createDiv({ cls: 'agenda-view__item-count', text: countText }); - + return dayHeader; } else if (item.type === 'task') { return this.createTaskItemElement(item.item as TaskInfo, item.date); @@ -1013,6 +1033,158 @@ export class AgendaView extends ItemView { + /** + * Create day header element with chevron and click handlers + */ + private createDayHeader(dayData: {date: Date, tasks: TaskInfo[], notes: NoteInfo[], ics: import('../types').ICSEvent[]}, dayKey: string): HTMLElement { + const dayHeader = document.createElement('div'); + dayHeader.className = 'agenda-view__day-header task-group-header'; + dayHeader.setAttribute('data-day', dayKey); + + // Create toggle button first (consistent with TaskList view) + const toggleBtn = dayHeader.createEl('button', { + cls: 'task-group-toggle', + attr: { 'aria-label': 'Toggle day' } + }); + try { + setIcon(toggleBtn, 'chevron-right'); + } catch (_) {} + const svg = toggleBtn.querySelector('svg'); + if (svg) { + svg.classList.add('chevron'); + svg.setAttr('width', '16'); + svg.setAttr('height', '16'); + } else { + toggleBtn.textContent = '▸'; + toggleBtn.addClass('chevron-text'); + } + + const headerText = dayHeader.createDiv({ cls: 'agenda-view__day-header-text' }); + // FIX: Convert UTC-anchored date to local calendar date for proper display formatting + const displayDate = convertUTCToLocalCalendarDate(dayData.date); + const dayName = format(displayDate, 'EEEE'); + const dateFormatted = format(displayDate, 'MMMM d'); + + if (isTodayUTC(dayData.date)) { + headerText.createSpan({ cls: 'agenda-view__day-name agenda-view__day-name--today', text: 'Today' }); + headerText.createSpan({ cls: 'agenda-view__day-date', text: ` • ${dateFormatted}` }); + } else { + headerText.createSpan({ cls: 'agenda-view__day-name', text: dayName }); + headerText.createSpan({ cls: 'agenda-view__day-date', text: ` • ${dateFormatted}` }); + } + + // Item count badge - show completion count for tasks only + const tasks = dayData.tasks || []; + let countText: string; + + if (tasks.length > 0) { + // Show completion count for tasks + const taskStats = GroupCountUtils.calculateGroupStats(tasks, this.plugin); + countText = GroupCountUtils.formatGroupCount(taskStats.completed, taskStats.total).text; + } else { + // Show total count for other items (notes + ICS events) + const itemCount = (dayData.notes?.length || 0) + (dayData.ics?.length || 0); + countText = `${itemCount}`; + } + + dayHeader.createDiv({ cls: 'agenda-view__item-count', text: countText }); + + // Set initial ARIA state + const collapsedInitially = this.isDayCollapsed(dayKey); + toggleBtn.setAttr('aria-expanded', String(!collapsedInitially)); + + return dayHeader; + } + + /** + * Add click handlers for day header collapse/expand + */ + private addDayHeaderClickHandlers(dayHeader: HTMLElement, daySection: HTMLElement, itemsContainer: HTMLElement, dayKey: string): void { + const toggleBtn = dayHeader.querySelector('.task-group-toggle') as HTMLElement; + + // Header click handler + this.registerDomEvent(dayHeader, 'click', (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target.closest('a')) return; // Ignore link clicks + + const willCollapse = !daySection.hasClass('is-collapsed'); + this.setDayCollapsed(dayKey, willCollapse); + daySection.toggleClass('is-collapsed', willCollapse); + itemsContainer.style.display = willCollapse ? 'none' : ''; + if (toggleBtn) toggleBtn.setAttr('aria-expanded', String(!willCollapse)); + }); + + // Toggle button click handler + if (toggleBtn) { + this.registerDomEvent(toggleBtn, 'click', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const willCollapse = !daySection.hasClass('is-collapsed'); + this.setDayCollapsed(dayKey, willCollapse); + daySection.toggleClass('is-collapsed', willCollapse); + itemsContainer.style.display = willCollapse ? 'none' : ''; + toggleBtn.setAttr('aria-expanded', String(!willCollapse)); + }); + } + } + + /** + * Create day item element (task, note, or ICS event) + */ + private createDayItemElement(item: {type: 'task' | 'note' | 'ics', item: any, date: Date}): HTMLElement { + if (item.type === 'task') { + return this.createTaskItemElement(item.item as TaskInfo, item.date); + } else if (item.type === 'note') { + return this.createNoteItemElement(item.item as NoteInfo, item.date); + } else { + return this.createICSEventItemElement(item.item as import('../types').ICSEvent); + } + } + + /** + * Update day item element + */ + private updateDayItemElement(element: HTMLElement, item: {type: 'task' | 'note' | 'ics', item: any, date: Date}): void { + if (item.type === 'task') { + updateTaskCard(element, item.item as TaskInfo, this.plugin, { + showDueDate: !this.groupByDate, + showCheckbox: false, + showTimeTracking: true, + showRecurringControls: true, + groupByDate: this.groupByDate, + targetDate: item.date + }); + } else if (item.type === 'ics') { + updateICSEventCard(element, item.item as import('../types').ICSEvent, this.plugin); + } + // Note updates are handled automatically by the note card structure + } + + /** + * Check if a day is collapsed + */ + private isDayCollapsed(dayKey: string): boolean { + try { + const prefs = this.plugin.viewStateManager.getViewPreferences(AGENDA_VIEW_TYPE) || {}; + const collapsed = prefs.collapsedDays || {}; + return !!collapsed[dayKey]; + } catch { + return false; + } + } + + /** + * Set day collapsed state + */ + private setDayCollapsed(dayKey: string, collapsed: boolean): void { + const prefs = this.plugin.viewStateManager.getViewPreferences(AGENDA_VIEW_TYPE) || {}; + const next = { ...prefs }; + if (!next.collapsedDays) next.collapsedDays = {}; + next.collapsedDays[dayKey] = collapsed; + this.plugin.viewStateManager.setViewPreferences(AGENDA_VIEW_TYPE, next); + } + /** * Wait for cache to be ready with actual data */ @@ -1021,7 +1193,7 @@ export class AgendaView extends ItemView { if (this.plugin.cacheManager.isInitialized()) { return; } - + // If not initialized, wait for the cache-initialized event return new Promise((resolve) => { const unsubscribe = this.plugin.cacheManager.subscribe('cache-initialized', () => { diff --git a/styles/agenda-view.css b/styles/agenda-view.css index 8208ca2e..dbbcdbe1 100644 --- a/styles/agenda-view.css +++ b/styles/agenda-view.css @@ -266,6 +266,64 @@ margin-bottom: var(--tn-spacing-sm); border-bottom: 1px solid var(--tn-border-color); position: relative; + cursor: pointer; +} + +/* Day section container (like task groups) */ +.tasknotes-plugin .agenda-view__day-section { + margin-bottom: var(--tn-spacing-md); +} + +/* Day items container */ +.tasknotes-plugin .agenda-view__day-items { + display: flex; + flex-direction: column; + gap: var(--tn-spacing-xs); +} + +/* Collapsed state */ +.tasknotes-plugin .agenda-view__day-section.is-collapsed .agenda-view__day-items { + display: none; +} + +/* Chevron/toggle styles for agenda day headers (consistent with TaskList view) */ +.tasknotes-plugin .agenda-view__day-header .task-group-toggle { + display: contents; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-right: var(--tn-spacing-xs); + border: none; + background: transparent; + color: var(--tn-text-normal); + font-size: 14px; + line-height: 1; +} + +.tasknotes-plugin .agenda-view__day-header .task-group-toggle svg, +.tasknotes-plugin .agenda-view__day-header .task-group-toggle .chevron, +.tasknotes-plugin .agenda-view__day-header .task-group-toggle .chevron path { + color: var(--tn-text-normal); +} + +.tasknotes-plugin .agenda-view__day-header .task-group-toggle svg, +.tasknotes-plugin .agenda-view__day-header .task-group-toggle .chevron path { + stroke: currentColor; +} + +.tasknotes-plugin .agenda-view__day-header .task-group-toggle .chevron { + transition: transform var(--tn-transition-fast); +} + +.tasknotes-plugin .agenda-view__day-section .task-group-toggle .chevron, +.tasknotes-plugin .agenda-view__day-section .task-group-toggle svg { + transform: rotate(90deg); +} + +.tasknotes-plugin .agenda-view__day-section.is-collapsed .task-group-toggle svg, +.tasknotes-plugin .agenda-view__day-section.is-collapsed .task-group-toggle .chevron { + transform: rotate(0deg); } .tasknotes-plugin .agenda-view__day-header-text {