taskgenius_taskgenius-plugin/src/components/features/calendar/index.ts

2166 lines
59 KiB
TypeScript

/**
* CalendarComponent - Enhanced calendar view using @taskgenius/calendar
*
* This component integrates @taskgenius/calendar for month/week/day views
* while preserving the original year/agenda views for compatibility.
*
* Features:
* - Month/Week/Day views powered by @taskgenius/calendar
* - Agenda/Year views using original implementation
* - Multi-day spanning tasks
* - Drag-and-drop date adjustment
* - ICS integration with badge events
* - Obsidian theme integration
*/
import {
App,
ButtonComponent,
Component,
DropdownComponent,
moment,
Notice,
} from "obsidian";
import {
Calendar,
type DateAdapter,
hideWeekends,
workingHours,
onlyDays,
hideDays,
ViewRegistry,
} from "@taskgenius/calendar";
import type {
CalendarConfig,
CalendarEvent as TGCalendarEvent,
DayFilterContext,
TimeSlotConfig,
ViewClass,
ExtendedCalendarConfig,
EventRenderContext,
} from "@taskgenius/calendar";
import * as dateFns from "date-fns";
import { addDays, startOfDay, differenceInDays, isBefore } from "date-fns";
import { Task } from "@/types/task";
import { IcsTask } from "@/types/ics";
import {
tasksToCalendarEvents,
getTaskFromEvent,
hasDateInformation,
type CalendarEvent as AdapterCalendarEvent,
} from "@/utils/adapters/TaskCalendarAdapter";
import "@taskgenius/calendar/styles.css";
import "@/styles/taskgenius-calendar.css";
import "@/styles/calendar/view.css";
import "@/styles/calendar/event.css";
import "@/styles/calendar/badge.css";
import { t } from "@/translations/helper";
// Import original view implementations for agenda/year (legacy, kept for reference)
import { AgendaView } from "./views/agenda-view";
import { YearView } from "./views/year-view";
// Import new TG-based view implementations
import { TGAgendaView } from "./views/tg-agenda-view";
import { TGYearView } from "./views/tg-year-view";
import TaskProgressBarPlugin from "@/index";
import { QuickCaptureModal } from "@/components/features/quick-capture/modals/QuickCaptureModalWithSwitch";
import {
CalendarSpecificConfig,
CustomCalendarViewConfig,
} from "@/common/setting-definition";
import { createTaskCheckbox } from "@/components/features/task/view/details";
// View modes - now includes custom view IDs
type BuiltinCalendarViewMode = "year" | "month" | "week" | "day" | "agenda";
type CalendarViewMode = BuiltinCalendarViewMode | string;
// Legacy CalendarEvent interface (extends Task)
export interface CalendarEvent extends Task {
title: string;
start: Date;
end?: Date;
allDay: boolean;
color?: string;
badge?: boolean;
}
class NormalizedDateFnsAdapter implements DateAdapter<Date> {
constructor(private readonly fns: typeof dateFns) {}
private normalizeFormat(formatStr?: string) {
if (!formatStr) return formatStr;
// Align legacy moment-style tokens with date-fns v4 requirements
return formatStr
.replace(/Y{2,4}/g, (token) => token.toLowerCase())
.replace(/D{1,2}/g, (token) => token.toLowerCase());
}
create(date?: string | Date) {
if (!date) return new Date();
if (date instanceof Date) return new Date(date);
return this.fns.parseISO(date);
}
parse(dateStr: string, formatStr?: string) {
const normalizedFormat = this.normalizeFormat(formatStr);
if (!normalizedFormat) {
return this.fns.parseISO(dateStr);
}
return this.fns.parse(dateStr, normalizedFormat, new Date());
}
format(date: Date, formatStr: string) {
return this.fns.format(
date,
this.normalizeFormat(formatStr) ?? formatStr,
);
}
year(date: Date) {
return this.fns.getYear(date);
}
month(date: Date) {
return this.fns.getMonth(date);
}
date(date: Date) {
return this.fns.getDate(date);
}
day(date: Date) {
return this.fns.getDay(date);
}
hour(date: Date) {
return this.fns.getHours(date);
}
minute(date: Date) {
return this.fns.getMinutes(date);
}
setHour(date: Date, hour: number) {
return this.fns.setHours(date, hour);
}
setMinute(date: Date, minute: number) {
return this.fns.setMinutes(date, minute);
}
add(date: Date, amount: number, unit: string) {
switch (unit) {
case "year":
return this.fns.addYears(date, amount);
case "month":
return this.fns.addMonths(date, amount);
case "week":
return this.fns.addWeeks(date, amount);
case "day":
return this.fns.addDays(date, amount);
case "hour":
return this.fns.addHours(date, amount);
case "minute":
return this.fns.addMinutes(date, amount);
default:
return date;
}
}
diff(date1: Date, date2: Date, unit: string) {
switch (unit) {
case "year":
return this.fns.differenceInYears(date1, date2);
case "month":
return this.fns.differenceInMonths(date1, date2);
case "week":
return this.fns.differenceInWeeks(date1, date2);
case "day":
return this.fns.differenceInDays(date1, date2);
case "hour":
return this.fns.differenceInHours(date1, date2);
case "minute":
return this.fns.differenceInMinutes(date1, date2);
default:
return 0;
}
}
startOf(date: Date, unit: string) {
switch (unit) {
case "year":
return this.fns.startOfYear(date);
case "month":
return this.fns.startOfMonth(date);
case "week":
return this.fns.startOfWeek(date);
case "day":
return this.fns.startOfDay(date);
case "hour":
return this.fns.startOfHour(date);
case "minute":
return this.fns.startOfMinute(date);
default:
return date;
}
}
endOf(date: Date, unit: string) {
switch (unit) {
case "year":
return this.fns.endOfYear(date);
case "month":
return this.fns.endOfMonth(date);
case "week":
return this.fns.endOfWeek(date);
case "day":
return this.fns.endOfDay(date);
case "hour":
return this.fns.endOfHour(date);
case "minute":
return this.fns.endOfMinute(date);
default:
return date;
}
}
isBefore(date1: Date, date2: Date, unit?: string) {
if (!unit) return this.fns.isBefore(date1, date2);
return this.fns.isBefore(
this.startOf(date1, unit),
this.startOf(date2, unit),
);
}
isAfter(date1: Date, date2: Date, unit?: string) {
if (!unit) return this.fns.isAfter(date1, date2);
return this.fns.isAfter(
this.startOf(date1, unit),
this.startOf(date2, unit),
);
}
isSame(date1: Date, date2: Date, unit?: string) {
if (!unit) return this.fns.isEqual(date1, date2);
return this.fns.isEqual(
this.startOf(date1, unit),
this.startOf(date2, unit),
);
}
}
/**
* Main CalendarComponent class
*/
export class CalendarComponent extends Component {
public containerEl: HTMLElement;
private tasks: Task[] = [];
private events: CalendarEvent[] = [];
private currentViewMode: CalendarViewMode = "month";
private currentDate: moment.Moment = moment();
private headerEl: HTMLElement;
private viewContainerEl: HTMLElement;
private app: App;
private plugin: TaskProgressBarPlugin;
// @taskgenius/calendar instance (for all views including agenda/year)
private tgCalendar: Calendar | null = null;
// View registry for custom views (agenda, year, and user-defined)
private viewRegistry: ViewRegistry = new ViewRegistry();
// Original view components (legacy, kept for backward compatibility)
private agendaView: AgendaView | null = null;
private yearView: YearView | null = null;
// Badge events cache
private badgeEventsCache: Map<string, CalendarEvent[]> = new Map();
// Config override from Bases
private configOverride: Partial<CalendarSpecificConfig> | null = null;
constructor(
app: App,
plugin: TaskProgressBarPlugin,
parentEl: HTMLElement,
initialTasks: Task[] = [],
private params: {
onTaskSelected?: (task: Task | null) => void;
onTaskCompleted?: (task: Task) => void;
onEventContextMenu?: (ev: MouseEvent, event: CalendarEvent) => void;
} = {},
private viewId: string = "calendar",
) {
super();
this.app = app;
this.plugin = plugin;
this.containerEl = parentEl.createDiv("full-calendar-container");
this.tasks = initialTasks;
this.headerEl = this.containerEl.createDiv("calendar-header");
this.viewContainerEl = this.containerEl.createDiv(
"calendar-view-container",
);
// Load saved view mode
const savedView = this.app.loadLocalStorage(
"task-genius:calendar-view",
);
if (savedView) {
this.currentViewMode = savedView as CalendarViewMode;
}
// Initialize view registry with custom views (agenda, year)
this.initializeViewRegistry();
}
/**
* Initialize the view registry with custom views (Agenda, Year)
* These views extend @taskgenius/calendar's BaseView for integration
*/
private initializeViewRegistry(): void {
// Register TGAgendaView
this.viewRegistry.register(TGAgendaView as unknown as ViewClass);
// Register TGYearView
this.viewRegistry.register(TGYearView as unknown as ViewClass);
}
override onload() {
super.onload();
this.processTasks();
this.render();
// Listen for calendar views configuration changes
this.registerEvent(
this.app.workspace.on(
// @ts-ignore - custom event
"task-genius:calendar-views-changed",
() => {
this.handleCalendarViewsChanged();
},
),
);
}
/**
* Handle calendar views configuration changes (add/remove/edit/reorder)
*/
private handleCalendarViewsChanged() {
// If currently viewing a custom view that's no longer valid, switch to month
if (this.isCustomView(this.currentViewMode)) {
const customConfig = this.getCustomViewConfig(this.currentViewMode);
const hiddenCalendarViews =
this.plugin.workspaceManager?.getActiveWorkspace()?.settings
?.hiddenModules?.calendarViews || [];
if (
!customConfig ||
!customConfig.enabled ||
hiddenCalendarViews.includes(this.currentViewMode)
) {
this.currentViewMode = "month";
this.saveCurrentViewMode();
}
}
// Re-render to update the view switcher and current view
this.render();
}
override onunload() {
super.onunload();
// Clean up @taskgenius/calendar
if (this.tgCalendar) {
this.tgCalendar.destroy();
this.tgCalendar = null;
}
// Clean up original views
if (this.agendaView) {
this.removeChild(this.agendaView);
this.agendaView = null;
}
if (this.yearView) {
this.removeChild(this.yearView);
this.yearView = null;
}
this.containerEl.empty();
}
// ============================================
// Public API
// ============================================
public updateTasks(newTasks: Task[]) {
this.tasks = newTasks;
this.badgeEventsCache.clear();
this.processTasks();
this.renderCurrentView();
}
public setView(viewMode: CalendarViewMode) {
if (this.currentViewMode !== viewMode) {
this.currentViewMode = viewMode;
this.saveCurrentViewMode();
this.render();
}
}
/**
* Save the current view mode to localStorage
*/
private saveCurrentViewMode() {
this.app.saveLocalStorage(
"task-genius:calendar-view",
this.currentViewMode,
);
}
public navigate(direction: "prev" | "next") {
const unit = this.getViewUnit();
if (direction === "prev") {
this.currentDate.subtract(1, unit);
} else {
this.currentDate.add(1, unit);
}
// Update @taskgenius/calendar or re-render original views
if (this.tgCalendar) {
if (direction === "prev") {
this.tgCalendar.prev();
} else {
this.tgCalendar.next();
}
} else {
this.render();
}
}
public goToToday() {
this.currentDate = moment();
if (this.tgCalendar) {
this.tgCalendar.today();
} else {
this.render();
}
}
public setTasks(tasks: Task[]) {
this.updateTasks(tasks);
}
public setConfigOverride(override: Partial<CalendarSpecificConfig> | null) {
this.configOverride = override ?? null;
this.render();
}
public get currentViewComponent() {
return this.agendaView || this.yearView || null;
}
public getBadgeEventsForDate(date: Date): CalendarEvent[] {
const dateKey = this.formatDateKey(date);
if (this.badgeEventsCache.has(dateKey)) {
return this.badgeEventsCache.get(dateKey) || [];
}
const badgeEvents: CalendarEvent[] = [];
this.tasks.forEach((task) => {
const isIcsTask = (task as any).source?.type === "ics";
const icsTask = isIcsTask ? (task as IcsTask) : null;
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
if (isIcsTask && showAsBadge && icsTask?.icsEvent) {
const eventDate = new Date(icsTask.icsEvent.dtstart);
if (this.isSameDay(eventDate, date)) {
badgeEvents.push({
...task,
title: task.content,
start: icsTask.icsEvent.dtstart,
end: icsTask.icsEvent.dtend,
allDay: icsTask.icsEvent.allDay,
color: icsTask.icsEvent.source.color,
badge: true,
});
}
}
});
this.badgeEventsCache.set(dateKey, badgeEvents);
return badgeEvents;
}
// ============================================
// Rendering
// ============================================
private render() {
this.renderHeader();
this.renderCurrentView();
}
private renderHeader() {
this.headerEl.empty();
// Navigation
const navGroup = this.headerEl.createDiv("calendar-nav");
const prevBtn = new ButtonComponent(navGroup.createDiv());
prevBtn.buttonEl.addClass("calendar-nav-button", "prev-button");
prevBtn.setIcon("chevron-left");
prevBtn.onClick(() => this.navigate("prev"));
const todayBtn = new ButtonComponent(navGroup.createDiv());
todayBtn.buttonEl.addClass("calendar-nav-button", "today-button");
todayBtn.setButtonText(t("Today"));
todayBtn.onClick(() => this.goToToday());
const nextBtn = new ButtonComponent(navGroup.createDiv());
nextBtn.buttonEl.addClass("calendar-nav-button", "next-button");
nextBtn.setIcon("chevron-right");
nextBtn.onClick(() => this.navigate("next"));
// Date display
const dateDisplay = this.headerEl.createSpan("calendar-current-date");
dateDisplay.textContent = this.getCurrentDateDisplay();
// View switcher - segmented control style
const viewGroup = this.headerEl.createDiv("calendar-view-switcher");
// Built-in modes with short labels (first letter or abbreviated)
const builtinModes: {
id: BuiltinCalendarViewMode;
label: string;
shortLabel: string;
title: string;
}[] = [
{ id: "year", label: t("Year"), shortLabel: "Y", title: t("Year") },
{
id: "month",
label: t("Month"),
shortLabel: "M",
title: t("Month"),
},
{ id: "week", label: t("Week"), shortLabel: "W", title: t("Week") },
{ id: "day", label: t("Day"), shortLabel: "D", title: t("Day") },
{
id: "agenda",
label: t("Agenda"),
shortLabel: "A",
title: t("Agenda"),
},
];
// Get enabled custom views
const customViews = this.getEnabledCustomViews();
// Segmented control container (for wide screens)
const segmentedControl = viewGroup.createDiv(
"calendar-segmented-control",
);
// Track the active button for initial indicator positioning
let activeButton: HTMLElement | null = null;
// Render built-in view buttons
builtinModes.forEach((mode, index) => {
const btn = segmentedControl.createEl("button", {
cls: "calendar-segment-btn",
attr: { "aria-label": mode.title, "data-view": mode.id },
});
// Short label (single letter)
btn.createSpan({
cls: "calendar-segment-short",
text: mode.shortLabel,
});
// Full label (for hover/wider screens)
btn.createSpan({
cls: "calendar-segment-full",
text: mode.label,
});
if (mode.id === this.currentViewMode) {
btn.addClass("is-active");
activeButton = btn;
}
btn.onclick = () => {
this.setView(mode.id);
};
});
// Render custom view buttons (if any)
if (customViews.length > 0) {
segmentedControl.createDiv("calendar-segment-separator");
customViews.forEach((customView) => {
const btn = segmentedControl.createEl("button", {
cls: "calendar-segment-btn calendar-segment-custom",
attr: {
"data-view": customView.id,
"aria-label": customView.name,
},
});
// Icon for custom view
const iconEl = btn.createSpan({
cls: "calendar-segment-icon",
});
import("obsidian").then(({ setIcon }) => {
setIcon(iconEl, customView.icon);
});
// Short label (first letter of name)
btn.createSpan({
cls: "calendar-segment-short",
text: customView.name.charAt(0).toUpperCase(),
});
if (customView.id === this.currentViewMode) {
btn.addClass("is-active");
activeButton = btn;
}
btn.onclick = () => {
this.setView(customView.id);
};
});
}
// Dropdown selector (for narrow screens)
viewGroup.createEl(
"div",
{ cls: "calendar-view-switcher-selector" },
(el) => {
const dropdown = new DropdownComponent(el);
// Add built-in options
builtinModes.forEach((mode) => {
dropdown.addOption(mode.id, mode.label);
});
// Add custom view options
if (customViews.length > 0) {
customViews.forEach((customView) => {
dropdown.addOption(
customView.id,
`${customView.name}`,
);
});
}
dropdown
.onChange((value) =>
this.setView(value as CalendarViewMode),
)
.setValue(this.currentViewMode);
},
);
}
/**
* Update the position of the active indicator in the segmented control
*/
private updateActiveIndicator(
container: HTMLElement,
indicator: HTMLElement,
activeBtn: HTMLElement,
) {
const containerRect = container.getBoundingClientRect();
const btnRect = activeBtn.getBoundingClientRect();
indicator.style.width = `${btnRect.width}px`;
indicator.style.transform = `translateX(${btnRect.left - containerRect.left}px)`;
}
/**
* Get all enabled custom calendar views (respecting workspace visibility)
*/
private getEnabledCustomViews(): CustomCalendarViewConfig[] {
const customViews = this.plugin.settings.customCalendarViews || [];
// Get hidden calendar views from active workspace
const hiddenCalendarViews =
this.plugin.workspaceManager?.getActiveWorkspace()?.settings
?.hiddenModules?.calendarViews || [];
return customViews
.filter((v) => v.enabled && !hiddenCalendarViews.includes(v.id))
.sort((a, b) => a.order - b.order);
}
/**
* Get a custom view configuration by ID
*/
private getCustomViewConfig(
viewId: string,
): CustomCalendarViewConfig | null {
const customViews = this.plugin.settings.customCalendarViews || [];
return customViews.find((v) => v.id === viewId) || null;
}
/**
* Check if a view mode is a custom view
*/
private isCustomView(viewMode: CalendarViewMode): boolean {
return !["year", "month", "week", "day", "agenda"].includes(viewMode);
}
private renderCurrentView() {
this.viewContainerEl.empty();
// Clean up previous views
if (this.tgCalendar) {
this.tgCalendar.destroy();
this.tgCalendar = null;
}
if (this.agendaView) {
this.removeChild(this.agendaView);
this.agendaView = null;
}
if (this.yearView) {
this.removeChild(this.yearView);
this.yearView = null;
}
// Update view class
this.viewContainerEl.removeClass(
"view-year",
"view-month",
"view-week",
"view-day",
"view-agenda",
);
this.viewContainerEl.addClass(`view-${this.currentViewMode}`);
// Render view - all views now go through unified TGCalendar
switch (this.currentViewMode) {
case "month":
case "week":
case "day":
case "agenda":
case "year":
this.renderTGCalendarView();
break;
default:
// Check if it's a custom view
if (this.isCustomView(this.currentViewMode)) {
this.renderCustomCalendarView(this.currentViewMode);
}
break;
}
}
/**
* Render a custom calendar view based on its configuration
*/
private renderCustomCalendarView(viewId: string) {
const customConfig = this.getCustomViewConfig(viewId);
if (!customConfig) {
// Fallback to month view if custom view not found
this.currentViewMode = "month";
this.saveCurrentViewMode();
this.renderTGCalendarView();
return;
}
// Check if the view is enabled and not hidden in workspace
if (!customConfig.enabled) {
// View is disabled, fallback to month view
this.currentViewMode = "month";
this.saveCurrentViewMode();
this.renderTGCalendarView();
return;
}
// Check workspace hidden modules
const hiddenCalendarViews =
this.plugin.workspaceManager?.getActiveWorkspace()?.settings
?.hiddenModules?.calendarViews || [];
if (hiddenCalendarViews.includes(viewId)) {
// View is hidden in this workspace, fallback to month view
this.currentViewMode = "month";
this.saveCurrentViewMode();
this.renderTGCalendarView();
return;
}
// Check if it's based on agenda or year view
const isAgendaOrYearBased =
customConfig.baseViewType === "agenda" ||
customConfig.baseViewType === "year";
// Build the calendar config from custom view settings
const calendarConfig = this.buildCustomViewCalendarConfig(customConfig);
this.tgCalendar = new Calendar(this.viewContainerEl, calendarConfig);
// For agenda/year based views, switch to that view after creation
if (isAgendaOrYearBased) {
this.tgCalendar.setView(customConfig.baseViewType);
// Set plugin context and options on the active view
const customView = this.tgCalendar.getActiveView();
if (customView) {
if ("setPluginContext" in customView) {
(customView as any).setPluginContext(this.plugin, this.app);
}
if ("setOptions" in customView) {
const cc = customConfig.calendarConfig;
if (customConfig.baseViewType === "agenda") {
(customView as any).setOptions({
daysToShow: (cc as any).daysToShow ?? 7,
showEmptyDays: (cc as any).showEmptyDays ?? false,
onEventClick: this.onEventClick,
onEventHover: this.onEventHover,
onEventContextMenu: this.onEventContextMenu,
onEventComplete: this.onEventComplete,
});
}
if (customConfig.baseViewType === "year") {
(customView as any).setOptions({
firstDayOfWeek: cc.firstDayOfWeek,
hideWeekends: cc.dayFilter?.type === "hideWeekends",
onDayClick: this.onDayClick,
onDayHover: this.onDayHover,
onMonthClick: this.onMonthClick,
onMonthHover: this.onMonthHover,
});
}
}
}
// Trigger re-render to apply the options
this.tgCalendar.refresh();
}
// Sync current date
this.tgCalendar.goToDate(this.currentDate.toDate());
}
/**
* Build CalendarConfig from a CustomCalendarViewConfig
*/
private buildCustomViewCalendarConfig(
customConfig: CustomCalendarViewConfig,
): CalendarConfig {
const cc = customConfig.calendarConfig;
// Build dayFilter based on config
// The filter functions return DayFilterResult which is accepted by the view config
let dayFilter: ReturnType<typeof hideWeekends> | undefined;
if (cc.dayFilter) {
switch (cc.dayFilter.type) {
case "hideWeekends":
dayFilter = hideWeekends();
break;
case "hideWeekdays":
// hideWeekdays = only show weekends (hide Mon-Fri)
dayFilter = hideDays([1, 2, 3, 4, 5]);
break;
case "customDays":
if (
cc.dayFilter.hiddenDays &&
cc.dayFilter.hiddenDays.length > 0
) {
dayFilter = hideDays(cc.dayFilter.hiddenDays);
}
break;
}
}
// Build timeFilter based on config (only for week/day views)
// The filter functions return TimeFilterResult which is accepted by the view config
let timeFilter: ReturnType<typeof workingHours> | undefined;
if (
cc.timeFilter?.enabled &&
(customConfig.baseViewType === "week" ||
customConfig.baseViewType === "day")
) {
if (cc.timeFilter.type === "workingHours") {
timeFilter = workingHours(9, 18);
} else {
timeFilter = workingHours(
cc.timeFilter.startHour,
cc.timeFilter.endHour,
);
}
}
// For agenda/year views, use month as the initial view type
// (the actual view switch happens in renderCustomCalendarView)
const isAgendaOrYear =
customConfig.baseViewType === "agenda" ||
customConfig.baseViewType === "year";
const viewType = isAgendaOrYear
? "month"
: (customConfig.baseViewType as "month" | "week" | "day");
const calendarConfig: ExtendedCalendarConfig = {
view: {
type: viewType,
showWeekNumbers: cc.showWeekNumbers ?? false,
showDateHeader: true,
firstDayOfWeek: this.getEffectiveFirstDayOfWeek(customConfig),
dayFilter,
timeFilter,
},
// Use custom view registry for agenda/year views
viewRegistry: isAgendaOrYear ? this.viewRegistry : undefined,
dateAdapter: new NormalizedDateFnsAdapter(dateFns),
events: this.convertTasksToTGEvents(),
showEventCounts: cc.showEventCounts ?? true,
dateFormats: {
date: "yyyy-MM-dd",
dateTime: "yyyy-MM-dd HH:mm",
time: "HH:mm",
monthHeader: cc.dateFormats?.monthHeader ?? "yyyy M",
dayHeader: cc.dateFormats?.dayHeader ?? "yyyy M d",
},
draggable: {
enabled: true,
snapMinutes: 1440,
ghostOpacity: 0.5,
},
theme: {
primaryColor: "var(--interactive-accent)",
cellHeight: 60,
fontSize: {
header: "var(--font-ui-small)",
event: "var(--font-ui-smaller)",
},
},
// Event interactions
onEventClick: (event: any) => this.handleTGEventClick(event),
onEventContextMenu: (event: any, x: number, y: number) =>
this.handleTGEventContextMenu(event, x, y),
onEventDrop: (event: any, newStart: any, newEnd: any) =>
this.handleTGEventDrop(event, newStart, newEnd),
onEventResize: (event: any, newStart: any, newEnd: any) =>
this.handleTGEventResize(event, newStart, newEnd),
onDateClick: (date: Date) => this.handleDateClick(date),
onDateDoubleClick: (date: Date) => this.handleDateDoubleClick(date),
onDateContextMenu: (date: Date, x: number, y: number) =>
this.handleDateContextMenu(date, x, y),
onTimeSlotClick: (dateTime: Date) =>
this.handleTimeSlotClick(dateTime),
onTimeSlotDoubleClick: (dateTime: Date) =>
this.handleTimeSlotDoubleClick(dateTime),
onRenderDateCell: (ctx: any) => this.handleTGRenderDateCell(ctx),
// Custom event rendering - adds checkbox for task completion
onRenderEvent: (ctx: EventRenderContext) =>
this.handleTGRenderEvent(ctx),
};
// Add maxEventsPerRow for month view
if (customConfig.baseViewType === "month" && cc.maxEventsPerRow) {
(calendarConfig as any).maxEventsPerRow = cc.maxEventsPerRow;
}
return calendarConfig;
}
private renderTGCalendarView() {
const config = this.getEffectiveCalendarConfig();
// Determine view type - for agenda/year, use month as base but switch to custom view
const isCustomTGView =
this.currentViewMode === "agenda" ||
this.currentViewMode === "year";
const baseViewType = isCustomTGView
? "month"
: (this.currentViewMode as "month" | "week" | "day");
const calendarConfig: ExtendedCalendarConfig = {
view: {
type: baseViewType,
showWeekNumbers: false,
showDateHeader: true,
firstDayOfWeek: this.getEffectiveFirstDayOfWeek(),
// Use new dayFilter API (v0.6.0+) - must be inside view config
dayFilter: config.hideWeekends ? hideWeekends() : undefined,
// Add timeFilter for working hours (week/day view only)
timeFilter:
config.showWorkingHoursOnly &&
(this.currentViewMode === "week" ||
this.currentViewMode === "day")
? workingHours(
config.workingHoursStart ?? 9,
config.workingHoursEnd ?? 18,
)
: undefined,
},
dateAdapter: new NormalizedDateFnsAdapter(dateFns),
events: this.convertTasksToTGEvents(),
showEventCounts: true,
dateFormats: {
date: "yyyy-MM-dd",
dateTime: "yyyy-MM-dd HH:mm",
time: "HH:mm",
monthHeader: "yyyy M",
dayHeader: "yyyy M d",
},
draggable: {
enabled: true,
snapMinutes: 1440, // Date-only drag
ghostOpacity: 0.5,
},
theme: {
primaryColor: "var(--interactive-accent)",
cellHeight: 60,
fontSize: {
header: "var(--font-ui-small)",
event: "var(--font-ui-smaller)",
},
},
// Use custom view registry for agenda/year views
viewRegistry: this.viewRegistry,
// Event interactions - use arrow functions for safe binding
onEventClick: (event: any) => this.handleTGEventClick(event),
onEventContextMenu: (event: any, x: number, y: number) =>
this.handleTGEventContextMenu(event, x, y),
onEventDrop: (event: any, newStart: any, newEnd: any) =>
this.handleTGEventDrop(event, newStart, newEnd),
onEventResize: (event: any, newStart: any, newEnd: any) =>
this.handleTGEventResize(event, newStart, newEnd),
// Date interactions (v0.6.0+) - match library's callback signatures
onDateClick: (date: Date) => this.handleDateClick(date),
onDateDoubleClick: (date: Date) => this.handleDateDoubleClick(date),
onDateContextMenu: (date: Date, x: number, y: number) =>
this.handleDateContextMenu(date, x, y),
// Time slot interactions (week/day views, v0.6.0+)
onTimeSlotClick: (dateTime: Date) =>
this.handleTimeSlotClick(dateTime),
onTimeSlotDoubleClick: (dateTime: Date) =>
this.handleTimeSlotDoubleClick(dateTime),
// Custom cell rendering
onRenderDateCell: (ctx: any) => this.handleTGRenderDateCell(ctx),
// Custom event rendering - adds checkbox for task completion
onRenderEvent: (ctx: EventRenderContext) =>
this.handleTGRenderEvent(ctx),
};
this.tgCalendar = new Calendar(this.viewContainerEl, calendarConfig);
// For custom TG views (agenda/year), switch to that view after creation
if (isCustomTGView) {
// Set plugin context for custom views
const activeView = this.tgCalendar.getActiveView();
if (activeView) {
// The view will be switched, so we need to set context after switching
}
this.tgCalendar.setView(this.currentViewMode);
// Now set plugin context on the active view
const customView = this.tgCalendar.getActiveView();
if (customView) {
if ("setPluginContext" in customView) {
(customView as any).setPluginContext(this.plugin, this.app);
}
if ("setOptions" in customView) {
// Set options for agenda view
if (this.currentViewMode === "agenda") {
(customView as any).setOptions({
onEventClick: this.onEventClick,
onEventHover: this.onEventHover,
onEventContextMenu: this.onEventContextMenu,
onEventComplete: this.onEventComplete,
});
}
// Set options for year view
if (this.currentViewMode === "year") {
(customView as any).setOptions({
firstDayOfWeek: config.firstDayOfWeek,
hideWeekends: config.hideWeekends,
onDayClick: this.onDayClick,
onDayHover: this.onDayHover,
onMonthClick: this.onMonthClick,
onMonthHover: this.onMonthHover,
});
if ("setOverrideConfig" in customView) {
(customView as any).setOverrideConfig(config);
}
}
}
}
// Trigger re-render to apply the options
this.tgCalendar.refresh();
}
// Sync current date
this.tgCalendar.goToDate(this.currentDate.toDate());
}
private renderAgendaView() {
this.agendaView = new AgendaView(
this.app,
this.plugin,
this.viewContainerEl,
this.currentDate,
this.events,
{
onEventClick: this.onEventClick,
onEventHover: this.onEventHover,
onEventContextMenu: this.onEventContextMenu,
onEventComplete: this.onEventComplete,
},
);
this.addChild(this.agendaView);
this.agendaView.updateEvents(this.events);
}
private renderYearView() {
const config = this.getEffectiveCalendarConfig();
this.yearView = new YearView(
this.app,
this.plugin,
this.viewContainerEl,
this.currentDate,
this.events,
{
onEventClick: this.onEventClick,
onEventHover: this.onEventHover,
onDayClick: this.onDayClick,
onDayHover: this.onDayHover,
onMonthClick: this.onMonthClick,
onMonthHover: this.onMonthHover,
},
config,
);
this.addChild(this.yearView);
this.yearView.updateEvents(this.events);
}
// ============================================
// Event Handlers
// ============================================
private handleTGEventClick(event: AdapterCalendarEvent) {
const task = getTaskFromEvent(event as any);
if (task) {
this.params?.onTaskSelected?.(task);
}
}
/**
* Handle custom event rendering - adds checkbox for task completion
* Uses the onRenderEvent hook from @taskgenius/calendar v0.6.0+
* Based on CalendarEventComponent implementation in event-renderer.ts
*/
private handleTGRenderEvent(ctx: EventRenderContext) {
console.log(
"[Calendar] handleTGRenderEvent called",
ctx.event.id,
ctx.viewType,
);
// First render the default content
ctx.defaultRender();
const { event, el } = ctx;
// Skip if checkbox already added
if (el.querySelector(".task-list-item-checkbox")) {
return;
}
// Find the corresponding task
const task = getTaskFromEvent(event as any);
if (!task) return;
// Create checkbox using the shared utility function
// This ensures consistent styling with other calendar views
const checkbox = createTaskCheckbox(task.status || " ", task, el);
// Move checkbox to the beginning of the event element
if (el.firstChild && el.firstChild !== checkbox) {
el.insertBefore(checkbox, el.firstChild);
}
// Handle checkbox click - same pattern as CalendarEventComponent
this.registerDomEvent(checkbox, "click", async (ev) => {
ev.stopPropagation();
// Toggle task status
const newStatus = task.completed ? " " : "x";
if (this.plugin.writeAPI) {
const result = await this.plugin.writeAPI.updateTaskStatus({
taskId: task.id,
status: newStatus,
completed: !task.completed,
});
if (result.success) {
// Update UI immediately
checkbox.checked = !task.completed;
checkbox.dataset.task = newStatus;
// Trigger task-completed event if needed
if (!task.completed) {
this.app.workspace.trigger(
"task-genius:task-completed",
task,
);
}
// Refresh calendar
setTimeout(() => {
this.processTasks();
this.renderCurrentView();
}, 100);
}
}
});
}
/**
* Handle event context menu from @taskgenius/calendar InteractionController
* Bridges the library's (event, x, y) signature to our (MouseEvent, CalendarEvent) signature
*/
private handleTGEventContextMenu(
event: AdapterCalendarEvent,
x: number,
y: number,
) {
// Create a synthetic MouseEvent-like object with the coordinates
const syntheticEvent = {
clientX: x,
clientY: y,
preventDefault: () => {},
stopPropagation: () => {},
} as MouseEvent;
console.log("Event context menu clicked");
// Convert to CalendarEvent and call our handler
const calendarEvent = event as unknown as CalendarEvent;
this.onEventContextMenu(syntheticEvent, calendarEvent);
}
private async handleTGEventDrop(
event: AdapterCalendarEvent,
newStart: Date | string,
newEnd: Date | string,
) {
const task = getTaskFromEvent(event as any);
if (!task) {
new Notice(t("Failed to update task: Task not found"));
return;
}
// Don't allow dragging ICS tasks (external calendar events)
const isIcsTask = (task as any).source?.type === "ics";
if (isIcsTask) {
new Notice(
t(
"Cannot move external calendar events. Please update them in the original calendar.",
),
);
// Refresh to reset the visual position
setTimeout(() => {
this.processTasks();
this.renderCurrentView();
}, 100);
return;
}
const newStartDate =
newStart instanceof Date ? newStart : new Date(newStart);
const newEndInput = newEnd ?? newStart;
const newEndDate =
newEndInput instanceof Date ? newEndInput : new Date(newEndInput);
const oldStartDate = new Date(event.start);
const oldEndDate = event.end ? new Date(event.end) : oldStartDate;
if (
Number.isNaN(newStartDate.getTime()) ||
Number.isNaN(newEndDate.getTime()) ||
Number.isNaN(oldStartDate.getTime()) ||
Number.isNaN(oldEndDate.getTime())
) {
console.warn("Calendar: Invalid date detected during drag/drop", {
event,
newStart,
newEnd,
});
return;
}
const isAllDayEvent = event.allDay === true;
try {
const updates: any = { metadata: {} };
let updatedFields: string[] = [];
if (isAllDayEvent) {
const normalizedNewStart = startOfDay(newStartDate);
const normalizedNewEnd = startOfDay(newEndDate);
const normalizedOldStart = startOfDay(oldStartDate);
const normalizedOldEnd = startOfDay(oldEndDate);
const startDiff = differenceInDays(
normalizedNewStart,
normalizedOldStart,
);
const endDiff = differenceInDays(
normalizedNewEnd,
normalizedOldEnd,
);
if (startDiff === 0 && endDiff === 0) return;
const isMove = startDiff === endDiff;
const isResizeStart = startDiff !== 0 && startDiff !== endDiff;
const isResizeEnd = endDiff !== 0 && startDiff !== endDiff;
if (isMove) {
if (task.metadata.dueDate) {
updates.metadata.dueDate = addDays(
startOfDay(new Date(task.metadata.dueDate)),
startDiff,
).getTime();
updatedFields.push("due date");
}
if (task.metadata.scheduledDate) {
updates.metadata.scheduledDate = addDays(
startOfDay(new Date(task.metadata.scheduledDate)),
startDiff,
).getTime();
updatedFields.push("scheduled date");
}
if (task.metadata.startDate) {
updates.metadata.startDate = addDays(
startOfDay(new Date(task.metadata.startDate)),
startDiff,
).getTime();
updatedFields.push("start date");
}
if (updatedFields.length === 0) {
updates.metadata.dueDate = normalizedNewStart.getTime();
updatedFields.push("due date");
}
} else if (isResizeStart) {
updates.metadata.startDate = normalizedNewStart.getTime();
updatedFields.push("start date");
if (
!task.metadata.dueDate &&
differenceInDays(newEndDate, newStartDate) > 0
) {
updates.metadata.dueDate = normalizedNewEnd.getTime();
updatedFields.push("due date");
}
} else if (isResizeEnd) {
updates.metadata.dueDate = normalizedNewEnd.getTime();
updatedFields.push("due date");
if (
!task.metadata.startDate &&
differenceInDays(newEndDate, newStartDate) > 0
) {
updates.metadata.startDate =
normalizedNewStart.getTime();
updatedFields.push("start date");
}
}
} else {
const startDiffMs =
newStartDate.getTime() - oldStartDate.getTime();
const endDiffMs = newEndDate.getTime() - oldEndDate.getTime();
if (startDiffMs === 0 && endDiffMs === 0) return;
const isMove = startDiffMs === endDiffMs;
const isResizeStart =
startDiffMs !== 0 && startDiffMs !== endDiffMs;
const isResizeEnd =
endDiffMs !== 0 && startDiffMs !== endDiffMs;
if (isMove) {
if (task.metadata.dueDate) {
updates.metadata.dueDate =
task.metadata.dueDate + startDiffMs;
updatedFields.push("due date");
}
if (task.metadata.scheduledDate) {
updates.metadata.scheduledDate =
task.metadata.scheduledDate + startDiffMs;
updatedFields.push("scheduled date");
}
if (task.metadata.startDate) {
updates.metadata.startDate =
task.metadata.startDate + startDiffMs;
updatedFields.push("start date");
}
if (updatedFields.length === 0) {
updates.metadata.dueDate = newStartDate.getTime();
updatedFields.push("due date");
}
} else if (isResizeStart) {
updates.metadata.startDate = newStartDate.getTime();
updatedFields.push("start date");
if (
!task.metadata.dueDate &&
newEndDate.getTime() !== newStartDate.getTime()
) {
updates.metadata.dueDate = newEndDate.getTime();
updatedFields.push("due date");
}
} else if (isResizeEnd) {
updates.metadata.dueDate = newEndDate.getTime();
updatedFields.push("due date");
if (
!task.metadata.startDate &&
newEndDate.getTime() !== newStartDate.getTime()
) {
updates.metadata.startDate = newStartDate.getTime();
updatedFields.push("start date");
}
}
}
// Update task using WriteAPI
if (!this.plugin.writeAPI) {
new Notice(t("Task update system not available"));
return;
}
// Apply optimistic update to local state immediately
// This prevents the "snap back" effect during async update
this.applyOptimisticEventUpdate(event.id, newStartDate, newEndDate);
const result = await this.plugin.writeAPI.updateTask({
taskId: task.id,
updates,
});
if (result.success) {
new Notice(t("Task date updated successfully"));
// No immediate refresh needed - the optimistic update already shows the correct position
// The dataflow system will eventually sync the task index
} else {
console.error(
"Calendar: Failed to update task after drag:",
result.error,
);
new Notice(t("Failed to update task"));
// Rollback optimistic update on failure
this.processTasks();
this.renderCurrentView();
}
} catch (error) {
console.error("Calendar: Error updating task after drag:", error);
new Notice(t("Failed to update task"));
// Rollback optimistic update on error
this.processTasks();
this.renderCurrentView();
}
}
private async handleTGEventResize(
event: AdapterCalendarEvent,
newStart: Date | string,
newEnd: Date | string,
) {
const task = getTaskFromEvent(event as any);
if (!task) {
new Notice(t("Failed to update task: Task not found"));
return;
}
const isIcsTask = (task as any).source?.type === "ics";
if (isIcsTask) {
new Notice(
"In current version, cannot resize external calendar events",
);
setTimeout(() => {
this.processTasks();
this.renderCurrentView();
}, 100);
return;
}
const newStartDate =
newStart instanceof Date ? newStart : new Date(newStart);
const newEndDate = newEnd instanceof Date ? newEnd : new Date(newEnd);
const oldStartDate = new Date(event.start);
const oldEndDate = event.end ? new Date(event.end) : oldStartDate;
const isAllDayEvent = event.allDay === true;
const normalizedNewStart = isAllDayEvent
? startOfDay(newStartDate)
: newStartDate;
const normalizedNewEnd = isAllDayEvent
? startOfDay(newEndDate)
: newEndDate;
const normalizedOldStart = isAllDayEvent
? startOfDay(oldStartDate)
: oldStartDate;
const normalizedOldEnd = isAllDayEvent
? startOfDay(oldEndDate)
: oldEndDate;
const startChanged =
normalizedNewStart.getTime() !== normalizedOldStart.getTime();
const endChanged =
normalizedNewEnd.getTime() !== normalizedOldEnd.getTime();
if (!startChanged && !endChanged) return;
const updates: any = { metadata: {} };
const updatedFields: string[] = [];
const newStartMs = normalizedNewStart.getTime();
const newEndMs = normalizedNewEnd.getTime();
const setField = (
key: "startDate" | "dueDate",
value: number,
label: string,
) => {
if (updates.metadata[key] === undefined) {
updates.metadata[key] = value;
updatedFields.push(label);
}
};
if (startChanged) {
setField("startDate", newStartMs, "start date");
}
if (endChanged) {
setField("dueDate", newEndMs, "due date");
}
// If a task only had one date, ensure both ends are updated for the new range
if (!task.metadata.startDate) {
setField("startDate", newStartMs, "start date");
}
if (!task.metadata.dueDate) {
setField("dueDate", newEndMs, "due date");
}
if (updatedFields.length === 0) return;
if (!this.plugin.writeAPI) {
new Notice(t("Task update system not available"));
return;
}
// Apply optimistic update to local state immediately
// This prevents the "snap back" effect during async update
this.applyOptimisticEventUpdate(
event.id,
normalizedNewStart,
normalizedNewEnd,
);
const result = await this.plugin.writeAPI.updateTask({
taskId: task.id,
updates,
});
if (result.success) {
new Notice(t("Task time updated: ") + updatedFields.join(", "));
// No immediate refresh needed - the optimistic update already shows the correct position
} else {
console.error("Calendar: Failed to resize task:", result.error);
new Notice(t("Failed to update task"));
// Rollback optimistic update on failure
this.processTasks();
this.renderCurrentView();
}
}
/**
* Apply optimistic update to local events array and TGCalendar
* This prevents the "snap back" visual glitch during async task updates
*/
private applyOptimisticEventUpdate(
eventId: string,
newStart: Date,
newEnd: Date,
): void {
// Update local events array
const eventIndex = this.events.findIndex((e) => e.id === eventId);
if (eventIndex !== -1) {
this.events[eventIndex] = {
...this.events[eventIndex],
start: newStart,
end: newEnd,
};
}
// Update TGCalendar's internal event state if available
if (this.tgCalendar) {
this.tgCalendar.updateEvent(eventId, {
start: newStart.toISOString(),
end: newEnd.toISOString(),
});
}
}
private handleTGRenderDateCell(ctx: any) {
const { date, cellEl } = ctx;
const tasksOnDate = this.getTasksForDate(date);
const badgeEvents = this.getBadgeEventsForDate(date);
// Render individual badge events (ICS events with badge showType)
// Note: Event counts are automatically handled by @taskgenius/calendar's showEventCounts feature
if (badgeEvents.length > 0) {
// Find the date header area to place badges
const dateHeader = cellEl.querySelector(".tg-date-header");
if (dateHeader) {
const badgesContainer = dateHeader.createDiv(
"calendar-badges-container",
);
badgeEvents.forEach((badgeEvent) => {
const badgeEl = badgesContainer.createEl("div", {
cls: "calendar-badge",
});
// Add color styling if available
if (badgeEvent.color) {
badgeEl.style.backgroundColor = badgeEvent.color;
}
// Add content text
badgeEl.textContent = badgeEvent.content;
});
}
}
// Add checkboxes to TGCalendar events
// TGCalendar library doesn't support custom event rendering,
// so we need to add checkboxes after the events are rendered
setTimeout(() => {
const eventElements = cellEl.querySelectorAll(".tg-event");
eventElements.forEach((eventEl: HTMLElement) => {
// Skip if checkbox already added
if (eventEl.querySelector(".task-checkbox-overlay")) {
return;
}
// Get event ID from data attribute
const eventId = eventEl.dataset.eventId;
if (!eventId) return;
// Find the corresponding task
const task = tasksOnDate.find((t) => t.id === eventId);
if (!task) return;
// Create checkbox overlay
const checkboxContainer = eventEl.createDiv({
cls: "task-checkbox-overlay",
});
const checkbox = checkboxContainer.createEl("input", {
cls: "task-list-item-checkbox",
type: "checkbox",
attr: {
"data-task": task.status || " ",
},
});
checkbox.checked = task.status !== " " && task.status !== "?";
// Handle checkbox click
this.registerDomEvent(checkbox, "click", async (ev) => {
ev.stopPropagation();
// Toggle task status
const newStatus = task.completed ? " " : "x";
if (this.plugin.writeAPI) {
const result =
await this.plugin.writeAPI.updateTaskStatus({
taskId: task.id,
status: newStatus,
completed: !task.completed,
});
if (result.success) {
// Update UI
checkbox.checked = !task.completed;
checkbox.dataset.task = newStatus;
// Trigger task-completed event if needed
if (!task.completed) {
this.app.workspace.trigger(
"task-genius:task-completed",
task,
);
}
// Refresh calendar
setTimeout(() => {
this.processTasks();
this.renderCurrentView();
}, 100);
}
}
});
});
}, 50); // Small delay to ensure TGCalendar has rendered events
// Mark past due dates
const today = new Date();
today.setHours(0, 0, 0, 0);
if (date < today && tasksOnDate.some((t) => !t.completed)) {
const dateNum = cellEl.querySelector(".tg-date-number");
if (dateNum) {
dateNum.addClass("past-due");
}
}
}
// ============================================
// TGCalendar Interaction Handlers (v0.6.0+)
// ============================================
/**
* Handle single click on a date cell (month view)
*/
private handleDateClick(date: Date) {
// Allow event bubbling for other interactions
// This maintains compatibility with existing click behavior
}
/**
* Handle double click on a date cell - opens quick capture modal
*/
private handleDateDoubleClick(date: Date) {
new QuickCaptureModal(
this.app,
this.plugin,
{ dueDate: date },
true,
).open();
}
/**
* Handle context menu (right-click) on a date cell
*/
private handleDateContextMenu(date: Date, x?: number, y?: number) {
// Can be extended to show custom context menu
// For now, allow default browser context menu
}
/**
* Handle click on time slot (week/day view)
*/
private handleTimeSlotClick(dateTime: Date) {
// Single click on time slot - could show tooltip or select
}
/**
* Handle double click on time slot - opens quick capture with time
*/
private handleTimeSlotDoubleClick(dateTime: Date) {
// Create task with specific time
new QuickCaptureModal(
this.app,
this.plugin,
{ dueDate: dateTime },
true,
).open();
}
// ============================================
// Original Event Handlers (for Agenda/Year views)
// ============================================
private onEventClick = (ev: MouseEvent, event: CalendarEvent) => {
this.params?.onTaskSelected?.(event);
};
private onEventHover = (ev: MouseEvent, event: CalendarEvent) => {
// Hover logic
};
private onDayClick = (
ev: MouseEvent,
day: number,
options: { behavior: "open-quick-capture" | "open-task-view" },
) => {
const dayDate = new Date(day);
if (this.currentViewMode === "year") {
this.setView("day");
this.currentDate = moment(dayDate);
this.render();
} else if (options.behavior === "open-quick-capture") {
new QuickCaptureModal(
this.app,
this.plugin,
{ dueDate: dayDate },
true,
).open();
}
};
private onDayHover = (ev: MouseEvent, day: number) => {};
private onMonthClick = (ev: MouseEvent, month: number) => {
this.setView("month");
this.currentDate = moment(new Date(month));
this.render();
};
private onMonthHover = (ev: MouseEvent, month: number) => {};
private onEventContextMenu = (ev: MouseEvent, event: CalendarEvent) => {
this.params?.onEventContextMenu?.(ev, event);
};
private onEventComplete = (ev: MouseEvent, event: CalendarEvent) => {
this.params?.onTaskCompleted?.(event);
};
// ============================================
// Task Processing
// ============================================
private async processTasks() {
this.events = [];
this.badgeEventsCache.clear();
this.tasks.forEach((task) => {
const isIcsTask = (task as any).source?.type === "ics";
const icsTask = isIcsTask ? (task as IcsTask) : null;
const showAsBadge = icsTask?.icsEvent?.source?.showType === "badge";
if (isIcsTask && showAsBadge) {
return; // Skip badge events
}
let eventDate: number | null = null;
let isAllDay = true;
if (isIcsTask && icsTask?.icsEvent) {
eventDate = icsTask.icsEvent.dtstart.getTime();
isAllDay = icsTask.icsEvent.allDay;
} else {
eventDate =
task.metadata.dueDate ||
task.metadata.scheduledDate ||
task.metadata.startDate ||
null;
}
if (eventDate) {
const eventDateObj = new Date(eventDate);
const start = isAllDay
? startOfDay(eventDateObj)
: eventDateObj;
let end: Date | undefined = undefined;
let effectiveStart = start;
if (isIcsTask && icsTask?.icsEvent?.dtend) {
end = icsTask.icsEvent.dtend;
} else if (
task.metadata.startDate &&
task.metadata.dueDate &&
task.metadata.startDate !== task.metadata.dueDate
) {
const taskStart = startOfDay(
new Date(task.metadata.startDate),
);
const taskDue = startOfDay(new Date(task.metadata.dueDate));
if (isBefore(taskStart, taskDue)) {
end = addDays(taskDue, 1);
effectiveStart = taskStart;
}
}
let eventColor: string | undefined;
if (isIcsTask && icsTask?.icsEvent?.source?.color) {
eventColor = icsTask.icsEvent.source.color;
} else {
eventColor = task.completed ? "grey" : undefined;
}
this.events.push({
...task,
title: task.content,
start: effectiveStart,
end,
allDay: isAllDay,
color: eventColor,
});
}
});
this.events.sort((a, b) => a.start.getTime() - b.start.getTime());
}
private convertTasksToTGEvents(): AdapterCalendarEvent[] {
const tasksWithDates = this.tasks.filter((task) =>
hasDateInformation(task),
);
return tasksToCalendarEvents(tasksWithDates);
}
private getTasksForDate(date: Date): Task[] {
const targetTime = this.normalizeDateToDay(date).getTime();
return this.tasks.filter((task) => {
if (task.metadata.dueDate) {
const dueDate = this.normalizeDateToDay(
new Date(task.metadata.dueDate),
);
if (dueDate.getTime() === targetTime) return true;
}
if (task.metadata.scheduledDate) {
const scheduledDate = this.normalizeDateToDay(
new Date(task.metadata.scheduledDate),
);
if (scheduledDate.getTime() === targetTime) return true;
}
if (task.metadata.startDate) {
const startDate = this.normalizeDateToDay(
new Date(task.metadata.startDate),
);
if (startDate.getTime() === targetTime) return true;
}
return false;
});
}
// ============================================
// Utilities
// ============================================
private getViewUnit(): moment.unitOfTime.DurationConstructor {
// Check for custom view first
if (this.isCustomView(this.currentViewMode)) {
const customConfig = this.getCustomViewConfig(this.currentViewMode);
if (customConfig) {
// Use the base view type's unit
switch (customConfig.baseViewType) {
case "month":
return "month";
case "week":
return "week";
case "day":
return "day";
}
}
}
switch (this.currentViewMode) {
case "year":
return "year";
case "month":
return "month";
case "week":
return "week";
case "day":
return "day";
case "agenda":
return "week";
default:
return "month";
}
}
/**
* Get the effective first day of week, respecting:
* 1. Custom view config (if specified)
* 2. Base calendar config (if specified)
* 3. System/locale default from moment
*
* @param customViewConfig Optional custom view config to check first
* @returns 0-6 (Sunday to Saturday)
*/
private getEffectiveFirstDayOfWeek(
customViewConfig?: CustomCalendarViewConfig | null,
): 0 | 1 | 2 | 3 | 4 | 5 | 6 {
// Check custom view config first
if (customViewConfig?.calendarConfig?.firstDayOfWeek !== undefined) {
return customViewConfig.calendarConfig.firstDayOfWeek;
}
// Check base calendar config
const baseConfig = this.getEffectiveCalendarConfig();
if (baseConfig.firstDayOfWeek !== undefined) {
// Ensure it's a valid value (0-6)
const baseFdow = baseConfig.firstDayOfWeek;
if (baseFdow >= 0 && baseFdow <= 6) {
return baseFdow as 0 | 1 | 2 | 3 | 4 | 5 | 6;
}
}
// Fall back to system/locale default from moment
const localeFirstDay = moment.localeData().firstDayOfWeek();
// moment returns 0-6
if (localeFirstDay >= 0 && localeFirstDay <= 6) {
return localeFirstDay as 0 | 1 | 2 | 3 | 4 | 5 | 6;
}
// Fallback to Sunday if somehow invalid
return 0;
}
/**
* Get the start of week for a given date, respecting the configured first day of week
*/
private getStartOfWeekWithConfig(
date: moment.Moment,
customViewConfig?: CustomCalendarViewConfig | null,
): moment.Moment {
const firstDayOfWeek =
this.getEffectiveFirstDayOfWeek(customViewConfig);
const cloned = date.clone();
const currentDay = cloned.day();
const diff = (currentDay - firstDayOfWeek + 7) % 7;
return cloned.subtract(diff, "days").startOf("day");
}
/**
* Get the end of week for a given date, respecting the configured first day of week
*/
private getEndOfWeekWithConfig(
date: moment.Moment,
customViewConfig?: CustomCalendarViewConfig | null,
): moment.Moment {
const startOfWeek = this.getStartOfWeekWithConfig(
date,
customViewConfig,
);
return startOfWeek.clone().add(6, "days").endOf("day");
}
private getCurrentDateDisplay(): string {
// Check for custom view first
if (this.isCustomView(this.currentViewMode)) {
const customConfig = this.getCustomViewConfig(this.currentViewMode);
if (customConfig) {
// Use the base view type's format, with custom view name prefix
const prefix = ``;
switch (customConfig.baseViewType) {
case "month":
return prefix + this.currentDate.format("MMMM/YYYY");
case "week": {
// Use config-aware week start/end
const startOfWeek = this.getStartOfWeekWithConfig(
this.currentDate,
customConfig,
);
const endOfWeek = this.getEndOfWeekWithConfig(
this.currentDate,
customConfig,
);
if (startOfWeek.month() !== endOfWeek.month()) {
return (
prefix +
`${startOfWeek.format("MMM D")} - ${endOfWeek.format("MMM D, YYYY")}`
);
}
return (
prefix +
`${startOfWeek.format("MMM D")} - ${endOfWeek.format("D, YYYY")}`
);
}
case "day":
return (
prefix +
this.currentDate.format("dddd, MMMM D, YYYY")
);
}
}
}
switch (this.currentViewMode) {
case "year":
return this.currentDate.format("YYYY");
case "month":
return this.currentDate.format("MMMM/YYYY");
case "week": {
// Use config-aware week start/end
const startOfWeek = this.getStartOfWeekWithConfig(
this.currentDate,
);
const endOfWeek = this.getEndOfWeekWithConfig(this.currentDate);
if (startOfWeek.month() !== endOfWeek.month()) {
if (startOfWeek.year() !== endOfWeek.year()) {
return `${startOfWeek.format(
"MMM D, YYYY",
)} - ${endOfWeek.format("MMM D, YYYY")}`;
}
return `${startOfWeek.format("MMM D")} - ${endOfWeek.format(
"MMM D, YYYY",
)}`;
}
return `${startOfWeek.format("MMM D")} - ${endOfWeek.format(
"D, YYYY",
)}`;
}
case "day":
return this.currentDate.format("dddd, MMMM D, YYYY");
case "agenda": {
const endOfAgenda = this.currentDate.clone().add(6, "days");
return `${this.currentDate.format(
"MMM D",
)} - ${endOfAgenda.format("MMM D, YYYY")}`;
}
default:
return this.currentDate.format("MMMM YYYY");
}
}
private getEffectiveCalendarConfig(): Partial<CalendarSpecificConfig> {
const baseCfg = this.plugin.settings.viewConfiguration.find(
(v) => v.id === this.viewId,
)?.specificConfig as Partial<CalendarSpecificConfig> | undefined;
return { ...(baseCfg ?? {}), ...(this.configOverride ?? {}) };
}
private formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
private normalizeDateToDay(date: Date): Date {
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
return normalized;
}
private isSameDay(date1: Date, date2: Date): boolean {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
}
public precomputeBadgeEventsForCurrentView(): void {
// Legacy compatibility - no-op
}
private invalidateBadgeEventsCache(): void {
this.badgeEventsCache.clear();
}
}