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:
Callum Alpass 2025-08-07 13:01:04 +10:00
parent 30f609bb50
commit 24eeb4a92b
17 changed files with 1458 additions and 41 deletions

View file

@ -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

View 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();
}
}

View file

@ -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
View 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();
}
}

View file

@ -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

View file

@ -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 || []);

View file

@ -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 {

View file

@ -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] : [];

View 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));
}
}

View file

@ -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
};

View file

@ -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'];

View file

@ -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 {

View file

@ -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');

View file

@ -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);

View file

@ -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
View 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);
}

View file

@ -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