1104 lines
32 KiB
TypeScript
1104 lines
32 KiB
TypeScript
import {
|
|
App,
|
|
Notice,
|
|
Plugin,
|
|
PluginSettingTab,
|
|
Setting,
|
|
TFile,
|
|
WorkspaceLeaf,
|
|
} from 'obsidian';
|
|
|
|
import {
|
|
FocusTask,
|
|
FocusTaskSettings,
|
|
FocusTaskData,
|
|
DEFAULT_SETTINGS,
|
|
DEFAULT_DATA,
|
|
VIEW_TYPE_FOCUS_TASK,
|
|
CELEBRATION_MESSAGES,
|
|
EARLY_FINISH_MESSAGES,
|
|
OVERTIME_MESSAGES,
|
|
} from './types';
|
|
|
|
import { FocusTaskView } from './view';
|
|
import { QuickAddTaskModal } from './modals';
|
|
|
|
// ============ Main Plugin Class ============
|
|
|
|
export default class FocusTaskPlugin extends Plugin {
|
|
settings: FocusTaskSettings;
|
|
data: FocusTaskData;
|
|
|
|
// Timer state
|
|
timerInterval: number | null = null;
|
|
currentTimerSeconds: number = 0;
|
|
isTimerRunning: boolean = false;
|
|
isBreakMode: boolean = false;
|
|
activeTaskId: string | null = null;
|
|
pomodoroCount: number = 0;
|
|
|
|
// Timestamp-based tracking for reliable background timing
|
|
private timerStartTimestamp: number = 0;
|
|
private pausedTimeRemaining: number = 0;
|
|
|
|
// Focus time tracking (in seconds for accuracy)
|
|
private focusSecondsToday: number = 0;
|
|
private secondsWorkedOnCurrentTask: number = 0;
|
|
|
|
// Status bar element
|
|
statusBarEl: HTMLElement | null = null;
|
|
|
|
async onload() {
|
|
await this.loadAllData();
|
|
|
|
// Check and reset daily stats
|
|
this.checkDailyReset();
|
|
|
|
// Handle visibility changes to sync timer when app comes back to foreground
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden && this.isTimerRunning) {
|
|
this.syncTimerFromTimestamp();
|
|
}
|
|
});
|
|
|
|
// Register the main view
|
|
this.registerView(
|
|
VIEW_TYPE_FOCUS_TASK,
|
|
(leaf) => new FocusTaskView(leaf, this)
|
|
);
|
|
|
|
// Add ribbon icon
|
|
this.addRibbonIcon('zap', 'Open Focus Task', () => {
|
|
this.activateView();
|
|
});
|
|
|
|
// Add commands
|
|
this.addCommand({
|
|
id: 'open-focus-task',
|
|
name: 'Open Focus Task Panel',
|
|
callback: () => this.activateView(),
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'quick-add-task',
|
|
name: 'Quick Add Task',
|
|
callback: () => new QuickAddTaskModal(this.app, this).open(),
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'start-focus-mode',
|
|
name: 'Start Focus Mode on Next Task',
|
|
callback: () => this.startFocusOnNextTask(),
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'toggle-timer',
|
|
name: 'Toggle Timer (Play/Pause)',
|
|
callback: () => this.toggleTimer(),
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'complete-current-task',
|
|
name: 'Complete Current Task',
|
|
callback: () => this.completeActiveTask(),
|
|
});
|
|
|
|
// Add settings tab
|
|
this.addSettingTab(new FocusTaskSettingTab(this.app, this));
|
|
|
|
// Create status bar timer
|
|
this.createStatusBar();
|
|
}
|
|
|
|
onunload() {
|
|
this.stopTimer();
|
|
}
|
|
|
|
async loadAllData() {
|
|
const loaded = await this.loadData();
|
|
|
|
// Merge loaded data with defaults (defaults first, then override with loaded)
|
|
// This ensures new fields get default values even for existing installs
|
|
this.data = Object.assign({}, DEFAULT_DATA, loaded?.data || {});
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded?.settings || {});
|
|
|
|
// Ensure lists array exists and has at least the default lists
|
|
if (!this.settings.lists || this.settings.lists.length === 0) {
|
|
this.settings.lists = DEFAULT_SETTINGS.lists;
|
|
}
|
|
|
|
// Initialize seconds from stored minutes
|
|
this.focusSecondsToday = (this.data.totalFocusMinutesToday || 0) * 60;
|
|
}
|
|
|
|
async saveAllData() {
|
|
// Sync minutes from seconds before saving
|
|
this.data.totalFocusMinutesToday = Math.floor(this.focusSecondsToday / 60);
|
|
await this.saveData({
|
|
settings: this.settings,
|
|
data: this.data,
|
|
});
|
|
}
|
|
|
|
checkDailyReset() {
|
|
const today = new Date().toDateString();
|
|
if (this.data.lastActiveDate !== today) {
|
|
// Check streak
|
|
const yesterday = new Date();
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
if (this.data.lastActiveDate === yesterday.toDateString()) {
|
|
this.data.streak++;
|
|
} else if (this.data.lastActiveDate !== today) {
|
|
this.data.streak = 0;
|
|
}
|
|
|
|
// Reset daily stats
|
|
this.data.completedToday = 0;
|
|
this.data.totalFocusMinutesToday = 0;
|
|
this.focusSecondsToday = 0;
|
|
this.data.lastActiveDate = today;
|
|
this.saveAllData();
|
|
}
|
|
}
|
|
|
|
async activateView() {
|
|
const { workspace } = this.app;
|
|
|
|
let leaf: WorkspaceLeaf | null = null;
|
|
const leaves = workspace.getLeavesOfType(VIEW_TYPE_FOCUS_TASK);
|
|
|
|
if (leaves.length > 0) {
|
|
leaf = leaves[0];
|
|
} else {
|
|
leaf = workspace.getRightLeaf(false);
|
|
if (leaf) {
|
|
await leaf.setViewState({ type: VIEW_TYPE_FOCUS_TASK, active: true });
|
|
}
|
|
}
|
|
|
|
if (leaf) {
|
|
workspace.revealLeaf(leaf);
|
|
}
|
|
}
|
|
|
|
// ============ Task Management ============
|
|
|
|
createTask(text: string, estimatedMinutes: number = this.settings.defaultEstimateMinutes, list: string = 'work'): FocusTask {
|
|
return {
|
|
id: this.generateId(),
|
|
text,
|
|
completed: false,
|
|
estimatedMinutes,
|
|
actualMinutes: 0,
|
|
createdAt: Date.now(),
|
|
list,
|
|
notes: '',
|
|
isActive: false,
|
|
};
|
|
}
|
|
|
|
generateId(): string {
|
|
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
|
}
|
|
|
|
addTask(task: FocusTask) {
|
|
this.data.tasks.push(task);
|
|
this.saveAllData();
|
|
this.refreshView();
|
|
}
|
|
|
|
updateTask(taskId: string, updates: Partial<FocusTask>) {
|
|
const task = this.data.tasks.find(t => t.id === taskId);
|
|
if (task) {
|
|
Object.assign(task, updates);
|
|
this.saveAllData();
|
|
this.refreshView();
|
|
}
|
|
}
|
|
|
|
deleteTask(taskId: string) {
|
|
this.data.tasks = this.data.tasks.filter(t => t.id !== taskId);
|
|
if (this.activeTaskId === taskId) {
|
|
this.stopTimer();
|
|
this.activeTaskId = null;
|
|
}
|
|
this.saveAllData();
|
|
this.refreshView();
|
|
}
|
|
|
|
completeTask(taskId: string) {
|
|
const task = this.data.tasks.find(t => t.id === taskId);
|
|
if (task && !task.completed) {
|
|
task.completed = true;
|
|
task.completedAt = Date.now();
|
|
task.isActive = false;
|
|
|
|
this.data.completedToday++;
|
|
this.data.lastActiveDate = new Date().toDateString();
|
|
|
|
// Show celebration
|
|
if (this.settings.enableCelebrations) {
|
|
this.showCelebration(task);
|
|
}
|
|
|
|
// Play sound
|
|
if (this.settings.enableSounds) {
|
|
this.playCompletionSound();
|
|
}
|
|
|
|
// Log to daily note
|
|
if (this.settings.logToDaily) {
|
|
this.logTaskToDailyNote(task);
|
|
}
|
|
|
|
if (this.activeTaskId === taskId) {
|
|
this.stopTimer();
|
|
this.activeTaskId = null;
|
|
}
|
|
|
|
this.saveAllData();
|
|
this.refreshView();
|
|
}
|
|
}
|
|
|
|
completeActiveTask() {
|
|
if (this.activeTaskId) {
|
|
this.completeTask(this.activeTaskId);
|
|
} else {
|
|
new Notice('No active task to complete');
|
|
}
|
|
}
|
|
|
|
// ============ Timer Management ============
|
|
|
|
// Sync timer based on timestamp when app returns from background
|
|
syncTimerFromTimestamp() {
|
|
// Since all intervals now calculate from timestamps directly,
|
|
// we just need to trigger an update when coming back to foreground
|
|
if (!this.isTimerRunning) return;
|
|
|
|
// The interval will automatically calculate correct values on next tick
|
|
// Just update the display immediately to show current state
|
|
this.updateStatusBar();
|
|
this.updateTimerDisplay();
|
|
}
|
|
|
|
startTimer(taskId: string) {
|
|
const task = this.data.tasks.find(t => t.id === taskId);
|
|
if (!task) return;
|
|
|
|
// Stop any existing timer
|
|
this.stopTimer();
|
|
|
|
// Set active task
|
|
this.activeTaskId = taskId;
|
|
task.isActive = true;
|
|
this.isBreakMode = false;
|
|
this.currentTimerSeconds = 0;
|
|
this.isTimerRunning = true;
|
|
this.secondsWorkedOnCurrentTask = task.actualMinutes * 60;
|
|
|
|
// Set timestamp for background tracking (stopwatch mode)
|
|
this.timerStartTimestamp = Date.now();
|
|
this.pausedTimeRemaining = 0; // 0 indicates stopwatch mode
|
|
|
|
// Store initial values
|
|
const initialSecondsWorked = this.secondsWorkedOnCurrentTask;
|
|
let alertShown = false;
|
|
|
|
// Full refresh to show the active task card
|
|
this.refreshView();
|
|
this.updateStatusBar();
|
|
|
|
// Start interval (count up mode - stopwatch)
|
|
this.timerInterval = window.setInterval(() => {
|
|
// Calculate elapsed time from timestamp
|
|
const now = Date.now();
|
|
const elapsedMs = now - this.timerStartTimestamp;
|
|
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
|
|
|
// Update timer (count up)
|
|
this.currentTimerSeconds = elapsedSeconds;
|
|
|
|
// Update actual time worked
|
|
this.secondsWorkedOnCurrentTask = initialSecondsWorked + elapsedSeconds;
|
|
task.actualMinutes = Math.floor(this.secondsWorkedOnCurrentTask / 60);
|
|
|
|
// Update focus time
|
|
const newFocusSeconds = Math.floor((this.data.totalFocusMinutesToday || 0) * 60) + elapsedSeconds;
|
|
this.focusSecondsToday = newFocusSeconds;
|
|
|
|
// Light update - only timer display, no full refresh
|
|
this.updateStatusBar();
|
|
this.updateTimerDisplay();
|
|
|
|
// Check if over estimate (only alert once)
|
|
if (!alertShown && this.currentTimerSeconds >= task.estimatedMinutes * 60) {
|
|
alertShown = true;
|
|
if (this.settings.enableSounds) {
|
|
this.playAlertSound();
|
|
}
|
|
new Notice(`⏰ Time's up for: ${task.text}`);
|
|
}
|
|
}, 1000);
|
|
|
|
this.saveAllData();
|
|
}
|
|
|
|
startPomodoro(taskId: string) {
|
|
const task = this.data.tasks.find(t => t.id === taskId);
|
|
if (!task) return;
|
|
|
|
this.stopTimer();
|
|
this.activeTaskId = taskId;
|
|
task.isActive = true;
|
|
this.isBreakMode = false;
|
|
this.currentTimerSeconds = this.settings.pomodoroWorkMinutes * 60;
|
|
this.isTimerRunning = true;
|
|
|
|
// Initialize from existing actual time to preserve progress across breaks
|
|
// Store as seconds for precision
|
|
this.secondsWorkedOnCurrentTask = Math.floor(task.actualMinutes * 60);
|
|
|
|
// Set timestamp for background tracking (pomodoro countdown mode)
|
|
this.timerStartTimestamp = Date.now();
|
|
this.pausedTimeRemaining = this.currentTimerSeconds;
|
|
|
|
// Store the initial seconds worked to calculate delta
|
|
const initialSecondsWorked = this.secondsWorkedOnCurrentTask;
|
|
|
|
// Full refresh to show the active task card
|
|
this.refreshView();
|
|
this.updateStatusBar();
|
|
|
|
this.timerInterval = window.setInterval(() => {
|
|
// Calculate elapsed time from timestamp (more accurate than counting ticks)
|
|
const now = Date.now();
|
|
const elapsedMs = now - this.timerStartTimestamp;
|
|
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
|
|
|
// Update countdown timer
|
|
this.currentTimerSeconds = Math.max(0, this.pausedTimeRemaining - elapsedSeconds);
|
|
|
|
if (!this.isBreakMode) {
|
|
// Update actual time worked based on real elapsed time
|
|
this.secondsWorkedOnCurrentTask = initialSecondsWorked + elapsedSeconds;
|
|
const actualMinutes = Math.floor(this.secondsWorkedOnCurrentTask / 60);
|
|
|
|
if (task.actualMinutes !== actualMinutes) {
|
|
task.actualMinutes = actualMinutes;
|
|
}
|
|
|
|
// Update focus time based on elapsed seconds
|
|
const newFocusSeconds = Math.floor((this.data.totalFocusMinutesToday || 0) * 60) + elapsedSeconds;
|
|
this.focusSecondsToday = newFocusSeconds;
|
|
}
|
|
|
|
// Light update - only timer display, no full refresh
|
|
this.updateStatusBar();
|
|
this.updateTimerDisplay();
|
|
|
|
if (this.currentTimerSeconds <= 0) {
|
|
this.handlePomodoroEnd();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
handlePomodoroEnd() {
|
|
// Stop the timer interval to prevent going into negative
|
|
if (this.timerInterval) {
|
|
window.clearInterval(this.timerInterval);
|
|
this.timerInterval = null;
|
|
}
|
|
|
|
// Set timer to 0 to ensure it doesn't show negative
|
|
this.currentTimerSeconds = 0;
|
|
this.isTimerRunning = false;
|
|
|
|
// Update displays immediately
|
|
this.updateStatusBar();
|
|
this.updateTimerDisplay();
|
|
|
|
if (!this.isBreakMode) {
|
|
// Work session ended
|
|
this.pomodoroCount++;
|
|
this.data.pomodorosCompleted++;
|
|
|
|
if (this.settings.enableSounds) {
|
|
this.playAlertSound();
|
|
}
|
|
|
|
new Notice('🍅 Pomodoro complete! Time for a break.');
|
|
|
|
if (this.settings.autoStartBreak) {
|
|
this.startBreak();
|
|
} else {
|
|
this.refreshView();
|
|
}
|
|
} else {
|
|
// Break ended - keep timer at 0 until user resumes
|
|
if (this.settings.enableSounds) {
|
|
this.playAlertSound();
|
|
}
|
|
new Notice('⚡ Break over! Ready to focus?');
|
|
|
|
// Keep the break card visible with timer at 0:00
|
|
this.refreshView();
|
|
}
|
|
|
|
this.saveAllData();
|
|
}
|
|
|
|
startBreak() {
|
|
this.isBreakMode = true;
|
|
this.isTimerRunning = true;
|
|
const isLongBreak = this.pomodoroCount % this.settings.longBreakInterval === 0;
|
|
this.currentTimerSeconds = (isLongBreak ? this.settings.longBreakMinutes : this.settings.pomodoroBreakMinutes) * 60;
|
|
|
|
// Set timestamp for background tracking (break countdown mode)
|
|
this.timerStartTimestamp = Date.now();
|
|
this.pausedTimeRemaining = this.currentTimerSeconds;
|
|
|
|
new Notice(isLongBreak ? '☕ Long break time!' : '☕ Short break time!');
|
|
|
|
// Full refresh to show break state
|
|
this.refreshView();
|
|
|
|
if (!this.timerInterval) {
|
|
this.timerInterval = window.setInterval(() => {
|
|
// Calculate elapsed time from timestamp
|
|
const now = Date.now();
|
|
const elapsedMs = now - this.timerStartTimestamp;
|
|
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
|
|
|
// Update countdown timer
|
|
this.currentTimerSeconds = Math.max(0, this.pausedTimeRemaining - elapsedSeconds);
|
|
|
|
// Light update - only timer display
|
|
this.updateStatusBar();
|
|
this.updateTimerDisplay();
|
|
|
|
if (this.currentTimerSeconds <= 0) {
|
|
this.handlePomodoroEnd();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
this.updateStatusBar();
|
|
}
|
|
|
|
toggleTimer() {
|
|
if (this.isTimerRunning && this.timerInterval) {
|
|
// Pause - save current state
|
|
window.clearInterval(this.timerInterval);
|
|
this.timerInterval = null;
|
|
this.isTimerRunning = false;
|
|
this.pausedTimeRemaining = this.currentTimerSeconds;
|
|
} else if (this.activeTaskId) {
|
|
// Resume - restart with new timestamp
|
|
this.isTimerRunning = true;
|
|
this.timerStartTimestamp = Date.now();
|
|
const task = this.data.tasks.find(t => t.id === this.activeTaskId);
|
|
|
|
// Store initial values for resume
|
|
const initialSecondsWorked = this.secondsWorkedOnCurrentTask;
|
|
|
|
this.timerInterval = window.setInterval(() => {
|
|
// Calculate elapsed time from timestamp
|
|
const now = Date.now();
|
|
const elapsedMs = now - this.timerStartTimestamp;
|
|
const elapsedSeconds = Math.floor(elapsedMs / 1000);
|
|
|
|
// Update timer (countdown from paused position)
|
|
this.currentTimerSeconds = Math.max(0, this.pausedTimeRemaining - elapsedSeconds);
|
|
|
|
if (task && !this.isBreakMode) {
|
|
// Update actual time worked
|
|
this.secondsWorkedOnCurrentTask = initialSecondsWorked + elapsedSeconds;
|
|
task.actualMinutes = Math.floor(this.secondsWorkedOnCurrentTask / 60);
|
|
|
|
// Update focus time
|
|
const newFocusSeconds = Math.floor((this.data.totalFocusMinutesToday || 0) * 60) + elapsedSeconds;
|
|
this.focusSecondsToday = newFocusSeconds;
|
|
}
|
|
|
|
// Light update - only timer display
|
|
this.updateStatusBar();
|
|
this.updateTimerDisplay();
|
|
|
|
if (this.currentTimerSeconds <= 0) {
|
|
this.handlePomodoroEnd();
|
|
}
|
|
}, 1000);
|
|
} else {
|
|
new Notice('No active task. Select a task first.');
|
|
}
|
|
|
|
// Full refresh to update pause/resume button state
|
|
this.updateStatusBar();
|
|
this.refreshView();
|
|
}
|
|
|
|
stopTimer() {
|
|
if (this.timerInterval) {
|
|
window.clearInterval(this.timerInterval);
|
|
this.timerInterval = null;
|
|
}
|
|
|
|
if (this.activeTaskId) {
|
|
const task = this.data.tasks.find(t => t.id === this.activeTaskId);
|
|
if (task) {
|
|
task.isActive = false;
|
|
// Reset actual time when manually stopping (not after a break)
|
|
// This allows starting fresh next time
|
|
task.actualMinutes = 0;
|
|
}
|
|
}
|
|
|
|
this.isTimerRunning = false;
|
|
this.activeTaskId = null;
|
|
this.secondsWorkedOnCurrentTask = 0;
|
|
this.timerStartTimestamp = 0;
|
|
this.pausedTimeRemaining = 0;
|
|
this.updateStatusBar();
|
|
this.saveAllData();
|
|
this.refreshView();
|
|
}
|
|
|
|
startFocusOnNextTask() {
|
|
const pendingTasks = this.data.tasks.filter(t => !t.completed);
|
|
if (pendingTasks.length > 0) {
|
|
this.startPomodoro(pendingTasks[0].id);
|
|
} else {
|
|
new Notice('No pending tasks. Add a task first!');
|
|
}
|
|
}
|
|
|
|
// ============ Status Bar Timer ============
|
|
|
|
createStatusBar() {
|
|
this.statusBarEl = this.addStatusBarItem();
|
|
this.statusBarEl.addClass('focus-task-status-bar');
|
|
this.updateStatusBar();
|
|
|
|
// Click to open panel
|
|
this.statusBarEl.addEventListener('click', () => {
|
|
this.activateView();
|
|
});
|
|
}
|
|
|
|
updateStatusBar() {
|
|
if (!this.statusBarEl) return;
|
|
|
|
if (this.activeTaskId) {
|
|
const task = this.data.tasks.find(t => t.id === this.activeTaskId);
|
|
const taskName = this.isBreakMode ? '☕ Break' : (task?.text.substring(0, 20) || 'Task');
|
|
const timeStr = this.formatTime(this.currentTimerSeconds);
|
|
const icon = this.isTimerRunning ? '▶' : '⏸';
|
|
|
|
this.statusBarEl.setText(`⚡ ${icon} ${timeStr} - ${taskName}${task && task.text.length > 20 ? '...' : ''}`);
|
|
this.statusBarEl.addClass('focus-task-status-active');
|
|
} else {
|
|
this.statusBarEl.setText('⚡ Focus Task');
|
|
this.statusBarEl.removeClass('focus-task-status-active');
|
|
}
|
|
}
|
|
|
|
// ============ Sounds & Celebrations ============
|
|
|
|
showCelebration(task: FocusTask) {
|
|
let messages = CELEBRATION_MESSAGES;
|
|
let extraMessage = '';
|
|
|
|
if (task.actualMinutes < task.estimatedMinutes) {
|
|
const saved = task.estimatedMinutes - task.actualMinutes;
|
|
messages = EARLY_FINISH_MESSAGES;
|
|
extraMessage = ` (${saved} min early!)`;
|
|
} else if (task.actualMinutes > task.estimatedMinutes * 1.5) {
|
|
messages = OVERTIME_MESSAGES;
|
|
}
|
|
|
|
const celebration = messages[Math.floor(Math.random() * messages.length)];
|
|
new Notice(`${celebration.emoji} ${celebration.message}${extraMessage}`);
|
|
}
|
|
|
|
playCompletionSound() {
|
|
try {
|
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
|
oscillator.frequency.setValueAtTime(1000, audioContext.currentTime + 0.1);
|
|
oscillator.frequency.setValueAtTime(1200, audioContext.currentTime + 0.2);
|
|
|
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.3);
|
|
} catch (e) {
|
|
console.log('Audio not available');
|
|
}
|
|
}
|
|
|
|
playAlertSound() {
|
|
try {
|
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
oscillator.frequency.setValueAtTime(440, audioContext.currentTime);
|
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
oscillator.frequency.setValueAtTime(440, audioContext.currentTime + i * 0.3);
|
|
oscillator.frequency.setValueAtTime(550, audioContext.currentTime + i * 0.3 + 0.15);
|
|
}
|
|
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.9);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.9);
|
|
} catch (e) {
|
|
console.log('Audio not available');
|
|
}
|
|
}
|
|
|
|
// ============ Daily Note Logging ============
|
|
|
|
async logTaskToDailyNote(task: FocusTask) {
|
|
try {
|
|
const list = this.settings.lists.find(l => l.id === task.list);
|
|
|
|
// Format the task entry
|
|
const timeDiff = task.actualMinutes - task.estimatedMinutes;
|
|
let timeComparison = '';
|
|
if (timeDiff < 0) {
|
|
timeComparison = `${Math.abs(timeDiff)}min under estimate ✨`;
|
|
} else if (timeDiff > 0) {
|
|
timeComparison = `${timeDiff}min over estimate`;
|
|
} else {
|
|
timeComparison = `exactly on target 🎯`;
|
|
}
|
|
|
|
const completedTime = new Date(task.completedAt || Date.now()).toLocaleTimeString('en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
});
|
|
|
|
const taskEntry = `- [x] ${task.text} | ${list?.icon || '📋'} ${list?.name || 'Task'} | ⏱️ ${this.formatTimeHuman(task.actualMinutes)} / ${this.formatTimeHuman(task.estimatedMinutes)} (${timeComparison}) | ✅ ${completedTime}`;
|
|
|
|
await this.appendToDailyNote(taskEntry);
|
|
} catch (e) {
|
|
console.error('Failed to log task to daily note:', e);
|
|
new Notice('Failed to log task to daily note. Make sure Daily Notes core plugin is enabled.');
|
|
}
|
|
}
|
|
|
|
getDailyNoteSettings(): { folder: string; format: string; template: string } | null {
|
|
// Access the core Daily Notes plugin settings
|
|
const dailyNotesPlugin = (this.app as any).internalPlugins?.plugins?.['daily-notes'];
|
|
|
|
if (!dailyNotesPlugin?.enabled) {
|
|
return null;
|
|
}
|
|
|
|
const settings = dailyNotesPlugin.instance?.options;
|
|
return {
|
|
folder: settings?.folder || '',
|
|
format: settings?.format || 'YYYY-MM-DD',
|
|
template: settings?.template || '',
|
|
};
|
|
}
|
|
|
|
formatDailyNoteDate(format: string): string {
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = now.getMonth() + 1;
|
|
const day = now.getDate();
|
|
|
|
// Use placeholders to avoid replacement conflicts
|
|
// Replace longer tokens first, use unique placeholders
|
|
let result = format;
|
|
|
|
// Year tokens
|
|
result = result.replace(/YYYY/g, year.toString());
|
|
result = result.replace(/YY/g, year.toString().slice(-2));
|
|
|
|
// Month tokens (longer first)
|
|
result = result.replace(/MMMM/g, now.toLocaleDateString('en-US', { month: 'long' }));
|
|
result = result.replace(/MMM/g, now.toLocaleDateString('en-US', { month: 'short' }));
|
|
result = result.replace(/MM/g, month.toString().padStart(2, '0'));
|
|
// Only replace standalone M, not part of other tokens
|
|
result = result.replace(/(?<![A-Za-z])M(?![A-Za-z])/g, month.toString());
|
|
|
|
// Day tokens (longer first)
|
|
result = result.replace(/dddd/g, now.toLocaleDateString('en-US', { weekday: 'long' }));
|
|
result = result.replace(/ddd/g, now.toLocaleDateString('en-US', { weekday: 'short' }));
|
|
result = result.replace(/DD/g, day.toString().padStart(2, '0'));
|
|
// Only replace standalone D, not part of other tokens
|
|
result = result.replace(/(?<![A-Za-z])D(?![A-Za-z])/g, day.toString());
|
|
|
|
return result;
|
|
}
|
|
|
|
async getOrCreateDailyNote(): Promise<TFile | null> {
|
|
const { vault } = this.app;
|
|
const dailySettings = this.getDailyNoteSettings();
|
|
|
|
if (!dailySettings) {
|
|
new Notice('Daily Notes core plugin is not enabled. Please enable it in Settings → Core plugins.');
|
|
return null;
|
|
}
|
|
|
|
const filename = this.formatDailyNoteDate(dailySettings.format);
|
|
const folder = dailySettings.folder ? `${dailySettings.folder}/` : '';
|
|
const path = `${folder}${filename}.md`;
|
|
|
|
// Check if daily note exists
|
|
let file = vault.getAbstractFileByPath(path);
|
|
|
|
if (file && file instanceof TFile) {
|
|
return file;
|
|
}
|
|
|
|
// Create the daily note
|
|
try {
|
|
// Ensure folder exists
|
|
if (dailySettings.folder) {
|
|
const folderExists = vault.getAbstractFileByPath(dailySettings.folder);
|
|
if (!folderExists) {
|
|
await vault.createFolder(dailySettings.folder);
|
|
}
|
|
}
|
|
|
|
// Check if template exists and use it
|
|
let content = '';
|
|
if (dailySettings.template) {
|
|
const templatePath = dailySettings.template.endsWith('.md')
|
|
? dailySettings.template
|
|
: `${dailySettings.template}.md`;
|
|
const templateFile = vault.getAbstractFileByPath(templatePath);
|
|
|
|
if (templateFile && templateFile instanceof TFile) {
|
|
content = await vault.read(templateFile);
|
|
// Replace template variables
|
|
content = content
|
|
.replace(/{{date}}/g, filename)
|
|
.replace(/{{time}}/g, new Date().toLocaleTimeString())
|
|
.replace(/{{title}}/g, filename);
|
|
}
|
|
}
|
|
|
|
// Create the file
|
|
const newFile = await vault.create(path, content);
|
|
new Notice(`📝 Created daily note: ${filename}`);
|
|
return newFile;
|
|
} catch (e) {
|
|
console.error('Failed to create daily note:', e);
|
|
new Notice('Failed to create daily note');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async appendToDailyNote(content: string) {
|
|
const file = await this.getOrCreateDailyNote();
|
|
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
const { vault } = this.app;
|
|
|
|
// Read existing content and append to the end
|
|
const existingContent = await vault.read(file);
|
|
const newContent = existingContent.trimEnd() + '\n' + content + '\n';
|
|
|
|
await vault.modify(file, newContent);
|
|
new Notice('📝 Task logged to daily note');
|
|
}
|
|
|
|
// ============ Utilities ============
|
|
|
|
formatTime(seconds: number): string {
|
|
const absSeconds = Math.abs(seconds);
|
|
const mins = Math.floor(absSeconds / 60);
|
|
const secs = absSeconds % 60;
|
|
const sign = seconds < 0 ? '-' : '';
|
|
return `${sign}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
formatTimeHuman(minutes: number): string {
|
|
if (minutes < 60) return `${minutes}min`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const mins = minutes % 60;
|
|
return mins > 0 ? `${hours}hr ${mins}min` : `${hours}hr`;
|
|
}
|
|
|
|
refreshView() {
|
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_FOCUS_TASK);
|
|
leaves.forEach(leaf => {
|
|
if (leaf.view instanceof FocusTaskView) {
|
|
leaf.view.refresh();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Light refresh - only updates timer display without rebuilding DOM
|
|
updateTimerDisplay() {
|
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_FOCUS_TASK);
|
|
leaves.forEach(leaf => {
|
|
if (leaf.view instanceof FocusTaskView) {
|
|
leaf.view.updateTimerDisplay();
|
|
}
|
|
});
|
|
}
|
|
|
|
getTasksByList(listId: string): FocusTask[] {
|
|
return this.data.tasks.filter(t => t.list === listId);
|
|
}
|
|
|
|
getPendingTasks(): FocusTask[] {
|
|
return this.data.tasks.filter(t => !t.completed);
|
|
}
|
|
|
|
getTodaysTasks(): FocusTask[] {
|
|
const today = new Date().toDateString();
|
|
return this.data.tasks.filter(t => {
|
|
if (t.scheduledDate === today) return true;
|
|
if (!t.scheduledDate && !t.completed) return true;
|
|
return false;
|
|
});
|
|
}
|
|
|
|
getStats() {
|
|
const pending = this.getPendingTasks();
|
|
const totalEstimate = pending.reduce((sum, t) => sum + t.estimatedMinutes, 0);
|
|
const completedTasks = this.data.tasks.filter(t => t.completed);
|
|
const avgAccuracy = completedTasks.length > 0
|
|
? completedTasks.reduce((sum, t) => sum + (t.estimatedMinutes / Math.max(t.actualMinutes, 1)), 0) / completedTasks.length
|
|
: 1;
|
|
|
|
return {
|
|
pendingCount: pending.length,
|
|
completedToday: this.data.completedToday,
|
|
totalEstimatedMinutes: totalEstimate,
|
|
totalFocusMinutesToday: Math.floor(this.focusSecondsToday / 60),
|
|
streak: this.data.streak,
|
|
pomodorosCompleted: this.data.pomodorosCompleted,
|
|
avgAccuracy: Math.round(avgAccuracy * 100),
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============ Settings Tab ============
|
|
|
|
class FocusTaskSettingTab extends PluginSettingTab {
|
|
plugin: FocusTaskPlugin;
|
|
|
|
constructor(app: App, plugin: FocusTaskPlugin) {
|
|
super(app, plugin);
|
|
this.plugin = plugin;
|
|
}
|
|
|
|
display(): void {
|
|
const { containerEl } = this;
|
|
containerEl.empty();
|
|
|
|
containerEl.createEl('h1', { text: '⚡ Focus Task Settings' });
|
|
|
|
// Pomodoro Settings
|
|
containerEl.createEl('h2', { text: '🍅 Pomodoro Timer' });
|
|
|
|
new Setting(containerEl)
|
|
.setName('Work Duration')
|
|
.setDesc('Length of each work session in minutes')
|
|
.addSlider(slider => slider
|
|
.setLimits(5, 60, 5)
|
|
.setValue(this.plugin.settings.pomodoroWorkMinutes)
|
|
.setDynamicTooltip()
|
|
.onChange(async value => {
|
|
this.plugin.settings.pomodoroWorkMinutes = value;
|
|
await this.plugin.saveAllData();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Short Break Duration')
|
|
.setDesc('Length of short breaks in minutes')
|
|
.addSlider(slider => slider
|
|
.setLimits(1, 15, 1)
|
|
.setValue(this.plugin.settings.pomodoroBreakMinutes)
|
|
.setDynamicTooltip()
|
|
.onChange(async value => {
|
|
this.plugin.settings.pomodoroBreakMinutes = value;
|
|
await this.plugin.saveAllData();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Long Break Duration')
|
|
.setDesc('Length of long breaks in minutes')
|
|
.addSlider(slider => slider
|
|
.setLimits(5, 30, 5)
|
|
.setValue(this.plugin.settings.longBreakMinutes)
|
|
.setDynamicTooltip()
|
|
.onChange(async value => {
|
|
this.plugin.settings.longBreakMinutes = value;
|
|
await this.plugin.saveAllData();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Long Break Interval')
|
|
.setDesc('Number of pomodoros before a long break')
|
|
.addSlider(slider => slider
|
|
.setLimits(2, 6, 1)
|
|
.setValue(this.plugin.settings.longBreakInterval)
|
|
.setDynamicTooltip()
|
|
.onChange(async value => {
|
|
this.plugin.settings.longBreakInterval = value;
|
|
await this.plugin.saveAllData();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Auto-start Breaks')
|
|
.setDesc('Automatically start break timer after work session')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.autoStartBreak)
|
|
.onChange(async value => {
|
|
this.plugin.settings.autoStartBreak = value;
|
|
await this.plugin.saveAllData();
|
|
}));
|
|
|
|
// General Settings
|
|
containerEl.createEl('h2', { text: '⚙️ General' });
|
|
|
|
new Setting(containerEl)
|
|
.setName('Default Time Estimate')
|
|
.setDesc('Default estimated time for new tasks in minutes')
|
|
.addSlider(slider => slider
|
|
.setLimits(5, 120, 5)
|
|
.setValue(this.plugin.settings.defaultEstimateMinutes)
|
|
.setDynamicTooltip()
|
|
.onChange(async value => {
|
|
this.plugin.settings.defaultEstimateMinutes = value;
|
|
await this.plugin.saveAllData();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Enable Sounds')
|
|
.setDesc('Play sounds for timer completion and task completion')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.enableSounds)
|
|
.onChange(async value => {
|
|
this.plugin.settings.enableSounds = value;
|
|
await this.plugin.saveAllData();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName('Enable Celebrations')
|
|
.setDesc('Show celebration messages when completing tasks')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.enableCelebrations)
|
|
.onChange(async value => {
|
|
this.plugin.settings.enableCelebrations = value;
|
|
await this.plugin.saveAllData();
|
|
}));
|
|
|
|
// Daily Note Integration
|
|
containerEl.createEl('h2', { text: '📝 Daily Note Integration' });
|
|
|
|
new Setting(containerEl)
|
|
.setName('Log completed tasks to daily note')
|
|
.setDesc('When you complete a task, add an entry to your daily note. Uses the core Daily Notes plugin settings.')
|
|
.addToggle(toggle => toggle
|
|
.setValue(this.plugin.settings.logToDaily)
|
|
.onChange(async value => {
|
|
this.plugin.settings.logToDaily = value;
|
|
await this.plugin.saveAllData();
|
|
}));
|
|
|
|
// Show info about Daily Notes plugin
|
|
const infoEl = containerEl.createEl('div', { cls: 'setting-item-description' });
|
|
infoEl.style.marginTop = '-10px';
|
|
infoEl.style.marginBottom = '20px';
|
|
infoEl.innerHTML = `
|
|
<small>
|
|
This feature uses the <strong>Daily Notes</strong> core plugin.
|
|
Configure your daily note folder, date format, and template in
|
|
<em>Settings → Core plugins → Daily notes</em>.
|
|
</small>
|
|
`;
|
|
|
|
// Lists Management
|
|
containerEl.createEl('h2', { text: '📋 Lists' });
|
|
|
|
this.plugin.settings.lists.forEach((list, index) => {
|
|
new Setting(containerEl)
|
|
.setName(`${list.icon} ${list.name}`)
|
|
.addText(text => text
|
|
.setValue(list.name)
|
|
.setPlaceholder('List name')
|
|
.onChange(async value => {
|
|
this.plugin.settings.lists[index].name = value;
|
|
await this.plugin.saveAllData();
|
|
}))
|
|
.addText(text => text
|
|
.setValue(list.icon)
|
|
.setPlaceholder('Emoji')
|
|
.onChange(async value => {
|
|
this.plugin.settings.lists[index].icon = value;
|
|
await this.plugin.saveAllData();
|
|
}))
|
|
.addColorPicker(picker => picker
|
|
.setValue(list.color)
|
|
.onChange(async value => {
|
|
this.plugin.settings.lists[index].color = value;
|
|
await this.plugin.saveAllData();
|
|
}))
|
|
.addButton(btn => btn
|
|
.setIcon('trash')
|
|
.setTooltip('Delete list')
|
|
.onClick(async () => {
|
|
this.plugin.settings.lists.splice(index, 1);
|
|
await this.plugin.saveAllData();
|
|
this.display();
|
|
}));
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.addButton(btn => btn
|
|
.setButtonText('+ Add List')
|
|
.onClick(async () => {
|
|
this.plugin.settings.lists.push({
|
|
id: this.plugin.generateId(),
|
|
name: 'New List',
|
|
color: '#6366f1',
|
|
icon: '📁',
|
|
});
|
|
await this.plugin.saveAllData();
|
|
this.display();
|
|
}));
|
|
|
|
// About section
|
|
containerEl.createEl('h2', { text: '📖 About' });
|
|
|
|
const aboutDiv = containerEl.createDiv({ cls: 'focus-task-about' });
|
|
aboutDiv.innerHTML = `
|
|
<p><strong>Focus Task</strong> is heavily inspired by <a href="https://www.blitzit.app/">Blitzit</a>,
|
|
a fantastic productivity app that combines task management with focused time tracking.</p>
|
|
<p>This plugin brings similar functionality directly into Obsidian, allowing you to manage tasks,
|
|
use the Pomodoro technique, and track your productivity without leaving your notes.</p>
|
|
<p>
|
|
<a href="https://git.cribdev.com/crib/focus-task">Source Code</a>
|
|
</p>
|
|
`;
|
|
}
|
|
}
|