Skip to content

Commit 2a70cf0

Browse files
committed
feat(#1108): Add option to use task duration for ICS export event length
When enabled, exported calendar events use scheduled date + time estimate as DTSTART/DTEND instead of using due date for DTEND. This aligns with GTD workflows where scheduled + duration represents work planning, while due date represents deadlines. The feature is off by default to preserve existing behavior. - Add useDurationForExport setting to ICSIntegrationSettings - Add ICSExportOptions interface for export configuration - Update CalendarExportService methods to accept duration option - Add UI toggle in Settings → Integrations → Automatic ICS Export - Add English translation keys for the new setting - Update and enable tests for the feature
1 parent 79a4839 commit 2a70cf0

File tree

8 files changed

+224
-93
lines changed

8 files changed

+224
-93
lines changed

docs/releases/unreleased.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ Example:
9999
- Defaults to disabled to preserve existing behavior
100100
- Thanks to @MiserMagus for the feature request
101101

102+
- (#1108) Option to use task duration instead of due date for ICS calendar export
103+
- New "Use task duration for event length" toggle in Settings → Integrations → Automatic ICS Export
104+
- When enabled, exported calendar events use scheduled date + time estimate as DTSTART/DTEND
105+
- This aligns with GTD workflows where scheduled + duration represents work planning, while due date represents deadlines
106+
- When disabled (default), preserves existing behavior using due date as DTEND
107+
- Thanks to @bepolymathe for the feature request
108+
102109
## Fixed
103110

104111
- (#1384) Fixed title being sanitized even when "Store Task Title in Filename" is disabled

src/i18n/resources/en.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1677,6 +1677,11 @@ export const en: TranslationTree = {
16771677
description: "How often to update the export file",
16781678
placeholder: "60",
16791679
},
1680+
useDuration: {
1681+
name: "Use task duration for event length",
1682+
description:
1683+
"When enabled, uses the task's time estimate (duration) instead of due date for the calendar event end time. This is useful for GTD workflows where scheduled + duration represents work planning, while due date represents deadlines.",
1684+
},
16801685
exportNow: {
16811686
name: "Export now",
16821687
description: "Manually trigger an immediate export",

src/services/AutoExportService.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,11 @@ export class AutoExportService {
100100
return;
101101
}
102102

103-
// Generate ICS content
104-
const icsContent = CalendarExportService.generateMultipleTasksICSContent(allTasks);
103+
// Generate ICS content with export options from settings
104+
const exportOptions = {
105+
useDurationForExport: this.plugin.settings.icsIntegration.useDurationForExport,
106+
};
107+
const icsContent = CalendarExportService.generateMultipleTasksICSContent(allTasks, exportOptions);
105108

106109
// Write to file - use path as-is since Obsidian handles normalization
107110
const normalizedPath = exportPath;

src/services/CalendarExportService.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export interface CalendarURLOptions {
99
useScheduledAsDue?: boolean; // If task has no due date, use scheduled as end time
1010
}
1111

12+
export interface ICSExportOptions {
13+
useDurationForExport?: boolean; // Use timeEstimate (duration) instead of due date for DTEND
14+
}
15+
1216
type TranslateFn = (key: TranslationKey, variables?: Record<string, any>) => string;
1317

1418
export class CalendarExportService {
@@ -162,7 +166,7 @@ export class CalendarExportService {
162166
/**
163167
* Generate ICS file content
164168
*/
165-
static generateICSContent(task: TaskInfo): string {
169+
static generateICSContent(task: TaskInfo, options?: ICSExportOptions): string {
166170
const uid = `${task.path.replace(/[^a-zA-Z0-9]/g, "-")}-${Date.now()}@tasknotes`;
167171
const now = new Date()
168172
.toISOString()
@@ -184,7 +188,7 @@ export class CalendarExportService {
184188
lines.push(`SUMMARY:${this.escapeICSText(task.title)}`);
185189

186190
// Add dates
187-
const { startICS, endICS } = this.getICSDateFormat(task);
191+
const { startICS, endICS } = this.getICSDateFormat(task, true, options);
188192
if (startICS) {
189193
lines.push(`DTSTART:${startICS}`);
190194
}
@@ -309,7 +313,8 @@ export class CalendarExportService {
309313
*/
310314
private static getTaskDateRange(
311315
task: TaskInfo,
312-
useScheduledAsDue: boolean
316+
useScheduledAsDue: boolean,
317+
options?: ICSExportOptions
313318
): { startISO: string | null; endISO: string | null } {
314319
let startISO: string | null = null;
315320
let endISO: string | null = null;
@@ -323,15 +328,22 @@ export class CalendarExportService {
323328
}
324329
}
325330

326-
if (task.due) {
331+
// When useDurationForExport is enabled, use timeEstimate to calculate end time
332+
// instead of using due date
333+
if (options?.useDurationForExport && startISO && task.timeEstimate && task.timeEstimate > 0) {
334+
// Use scheduled + timeEstimate (in minutes) as end time
335+
const start = new Date(startISO);
336+
const end = new Date(start.getTime() + task.timeEstimate * 60 * 1000);
337+
endISO = end.toISOString();
338+
} else if (task.due) {
327339
try {
328340
const dueDate = this.parseTaskDate(task.due);
329341
endISO = dueDate.toISOString();
330342
} catch (e) {
331343
console.warn("Invalid due date:", task.due);
332344
}
333345
} else if (useScheduledAsDue && startISO) {
334-
// Use scheduled + 1 hour as end time
346+
// Use scheduled + 1 hour as end time (default fallback)
335347
const start = new Date(startISO);
336348
const end = new Date(start.getTime() + 60 * 60 * 1000);
337349
endISO = end.toISOString();
@@ -365,9 +377,10 @@ export class CalendarExportService {
365377
*/
366378
private static getICSDateFormat(
367379
task: TaskInfo,
368-
useScheduledAsDue = true
380+
useScheduledAsDue = true,
381+
options?: ICSExportOptions
369382
): { startICS: string | null; endICS: string | null } {
370-
const { startISO, endISO } = this.getTaskDateRange(task, useScheduledAsDue);
383+
const { startISO, endISO } = this.getTaskDateRange(task, useScheduledAsDue, options);
371384

372385
const formatICS = (isoString: string): string => {
373386
const date = new Date(isoString);
@@ -460,7 +473,7 @@ export class CalendarExportService {
460473
/**
461474
* Generate ICS content for multiple tasks
462475
*/
463-
static generateMultipleTasksICSContent(tasks: TaskInfo[]): string {
476+
static generateMultipleTasksICSContent(tasks: TaskInfo[], options?: ICSExportOptions): string {
464477
const now = new Date()
465478
.toISOString()
466479
.replace(/[-:]/g, "")
@@ -485,7 +498,7 @@ export class CalendarExportService {
485498
lines.push(`SUMMARY:${this.escapeICSText(task.title)}`);
486499

487500
// Add dates - ensure every event has a DTSTART (required by ICS standard)
488-
let { startICS, endICS } = this.getICSDateFormat(task);
501+
let { startICS, endICS } = this.getICSDateFormat(task, true, options);
489502

490503
// If no start date, use task creation date or current date as fallback
491504
if (!startICS) {
@@ -567,7 +580,7 @@ export class CalendarExportService {
567580
/**
568581
* Download ICS file for all tasks
569582
*/
570-
static downloadAllTasksICSFile(tasks: TaskInfo[], translate?: TranslateFn): void {
583+
static downloadAllTasksICSFile(tasks: TaskInfo[], translate?: TranslateFn, options?: ICSExportOptions): void {
571584
try {
572585
if (!tasks || tasks.length === 0) {
573586
new Notice(
@@ -578,7 +591,7 @@ export class CalendarExportService {
578591
return;
579592
}
580593

581-
const icsContent = this.generateMultipleTasksICSContent(tasks);
594+
const icsContent = this.generateMultipleTasksICSContent(tasks, options);
582595
const blob = new Blob([icsContent], { type: "text/calendar" });
583596
const url = URL.createObjectURL(blob);
584597

@@ -615,9 +628,9 @@ export class CalendarExportService {
615628
/**
616629
* Download ICS file for a task
617630
*/
618-
static downloadICSFile(task: TaskInfo, translate?: TranslateFn): void {
631+
static downloadICSFile(task: TaskInfo, translate?: TranslateFn, options?: ICSExportOptions): void {
619632
try {
620-
const icsContent = this.generateICSContent(task);
633+
const icsContent = this.generateICSContent(task, options);
621634
const blob = new Blob([icsContent], { type: "text/calendar" });
622635
const url = URL.createObjectURL(blob);
623636

src/settings/defaults.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export const DEFAULT_ICS_INTEGRATION_SETTINGS: ICSIntegrationSettings = {
189189
enableAutoExport: false,
190190
autoExportPath: "tasknotes-calendar.ics",
191191
autoExportInterval: 60, // 60 minutes by default
192+
useDurationForExport: false, // Preserve existing behavior: use due date as DTEND
192193
// Task creation defaults
193194
useICSEndAsDue: false, // Preserve existing behavior: don't set due date from ICS events
194195
};

src/settings/tabs/integrationsTab.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,18 @@ export function renderIntegrationsTab(
10291029
})
10301030
);
10311031

1032+
group.addSetting((setting) =>
1033+
configureToggleSetting(setting, {
1034+
name: translate("settings.integrations.autoExport.useDuration.name"),
1035+
desc: translate("settings.integrations.autoExport.useDuration.description"),
1036+
getValue: () => plugin.settings.icsIntegration.useDurationForExport ?? false,
1037+
setValue: async (value: boolean) => {
1038+
plugin.settings.icsIntegration.useDurationForExport = value;
1039+
save();
1040+
},
1041+
})
1042+
);
1043+
10321044
// Manual export trigger button
10331045
group.addSetting((setting) =>
10341046
configureButtonSetting(setting, {

src/types/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ export interface ICSIntegrationSettings {
280280
enableAutoExport: boolean; // Whether to automatically export tasks to ICS file
281281
autoExportPath: string; // Path where the ICS file should be saved
282282
autoExportInterval: number; // Export interval in minutes (default: 60)
283+
useDurationForExport: boolean; // Whether to use timeEstimate (duration) instead of due date for DTEND
283284
// Task creation from ICS events
284285
useICSEndAsDue: boolean; // Whether to use ICS event end time as task due date
285286
}

0 commit comments

Comments
 (0)