feat: Add task scheduling and reminders system

Add comprehensive scheduling functionality with reminder notifications:

- Add scheduledDate, scheduledTime, and reminderMinutes fields to ImmerseTask
- Add enableReminders and defaultReminderMinutes to plugin settings
- Implement reminder notification system with 30-second background checks
- Add overdue task detection with visual indicators
- Add native HTML5 date/time pickers in Quick Add and Edit modals
- Add reminder dropdown with 5/10/15/30/60 minute options
- Display scheduled date/time with blue badge (📅) in task list
- Show pulsing red "OVERDUE" badge (⚠️) for past-due tasks
- Add red left border highlight for overdue tasks
- Implement startup check for due/overdue tasks when plugin loads
- Show overdue notices when Obsidian opens if tasks are past due
- Play alert sounds for reminders (if sounds enabled)
- Track shown reminders to prevent duplicate notifications
This commit is contained in:
2025-11-23 21:12:21 +01:00
parent 683c4ddafe
commit 2fad5d88ab
7 changed files with 385 additions and 8 deletions

2
.gitignore vendored
View File

@@ -21,4 +21,6 @@ data.json
# Development/Documentation (not for distribution)
RELEASE-GUIDE.md
ROADMAP.md
deploy-test.bat
.claude/

140
main.js
View File

