mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
feat: Implement task reminders system with iCalendar VALARM support
## New Features ### Core Reminder System - Add `reminders` field to TaskInfo interface following iCalendar VALARM spec - Support both relative (`-PT15M`) and absolute (`2025-10-26T09:00:00`) reminders - Implement reminder data mapping in FieldMapper and MinimalNativeCache - Add reminders to task creation and edit workflows ### User Interface Components - **ReminderModal**: Complete modal for managing task reminders - Add/remove relative and absolute reminders - Visual forms with date/time pickers and duration controls - Real-time preview and validation - **ReminderContextMenu**: Quick access menu for common reminder actions - Pre-configured options (5min, 15min, 1hr, 1day before) - Context-aware based on task due/scheduled dates - **Task Cards**: Bell icon indicators for tasks with reminders - Click to open reminder management modal - Tooltip showing reminder count - Proper CSS positioning to avoid icon overlap ### Modal Enhancements - Add reminder icons to TaskCreationModal and TaskEditModal action bars - Enhanced event system for real-time UI updates - Support for reminder preview changes during editing ### Notification Service - NotificationService foundation for future reminder notifications - Settings for notification preferences (system vs in-app) - Integration points for reminder processing ## Technical Implementation ### Data Layer - Extended TaskInfo with optional `reminders: Reminder[]` field - Updated FieldMapper to handle reminder array serialization - Modified MinimalNativeCache and helpers to include reminders in task extraction - Enhanced TaskService to support reminder CRUD operations ### Event System - Enhanced ReminderModal with comprehensive event emission - `reminder-changed` events for saved changes - `reminder-preview-changed` events for real-time feedback - Proper cancellation handling and state reset ### Styling - New reminder-modal.css for modal components - Updated task-card-bem.css with proper icon positioning - BEM methodology for consistent component styling - Responsive design considerations ## Bug Fixes - Fix icon overlap on task cards by adjusting CSS positioning: - Recurring indicator: `right: 26px` - Reminder indicator: `right: 44px` - Project indicator: `right: 62px` - Chevron: `right: 80px` - Ensure reminders field properly propagates through cache system - Add TypeScript type safety for reminder data structures ## Settings Integration - Add notification preferences to settings panel - Field mapping support for custom reminder property names - Backward compatibility with existing task data This implementation provides a complete foundation for task reminders while maintaining full backward compatibility and following the plugin's architectural patterns.
This commit is contained in:
parent
30f609bb50
commit
24eeb4a92b
17 changed files with 1458 additions and 41 deletions
|
|
@ -14,6 +14,7 @@ const CSS_FILES = [
|
|||
'styles/filter-bar-bem.css', // FilterBar component with proper BEM scoping
|
||||
'styles/modal-bem.css', // Modal components with proper BEM scoping
|
||||
'styles/task-modal.css', // Task modal components (Google Keep/Todoist style)
|
||||
'styles/reminder-modal.css', // Reminder modal component with proper BEM scoping
|
||||
'styles/date-picker.css', // Enhanced date/time picker styling
|
||||
'styles/task-selector-modal.css', // TaskSelectorModal component with proper BEM scoping
|
||||
'styles/unscheduled-tasks-selector-modal.css', // UnscheduledTasksSelectorModal component with proper BEM scoping
|
||||
|
|
|
|||
159
src/components/ReminderContextMenu.ts
Normal file
159
src/components/ReminderContextMenu.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { Menu, setIcon } from 'obsidian';
|
||||
import TaskNotesPlugin from '../main';
|
||||
import { TaskInfo, Reminder } from '../types';
|
||||
import { ReminderModal } from '../modals/ReminderModal';
|
||||
|
||||
export class ReminderContextMenu {
|
||||
private plugin: TaskNotesPlugin;
|
||||
private task: TaskInfo;
|
||||
private triggerElement: HTMLElement;
|
||||
private onUpdate: (task: TaskInfo) => void;
|
||||
|
||||
constructor(
|
||||
plugin: TaskNotesPlugin,
|
||||
task: TaskInfo,
|
||||
triggerElement: HTMLElement,
|
||||
onUpdate: (task: TaskInfo) => void
|
||||
) {
|
||||
this.plugin = plugin;
|
||||
this.task = task;
|
||||
this.triggerElement = triggerElement;
|
||||
this.onUpdate = onUpdate;
|
||||
}
|
||||
|
||||
show(event?: MouseEvent): void {
|
||||
const menu = new Menu();
|
||||
|
||||
// Quick Add sections
|
||||
this.addQuickRemindersSection(menu, 'due', 'Remind before due...');
|
||||
this.addQuickRemindersSection(menu, 'scheduled', 'Remind before scheduled...');
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
// Manage reminders
|
||||
menu.addItem(item => {
|
||||
item
|
||||
.setTitle('Manage All Reminders...')
|
||||
.setIcon('settings')
|
||||
.onClick(() => {
|
||||
this.openReminderModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Clear reminders (if any exist)
|
||||
if (this.task.reminders && this.task.reminders.length > 0) {
|
||||
menu.addItem(item => {
|
||||
item
|
||||
.setTitle('Clear All Reminders')
|
||||
.setIcon('trash')
|
||||
.onClick(async () => {
|
||||
await this.clearAllReminders();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (event) {
|
||||
menu.showAtMouseEvent(event);
|
||||
} else {
|
||||
menu.showAtMouseEvent(new MouseEvent('contextmenu'));
|
||||
}
|
||||
}
|
||||
|
||||
private addQuickRemindersSection(menu: Menu, anchor: 'due' | 'scheduled', title: string): void {
|
||||
const anchorDate = anchor === 'due' ? this.task.due : this.task.scheduled;
|
||||
|
||||
if (!anchorDate) {
|
||||
// If no anchor date, show disabled option
|
||||
menu.addItem(item => {
|
||||
item
|
||||
.setTitle(title)
|
||||
.setIcon('bell')
|
||||
.setDisabled(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add submenu for quick reminder options
|
||||
menu.addItem(item => {
|
||||
item
|
||||
.setTitle(title)
|
||||
.setIcon('bell')
|
||||
.onClick((event) => {
|
||||
// Only pass MouseEvent, ignore KeyboardEvent
|
||||
this.showQuickReminderSubmenu(anchor, event instanceof MouseEvent ? event : undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private showQuickReminderSubmenu(anchor: 'due' | 'scheduled', event?: MouseEvent): void {
|
||||
const menu = new Menu();
|
||||
|
||||
const quickOptions = [
|
||||
{ label: 'At time of event', offset: 'PT0M' },
|
||||
{ label: '5 minutes before', offset: '-PT5M' },
|
||||
{ label: '15 minutes before', offset: '-PT15M' },
|
||||
{ label: '1 hour before', offset: '-PT1H' },
|
||||
{ label: '1 day before', offset: '-P1D' }
|
||||
];
|
||||
|
||||
quickOptions.forEach(option => {
|
||||
menu.addItem(item => {
|
||||
item
|
||||
.setTitle(option.label)
|
||||
.onClick(async () => {
|
||||
await this.addQuickReminder(anchor, option.offset, option.label);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (event) {
|
||||
menu.showAtMouseEvent(event);
|
||||
} else {
|
||||
menu.showAtMouseEvent(new MouseEvent('contextmenu'));
|
||||
}
|
||||
}
|
||||
|
||||
private async addQuickReminder(anchor: 'due' | 'scheduled', offset: string, description: string): Promise<void> {
|
||||
const reminder: Reminder = {
|
||||
id: `rem_${Date.now()}`,
|
||||
type: 'relative',
|
||||
relatedTo: anchor,
|
||||
offset,
|
||||
description
|
||||
};
|
||||
|
||||
const updatedReminders = [...(this.task.reminders || []), reminder];
|
||||
await this.saveReminders(updatedReminders);
|
||||
}
|
||||
|
||||
private async clearAllReminders(): Promise<void> {
|
||||
await this.saveReminders([]);
|
||||
}
|
||||
|
||||
private async saveReminders(reminders: Reminder[]): Promise<void> {
|
||||
const updatedTask: TaskInfo = {
|
||||
...this.task,
|
||||
reminders
|
||||
};
|
||||
|
||||
// Only save to file if the task already exists (has a valid path)
|
||||
if (this.task.path && this.task.path.trim() !== '') {
|
||||
await this.plugin.taskService.updateProperty(this.task, 'reminders', reminders);
|
||||
}
|
||||
|
||||
// Always notify the caller about the update (for local state management)
|
||||
this.onUpdate(updatedTask);
|
||||
}
|
||||
|
||||
private openReminderModal(): void {
|
||||
const modal = new ReminderModal(
|
||||
this.plugin.app,
|
||||
this.plugin,
|
||||
this.task,
|
||||
async (reminders: Reminder[]) => {
|
||||
await this.saveReminders(reminders);
|
||||
}
|
||||
);
|
||||
modal.open();
|
||||
}
|
||||
}
|
||||
13
src/main.ts
13
src/main.ts
|
|
@ -64,6 +64,7 @@ import { showMigrationPrompt } from './modals/MigrationModal';
|
|||
import { StatusBarService } from './services/StatusBarService';
|
||||
import { ProjectSubtasksService } from './services/ProjectSubtasksService';
|
||||
import { ExpandedProjectsService } from './services/ExpandedProjectsService';
|
||||
import { NotificationService } from './services/NotificationService';
|
||||
|
||||
// Type definitions for better type safety
|
||||
interface TaskUpdateEventData {
|
||||
|
|
@ -141,6 +142,9 @@ export default class TaskNotesPlugin extends Plugin {
|
|||
// Status bar service
|
||||
statusBarService: StatusBarService;
|
||||
|
||||
// Notification service
|
||||
notificationService: NotificationService;
|
||||
|
||||
// Event listener cleanup
|
||||
private taskUpdateListenerForEditor: import('obsidian').EventRef | null = null;
|
||||
|
||||
|
|
@ -194,6 +198,7 @@ export default class TaskNotesPlugin extends Plugin {
|
|||
this.dragDropManager = new DragDropManager(this);
|
||||
this.migrationService = new MigrationService(this.app);
|
||||
this.statusBarService = new StatusBarService(this);
|
||||
this.notificationService = new NotificationService(this);
|
||||
|
||||
// Note: View registration and heavy operations moved to onLayoutReady
|
||||
|
||||
|
|
@ -319,6 +324,9 @@ export default class TaskNotesPlugin extends Plugin {
|
|||
// Initialize status bar service
|
||||
this.statusBarService.initialize();
|
||||
|
||||
// Initialize notification service
|
||||
await this.notificationService.initialize();
|
||||
|
||||
// Defer heavy service initialization until needed
|
||||
this.initializeServicesLazily();
|
||||
|
||||
|
|
@ -729,6 +737,11 @@ export default class TaskNotesPlugin extends Plugin {
|
|||
this.statusBarService.destroy();
|
||||
}
|
||||
|
||||
// Clean up notification service
|
||||
if (this.notificationService) {
|
||||
this.notificationService.destroy();
|
||||
}
|
||||
|
||||
// Clean up native cache manager
|
||||
if (this.cacheManager) {
|
||||
this.cacheManager.destroy();
|
||||
|
|
|
|||
491
src/modals/ReminderModal.ts
Normal file
491
src/modals/ReminderModal.ts
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
import { App, Modal, Setting, setIcon, Notice } from 'obsidian';
|
||||
import TaskNotesPlugin from '../main';
|
||||
import { TaskInfo, Reminder } from '../types';
|
||||
import { formatDateForDisplay, parseDateToLocal, getCurrentTimestamp } from '../utils/dateUtils';
|
||||
|
||||
export class ReminderModal extends Modal {
|
||||
private plugin: TaskNotesPlugin;
|
||||
private task: TaskInfo;
|
||||
private reminders: Reminder[];
|
||||
private onSave: (reminders: Reminder[]) => void;
|
||||
private originalReminders: Reminder[];
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: TaskNotesPlugin,
|
||||
task: TaskInfo,
|
||||
onSave: (reminders: Reminder[]) => void
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.task = task;
|
||||
this.reminders = task.reminders ? [...task.reminders] : [];
|
||||
this.originalReminders = task.reminders ? [...task.reminders] : [];
|
||||
this.onSave = onSave;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('tasknotes-reminder-modal');
|
||||
|
||||
// Header
|
||||
const header = contentEl.createEl('h2', { text: 'Manage Reminders' });
|
||||
const subtitle = contentEl.createEl('div', {
|
||||
cls: 'tasknotes-reminder-subtitle',
|
||||
text: this.task.title
|
||||
});
|
||||
|
||||
// Existing reminders section
|
||||
this.renderExistingReminders(contentEl);
|
||||
|
||||
// Add new reminder section
|
||||
this.renderAddReminderForm(contentEl);
|
||||
|
||||
// Action buttons
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'modal-button-container' });
|
||||
|
||||
const saveBtn = buttonContainer.createEl('button', { text: 'Save' });
|
||||
saveBtn.onclick = () => {
|
||||
this.save();
|
||||
};
|
||||
|
||||
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' });
|
||||
cancelBtn.onclick = () => {
|
||||
this.cancel();
|
||||
};
|
||||
}
|
||||
|
||||
private renderExistingReminders(container: HTMLElement): void {
|
||||
const section = container.createDiv({ cls: 'reminder-section' });
|
||||
section.createEl('h3', { text: 'Existing Reminders' });
|
||||
|
||||
if (this.reminders.length === 0) {
|
||||
section.createEl('div', {
|
||||
cls: 'no-reminders',
|
||||
text: 'No reminders set'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reminderList = section.createDiv({ cls: 'reminder-list' });
|
||||
|
||||
this.reminders.forEach((reminder, index) => {
|
||||
const reminderItem = reminderList.createDiv({ cls: 'reminder-item' });
|
||||
|
||||
// Main content area
|
||||
const content = reminderItem.createDiv({ cls: 'reminder-item__content' });
|
||||
|
||||
// Type and timing
|
||||
const timing = content.createDiv({ cls: 'reminder-item__timing' });
|
||||
timing.textContent = this.formatReminderTiming(reminder);
|
||||
|
||||
// Description (if custom)
|
||||
if (reminder.description) {
|
||||
const description = content.createDiv({ cls: 'reminder-item__description' });
|
||||
description.textContent = reminder.description;
|
||||
}
|
||||
|
||||
// Details
|
||||
const details = content.createDiv({ cls: 'reminder-item__details' });
|
||||
details.textContent = this.formatReminderDetails(reminder);
|
||||
|
||||
// Actions area
|
||||
const actions = reminderItem.createDiv({ cls: 'reminder-item__actions' });
|
||||
|
||||
// Remove button
|
||||
const removeBtn = actions.createEl('button', {
|
||||
cls: 'reminder-item__remove-btn',
|
||||
attr: { 'aria-label': 'Remove reminder' }
|
||||
});
|
||||
setIcon(removeBtn, 'trash');
|
||||
removeBtn.onclick = () => {
|
||||
this.removeReminder(index);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private renderAddReminderForm(container: HTMLElement): void {
|
||||
const section = container.createDiv({ cls: 'reminder-section' });
|
||||
section.createEl('h3', { text: 'Add New Reminder' });
|
||||
|
||||
const form = section.createDiv({ cls: 'reminder-form' });
|
||||
|
||||
// Type selector
|
||||
let selectedType: 'absolute' | 'relative' = 'relative';
|
||||
let relativeAnchor: 'due' | 'scheduled' = 'due';
|
||||
let relativeOffset = 15;
|
||||
let relativeUnit: 'minutes' | 'hours' | 'days' = 'minutes';
|
||||
let relativeDirection: 'before' | 'after' = 'before';
|
||||
let absoluteDate = '';
|
||||
let absoluteTime = '';
|
||||
let description = '';
|
||||
|
||||
new Setting(form)
|
||||
.setName('Reminder Type')
|
||||
.addDropdown(dropdown => {
|
||||
dropdown
|
||||
.addOption('relative', 'Relative to task date')
|
||||
.addOption('absolute', 'Specific date and time')
|
||||
.setValue(selectedType)
|
||||
.onChange(value => {
|
||||
selectedType = value as 'absolute' | 'relative';
|
||||
this.updateFormVisibility(form, selectedType);
|
||||
});
|
||||
});
|
||||
|
||||
// Relative reminder fields
|
||||
const relativeContainer = form.createDiv({ cls: 'relative-fields' });
|
||||
|
||||
new Setting(relativeContainer)
|
||||
.setName('Time')
|
||||
.addText(text => {
|
||||
text
|
||||
.setPlaceholder('15')
|
||||
.setValue(String(relativeOffset))
|
||||
.onChange(value => {
|
||||
relativeOffset = parseInt(value) || 0;
|
||||
});
|
||||
})
|
||||
.addDropdown(dropdown => {
|
||||
dropdown
|
||||
.addOption('minutes', 'minutes')
|
||||
.addOption('hours', 'hours')
|
||||
.addOption('days', 'days')
|
||||
.setValue(relativeUnit)
|
||||
.onChange(value => {
|
||||
relativeUnit = value as 'minutes' | 'hours' | 'days';
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(relativeContainer)
|
||||
.setName('Direction')
|
||||
.addDropdown(dropdown => {
|
||||
dropdown
|
||||
.addOption('before', 'Before')
|
||||
.addOption('after', 'After')
|
||||
.setValue(relativeDirection)
|
||||
.onChange(value => {
|
||||
relativeDirection = value as 'before' | 'after';
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(relativeContainer)
|
||||
.setName('Relative to')
|
||||
.addDropdown(dropdown => {
|
||||
const options: any = {};
|
||||
if (this.task.due) {
|
||||
options.due = `Due date (${formatDateForDisplay(this.task.due)})`;
|
||||
}
|
||||
if (this.task.scheduled) {
|
||||
options.scheduled = `Scheduled date (${formatDateForDisplay(this.task.scheduled)})`;
|
||||
}
|
||||
|
||||
if (Object.keys(options).length === 0) {
|
||||
options.none = 'No dates available';
|
||||
dropdown.setDisabled(true);
|
||||
} else {
|
||||
Object.entries(options).forEach(([key, label]) => {
|
||||
dropdown.addOption(key, label as string);
|
||||
});
|
||||
dropdown.setValue(relativeAnchor);
|
||||
}
|
||||
|
||||
dropdown.onChange(value => {
|
||||
relativeAnchor = value as 'due' | 'scheduled';
|
||||
});
|
||||
});
|
||||
|
||||
// Absolute reminder fields
|
||||
const absoluteContainer = form.createDiv({ cls: 'absolute-fields' });
|
||||
absoluteContainer.style.display = 'none';
|
||||
|
||||
new Setting(absoluteContainer)
|
||||
.setName('Date')
|
||||
.addText(text => {
|
||||
text
|
||||
.setPlaceholder('YYYY-MM-DD')
|
||||
.onChange(value => {
|
||||
absoluteDate = value;
|
||||
});
|
||||
text.inputEl.type = 'date';
|
||||
});
|
||||
|
||||
new Setting(absoluteContainer)
|
||||
.setName('Time')
|
||||
.addText(text => {
|
||||
text
|
||||
.setPlaceholder('HH:MM')
|
||||
.onChange(value => {
|
||||
absoluteTime = value;
|
||||
});
|
||||
text.inputEl.type = 'time';
|
||||
});
|
||||
|
||||
// Description field (common)
|
||||
new Setting(form)
|
||||
.setName('Description (optional)')
|
||||
.addText(text => {
|
||||
text
|
||||
.setPlaceholder('Custom reminder message')
|
||||
.onChange(value => {
|
||||
description = value;
|
||||
});
|
||||
});
|
||||
|
||||
// Add button
|
||||
const addBtn = form.createEl('button', {
|
||||
cls: 'reminder-add-btn',
|
||||
text: 'Add Reminder'
|
||||
});
|
||||
addBtn.onclick = () => {
|
||||
const newReminder = this.createReminder(
|
||||
selectedType,
|
||||
relativeAnchor,
|
||||
relativeOffset,
|
||||
relativeUnit,
|
||||
relativeDirection,
|
||||
absoluteDate,
|
||||
absoluteTime,
|
||||
description
|
||||
);
|
||||
|
||||
if (newReminder) {
|
||||
this.addReminder(newReminder);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private updateFormVisibility(form: HTMLElement, type: 'absolute' | 'relative'): void {
|
||||
const relativeFields = form.querySelector('.relative-fields') as HTMLElement;
|
||||
const absoluteFields = form.querySelector('.absolute-fields') as HTMLElement;
|
||||
|
||||
if (type === 'relative') {
|
||||
relativeFields.style.display = 'block';
|
||||
absoluteFields.style.display = 'none';
|
||||
} else {
|
||||
relativeFields.style.display = 'none';
|
||||
absoluteFields.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
private createReminder(
|
||||
type: 'absolute' | 'relative',
|
||||
anchor: 'due' | 'scheduled',
|
||||
offset: number,
|
||||
unit: 'minutes' | 'hours' | 'days',
|
||||
direction: 'before' | 'after',
|
||||
date: string,
|
||||
time: string,
|
||||
description: string
|
||||
): Reminder | null {
|
||||
const id = `rem_${Date.now()}`;
|
||||
|
||||
if (type === 'relative') {
|
||||
// Check if anchor date exists
|
||||
const anchorDate = anchor === 'due' ? this.task.due : this.task.scheduled;
|
||||
if (!anchorDate) {
|
||||
new Notice(`Cannot create reminder: Task has no ${anchor} date`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert offset to ISO 8601 duration
|
||||
let duration = 'PT';
|
||||
if (unit === 'days') {
|
||||
duration = `P${offset}D`;
|
||||
} else if (unit === 'hours') {
|
||||
duration = `PT${offset}H`;
|
||||
} else {
|
||||
duration = `PT${offset}M`;
|
||||
}
|
||||
|
||||
// Add negative sign for "before"
|
||||
if (direction === 'before') {
|
||||
duration = '-' + duration;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'relative',
|
||||
relatedTo: anchor,
|
||||
offset: duration,
|
||||
description: description || undefined
|
||||
};
|
||||
} else {
|
||||
// Absolute reminder
|
||||
if (!date || !time) {
|
||||
new Notice('Please specify both date and time for absolute reminder');
|
||||
return null;
|
||||
}
|
||||
|
||||
const absoluteTime = `${date}T${time}:00`;
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'absolute',
|
||||
absoluteTime,
|
||||
description: description || undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private formatReminderTiming(reminder: Reminder): string {
|
||||
if (reminder.type === 'absolute') {
|
||||
return 'Absolute reminder';
|
||||
} else {
|
||||
const anchor = reminder.relatedTo === 'due' ? 'due date' : 'scheduled date';
|
||||
const offset = this.formatOffset(reminder.offset || '');
|
||||
return `${offset} ${anchor}`;
|
||||
}
|
||||
}
|
||||
|
||||
private formatReminderDetails(reminder: Reminder): string {
|
||||
if (reminder.type === 'absolute') {
|
||||
return `At ${formatDateForDisplay(reminder.absoluteTime || '')}`;
|
||||
} else {
|
||||
const anchor = reminder.relatedTo === 'due' ? this.task.due : this.task.scheduled;
|
||||
if (!anchor) {
|
||||
return `Relative to ${reminder.relatedTo} date (not set)`;
|
||||
}
|
||||
return `When ${reminder.relatedTo} date is ${formatDateForDisplay(anchor)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private formatReminderDescription(reminder: Reminder): string {
|
||||
if (reminder.description) {
|
||||
return reminder.description;
|
||||
}
|
||||
|
||||
if (reminder.type === 'absolute') {
|
||||
const date = parseDateToLocal(reminder.absoluteTime || '');
|
||||
return `At ${formatDateForDisplay(reminder.absoluteTime || '')}`;
|
||||
} else {
|
||||
const anchor = reminder.relatedTo === 'due' ? 'due date' : 'scheduled date';
|
||||
const offset = this.formatOffset(reminder.offset || '');
|
||||
return `${offset} ${anchor}`;
|
||||
}
|
||||
}
|
||||
|
||||
private formatOffset(offset: string): string {
|
||||
const isNegative = offset.startsWith('-');
|
||||
const cleanOffset = isNegative ? offset.substring(1) : offset;
|
||||
|
||||
const match = cleanOffset.match(/P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?)?/);
|
||||
if (!match) return offset;
|
||||
|
||||
const [, days, hours, minutes] = match;
|
||||
|
||||
let parts: string[] = [];
|
||||
if (days) parts.push(`${days} day${days !== '1' ? 's' : ''}`);
|
||||
if (hours) parts.push(`${hours} hour${hours !== '1' ? 's' : ''}`);
|
||||
if (minutes) parts.push(`${minutes} minute${minutes !== '1' ? 's' : ''}`);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return 'At time of';
|
||||
}
|
||||
|
||||
const formatted = parts.join(' ');
|
||||
return isNegative ? `${formatted} before` : `${formatted} after`;
|
||||
}
|
||||
|
||||
private addReminder(reminder: Reminder): void {
|
||||
this.reminders.push(reminder);
|
||||
this.refresh();
|
||||
|
||||
// Emit immediate event for live UI updates (optional, for real-time feedback)
|
||||
if (this.task.path) {
|
||||
this.plugin.emitter.trigger('reminder-preview-changed', {
|
||||
taskPath: this.task.path,
|
||||
currentReminders: [...this.reminders],
|
||||
action: 'added',
|
||||
reminder: reminder
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private removeReminder(index: number): void {
|
||||
const removedReminder = this.reminders[index];
|
||||
this.reminders.splice(index, 1);
|
||||
this.refresh();
|
||||
|
||||
// Emit immediate event for live UI updates (optional, for real-time feedback)
|
||||
if (this.task.path && removedReminder) {
|
||||
this.plugin.emitter.trigger('reminder-preview-changed', {
|
||||
taskPath: this.task.path,
|
||||
currentReminders: [...this.reminders],
|
||||
action: 'removed',
|
||||
reminder: removedReminder
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.onOpen();
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
// Clear processed reminders for this task so they can trigger again if needed
|
||||
if (this.task.path && this.task.path.trim() !== '') {
|
||||
this.plugin.notificationService?.clearProcessedRemindersForTask(this.task.path);
|
||||
}
|
||||
|
||||
// Check if reminders have actually changed
|
||||
const hasChanges = this.remindersHaveChanged();
|
||||
|
||||
// Always call onSave to maintain existing behavior, but indicate if changes occurred
|
||||
this.onSave(this.reminders);
|
||||
|
||||
// Emit a custom event to notify about reminder changes for immediate UI updates
|
||||
if (hasChanges && this.task.path) {
|
||||
this.plugin.emitter.trigger('reminder-changed', {
|
||||
taskPath: this.task.path,
|
||||
oldReminders: this.originalReminders,
|
||||
newReminders: [...this.reminders]
|
||||
});
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
private cancel(): void {
|
||||
// Emit cancellation event to reset any preview changes
|
||||
if (this.remindersHaveChanged() && this.task.path) {
|
||||
this.plugin.emitter.trigger('reminder-preview-changed', {
|
||||
taskPath: this.task.path,
|
||||
currentReminders: [...this.originalReminders],
|
||||
action: 'cancelled'
|
||||
});
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
private remindersHaveChanged(): boolean {
|
||||
// Quick reference check first
|
||||
if (this.reminders.length !== this.originalReminders.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deep comparison of reminder arrays
|
||||
return !this.reminders.every((reminder, index) => {
|
||||
const original = this.originalReminders[index];
|
||||
if (!original) return false;
|
||||
|
||||
return (
|
||||
reminder.id === original.id &&
|
||||
reminder.type === original.type &&
|
||||
reminder.relatedTo === original.relatedTo &&
|
||||
reminder.offset === original.offset &&
|
||||
reminder.absoluteTime === original.absoluteTime &&
|
||||
reminder.description === original.description
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -443,6 +443,11 @@ export class TaskCreationModal extends TaskModal {
|
|||
this.showRecurrenceContextMenu(event);
|
||||
}, 'recurrence');
|
||||
|
||||
// Reminder icon
|
||||
this.createActionIcon(this.actionBar, 'bell', 'Set reminders', (icon, event) => {
|
||||
this.showReminderContextMenu(event);
|
||||
}, 'reminders');
|
||||
|
||||
// Update icon states based on current values
|
||||
this.updateIconStates();
|
||||
}
|
||||
|
|
@ -639,6 +644,7 @@ export class TaskCreationModal extends TaskModal {
|
|||
tags: tagList.length > 0 ? tagList : undefined,
|
||||
timeEstimate: this.timeEstimate > 0 ? this.timeEstimate : undefined,
|
||||
recurrence: this.recurrenceRule || undefined,
|
||||
reminders: this.reminders.length > 0 ? this.reminders : undefined,
|
||||
creationContext: 'manual-creation', // Mark as manual creation for folder logic
|
||||
dateCreated: now,
|
||||
dateModified: now
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ export class TaskEditModal extends TaskModal {
|
|||
} else {
|
||||
this.recurrenceRule = '';
|
||||
}
|
||||
|
||||
// Initialize reminders
|
||||
this.reminders = this.task.reminders ? [...this.task.reminders] : [];
|
||||
}
|
||||
|
||||
private convertLegacyRecurrenceToString(recurrence: { frequency?: string; days_of_week?: string[]; day_of_month?: number }): string {
|
||||
|
|
@ -459,6 +462,14 @@ export class TaskEditModal extends TaskModal {
|
|||
changes.recurrence = this.recurrenceRule || undefined;
|
||||
}
|
||||
|
||||
// Compare reminders
|
||||
const oldReminders = this.task.reminders || [];
|
||||
const newReminders = this.reminders || [];
|
||||
|
||||
if (JSON.stringify(newReminders) !== JSON.stringify(oldReminders)) {
|
||||
changes.reminders = newReminders.length > 0 ? newReminders : undefined;
|
||||
}
|
||||
|
||||
// Apply completed instances changes
|
||||
if (this.completedInstancesChanges.size > 0) {
|
||||
const currentCompleted = new Set(this.task.complete_instances || []);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { DateContextMenu } from '../components/DateContextMenu';
|
|||
import { PriorityContextMenu } from '../components/PriorityContextMenu';
|
||||
import { StatusContextMenu } from '../components/StatusContextMenu';
|
||||
import { RecurrenceContextMenu } from '../components/RecurrenceContextMenu';
|
||||
import { ReminderContextMenu } from '../components/ReminderContextMenu';
|
||||
import { getDatePart, getTimePart, combineDateAndTime } from '../utils/dateUtils';
|
||||
import { ProjectSelectModal } from './ProjectSelectModal';
|
||||
import { TaskInfo, Reminder } from '../types';
|
||||
|
||||
export abstract class TaskModal extends Modal {
|
||||
plugin: TaskNotesPlugin;
|
||||
|
|
@ -22,6 +24,7 @@ export abstract class TaskModal extends Modal {
|
|||
protected tags = '';
|
||||
protected timeEstimate = 0;
|
||||
protected recurrenceRule = '';
|
||||
protected reminders: Reminder[] = [];
|
||||
|
||||
// Project link storage
|
||||
protected selectedProjectFiles: TAbstractFile[] = [];
|
||||
|
|
@ -119,6 +122,11 @@ export abstract class TaskModal extends Modal {
|
|||
this.showRecurrenceContextMenu(event);
|
||||
}, 'recurrence');
|
||||
|
||||
// Reminder icon
|
||||
this.createActionIcon(this.actionBar, 'bell', 'Set reminders', (icon, event) => {
|
||||
this.showReminderContextMenu(event);
|
||||
}, 'reminders');
|
||||
|
||||
// Update icon states based on current values
|
||||
this.updateIconStates();
|
||||
}
|
||||
|
|
@ -402,6 +410,32 @@ export abstract class TaskModal extends Modal {
|
|||
menu.show(event);
|
||||
}
|
||||
|
||||
protected showReminderContextMenu(event: MouseEvent): void {
|
||||
// Create a temporary task info object for the context menu
|
||||
const tempTask: TaskInfo = {
|
||||
title: this.title,
|
||||
status: this.status,
|
||||
priority: this.priority,
|
||||
due: this.dueDate,
|
||||
scheduled: this.scheduledDate,
|
||||
path: '', // Will be set when saving
|
||||
archived: false,
|
||||
reminders: this.reminders
|
||||
};
|
||||
|
||||
const menu = new ReminderContextMenu(
|
||||
this.plugin,
|
||||
tempTask,
|
||||
event.target as HTMLElement,
|
||||
(updatedTask: TaskInfo) => {
|
||||
this.reminders = updatedTask.reminders || [];
|
||||
this.updateReminderIconState();
|
||||
}
|
||||
);
|
||||
|
||||
menu.show(event);
|
||||
}
|
||||
|
||||
protected updateDateIconState(): void {
|
||||
this.updateIconStates();
|
||||
}
|
||||
|
|
@ -418,6 +452,10 @@ export abstract class TaskModal extends Modal {
|
|||
this.updateIconStates();
|
||||
}
|
||||
|
||||
protected updateReminderIconState(): void {
|
||||
this.updateIconStates();
|
||||
}
|
||||
|
||||
|
||||
protected getDefaultStatus(): string {
|
||||
// Get the first status (lowest order) as default
|
||||
|
|
@ -607,6 +645,20 @@ export abstract class TaskModal extends Modal {
|
|||
setTooltip(recurrenceIcon, 'Set recurrence', { placement: 'top' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update reminder icon
|
||||
const reminderIcon = this.actionBar.querySelector('[data-type="reminders"]') as HTMLElement;
|
||||
if (reminderIcon) {
|
||||
if (this.reminders && this.reminders.length > 0) {
|
||||
reminderIcon.classList.add('has-value');
|
||||
const count = this.reminders.length;
|
||||
const tooltip = count === 1 ? '1 reminder set' : `${count} reminders set`;
|
||||
setTooltip(reminderIcon, tooltip, { placement: 'top' });
|
||||
} else {
|
||||
reminderIcon.classList.remove('has-value');
|
||||
setTooltip(reminderIcon, 'Set reminders', { placement: 'top' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected focusTitleInput(): void {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,12 @@ export class FieldMapper {
|
|||
// Ensure icsEventId is always an array
|
||||
mapped.icsEventId = Array.isArray(icsEventId) ? icsEventId : [icsEventId];
|
||||
}
|
||||
|
||||
if (frontmatter[this.mapping.reminders] !== undefined) {
|
||||
const reminders = frontmatter[this.mapping.reminders];
|
||||
// Ensure reminders is always an array
|
||||
mapped.reminders = Array.isArray(reminders) ? reminders : [reminders];
|
||||
}
|
||||
|
||||
// Handle tags array (includes archive tag)
|
||||
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
|
||||
|
|
@ -182,6 +188,10 @@ export class FieldMapper {
|
|||
if (taskData.icsEventId !== undefined && taskData.icsEventId.length > 0) {
|
||||
frontmatter[this.mapping.icsEventId] = taskData.icsEventId;
|
||||
}
|
||||
|
||||
if (taskData.reminders !== undefined && taskData.reminders.length > 0) {
|
||||
frontmatter[this.mapping.reminders] = taskData.reminders;
|
||||
}
|
||||
|
||||
// Handle tags (merge archive status into tags array)
|
||||
let tags = taskData.tags ? [...taskData.tags] : [];
|
||||
|
|
|
|||
315
src/services/NotificationService.ts
Normal file
315
src/services/NotificationService.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { Notice, TFile } from 'obsidian';
|
||||
import TaskNotesPlugin from '../main';
|
||||
import { TaskInfo, Reminder } from '../types';
|
||||
import { parseDateToLocal, hasTimeComponent } from '../utils/dateUtils';
|
||||
|
||||
interface NotificationQueueItem {
|
||||
taskPath: string;
|
||||
reminder: Reminder;
|
||||
notifyAt: number;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
private plugin: TaskNotesPlugin;
|
||||
private notificationQueue: NotificationQueueItem[] = [];
|
||||
private broadScanInterval?: NodeJS.Timer;
|
||||
private quickCheckInterval?: NodeJS.Timer;
|
||||
private processedReminders: Set<string> = new Set(); // Track processed reminders to avoid duplicates
|
||||
|
||||
// Configuration constants
|
||||
private readonly BROAD_SCAN_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly QUICK_CHECK_INTERVAL = 30 * 1000; // 30 seconds
|
||||
private readonly QUEUE_WINDOW = 5 * 60 * 1000; // 5 minutes ahead
|
||||
|
||||
constructor(plugin: TaskNotesPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.plugin.settings.enableNotifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Request notification permission if using system notifications
|
||||
if (this.plugin.settings.notificationType === 'system' && 'Notification' in window) {
|
||||
if (Notification.permission === 'default') {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
// Start the two-tier interval system
|
||||
this.startBroadScan();
|
||||
this.startQuickCheck();
|
||||
|
||||
// Do an initial scan
|
||||
await this.scanTasksAndBuildQueue();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.broadScanInterval) {
|
||||
clearInterval(this.broadScanInterval);
|
||||
}
|
||||
if (this.quickCheckInterval) {
|
||||
clearInterval(this.quickCheckInterval);
|
||||
}
|
||||
this.notificationQueue = [];
|
||||
this.processedReminders.clear();
|
||||
}
|
||||
|
||||
private startBroadScan(): void {
|
||||
this.broadScanInterval = setInterval(async () => {
|
||||
await this.scanTasksAndBuildQueue();
|
||||
}, this.BROAD_SCAN_INTERVAL);
|
||||
}
|
||||
|
||||
private startQuickCheck(): void {
|
||||
this.quickCheckInterval = setInterval(() => {
|
||||
this.checkNotificationQueue();
|
||||
}, this.QUICK_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
private async scanTasksAndBuildQueue(): Promise<void> {
|
||||
// Clear existing queue and rebuild
|
||||
this.notificationQueue = [];
|
||||
|
||||
// Get all tasks from the cache
|
||||
const tasks = await this.plugin.cacheManager.getAllTasks();
|
||||
const now = Date.now();
|
||||
const windowEnd = now + this.QUEUE_WINDOW;
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.reminders || task.reminders.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const reminder of task.reminders) {
|
||||
// Skip if already processed
|
||||
const reminderId = `${task.path}-${reminder.id}`;
|
||||
if (this.processedReminders.has(reminderId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const notifyAt = this.calculateNotificationTime(task, reminder);
|
||||
if (notifyAt === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add to queue if within the next scan window
|
||||
if (notifyAt > now && notifyAt <= windowEnd) {
|
||||
this.notificationQueue.push({
|
||||
taskPath: task.path,
|
||||
reminder,
|
||||
notifyAt
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort queue by notification time
|
||||
this.notificationQueue.sort((a, b) => a.notifyAt - b.notifyAt);
|
||||
}
|
||||
|
||||
private calculateNotificationTime(task: TaskInfo, reminder: Reminder): number | null {
|
||||
try {
|
||||
if (reminder.type === 'absolute') {
|
||||
// Absolute reminder - parse the timestamp directly
|
||||
if (!reminder.absoluteTime) {
|
||||
return null;
|
||||
}
|
||||
return parseDateToLocal(reminder.absoluteTime).getTime();
|
||||
} else if (reminder.type === 'relative') {
|
||||
// Relative reminder - calculate based on anchor date
|
||||
if (!reminder.relatedTo || !reminder.offset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchorDateStr = reminder.relatedTo === 'due' ? task.due : task.scheduled;
|
||||
if (!anchorDateStr) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the anchor date
|
||||
const anchorDate = parseDateToLocal(anchorDateStr);
|
||||
|
||||
// Parse the ISO 8601 duration and apply offset
|
||||
const offsetMs = this.parseISO8601Duration(reminder.offset);
|
||||
if (offsetMs === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return anchorDate.getTime() + offsetMs;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error calculating notification time:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseISO8601Duration(duration: string): number | null {
|
||||
// Parse ISO 8601 duration format (e.g., "-PT15M", "P2D", "-PT1H30M")
|
||||
const match = duration.match(/^(-?)P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, sign, years, months, weeks, days, hours, minutes, seconds] = match;
|
||||
|
||||
let totalMs = 0;
|
||||
|
||||
// Note: For simplicity, we treat months as 30 days and years as 365 days
|
||||
if (years) totalMs += parseInt(years) * 365 * 24 * 60 * 60 * 1000;
|
||||
if (months) totalMs += parseInt(months) * 30 * 24 * 60 * 60 * 1000;
|
||||
if (weeks) totalMs += parseInt(weeks) * 7 * 24 * 60 * 60 * 1000;
|
||||
if (days) totalMs += parseInt(days) * 24 * 60 * 60 * 1000;
|
||||
if (hours) totalMs += parseInt(hours) * 60 * 60 * 1000;
|
||||
if (minutes) totalMs += parseInt(minutes) * 60 * 1000;
|
||||
if (seconds) totalMs += parseInt(seconds) * 1000;
|
||||
|
||||
// Apply sign for negative durations (before the anchor date)
|
||||
return sign === '-' ? -totalMs : totalMs;
|
||||
}
|
||||
|
||||
private checkNotificationQueue(): void {
|
||||
const now = Date.now();
|
||||
const toRemove: number[] = [];
|
||||
|
||||
for (let i = 0; i < this.notificationQueue.length; i++) {
|
||||
const item = this.notificationQueue[i];
|
||||
|
||||
if (item.notifyAt <= now) {
|
||||
// Trigger the notification
|
||||
this.triggerNotification(item);
|
||||
toRemove.push(i);
|
||||
|
||||
// Mark as processed to avoid duplicates
|
||||
const reminderId = `${item.taskPath}-${item.reminder.id}`;
|
||||
this.processedReminders.add(reminderId);
|
||||
} else {
|
||||
// Queue is sorted, so we can break early
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove triggered items from queue
|
||||
for (let i = toRemove.length - 1; i >= 0; i--) {
|
||||
this.notificationQueue.splice(toRemove[i], 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async triggerNotification(item: NotificationQueueItem): Promise<void> {
|
||||
// Get the task info for the notification
|
||||
const file = this.plugin.app.vault.getAbstractFileByPath(item.taskPath) as TFile;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = this.plugin.app.metadataCache.getFileCache(file);
|
||||
if (!metadata || !metadata.frontmatter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const task = this.plugin.fieldMapper.mapFromFrontmatter(
|
||||
metadata.frontmatter,
|
||||
item.taskPath,
|
||||
this.plugin.settings.storeTitleInFilename
|
||||
) as TaskInfo;
|
||||
|
||||
// Generate notification message
|
||||
const message = item.reminder.description || this.generateDefaultMessage(task, item.reminder);
|
||||
|
||||
if (this.plugin.settings.notificationType === 'system') {
|
||||
// System notification
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
const notification = new Notification('TaskNotes Reminder', {
|
||||
body: message,
|
||||
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v6l4-4M12 8v8"/><circle cx="12" cy="20" r="2"/></svg>',
|
||||
tag: `tasknotes-${item.taskPath}-${item.reminder.id}`
|
||||
});
|
||||
|
||||
// Open task note when notification is clicked
|
||||
notification.onclick = () => {
|
||||
this.plugin.app.workspace.openLinkText(item.taskPath, '', false);
|
||||
notification.close();
|
||||
};
|
||||
} else {
|
||||
// Fallback to in-app notice if system notifications aren't available
|
||||
this.showInAppNotice(message, item.taskPath);
|
||||
}
|
||||
} else {
|
||||
// In-app notification
|
||||
this.showInAppNotice(message, item.taskPath);
|
||||
}
|
||||
}
|
||||
|
||||
private showInAppNotice(message: string, taskPath: string): void {
|
||||
const notice = new Notice(message, 0); // 0 = persistent until clicked
|
||||
|
||||
// Add click handler to open the task
|
||||
(notice as any).noticeEl.addEventListener('click', () => {
|
||||
this.plugin.app.workspace.openLinkText(taskPath, '', false);
|
||||
notice.hide();
|
||||
});
|
||||
|
||||
// Add styling to make it clickable
|
||||
(notice as any).noticeEl.style.cursor = 'pointer';
|
||||
}
|
||||
|
||||
private generateDefaultMessage(task: TaskInfo, reminder: Reminder): string {
|
||||
if (reminder.type === 'absolute') {
|
||||
return `Reminder: ${task.title}`;
|
||||
} else {
|
||||
const anchor = reminder.relatedTo === 'due' ? 'due' : 'scheduled';
|
||||
const offset = this.formatDurationForDisplay(reminder.offset || '');
|
||||
|
||||
if (offset.startsWith('-')) {
|
||||
return `${task.title} is ${anchor} in ${offset.substring(1)}`;
|
||||
} else if (offset === 'PT0S' || offset === 'PT0M') {
|
||||
return `${task.title} is ${anchor} now`;
|
||||
} else {
|
||||
return `${task.title} was ${anchor} ${offset} ago`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private formatDurationForDisplay(duration: string): string {
|
||||
const ms = this.parseISO8601Duration(duration);
|
||||
if (ms === null) return duration;
|
||||
|
||||
const absMs = Math.abs(ms);
|
||||
const minutes = Math.floor(absMs / (60 * 1000));
|
||||
const hours = Math.floor(absMs / (60 * 60 * 1000));
|
||||
const days = Math.floor(absMs / (24 * 60 * 60 * 1000));
|
||||
|
||||
let result = '';
|
||||
if (days > 0) {
|
||||
result = `${days} day${days > 1 ? 's' : ''}`;
|
||||
} else if (hours > 0) {
|
||||
result = `${hours} hour${hours > 1 ? 's' : ''}`;
|
||||
} else if (minutes > 0) {
|
||||
result = `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
} else {
|
||||
result = 'now';
|
||||
}
|
||||
|
||||
return ms < 0 ? `-${result}` : result;
|
||||
}
|
||||
|
||||
// Public method to manually refresh reminders (useful for testing)
|
||||
async refreshReminders(): Promise<void> {
|
||||
await this.scanTasksAndBuildQueue();
|
||||
}
|
||||
|
||||
// Public method to clear processed reminders (useful when task is edited)
|
||||
clearProcessedRemindersForTask(taskPath: string): void {
|
||||
const keysToRemove: string[] = [];
|
||||
for (const key of this.processedReminders) {
|
||||
if (key.startsWith(`${taskPath}-`)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => this.processedReminders.delete(key));
|
||||
}
|
||||
}
|
||||
|
|
@ -102,6 +102,7 @@ export class TaskService {
|
|||
dateCreated: dateCreated,
|
||||
dateModified: dateModified,
|
||||
recurrence: taskData.recurrence || undefined,
|
||||
reminders: taskData.reminders && taskData.reminders.length > 0 ? taskData.reminders : undefined,
|
||||
icsEventId: taskData.icsEventId || undefined
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ export interface TaskNotesSettings {
|
|||
icsIntegration: ICSIntegrationSettings;
|
||||
// Saved filter views
|
||||
savedViews: SavedView[];
|
||||
// Notification settings
|
||||
enableNotifications: boolean;
|
||||
notificationType: 'in-app' | 'system';
|
||||
}
|
||||
|
||||
export interface TaskCreationDefaults {
|
||||
|
|
@ -144,7 +147,8 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = {
|
|||
completeInstances: 'complete_instances',
|
||||
pomodoros: 'pomodoros',
|
||||
icsEventId: 'icsEventId',
|
||||
icsEventTag: 'ics_event'
|
||||
icsEventTag: 'ics_event',
|
||||
reminders: 'reminders'
|
||||
};
|
||||
|
||||
// Default status configuration matches current hardcoded behavior
|
||||
|
|
@ -323,7 +327,10 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = {
|
|||
defaultNoteFolder: ''
|
||||
},
|
||||
// Saved filter views defaults
|
||||
savedViews: []
|
||||
savedViews: [],
|
||||
// Notification defaults
|
||||
enableNotifications: true,
|
||||
notificationType: 'system'
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -356,6 +363,7 @@ export class TaskNotesSettingTab extends PluginSettingTab {
|
|||
{ id: 'statuses', name: 'Statuses' },
|
||||
{ id: 'priorities', name: 'Priorities' },
|
||||
{ id: 'pomodoro', name: 'Pomodoro' },
|
||||
{ id: 'notifications', name: 'Notifications' },
|
||||
{ id: 'misc', name: 'Misc' }
|
||||
];
|
||||
|
||||
|
|
@ -449,6 +457,9 @@ export class TaskNotesSettingTab extends PluginSettingTab {
|
|||
case 'pomodoro':
|
||||
this.renderPomodoroTab();
|
||||
break;
|
||||
case 'notifications':
|
||||
this.renderNotificationsTab();
|
||||
break;
|
||||
case 'misc':
|
||||
this.renderMiscTab();
|
||||
break;
|
||||
|
|
@ -1558,6 +1569,47 @@ export class TaskNotesSettingTab extends PluginSettingTab {
|
|||
}));
|
||||
}
|
||||
|
||||
private renderNotificationsTab(): void {
|
||||
const container = this.tabContents['notifications'];
|
||||
|
||||
new Setting(container).setName('Notifications').setHeading();
|
||||
|
||||
container.createEl('p', {
|
||||
text: 'Configure task reminder notifications.',
|
||||
cls: 'settings-help-note'
|
||||
});
|
||||
|
||||
new Setting(container)
|
||||
.setName('Enable reminders')
|
||||
.setDesc('Enable the task reminder system. When disabled, no reminder notifications will be shown.')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.enableNotifications)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.enableNotifications = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(container)
|
||||
.setName('Notification type')
|
||||
.setDesc('Choose how reminder notifications are displayed.')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('system', 'System notifications')
|
||||
.addOption('in-app', 'In-app notices')
|
||||
.setValue(this.plugin.settings.notificationType)
|
||||
.onChange(async (value: 'system' | 'in-app') => {
|
||||
this.plugin.settings.notificationType = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Additional info about system notifications
|
||||
const systemNotesEl = container.createDiv({ cls: 'setting-item-description' });
|
||||
systemNotesEl.innerHTML = `
|
||||
<strong>System notifications:</strong> Use your operating system's native notification system.
|
||||
Requires permission and works even when Obsidian is minimized.<br>
|
||||
<strong>In-app notices:</strong> Show notifications as temporary popups within Obsidian only.
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMiscTab(): void {
|
||||
const container = this.tabContents['misc'];
|
||||
|
||||
|
|
|
|||
18
src/types.ts
18
src/types.ts
|
|
@ -228,6 +228,7 @@ export interface TaskInfo {
|
|||
dateCreated?: string; // Creation date (ISO timestamp)
|
||||
dateModified?: string; // Last modification date (ISO timestamp)
|
||||
icsEventId?: string[]; // Links to ICS calendar event IDs
|
||||
reminders?: Reminder[]; // Task reminders
|
||||
}
|
||||
|
||||
export interface TaskCreationData extends Partial<TaskInfo> {
|
||||
|
|
@ -243,6 +244,22 @@ export interface TimeEntry {
|
|||
duration?: number; // Duration in minutes (calculated or manually set)
|
||||
}
|
||||
|
||||
// Reminder types
|
||||
export interface Reminder {
|
||||
id: string; // A unique ID for UI keying, e.g., 'rem_1678886400000'
|
||||
type: 'absolute' | 'relative';
|
||||
|
||||
// For relative reminders
|
||||
relatedTo?: 'due' | 'scheduled'; // The anchor date property
|
||||
offset?: string; // ISO 8601 duration format, e.g., "-PT5M", "-PT1H", "-P2D"
|
||||
|
||||
// For absolute reminders
|
||||
absoluteTime?: string; // Full ISO 8601 timestamp, e.g., "2025-10-26T09:00:00"
|
||||
|
||||
// Common properties
|
||||
description?: string; // The notification message (optional, can be auto-generated)
|
||||
}
|
||||
|
||||
// Timeblocking types
|
||||
export interface TimeBlock {
|
||||
id: string; // Unique identifier for the timeblock
|
||||
|
|
@ -385,6 +402,7 @@ export interface FieldMapping {
|
|||
pomodoros: string; // For daily note pomodoro tracking
|
||||
icsEventId: string; // For linking to ICS calendar events (stored as array in frontmatter)
|
||||
icsEventTag: string; // Tag used for ICS event-related content
|
||||
reminders: string; // For task reminders
|
||||
}
|
||||
|
||||
export interface StatusConfig {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { DateContextMenu } from '../components/DateContextMenu';
|
||||
import { PriorityContextMenu } from '../components/PriorityContextMenu';
|
||||
import { RecurrenceContextMenu } from '../components/RecurrenceContextMenu';
|
||||
import { ReminderModal } from '../modals/ReminderModal';
|
||||
|
||||
export interface TaskCardOptions {
|
||||
showDueDate: boolean;
|
||||
|
|
@ -283,6 +284,42 @@ export function createTaskCard(task: TaskInfo, plugin: TaskNotesPlugin, options:
|
|||
});
|
||||
}
|
||||
|
||||
// Reminder indicator (if task has reminders)
|
||||
if (task.reminders && task.reminders.length > 0) {
|
||||
const reminderIndicator = mainRow.createEl('div', {
|
||||
cls: 'task-card__reminder-indicator',
|
||||
attr: {
|
||||
'aria-label': `${task.reminders.length} reminder${task.reminders.length > 1 ? 's' : ''} set (click to manage)`
|
||||
}
|
||||
});
|
||||
|
||||
const count = task.reminders.length;
|
||||
const tooltip = count === 1 ? '1 reminder set (click to manage)' : `${count} reminders set (click to manage)`;
|
||||
setTooltip(reminderIndicator, tooltip, { placement: 'top' });
|
||||
|
||||
// Use Obsidian's built-in bell icon for reminders
|
||||
setIcon(reminderIndicator, 'bell');
|
||||
|
||||
// Add click handler to open reminder modal
|
||||
reminderIndicator.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Don't trigger card click
|
||||
const modal = new ReminderModal(
|
||||
plugin.app,
|
||||
plugin,
|
||||
task,
|
||||
async (reminders) => {
|
||||
try {
|
||||
await plugin.updateTaskProperty(task, 'reminders', reminders.length > 0 ? reminders : undefined);
|
||||
} catch (error) {
|
||||
console.error('Error updating reminders:', error);
|
||||
new Notice('Failed to update reminders');
|
||||
}
|
||||
}
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
|
||||
// Project indicator (if task is used as a project)
|
||||
// Create placeholder that will be updated asynchronously
|
||||
const projectIndicatorPlaceholder = mainRow.createEl('div', {
|
||||
|
|
@ -613,6 +650,7 @@ export async function showTaskContextMenu(event: MouseEvent, taskPath: string, p
|
|||
}
|
||||
|
||||
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
|
||||
|
|
@ -722,6 +760,28 @@ export async function showTaskContextMenu(event: MouseEvent, taskPath: string, p
|
|||
});
|
||||
});
|
||||
|
||||
// Manage Reminders
|
||||
menu.addItem((item) => {
|
||||
item.setTitle('Manage reminders...');
|
||||
item.setIcon('bell');
|
||||
item.onClick(() => {
|
||||
const modal = new ReminderModal(
|
||||
plugin.app,
|
||||
plugin,
|
||||
task,
|
||||
async (reminders) => {
|
||||
try {
|
||||
await plugin.updateTaskProperty(task, 'reminders', reminders.length > 0 ? reminders : undefined);
|
||||
} catch (error) {
|
||||
console.error('Error updating reminders:', error);
|
||||
new Notice('Failed to update reminders');
|
||||
}
|
||||
}
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
});
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
// Time Tracking - determine current state from fresh task data
|
||||
|
|
@ -928,6 +988,7 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas
|
|||
cls: 'task-card__recurring-indicator',
|
||||
attr: { 'aria-label': `Recurring: ${getRecurrenceDisplayText(task.recurrence)}` }
|
||||
});
|
||||
setIcon(recurringIndicator, 'rotate-ccw');
|
||||
statusDot?.insertAdjacentElement('afterend', recurringIndicator);
|
||||
} else if (!task.recurrence && existingRecurringIndicator) {
|
||||
// Remove recurring indicator if task is no longer recurring
|
||||
|
|
@ -937,6 +998,56 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas
|
|||
const frequencyDisplay = getRecurrenceDisplayText(task.recurrence);
|
||||
existingRecurringIndicator.setAttribute('aria-label', `Recurring: ${frequencyDisplay}`);
|
||||
}
|
||||
|
||||
// Update reminder indicator
|
||||
const existingReminderIndicator = element.querySelector('.task-card__reminder-indicator');
|
||||
if (task.reminders && task.reminders.length > 0 && !existingReminderIndicator) {
|
||||
// Add reminder indicator if task has reminders but didn't have one
|
||||
const reminderIndicator = mainRow.createEl('div', {
|
||||
cls: 'task-card__reminder-indicator',
|
||||
attr: {
|
||||
'aria-label': `${task.reminders.length} reminder${task.reminders.length > 1 ? 's' : ''} set (click to manage)`
|
||||
}
|
||||
});
|
||||
|
||||
const count = task.reminders.length;
|
||||
const tooltip = count === 1 ? '1 reminder set (click to manage)' : `${count} reminders set (click to manage)`;
|
||||
setTooltip(reminderIndicator, tooltip, { placement: 'top' });
|
||||
|
||||
setIcon(reminderIndicator, 'bell');
|
||||
|
||||
// Add click handler to open reminder modal
|
||||
reminderIndicator.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Don't trigger card click
|
||||
const modal = new ReminderModal(
|
||||
plugin.app,
|
||||
plugin,
|
||||
task,
|
||||
async (reminders) => {
|
||||
try {
|
||||
await plugin.updateTaskProperty(task, 'reminders', reminders.length > 0 ? reminders : undefined);
|
||||
} catch (error) {
|
||||
console.error('Error updating reminders:', error);
|
||||
new Notice('Failed to update reminders');
|
||||
}
|
||||
}
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
|
||||
// Insert after the recurring indicator or status dot
|
||||
const insertAfter = existingRecurringIndicator || statusDot;
|
||||
insertAfter?.insertAdjacentElement('afterend', reminderIndicator);
|
||||
} else if ((!task.reminders || task.reminders.length === 0) && existingReminderIndicator) {
|
||||
// Remove reminder indicator if task no longer has reminders
|
||||
existingReminderIndicator.remove();
|
||||
} else if (task.reminders && task.reminders.length > 0 && existingReminderIndicator) {
|
||||
// Update existing reminder indicator
|
||||
const count = task.reminders.length;
|
||||
const tooltip = count === 1 ? '1 reminder set (click to manage)' : `${count} reminders set (click to manage)`;
|
||||
existingReminderIndicator.setAttribute('aria-label', `${count} reminder${count > 1 ? 's' : ''} set (click to manage)`);
|
||||
setTooltip(existingReminderIndicator as HTMLElement, tooltip, { placement: 'top' });
|
||||
}
|
||||
|
||||
// Update project indicator
|
||||
const existingProjectIndicator = element.querySelector('.task-card__project-indicator');
|
||||
|
|
|
|||
|
|
@ -989,7 +989,8 @@ export class MinimalNativeCache extends Events {
|
|||
timeEstimate: mappedTask.timeEstimate,
|
||||
timeEntries: mappedTask.timeEntries,
|
||||
dateCreated: mappedTask.dateCreated,
|
||||
dateModified: mappedTask.dateModified
|
||||
dateModified: mappedTask.dateModified,
|
||||
reminders: mappedTask.reminders
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error extracting task info from native metadata for ${path}:`, error);
|
||||
|
|
|
|||
|
|
@ -247,7 +247,8 @@ export function extractTaskInfo(
|
|||
timeEstimate: mappedTask.timeEstimate,
|
||||
timeEntries: mappedTask.timeEntries,
|
||||
dateCreated: mappedTask.dateCreated,
|
||||
dateModified: mappedTask.dateModified
|
||||
dateModified: mappedTask.dateModified,
|
||||
reminders: mappedTask.reminders
|
||||
};
|
||||
|
||||
return taskInfo;
|
||||
|
|
@ -273,7 +274,8 @@ export function extractTaskInfo(
|
|||
timeEstimate: mappedTask.timeEstimate,
|
||||
timeEntries: mappedTask.timeEntries,
|
||||
dateCreated: mappedTask.dateCreated,
|
||||
dateModified: mappedTask.dateModified
|
||||
dateModified: mappedTask.dateModified,
|
||||
reminders: mappedTask.reminders
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -285,7 +287,8 @@ export function extractTaskInfo(
|
|||
status: 'open',
|
||||
priority: 'normal',
|
||||
path,
|
||||
archived: false
|
||||
archived: false,
|
||||
reminders: []
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
165
styles/reminder-modal.css
Normal file
165
styles/reminder-modal.css
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/* Reminder Modal Styles */
|
||||
.tasknotes-reminder-modal {
|
||||
--reminder-spacing: 12px;
|
||||
--reminder-border-radius: 6px;
|
||||
--reminder-border-color: var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.tasknotes-reminder-modal .reminder-section {
|
||||
margin-bottom: calc(var(--reminder-spacing) * 2);
|
||||
}
|
||||
|
||||
.tasknotes-reminder-modal .reminder-section h3 {
|
||||
margin: 0 0 var(--reminder-spacing) 0;
|
||||
color: var(--text-normal);
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
/* Existing Reminders List */
|
||||
.reminder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--reminder-spacing) / 2);
|
||||
}
|
||||
|
||||
.no-reminders {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: var(--reminder-spacing);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Reminder Item BEM */
|
||||
.reminder-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--reminder-spacing);
|
||||
padding: var(--reminder-spacing);
|
||||
border: 1px solid var(--reminder-border-color);
|
||||
border-radius: var(--reminder-border-radius);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.reminder-item__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--reminder-spacing) / 2);
|
||||
}
|
||||
|
||||
.reminder-item__timing {
|
||||
font-weight: 500;
|
||||
color: var(--text-normal);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reminder-item__description {
|
||||
color: var(--text-accent);
|
||||
font-size: 0.9em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reminder-item__details {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reminder-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reminder-item__remove-btn {
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: calc(var(--reminder-border-radius) / 2);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.reminder-item__remove-btn:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.reminder-item__remove-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.reminder-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--reminder-spacing);
|
||||
}
|
||||
|
||||
.relative-fields,
|
||||
.absolute-fields {
|
||||
padding: var(--reminder-spacing);
|
||||
border: 1px solid var(--reminder-border-color);
|
||||
border-radius: var(--reminder-border-radius);
|
||||
background: var(--background-primary-alt);
|
||||
margin: calc(var(--reminder-spacing) / 2) 0;
|
||||
}
|
||||
|
||||
.reminder-add-btn {
|
||||
align-self: flex-start;
|
||||
padding: 8px 16px;
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--reminder-border-radius);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.reminder-add-btn:hover {
|
||||
background: var(--interactive-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.reminder-add-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Modal Action Buttons */
|
||||
.modal-button-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--reminder-spacing);
|
||||
margin-top: calc(var(--reminder-spacing) * 2);
|
||||
padding-top: var(--reminder-spacing);
|
||||
border-top: 1px solid var(--reminder-border-color);
|
||||
}
|
||||
|
||||
.modal-button-container button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--reminder-border-color);
|
||||
border-radius: var(--reminder-border-radius);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-button-container button:first-child {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.modal-button-container button:first-child:hover {
|
||||
background: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.modal-button-container button:last-child {
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.modal-button-container button:last-child:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
|
@ -212,7 +212,6 @@
|
|||
|
||||
.tasknotes-plugin .task-card__status-dot:hover {
|
||||
border-color: var(--tn-text-muted);
|
||||
box-shadow: 0 0 8px rgba(100, 149, 237, 0.5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
|
|
@ -238,14 +237,16 @@
|
|||
transition: all 0.2s ease;
|
||||
border-radius: 3px;
|
||||
padding: 2px;
|
||||
margin: -2px;
|
||||
border-radius: var(--radius-s);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
transition: all var(--tn-transition-fast);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-card__recurring-indicator:hover {
|
||||
opacity: 1;
|
||||
color: var(--interactive-accent);
|
||||
background: var(--background-modifier-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-card__recurring-indicator svg {
|
||||
|
|
@ -253,10 +254,42 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
/* Reminder indicator - positioned to not overlap with other indicators */
|
||||
.tasknotes-plugin .task-card__reminder-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
width: 16px;
|
||||
right: 44px;
|
||||
height: 16px;
|
||||
color: var(--tn-text-muted);
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
transition: all var(--tn-transition-fast);
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-s);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
transition: all var(--tn-transition-fast);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-card__reminder-indicator:hover {
|
||||
opacity: 1;
|
||||
color: var(--interactive-accent);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-card__reminder-indicator svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-card__project-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 44px; /* Position after recurring indicator */
|
||||
right: 62px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--tn-text-muted);
|
||||
|
|
@ -275,7 +308,6 @@
|
|||
opacity: 1;
|
||||
color: var(--interactive-accent);
|
||||
background: var(--background-modifier-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-card__project-indicator svg {
|
||||
|
|
@ -286,11 +318,11 @@
|
|||
.tasknotes-plugin .task-card__chevron {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 62px; /* Position after project indicator */
|
||||
right: 80px; /* Position after project indicator */
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--tn-text-muted);
|
||||
opacity: 0.8;
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -305,7 +337,10 @@
|
|||
opacity: 1;
|
||||
color: var(--interactive-accent);
|
||||
background: var(--background-modifier-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-card:hover .task-card__chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-card__chevron svg {
|
||||
|
|
@ -728,33 +763,6 @@
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
/* DEPRECATED - Use .task-card__context-menu instead */
|
||||
.tasknotes-plugin .task-card .task-context-icon {
|
||||
position: absolute;
|
||||
top: var(--tn-spacing-md);
|
||||
right: var(--tn-spacing-md);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--tn-text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all var(--tn-transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
border-radius: var(--tn-radius-xs);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-card .task-context-icon:hover {
|
||||
color: var(--tn-text-normal);
|
||||
background: var(--tn-interactive-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.tasknotes-plugin .task-card:hover .task-context-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
TASKCARD DRAG AND DROP STATES
|
||||
|
|
|
|||
Loading…
Reference in a new issue