feat: add collapsible day headers to Agenda view for consistency

- Implement collapsible day headers with chevron toggle buttons
- Add click handlers for both header and chevron button clicks
- Persist collapsed state using ViewStateManager with collapsedDays preference
- Add consistent CSS styling with TaskList view chevron behavior
- Restructure grouped rendering to use day sections with item containers
- Support ARIA accessibility with aria-expanded attributes
- Maintain completion count display in collapsible headers
- Ensure consistent UX across subtask widget, TaskList, and Agenda views
This commit is contained in:
renatomen 2025-08-17 02:34:54 +00:00
parent a54eb761e1
commit bd657010f2
2 changed files with 280 additions and 50 deletions

View file

@ -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<any>(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<any>(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', () => {

View file

@ -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 {