@@ -46,7 +46,10 @@ var DEFAULT_SETTINGS = {
autoStartBreak: true,
tickSoundEnabled: false,
// Daily note logging
logToDaily: false
logToDaily: false,
// Task reminders
enableReminders: true,
defaultReminderMinutes: 15
};
var DEFAULT_DATA = {
tasks: [],
@@ -90,8 +93,12 @@ var QuickAddTaskModal = class extends import_obsidian.Modal {
super(app);
this.taskText = "";
this.selectedList = "work";
this.scheduledDate = "";
this.scheduledTime = "";
this.reminderMinutes = 0;
this.plugin = plugin;
this.estimatedMinutes = plugin.settings.defaultEstimateMinutes;
this.reminderMinutes = plugin.settings.defaultReminderMinutes;
if (plugin.settings.lists.length > 0) {
this.selectedList = plugin.settings.lists[0].id;
}
@@ -137,6 +144,26 @@ var QuickAddTaskModal = class extends import_obsidian.Modal {
dropdown.setValue(this.selectedList);
dropdown.onChange((value) => this.selectedList = value);
});
new import_obsidian.Setting(contentEl).setName("\u{1F4C5} Scheduled Date").setDesc("Optional: When do you plan to work on this?").addText((text) => {
text.setPlaceholder("YYYY-MM-DD").setValue(this.scheduledDate).onChange((value) => this.scheduledDate = value);
text.inputEl.type = "date";
});
new import_obsidian.Setting(contentEl).setName("\u23F0 Scheduled Time").setDesc("Optional: What time?").addText((text) => {
text.setPlaceholder("HH:mm").setValue(this.scheduledTime).onChange((value) => this.scheduledTime = value);
text.inputEl.type = "time";
});
if (this.plugin.settings.enableReminders) {
new import_obsidian.Setting(contentEl).setName("\u{1F514} Reminder").setDesc("Remind me before the scheduled time").addDropdown((dropdown) => {
dropdown.addOption("0", "No reminder");
dropdown.addOption("5", "5 minutes before");
dropdown.addOption("10", "10 minutes before");
dropdown.addOption("15", "15 minutes before");
dropdown.addOption("30", "30 minutes before");
dropdown.addOption("60", "1 hour before");
dropdown.setValue(this.reminderMinutes.toString());
dropdown.onChange((value) => this.reminderMinutes = parseInt(value));
});
}
const buttonContainer = contentEl.createEl("div", { cls: "immerse-modal-buttons" });
const cancelBtn = buttonContainer.createEl("button", { text: "Cancel", cls: "immerse-btn" });
cancelBtn.addEventListener("click", () => this.close());
@@ -146,6 +173,15 @@ var QuickAddTaskModal = class extends import_obsidian.Modal {
submitTask() {
if (this.taskText.trim()) {
const task = this.plugin.createTask(this.taskText, this.estimatedMinutes, this.selectedList);
if (this.scheduledDate) {
task.scheduledDate = this.scheduledDate;
}
if (this.scheduledTime) {
task.scheduledTime = this.scheduledTime;
}
if (this.reminderMinutes > 0 && this.scheduledDate && this.scheduledTime) {
task.reminderMinutes = this.reminderMinutes;
}
this.plugin.addTask(task);
new import_obsidian.Notice("\u2705 Task added!");
this.close();
@@ -201,6 +237,29 @@ var EditTaskModal = class extends import_obsidian.Modal {
textarea.setValue(this.task.notes).onChange((value) => this.task.notes = value);
textarea.inputEl.rows = 4;
});
new import_obsidian.Setting(contentEl).setName("\u{1F4C5} Scheduled Date").setDesc("Optional: When do you plan to work on this?").addText((text) => {
text.setPlaceholder("YYYY-MM-DD").setValue(this.task.scheduledDate || "").onChange((value) => this.task.scheduledDate = value || void 0);
text.inputEl.type = "date";
});
new import_obsidian.Setting(contentEl).setName("\u23F0 Scheduled Time").setDesc("Optional: What time?").addText((text) => {
text.setPlaceholder("HH:mm").setValue(this.task.scheduledTime || "").onChange((value) => this.task.scheduledTime = value || void 0);
text.inputEl.type = "time";
});
if (this.plugin.settings.enableReminders) {
new import_obsidian.Setting(contentEl).setName("\u{1F514} Reminder").setDesc("Remind me before the scheduled time").addDropdown((dropdown) => {
dropdown.addOption("0", "No reminder");
dropdown.addOption("5", "5 minutes before");
dropdown.addOption("10", "10 minutes before");
dropdown.addOption("15", "15 minutes before");
dropdown.addOption("30", "30 minutes before");
dropdown.addOption("60", "1 hour before");
dropdown.setValue((this.task.reminderMinutes || 0).toString());
dropdown.onChange((value) => {
const minutes = parseInt(value);
this.task.reminderMinutes = minutes > 0 ? minutes : void 0;
});
});
}
if (this.task.actualMinutes > 0) {
new import_obsidian.Setting(contentEl).setName("Time Tracked").setDesc(`You've worked on this task for ${this.plugin.formatTimeHuman(this.task.actualMinutes)}`);
}
@@ -459,8 +518,9 @@ var ImmerseView = class extends import_obsidian2.ItemView {
}
renderTaskItem(container, task) {
const list = this.plugin.settings.lists.find((l) => l.id === task.list);
const isOverdue = !task.completed && task.scheduledDate && task.scheduledTime && new Date(`${task.scheduledDate}T${task.scheduledTime}`).getTime() < Date.now();
const taskEl = container.createEl("div", {
cls: `immerse-task-item ${task.completed ? "completed" : ""} ${task.isActive ? "active" : ""}`
cls: `immerse-task-item ${task.completed ? "completed" : ""} ${task.isActive ? "active" : ""} ${isOverdue ? "overdue" : ""}`
});
const checkbox = taskEl.createEl("div", { cls: "immerse-checkbox" });
checkbox.innerHTML = task.completed ? "\u2713" : "";
@@ -495,6 +555,21 @@ var ImmerseView = class extends import_obsidian2.ItemView {
actualSpan.addClass("immerse-overtime-text");
}
}
if (task.scheduledDate) {
const scheduleSpan = taskMeta.createEl("span", {
cls: `immerse-schedule-badge ${isOverdue ? "overdue" : ""}`
});
const dateStr = task.scheduledDate;
const timeStr = task.scheduledTime || "";
if (isOverdue) {
scheduleSpan.setText(`\u26A0\uFE0F OVERDUE: ${dateStr}${timeStr ? " " + timeStr : ""}`);
} else {
scheduleSpan.setText(`\u{1F4C5} ${dateStr}${timeStr ? " " + timeStr : ""}`);
}
if (task.reminderMinutes) {
scheduleSpan.title = `Reminder set for ${task.reminderMinutes} min before`;
}
}
const actions = taskEl.createEl("div", { cls: "immerse-task-actions" });
if (!task.completed) {
const startBtn = actions.createEl("button", { cls: "immerse-task-btn", attr: { "aria-label": "Start Pomodoro" } });
@@ -544,7 +619,11 @@ var ImmersePlugin = class extends import_obsidian3.Plugin {
this.secondsWorkedOnCurrentTask = 0;
// Status bar element
this.statusBarEl = null;
// Reminder system
this.reminderCheckInterval = null;
this.notifiedReminders = /* @__PURE__ */ new Set();
}
// Track which reminders have been shown
async onload() {
await this.loadAllData();
this.checkDailyReset();
@@ -587,9 +666,13 @@ var ImmersePlugin = class extends import_obsidian3.Plugin {
});
this.addSettingTab(new ImmerseSettingTab(this.app, this));
this.createStatusBar();
if (this.settings.enableReminders) {
this.startReminderSystem();
}
}
onunload() {
this.stopTimer();
this.stopReminderSystem();
}
async loadAllData() {
const loaded = await this.loadData();
@@ -935,6 +1018,59 @@ var ImmersePlugin = class extends import_obsidian3.Plugin {
this.statusBarEl.removeClass("immerse-status-active");
}
}
// ============ Reminder System ============
startReminderSystem() {
this.reminderCheckInterval = window.setInterval(() => {
this.checkReminders();
}, 3e4);
this.checkReminders();
}
stopReminderSystem() {
if (this.reminderCheckInterval) {
window.clearInterval(this.reminderCheckInterval);
this.reminderCheckInterval = null;
}
}
checkReminders() {
if (!this.settings.enableReminders)
return;
const now = new Date();
const currentTime = now.getTime();
this.data.tasks.filter((task) => !task.completed && task.scheduledDate && task.scheduledTime).forEach((task) => {
const reminderKey = `${task.id}-${task.scheduledDate}-${task.scheduledTime}`;
if (this.notifiedReminders.has(reminderKey))
return;
const scheduledDateTime = new Date(`${task.scheduledDate}T${task.scheduledTime}`);
const scheduledTime = scheduledDateTime.getTime();
if (currentTime > scheduledTime) {
this.showOverdueNotice(task);
this.notifiedReminders.add(reminderKey);
return;
}
if (task.reminderMinutes) {
const reminderTime = scheduledTime - task.reminderMinutes * 60 * 1e3;
if (currentTime >= reminderTime) {
this.showReminder(task);
this.notifiedReminders.add(reminderKey);
}
}
});
}
showReminder(task) {
const timeStr = task.scheduledTime;
new import_obsidian3.Notice(`\u{1F514} Reminder: "${task.text}" is scheduled for ${timeStr}`, 8e3);
if (this.settings.enableSounds) {
this.playAlertSound();
}
}
showOverdueNotice(task) {
const dateStr = task.scheduledDate;
const timeStr = task.scheduledTime;
new import_obsidian3.Notice(`\u26A0\uFE0F Overdue: "${task.text}" was scheduled for ${dateStr} ${timeStr}`, 1e4);
if (this.settings.enableSounds) {
this.playAlertSound();
}
}
// ============ Sounds & Celebrations ============
showCelebration(task) {
let messages = CELEBRATION_MESSAGES;

View File

@@ -44,10 +44,14 @@ export default class ImmersePlugin extends Plugin {
// Focus time tracking (in seconds for accuracy)
private focusSecondsToday: number = 0;
private secondsWorkedOnCurrentTask: number = 0;
// Status bar element
statusBarEl: HTMLElement | null = null;
// Reminder system
private reminderCheckInterval: number | null = null;
private notifiedReminders: Set<string> = new Set(); // Track which reminders have been shown
async onload() {
await this.loadAllData();
@@ -108,10 +112,16 @@ export default class ImmersePlugin extends Plugin {
// Create status bar timer
this.createStatusBar();
// Start reminder checking system
if (this.settings.enableReminders) {
this.startReminderSystem();
}
}
onunload() {
this.stopTimer();
this.stopReminderSystem();
}
async loadAllData() {
@@ -604,6 +614,84 @@ export default class ImmersePlugin extends Plugin {
}
}
// ============ Reminder System ============
startReminderSystem() {
// Check for reminders every 30 seconds
this.reminderCheckInterval = window.setInterval(() => {
this.checkReminders();
}, 30000);
// Also check immediately
this.checkReminders();
}
stopReminderSystem() {
if (this.reminderCheckInterval) {
window.clearInterval(this.reminderCheckInterval);
this.reminderCheckInterval = null;
}
}
checkReminders() {
if (!this.settings.enableReminders) return;
const now = new Date();
const currentTime = now.getTime();
// Check each incomplete task with scheduling
this.data.tasks
.filter(task => !task.completed && task.scheduledDate && task.scheduledTime)
.forEach(task => {
const reminderKey = `${task.id}-${task.scheduledDate}-${task.scheduledTime}`;
// Skip if we already notified for this reminder
if (this.notifiedReminders.has(reminderKey)) return;
// Parse the scheduled date and time
const scheduledDateTime = new Date(`${task.scheduledDate}T${task.scheduledTime}`);
const scheduledTime = scheduledDateTime.getTime();
// Check for overdue tasks (scheduled time has passed)
if (currentTime > scheduledTime) {
this.showOverdueNotice(task);
this.notifiedReminders.add(reminderKey);
return;
}
// Check for tasks with reminders set
if (task.reminderMinutes) {
const reminderTime = scheduledTime - (task.reminderMinutes * 60 * 1000);
// Show reminder if it's past the reminder time but before the scheduled time
if (currentTime >= reminderTime) {
this.showReminder(task);
this.notifiedReminders.add(reminderKey);
}
}
});
}
showReminder(task: ImmerseTask) {
const timeStr = task.scheduledTime;
new Notice(`🔔 Reminder: "${task.text}" is scheduled for ${timeStr}`, 8000);
// Play alert sound if enabled
if (this.settings.enableSounds) {
this.playAlertSound();
}
}
showOverdueNotice(task: ImmerseTask) {
const dateStr = task.scheduledDate;
const timeStr = task.scheduledTime;
new Notice(`⚠️ Overdue: "${task.text}" was scheduled for ${dateStr} ${timeStr}`, 10000);
// Play alert sound if enabled
if (this.settings.enableSounds) {
this.playAlertSound();
}
}
// ============ Sounds & Celebrations ============
showCelebration(task: ImmerseTask) {

View File

@@ -15,11 +15,15 @@ export class QuickAddTaskModal extends Modal {
taskText: string = '';
estimatedMinutes: number;
selectedList: string = 'work';
scheduledDate: string = '';
scheduledTime: string = '';
reminderMinutes: number = 0;
constructor(app: App, plugin: ImmersePlugin) {
super(app);
this.plugin = plugin;
this.estimatedMinutes = plugin.settings.defaultEstimateMinutes;
this.reminderMinutes = plugin.settings.defaultReminderMinutes;
if (plugin.settings.lists.length > 0) {
this.selectedList = plugin.settings.lists[0].id;
}
@@ -82,6 +86,45 @@ export class QuickAddTaskModal extends Modal {
dropdown.onChange(value => this.selectedList = value);
});
// Scheduled date
new Setting(contentEl)
.setName('📅 Scheduled Date')
.setDesc('Optional: When do you plan to work on this?')
.addText(text => {
text.setPlaceholder('YYYY-MM-DD')
.setValue(this.scheduledDate)
.onChange(value => this.scheduledDate = value);
text.inputEl.type = 'date';
});
// Scheduled time
new Setting(contentEl)
.setName('⏰ Scheduled Time')
.setDesc('Optional: What time?')
.addText(text => {
text.setPlaceholder('HH:mm')
.setValue(this.scheduledTime)
.onChange(value => this.scheduledTime = value);
text.inputEl.type = 'time';
});
// Reminder
if (this.plugin.settings.enableReminders) {
new Setting(contentEl)
.setName('🔔 Reminder')
.setDesc('Remind me before the scheduled time')
.addDropdown(dropdown => {
dropdown.addOption('0', 'No reminder');
dropdown.addOption('5', '5 minutes before');
dropdown.addOption('10', '10 minutes before');
dropdown.addOption('15', '15 minutes before');
dropdown.addOption('30', '30 minutes before');
dropdown.addOption('60', '1 hour before');
dropdown.setValue(this.reminderMinutes.toString());
dropdown.onChange(value => this.reminderMinutes = parseInt(value));
});
}
// Buttons
const buttonContainer = contentEl.createEl('div', { cls: 'immerse-modal-buttons' });
@@ -95,6 +138,16 @@ export class QuickAddTaskModal extends Modal {
submitTask() {
if (this.taskText.trim()) {
const task = this.plugin.createTask(this.taskText, this.estimatedMinutes, this.selectedList);
// Add scheduling data if provided
if (this.scheduledDate) {
task.scheduledDate = this.scheduledDate;
}
if (this.scheduledTime) {
task.scheduledTime = this.scheduledTime;
}
if (this.reminderMinutes > 0 && this.scheduledDate && this.scheduledTime) {
task.reminderMinutes = this.reminderMinutes;
}
this.plugin.addTask(task);
new Notice('✅ Task added!');
this.close();
@@ -177,6 +230,48 @@ export class EditTaskModal extends Modal {
textarea.inputEl.rows = 4;
});
// Scheduled date
new Setting(contentEl)
.setName('📅 Scheduled Date')
.setDesc('Optional: When do you plan to work on this?')
.addText(text => {
text.setPlaceholder('YYYY-MM-DD')
.setValue(this.task.scheduledDate || '')
.onChange(value => this.task.scheduledDate = value || undefined);
text.inputEl.type = 'date';
});
// Scheduled time
new Setting(contentEl)
.setName('⏰ Scheduled Time')
.setDesc('Optional: What time?')
.addText(text => {
text.setPlaceholder('HH:mm')
.setValue(this.task.scheduledTime || '')
.onChange(value => this.task.scheduledTime = value || undefined);
text.inputEl.type = 'time';
});
// Reminder
if (this.plugin.settings.enableReminders) {
new Setting(contentEl)
.setName('🔔 Reminder')
.setDesc('Remind me before the scheduled time')
.addDropdown(dropdown => {
dropdown.addOption('0', 'No reminder');
dropdown.addOption('5', '5 minutes before');
dropdown.addOption('10', '10 minutes before');
dropdown.addOption('15', '15 minutes before');
dropdown.addOption('30', '30 minutes before');
dropdown.addOption('60', '1 hour before');
dropdown.setValue((this.task.reminderMinutes || 0).toString());
dropdown.onChange(value => {
const minutes = parseInt(value);
this.task.reminderMinutes = minutes > 0 ? minutes : undefined;
});
});
}
// Show actual time if task has been worked on
if (this.task.actualMinutes > 0) {
new Setting(contentEl)

View File

@@ -10,7 +10,9 @@ export interface ImmerseTask {
completedAt?: number;
list: string;
notes: string;
scheduledDate?: string;
scheduledDate?: string; // Date in YYYY-MM-DD format
scheduledTime?: string; // Time in HH:mm format (24-hour)
reminderMinutes?: number; // Minutes before scheduled time to remind (0 = no reminder)
isActive: boolean;
}
@@ -34,6 +36,9 @@ export interface ImmerseSettings {
tickSoundEnabled: boolean;
// Daily note logging
logToDaily: boolean;
// Task reminders
enableReminders: boolean;
defaultReminderMinutes: number; // Default minutes before task to remind
}
export interface ImmerseData {
@@ -62,6 +67,9 @@ export const DEFAULT_SETTINGS: ImmerseSettings = {
tickSoundEnabled: false,
// Daily note logging
logToDaily: false,
// Task reminders
enableReminders: true,
defaultReminderMinutes: 15,
};
export const DEFAULT_DATA: ImmerseData = {

View File

@@ -326,9 +326,13 @@ export class ImmerseView extends ItemView {
renderTaskItem(container: Element, task: ImmerseTask) {
const list = this.plugin.settings.lists.find(l => l.id === task.list);
const taskEl = container.createEl('div', {
cls: `immerse-task-item ${task.completed ? 'completed' : ''} ${task.isActive ? 'active' : ''}`
// Check if task is overdue
const isOverdue = !task.completed && task.scheduledDate && task.scheduledTime &&
new Date(`${task.scheduledDate}T${task.scheduledTime}`).getTime() < Date.now();
const taskEl = container.createEl('div', {
cls: `immerse-task-item ${task.completed ? 'completed' : ''} ${task.isActive ? 'active' : ''} ${isOverdue ? 'overdue' : ''}`
});
// Checkbox
@@ -363,7 +367,7 @@ export class ImmerseView extends ItemView {
const taskMeta = content.createEl('div', { cls: 'immerse-task-meta' });
taskMeta.createEl('span', { text: `Est: ${this.plugin.formatTimeHuman(task.estimatedMinutes)}` });
if (task.actualMinutes > 0) {
const actualSpan = taskMeta.createEl('span');
actualSpan.setText(`Actual: ${this.plugin.formatTimeHuman(task.actualMinutes)}`);
@@ -372,6 +376,23 @@ export class ImmerseView extends ItemView {
}
}
// Show scheduled date/time if set
if (task.scheduledDate) {
const scheduleSpan = taskMeta.createEl('span', {
cls: `immerse-schedule-badge ${isOverdue ? 'overdue' : ''}`
});
const dateStr = task.scheduledDate;
const timeStr = task.scheduledTime || '';
if (isOverdue) {
scheduleSpan.setText(`⚠️ OVERDUE: ${dateStr}${timeStr ? ' ' + timeStr : ''}`);
} else {
scheduleSpan.setText(`📅 ${dateStr}${timeStr ? ' ' + timeStr : ''}`);
}
if (task.reminderMinutes) {
scheduleSpan.title = `Reminder set for ${task.reminderMinutes} min before`;
}
}
// Actions
const actions = taskEl.createEl('div', { cls: 'immerse-task-actions' });

View File

@@ -464,6 +464,33 @@
color: var(--ft-warning);
}
.immerse-schedule-badge {
padding: 2px 8px;
background: var(--ft-primary);
color: white;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.immerse-schedule-badge.overdue {
background: var(--ft-danger);
animation: pulse-overdue 2s ease-in-out infinite;
}
@keyframes pulse-overdue {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.immerse-task-item.overdue {
border-left: 3px solid var(--ft-danger);
}
/* Task Actions */
.immerse-task-actions {
display: flex;