feat(calendar): add custom calendar views with configurable options

This commit is contained in:
Quorafind 2025-11-29 16:17:01 +08:00
parent 6987fdd2ba
commit e8c1dd1ec4
21 changed files with 4430 additions and 204 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "packages/calendar"]
path = packages/calendar
url = https://github.com/taskgenius/calendar.git

1
packages/calendar Submodule

@ -0,0 +1 @@
Subproject commit 14fc6c499353befebf7748172497d068ac699a5e

View file

@ -125,6 +125,89 @@ export interface CalendarSpecificConfig {
workingHoursEnd?: number; // End hour (0-23), default: 18
}
/**
* Day filter configuration for custom calendar views
* Supports preset filters and custom day selection
*/
export interface DayFilterConfig {
/** Filter type: none, preset, or custom */
type: "none" | "hideWeekends" | "hideWeekdays" | "customDays";
/** Custom hidden days (0=Sunday, 1=Monday, ..., 6=Saturday) */
hiddenDays?: number[];
}
/**
* Time filter configuration for week/day views
* Controls which time slots are displayed
*/
export interface TimeFilterConfig {
/** Whether time filtering is enabled */
enabled: boolean;
/** Filter type: working hours preset or custom range */
type: "workingHours" | "custom";
/** Start hour (0-23), default: 9 */
startHour: number;
/** End hour (0-23), default: 18 */
endHour: number;
}
/**
* Custom calendar view configuration
* Allows users to create customized calendar views based on Month/Week/Day
*/
export interface CustomCalendarViewConfig {
/** Unique identifier for this custom view */
id: string;
/** Display name shown in UI */
name: string;
/** Icon identifier (Lucide icon name) */
icon: string;
/** Base view type to inherit from */
baseViewType: "month" | "week" | "day" | "agenda" | "year";
/** Whether this view is enabled and visible */
enabled: boolean;
/** Display order in the view switcher */
order: number;
/** Calendar-specific configuration */
calendarConfig: CustomCalendarConfig;
/** Creation timestamp */
createdAt: number;
/** Last update timestamp */
updatedAt: number;
}
/**
* Calendar configuration options for custom views
* Maps to @taskgenius/calendar CalendarConfig options
*/
export interface CustomCalendarConfig {
// Basic configuration
/** First day of week: 0=Sunday, 1=Monday, ..., 6=Saturday */
firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Show week numbers in month view */
showWeekNumbers?: boolean;
/** Show event count badges on date cells */
showEventCounts?: boolean;
/** Maximum events to show per row before "+N more" indicator (month view) */
maxEventsPerRow?: number;
/** Day filter configuration */
dayFilter?: DayFilterConfig;
/** Time filter configuration (week/day views only) */
timeFilter?: TimeFilterConfig;
/** Custom date format configuration */
dateFormats?: {
/** Month header format (e.g., "yyyy M") */
monthHeader?: string;
/** Day header format (e.g., "yyyy M d") */
dayHeader?: string;
};
}
export interface GanttSpecificConfig {
viewType: "gantt"; // Discriminator
showTaskLabels: boolean;
@ -900,6 +983,9 @@ export interface TaskProgressBarSettings {
// Workspace Settings
workspaces?: WorkspacesConfig;
// Custom Calendar Views Settings
customCalendarViews?: CustomCalendarViewConfig[];
}
/** Define the default settings */
@ -1811,6 +1897,9 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
},
useWorkspaceSideLeaves: false,
},
// Custom Calendar Views Defaults
customCalendarViews: [],
};
// Helper function to get view settings safely

File diff suppressed because it is too large Load diff

View file

