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

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Build output
main.js
*.js.map
# npm
node_modules/
package-lock.json
# IDE
.idea/
.vscode/
*.sublime-*
# OS files
.DS_Store
Thumbs.db
# Obsidian
data.json

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Crib
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

254
README.md Normal file
View File

@@ -0,0 +1,254 @@
# ⚡ Focus Task
A powerful task management and focus timer plugin for [Obsidian](https://obsidian.md), heavily inspired by [Blitzit](https://www.blitzit.app/).
![Focus Task Banner](https://img.shields.io/badge/Obsidian-Plugin-7c3aed?style=for-the-badge&logo=obsidian&logoColor=white)
![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)
![Version](https://img.shields.io/badge/Version-1.0.0-blue?style=for-the-badge)
## 🎯 Overview
Focus Task brings the power of time-boxed task management directly into your Obsidian vault. Plan your day, track time with the Pomodoro technique, and crush your tasks with satisfying checkoffs - all without leaving your notes.
### Why Focus Task?
- **Stay in Flow**: No need to switch between apps - manage tasks where you take notes
- **Time Awareness**: Know exactly how long tasks take vs. your estimates
- **Pomodoro Built-in**: Work in focused sprints with automatic break reminders
- **Satisfying Feedback**: Celebratory messages and sounds when you complete tasks
- **Visual Progress**: See your daily progress with stats and streaks
## ✨ Features
### 📋 Task Management
- Create tasks with time estimates
- Organize tasks into customizable lists (Work, Personal, Learning, etc.)
- Add notes and details to tasks
- Filter tasks by list, today's tasks, or completed items
- Drag-and-drop task reordering
### ⏱️ Dual Timer Modes
#### Pomodoro Timer
- Configurable work sessions (default: 25 minutes)
- Short breaks (default: 5 minutes)
- Long breaks after a set number of pomodoros (default: 15 minutes every 4 pomodoros)
- Auto-start break option
- Visual countdown with progress bar
#### Stopwatch Mode
- Free-form time tracking
- Alerts when you exceed your estimate
- Track actual time vs. estimated time
### 📊 Progress Tracking
- **Daily Stats**: See tasks completed, focus time, and more
- **Streak Counter**: Build momentum with consecutive productive days
- **Time Comparison**: Compare estimated vs. actual time to improve planning
- **Pomodoro Count**: Track total pomodoros completed
### 🎨 User Experience
- **Floating Timer Widget**: Draggable timer that stays visible while you work
- **Celebration Messages**: Fun, randomized messages when you complete tasks
- **Sound Notifications**: Audio alerts for timer completion and task completion
- **Keyboard Shortcuts**: Quick access to common actions
- **Responsive Design**: Works great in any panel size
## 🚀 Installation
### From Obsidian Community Plugins (Coming Soon)
1. Open Obsidian Settings
2. Go to Community Plugins
3. Search for "Focus Task"
4. Click Install, then Enable
### Manual Installation
1. Download the latest release from the [releases page](https://git.cribdev.com/crib/focus-task/releases)
2. Extract the files to your vault's `.obsidian/plugins/focus-task/` folder
3. Reload Obsidian
4. Enable the plugin in Settings → Community Plugins
### Building from Source
```bash
# Clone the repository
git clone https://git.cribdev.com/crib/focus-task.git
cd focus-task
# Install dependencies
npm install
# Build the plugin
npm run build
# Copy to your vault
cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/focus-task/
```
## 📖 Usage
### Getting Started
1. **Open Focus Task**: Click the ⚡ icon in the ribbon or use the command palette (`Ctrl/Cmd + P` → "Open Focus Task Panel")
2. **Add a Task**: Click "+ Add Task" and fill in:
- Task description
- Time estimate
- List category
3. **Start Focusing**: Click ▶ on any task to start a Pomodoro session, or ⏱ for stopwatch mode
4. **Complete Tasks**: Check off tasks when done and enjoy the celebration!
### Keyboard Shortcuts
| Action | Command |
|--------|---------|
| Open Panel | `Ctrl/Cmd + P` → "Open Focus Task Panel" |
| Quick Add Task | `Ctrl/Cmd + P` → "Quick Add Task" |
| Toggle Timer | `Ctrl/Cmd + P` → "Toggle Timer" |
| Complete Task | `Ctrl/Cmd + P` → "Complete Current Task" |
| Start Focus | `Ctrl/Cmd + P` → "Start Focus Mode on Next Task" |
### Timer Modes Explained
#### 🍅 Pomodoro Mode (▶ button)
Best for: Maintaining focus on challenging tasks
1. Timer counts down from your configured work duration
2. When time's up, you'll get a notification
3. Take a break (short or long, based on your settings)
4. Repeat until the task is complete
#### ⏱️ Stopwatch Mode (⏱ button)
Best for: Tracking time on open-ended tasks
1. Timer counts up from zero
2. Get alerted when you exceed your estimate
3. Stop whenever the task is complete
4. See exactly how long the task took
## ⚙️ Settings
### Pomodoro Timer
| Setting | Description | Default |
|---------|-------------|---------|
| Work Duration | Length of each work session | 25 min |
| Short Break | Length of short breaks | 5 min |
| Long Break | Length of long breaks | 15 min |
| Long Break Interval | Pomodoros before a long break | 4 |
| Auto-start Breaks | Automatically start break timer | Off |
### General
| Setting | Description | Default |
|---------|-------------|---------|
| Default Time Estimate | Default estimate for new tasks | 30 min |
| Enable Sounds | Play completion/alert sounds | On |
| Enable Celebrations | Show celebration messages | On |
| Show Floating Timer | Display draggable timer widget | On |
### Lists
Customize your task lists with:
- Custom names
- Emoji icons
- Color coding
Default lists: Work 💼, Personal 🏠, Learning 📚
## 🎨 Customization
### Adding Custom Lists
1. Go to Settings → Focus Task → Lists
2. Click "+ Add List"
3. Set the name, emoji, and color
4. Click Save
### Theming
Focus Task respects your Obsidian theme and adapts to both light and dark modes automatically.
## 📁 Project Structure
```
focus-task/
├── src/
│ ├── main.ts # Main plugin class
│ ├── types.ts # TypeScript interfaces
│ ├── view.ts # Main UI view
│ └── modals.ts # Task modals
├── styles.css # Plugin styles
├── manifest.json # Obsidian plugin manifest
├── package.json # npm configuration
├── tsconfig.json # TypeScript config
├── esbuild.config.mjs # Build configuration
└── README.md # This file
```
## 🙏 Inspiration & Credits
This plugin is **heavily inspired by [Blitzit](https://www.blitzit.app/)**, a fantastic standalone productivity app that combines task management with focused time tracking.
Blitzit's approach to productivity resonated with me:
- Simple, focused interface
- Time estimation and tracking
- Pomodoro technique integration
- Satisfying task completion experience
- Progress insights
I wanted to bring this experience directly into Obsidian, where I already manage my notes and knowledge. Focus Task is my attempt to capture the essence of what makes Blitzit great while leveraging Obsidian's powerful ecosystem.
**If you're looking for a dedicated productivity app, I highly recommend checking out [Blitzit](https://www.blitzit.app/)!**
## 🤝 Contributing
Contributions are welcome! Feel free to:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Development Setup
```bash
# Clone the repo
git clone https://git.cribdev.com/crib/focus-task.git
cd focus-task
# Install dependencies
npm install
# Start development build (watches for changes)
npm run dev
# Create symlink to your test vault
ln -s $(pwd) /path/to/vault/.obsidian/plugins/focus-task
```
## 📝 Roadmap
- [ ] Integration with Obsidian Tasks plugin
- [ ] Calendar view for scheduled tasks
- [ ] Weekly/Monthly reports
- [ ] Task templates
- [ ] Sync with external task managers
- [ ] Mobile optimizations
- [ ] Task dependencies
- [ ] Time blocking in daily notes
## 📜 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🔗 Links
- **Repository**: [https://git.cribdev.com/crib/focus-task](https://git.cribdev.com/crib/focus-task)
- **Issues**: [https://git.cribdev.com/crib/focus-task/issues](https://git.cribdev.com/crib/focus-task/issues)
- **Inspiration**: [Blitzit App](https://www.blitzit.app/)
---
<p align="center">
Made with ❤️ for the Obsidian community
<br>
Inspired by <a href="https://www.blitzit.app/">Blitzit</a> ⚡
</p>

48
esbuild.config.mjs Normal file
View File

@@ -0,0 +1,48 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === 'production');
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ['src/main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtins],
format: 'cjs',
target: 'es2018',
logLevel: "info",
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

11
manifest.json Normal file
View File

@@ -0,0 +1,11 @@
{
"id": "focus-task",
"name": "Focus Task",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "A Blitzit-inspired task management and focus timer plugin. Plan your day, track time with Pomodoro technique, and crush your tasks with satisfying checkoffs.",
"author": "Crib",
"authorUrl": "https://git.cribdev.com/crib",
"fundingUrl": "",
"isDesktopOnly": false
}

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "focus-task",
"version": "1.0.0",
"description": "A Blitzit-inspired task management and focus timer plugin for Obsidian",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
"obsidian",
"plugin",
"productivity",
"pomodoro",
"tasks",
"timer",
"focus"
],
"author": "Crib",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.cribdev.com/crib/focus-task"
},
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^5.29.0",
"@typescript-eslint/parser": "^5.29.0",
"builtin-modules": "^3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
}

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

698
styles.css Normal file
View File

@@ -0,0 +1,698 @@
/* ============================================
FOCUS TASK - Obsidian Plugin Styles
Inspired by https://www.blitzit.app/
============================================ */
/* ============ CSS Variables ============ */
.focus-task-container {
--ft-primary: #6366f1;
--ft-primary-light: #818cf8;
--ft-primary-dark: #4f46e5;
--ft-success: #22c55e;
--ft-warning: #f59e0b;
--ft-danger: #ef4444;
--ft-break: #06b6d4;
--ft-bg: var(--background-primary);
--ft-bg-secondary: var(--background-secondary);
--ft-bg-tertiary: var(--background-modifier-hover);
--ft-text: var(--text-normal);
--ft-text-muted: var(--text-muted);
--ft-border: var(--background-modifier-border);
--ft-radius: 12px;
--ft-radius-sm: 8px;
--ft-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--ft-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* ============ Container ============ */
.focus-task-container {
padding: 16px;
font-family: var(--font-interface);
overflow-y: auto;
height: 100%;
}
/* ============ Header ============ */
.focus-task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--ft-border);
}
.focus-task-title-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.focus-task-title {
margin: 0;
font-size: 24px;
font-weight: 700;
background: linear-gradient(135deg, var(--ft-primary) 0%, var(--ft-primary-light) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.focus-task-date {
color: var(--ft-text-muted);
font-size: 13px;
}
.focus-task-header-actions {
display: flex;
gap: 8px;
}
/* ============ Buttons ============ */
.focus-task-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--ft-radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
background: var(--ft-bg-tertiary);
color: var(--ft-text);
}
.focus-task-btn:hover {
background: var(--ft-border);
transform: translateY(-1px);
}
.focus-task-btn-primary {
background: linear-gradient(135deg, var(--ft-primary) 0%, var(--ft-primary-dark) 100%);
color: white;
}
.focus-task-btn-primary:hover {
background: linear-gradient(135deg, var(--ft-primary-light) 0%, var(--ft-primary) 100%);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.focus-task-btn-success {
background: var(--ft-success);
color: white;
}
.focus-task-btn-success:hover {
background: #16a34a;
}
.focus-task-btn-secondary {
background: var(--ft-bg-secondary);
border: 1px solid var(--ft-border);
}
.focus-task-btn-danger {
background: transparent;
color: var(--ft-danger);
border: 1px solid var(--ft-danger);
}
.focus-task-btn-danger:hover {
background: var(--ft-danger);
color: white;
}
/* ============ Stats Bar ============ */
.focus-task-stats-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
@media (max-width: 600px) {
.focus-task-stats-bar {
grid-template-columns: repeat(2, 1fr);
}
}
.focus-task-stat-item {
background: var(--ft-bg-secondary);
border-radius: var(--ft-radius);
padding: 12px;
text-align: center;
transition: all 0.2s ease;
}
.focus-task-stat-item:hover {
transform: translateY(-2px);
box-shadow: var(--ft-shadow);
}
.focus-task-stat-icon {
font-size: 20px;
margin-bottom: 4px;
}
.focus-task-stat-value {
font-size: 18px;
font-weight: 700;
color: var(--ft-primary);
}
.focus-task-stat-label {
font-size: 11px;
color: var(--ft-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ============ Active Task Section ============ */
.focus-task-active-section {
margin-bottom: 24px;
}
.focus-task-start-prompt {
background: linear-gradient(135deg, var(--ft-bg-secondary) 0%, var(--ft-bg-tertiary) 100%);
border: 2px dashed var(--ft-border);
border-radius: var(--ft-radius);
padding: 32px;
text-align: center;
}
.focus-task-prompt-icon {
font-size: 48px;
margin-bottom: 12px;
animation: ft-pulse 2s ease-in-out infinite;
}
@keyframes ft-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
}
.focus-task-prompt-text {
font-size: 18px;
font-weight: 600;
color: var(--ft-text);
margin-bottom: 4px;
}
.focus-task-prompt-hint {
font-size: 14px;
color: var(--ft-text-muted);
}
/* ============ Active Task Card ============ */
.focus-task-active-card {
background: linear-gradient(135deg, var(--ft-primary) 0%, var(--ft-primary-dark) 100%);
border-radius: var(--ft-radius);
padding: 24px;
color: white;
box-shadow: var(--ft-shadow-lg);
animation: ft-slideIn 0.3s ease;
}
@keyframes ft-slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.focus-task-break-card {
background: linear-gradient(135deg, var(--ft-break) 0%, #0891b2 100%);
}
.focus-task-active-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.9;
margin-bottom: 8px;
}
.focus-task-active-task-name {
font-size: 20px;
font-weight: 700;
margin-bottom: 16px;
word-break: break-word;
}
.focus-task-timer-display {
text-align: center;
margin-bottom: 16px;
}
.focus-task-timer-time {
font-size: 48px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 2px;
}
.focus-task-timer-time.focus-task-overtime {
color: var(--ft-warning);
}
/* Progress Bar */
.focus-task-progress-wrap {
background: rgba(255, 255, 255, 0.2);
border-radius: 999px;
height: 8px;
overflow: hidden;
margin-bottom: 12px;
}
.focus-task-progress-bar {
height: 100%;
background: rgba(255, 255, 255, 0.9);
border-radius: 999px;
transition: width 0.3s ease;
}
.focus-task-progress-bar.focus-task-overtime {
background: var(--ft-warning);
}
.focus-task-time-info {
display: flex;
justify-content: space-between;
font-size: 13px;
opacity: 0.9;
margin-bottom: 16px;
}
.focus-task-active-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.focus-task-active-controls .focus-task-btn {
flex: 1;
min-width: 80px;
background: rgba(255, 255, 255, 0.2);
color: white;
border: none;
}
.focus-task-active-controls .focus-task-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.focus-task-active-controls .focus-task-btn-success {
background: var(--ft-success);
}
.focus-task-active-controls .focus-task-btn-danger {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.4);
}
/* ============ Task List Section ============ */
.focus-task-list-section {
flex: 1;
}
/* Filters */
.focus-task-filters {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.focus-task-filter-btn {
padding: 6px 12px;
border-radius: 999px;
font-size: 13px;
background: var(--ft-bg-secondary);
border: 1px solid var(--ft-border);
color: var(--ft-text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.focus-task-filter-btn:hover {
background: var(--ft-bg-tertiary);
color: var(--ft-text);
}
.focus-task-filter-btn.active {
background: var(--ft-primary);
border-color: var(--ft-primary);
color: white;
}
/* Task List */
.focus-task-task-list {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Empty State */
.focus-task-empty-state {
text-align: center;
padding: 40px 20px;
color: var(--ft-text-muted);
}
.focus-task-empty-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.focus-task-empty-text {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.focus-task-empty-hint {
font-size: 14px;
}
/* ============ Task Item ============ */
.focus-task-task-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--ft-bg-secondary);
border-radius: var(--ft-radius-sm);
border: 1px solid transparent;
transition: all 0.2s ease;
}
.focus-task-task-item:hover {
border-color: var(--ft-primary);
box-shadow: var(--ft-shadow);
transform: translateX(4px);
}
.focus-task-task-item.active {
border-color: var(--ft-primary);
background: linear-gradient(90deg, rgba(99, 102, 241, 0.1) 0%, var(--ft-bg-secondary) 100%);
}
.focus-task-task-item.completed {
opacity: 0.6;
}
.focus-task-task-item.completed .focus-task-task-text {
text-decoration: line-through;
}
/* Checkbox */
.focus-task-checkbox {
width: 24px;
height: 24px;
min-width: 24px;
border: 2px solid var(--ft-primary);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.focus-task-checkbox:hover {
transform: scale(1.1);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
/* Task Content */
.focus-task-task-content {
flex: 1;
min-width: 0;
}
.focus-task-task-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.focus-task-task-text {
font-size: 14px;
font-weight: 500;
color: var(--ft-text);
word-break: break-word;
}
.focus-task-list-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 500;
}
.focus-task-task-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--ft-text-muted);
}
.focus-task-overtime-text {
color: var(--ft-warning);
}
/* Task Actions */
.focus-task-task-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
}
.focus-task-task-item:hover .focus-task-task-actions {
opacity: 1;
}
.focus-task-task-btn {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: var(--ft-bg-tertiary);
border: none;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.focus-task-task-btn:hover {
background: var(--ft-primary);
color: white;
transform: scale(1.1);
}
.focus-task-delete-btn:hover {
background: var(--ft-danger);
}
/* ============ Floating Timer ============ */
.focus-task-floating-timer {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 16px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
cursor: move;
min-width: 180px;
transition: all 0.3s ease;
}
.focus-task-floating-timer:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
}
.focus-task-floating-timer.focus-task-active {
border-color: var(--ft-primary);
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.3);
}
.focus-task-floating-timer.focus-task-break-mode {
border-color: var(--ft-break);
box-shadow: 0 8px 32px rgba(6, 182, 212, 0.3);
}
.focus-task-floating-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.focus-task-floating-task {
font-size: 12px;
color: var(--text-muted);
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.focus-task-floating-time {
font-size: 28px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--ft-primary);
}
.focus-task-floating-time.focus-task-overtime {
color: var(--ft-warning);
}
.focus-task-floating-controls {
display: flex;
gap: 8px;
}
.focus-task-float-btn {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--ft-bg-secondary);
border: 1px solid var(--ft-border);
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.focus-task-float-btn:hover {
background: var(--ft-primary);
color: white;
border-color: var(--ft-primary);
}
/* ============ Modal Styles ============ */
.focus-task-modal {
padding: 20px;
}
.focus-task-modal h2 {
margin-top: 0;
margin-bottom: 20px;
}
.focus-task-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--ft-border);
}
/* About section in settings */
.focus-task-about {
background: var(--ft-bg-secondary);
border-radius: var(--ft-radius);
padding: 16px;
margin-top: 8px;
}
.focus-task-about p {
margin: 0 0 12px 0;
line-height: 1.6;
}
.focus-task-about p:last-child {
margin-bottom: 0;
}
.focus-task-about a {
color: var(--ft-primary);
}
/* ============ Scrollbar Styling ============ */
.focus-task-container::-webkit-scrollbar {
width: 8px;
}
.focus-task-container::-webkit-scrollbar-track {
background: transparent;
}
.focus-task-container::-webkit-scrollbar-thumb {
background: var(--ft-border);
border-radius: 4px;
}
.focus-task-container::-webkit-scrollbar-thumb:hover {
background: var(--ft-text-muted);
}
/* ============ Animations ============ */
@keyframes ft-checkComplete {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
.focus-task-checkbox.completing {
animation: ft-checkComplete 0.3s ease;
}
/* ============ Dark mode adjustments ============ */
.theme-dark .focus-task-floating-timer {
background: var(--background-secondary);
}
/* ============ Mobile Responsive ============ */
@media (max-width: 400px) {
.focus-task-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.focus-task-active-controls {
flex-direction: column;
}
.focus-task-active-controls .focus-task-btn {
flex: none;
width: 100%;
}
.focus-task-task-item {
flex-wrap: wrap;
}
.focus-task-task-actions {
opacity: 1;
width: 100%;
justify-content: flex-end;
margin-top: 8px;
}
}

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

14
version-bump.mjs Normal file
View File

@@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View File

@@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}