taskgenius_taskgenius-plugin/src/components/features/timeline-sidebar/TimelineSidebarView.ts
Quorafind 7116bb15ae refactor(styles): migrate from CSS to SCSS
- Convert all .css files to .scss format
- Add sass and esbuild-sass-plugin dependencies
- Configure esbuild with sassPlugin for SCSS compilation
- Update all component imports from .css to .scss
- Add _variables.scss for shared SCSS variables
- Add native-layout.scss for new layout styles
2025-12-11 14:33:41 +08:00

1459 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ItemView,
WorkspaceLeaf,
setIcon,
moment,
Component,
debounce,
ButtonComponent,
Platform,
TFile,
} from "obsidian";
import { Task } from "@/types/task";
import { TimeComponent } from "@/types/time-parsing";
import { t } from "@/translations/helper";
import TaskProgressBarPlugin from "@/index";
import { QuickCaptureModal } from "@/components/features/quick-capture/modals/QuickCaptureModalWithSwitch";
import {
createEmbeddableMarkdownEditor,
EmbeddableMarkdownEditor,
} from "../../../editor-extensions/core/markdown-editor";
import { saveCapture } from "@/utils/file/file-operations";
import "@/styles/timeline-sidebar.scss";
import { createTaskCheckbox } from "@/components/features/task/view/details";
import { MarkdownRendererComponent } from "@/components/ui/renderers/MarkdownRenderer";
export const TIMELINE_SIDEBAR_VIEW_TYPE = "tg-timeline-sidebar-view";
// Date type priority for deduplication (higher number = higher priority)
const DATE_TYPE_PRIORITY = {
due: 4,
scheduled: 3,
start: 2,
completed: 1,
} as const;
interface TimelineEvent {
id: string;
content: string;
time: Date;
type: "task" | "event";
status?: string;
task?: Task;
isToday?: boolean;
}
/**
* Enhanced TimelineEvent interface with time component support
*/
interface EnhancedTimelineEvent extends TimelineEvent {
/** Enhanced time information */
timeInfo?: {
/** Primary time for display and sorting */
primaryTime: Date;
/** End time for ranges */
endTime?: Date;
/** Whether this is a time range */
isRange: boolean;
/** Original time component from parsing */
timeComponent?: TimeComponent;
/** Display format preference */
displayFormat: "time-only" | "date-time" | "range";
};
}
export class TimelineSidebarView extends ItemView {
private plugin: TaskProgressBarPlugin;
public containerEl: HTMLElement;
private timelineContainerEl: HTMLElement;
private quickInputContainerEl: HTMLElement;
private markdownEditor: EmbeddableMarkdownEditor | null = null;
private currentDate: moment.Moment = moment();
private events: EnhancedTimelineEvent[] = [];
private isAutoScrolling: boolean = false;
// Collapse state management
private isInputCollapsed: boolean = false;
private tempEditorContent: string = "";
private isAnimating: boolean = false;
private collapsedHeaderEl: HTMLElement | null = null;
private quickInputHeaderEl: HTMLElement | null = null;
// Debounced methods
private debouncedRender = debounce(async () => {
await this.loadEvents();
this.renderTimeline();
}, 300);
private debouncedScroll = debounce(this.handleScroll.bind(this), 100);
constructor(leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return TIMELINE_SIDEBAR_VIEW_TYPE;
}
getDisplayText(): string {
return t("Timeline");
}
getIcon(): string {
return "calendar-clock";
}
async onOpen(): Promise<void> {
this.containerEl = this.contentEl;
this.containerEl.empty();
this.containerEl.addClass("timeline-sidebar-container");
// Restore collapsed state from settings
this.isInputCollapsed = this.plugin.settings.timelineSidebar.quickInputCollapsed;
this.createHeader();
this.createTimelineArea();
this.createQuickInputArea();
// Load initial data
await this.loadEvents();
this.renderTimeline();
// Auto-scroll to today on open
setTimeout(() => {
this.scrollToToday();
}, 100);
// Register for task updates
this.registerEvent(
this.plugin.app.vault.on("modify", () => {
this.debouncedRender();
})
);
// Register for task cache updates
this.registerEvent(
this.plugin.app.workspace.on(
"task-genius:task-cache-updated",
() => {
this.debouncedRender();
}
)
);
}
onClose(): Promise<void> {
if (this.markdownEditor) {
this.markdownEditor.destroy();
this.markdownEditor = null;
}
return Promise.resolve();
}
private createHeader(): void {
const headerEl = this.containerEl.createDiv("timeline-header");
// Title
const titleEl = headerEl.createDiv("timeline-title");
titleEl.setText(t("Timeline"));
// Controls
const controlsEl = headerEl.createDiv("timeline-controls");
// Today button
const todayBtn = controlsEl.createDiv(
"timeline-btn timeline-today-btn"
);
setIcon(todayBtn, "calendar");
todayBtn.setAttribute("aria-label", t("Go to today"));
this.registerDomEvent(todayBtn, "click", () => {
this.scrollToToday();
});
// Refresh button
const refreshBtn = controlsEl.createDiv(
"timeline-btn timeline-refresh-btn"
);
setIcon(refreshBtn, "refresh-cw");
refreshBtn.setAttribute("aria-label", t("Refresh"));
this.registerDomEvent(refreshBtn, "click", () => {
this.loadEvents();
this.renderTimeline();
});
// Focus mode toggle
const focusBtn = controlsEl.createDiv(
"timeline-btn timeline-focus-btn"
);
setIcon(focusBtn, "focus");
focusBtn.setAttribute("aria-label", t("Focus on today"));
this.registerDomEvent(focusBtn, "click", () => {
this.toggleFocusMode();
});
}
private createTimelineArea(): void {
this.timelineContainerEl =
this.containerEl.createDiv("timeline-content");
// Add scroll listener for infinite scroll
this.registerDomEvent(this.timelineContainerEl, "scroll", () => {
this.debouncedScroll();
});
}
private createQuickInputArea(): void {
this.quickInputContainerEl = this.containerEl.createDiv(
"timeline-quick-input"
);
// Create collapsed header (always exists but hidden when expanded)
this.collapsedHeaderEl = this.quickInputContainerEl.createDiv(
"quick-input-header-collapsed"
);
this.createCollapsedHeader();
// Input header with target info
this.quickInputHeaderEl =
this.quickInputContainerEl.createDiv("quick-input-header");
// Add collapse button to header
const headerLeft = this.quickInputHeaderEl.createDiv("quick-input-header-left");
const collapseBtn = headerLeft.createDiv("quick-input-collapse-btn");
setIcon(collapseBtn, "chevron-down");
collapseBtn.setAttribute("aria-label", t("Collapse quick input"));
this.registerDomEvent(collapseBtn, "click", () => {
this.toggleInputCollapse();
});
const headerTitle = headerLeft.createDiv("quick-input-title");
headerTitle.setText(t("Quick Capture"));
const targetInfo = this.quickInputHeaderEl.createDiv("quick-input-target-info");
this.updateTargetInfo(targetInfo);
// Editor container
const editorContainer =
this.quickInputContainerEl.createDiv("quick-input-editor");
// Initialize markdown editor
setTimeout(() => {
this.markdownEditor = createEmbeddableMarkdownEditor(
this.app,
editorContainer,
{
placeholder: t("What do you want to do today?"),
onEnter: (editor, mod, shift) => {
if (mod) {
// Submit on Cmd/Ctrl+Enter
this.handleQuickCapture();
return true;
}
return false;
},
onEscape: () => {
// Clear input on Escape
if (this.markdownEditor) {
this.markdownEditor.set("", false);
}
},
onChange: () => {
// Auto-resize or other behaviors
},
}
);
// Focus the editor if not collapsed
if (!this.isInputCollapsed) {
this.markdownEditor?.editor?.focus();
}
}, 50);
// Action buttons
const actionsEl = this.quickInputContainerEl.createDiv(
"quick-input-actions"
);
const captureBtn = actionsEl.createEl("button", {
cls: "quick-capture-btn mod-cta",
text: t("Capture"),
});
this.registerDomEvent(captureBtn, "click", () => {
this.handleQuickCapture();
});
const fullModalBtn = actionsEl.createEl("button", {
cls: "quick-modal-btn",
text: t("More options"),
});
this.registerDomEvent(fullModalBtn, "click", () => {
new QuickCaptureModal(this.app, this.plugin, {}, true).open();
});
// Apply initial collapsed state
if (this.isInputCollapsed) {
this.quickInputContainerEl.addClass("is-collapsed");
this.collapsedHeaderEl?.show();
} else {
this.collapsedHeaderEl?.hide();
}
}
private async loadEvents(): Promise<void> {
// Get tasks from the plugin's dataflow
let allTasks: Task[] = [];
if (this.plugin.dataflowOrchestrator) {
try {
allTasks = await this.plugin.dataflowOrchestrator.getQueryAPI().getAllTasks();
} catch (error) {
console.error("Failed to get tasks from dataflow:", error);
}
}
this.events = [];
// Filter tasks based on showCompletedTasks setting
const shouldShowCompletedTasks =
this.plugin.settings.timelineSidebar.showCompletedTasks;
// Get abandoned status markers to filter out
const abandonedStatuses = this.plugin.settings.taskStatuses.abandoned.split("|");
const filteredTasks = shouldShowCompletedTasks
? allTasks
: allTasks.filter((task) => {
// Filter out completed tasks AND abandoned/cancelled tasks
return !task.completed && !abandonedStatuses.includes(task.status);
});
// Filter out ICS badge events from timeline
// ICS badge events should only appear as badges in calendar views, not as individual timeline events
const timelineFilteredTasks = filteredTasks.filter((task) => {
// Check if this is an ICS task with badge showType
const isIcsTask = (task as any).source?.type === "ics";
const icsTask = isIcsTask ? (task as any) : null;
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
// Exclude ICS tasks with badge showType from timeline
return !(isIcsTask && showAsBadge);
});
// Convert tasks to timeline events
timelineFilteredTasks.forEach((task) => {
const dates = this.extractDatesFromTask(task);
dates.forEach(({date, type}) => {
const event: EnhancedTimelineEvent = {
id: `${task.id}-${type}`,
content: task.content,
time: date,
type: "task",
status: task.status,
task: task,
isToday: moment(date).isSame(moment(), "day"),
timeInfo: this.createTimeInfoFromTask(task, date, type),
};
this.events.push(event);
});
});
// Sort events by time (newest first for timeline display)
this.events.sort((a, b) => b.time.getTime() - a.time.getTime());
}
/**
* Deduplicates dates by priority when multiple date types fall on the same day
* @param dates Array of date objects with type information
* @returns Deduplicated array with highest priority date per day
*/
private deduplicateDatesByPriority(
dates: Array<{ date: Date; type: string }>
): Array<{ date: Date; type: string }> {
if (dates.length <= 1) {
return dates;
}
// Group dates by day (YYYY-MM-DD format)
const dateGroups = new Map<
string,
Array<{ date: Date; type: string }>
>();
dates.forEach((dateItem) => {
const dateKey = moment(dateItem.date).format("YYYY-MM-DD");
if (!dateGroups.has(dateKey)) {
dateGroups.set(dateKey, []);
}
dateGroups.get(dateKey)!.push(dateItem);
});
// For each day, keep only the highest priority date type
const deduplicatedDates: Array<{ date: Date; type: string }> = [];
dateGroups.forEach((dayDates) => {
if (dayDates.length === 1) {
// Only one date for this day, keep it
deduplicatedDates.push(dayDates[0]);
} else {
// Multiple dates for same day, find highest priority
const highestPriorityDate = dayDates.reduce(
(highest, current) => {
const currentPriority =
DATE_TYPE_PRIORITY[
current.type as keyof typeof DATE_TYPE_PRIORITY
] || 0;
const highestPriority =
DATE_TYPE_PRIORITY[
highest.type as keyof typeof DATE_TYPE_PRIORITY
] || 0;
return currentPriority > highestPriority
? current
: highest;
}
);
deduplicatedDates.push(highestPriorityDate);
}
});
return deduplicatedDates;
}
/**
* Create time information from task metadata and enhanced time components
*/
private createTimeInfoFromTask(
task: Task,
date: Date,
type: string
): EnhancedTimelineEvent["timeInfo"] {
// Check if task has enhanced metadata with time components
const enhancedMetadata = task.metadata as any;
const timeComponents = enhancedMetadata?.timeComponents;
const enhancedDates = enhancedMetadata?.enhancedDates;
if (!timeComponents) {
// No time components available, use default time display
return {
primaryTime: date,
isRange: false,
displayFormat: "date-time",
};
}
// Determine which time component to use based on the date type
let relevantTimeComponent: TimeComponent | undefined;
let relevantEndTime: Date | undefined;
switch (type) {
case "start":
relevantTimeComponent = timeComponents.startTime;
if (timeComponents.endTime && enhancedDates?.endDateTime) {
relevantEndTime = enhancedDates.endDateTime;
}
break;
case "due":
relevantTimeComponent = timeComponents.dueTime;
break;
case "scheduled":
relevantTimeComponent = timeComponents.scheduledTime;
break;
default:
relevantTimeComponent = undefined;
}
// If no specific time component found for this date type, try to use any available time component
if (!relevantTimeComponent) {
// Priority order: startTime > dueTime > scheduledTime
if (timeComponents.startTime) {
relevantTimeComponent = timeComponents.startTime;
// If we have both start and end time, treat it as a range
if (timeComponents.endTime && enhancedDates?.endDateTime) {
relevantEndTime = enhancedDates.endDateTime;
}
} else if (timeComponents.dueTime) {
relevantTimeComponent = timeComponents.dueTime;
} else if (timeComponents.scheduledTime) {
relevantTimeComponent = timeComponents.scheduledTime;
}
}
if (!relevantTimeComponent) {
// No time components available at all
return {
primaryTime: date,
isRange: false,
displayFormat: "date-time",
};
}
// Create enhanced datetime by combining date and time component
// Use local time (setHours) instead of UTC to match the parsed time components
const enhancedDateTime = new Date(date);
enhancedDateTime.setHours(
relevantTimeComponent.hour,
relevantTimeComponent.minute,
relevantTimeComponent.second || 0,
0
);
// Determine if this is a time range
// Check if the time component is marked as a range OR if we have an explicit end time
const isRange = relevantTimeComponent.isRange || !!relevantEndTime;
// If the time component is a range but we don't have enhancedDates.endDateTime,
// create the end time from the range partner
if (relevantTimeComponent.isRange && !relevantEndTime && relevantTimeComponent.rangePartner) {
const endDateTime = new Date(date);
// Use local time (setHours) instead of UTC to match the parsed time components
endDateTime.setHours(
relevantTimeComponent.rangePartner.hour,
relevantTimeComponent.rangePartner.minute,
relevantTimeComponent.rangePartner.second || 0,
0
);
relevantEndTime = endDateTime;
}
return {
primaryTime: enhancedDateTime,
endTime: relevantEndTime,
isRange,
timeComponent: relevantTimeComponent,
displayFormat: isRange ? "range" : "time-only",
};
}
private extractDatesFromTask(
task: Task
): Array<{ date: Date; type: string }> {
// Task-level deduplication: ensure each task appears only once in timeline
// Check if task has enhanced metadata with time components
const enhancedMetadata = task.metadata as any;
const timeComponents = enhancedMetadata?.timeComponents;
const enhancedDates = enhancedMetadata?.enhancedDates;
// For completed tasks: prioritize due date, fallback to completed date
if (task.completed) {
if (task.metadata.dueDate) {
// Use enhanced due datetime if available, otherwise use original timestamp
const dueDate = enhancedDates?.dueDateTime || new Date(task.metadata.dueDate);
return [{date: dueDate, type: "due"}];
} else if (task.metadata.completedDate) {
return [{date: new Date(task.metadata.completedDate), type: "completed"}];
}
}
// For non-completed tasks: select single highest priority date with enhanced datetime support
const dates: Array<{ date: Date; type: string }> = [];
if (task.metadata.dueDate) {
// Use enhanced due datetime if available
const dueDate = enhancedDates?.dueDateTime || new Date(task.metadata.dueDate);
dates.push({date: dueDate, type: "due"});
}
if (task.metadata.scheduledDate) {
// Use enhanced scheduled datetime if available
const scheduledDate = enhancedDates?.scheduledDateTime || new Date(task.metadata.scheduledDate);
dates.push({
date: scheduledDate,
type: "scheduled",
});
}
if (task.metadata.startDate) {
// Use enhanced start datetime if available
const startDate = enhancedDates?.startDateTime || new Date(task.metadata.startDate);
dates.push({
date: startDate,
type: "start",
});
}
// For non-completed tasks, select the highest priority date
if (dates.length > 0) {
const highestPriorityDate = dates.reduce((highest, current) => {
const currentPriority = DATE_TYPE_PRIORITY[current.type as keyof typeof DATE_TYPE_PRIORITY] || 0;
const highestPriority = DATE_TYPE_PRIORITY[highest.type as keyof typeof DATE_TYPE_PRIORITY] || 0;
return currentPriority > highestPriority ? current : highest;
});
return [highestPriorityDate];
}
// Fallback: if no planning dates exist, use deduplication for edge cases
const allDates: Array<{ date: Date; type: string }> = [];
if (task.metadata.completedDate) {
allDates.push({
date: new Date(task.metadata.completedDate),
type: "completed",
});
}
return this.deduplicateDatesByPriority(allDates);
}
private renderTimeline(): void {
this.timelineContainerEl.empty();
if (this.events.length === 0) {
const emptyEl =
this.timelineContainerEl.createDiv("timeline-empty");
emptyEl.setText(t("No events to display"));
return;
}
// Group events by date
const eventsByDate = this.groupEventsByDate();
// Render each date group
for (const [dateStr, dayEvents] of eventsByDate) {
this.renderDateGroup(dateStr, dayEvents);
}
}
private groupEventsByDate(): Map<string, EnhancedTimelineEvent[]> {
const grouped = new Map<string, EnhancedTimelineEvent[]>();
this.events.forEach((event) => {
const dateKey = moment(event.time).format("YYYY-MM-DD");
if (!grouped.has(dateKey)) {
grouped.set(dateKey, []);
}
grouped.get(dateKey)!.push(event);
});
return grouped;
}
private renderDateGroup(dateStr: string, events: EnhancedTimelineEvent[]): void {
const dateGroupEl = this.timelineContainerEl.createDiv(
"timeline-date-group"
);
const dateMoment = moment(dateStr);
const isToday = dateMoment.isSame(moment(), "day");
const isYesterday = dateMoment.isSame(
moment().subtract(1, "day"),
"day"
);
const isTomorrow = dateMoment.isSame(moment().add(1, "day"), "day");
if (isToday) {
dateGroupEl.addClass("is-today");
}
// Date header
const dateHeaderEl = dateGroupEl.createDiv("timeline-date-header");
let displayDate = dateMoment.format("MMM DD, YYYY");
if (isToday) {
displayDate = t("Today");
} else if (isYesterday) {
displayDate = t("Yesterday");
} else if (isTomorrow) {
displayDate = t("Tomorrow");
}
dateHeaderEl.setText(displayDate);
// Add relative time
const relativeEl = dateHeaderEl.createSpan("timeline-date-relative");
if (!isToday && !isYesterday && !isTomorrow) {
relativeEl.setText(dateMoment.fromNow());
}
// Events list
const eventsListEl = dateGroupEl.createDiv("timeline-events-list");
// Sort events by time within the day for chronological ordering
const sortedEvents = this.sortEventsByTime(events);
// Group events by time and render them
this.renderGroupedEvents(eventsListEl, sortedEvents);
}
/**
* Render time information for a timeline event
*/
private renderEventTime(timeEl: HTMLElement, event: EnhancedTimelineEvent): void {
if (event.timeInfo?.timeComponent) {
// Use parsed time component for accurate display
const {timeComponent, isRange, endTime} = event.timeInfo;
if (isRange && endTime) {
// Display time range
const startTimeStr = this.formatTimeComponent(timeComponent);
const endTimeStr = moment(endTime).format("HH:mm");
timeEl.setText(`${startTimeStr}-${endTimeStr}`);
timeEl.addClass("timeline-event-time-range");
// Add duration badge attribute for CSS ::after to render
try {
const start = event.timeInfo?.primaryTime;
if (start && endTime.getTime() > start.getTime()) {
const minutes = Math.round((endTime.getTime() - start.getTime()) / 60000);
const duration = minutes >= 60
? `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${minutes % 60}m` : ''}`
: `${minutes}m`;
timeEl.setAttribute("data-duration", duration);
}
} catch (_) {
}
} else {
// Display single time
timeEl.setText(this.formatTimeComponent(timeComponent));
timeEl.addClass("timeline-event-time-single");
}
} else {
// Try to parse time directly from content as a fallback to avoid 00:00 mismatches
const content = event.content || "";
// Detect time range first (e.g., 15:00-16:00)
const rangeRegex = /([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?\s*[-~]\s*([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?/;
const rangeMatch = content.match(rangeRegex);
if (rangeMatch) {
const start = `${rangeMatch[1].padStart(2, '0')}:${rangeMatch[2]}${rangeMatch[3] ? `:${rangeMatch[3]}` : ''}`;
const end = `${rangeMatch[4].padStart(2, '0')}:${rangeMatch[5]}${rangeMatch[6] ? `:${rangeMatch[6]}` : ''}`;
timeEl.setText(`${start}-${end}`);
timeEl.addClass("timeline-event-time-range");
return;
}
// Detect 12-hour format (e.g., 3:30 PM)
const pattern12h = /(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s*(AM|PM|am|pm)/;
const m12 = content.match(pattern12h);
if (m12) {
let hour = parseInt(m12[1], 10);
const minute = m12[2];
const second = m12[3];
const period = m12[4].toUpperCase();
if (period === 'PM' && hour !== 12) hour += 12;
if (period === 'AM' && hour === 12) hour = 0;
const display = `${hour.toString().padStart(2, '0')}:${minute}${second ? `:${second}` : ''}`;
timeEl.setText(display);
timeEl.addClass("timeline-event-time-single");
return;
}
// Detect 24-hour single time (e.g., 15:00)
const pattern24h = /([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?/;
const m24 = content.match(pattern24h);
if (m24) {
const display = `${m24[1].padStart(2, '0')}:${m24[2]}${m24[3] ? `:${m24[3]}` : ''}`;
timeEl.setText(display);
timeEl.addClass("timeline-event-time-single");
return;
}
// Fallback to default time display - prefer enhanced primaryTime when available
const fallbackTime = event.timeInfo?.primaryTime || event.time;
timeEl.setText(moment(fallbackTime).format("HH:mm"));
timeEl.addClass("timeline-event-time-default");
}
}
/**
* Format a time component for display
*/
private formatTimeComponent(timeComponent: TimeComponent): string {
const hour = timeComponent.hour.toString().padStart(2, '0');
const minute = timeComponent.minute.toString().padStart(2, '0');
if (timeComponent.second !== undefined) {
const second = timeComponent.second.toString().padStart(2, '0');
return `${hour}:${minute}:${second}`;
}
return `${hour}:${minute}`;
}
/**
* Sort events by time within a day for chronological ordering
*/
private sortEventsByTime(events: EnhancedTimelineEvent[]): EnhancedTimelineEvent[] {
return events.sort((a, b) => {
// Get the primary time for sorting - use enhanced time if available
const timeA = a.timeInfo?.primaryTime || a.time;
const timeB = b.timeInfo?.primaryTime || b.time;
// Sort by time of day (earlier times first)
const timeComparison = timeA.getTime() - timeB.getTime();
if (timeComparison !== 0) {
return timeComparison;
}
// If times are equal, sort by task content for consistent ordering
return a.content.localeCompare(b.content);
});
}
/**
* Render events grouped by time, separating timed events from date-only events
*/
private renderGroupedEvents(containerEl: HTMLElement, events: EnhancedTimelineEvent[]): void {
// Separate events into timed and date-only categories
const timedEvents: EnhancedTimelineEvent[] = [];
const dateOnlyEvents: EnhancedTimelineEvent[] = [];
events.forEach((event) => {
if (this.hasSpecificTime(event)) {
timedEvents.push(event);
} else {
dateOnlyEvents.push(event);
}
});
// Render timed events first, grouped by time
if (timedEvents.length > 0) {
this.renderTimedEventsWithGrouping(containerEl, timedEvents);
}
// Render date-only events in a separate section
if (dateOnlyEvents.length > 0) {
this.renderDateOnlyEvents(containerEl, dateOnlyEvents);
}
}
/**
* Check if an event has a specific time (not just a date)
*/
private hasSpecificTime(event: EnhancedTimelineEvent): boolean {
// Check if the event has enhanced time information
if (event.timeInfo?.timeComponent) {
return true;
}
// Heuristic: detect explicit time patterns in the content (e.g., "15:00", "3:30 PM")
if (event.content) {
const timePattern24h = /(^|[^0-9])([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?/;
const timePattern12h = /(^|\s)(1[0-2]|0?[1-9]):([0-5]\d)(?::([0-5]\d))?\s*(AM|PM|am|pm)/;
if (timePattern24h.test(event.content) || timePattern12h.test(event.content)) {
return true;
}
}
// Check if the original time has non-zero hours/minutes (not just midnight)
// Use local time (getHours) to check for specific time
const time = event.timeInfo?.primaryTime || event.time;
return time.getHours() !== 0 || time.getMinutes() !== 0 || time.getSeconds() !== 0;
}
/**
* Render timed events with grouping for events at the same time
*/
private renderTimedEventsWithGrouping(containerEl: HTMLElement, events: EnhancedTimelineEvent[]): void {
// Group events by their time
const timeGroups = new Map<string, EnhancedTimelineEvent[]>();
events.forEach((event) => {
const time = event.timeInfo?.primaryTime || event.time;
const timeKey = this.getTimeGroupKey(time, event);
if (!timeGroups.has(timeKey)) {
timeGroups.set(timeKey, []);
}
timeGroups.get(timeKey)!.push(event);
});
// Render each time group
for (const [timeKey, groupEvents] of timeGroups) {
if (groupEvents.length === 1) {
// Single event - render normally
this.renderEvent(containerEl, groupEvents[0]);
} else {
// Multiple events at same time - render as a group
this.renderTimeGroup(containerEl, timeKey, groupEvents);
}
}
}
/**
* Generate a time group key for grouping events
*/
private getTimeGroupKey(time: Date, event: EnhancedTimelineEvent): string {
if (event.timeInfo?.timeComponent) {
// Use the formatted time component for precise grouping
return this.formatTimeComponent(event.timeInfo.timeComponent);
}
// Fallback to hour:minute format
return moment(time).format("HH:mm");
}
/**
* Render a group of events that occur at the same time
*/
private renderTimeGroup(containerEl: HTMLElement, timeKey: string, events: EnhancedTimelineEvent[]): void {
const groupEl = containerEl.createDiv("timeline-time-group");
// Time group header
const groupHeaderEl = groupEl.createDiv("timeline-time-group-header");
const timeEl = groupHeaderEl.createDiv("timeline-time-group-time");
timeEl.setText(timeKey);
timeEl.addClass("timeline-event-time");
timeEl.addClass("timeline-event-time-group");
const countEl = groupHeaderEl.createDiv("timeline-time-group-count");
countEl.setText(`${events.length} events`);
// Events in the group
const groupEventsEl = groupEl.createDiv("timeline-time-group-events");
events.forEach((event) => {
const eventEl = groupEventsEl.createDiv("timeline-event timeline-event-grouped");
eventEl.setAttribute("data-event-id", event.id);
if (event.task?.completed) {
eventEl.addClass("is-completed");
}
// Event content (no time display since it's in the group header)
const contentEl = eventEl.createDiv("timeline-event-content");
// Task checkbox if it's a task
if (event.task) {
const checkboxEl = contentEl.createDiv("timeline-event-checkbox");
checkboxEl.createEl(
"span",
{
cls: "status-option-checkbox",
},
(el) => {
const checkbox = createTaskCheckbox(
event.task?.status || " ",
event.task!,
el
);
this.registerDomEvent(checkbox, "change", async (e) => {
e.stopPropagation();
e.preventDefault();
if (event.task) {
await this.toggleTaskCompletion(event.task, event);
}
});
}
);
}
// Event text with markdown rendering
const textEl = contentEl.createDiv("timeline-event-text");
const contentContainer = textEl.createDiv("timeline-event-content-text");
// Use MarkdownRendererComponent to render the task content
if (event.task) {
const markdownRenderer = new MarkdownRendererComponent(
this.app,
contentContainer,
event.task.filePath,
true // hideMarks = true to clean up task metadata
);
this.addChild(markdownRenderer);
// Set the file context if available
const file = this.app.vault.getFileByPath(event.task.filePath);
if (file instanceof TFile) {
markdownRenderer.setFile(file);
}
// Render the content asynchronously
markdownRenderer.render(event.content, true).catch((error) => {
console.error("Failed to render markdown in timeline:", error);
// Fallback to plain text if rendering fails
contentContainer.setText(event.content);
});
} else {
// Fallback for non-task events
contentContainer.setText(event.content);
}
// Event actions
const actionsEl = eventEl.createDiv("timeline-event-actions");
if (event.task) {
// Go to task
const gotoBtn = actionsEl.createDiv("timeline-event-action");
setIcon(gotoBtn, "external-link");
gotoBtn.setAttribute("aria-label", t("Go to task"));
this.registerDomEvent(gotoBtn, "click", () => {
this.goToTask(event.task!);
});
}
// Click to focus (but not when clicking on checkbox or actions)
this.registerDomEvent(eventEl, "click", (e) => {
// Prevent navigation if clicking on checkbox or action buttons
const target = e.target as HTMLElement;
if (
target.closest(".timeline-event-checkbox") ||
target.closest(".timeline-event-actions") ||
target.closest('input[type="checkbox"]')
) {
return;
}
if (event.task) {
this.goToTask(event.task);
}
});
});
}
/**
* Render date-only events (events without specific times)
*/
private renderDateOnlyEvents(containerEl: HTMLElement, events: EnhancedTimelineEvent[]): void {
if (events.length === 0) return;
// Create a section for date-only events
const dateOnlySection = containerEl.createDiv("timeline-date-only-section");
const sectionHeaderEl = dateOnlySection.createDiv("timeline-date-only-header");
const headerTimeEl = sectionHeaderEl.createDiv("timeline-event-time timeline-event-time-date-only");
headerTimeEl.setText("All day");
const headerTextEl = sectionHeaderEl.createDiv("timeline-date-only-title");
headerTextEl.setText(`${events.length} all-day event${events.length > 1 ? 's' : ''}`);
// Render each date-only event (hide individual time labels)
events.forEach((event) => {
this.renderEvent(dateOnlySection, event, false);
});
}
private renderEvent(containerEl: HTMLElement, event: EnhancedTimelineEvent, showTime: boolean = true): void {
const eventEl = containerEl.createDiv("timeline-event");
eventEl.setAttribute("data-event-id", event.id);
if (event.task?.completed) {
eventEl.addClass("is-completed");
}
// Event time - use enhanced time information if available
if (showTime) {
const timeEl = eventEl.createDiv("timeline-event-time");
this.renderEventTime(timeEl, event);
}
// Event content
const contentEl = eventEl.createDiv("timeline-event-content");
// Task checkbox if it's a task
if (event.task) {
const checkboxEl = contentEl.createDiv("timeline-event-checkbox");
checkboxEl.createEl(
"span",
{
cls: "status-option-checkbox",
},
(el) => {
const checkbox = createTaskCheckbox(
event.task?.status || " ",
event.task!,
el
);
this.registerDomEvent(checkbox, "change", async (e) => {
e.stopPropagation();
e.preventDefault();
if (event.task) {
await this.toggleTaskCompletion(event.task, event);
}
});
}
);
}
// Event text with markdown rendering
const textEl = contentEl.createDiv("timeline-event-text");
const contentContainer = textEl.createDiv(
"timeline-event-content-text"
);
// Use MarkdownRendererComponent to render the task content
if (event.task) {
const markdownRenderer = new MarkdownRendererComponent(
this.app,
contentContainer,
event.task.filePath,
true // hideMarks = true to clean up task metadata
);
this.addChild(markdownRenderer);
// Set the file context if available
const file = this.app.vault.getFileByPath(event.task.filePath);
if (file instanceof TFile) {
markdownRenderer.setFile(file);
}
// Render the content asynchronously
markdownRenderer.render(event.content, true).catch((error) => {
console.error("Failed to render markdown in timeline:", error);
// Fallback to plain text if rendering fails
contentContainer.setText(event.content);
});
} else {
// Fallback for non-task events
contentContainer.setText(event.content);
}
// Event actions
const actionsEl = eventEl.createDiv("timeline-event-actions");
if (event.task) {
// Go to task
const gotoBtn = actionsEl.createDiv("timeline-event-action");
setIcon(gotoBtn, "external-link");
gotoBtn.setAttribute("aria-label", t("Go to task"));
this.registerDomEvent(gotoBtn, "click", () => {
this.goToTask(event.task!);
});
}
// Click to focus (but not when clicking on checkbox or actions)
this.registerDomEvent(eventEl, "click", (e) => {
// Prevent navigation if clicking on checkbox or action buttons
const target = e.target as HTMLElement;
if (
target.closest(".timeline-event-checkbox") ||
target.closest(".timeline-event-actions") ||
target.closest('input[type="checkbox"]')
) {
return;
}
if (event.task) {
this.goToTask(event.task);
}
});
}
private async goToTask(task: Task): Promise<void> {
const file = this.app.vault.getFileByPath(task.filePath);
if (!file) return;
// Check if it's a canvas file
if ((task.metadata as any).sourceType === "canvas") {
// For canvas files, open directly
const leaf = this.app.workspace.getLeaf("tab");
await leaf.openFile(file);
this.app.workspace.setActiveLeaf(leaf, {focus: true});
return;
}
// For markdown files, prefer activating existing leaf if file is open
const existingLeaf = this.app.workspace
.getLeavesOfType("markdown")
.find(
(leaf) => (leaf.view as any).file === file // Type assertion needed here
);
const leafToUse = existingLeaf || this.app.workspace.getLeaf("tab"); // Open in new tab if not open
await leafToUse.openFile(file, {
active: true, // Ensure the leaf becomes active
eState: {
line: task.line,
},
});
// Focus the editor after opening
this.app.workspace.setActiveLeaf(leafToUse, {focus: true});
}
private async handleQuickCapture(): Promise<void> {
if (!this.markdownEditor) return;
const content = this.markdownEditor.value.trim();
if (!content) return;
try {
// Use the plugin's quick capture settings
const captureOptions = this.plugin.settings.quickCapture;
await saveCapture(this.app, content, captureOptions);
// Clear the input
this.markdownEditor.set("", false);
// Refresh timeline
await this.loadEvents();
this.renderTimeline();
// Check if we should collapse after capture
if (this.plugin.settings.timelineSidebar.quickInputCollapseOnCapture) {
this.toggleInputCollapse();
} else {
// Focus back to input
this.markdownEditor.editor?.focus();
}
} catch (error) {
console.error("Failed to capture:", error);
}
}
private scrollToToday(): void {
const todayEl = this.timelineContainerEl.querySelector(
".timeline-date-group.is-today"
);
if (todayEl) {
this.isAutoScrolling = true;
todayEl.scrollIntoView({behavior: "smooth", block: "start"});
setTimeout(() => {
this.isAutoScrolling = false;
}, 1000);
}
}
private toggleFocusMode(): void {
this.timelineContainerEl.toggleClass(
"focus-mode",
!this.timelineContainerEl.hasClass("focus-mode")
);
// In focus mode, only show today's events
// Implementation depends on specific requirements
}
private handleScroll(): void {
if (this.isAutoScrolling) return;
// Implement infinite scroll or lazy loading if needed
const {scrollTop, scrollHeight, clientHeight} =
this.timelineContainerEl;
// Load more events when near bottom
if (scrollTop + clientHeight >= scrollHeight - 100) {
// Load more historical events
this.loadMoreEvents();
}
}
private async loadMoreEvents(): Promise<void> {
// Implement loading more historical events
// This could involve loading older tasks or extending the date range
}
private async toggleTaskCompletion(
task: Task,
event?: EnhancedTimelineEvent
): Promise<void> {
const updatedTask = {...task, completed: !task.completed};
if (updatedTask.completed) {
updatedTask.metadata.completedDate = Date.now();
const completedMark = (
this.plugin.settings.taskStatuses.completed || "x"
).split("|")[0];
if (updatedTask.status !== completedMark) {
updatedTask.status = completedMark;
}
} else {
updatedTask.metadata.completedDate = undefined;
const notStartedMark =
this.plugin.settings.taskStatuses.notStarted || " ";
if (updatedTask.status.toLowerCase() === "x") {
updatedTask.status = notStartedMark;
}
}
if (!this.plugin.writeAPI) {
console.error("WriteAPI not available");
return;
}
try {
const result = await this.plugin.writeAPI.updateTask({
taskId: task.id,
updates: updatedTask,
});
if (!result.success) {
console.error("Failed to toggle task completion:", result.error);
return;
}
// Update the local event data immediately for responsive UI
if (event) {
event.task = updatedTask;
event.status = updatedTask.status;
// Update the event element's visual state immediately
const eventEl = this.timelineContainerEl.querySelector(
`[data-event-id="${event.id}"]`
) as HTMLElement;
if (eventEl) {
if (updatedTask.completed) {
eventEl.addClass("is-completed");
} else {
eventEl.removeClass("is-completed");
}
}
}
// Reload events to ensure consistency
await this.loadEvents();
this.renderTimeline();
} catch (error) {
console.error("Failed to toggle task completion:", error);
// Revert local changes if the update failed
if (event) {
event.task = task;
event.status = task.status;
}
}
}
private updateTargetInfo(targetInfoEl: HTMLElement): void {
targetInfoEl.empty();
const settings = this.plugin.settings.quickCapture;
let targetText = "";
if (settings.targetType === "daily-note") {
const dateStr = moment().format(settings.dailyNoteSettings.format);
const fileName = `${dateStr}.md`;
const fullPath = settings.dailyNoteSettings.folder
? `${settings.dailyNoteSettings.folder}/${fileName}`
: fileName;
targetText = `${t("to")} ${fullPath}`;
} else {
targetText = `${t("to")} ${
settings.targetFile || "Quick Capture.md"
}`;
}
if (settings.targetHeading) {
targetText += `${settings.targetHeading}`;
}
targetInfoEl.setText(targetText);
targetInfoEl.setAttribute("title", targetText);
}
// Method to trigger view update (called when settings change)
public async triggerViewUpdate(): Promise<void> {
await this.loadEvents();
this.renderTimeline();
}
// Method to refresh timeline data
public async refreshTimeline(): Promise<void> {
await this.loadEvents();
this.renderTimeline();
}
// Create collapsed header content
private createCollapsedHeader(): void {
if (!this.collapsedHeaderEl) return;
// Expand button
const expandBtn = this.collapsedHeaderEl.createDiv("collapsed-expand-btn");
setIcon(expandBtn, "chevron-right");
expandBtn.setAttribute("aria-label", t("Expand quick input"));
this.registerDomEvent(expandBtn, "click", () => {
this.toggleInputCollapse();
});
// Title
const titleEl = this.collapsedHeaderEl.createDiv("collapsed-title");
titleEl.setText(t("Quick Capture"));
// Quick actions
if (this.plugin.settings.timelineSidebar.quickInputShowQuickActions) {
const quickActionsEl = this.collapsedHeaderEl.createDiv("collapsed-quick-actions");
// Quick capture button
const quickCaptureBtn = quickActionsEl.createDiv("collapsed-quick-capture");
setIcon(quickCaptureBtn, "plus");
quickCaptureBtn.setAttribute("aria-label", t("Quick capture"));
this.registerDomEvent(quickCaptureBtn, "click", () => {
// Expand and focus editor
if (this.isInputCollapsed) {
this.toggleInputCollapse();
setTimeout(() => {
this.markdownEditor?.editor?.focus();
}, 350); // Wait for animation
}
});
// More options button
const moreOptionsBtn = quickActionsEl.createDiv("collapsed-more-options");
setIcon(moreOptionsBtn, "more-horizontal");
moreOptionsBtn.setAttribute("aria-label", t("More options"));
this.registerDomEvent(moreOptionsBtn, "click", () => {
new QuickCaptureModal(this.app, this.plugin, {}, true).open();
});
}
}
// Toggle collapse state
private toggleInputCollapse(): void {
if (this.isAnimating) return;
this.isAnimating = true;
this.isInputCollapsed = !this.isInputCollapsed;
// Save state to settings
this.plugin.settings.timelineSidebar.quickInputCollapsed = this.isInputCollapsed;
this.plugin.saveSettings();
if (this.isInputCollapsed) {
this.handleCollapseEditor();
} else {
this.handleExpandEditor();
}
// Reset animation flag after animation completes
setTimeout(() => {
this.isAnimating = false;
}, this.plugin.settings.timelineSidebar.quickInputAnimationDuration);
}
// Handle collapsing the editor
private handleCollapseEditor(): void {
// Save current editor content
if (this.markdownEditor) {
this.tempEditorContent = this.markdownEditor.value;
}
// Add collapsed class for animation
this.quickInputContainerEl.addClass("is-collapsing");
this.quickInputContainerEl.addClass("is-collapsed");
// Show collapsed header after a slight delay
setTimeout(() => {
this.collapsedHeaderEl?.show();
this.quickInputContainerEl.removeClass("is-collapsing");
}, 50);
// Update collapse button icon
const collapseBtn = this.quickInputHeaderEl?.querySelector(".quick-input-collapse-btn");
if (collapseBtn) {
setIcon(collapseBtn as HTMLElement, "chevron-right");
collapseBtn.setAttribute("aria-label", t("Expand quick input"));
}
}
// Handle expanding the editor
private handleExpandEditor(): void {
// Hide collapsed header immediately
this.collapsedHeaderEl?.hide();
// Remove collapsed class for animation
this.quickInputContainerEl.addClass("is-expanding");
this.quickInputContainerEl.removeClass("is-collapsed");
// Restore editor content
if (this.markdownEditor && this.tempEditorContent) {
this.markdownEditor.set(this.tempEditorContent, false);
this.tempEditorContent = "";
}
// Focus editor after animation
setTimeout(() => {
this.quickInputContainerEl.removeClass("is-expanding");
this.markdownEditor?.editor?.focus();
}, 50);
// Update collapse button icon
const collapseBtn = this.quickInputHeaderEl?.querySelector(".quick-input-collapse-btn");
if (collapseBtn) {
setIcon(collapseBtn as HTMLElement, "chevron-down");
collapseBtn.setAttribute("aria-label", t("Collapse quick input"));
}
}
}