@ -58,8 +58,13 @@ export class CalendarEventComponent extends Component {
this.app = params.app;
// Create the main element
// Include tg-event-block for @taskgenius/calendar InteractionController compatibility
this.eventEl = createEl("div", {
cls: ["calendar-event", `calendar-event-${this.viewType}`],
cls: [
"calendar-event",
`calendar-event-${this.viewType}`,
"tg-event-block",
],
});
if (this.event.metadata.project) {
@ -79,6 +84,8 @@ export class CalendarEventComponent extends Component {
this.eventEl.dataset.filePath = this.event.filePath;
}
this.eventEl.dataset.eventId = this.event.id;
// data-eid is used by @taskgenius/calendar InteractionController for event lookup
this.eventEl.dataset.eid = this.event.id;
}
override onload(): void {

View file

@ -1,9 +1,23 @@
/**
* @deprecated This file is deprecated. Use `tg-agenda-view.ts` instead.
*
* The new TGAgendaView extends @taskgenius/calendar's BaseView for better
* integration with the calendar system. This legacy implementation is kept
* for backward compatibility but should not be used for new development.
*
* @see {@link ./tg-agenda-view.ts} for the new implementation
*/
import { App, Component, moment } from "obsidian";
import { CalendarEvent } from '@/components/features/calendar/index';
import { CalendarEvent } from "@/components/features/calendar/index";
import { renderCalendarEvent } from "../rendering/event-renderer"; // Use new renderer
import { CalendarViewComponent, CalendarViewOptions } from "./base-view"; // Import base class
import TaskProgressBarPlugin from "@/index"; // Import plugin type
/**
* @deprecated Use TGAgendaView from `tg-agenda-view.ts` instead.
* This class extends the legacy CalendarViewComponent (Obsidian Component).
* The new TGAgendaView extends @taskgenius/calendar's BaseView for unified view management.
*/
export class AgendaView extends CalendarViewComponent {
// Extend base class
// private containerEl: HTMLElement; // Inherited
@ -18,7 +32,7 @@ export class AgendaView extends CalendarViewComponent {
containerEl: HTMLElement,
currentDate: moment.Moment,
events: CalendarEvent[],
options: CalendarViewOptions = {} // Use base options, default to empty
options: CalendarViewOptions = {}, // Use base options, default to empty
) {
super(plugin, app, containerEl, events, options); // Call base constructor
this.app = app;
@ -43,11 +57,11 @@ export class AgendaView extends CalendarViewComponent {
rangeStart,
rangeEnd,
undefined,
"[]"
"[]",
);
})
.sort(
(a, b) => moment(a.start).valueOf() - moment(b.start).valueOf()
(a, b) => moment(a.start).valueOf() - moment(b.start).valueOf(),
); // Ensure sorting by start time
// 3. Group events by their start day
@ -67,8 +81,8 @@ export class AgendaView extends CalendarViewComponent {
if (Object.keys(eventsByDay).length === 0) {
this.containerEl.setText(
`No upcoming events from ${rangeStart.format(
"MMM D"
)} to ${rangeEnd.format("MMM D, YYYY")}.`
"MMM D",
)} to ${rangeEnd.format("MMM D, YYYY")}.`,
);
return;
}
@ -83,7 +97,7 @@ export class AgendaView extends CalendarViewComponent {
// Left column for the date
const dateColumn = daySection.createDiv(
"agenda-day-date-column"
"agenda-day-date-column",
);
const dayHeader = dateColumn.createDiv("agenda-day-header");
dayHeader.textContent = currentDayIter.format("dddd, MMMM D");
@ -93,7 +107,7 @@ export class AgendaView extends CalendarViewComponent {
// Right column for the events
const eventsColumn = daySection.createDiv(
"agenda-day-events-column"
"agenda-day-events-column",
);
const eventsList = eventsColumn.createDiv("agenda-events-list"); // Keep the original list class if needed
@ -124,8 +138,8 @@ export class AgendaView extends CalendarViewComponent {
console.log(
`Rendered Agenda View component from ${rangeStart.format(
"YYYY-MM-DD"
)} to ${rangeEnd.format("YYYY-MM-DD")}`
"YYYY-MM-DD",
)} to ${rangeEnd.format("YYYY-MM-DD")}`,
);
}

View file

@ -1,7 +1,27 @@
/**
* @deprecated This file is deprecated. Use @taskgenius/calendar's BaseView instead.
*
* For new calendar views, extend BaseView from @taskgenius/calendar:
*
* ```typescript
* import { BaseView, type ViewMeta } from "@taskgenius/calendar";
*
* class MyView extends BaseView {
* static meta: ViewMeta = { type: "my-view", label: "My View", order: 50 };
* render(container: HTMLElement, events: CalendarEvent[]): void { ... }
* }
* ```
*
* @see {@link ./tg-agenda-view.ts} for an example of the new pattern
* @see {@link ./tg-year-view.ts} for another example
*/
import { App, Component } from "obsidian";
import { CalendarEvent } from '@/components/features/calendar/index';
import { CalendarEvent } from "@/components/features/calendar/index";
import TaskProgressBarPlugin from "@/index";
/**
* @deprecated Use @taskgenius/calendar's BaseView instead.
*/
interface EventMap {
onEventClick: (ev: MouseEvent, event: CalendarEvent) => void;
onEventHover: (ev: MouseEvent, event: CalendarEvent) => void;
@ -10,7 +30,7 @@ interface EventMap {
day: number,
options: {
behavior: "open-quick-capture" | "open-task-view";
}
},
) => void;
onDayHover: (ev: MouseEvent, day: number) => void;
onMonthClick: (ev: MouseEvent, month: number) => void;
@ -21,12 +41,20 @@ interface EventMap {
onEventComplete: (ev: MouseEvent, event: CalendarEvent) => void;
}
/**
* @deprecated Use @taskgenius/calendar's BaseView options instead.
*/
// Combine event handlers into a single options object, making them optional
export interface CalendarViewOptions extends Partial<EventMap> {
// Add other common view options here if needed
getBadgeEventsForDate?: (date: Date) => CalendarEvent[];
}
/**
* @deprecated Use @taskgenius/calendar's BaseView instead.
* This class extends Obsidian's Component for lifecycle management.
* The new BaseView from @taskgenius/calendar provides unified view management.
*/
export abstract class CalendarViewComponent extends Component {
protected containerEl: HTMLElement;
protected events: CalendarEvent[];
@ -37,7 +65,7 @@ export abstract class CalendarViewComponent extends Component {
app: App,
containerEl: HTMLElement,
events: CalendarEvent[],
options: CalendarViewOptions = {} // Provide default empty options
options: CalendarViewOptions = {}, // Provide default empty options
) {
super(); // Call the base class constructor
this.containerEl = containerEl;

View file

@ -0,0 +1,380 @@
/**
* TGAgendaView - Agenda view extending @taskgenius/calendar BaseView
*
* This view displays upcoming events in a list format grouped by day.
* Users can copy this view to create their own custom agenda-style views.
*/
import { App, moment } from "obsidian";
import {
BaseView,
type ViewMeta,
type ViewRenderOptions,
type CalendarEvent as TGCalendarEvent,
} from "@taskgenius/calendar";
import { CalendarEvent } from "@/components/features/calendar/index";
import { renderCalendarEvent } from "../rendering/event-renderer";
import TaskProgressBarPlugin from "@/index";
/**
* Options for AgendaView customization
*/
export interface AgendaViewOptions {
/** Number of days to show in the agenda (default: 7) */
daysToShow?: number;
/** Whether to show empty days (default: false) */
showEmptyDays?: boolean;
/** Date format for day headers (default: "dddd, MMMM D") */
dayHeaderFormat?: string;
/** Event click callback */
onEventClick?: (ev: MouseEvent, event: CalendarEvent) => void;
/** Event hover callback */
onEventHover?: (ev: MouseEvent, event: CalendarEvent) => void;
/** Event context menu callback */
onEventContextMenu?: (ev: MouseEvent, event: CalendarEvent) => void;
/** Event complete callback */
onEventComplete?: (ev: MouseEvent, event: CalendarEvent) => void;
}
/**
* AgendaView - Displays events in a list format grouped by day
*
* Extends BaseView from @taskgenius/calendar for integration with the calendar system.
*
* @example Creating a custom agenda view
* ```typescript
* class MyCustomAgendaView extends TGAgendaView {
* static override meta: ViewMeta = {
* type: 'my-agenda',
* label: 'My Agenda',
* shortLabel: 'MA',
* order: 50
* };
*
* protected override daysToShow = 14; // Show 2 weeks
* }
* ```
*/
export class TGAgendaView<T = unknown> extends BaseView<T> {
static meta: ViewMeta = {
type: "agenda",
label: "Agenda",
shortLabel: "A",
order: 40,
};
/** Plugin instance for accessing settings and services */
protected plugin: TaskProgressBarPlugin | null = null;
/** App instance for Obsidian integration */
protected app: App | null = null;
/** Custom options for this view */
protected viewOptions: AgendaViewOptions = {};
/** Number of days to display (can be overridden by subclasses) */
protected daysToShow = 7;
/** Whether to show days with no events */
protected showEmptyDays = false;
/** Format string for day headers */
protected dayHeaderFormat = "dddd, MMMM D";
/** Child components for cleanup */
private childComponents: { unload: () => void }[] = [];
/**
* Set plugin and app references (called after construction)
*/
setPluginContext(plugin: TaskProgressBarPlugin, app: App): void {
this.plugin = plugin;
this.app = app;
}
/**
* Set custom options for this view
*/
setOptions(options: AgendaViewOptions): void {
this.viewOptions = options;
if (options.daysToShow !== undefined) {
this.daysToShow = options.daysToShow;
}
if (options.showEmptyDays !== undefined) {
this.showEmptyDays = options.showEmptyDays;
}
if (options.dayHeaderFormat !== undefined) {
this.dayHeaderFormat = options.dayHeaderFormat;
}
}
/**
* Render the agenda view
*/
render(
container: HTMLElement,
events: TGCalendarEvent[],
_options?: ViewRenderOptions,
): void {
// Cleanup previous child components
this.cleanupChildren();
container.empty();
container.addClass("view-agenda", "tg-agenda-view");
// Convert TGCalendarEvent[] to CalendarEvent[] for rendering
const calendarEvents = this.convertEvents(events);
// Calculate date range
const currentDate = this.context.adapter.create(
this.context.currentDate as unknown as Date,
);
const rangeStart = moment(currentDate as unknown as Date).startOf(
"day",
);
const rangeEnd = rangeStart
.clone()
.add(this.daysToShow - 1, "days")
.endOf("day");
// Filter and sort events
const agendaEvents = this.filterAndSortEvents(
calendarEvents,
rangeStart,
rangeEnd,
);
// Group events by day
const eventsByDay = this.groupEventsByDay(agendaEvents);
// Render empty state if no events
if (Object.keys(eventsByDay).length === 0 && !this.showEmptyDays) {
this.renderEmptyState(container, rangeStart, rangeEnd);
return;
}
// Render each day
this.renderDays(container, eventsByDay, rangeStart, rangeEnd);
}
/**
* Convert TGCalendarEvent to CalendarEvent
*/
protected convertEvents(events: TGCalendarEvent[]): CalendarEvent[] {
return events.map((e) => ({
...e,
start: new Date(e.start),
end: e.end ? new Date(e.end) : undefined,
allDay: this.isAllDayEvent(e),
metadata: e.metadata as CalendarEvent["metadata"],
})) as unknown as CalendarEvent[];
}
/**
* Check if event is all-day
*/
protected isAllDayEvent(event: TGCalendarEvent): boolean {
const start = new Date(event.start);
const end = event.end ? new Date(event.end) : start;
return (
start.getHours() === 0 &&
start.getMinutes() === 0 &&
(end.getHours() === 0 ||
(end.getHours() === 23 && end.getMinutes() === 59))
);
}
/**
* Filter events within the date range and sort by start time
*/
protected filterAndSortEvents(
events: CalendarEvent[],
rangeStart: moment.Moment,
rangeEnd: moment.Moment,
): CalendarEvent[] {
return events
.filter((event) => {
const eventStart = moment(event.start);
return eventStart.isBetween(
rangeStart,
rangeEnd,
undefined,
"[]",
);
})
.sort(
(a, b) => moment(a.start).valueOf() - moment(b.start).valueOf(),
);
}
/**
* Group events by their start day
*/
protected groupEventsByDay(
events: CalendarEvent[],
): Record<string, CalendarEvent[]> {
const eventsByDay: Record<string, CalendarEvent[]> = {};
events.forEach((event) => {
const dateStr = moment(event.start).format("YYYY-MM-DD");
if (!eventsByDay[dateStr]) {
eventsByDay[dateStr] = [];
}
eventsByDay[dateStr].push(event);
});
return eventsByDay;
}
/**
* Render empty state when no events
*/
protected renderEmptyState(
container: HTMLElement,
rangeStart: moment.Moment,
rangeEnd: moment.Moment,
): void {
const emptyEl = container.createDiv("agenda-empty-state");
emptyEl.setText(
`No upcoming events from ${rangeStart.format(
"MMM D",
)} to ${rangeEnd.format("MMM D, YYYY")}.`,
);
}
/**
* Render all days in the range
*/
protected renderDays(
container: HTMLElement,
eventsByDay: Record<string, CalendarEvent[]>,
rangeStart: moment.Moment,
rangeEnd: moment.Moment,
): void {
const currentDayIter = rangeStart.clone();
while (currentDayIter.isSameOrBefore(rangeEnd, "day")) {
const dateStr = currentDayIter.format("YYYY-MM-DD");
const dayEvents = eventsByDay[dateStr] || [];
if (dayEvents.length > 0 || this.showEmptyDays) {
this.renderDaySection(
container,
currentDayIter.clone(),
dayEvents,
);
}
currentDayIter.add(1, "day");
}
}
/**
* Render a single day section
*/
protected renderDaySection(
container: HTMLElement,
date: moment.Moment,
events: CalendarEvent[],
): void {
const daySection = container.createDiv("agenda-day-section");
// Date column
const dateColumn = daySection.createDiv("agenda-day-date-column");
const dayHeader = dateColumn.createDiv("agenda-day-header");
dayHeader.textContent = date.format(this.dayHeaderFormat);
if (date.isSame(moment(), "day")) {
dayHeader.addClass("is-today");
}
// Events column
const eventsColumn = daySection.createDiv("agenda-day-events-column");
if (events.length === 0) {
const emptyDay = eventsColumn.createDiv("agenda-empty-day");
emptyDay.setText("No events");
} else {
const eventsList = eventsColumn.createDiv("agenda-events-list");
this.renderEvents(eventsList, events);
}
}
/**
* Render events for a day
*/
protected renderEvents(
container: HTMLElement,
events: CalendarEvent[],
): void {
// Sort events by time
const sortedEvents = [...events].sort((a, b) => {
const timeA = a.start ? moment(a.start).valueOf() : 0;
const timeB = b.start ? moment(b.start).valueOf() : 0;
return timeA - timeB;
});
sortedEvents.forEach((event) => {
const eventItem = container.createDiv("agenda-event-item");
if (this.app) {
const { eventEl, component } = renderCalendarEvent({
event,
viewType: "agenda",
app: this.app,
onEventClick: this.viewOptions.onEventClick,
onEventHover: this.viewOptions.onEventHover,
onEventContextMenu: this.viewOptions.onEventContextMenu,
onEventComplete: this.viewOptions.onEventComplete,
});
this.childComponents.push(component);
eventItem.appendChild(eventEl);
} else {
// Fallback rendering without app context
eventItem.textContent = event.title;
if (event.color) {
eventItem.style.borderLeftColor = event.color;
}
}
});
}
/**
* Cleanup child components
*/
protected cleanupChildren(): void {
this.childComponents.forEach((c) => c.unload());
this.childComponents = [];
}
/**
* Navigation unit for prev/next buttons
*/
override getNavigationUnit(): "week" {
return "week";
}
/**
* Header title for this view
*/
override getHeaderTitle(): string {
const currentDate = this.context.adapter.create(
this.context.currentDate as unknown as Date,
);
const start = moment(currentDate as unknown as Date);
const end = start.clone().add(this.daysToShow - 1, "days");
if (start.isSame(end, "month")) {
return `${start.format("MMM D")} - ${end.format("D, YYYY")}`;
}
return `${start.format("MMM D")} - ${end.format("MMM D, YYYY")}`;
}
/**
* Cleanup when view is unmounted
*/
override onUnmount(): void {
this.cleanupChildren();
super.onUnmount();
}
}

View file

@ -0,0 +1,575 @@
/**
* TGYearView - Year view extending @taskgenius/calendar BaseView
*
* This view displays a full year with 12 mini-month calendars.
* Users can copy this view to create their own custom year-style views.
*/
import { App, debounce, moment } from "obsidian";
import {
BaseView,
type ViewMeta,
type ViewRenderOptions,
type CalendarEvent as TGCalendarEvent,
} from "@taskgenius/calendar";
import { CalendarEvent } from "@/components/features/calendar/index";
import {
CalendarSpecificConfig,
getViewSettingOrDefault,
} from "@/common/setting-definition";
import TaskProgressBarPlugin from "@/index";
/**
* Options for YearView customization
*/
export interface YearViewOptions {
/** First day of week (0=Sunday, 1=Monday, etc.) */
firstDayOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Whether to hide weekends */
hideWeekends?: boolean;
/** Day click callback */
onDayClick?: (
ev: MouseEvent,
timestamp: number,
options: { behavior: "open-quick-capture" | "open-task-view" },
) => void;
/** Day hover callback */
onDayHover?: (ev: MouseEvent, timestamp: number) => void;
/** Month click callback */
onMonthClick?: (ev: MouseEvent, timestamp: number) => void;
/** Month hover callback */
onMonthHover?: (ev: MouseEvent, timestamp: number) => void;
}
/**
* YearView - Displays a full year with 12 mini-month calendars
*
* Extends BaseView from @taskgenius/calendar for integration with the calendar system.
*
* @example Creating a custom year view
* ```typescript
* class MyCustomYearView extends TGYearView {
* static override meta: ViewMeta = {
* type: 'my-year',
* label: 'My Year',
* shortLabel: 'MY',
* order: 60
* };
*
* // Override to customize mini-month rendering
* protected override renderMiniMonth(container: HTMLElement, month: number, year: number) {
* // Custom rendering logic
* }
* }
* ```
*/
export class TGYearView<T = unknown> extends BaseView<T> {
static meta: ViewMeta = {
type: "year",
label: "Year",
shortLabel: "Y",
order: 50,
};
/** Plugin instance for accessing settings */
protected plugin: TaskProgressBarPlugin | null = null;
/** App instance for Obsidian integration */
protected app: App | null = null;
/** Custom options for this view */
protected viewOptions: YearViewOptions = {};
/** Override config from settings */
protected overrideConfig?: Partial<CalendarSpecificConfig>;
/** DOM event cleanup functions */
private cleanupFns: (() => void)[] = [];
/**
* Set plugin and app references (called after construction)
*/
setPluginContext(plugin: TaskProgressBarPlugin, app: App): void {
this.plugin = plugin;
this.app = app;
}
/**
* Set custom options for this view
*/
setOptions(options: YearViewOptions): void {
this.viewOptions = options;
}
/**
* Set override config
*/
setOverrideConfig(config?: Partial<CalendarSpecificConfig>): void {
this.overrideConfig = config;
}
/**
* Render the year view
*/
render(
container: HTMLElement,
events: TGCalendarEvent[],
_options?: ViewRenderOptions,
): void {
// Cleanup previous event listeners
this.cleanupEventListeners();
const currentDate = this.context.adapter.create(
this.context.currentDate as unknown as Date,
);
const year = moment(currentDate as unknown as Date).year();
container.empty();
container.addClass("view-year", "tg-year-view");
// Convert events
const calendarEvents = this.convertEvents(events);
// Create year grid
const yearGrid = container.createDiv("calendar-year-grid");
// Filter events for this year
const yearStart = moment({ year, month: 0, day: 1 });
const yearEnd = moment({ year, month: 11, day: 31 });
const yearEvents = this.filterEventsForYear(
calendarEvents,
yearStart,
yearEnd,
);
// Get settings
const { firstDayOfWeek, hideWeekends } = this.getEffectiveSettings();
// Apply weekend hiding class
if (hideWeekends) {
container.addClass("hide-weekends");
} else {
container.removeClass("hide-weekends");
}
// Render 12 months
for (let month = 0; month < 12; month++) {
this.renderMiniMonth(
yearGrid,
year,
month,
yearEvents,
firstDayOfWeek,
hideWeekends,
);
}
}
/**
* Convert TGCalendarEvent to CalendarEvent
*/
protected convertEvents(events: TGCalendarEvent[]): CalendarEvent[] {
return events.map((e) => ({
...e,
start: new Date(e.start),
end: e.end ? new Date(e.end) : undefined,
allDay: this.isAllDayEvent(e),
metadata: e.metadata as CalendarEvent["metadata"],
})) as unknown as CalendarEvent[];
}
/**
* Check if event is all-day
*/
protected isAllDayEvent(event: TGCalendarEvent): boolean {
const start = new Date(event.start);
const end = event.end ? new Date(event.end) : start;
return (
start.getHours() === 0 &&
start.getMinutes() === 0 &&
(end.getHours() === 0 ||
(end.getHours() === 23 && end.getMinutes() === 59))
);
}
/**
* Filter events for the year
*/
protected filterEventsForYear(
events: CalendarEvent[],
yearStart: moment.Moment,
yearEnd: moment.Moment,
): CalendarEvent[] {
return events.filter((e) => {
const start = moment(e.start);
const end = e.end ? moment(e.end) : start;
return (
start.isSameOrBefore(yearEnd.endOf("day")) &&
end.isSameOrAfter(yearStart.startOf("day"))
);
});
}
/**
* Get effective settings from plugin config and overrides
*/
protected getEffectiveSettings(): {
firstDayOfWeek: number;
hideWeekends: boolean;
} {
let firstDayOfWeek = 0;
let hideWeekends = false;
if (this.plugin) {
const viewConfig = getViewSettingOrDefault(this.plugin, "calendar");
const specificConfig =
viewConfig.specificConfig as CalendarSpecificConfig;
firstDayOfWeek =
this.overrideConfig?.firstDayOfWeek ??
this.viewOptions.firstDayOfWeek ??
specificConfig.firstDayOfWeek ??
0;
hideWeekends =
this.overrideConfig?.hideWeekends ??
this.viewOptions.hideWeekends ??
specificConfig?.hideWeekends ??
false;
} else {
firstDayOfWeek = this.viewOptions.firstDayOfWeek ?? 0;
hideWeekends = this.viewOptions.hideWeekends ?? false;
}
return { firstDayOfWeek, hideWeekends };
}
/**
* Render a single mini-month
*/
protected renderMiniMonth(
container: HTMLElement,
year: number,
month: number,
yearEvents: CalendarEvent[],
firstDayOfWeek: number,
hideWeekends: boolean,
): void {
const monthContainer = container.createDiv("calendar-mini-month");
const monthMoment = moment({ year, month, day: 1 });
// Month header
const monthHeader = monthContainer.createDiv("mini-month-header");
monthHeader.textContent = monthMoment.format("MMMM");
monthHeader.style.cursor = "pointer";
// Month header click
const monthClickHandler = (ev: MouseEvent) => {
if (this.viewOptions.onMonthClick) {
this.viewOptions.onMonthClick(ev, monthMoment.valueOf());
}
};
monthHeader.addEventListener("click", monthClickHandler);
this.cleanupFns.push(() =>
monthHeader.removeEventListener("click", monthClickHandler),
);
// Month header hover
const monthHoverHandler = (ev: MouseEvent) => {
if (this.viewOptions.onMonthHover) {
this.viewOptions.onMonthHover(ev, monthMoment.valueOf());
}
};
monthHeader.addEventListener("mouseenter", monthHoverHandler);
this.cleanupFns.push(() =>
monthHeader.removeEventListener("mouseenter", monthHoverHandler),
);
// Month body
const monthBody = monthContainer.createDiv("mini-month-body");
const daysWithEvents = this.calculateDaysWithEvents(
monthMoment,
yearEvents,
);
this.renderMiniMonthGrid(
monthBody,
monthMoment,
daysWithEvents,
firstDayOfWeek,
hideWeekends,
);
}
/**
* Calculate which days have events
*/
protected calculateDaysWithEvents(
monthMoment: moment.Moment,
events: CalendarEvent[],
): Set<number> {
const days = new Set<number>();
const monthStart = monthMoment.clone().startOf("month");
const monthEnd = monthMoment.clone().endOf("month");
events.forEach((event) => {
const datesToCheck = [
event.start,
event.metadata?.scheduledDate,
event.metadata?.dueDate,
];
datesToCheck.forEach((dateInput) => {
if (dateInput) {
const dateMoment = moment(dateInput);
if (
dateMoment.isBetween(monthStart, monthEnd, "day", "[]")
) {
days.add(dateMoment.date());
}
}
});
});
return days;
}
/**
* Render the mini-month grid
*/
protected renderMiniMonthGrid(
container: HTMLElement,
monthMoment: moment.Moment,
daysWithEvents: Set<number>,
firstDayOfWeek: number,
hideWeekends: boolean,
): void {
container.empty();
container.addClass("mini-month-grid");
// Weekday headers
this.renderWeekdayHeaders(container, firstDayOfWeek, hideWeekends);
// Calculate grid boundaries
const { gridStart, gridEnd } = this.calculateGridBoundaries(
monthMoment,
firstDayOfWeek,
hideWeekends,
);
// Render day cells
this.renderDayCells(
container,
monthMoment,
gridStart,
gridEnd,
daysWithEvents,
hideWeekends,
);
// Setup event listeners for the grid
this.setupGridEventListeners(container);
}
/**
* Render weekday headers
*/
protected renderWeekdayHeaders(
container: HTMLElement,
firstDayOfWeek: number,
hideWeekends: boolean,
): void {
const headerRow = container.createDiv("mini-weekday-header");
const weekdays = moment.weekdaysMin(true);
const rotatedWeekdays = [
...weekdays.slice(firstDayOfWeek),
...weekdays.slice(0, firstDayOfWeek),
];
const filteredWeekdays = hideWeekends
? rotatedWeekdays.filter((_, index) => {
const dayOfWeek = (firstDayOfWeek + index) % 7;
return dayOfWeek !== 0 && dayOfWeek !== 6;
})
: rotatedWeekdays;
filteredWeekdays.forEach((day) => {
headerRow.createDiv("mini-weekday").textContent = day;
});
}
/**
* Calculate grid start and end dates
*/
protected calculateGridBoundaries(
monthMoment: moment.Moment,
firstDayOfWeek: number,
hideWeekends: boolean,
): { gridStart: moment.Moment; gridEnd: moment.Moment } {
const monthStart = monthMoment.clone().startOf("month");
const monthEnd = monthMoment.clone().endOf("month");
let gridStart: moment.Moment;
let gridEnd: moment.Moment;
if (hideWeekends) {
gridStart = monthStart.clone();
const daysToSubtractStart =
(monthStart.weekday() - firstDayOfWeek + 7) % 7;
gridStart.subtract(daysToSubtractStart, "days");
while (gridStart.day() === 0 || gridStart.day() === 6) {
gridStart.add(1, "day");
}
gridEnd = monthEnd.clone();
const daysToAddEnd =
(firstDayOfWeek + 4 - monthEnd.weekday() + 7) % 7;
gridEnd.add(daysToAddEnd, "days");
while (gridEnd.day() === 0 || gridEnd.day() === 6) {
gridEnd.subtract(1, "day");
}
} else {
const daysToSubtractStart =
(monthStart.weekday() - firstDayOfWeek + 7) % 7;
gridStart = monthStart
.clone()
.subtract(daysToSubtractStart, "days");
const daysToAddEnd =
(firstDayOfWeek + 6 - monthEnd.weekday() + 7) % 7;
gridEnd = monthEnd.clone().add(daysToAddEnd, "days");
}
return { gridStart, gridEnd };
}
/**
* Render day cells
*/
protected renderDayCells(
container: HTMLElement,
monthMoment: moment.Moment,
gridStart: moment.Moment,
gridEnd: moment.Moment,
daysWithEvents: Set<number>,
hideWeekends: boolean,
): void {
const currentDayIter = gridStart.clone();
while (currentDayIter.isSameOrBefore(gridEnd, "day")) {
const isWeekend =
currentDayIter.day() === 0 || currentDayIter.day() === 6;
if (hideWeekends && isWeekend) {
currentDayIter.add(1, "day");
continue;
}
const cell = container.createEl("div", {
cls: "mini-day-cell",
attr: {
"data-date": currentDayIter.format("YYYY-MM-DD"),
},
});
const dayNumber = currentDayIter.date();
const isCurrentMonth = currentDayIter.isSame(monthMoment, "month");
cell.textContent = String(dayNumber);
if (!isCurrentMonth) {
cell.addClass("is-other-month");
}
if (currentDayIter.isSame(moment(), "day")) {
cell.addClass("is-today");
}
if (isCurrentMonth && daysWithEvents.has(dayNumber)) {
cell.addClass("has-events");
}
cell.style.cursor = isCurrentMonth ? "pointer" : "default";
currentDayIter.add(1, "day");
}
}
/**
* Setup event listeners for the grid
*/
protected setupGridEventListeners(container: HTMLElement): void {
// Click handler
const clickHandler = (ev: MouseEvent) => {
const target = ev.target as HTMLElement;
const cell = target.closest(".mini-day-cell");
if (cell) {
const dateStr = cell.getAttribute("data-date");
if (dateStr && this.viewOptions.onDayClick) {
this.viewOptions.onDayClick(ev, moment(dateStr).valueOf(), {
behavior: "open-task-view",
});
}
}
};
container.addEventListener("click", clickHandler);
this.cleanupFns.push(() =>
container.removeEventListener("click", clickHandler),
);
// Hover handler (debounced)
const hoverHandler = this.createDebouncedHoverHandler();
container.addEventListener("mouseover", hoverHandler);
this.cleanupFns.push(() =>
container.removeEventListener("mouseover", hoverHandler),
);
}
/**
* Create debounced hover handler
*/
protected createDebouncedHoverHandler(): (ev: MouseEvent) => void {
return debounce((ev: MouseEvent) => {
const target = ev.target as HTMLElement;
const cell = target.closest(".mini-day-cell");
if (cell) {
const dateStr = cell.getAttribute("data-date");
if (dateStr && this.viewOptions.onDayHover) {
this.viewOptions.onDayHover(ev, moment(dateStr).valueOf());
}
}
}, 200);
}
/**
* Cleanup event listeners
*/
protected cleanupEventListeners(): void {
this.cleanupFns.forEach((fn) => fn());
this.cleanupFns = [];
}
/**
* Navigation unit for prev/next buttons
*/
override getNavigationUnit(): "year" {
return "year";
}
/**
* Header title for this view
*/
override getHeaderTitle(): string {
const currentDate = this.context.adapter.create(
this.context.currentDate as unknown as Date,
);
return moment(currentDate as unknown as Date).format("YYYY");
}
/**
* Cleanup when view is unmounted
*/
override onUnmount(): void {
this.cleanupEventListeners();
super.onUnmount();
}
}

View file

@ -1,5 +1,14 @@
/**
* @deprecated This file is deprecated. Use `tg-year-view.ts` instead.
*
* The new TGYearView extends @taskgenius/calendar's BaseView for better
* integration with the calendar system. This legacy implementation is kept
* for backward compatibility but should not be used for new development.
*
* @see {@link ./tg-year-view.ts} for the new implementation
*/
import { App, Component, debounce, moment } from "obsidian";
import { CalendarEvent } from '@/components/features/calendar/index';
import { CalendarEvent } from "@/components/features/calendar/index";
import {
CalendarSpecificConfig,
getViewSettingOrDefault,
@ -8,7 +17,9 @@ import TaskProgressBarPlugin from "@/index"; // Import plugin type for settings
import { CalendarViewComponent, CalendarViewOptions } from "./base-view"; // Import base class
/**
* Renders the year view grid as a component.
* @deprecated Use TGYearView from `tg-year-view.ts` instead.
* This class extends the legacy CalendarViewComponent (Obsidian Component).
* The new TGYearView extends @taskgenius/calendar's BaseView for unified view management.
*/
export class YearView extends CalendarViewComponent {
// Extend base class
@ -18,8 +29,7 @@ export class YearView extends CalendarViewComponent {
private app: App; // Keep app reference
private plugin: TaskProgressBarPlugin; // Keep plugin reference
// Removed specific click/hover properties, use this.options
private overrideConfig?: Partial<CalendarSpecificConfig>;
private overrideConfig?: Partial<CalendarSpecificConfig>;
constructor(
app: App,
@ -28,7 +38,7 @@ export class YearView extends CalendarViewComponent {
currentDate: moment.Moment,
events: CalendarEvent[],
options: CalendarViewOptions, // Use base options type
overrideConfig?: Partial<CalendarSpecificConfig>
overrideConfig?: Partial<CalendarSpecificConfig>,
) {
super(plugin, app, containerEl, events, options); // Call base constructor
this.app = app;
@ -43,7 +53,7 @@ export class YearView extends CalendarViewComponent {
this.containerEl.addClass("view-year");
console.log(
`YearView: Rendering year ${year}. Total events received: ${this.events.length}`
`YearView: Rendering year ${year}. Total events received: ${this.events.length}`,
); // Log total events
// Create a grid container for the 12 months (e.g., 4x3)
@ -65,13 +75,20 @@ export class YearView extends CalendarViewComponent {
console.log(
`YearView: Filtered ${
yearEvents.length
} events for year ${year} in ${endTimeFilter - startTimeFilter}ms`
} events for year ${year} in ${endTimeFilter - startTimeFilter}ms`,
); // Log filtering time
// Get view settings (prefer override when provided)
const viewConfig = getViewSettingOrDefault(this.plugin, "calendar"); // Adjust if needed
const firstDayOfWeekSetting = (this.overrideConfig?.firstDayOfWeek ?? (viewConfig.specificConfig as CalendarSpecificConfig).firstDayOfWeek);
const hideWeekends = (this.overrideConfig?.hideWeekends ?? (viewConfig.specificConfig as CalendarSpecificConfig)?.hideWeekends) ?? false;
const firstDayOfWeekSetting =
this.overrideConfig?.firstDayOfWeek ??
(viewConfig.specificConfig as CalendarSpecificConfig)
.firstDayOfWeek;
const hideWeekends =
this.overrideConfig?.hideWeekends ??
(viewConfig.specificConfig as CalendarSpecificConfig)
?.hideWeekends ??
false;
// Add hide-weekends class if weekend hiding is enabled
if (hideWeekends) {
@ -115,7 +132,7 @@ export class YearView extends CalendarViewComponent {
const monthBody = monthContainer.createDiv("mini-month-body");
const daysWithEvents = this.calculateDaysWithEvents(
monthMoment,
yearEvents // Pass already filtered year events
yearEvents, // Pass already filtered year events
);
this.renderMiniMonthGrid(
@ -123,7 +140,7 @@ export class YearView extends CalendarViewComponent {
monthMoment,
daysWithEvents,
effectiveFirstDay,
hideWeekends
hideWeekends,
);
}
@ -131,14 +148,14 @@ export class YearView extends CalendarViewComponent {
console.log(
`YearView: Finished rendering year ${year} in ${
totalRenderEndTime - totalRenderStartTime
}ms. (First day: ${effectiveFirstDay})`
}ms. (First day: ${effectiveFirstDay})`,
);
}
// Helper function to calculate which days in a month have events
private calculateDaysWithEvents(
monthMoment: moment.Moment,
relevantEvents: CalendarEvent[] // Use the pre-filtered events
relevantEvents: CalendarEvent[], // Use the pre-filtered events
): Set<number> {
const days = new Set<number>();
const monthStart = monthMoment.clone().startOf("month");
@ -182,7 +199,7 @@ export class YearView extends CalendarViewComponent {
monthMoment: moment.Moment,
daysWithEvents: Set<number>,
effectiveFirstDay: number, // Pass the effective first day
hideWeekends: boolean // Pass the weekend hiding setting
hideWeekends: boolean, // Pass the weekend hiding setting
) {
container.empty(); // Clear placeholder
container.addClass("mini-month-grid");
@ -198,10 +215,10 @@ export class YearView extends CalendarViewComponent {
// Filter out weekends if hideWeekends is enabled
const filteredWeekdays = hideWeekends
? rotatedWeekdays.filter((_, index) => {
// Calculate the actual day of week for this header position
const dayOfWeek = (effectiveFirstDay + index) % 7;
return dayOfWeek !== 0 && dayOfWeek !== 6; // Exclude Sunday (0) and Saturday (6)
})
// Calculate the actual day of week for this header position
const dayOfWeek = (effectiveFirstDay + index) % 7;
return dayOfWeek !== 0 && dayOfWeek !== 6; // Exclude Sunday (0) and Saturday (6)
})
: rotatedWeekdays;
filteredWeekdays.forEach((day) => {
@ -219,7 +236,8 @@ export class YearView extends CalendarViewComponent {
// When weekends are hidden, adjust grid to start and end on work days
// Find the first work day of the week containing the start of month
gridStart = monthStart.clone();
const daysToSubtractStart = (monthStart.weekday() - effectiveFirstDay + 7) % 7;
const daysToSubtractStart =
(monthStart.weekday() - effectiveFirstDay + 7) % 7;
gridStart.subtract(daysToSubtractStart, "days");
// Ensure gridStart is not a weekend
@ -229,7 +247,8 @@ export class YearView extends CalendarViewComponent {
// Find the last work day of the week containing the end of month
gridEnd = monthEnd.clone();
const daysToAddEnd = (effectiveFirstDay + 4 - monthEnd.weekday() + 7) % 7; // 4 = Friday in work week
const daysToAddEnd =
(effectiveFirstDay + 4 - monthEnd.weekday() + 7) % 7; // 4 = Friday in work week
gridEnd.add(daysToAddEnd, "days");
// Ensure gridEnd is not a weekend
@ -238,16 +257,21 @@ export class YearView extends CalendarViewComponent {
}
} else {
// Original logic for when weekends are shown
const daysToSubtractStart = (monthStart.weekday() - effectiveFirstDay + 7) % 7;
gridStart = monthStart.clone().subtract(daysToSubtractStart, "days");
const daysToSubtractStart =
(monthStart.weekday() - effectiveFirstDay + 7) % 7;
gridStart = monthStart
.clone()
.subtract(daysToSubtractStart, "days");
const daysToAddEnd = (effectiveFirstDay + 6 - monthEnd.weekday() + 7) % 7;
const daysToAddEnd =
(effectiveFirstDay + 6 - monthEnd.weekday() + 7) % 7;
gridEnd = monthEnd.clone().add(daysToAddEnd, "days");
}
let currentDayIter = gridStart.clone();
while (currentDayIter.isSameOrBefore(gridEnd, "day")) {
const isWeekend = currentDayIter.day() === 0 || currentDayIter.day() === 6; // Sunday or Saturday
const isWeekend =
currentDayIter.day() === 0 || currentDayIter.day() === 6; // Sunday or Saturday
// Skip weekend days if hideWeekends is enabled
if (hideWeekends && isWeekend) {

View file

@ -467,7 +467,7 @@ export class WorkspaceManager {
if (this.isSaving) {
console.warn(
"[TG-WORKSPACE] Recursive saveOverrides detected, skipping to prevent loop",
{ workspaceId }
{ workspaceId },
);
return;
}
@ -783,6 +783,7 @@ export class WorkspaceManager {
views: [],
sidebarComponents: [],
features: [],
calendarViews: [],
};
}
if (!workspace.settings.hiddenModules.views) {
@ -797,6 +798,9 @@ export class WorkspaceManager {
// Features can no longer be hidden; normalize to empty
workspace.settings.hiddenModules.features = [];
}
if (!workspace.settings.hiddenModules.calendarViews) {
workspace.settings.hiddenModules.calendarViews = [];
}
return workspace.settings
.hiddenModules as Required<HiddenModulesConfig>;
}
@ -999,6 +1003,32 @@ export class WorkspaceManager {
emitWorkspaceOverridesSaved(this.app, workspace.id, ["hiddenModules"]);
}
/**
* Set hidden custom calendar views for a workspace
* @param calendarViewIds - Array of custom calendar view IDs to hide
* @param workspaceId - Optional workspace ID, defaults to active workspace
*/
public async setHiddenCalendarViews(
calendarViewIds: string[],
workspaceId?: string,
): Promise<void> {
const workspace = workspaceId
? this.getWorkspace(workspaceId)
: this.getActiveWorkspace();
if (!workspace) return;
// Ensure complete initialization and get the initialized object
const hiddenModules = this.ensureHiddenModulesInitialized(workspace);
hiddenModules.calendarViews = [...calendarViewIds];
workspace.updatedAt = Date.now();
this.clearCache();
await this.plugin.saveSettings();
emitWorkspaceOverridesSaved(this.app, workspace.id, ["hiddenModules"]);
}
/**
* Set hidden features for a workspace
* @param featureIds - Array of feature IDs to hide

View file

@ -17,3 +17,4 @@ export { renderTimelineSidebarSettingsTab } from "./tabs/TimelineSidebarSettings
export { renderIndexSettingsTab } from "./tabs/IndexSettingsTab";
export { createFileSourceSettings } from "./components/FileSourceSettingsSection";
export { renderDesktopIntegrationSettingsTab } from "./tabs/DesktopIntegrationSettingsTab";
export { renderCalendarViewSettingsTab } from "./tabs/CalendarViewSettingTab";

View file

@ -0,0 +1,477 @@
import { Setting, Notice, setIcon, ButtonComponent } from "obsidian";
import { TaskProgressBarSettingTab } from "@/setting";
import { CustomCalendarViewConfig } from "@/common/setting-definition";
import { t } from "@/translations/helper";
import { CalendarViewConfigModal } from "@/components/ui/modals/CalendarViewConfigModal";
import Sortable from "sortablejs";
import "@/styles/calendar-view-settings.css";
/**
* Built-in calendar view templates that users can use as base
*/
const BUILTIN_VIEW_TEMPLATES = [
{
type: "month" as const,
name: t("Month View"),
icon: "calendar",
description: t("Display tasks in a monthly calendar grid"),
},
{
type: "week" as const,
name: t("Week View"),
icon: "calendar-range",
description: t("Display tasks in a weekly time grid with hourly slots"),
},
{
type: "day" as const,
name: t("Day View"),
icon: "calendar-days",
description: t(
"Display tasks for a single day with detailed time slots",
),
},
{
type: "agenda" as const,
name: t("Agenda View"),
icon: "list",
description: t(
"Display upcoming tasks in a list format grouped by day",
),
},
{
type: "year" as const,
name: t("Year View"),
icon: "calendar-clock",
description: t("Display a full year with 12 mini-month calendars"),
},
];
/**
* Render the Calendar View Settings Tab
*/
export function renderCalendarViewSettingsTab(
settingTab: TaskProgressBarSettingTab,
containerEl: HTMLElement,
) {
// Header
new Setting(containerEl)
.setName(t("Calendar View Configuration"))
.setDesc(
t(
"Create custom calendar views based on Month, Week, or Day views. Each custom view can have its own display settings.",
),
)
.setHeading();
// Built-in Templates Section
new Setting(containerEl)
.setName(t("Built-in View Templates"))
.setDesc(
t(
"Use these templates as a starting point to create your own custom calendar views.",
),
)
.setHeading();
const templatesContainer = containerEl.createDiv({
cls: "calendar-templates-container",
});
BUILTIN_VIEW_TEMPLATES.forEach((template) => {
const templateCard = templatesContainer.createDiv({
cls: "calendar-template-card",
});
// Icon
const iconEl = templateCard.createDiv({
cls: "calendar-template-icon",
});
setIcon(iconEl, template.icon);
// Info
const infoEl = templateCard.createDiv({
cls: "calendar-template-info",
});
infoEl.createDiv({
cls: "calendar-template-name",
text: template.name,
});
infoEl.createDiv({
cls: "calendar-template-description",
text: template.description,
});
// Actions
const actionsEl = templateCard.createDiv({
cls: "calendar-template-actions",
});
const createBtn = new ButtonComponent(actionsEl);
createBtn.setButtonText(t("Create Custom View"));
createBtn.setIcon("plus");
createBtn.onClick(() => {
openCreateViewModal(settingTab, template.type, () => {
renderCustomViewsList(settingTab, customViewsContainer);
});
});
});
// Custom Views Section
new Setting(containerEl)
.setName(t("Custom Calendar Views"))
.setDesc(
t(
"Your custom calendar views. Drag to reorder, toggle visibility, or edit settings.",
),
)
.setHeading();
const customViewsContainer = containerEl.createDiv({
cls: "custom-calendar-views-container",
});
// Render the list
renderCustomViewsList(settingTab, customViewsContainer);
// Add New View Button
const addBtnContainer = containerEl.createDiv({
cls: "calendar-add-view-container",
});
new Setting(addBtnContainer).addButton((button) => {
button
.setButtonText(t("Create New Calendar View"))
.setCta()
.setIcon("plus")
.onClick(() => {
openCreateViewModal(settingTab, "month", () => {
renderCustomViewsList(settingTab, customViewsContainer);
});
});
});
}
/**
* Render the list of custom calendar views
*/
function renderCustomViewsList(
settingTab: TaskProgressBarSettingTab,
container: HTMLElement,
) {
container.empty();
const customViews = settingTab.plugin.settings.customCalendarViews || [];
if (customViews.length === 0) {
const emptyState = container.createDiv({ cls: "calendar-views-empty" });
const emptyIcon = emptyState.createDiv({
cls: "calendar-views-empty-icon",
});
setIcon(emptyIcon, "calendar-plus");
emptyState.createDiv({
cls: "calendar-views-empty-text",
text: t("No custom calendar views yet"),
});
emptyState.createDiv({
cls: "calendar-views-empty-hint",
text: t(
"Create a custom view from the templates above to get started.",
),
});
return;
}
const listContainer = container.createDiv({
cls: "calendar-views-list sortable-calendar-views",
});
// Render each custom view
customViews.forEach((view) => {
createCustomViewItem(settingTab, listContainer, view, () => {
renderCustomViewsList(settingTab, container);
});
});
// Setup sortable
Sortable.create(listContainer, {
animation: 150,
handle: ".calendar-view-drag-handle",
ghostClass: "sortable-ghost",
chosenClass: "sortable-chosen",
dragClass: "sortable-drag",
onEnd: async (evt) => {
const views = settingTab.plugin.settings.customCalendarViews || [];
const [movedItem] = views.splice(evt.oldIndex!, 1);
views.splice(evt.newIndex!, 0, movedItem);
// Update order values
views.forEach((v, index) => {
v.order = index;
v.updatedAt = Date.now();
});
settingTab.plugin.settings.customCalendarViews = views;
await settingTab.plugin.saveSettings();
// Notify calendar component
(settingTab.app.workspace as any).trigger(
"task-genius:calendar-views-changed",
{ reason: "order-changed" },
);
},
});
}
/**
* Create a single custom view item in the list
*/
function createCustomViewItem(
settingTab: TaskProgressBarSettingTab,
container: HTMLElement,
view: CustomCalendarViewConfig,
onUpdate: () => void,
) {
const viewEl = container.createDiv({
cls: `calendar-view-item ${!view.enabled ? "is-disabled" : ""}`,
attr: { "data-view-id": view.id },
});
// Drag handle
const dragHandle = viewEl.createDiv({ cls: "calendar-view-drag-handle" });
setIcon(dragHandle, "grip-vertical");
// Icon
const iconEl = viewEl.createDiv({ cls: "calendar-view-icon" });
setIcon(iconEl, view.icon);
// Info
const infoEl = viewEl.createDiv({ cls: "calendar-view-info" });
infoEl.createDiv({ cls: "calendar-view-name", text: view.name });
const metaEl = infoEl.createDiv({ cls: "calendar-view-meta" });
const baseViewLabel = {
month: t("Month"),
week: t("Week"),
day: t("Day"),
agenda: t("Agenda"),
year: t("Year"),
}[view.baseViewType];
metaEl.createSpan({
cls: "calendar-view-base-type",
text: t("Based on: ") + baseViewLabel,
});
// Config summary
const configSummary = getConfigSummary(view);
if (configSummary) {
metaEl.createSpan({
cls: "calendar-view-config-summary",
text: configSummary,
});
}
// Actions
const actionsEl = viewEl.createDiv({ cls: "calendar-view-actions" });
// Toggle enabled
const toggleBtn = actionsEl.createEl("button", {
cls: ["calendar-view-action-btn", "clickable-icon"],
attr: { "aria-label": view.enabled ? t("Disable") : t("Enable") },
});
setIcon(toggleBtn, view.enabled ? "eye" : "eye-off");
toggleBtn.onclick = async () => {
view.enabled = !view.enabled;
view.updatedAt = Date.now();
await settingTab.plugin.saveSettings();
onUpdate();
(settingTab.app.workspace as any).trigger(
"task-genius:calendar-views-changed",
{ reason: "visibility-changed", viewId: view.id },
);
};
// Edit
const editBtn = actionsEl.createEl("button", {
cls: ["calendar-view-action-btn", "clickable-icon"],
attr: { "aria-label": t("Edit") },
});
setIcon(editBtn, "pencil");
editBtn.onclick = () => {
openEditViewModal(settingTab, view, onUpdate);
};
// Duplicate
const duplicateBtn = actionsEl.createEl("button", {
cls: ["calendar-view-action-btn", "clickable-icon"],
attr: { "aria-label": t("Duplicate") },
});
setIcon(duplicateBtn, "copy");
duplicateBtn.onclick = async () => {
const newView: CustomCalendarViewConfig = {
...JSON.parse(JSON.stringify(view)),
id: `custom-calendar-${Date.now()}`,
name: view.name + " " + t("(Copy)"),
createdAt: Date.now(),
updatedAt: Date.now(),
};
const views = settingTab.plugin.settings.customCalendarViews || [];
views.push(newView);
settingTab.plugin.settings.customCalendarViews = views;
await settingTab.plugin.saveSettings();
new Notice(t("View duplicated: ") + newView.name);
onUpdate();
(settingTab.app.workspace as any).trigger(
"task-genius:calendar-views-changed",
{ reason: "view-added", viewId: newView.id },
);
};
// Delete
const deleteBtn = actionsEl.createEl("button", {
cls: [
"calendar-view-action-btn",
"calendar-view-action-delete",
"clickable-icon",
],
attr: { "aria-label": t("Delete") },
});
setIcon(deleteBtn, "trash");
deleteBtn.onclick = async () => {
const views = settingTab.plugin.settings.customCalendarViews || [];
const index = views.findIndex((v) => v.id === view.id);
if (index !== -1) {
views.splice(index, 1);
settingTab.plugin.settings.customCalendarViews = views;
await settingTab.plugin.saveSettings();
new Notice(t("View deleted: ") + view.name);
onUpdate();
(settingTab.app.workspace as any).trigger(
"task-genius:calendar-views-changed",
{ reason: "view-deleted", viewId: view.id },
);
}
};
}
/**
* Get a summary of the view configuration for display
*/
function getConfigSummary(view: CustomCalendarViewConfig): string {
const parts: string[] = [];
const config = view.calendarConfig;
if (config.dayFilter?.type === "hideWeekends") {
parts.push(t("No weekends"));
} else if (config.dayFilter?.type === "hideWeekdays") {
parts.push(t("Weekends only"));
} else if (
config.dayFilter?.type === "customDays" &&
config.dayFilter.hiddenDays?.length
) {
parts.push(t("Custom days"));
}
if (config.timeFilter?.enabled) {
parts.push(
`${config.timeFilter.startHour}:00-${config.timeFilter.endHour}:00`,
);
}
if (config.firstDayOfWeek === 1) {
parts.push(t("Mon first"));
} else if (config.firstDayOfWeek === 6) {
parts.push(t("Sat first"));
}
return parts.join(" · ");
}
/**
* Open the modal to create a new custom view
*/
function openCreateViewModal(
settingTab: TaskProgressBarSettingTab,
baseViewType: "month" | "week" | "day" | "agenda" | "year",
onSave: () => void,
) {
const newView: CustomCalendarViewConfig = {
id: `custom-calendar-${Date.now()}`,
name: t("New Calendar View"),
icon: "calendar",
baseViewType,
enabled: true,
order: (settingTab.plugin.settings.customCalendarViews || []).length,
calendarConfig: {
firstDayOfWeek: undefined,
showWeekNumbers: false,
showEventCounts: true,
dayFilter: { type: "none" },
timeFilter: {
enabled: false,
type: "workingHours",
startHour: 9,
endHour: 18,
},
},
createdAt: Date.now(),
updatedAt: Date.now(),
};
new CalendarViewConfigModal(
settingTab.app,
settingTab.plugin,
newView,
async (savedView) => {
const views = settingTab.plugin.settings.customCalendarViews || [];
views.push(savedView);
settingTab.plugin.settings.customCalendarViews = views;
await settingTab.plugin.saveSettings();
new Notice(t("Calendar view created: ") + savedView.name);
onSave();
(settingTab.app.workspace as any).trigger(
"task-genius:calendar-views-changed",
{ reason: "view-added", viewId: savedView.id },
);
},
).open();
}
/**
* Open the modal to edit an existing custom view
*/
function openEditViewModal(
settingTab: TaskProgressBarSettingTab,
view: CustomCalendarViewConfig,
onSave: () => void,
) {
new CalendarViewConfigModal(
settingTab.app,
settingTab.plugin,
{ ...view },
async (savedView) => {
const views = settingTab.plugin.settings.customCalendarViews || [];
const index = views.findIndex((v) => v.id === savedView.id);
if (index !== -1) {
savedView.updatedAt = Date.now();
views[index] = savedView;
settingTab.plugin.settings.customCalendarViews = views;
await settingTab.plugin.saveSettings();
new Notice(t("Calendar view updated: ") + savedView.name);
onSave();
(settingTab.app.workspace as any).trigger(
"task-genius:calendar-views-changed",
{ reason: "view-updated", viewId: savedView.id },
);
}
},
).open();
}

View file

@ -249,6 +249,7 @@ function showDeleteWorkspaceDialog(
function getAvailableModules(plugin: TaskProgressBarPlugin): {
views: ModuleDefinition[];
sidebarComponents: ModuleDefinition[];
calendarViews: ModuleDefinition[];
} {
// Get view modules from plugin settings
const views: ModuleDefinition[] = plugin.settings.viewConfiguration.map(
@ -260,6 +261,16 @@ function getAvailableModules(plugin: TaskProgressBarPlugin): {
}),
);
// Get custom calendar views
const calendarViews: ModuleDefinition[] = (
plugin.settings.customCalendarViews || []
).map((calView) => ({
id: calView.id,
name: calView.name,
icon: calView.icon,
type: "calendarView" as const,
}));
// Define sidebar component modules (Fluent interface only)
const sidebarComponents: ModuleDefinition[] = [
{
@ -276,7 +287,7 @@ function getAvailableModules(plugin: TaskProgressBarPlugin): {
},
];
return { views, sidebarComponents };
return { views, sidebarComponents, calendarViews };
}
/**
@ -323,7 +334,7 @@ function renderHiddenModulesConfig(
groupIcon: string,
moduleList: ModuleDefinition[],
initialHiddenList: string[],
moduleType: "views" | "sidebarComponents",
moduleType: "views" | "sidebarComponents" | "calendarViews",
) => {
let hiddenList = [...initialHiddenList];
@ -412,6 +423,12 @@ function renderHiddenModulesConfig(
workspace.id,
);
break;
case "calendarViews":
await plugin.workspaceManager?.setHiddenCalendarViews(
[...newHiddenList],
workspace.id,
);
break;
}
const refreshed = plugin.workspaceManager?.getWorkspace(
@ -423,6 +440,7 @@ function renderHiddenModulesConfig(
hiddenModules = {
views: [],
sidebarComponents: [],
calendarViews: [],
};
}
@ -434,6 +452,9 @@ function renderHiddenModulesConfig(
case "sidebarComponents":
hiddenList = hiddenModules.sidebarComponents || [];
break;
case "calendarViews":
hiddenList = hiddenModules.calendarViews || [];
break;
}
itemEl.toggleClass("is-hidden", !shouldBeVisible);
@ -491,6 +512,17 @@ function renderHiddenModulesConfig(
hiddenModules.sidebarComponents || [],
"sidebarComponents",
);
// Only render calendar views group if there are custom calendar views
if (modules.calendarViews.length > 0) {
renderModuleGroup(
t("Custom Calendar Views"),
"calendar",
modules.calendarViews,
hiddenModules.calendarViews || [],
"calendarViews",
);
}
}
/**

View file

@ -0,0 +1,642 @@
import {
App,
Modal,
Setting,
ButtonComponent,
DropdownComponent,
TextComponent,
ToggleComponent,
SliderComponent,
setIcon,
} from "obsidian";
import type TaskProgressBarPlugin from "@/index";
import {
CustomCalendarViewConfig,
DayFilterConfig,
TimeFilterConfig,
} from "@/common/setting-definition";
import { t } from "@/translations/helper";
import { attachIconMenu } from "@/components/ui/menus/IconMenu";
/**
* Days of the week for custom day filter
*/
const WEEKDAYS = [
{ value: 0, label: "Sunday", short: "Sun" },
{ value: 1, label: "Monday", short: "Mon" },
{ value: 2, label: "Tuesday", short: "Tue" },
{ value: 3, label: "Wednesday", short: "Wed" },
{ value: 4, label: "Thursday", short: "Thu" },
{ value: 5, label: "Friday", short: "Fri" },
{ value: 6, label: "Saturday", short: "Sat" },
];
/**
* Modal for creating/editing custom calendar views
*/
export class CalendarViewConfigModal extends Modal {
private view: CustomCalendarViewConfig;
private onSave: (view: CustomCalendarViewConfig) => void;
private plugin: TaskProgressBarPlugin;
private currentTab: "basic" | "date" | "time" | "display" = "basic";
constructor(
app: App,
plugin: TaskProgressBarPlugin,
view: CustomCalendarViewConfig,
onSave: (view: CustomCalendarViewConfig) => void,
) {
super(app);
this.plugin = plugin;
this.view = JSON.parse(JSON.stringify(view)); // Deep clone
this.onSave = onSave;
}
onOpen() {
const { contentEl } = this;
this.modalEl.addClass("calendar-view-config-modal");
// Header
const headerEl = contentEl.createDiv({ cls: "modal-header" });
headerEl.createEl("h2", {
text:
this.view.createdAt === this.view.updatedAt
? t("Create Calendar View")
: t("Edit Calendar View"),
});
// Tabs
const tabsEl = contentEl.createDiv({ cls: "calendar-config-tabs" });
this.renderTabs(tabsEl);
// Content
const contentContainer = contentEl.createDiv({
cls: "calendar-config-content",
});
this.renderTabContent(contentContainer);
// Footer
const footerEl = contentEl.createDiv({ cls: "modal-button-container" });
this.renderFooter(footerEl);
}
private renderTabs(container: HTMLElement) {
const tabs = [
{ id: "basic", label: t("Basic"), icon: "settings" },
{ id: "date", label: t("Date Filter"), icon: "calendar" },
{ id: "time", label: t("Time Filter"), icon: "clock" },
{ id: "display", label: t("Display"), icon: "eye" },
] as const;
tabs.forEach((tab) => {
const tabEl = container.createDiv({
cls: `calendar-config-tab ${this.currentTab === tab.id ? "is-active" : ""}`,
});
const iconEl = tabEl.createSpan({
cls: "calendar-config-tab-icon",
});
setIcon(iconEl, tab.icon);
tabEl.createSpan({
cls: "calendar-config-tab-label",
text: tab.label,
});
// Disable time tab for month/agenda/year views
const isTimeTabDisabled =
tab.id === "time" &&
(this.view.baseViewType === "month" ||
this.view.baseViewType === "agenda" ||
this.view.baseViewType === "year");
if (isTimeTabDisabled) {
tabEl.addClass("is-disabled");
tabEl.setAttribute("aria-disabled", "true");
tabEl.title = t(
"Time filter is only available for Week and Day views",
);
} else {
tabEl.onclick = () => {
this.currentTab = tab.id;
this.refreshContent();
};
}
});
}
private renderTabContent(container: HTMLElement) {
container.empty();
switch (this.currentTab) {
case "basic":
this.renderBasicTab(container);
break;
case "date":
this.renderDateTab(container);
break;
case "time":
this.renderTimeTab(container);
break;
case "display":
this.renderDisplayTab(container);
break;
}
}
private renderBasicTab(container: HTMLElement) {
// View Name
new Setting(container)
.setName(t("View Name"))
.setDesc(t("Display name for this calendar view"))
.addText((text) => {
text.setPlaceholder(t("My Custom View"))
.setValue(this.view.name)
.onChange((value) => {
this.view.name = value;
});
});
// Icon
const iconSetting = new Setting(container)
.setName(t("View Icon"))
.setDesc(t("Choose an icon for this view"));
const iconContainer = iconSetting.controlEl.createDiv({
cls: "calendar-view-icon-selector",
});
const iconButton = new ButtonComponent(iconContainer);
iconButton.setIcon(this.view.icon);
attachIconMenu(iconButton, {
containerEl: iconContainer,
plugin: this.plugin,
onIconSelected: (iconId: string) => {
this.view.icon = iconId;
iconButton.setIcon(iconId);
},
});
// Base View Type
new Setting(container)
.setName(t("Base View Type"))
.setDesc(t("The calendar layout this view is based on"))
.addDropdown((dropdown) => {
dropdown
.addOption("month", t("Month View"))
.addOption("week", t("Week View"))
.addOption("day", t("Day View"))
.addOption("agenda", t("Agenda View"))
.addOption("year", t("Year View"))
.setValue(this.view.baseViewType)
.onChange(
(
value: "month" | "week" | "day" | "agenda" | "year",
) => {
this.view.baseViewType = value;
// Reset time filter if switching to non-time views
if (
(value === "month" ||
value === "agenda" ||
value === "year") &&
this.view.calendarConfig.timeFilter
) {
this.view.calendarConfig.timeFilter.enabled = false;
}
this.refreshContent();
},
);
});
// First Day of Week (not for agenda view)
if (this.view.baseViewType !== "agenda") {
new Setting(container)
.setName(t("First Day of Week"))
.setDesc(t("Which day should the week start on"))
.addDropdown((dropdown) => {
dropdown
.addOption("", t("System Default"))
.addOption("0", t("Sunday"))
.addOption("1", t("Monday"))
.addOption("2", t("Tuesday"))
.addOption("3", t("Wednesday"))
.addOption("4", t("Thursday"))
.addOption("5", t("Friday"))
.addOption("6", t("Saturday"))
.setValue(
this.view.calendarConfig.firstDayOfWeek?.toString() ??
"",
)
.onChange((value) => {
this.view.calendarConfig.firstDayOfWeek = value
? (parseInt(value) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
: undefined;
});
});
}
// Agenda-specific options
if (this.view.baseViewType === "agenda") {
new Setting(container)
.setName(t("Days to Show"))
.setDesc(t("Number of days to display in the agenda"))
.addSlider((slider) => {
slider
.setLimits(1, 30, 1)
.setValue(
(this.view.calendarConfig as any).daysToShow ?? 7,
)
.setDynamicTooltip()
.onChange((value) => {
(this.view.calendarConfig as any).daysToShow =
value;
});
});
new Setting(container)
.setName(t("Show Empty Days"))
.setDesc(t("Display days with no events"))
.addToggle((toggle) => {
toggle
.setValue(
(this.view.calendarConfig as any).showEmptyDays ??
false,
)
.onChange((value) => {
(this.view.calendarConfig as any).showEmptyDays =
value;
});
});
}
}
private renderDateTab(container: HTMLElement) {
const dayFilter = this.view.calendarConfig.dayFilter || {
type: "none",
};
// Day Filter Type
new Setting(container)
.setName(t("Day Filter"))
.setDesc(t("Control which days are displayed in the calendar"))
.addDropdown((dropdown) => {
dropdown
.addOption("none", t("Show All Days"))
.addOption("hideWeekends", t("Hide Weekends"))
.addOption("hideWeekdays", t("Weekends Only"))
.addOption("customDays", t("Custom Selection"))
.setValue(dayFilter.type)
.onChange((value: DayFilterConfig["type"]) => {
this.view.calendarConfig.dayFilter = {
type: value,
hiddenDays:
value === "customDays"
? dayFilter.hiddenDays || []
: undefined,
};
this.refreshContent();
});
});
// Custom Day Selection (only shown when customDays is selected)
if (dayFilter.type === "customDays") {
const customDaysSetting = new Setting(container)
.setName(t("Hidden Days"))
.setDesc(t("Select which days to hide from the calendar"));
const daysContainer = customDaysSetting.controlEl.createDiv({
cls: "calendar-days-selector",
});
const hiddenDays = dayFilter.hiddenDays || [];
WEEKDAYS.forEach((day) => {
const dayEl = daysContainer.createDiv({
cls: `calendar-day-chip ${hiddenDays.includes(day.value) ? "is-hidden" : ""}`,
});
dayEl.createSpan({ text: t(day.short) });
dayEl.onclick = () => {
const currentHidden =
this.view.calendarConfig.dayFilter?.hiddenDays || [];
const index = currentHidden.indexOf(day.value);
if (index === -1) {
currentHidden.push(day.value);
} else {
currentHidden.splice(index, 1);
}
this.view.calendarConfig.dayFilter = {
type: "customDays",
hiddenDays: currentHidden,
};
dayEl.toggleClass(
"is-hidden",
currentHidden.includes(day.value),
);
};
});
}
// Show Week Numbers (for month view)
if (this.view.baseViewType === "month") {
new Setting(container)
.setName(t("Show Week Numbers"))
.setDesc(t("Display week numbers in the month view"))
.addToggle((toggle) => {
toggle
.setValue(
this.view.calendarConfig.showWeekNumbers ?? false,
)
.onChange((value) => {
this.view.calendarConfig.showWeekNumbers = value;
});
});
}
}
private renderTimeTab(container: HTMLElement) {
// Only for week/day views
if (
this.view.baseViewType === "month" ||
this.view.baseViewType === "agenda" ||
this.view.baseViewType === "year"
) {
container.createDiv({
cls: "calendar-config-notice",
text: t(
"Time filter is only available for Week and Day views.",
),
});
return;
}
const timeFilter = this.view.calendarConfig.timeFilter || {
enabled: false,
type: "workingHours",
startHour: 9,
endHour: 18,
};
// Enable Time Filter
new Setting(container)
.setName(t("Enable Time Filter"))
.setDesc(t("Only show certain hours of the day"))
.addToggle((toggle) => {
toggle.setValue(timeFilter.enabled).onChange((value) => {
this.view.calendarConfig.timeFilter = {
...timeFilter,
enabled: value,
};
this.refreshContent();
});
});
if (timeFilter.enabled) {
// Time Filter Type
new Setting(container)
.setName(t("Time Range Preset"))
.setDesc(t("Choose a preset or customize the time range"))
.addDropdown((dropdown) => {
dropdown
.addOption(
"workingHours",
t("Working Hours (9:00-18:00)"),
)
.addOption("custom", t("Custom Range"))
.setValue(timeFilter.type)
.onChange((value: TimeFilterConfig["type"]) => {
if (value === "workingHours") {
this.view.calendarConfig.timeFilter = {
enabled: true,
type: "workingHours",
startHour: 9,
endHour: 18,
};
} else {
this.view.calendarConfig.timeFilter = {
...timeFilter,
type: "custom",
};
}
this.refreshContent();
});
});
// Custom Time Range
if (timeFilter.type === "custom") {
// Start Hour
new Setting(container)
.setName(t("Start Hour"))
.setDesc(t("First hour to display (0-23)"))
.addSlider((slider) => {
slider
.setLimits(0, 23, 1)
.setValue(timeFilter.startHour)
.setDynamicTooltip()
.onChange((value) => {
this.view.calendarConfig.timeFilter = {
...this.view.calendarConfig.timeFilter!,
startHour: value,
};
this.updateTimeRangeDisplay(container);
});
});
// End Hour
new Setting(container)
.setName(t("End Hour"))
.setDesc(t("Last hour to display (0-23)"))
.addSlider((slider) => {
slider
.setLimits(1, 24, 1)
.setValue(timeFilter.endHour)
.setDynamicTooltip()
.onChange((value) => {
this.view.calendarConfig.timeFilter = {
...this.view.calendarConfig.timeFilter!,
endHour: value,
};
this.updateTimeRangeDisplay(container);
});
});
// Time Range Display
const rangeDisplay = container.createDiv({
cls: "calendar-time-range-display",
});
this.updateTimeRangeDisplay(container, rangeDisplay);
}
}
}
private updateTimeRangeDisplay(
container: HTMLElement,
displayEl?: HTMLElement,
) {
const el =
displayEl ||
container.querySelector(".calendar-time-range-display");
if (!el) return;
const timeFilter = this.view.calendarConfig.timeFilter;
if (!timeFilter) return;
const formatHour = (h: number) => `${h.toString().padStart(2, "0")}:00`;
(el as HTMLElement).textContent =
`${formatHour(timeFilter.startHour)} - ${formatHour(timeFilter.endHour)}`;
}
private renderDisplayTab(container: HTMLElement) {
// Show Event Counts (not for agenda view)
if (this.view.baseViewType !== "agenda") {
new Setting(container)
.setName(t("Show Event Counts"))
.setDesc(t("Display event count badges on date cells"))
.addToggle((toggle) => {
toggle
.setValue(
this.view.calendarConfig.showEventCounts ?? true,
)
.onChange((value) => {
this.view.calendarConfig.showEventCounts = value;
});
});
}
// Max Events Per Row (for month view only)
if (this.view.baseViewType === "month") {
new Setting(container)
.setName(t("Max Events Per Row"))
.setDesc(
t(
"Maximum events to show per day cell before showing '+N more'",
),
)
.addSlider((slider) => {
slider
.setLimits(1, 10, 1)
.setValue(this.view.calendarConfig.maxEventsPerRow ?? 3)
.setDynamicTooltip()
.onChange((value) => {
this.view.calendarConfig.maxEventsPerRow = value;
});
});
}
// Agenda-specific display options
if (this.view.baseViewType === "agenda") {
new Setting(container)
.setName(t("Day Header Format"))
.setDesc(t("Format for day headers (e.g., dddd, MMMM D)"))
.addText((text) => {
text.setPlaceholder("dddd, MMMM D")
.setValue(
(this.view.calendarConfig as any).dayHeaderFormat ??
"",
)
.onChange((value) => {
(this.view.calendarConfig as any).dayHeaderFormat =
value || undefined;
});
});
}
// Year view note
if (this.view.baseViewType === "year") {
container.createDiv({
cls: "calendar-config-notice",
text: t(
"Year view displays 12 mini-month calendars. Use the Date Filter tab to hide weekends if needed.",
),
});
}
// Date Formats Section
new Setting(container)
.setName(t("Custom Date Formats"))
.setDesc(t("Customize how dates are displayed in headers"))
.setHeading();
// Month Header Format
new Setting(container)
.setName(t("Month Header Format"))
.setDesc(t("Format for month view header (e.g., yyyy M)"))
.addText((text) => {
text.setPlaceholder("yyyy M")
.setValue(
this.view.calendarConfig.dateFormats?.monthHeader ?? "",
)
.onChange((value) => {
if (!this.view.calendarConfig.dateFormats) {
this.view.calendarConfig.dateFormats = {};
}
this.view.calendarConfig.dateFormats.monthHeader =
value || undefined;
});
});
// Day Header Format
new Setting(container)
.setName(t("Day Header Format"))
.setDesc(t("Format for day view header (e.g., yyyy'年'M'月'd'日')"))
.addText((text) => {
text.setPlaceholder("yyyy'年'M'月'd'日'")
.setValue(
this.view.calendarConfig.dateFormats?.dayHeader ?? "",
)
.onChange((value) => {
if (!this.view.calendarConfig.dateFormats) {
this.view.calendarConfig.dateFormats = {};
}
this.view.calendarConfig.dateFormats.dayHeader =
value || undefined;
});
});
}
private renderFooter(container: HTMLElement) {
const cancelBtn = new ButtonComponent(container);
cancelBtn.setButtonText(t("Cancel")).onClick(() => {
this.close();
});
const saveBtn = new ButtonComponent(container);
saveBtn
.setButtonText(t("Save"))
.setCta()
.onClick(() => {
if (!this.view.name.trim()) {
this.view.name = t("Untitled View");
}
this.onSave(this.view);
this.close();
});
}
private refreshContent() {
const { contentEl } = this;
// Re-render tabs
const tabsEl = contentEl.querySelector(".calendar-config-tabs");
if (tabsEl) {
tabsEl.empty();
this.renderTabs(tabsEl as HTMLElement);
}
// Re-render content
const contentContainer = contentEl.querySelector(
".calendar-config-content",
);
if (contentContainer) {
this.renderTabContent(contentContainer as HTMLElement);
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -35,6 +35,7 @@ import {
renderIndexSettingsTab,
IcsSettingsComponent,
renderDesktopIntegrationSettingsTab,
renderCalendarViewSettingsTab,
} from "./components/features/settings";
import { renderFileFilterSettingsTab } from "./components/features/settings/tabs/FileFilterSettingsTab";
import { renderTimeParsingSettingsTab } from "./components/features/settings/tabs/TimeParsingSettingsTab";
@ -180,6 +181,12 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
},
// Integration & Advanced
{
id: "calendar-views",
name: t("Calendar Views"),
icon: "calendar-range",
category: "integration",
},
{
id: "ics-integration",
name: t("Calendar Sync"),
@ -238,7 +245,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
if (plugin.dataflowOrchestrator) {
// Call async updateSettings and await to ensure incremental reindex completes
await plugin.dataflowOrchestrator.updateSettings(
plugin.settings
plugin.settings,
);
}
@ -249,7 +256,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
await plugin.triggerViewUpdate();
},
100,
true
true,
);
this.debouncedApplyNotifications = debounce(
@ -260,7 +267,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
// Minimal view updates are unnecessary here
},
100,
true
true,
);
}
@ -280,7 +287,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
}
this.searchComponent = new SettingsSearchComponent(
this,
this.containerEl
this.containerEl,
);
}
@ -377,7 +384,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
tab.name +
(tab.id === "about"
? " v" + this.plugin.manifest.version
: "")
: ""),
);
// Add click handler
@ -410,7 +417,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
// Show active section, hide others
const sections = this.containerEl.querySelectorAll(
".settings-tab-section"
".settings-tab-section",
);
sections.forEach((section) => {
if (section.getAttribute("data-tab-id") === tabId) {
@ -424,10 +431,10 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
// Handle tab container and header visibility based on selected tab
const tabsContainer = this.containerEl.querySelector(
".settings-tabs-categorized-container"
".settings-tabs-categorized-container",
);
const settingsHeader = this.containerEl.querySelector(
".task-genius-settings-header"
".task-genius-settings-header",
);
if (tabId === "general") {
@ -452,7 +459,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
if (tabId === "workspaces") {
// Make sure the workspace section is visible even though the tab is hidden
const workspaceSection = this.containerEl.querySelector(
'[data-tab-id="workspaces"]'
'[data-tab-id="workspaces"]',
);
if (workspaceSection) {
(workspaceSection as unknown as HTMLElement).style.display =
@ -473,7 +480,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
public navigateToTab(
tabId: string,
section?: string,
search?: string
search?: string,
): void {
// Set the current tab
this.currentTab = tabId;
@ -514,7 +521,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
// Special handling for MCP sections
if (sectionId === "cursor" && this.currentTab === "mcp-integration") {
const cursorSection = this.containerEl.querySelector(
".mcp-client-section"
".mcp-client-section",
);
if (cursorSection) {
const header =
@ -534,7 +541,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
private createTabSection(tabId: string): HTMLElement {
// Get the sections container
const sectionsContainer = this.containerEl.querySelector(
".settings-tab-sections"
".settings-tab-sections",
);
if (!sectionsContainer) return this.containerEl;
@ -567,7 +574,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
new IframeModal(
this.app,
url,
`How to use — ${tabInfo?.name ?? tabId}`
`How to use — ${tabInfo?.name ?? tabId}`,
).open();
} catch (e) {
window.open(url);
@ -689,13 +696,17 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
const habitSection = this.createTabSection("habit");
this.displayHabitSettings(habitSection);
// Calendar Views Tab
const calendarViewsSection = this.createTabSection("calendar-views");
this.displayCalendarViewsSettings(calendarViewsSection);
// ICS Integration Tab
const icsSection = this.createTabSection("ics-integration");
this.displayIcsSettings(icsSection);
// Notifications Tab
const notificationsSection = this.createTabSection(
"desktop-integration"
"desktop-integration",
);
this.displayDesktopIntegrationSettings(notificationsSection);
@ -767,6 +778,8 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
return `${base}/reward`;
case "habit":
return `${base}/habit`;
case "calendar-views":
return `${base}/task-view/calendar`;
case "ics-integration":
return `${base}/ics-support`;
case "mcp-integration":
@ -852,6 +865,10 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
renderProjectSettingsTab(this, containerEl);
}
private displayCalendarViewsSettings(containerEl: HTMLElement): void {
renderCalendarViewSettingsTab(this, containerEl);
}
private displayIcsSettings(containerEl: HTMLElement): void {
const icsSettingsComponent = new IcsSettingsComponent(
this.plugin,
@ -859,7 +876,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
() => {
this.currentTab = "general";
this.display();
}
},
);
icsSettingsComponent.display();
}
@ -870,7 +887,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
private displayMcpSettings(containerEl: HTMLElement): void {
renderMcpIntegrationSettingsTab(this, containerEl, this.plugin, () =>
this.applySettingsUpdate()
this.applySettingsUpdate(),
);
}

View file

@ -0,0 +1,384 @@
/* ============================================
Calendar View Settings Tab Styles
============================================ */
/* Templates Container */
.calendar-templates-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--size-4-3);
margin-bottom: var(--size-4-4);
}
.calendar-template-card {
display: flex;
align-items: flex-start;
gap: var(--size-4-3);
padding: var(--size-4-3);
background: var(--background-secondary);
border-radius: var(--radius-m);
border: 1px solid var(--background-modifier-border);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.calendar-template-card:hover {
border-color: var(--interactive-accent);
box-shadow: 0 2px 8px var(--background-modifier-box-shadow);
}
.calendar-template-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--background-primary);
border-radius: var(--radius-s);
color: var(--text-accent);
}
.calendar-template-icon svg {
width: 20px;
height: 20px;
}
.calendar-template-info {
flex: 1;
min-width: 0;
}
.calendar-template-name {
font-weight: var(--font-semibold);
color: var(--text-normal);
margin-bottom: var(--size-2-1);
}
.calendar-template-description {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
line-height: 1.4;
}
.calendar-template-actions {
flex-shrink: 0;
}
.calendar-template-actions button {
font-size: var(--font-ui-smaller);
}
/* Custom Views Container */
.custom-calendar-views-container {
margin-bottom: var(--size-4-4);
}
/* Empty State */
.calendar-views-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--size-4-8) var(--size-4-4);
background: var(--background-secondary);
border-radius: var(--radius-m);
border: 2px dashed var(--background-modifier-border);
text-align: center;
}
.calendar-views-empty-icon {
color: var(--text-faint);
margin-bottom: var(--size-4-2);
}
.calendar-views-empty-icon svg {
width: 48px;
height: 48px;
}
.calendar-views-empty-text {
font-size: var(--font-ui-medium);
color: var(--text-muted);
font-weight: var(--font-medium);
margin-bottom: var(--size-2-2);
}
.calendar-views-empty-hint {
font-size: var(--font-ui-smaller);
color: var(--text-faint);
}
/* Views List */
.calendar-views-list {
display: flex;
flex-direction: column;
gap: var(--size-2-2);
}
/* View Item */
.calendar-view-item {
display: flex;
align-items: center;
gap: var(--size-4-2);
padding: var(--size-4-2) var(--size-4-3);
background: var(--background-secondary);
border-radius: var(--radius-m);
border: 1px solid var(--background-modifier-border);
transition: all 0.15s ease;
}
.calendar-view-item:hover {
border-color: var(--interactive-accent);
}
.calendar-view-item.is-disabled {
opacity: 0.6;
}
.calendar-view-item.is-disabled .calendar-view-name {
text-decoration: line-through;
}
.calendar-view-drag-handle {
cursor: grab;
color: var(--text-faint);
padding: var(--size-2-1);
}
.calendar-view-drag-handle:hover {
color: var(--text-muted);
}
.calendar-view-drag-handle:active {
cursor: grabbing;
}
.calendar-view-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--background-primary);
border-radius: var(--radius-s);
color: var(--text-accent);
}
.calendar-view-icon svg {
width: 16px;
height: 16px;
}
.calendar-view-info {
flex: 1;
min-width: 0;
}
.calendar-view-name {
font-weight: var(--font-medium);
color: var(--text-normal);
}
.calendar-view-meta {
display: flex;
flex-wrap: wrap;
gap: var(--size-2-2);
margin-top: var(--size-2-1);
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.calendar-view-base-type {
background: var(--background-primary);
padding: 1px 6px;
border-radius: var(--radius-s);
}
.calendar-view-config-summary {
color: var(--text-faint);
}
.calendar-view-actions {
display: flex;
gap: var(--size-2-1);
}
.calendar-view-action-btn {
padding: var(--size-2-2);
border-radius: var(--radius-s);
}
.calendar-view-action-delete:hover {
color: var(--text-error);
}
/* Add View Container */
.calendar-add-view-container {
margin-top: var(--size-4-2);
}
/* Sortable States */
.sortable-ghost {
opacity: 0.4;
background: var(--interactive-accent);
}
.sortable-chosen {
box-shadow: 0 4px 12px var(--background-modifier-box-shadow);
}
.sortable-drag {
opacity: 0.9;
}
/* ============================================
Calendar View Config Modal Styles
============================================ */
.calendar-view-config-modal {
width: 560px;
max-width: 90vw;
}
.calendar-view-config-modal .modal-header {
margin-bottom: var(--size-4-3);
}
.calendar-view-config-modal .modal-header h2 {
margin: 0;
}
/* Tabs */
.calendar-config-tabs {
display: flex;
gap: var(--size-2-1);
margin-bottom: var(--size-4-4);
padding-bottom: var(--size-4-2);
border-bottom: 1px solid var(--background-modifier-border);
}
.calendar-config-tab {
display: flex;
align-items: center;
gap: var(--size-2-2);
padding: var(--size-2-2) var(--size-4-2);
border-radius: var(--radius-s);
cursor: pointer;
color: var(--text-muted);
font-size: var(--font-ui-small);
transition: all 0.15s ease;
}
.calendar-config-tab:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.calendar-config-tab.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.calendar-config-tab.is-disabled {
opacity: 0.4;
cursor: not-allowed;
}
.calendar-config-tab.is-disabled:hover {
background: transparent;
color: var(--text-muted);
}
.calendar-config-tab-icon {
display: flex;
align-items: center;
}
.calendar-config-tab-icon svg {
width: 14px;
height: 14px;
}
/* Content */
.calendar-config-content {
min-height: 280px;
}
.calendar-config-content .setting-item {
border-top: none;
padding: var(--size-4-2) 0;
}
.calendar-config-content .setting-item:first-child {
padding-top: 0;
}
.calendar-config-content .setting-item-heading {
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: var(--size-4-2);
margin-bottom: var(--size-4-2);
}
/* Icon Selector */
.calendar-view-icon-selector {
display: inline-block;
}
.calendar-view-icon-selector button {
padding: var(--size-4-2);
}
/* Days Selector */
.calendar-days-selector {
display: flex;
flex-wrap: wrap;
gap: var(--size-2-2);
}
.calendar-day-chip {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
padding: var(--size-2-2) var(--size-4-2);
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
cursor: pointer;
font-size: var(--font-ui-smaller);
color: var(--text-normal);
transition: all 0.15s ease;
}
.calendar-day-chip:hover {
border-color: var(--interactive-accent);
}
.calendar-day-chip.is-hidden {
background: var(--text-error);
border-color: var(--text-error);
color: white;
}
/* Time Range Display */
.calendar-time-range-display {
text-align: center;
padding: var(--size-4-2);
background: var(--background-secondary);
border-radius: var(--radius-s);
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
color: var(--text-accent);
margin-top: var(--size-4-2);
}
/* Notice */
.calendar-config-notice {
padding: var(--size-4-3);
background: var(--background-secondary);
border-radius: var(--radius-s);
color: var(--text-muted);
font-size: var(--font-ui-small);
text-align: center;
}

View file

@ -19,31 +19,177 @@
margin-bottom: 0;
}
.full-calendar-container .calendar-header button {
margin: 0 var(--size-4-1);
}
.full-calendar-container .calendar-nav,
.full-calendar-container .calendar-view-switcher {
display: flex;
gap: var(--size-2-2);
}
/* ========================================
Navigation Control (prev/today/next)
======================================== */
.full-calendar-container .calendar-nav {
background-color: var(--background-secondary);
border-radius: var(--radius-m);
padding: 3px;
gap: 2px;
}
.full-calendar-container .calendar-nav > div {
display: flex;
}
.full-calendar-container .calendar-nav button {
border-radius: var(--radius-s);
display: flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
min-width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: calc(var(--radius-m) - 2px);
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-weight: var(--font-medium);
text-transform: uppercase;
box-shadow: none;
}
.full-calendar-container .calendar-view-switcher button {
border-radius: var(--radius-s);
text-transform: uppercase;
.full-calendar-container .calendar-nav button:hover {
color: var(--text-normal);
background-color: var(--background-modifier-hover);
}
.full-calendar-container .calendar-view-switcher button:not(.is-active),
.full-calendar-container .calendar-nav button:not(.is-active) {
box-shadow: var(--shadow-xs);
border: 1px solid var(--background-modifier-border);
.full-calendar-container .calendar-nav button:active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.full-calendar-container .calendar-nav .today-button {
padding: 6px 12px;
font-weight: var(--font-semibold);
}
/* ========================================
Segmented Control View Switcher
======================================== */
.full-calendar-container .calendar-segmented-control {
display: flex;
align-items: center;
position: relative;
background-color: var(--background-secondary);
border-radius: var(--radius-m);
padding: 3px;
gap: 2px;
}
/* Sliding active indicator */
.full-calendar-container .calendar-segmented-indicator {
position: absolute;
top: 3px;
bottom: 3px;
left: 3px;
background-color: var(--interactive-accent);
border-radius: calc(var(--radius-m) - 2px);
transition:
transform 0.2s ease,
width 0.2s ease;
z-index: 0;
pointer-events: none;
}
/* Segment button base styles */
.full-calendar-container button.calendar-segment-btn {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
min-width: 32px;
height: 28px;
border: none;
background: transparent;
border-radius: calc(var(--radius-m) - 2px);
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
color: var(--text-muted);
font-size: var(--font-ui-small);
font-weight: var(--font-medium);
white-space: nowrap;
box-shadow: none;
}
.full-calendar-container button.calendar-segment-btn:hover {
color: var(--text-normal);
background-color: var(--background-modifier-hover);
}
.full-calendar-container button.calendar-segment-btn.is-active {
color: var(--text-on-accent);
background-color: var(--color-accent); /* Indicator provides background */
}
.full-calendar-container button.calendar-segment-btn.is-active:hover {
background-color: var(--color-accent-1);
}
/* Short label (always visible) */
.full-calendar-container .calendar-segment-short {
font-weight: var(--font-semibold);
font-size: var(--font-ui-small);
line-height: 1;
}
/* Full label (hidden by default, shown on wider screens) */
.full-calendar-container .calendar-segment-full {
display: none;
font-size: var(--font-ui-smaller);
line-height: 1;
}
/* Custom view icon */
.full-calendar-container .calendar-segment-icon {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
}
.full-calendar-container .calendar-segment-icon svg {
width: 14px;
height: 14px;
}
/* Separator between built-in and custom views */
.full-calendar-container .calendar-segment-separator {
width: 1px;
height: 16px;
background-color: var(--background-modifier-border);
margin: 0 4px;
}
/* Custom segment button adjustments */
.full-calendar-container .calendar-segment-btn.calendar-segment-custom {
gap: 2px;
}
.full-calendar-container
.calendar-segment-btn.calendar-segment-custom
.calendar-segment-icon {
display: flex;
}
.full-calendar-container .calendar-current-date {
@ -58,10 +204,22 @@
white-space: nowrap;
}
.full-calendar-container .calendar-view-switcher button.is-active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent-hover);
/* Show full labels on wider screens */
@container (min-width: 700px) {
.full-calendar-container .calendar-segment-full {
display: inline;
}
.full-calendar-container .calendar-segment-btn {
padding: 6px 12px;
}
/* Show short label for custom views on wider screens too */
.full-calendar-container
.calendar-segment-btn.calendar-segment-custom
.calendar-segment-short {
display: inline;
}
}
.full-calendar-container .calendar-view-container {
@ -638,7 +796,7 @@
}
.full-calendar-container input.task-list-item-checkbox {
scale: 0.9;
vertical-align: middle;
}
.full-calendar-container .calendar-view-switcher-selector {
@ -687,8 +845,9 @@
transition: background-color 0.2s ease;
}
/* Hide segmented control and show dropdown on narrow screens */
@container (max-width: 600px) {
.full-calendar-container .calendar-view-switcher button {
.full-calendar-container .calendar-segmented-control {
display: none;
}
@ -704,3 +863,19 @@
display: block;
}
}
/* Medium width: show only short labels */
@container (min-width: 601px) and (max-width: 699px) {
.full-calendar-container .calendar-segment-full {
display: none;
}
.full-calendar-container .calendar-segment-short {
display: inline;
}
.full-calendar-container .calendar-segment-btn {
padding: 6px 8px;
min-width: 28px;
}
}

View file

@ -125,7 +125,7 @@
.tg-month-row {
position: relative;
height: 120px;
/*height: 120px;*/
border-bottom: 1px solid var(--background-modifier-border);
}

View file

@ -60,6 +60,9 @@ export interface HiddenModulesConfig {
/** Hidden feature components */
features?: FeatureComponentType[];
/** Hidden custom calendar view IDs */
calendarViews?: string[];
}
/** Sidebar component types that can be hidden (Fluent interface) */
@ -98,7 +101,7 @@ export interface ModuleDefinition {
id: string;
name: string;
icon: string;
type: "view" | "sidebar" | "feature";
type: "view" | "sidebar" | "feature" | "calendarView";
}
export interface WorkspacesConfig {

File diff suppressed because one or more lines are too long