mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
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:
parent
a54eb761e1
commit
bd657010f2
2 changed files with 280 additions and 50 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue