Initial commit

This commit is contained in:
2025-11-22 13:34:07 +01:00
commit a652a6ac04
14 changed files with 2595 additions and 0 deletions

843
src/main.ts Normal file
View File

@@ -0,0 +1,843 @@
import {
App,
Notice,
Plugin,
PluginSettingTab,
Setting,
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;
// Floating timer element
floatingTimerEl: HTMLElement | null = null;
async onload() {
await this.loadAllData();
// Check and reset daily stats
this.checkDailyReset();
// 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 floating timer if enabled
if (this.settings.showFloatingTimer) {
this.createFloatingTimer();
}
}
onunload() {
this.stopTimer();
this.removeFloatingTimer();
}
async loadAllData() {
const loaded = await this.loadData();
this.data = Object.assign({}, DEFAULT_DATA, loaded?.data || {});
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded?.settings || {});
}
async saveAllData() {
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.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();
}
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 ============
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;
// Start interval (count up mode - stopwatch)
this.timerInterval = window.setInterval(() => {
this.currentTimerSeconds++;
task.actualMinutes = Math.floor(this.currentTimerSeconds / 60);
// Update floating timer
this.updateFloatingTimer();
this.refreshView();
// Check if over estimate
if (this.currentTimerSeconds === task.estimatedMinutes * 60) {
if (this.settings.enableSounds) {
this.playAlertSound();
}
new Notice(`⏰ Time's up for: ${task.text}`);
}
}, 1000);
this.saveAllData();
this.refreshView();
this.updateFloatingTimer();
}
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;
this.timerInterval = window.setInterval(() => {
this.currentTimerSeconds--;
if (!this.isBreakMode) {
task.actualMinutes = Math.floor((this.settings.pomodoroWorkMinutes * 60 - this.currentTimerSeconds) / 60);
this.data.totalFocusMinutesToday = Math.floor(this.data.totalFocusMinutesToday + 1/60);
}
this.updateFloatingTimer();
this.refreshView();
if (this.currentTimerSeconds <= 0) {
this.handlePomodoroEnd();
}
}, 1000);
this.updateFloatingTimer();
this.refreshView();
}
handlePomodoroEnd() {
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.stopTimer();
}
} else {
// Break ended
if (this.settings.enableSounds) {
this.playAlertSound();
}
new Notice('⚡ Break over! Ready to focus?');
this.isBreakMode = false;
this.stopTimer();
}
this.saveAllData();
}
startBreak() {
this.isBreakMode = true;
const isLongBreak = this.pomodoroCount % this.settings.longBreakInterval === 0;
this.currentTimerSeconds = (isLongBreak ? this.settings.longBreakMinutes : this.settings.pomodoroBreakMinutes) * 60;
new Notice(isLongBreak ? '☕ Long break time!' : '☕ Short break time!');
if (!this.timerInterval) {
this.timerInterval = window.setInterval(() => {
this.currentTimerSeconds--;
this.updateFloatingTimer();
this.refreshView();
if (this.currentTimerSeconds <= 0) {
this.handlePomodoroEnd();
}
}, 1000);
}
this.updateFloatingTimer();
this.refreshView();
}
toggleTimer() {
if (this.isTimerRunning && this.timerInterval) {
// Pause
window.clearInterval(this.timerInterval);
this.timerInterval = null;
this.isTimerRunning = false;
} else if (this.activeTaskId) {
// Resume
this.isTimerRunning = true;
const task = this.data.tasks.find(t => t.id === this.activeTaskId);
this.timerInterval = window.setInterval(() => {
this.currentTimerSeconds--;
if (task && !this.isBreakMode) {
task.actualMinutes = Math.floor((this.settings.pomodoroWorkMinutes * 60 - this.currentTimerSeconds) / 60);
}
this.updateFloatingTimer();
this.refreshView();
if (this.currentTimerSeconds <= 0) {
this.handlePomodoroEnd();
}
}, 1000);
} else {
new Notice('No active task. Select a task first.');
}
this.updateFloatingTimer();
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;
}
}
this.isTimerRunning = false;
this.activeTaskId = null;
this.updateFloatingTimer();
this.saveAllData();
}
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!');
}
}
// ============ Floating Timer ============
createFloatingTimer() {
if (this.floatingTimerEl) return;
this.floatingTimerEl = document.body.createEl('div', {
cls: 'focus-task-floating-timer',
});
this.floatingTimerEl.innerHTML = `
<div class="focus-task-floating-inner">
<div class="focus-task-floating-task">No active task</div>
<div class="focus-task-floating-time">00:00</div>
<div class="focus-task-floating-controls">
<button class="focus-task-float-btn play-pause">▶</button>
<button class="focus-task-float-btn complete">✓</button>
</div>
</div>
`;
// Make draggable
this.makeDraggable(this.floatingTimerEl);
// Add event listeners
const playPauseBtn = this.floatingTimerEl.querySelector('.play-pause');
const completeBtn = this.floatingTimerEl.querySelector('.complete');
playPauseBtn?.addEventListener('click', () => this.toggleTimer());
completeBtn?.addEventListener('click', () => this.completeActiveTask());
}
removeFloatingTimer() {
if (this.floatingTimerEl) {
this.floatingTimerEl.remove();
this.floatingTimerEl = null;
}
}
updateFloatingTimer() {
if (!this.floatingTimerEl) return;
const taskEl = this.floatingTimerEl.querySelector('.focus-task-floating-task');
const timeEl = this.floatingTimerEl.querySelector('.focus-task-floating-time');
const playPauseBtn = this.floatingTimerEl.querySelector('.play-pause');
if (this.activeTaskId) {
const task = this.data.tasks.find(t => t.id === this.activeTaskId);
if (task && taskEl) {
taskEl.textContent = this.isBreakMode ? '☕ Break Time' : task.text;
}
} else if (taskEl) {
taskEl.textContent = 'No active task';
}
if (timeEl) {
timeEl.textContent = this.formatTime(this.currentTimerSeconds);
timeEl.classList.toggle('focus-task-overtime', this.currentTimerSeconds < 0);
}
if (playPauseBtn) {
playPauseBtn.textContent = this.isTimerRunning ? '⏸' : '▶';
}
// Update color based on state
this.floatingTimerEl.classList.toggle('focus-task-break-mode', this.isBreakMode);
this.floatingTimerEl.classList.toggle('focus-task-active', this.isTimerRunning);
}
makeDraggable(el: HTMLElement) {
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
el.onmousedown = dragMouseDown;
function dragMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).tagName === 'BUTTON') return;
e.preventDefault();
pos3 = e.clientX;
pos4 = e.clientY;
document.onmouseup = closeDragElement;
document.onmousemove = elementDrag;
}
function elementDrag(e: MouseEvent) {
e.preventDefault();
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
el.style.top = (el.offsetTop - pos2) + "px";
el.style.left = (el.offsetLeft - pos1) + "px";
el.style.right = 'auto';
el.style.bottom = 'auto';
}
function closeDragElement() {
document.onmouseup = null;
document.onmousemove = null;
}
}
// ============ 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');
}
}
// ============ 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();
}
});
}
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.data.totalFocusMinutesToday),
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();
}));
new Setting(containerEl)
.setName('Show Floating Timer')
.setDesc('Display a draggable floating timer widget')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showFloatingTimer)
.onChange(async value => {
this.plugin.settings.showFloatingTimer = value;
if (value) {
this.plugin.createFloatingTimer();
} else {
this.plugin.removeFloatingTimer();
}
await this.plugin.saveAllData();
}));
// 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>
`;
}
}

204
src/modals.ts Normal file
View File

@@ -0,0 +1,204 @@
import {
App,
Modal,
Notice,
Setting,
} from 'obsidian';
import { FocusTask } from './types';
import FocusTaskPlugin from './main';
// ============ Quick Add Task Modal ============
export class QuickAddTaskModal extends Modal {
plugin: FocusTaskPlugin;
taskText: string = '';
estimatedMinutes: number;
selectedList: string = 'work';
constructor(app: App, plugin: FocusTaskPlugin) {
super(app);
this.plugin = plugin;
this.estimatedMinutes = plugin.settings.defaultEstimateMinutes;
if (plugin.settings.lists.length > 0) {
this.selectedList = plugin.settings.lists[0].id;
}
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('focus-task-modal');
contentEl.createEl('h2', { text: '⚡ Add New Task' });
// Task text input
new Setting(contentEl)
.setName('Task')
.addText(text => {
text.setPlaceholder('What do you need to do?')
.onChange(value => this.taskText = value);
text.inputEl.focus();
text.inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && this.taskText.trim()) {
this.submitTask();
}
});
});
// Time estimate
new Setting(contentEl)
.setName('Estimated Time')
.setDesc('How long do you think this will take?')
.addDropdown(dropdown => {
const options: Record<string, string> = {
'5': '5 min',
'10': '10 min',
'15': '15 min',
'20': '20 min',
'25': '25 min (1 pomodoro)',
'30': '30 min',
'45': '45 min',
'50': '50 min (2 pomodoros)',
'60': '1 hour',
'90': '1.5 hours',
'120': '2 hours',
'180': '3 hours',
};
Object.entries(options).forEach(([value, label]) => {
dropdown.addOption(value, label);
});
dropdown.setValue(this.estimatedMinutes.toString());
dropdown.onChange(value => this.estimatedMinutes = parseInt(value));
});
// List selection
new Setting(contentEl)
.setName('List')
.addDropdown(dropdown => {
this.plugin.settings.lists.forEach(list => {
dropdown.addOption(list.id, `${list.icon} ${list.name}`);
});
dropdown.setValue(this.selectedList);
dropdown.onChange(value => this.selectedList = value);
});
// Buttons
const buttonContainer = contentEl.createEl('div', { cls: 'focus-task-modal-buttons' });
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'focus-task-btn' });
cancelBtn.addEventListener('click', () => this.close());
const addBtn = buttonContainer.createEl('button', { text: 'Add Task', cls: 'focus-task-btn focus-task-btn-primary' });
addBtn.addEventListener('click', () => this.submitTask());
}
submitTask() {
if (this.taskText.trim()) {
const task = this.plugin.createTask(this.taskText, this.estimatedMinutes, this.selectedList);
this.plugin.addTask(task);
new Notice('✅ Task added!');
this.close();
} else {
new Notice('Please enter a task description');
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
// ============ Edit Task Modal ============
export class EditTaskModal extends Modal {
plugin: FocusTaskPlugin;
task: FocusTask;
constructor(app: App, plugin: FocusTaskPlugin, task: FocusTask) {
super(app);
this.plugin = plugin;
this.task = { ...task };
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('focus-task-modal');
contentEl.createEl('h2', { text: '✏️ Edit Task' });
new Setting(contentEl)
.setName('Task')
.addText(text => text
.setValue(this.task.text)
.onChange(value => this.task.text = value));
new Setting(contentEl)
.setName('Estimated Time')
.addDropdown(dropdown => {
const options: Record<string, string> = {
'5': '5 min',
'10': '10 min',
'15': '15 min',
'20': '20 min',
'25': '25 min',
'30': '30 min',
'45': '45 min',
'50': '50 min',
'60': '1 hour',
'90': '1.5 hours',
'120': '2 hours',
'180': '3 hours',
};
Object.entries(options).forEach(([value, label]) => {
dropdown.addOption(value, label);
});
dropdown.setValue(this.task.estimatedMinutes.toString());
dropdown.onChange(value => this.task.estimatedMinutes = parseInt(value));
});
new Setting(contentEl)
.setName('List')
.addDropdown(dropdown => {
this.plugin.settings.lists.forEach(list => {
dropdown.addOption(list.id, `${list.icon} ${list.name}`);
});
dropdown.setValue(this.task.list);
dropdown.onChange(value => this.task.list = value);
});
new Setting(contentEl)
.setName('Notes')
.setDesc('Add any additional details or links')
.addTextArea(textarea => {
textarea
.setValue(this.task.notes)
.onChange(value => this.task.notes = value);
textarea.inputEl.rows = 4;
});
// Show actual time if task has been worked on
if (this.task.actualMinutes > 0) {
new Setting(contentEl)
.setName('Time Tracked')
.setDesc(`You've worked on this task for ${this.plugin.formatTimeHuman(this.task.actualMinutes)}`);
}
const buttonContainer = contentEl.createEl('div', { cls: 'focus-task-modal-buttons' });
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'focus-task-btn' });
cancelBtn.addEventListener('click', () => this.close());
const saveBtn = buttonContainer.createEl('button', { text: 'Save', cls: 'focus-task-btn focus-task-btn-primary' });
saveBtn.addEventListener('click', () => {
this.plugin.updateTask(this.task.id, this.task);
new Notice('✅ Task updated!');
this.close();
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

101
src/types.ts Normal file
View File

@@ -0,0 +1,101 @@
// ============ Types & Interfaces ============
export interface FocusTask {
id: string;
text: string;
completed: boolean;
estimatedMinutes: number;
actualMinutes: number;
createdAt: number;
completedAt?: number;
list: string;
notes: string;
scheduledDate?: string;
isActive: boolean;
}
export interface TaskList {
id: string;
name: string;
color: string;
icon: string;
}
export interface FocusTaskSettings {
pomodoroWorkMinutes: number;
pomodoroBreakMinutes: number;
longBreakMinutes: number;
longBreakInterval: number;
enableSounds: boolean;
enableCelebrations: boolean;
defaultEstimateMinutes: number;
lists: TaskList[];
showFloatingTimer: boolean;
autoStartBreak: boolean;
tickSoundEnabled: boolean;
}
export interface FocusTaskData {
tasks: FocusTask[];
completedToday: number;
totalFocusMinutesToday: number;
streak: number;
lastActiveDate: string;
pomodorosCompleted: number;
}
export const DEFAULT_SETTINGS: FocusTaskSettings = {
pomodoroWorkMinutes: 25,
pomodoroBreakMinutes: 5,
longBreakMinutes: 15,
longBreakInterval: 4,
enableSounds: true,
enableCelebrations: true,
defaultEstimateMinutes: 30,
lists: [
{ id: 'work', name: 'Work', color: '#6366f1', icon: '💼' },
{ id: 'personal', name: 'Personal', color: '#22c55e', icon: '🏠' },
{ id: 'learning', name: 'Learning', color: '#f59e0b', icon: '📚' },
],
showFloatingTimer: true,
autoStartBreak: false,
tickSoundEnabled: false,
};
export const DEFAULT_DATA: FocusTaskData = {
tasks: [],
completedToday: 0,
totalFocusMinutesToday: 0,
streak: 0,
lastActiveDate: '',
pomodorosCompleted: 0,
};
export const VIEW_TYPE_FOCUS_TASK = 'focus-task-view';
// ============ Celebration Messages ============
export const CELEBRATION_MESSAGES = [
{ emoji: '💥', message: 'Crushed it!' },
{ emoji: '🔥', message: 'On fire!' },
{ emoji: '⚡', message: 'Lightning fast!' },
{ emoji: '🎯', message: 'Bullseye!' },
{ emoji: '🚀', message: 'Blasting through!' },
{ emoji: '💪', message: 'Strong work!' },
{ emoji: '🌟', message: 'Stellar!' },
{ emoji: '✨', message: 'Brilliant!' },
{ emoji: '🏆', message: 'Champion!' },
{ emoji: '🎉', message: 'Well done!' },
];
export const EARLY_FINISH_MESSAGES = [
{ emoji: '⚡', message: 'Speed demon! Finished early!' },
{ emoji: '🏎️', message: 'Faster than expected!' },
{ emoji: '🎯', message: 'Under budget! Nice work!' },
];
export const OVERTIME_MESSAGES = [
{ emoji: '💪', message: 'Persistence pays off!' },
{ emoji: '🏃', message: 'Marathon runner!' },
{ emoji: '🔥', message: 'The grind is real!' },
];

319
src/view.ts Normal file
View File

@@ -0,0 +1,319 @@
import {
ItemView,
WorkspaceLeaf,
} from 'obsidian';
import { VIEW_TYPE_FOCUS_TASK, FocusTask } from './types';
import { QuickAddTaskModal, EditTaskModal } from './modals';
import FocusTaskPlugin from './main';
// ============ Main View ============
export class FocusTaskView extends ItemView {
plugin: FocusTaskPlugin;
currentFilter: string = 'all';
constructor(leaf: WorkspaceLeaf, plugin: FocusTaskPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return VIEW_TYPE_FOCUS_TASK;
}
getDisplayText(): string {
return 'Focus Task';
}
getIcon(): string {
return 'zap';
}
async onOpen() {
this.refresh();
}
refresh() {
const container = this.containerEl.children[1];
container.empty();
container.addClass('focus-task-container');
// Header
this.renderHeader(container);
// Stats bar
this.renderStatsBar(container);
// Active task / Timer
this.renderActiveTask(container);
// Task list
this.renderTaskList(container);
}
renderHeader(container: Element) {
const header = container.createEl('div', { cls: 'focus-task-header' });
const titleSection = header.createEl('div', { cls: 'focus-task-title-section' });
titleSection.createEl('h2', { text: '⚡ Focus Task', cls: 'focus-task-title' });
const today = new Date();
const dateStr = today.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
titleSection.createEl('div', { text: dateStr, cls: 'focus-task-date' });
const actions = header.createEl('div', { cls: 'focus-task-header-actions' });
const addBtn = actions.createEl('button', { cls: 'focus-task-btn focus-task-btn-primary' });
addBtn.innerHTML = '+ Add Task';
addBtn.addEventListener('click', () => {
new QuickAddTaskModal(this.app, this.plugin).open();
});
}
renderStatsBar(container: Element) {
const stats = this.plugin.getStats();
const statsBar = container.createEl('div', { cls: 'focus-task-stats-bar' });
const statItems = [
{ label: 'Pending', value: stats.pendingCount.toString(), icon: '📋' },
{ label: 'Done Today', value: stats.completedToday.toString(), icon: '✅' },
{ label: 'Focus Time', value: this.plugin.formatTimeHuman(stats.totalFocusMinutesToday), icon: '⏱️' },
{ label: 'Streak', value: `${stats.streak} days`, icon: '🔥' },
];
statItems.forEach(stat => {
const item = statsBar.createEl('div', { cls: 'focus-task-stat-item' });
item.createEl('div', { cls: 'focus-task-stat-icon', text: stat.icon });
item.createEl('div', { cls: 'focus-task-stat-value', text: stat.value });
item.createEl('div', { cls: 'focus-task-stat-label', text: stat.label });
});
}
renderActiveTask(container: Element) {
const activeSection = container.createEl('div', { cls: 'focus-task-active-section' });
if (this.plugin.activeTaskId) {
const task = this.plugin.data.tasks.find(t => t.id === this.plugin.activeTaskId);
if (task) {
activeSection.addClass('focus-task-has-active');
const activeCard = activeSection.createEl('div', { cls: 'focus-task-active-card' });
if (this.plugin.isBreakMode) {
activeCard.addClass('focus-task-break-card');
activeCard.createEl('div', { cls: 'focus-task-active-label', text: '☕ BREAK TIME' });
} else {
activeCard.createEl('div', { cls: 'focus-task-active-label', text: '🎯 FOCUSING ON' });
}
activeCard.createEl('div', { cls: 'focus-task-active-task-name', text: task.text });
// Timer display
const timerDisplay = activeCard.createEl('div', { cls: 'focus-task-timer-display' });
timerDisplay.createEl('span', {
cls: 'focus-task-timer-time',
text: this.plugin.formatTime(this.plugin.currentTimerSeconds)
});
// Progress bar
const progressWrap = activeCard.createEl('div', { cls: 'focus-task-progress-wrap' });
const progress = progressWrap.createEl('div', { cls: 'focus-task-progress-bar' });
let progressPercent = 0;
if (this.plugin.isBreakMode) {
const breakDuration = this.plugin.pomodoroCount % this.plugin.settings.longBreakInterval === 0
? this.plugin.settings.longBreakMinutes * 60
: this.plugin.settings.pomodoroBreakMinutes * 60;
progressPercent = ((breakDuration - this.plugin.currentTimerSeconds) / breakDuration) * 100;
} else {
const workDuration = this.plugin.settings.pomodoroWorkMinutes * 60;
progressPercent = ((workDuration - this.plugin.currentTimerSeconds) / workDuration) * 100;
}
progress.style.width = `${Math.min(Math.max(progressPercent, 0), 100)}%`;
if (progressPercent >= 100) progress.addClass('focus-task-overtime');
// Time info
if (!this.plugin.isBreakMode) {
const timeInfo = activeCard.createEl('div', { cls: 'focus-task-time-info' });
timeInfo.createEl('span', { text: `Est: ${this.plugin.formatTimeHuman(task.estimatedMinutes)}` });
timeInfo.createEl('span', { text: `Actual: ${this.plugin.formatTimeHuman(task.actualMinutes)}` });
}
// Controls
const controls = activeCard.createEl('div', { cls: 'focus-task-active-controls' });
const pauseBtn = controls.createEl('button', { cls: 'focus-task-btn focus-task-btn-secondary' });
pauseBtn.innerHTML = this.plugin.isTimerRunning ? '⏸ Pause' : '▶ Resume';
pauseBtn.addEventListener('click', () => this.plugin.toggleTimer());
if (!this.plugin.isBreakMode) {
const completeBtn = controls.createEl('button', { cls: 'focus-task-btn focus-task-btn-success' });
completeBtn.innerHTML = '✓ Complete';
completeBtn.addEventListener('click', () => this.plugin.completeTask(task.id));
}
const stopBtn = controls.createEl('button', { cls: 'focus-task-btn focus-task-btn-danger' });
stopBtn.innerHTML = '✕ Stop';
stopBtn.addEventListener('click', () => this.plugin.stopTimer());
if (this.plugin.isBreakMode) {
const skipBreakBtn = controls.createEl('button', { cls: 'focus-task-btn' });
skipBreakBtn.innerHTML = '⏭ Skip Break';
skipBreakBtn.addEventListener('click', () => {
this.plugin.isBreakMode = false;
this.plugin.stopTimer();
this.refresh();
});
}
}
} else {
// No active task - show start focus prompt
const startPrompt = activeSection.createEl('div', { cls: 'focus-task-start-prompt' });
startPrompt.createEl('div', { cls: 'focus-task-prompt-icon', text: '⚡' });
startPrompt.createEl('div', { cls: 'focus-task-prompt-text', text: 'Ready to focus?' });
startPrompt.createEl('div', { cls: 'focus-task-prompt-hint', text: 'Click ▶ on a task to start a Pomodoro session' });
}
}
renderTaskList(container: Element) {
const listSection = container.createEl('div', { cls: 'focus-task-list-section' });
// Filters
const filters = listSection.createEl('div', { cls: 'focus-task-filters' });
const filterOptions = [
{ id: 'all', label: 'All Tasks' },
{ id: 'today', label: 'Today' },
{ id: 'completed', label: 'Completed' },
...this.plugin.settings.lists.map(l => ({ id: l.id, label: `${l.icon} ${l.name}` })),
];
filterOptions.forEach(opt => {
const btn = filters.createEl('button', {
cls: `focus-task-filter-btn ${this.currentFilter === opt.id ? 'active' : ''}`,
text: opt.label,
});
btn.addEventListener('click', () => {
this.currentFilter = opt.id;
this.refresh();
});
});
// Task items
const taskList = listSection.createEl('div', { cls: 'focus-task-task-list' });
let tasks = this.plugin.data.tasks;
// Filter tasks
if (this.currentFilter === 'today') {
tasks = this.plugin.getTodaysTasks();
} else if (this.currentFilter === 'completed') {
tasks = this.plugin.data.tasks.filter(t => t.completed);
} else if (this.currentFilter !== 'all') {
tasks = this.plugin.getTasksByList(this.currentFilter);
}
// Sort: incomplete first, then by creation date
tasks = [...tasks].sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1;
return b.createdAt - a.createdAt;
});
if (tasks.length === 0) {
const emptyState = taskList.createEl('div', { cls: 'focus-task-empty-state' });
emptyState.createEl('div', { cls: 'focus-task-empty-icon', text: '📝' });
emptyState.createEl('div', { cls: 'focus-task-empty-text', text: 'No tasks yet' });
emptyState.createEl('div', { cls: 'focus-task-empty-hint', text: 'Add a task to get started!' });
} else {
tasks.forEach(task => this.renderTaskItem(taskList, task));
}
}
renderTaskItem(container: Element, task: FocusTask) {
const list = this.plugin.settings.lists.find(l => l.id === task.list);
const taskEl = container.createEl('div', {
cls: `focus-task-task-item ${task.completed ? 'completed' : ''} ${task.isActive ? 'active' : ''}`
});
// Checkbox
const checkbox = taskEl.createEl('div', { cls: 'focus-task-checkbox' });
checkbox.innerHTML = task.completed ? '✓' : '';
checkbox.style.borderColor = list?.color || '#6366f1';
if (task.completed) {
checkbox.style.backgroundColor = list?.color || '#6366f1';
checkbox.style.color = 'white';
}
checkbox.addEventListener('click', (e) => {
e.stopPropagation();
if (!task.completed) {
this.plugin.completeTask(task.id);
}
});
// Task content
const content = taskEl.createEl('div', { cls: 'focus-task-task-content' });
const taskHeader = content.createEl('div', { cls: 'focus-task-task-header' });
taskHeader.createEl('span', { cls: 'focus-task-task-text', text: task.text });
if (list) {
const listBadge = taskHeader.createEl('span', {
cls: 'focus-task-list-badge',
text: `${list.icon} ${list.name}`,
});
listBadge.style.backgroundColor = list.color + '20';
listBadge.style.color = list.color;
}
const taskMeta = content.createEl('div', { cls: 'focus-task-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)}`);
if (task.actualMinutes > task.estimatedMinutes) {
actualSpan.addClass('focus-task-overtime-text');
}
}
// Actions
const actions = taskEl.createEl('div', { cls: 'focus-task-task-actions' });
if (!task.completed) {
// Start pomodoro button
const startBtn = actions.createEl('button', { cls: 'focus-task-task-btn', attr: { 'aria-label': 'Start Pomodoro' } });
startBtn.innerHTML = '▶';
startBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.plugin.startPomodoro(task.id);
});
// Stopwatch mode button
const stopwatchBtn = actions.createEl('button', { cls: 'focus-task-task-btn', attr: { 'aria-label': 'Start Stopwatch' } });
stopwatchBtn.innerHTML = '⏱';
stopwatchBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.plugin.startTimer(task.id);
});
}
// Edit button
const editBtn = actions.createEl('button', { cls: 'focus-task-task-btn', attr: { 'aria-label': 'Edit' } });
editBtn.innerHTML = '✏️';
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
new EditTaskModal(this.app, this.plugin, task).open();
});
// Delete button
const deleteBtn = actions.createEl('button', { cls: 'focus-task-task-btn focus-task-delete-btn', attr: { 'aria-label': 'Delete' } });
deleteBtn.innerHTML = '🗑';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.plugin.deleteTask(task.id);
});
}
}