18 Commits

Author SHA1 Message Date
50ef40d2e0 Release v1.1.1: Critical timer bug fix
Fixed critical bug where actual time spent on tasks was being reset to 0 when resuming work after a break in Pomodoro mode.

## Bug Fix
- Fixed timer actual time resetting to 0 when skipping break or resuming work
- Added preserveActualTime parameter to stopTimer() function
- Actual time now correctly accumulates across multiple Pomodoro sessions
2025-11-25 10:11:05 +01:00
b3aa1f2992 Improve .gitignore comments and patterns 2025-11-24 21:22:48 +01:00
e1484b1723 Update .gitignore to exclude release notes and versioned roadmaps 2025-11-24 21:19:47 +01:00
8207b3626e Update .gitignore 2025-11-24 20:49:44 +01:00
f1af574eb9 Release v1.1.0: Reports, Scheduling, and Mobile Optimization 2025-11-24 20:48:47 +01:00
2fad5d88ab feat: Add task scheduling and reminders system
Add comprehensive scheduling functionality with reminder notifications:

- Add scheduledDate, scheduledTime, and reminderMinutes fields to ImmerseTask
- Add enableReminders and defaultReminderMinutes to plugin settings
- Implement reminder notification system with 30-second background checks
- Add overdue task detection with visual indicators
- Add native HTML5 date/time pickers in Quick Add and Edit modals
- Add reminder dropdown with 5/10/15/30/60 minute options
- Display scheduled date/time with blue badge (📅) in task list
- Show pulsing red "OVERDUE" badge (⚠️) for past-due tasks
- Add red left border highlight for overdue tasks
- Implement startup check for due/overdue tasks when plugin loads
- Show overdue notices when Obsidian opens if tasks are past due
- Play alert sounds for reminders (if sounds enabled)
- Track shown reminders to prevent duplicate notifications
2025-11-23 21:12:21 +01:00
683c4ddafe Complete internal code renaming from FocusTask to Immerse types
- Renamed FocusTask → ImmerseTask
- Renamed FocusTaskSettings → ImmerseSettings
- Renamed FocusTaskData → ImmerseData
- Updated CSS classes: focus-task-status-* → immerse-status-*
- Updated package release script zip filename
- Updated remaining README references
- Rebuilt main.js with new type names
2025-11-23 20:12:28 +01:00
331a2b41df Release v1.0.9: Rename plugin from Focus Task to Immerse 2025-11-23 19:56:06 +01:00
364935af66 Updated README.md 2025-11-23 16:02:14 +01:00
2f2346b4c8 Release v1.0.8: Timer Accuracy Fix 2025-11-23 15:49:08 +01:00
c8f5c69102 General Bugfixes 2025-11-23 15:33:51 +01:00
2800a7507e Release v1.0.7: General bugfixes 2025-11-23 15:06:12 +01:00
2f861c2fcb Release v1.0.6: Background Timer Fix 2025-11-23 13:15:45 +01:00
9abdd10ada Release v1.0.5: Break Timer & Time Tracking Improvements 2025-11-23 11:49:45 +01:00
e66d9b4d25 General Bug fixes on Timers/Breaks 2025-11-23 11:46:13 +01:00
285ac7a7c9 Updated README.md 2025-11-23 11:45:37 +01:00
9e35a2603c Updated README.md 2025-11-22 20:48:35 +01:00
f5e4a16aff Bug Fixes - Updated Version v1.0.4 2025-11-22 20:31:57 +01:00
13 changed files with 3522 additions and 493 deletions

15
.gitignore vendored
View File

@@ -1,5 +1,8 @@
# Build output # Build output (main.js is tracked for easy installation)
*.js.map *.js.map
release/
*.zip
immerse-*.zip
# npm # npm
node_modules/ node_modules/
@@ -15,4 +18,12 @@ package-lock.json
Thumbs.db Thumbs.db
# Obsidian # Obsidian
data.json data.json
# Development/Documentation (not for distribution)
RELEASE-GUIDE.md
ROADMAP.md
ROADMAP_*.md
deploy-test.bat
.claude/
RELEASE_NOTES_*.md

102
README.md
View File

@@ -1,16 +1,16 @@
# ⚡ Focus Task # ⚡ Immerse
A powerful task management and focus timer plugin for [Obsidian](https://obsidian.md), heavily inspired by [Blitzit](https://www.blitzit.app/). 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) ![Immerse 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) ![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)
![Version](https://img.shields.io/badge/Version-1.0.3-blue?style=for-the-badge) ![Version](https://img.shields.io/badge/Version-1.1.0-blue?style=for-the-badge)
## 🎯 Overview ## 🎯 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. Immerse 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? ### Why Immerse?
- **Stay in Flow**: No need to switch between apps - manage tasks where you take notes - **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 - **Time Awareness**: Know exactly how long tasks take vs. your estimates
@@ -46,9 +46,33 @@ Focus Task brings the power of time-boxed task management directly into your Obs
- **Streak Counter**: Build momentum with consecutive productive days - **Streak Counter**: Build momentum with consecutive productive days
- **Time Comparison**: Compare estimated vs. actual time to improve planning - **Time Comparison**: Compare estimated vs. actual time to improve planning
- **Pomodoro Count**: Track total pomodoros completed - **Pomodoro Count**: Track total pomodoros completed
- **Daily Note Logging**: Automatically log completed tasks to your daily notes with timestamps and performance metrics
### 📈 Reporting & Analytics (New in v1.1.0!)
- **Comprehensive Reports**: View detailed productivity reports with date range filtering
- **Key Metrics Dashboard**: Track tasks done, tasks per day, hours per day, minutes per task, and day streaks
- **Visual Analytics**: Pie charts showing task distribution and time allocation
- **Time by List**: See how much time you spend on different task categories
- **Productivity Insights**: Discover your most productive hour, day, and month
- **Daily Breakdown**: Visual bar charts showing last 10 days of activity
- **Quick Filters**: Today, Last 7/30/90 days for easy report generation
### 🗓️ Task Scheduling & Reminders (New in v1.1.0!)
- **Schedule Tasks**: Set specific date and time for tasks
- **Smart Reminders**: Get notifications before task is due (5/10/15/30/60 minute options)
- **Overdue Detection**: Visual indicators (⚠️ red badge) for past-due tasks
- **Startup Checks**: Alerts when opening Obsidian if tasks are overdue
- **Background Monitoring**: Automatic 30-second checks for due tasks
- **Sound Alerts**: Optional audio notifications for reminders
### 📱 Mobile Optimized (New in v1.1.0!)
- **Responsive Design**: Fully optimized for mobile screens (tablets and phones)
- **Touch-Friendly**: Larger buttons and tap targets (44px minimum)
- **Adaptive Layouts**: Charts and visualizations scale appropriately
- **Mobile Testing**: Works great on Obsidian mobile app
### 🎨 User Experience ### 🎨 User Experience
- **Status Bar Timer**: timer that stays visible while you work - **Status Bar Timer**: Timer that stays visible while you work
- **Celebration Messages**: Fun, randomized messages when you complete tasks - **Celebration Messages**: Fun, randomized messages when you complete tasks
- **Sound Notifications**: Audio alerts for timer completion and task completion - **Sound Notifications**: Audio alerts for timer completion and task completion
- **Keyboard Shortcuts**: Quick access to common actions - **Keyboard Shortcuts**: Quick access to common actions
@@ -59,20 +83,23 @@ Focus Task brings the power of time-boxed task management directly into your Obs
### From Obsidian Community Plugins (Coming Soon) ### From Obsidian Community Plugins (Coming Soon)
1. Open Obsidian Settings 1. Open Obsidian Settings
2. Go to Community Plugins 2. Go to Community Plugins
3. Search for "Focus Task" 3. Search for "Immerse"
4. Click Install, then Enable 4. Click Install, then Enable
### Manual Installation ### Manual Installation
1. Download the latest release from the [releases page](https://git.cribdev.com/crib/focus-task/releases) 1. Download the latest release from the [releases page](https://git.cribdev.com/crib/immerse/releases)
2. Extract the files to your vault's `.obsidian/plugins/focus-task/` folder 2. Extract the files to your vault's `.obsidian/plugins/immerse/` folder
3. Reload Obsidian 3. **⚠️ IMPORTANT**: When updating, do NOT replace or delete the existing `data.json` file - this contains all your tasks, settings, and progress!
4. Enable the plugin in Settings → Community Plugins 4. Reload Obsidian
5. Enable the plugin in Settings → Community Plugins
> **Note**: Only copy the three plugin files (`main.js`, `manifest.json`, `styles.css`) when updating. Your `data.json` file stores all your tasks and settings and should never be replaced.
### Building from Source ### Building from Source
```bash ```bash
# Clone the repository # Clone the repository
git clone https://git.cribdev.com/crib/focus-task.git git clone https://git.cribdev.com/crib/immerse.git
cd focus-task cd immerse
# Install dependencies # Install dependencies
npm install npm install
@@ -81,14 +108,14 @@ npm install
npm run build npm run build
# Copy to your vault # Copy to your vault
cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/focus-task/ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/immerse/
``` ```
## 📖 Usage ## 📖 Usage
### Getting Started ### Getting Started
1. **Open Focus Task**: Click the ⚡ icon in the ribbon or use the command palette (`Ctrl/Cmd + P` → "Open Focus Task Panel") 1. **Open Immerse**: Click the ⚡ icon in the ribbon or use the command palette (`Ctrl/Cmd + P` → "Open Immerse Panel")
2. **Add a Task**: Click "+ Add Task" and fill in: 2. **Add a Task**: Click "+ Add Task" and fill in:
- Task description - Task description
@@ -103,7 +130,7 @@ cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/focus-
| Action | Command | | Action | Command |
|--------|---------| |--------|---------|
| Open Panel | `Ctrl/Cmd + P` → "Open Focus Task Panel" | | Open Panel | `Ctrl/Cmd + P` → "Open Immerse Panel" |
| Quick Add Task | `Ctrl/Cmd + P` → "Quick Add Task" | | Quick Add Task | `Ctrl/Cmd + P` → "Quick Add Task" |
| Toggle Timer | `Ctrl/Cmd + P` → "Toggle Timer" | | Toggle Timer | `Ctrl/Cmd + P` → "Toggle Timer" |
| Complete Task | `Ctrl/Cmd + P` → "Complete Current Task" | | Complete Task | `Ctrl/Cmd + P` → "Complete Current Task" |
@@ -136,7 +163,7 @@ Best for: Tracking time on open-ended tasks
| Short Break | Length of short breaks | 5 min | | Short Break | Length of short breaks | 5 min |
| Long Break | Length of long breaks | 15 min | | Long Break | Length of long breaks | 15 min |
| Long Break Interval | Pomodoros before a long break | 4 | | Long Break Interval | Pomodoros before a long break | 4 |
| Auto-start Breaks | Automatically start break timer | Off | | Auto-start Breaks | Automatically start break timer | On |
### General ### General
| Setting | Description | Default | | Setting | Description | Default |
@@ -146,6 +173,27 @@ Best for: Tracking time on open-ended tasks
| Enable Celebrations | Show celebration messages | On | | Enable Celebrations | Show celebration messages | On |
| Show Floating Timer | Display draggable timer widget | On | | Show Floating Timer | Display draggable timer widget | On |
### Daily Note Integration
| Setting | Description | Default |
|---------|-------------|---------|
| Log to Daily Note | Automatically log completed tasks to your daily note | Off |
When enabled, completed tasks are automatically appended to your daily note with:
- Task name and list category
- Time spent vs. estimated time
- Completion timestamp
- Performance indicator (under/over/on target)
**Example entry:**
```
- [x] Write project proposal | 💼 Work | ⏱️ 45min / 30min (15min over estimate) | ✅ 14:30
```
**Requirements:** The core "Daily Notes" plugin must be enabled in Obsidian settings. Immerse respects your Daily Notes configuration (folder, date format, and template).
### Lists ### Lists
Customize your task lists with: Customize your task lists with:
- Custom names - Custom names
@@ -157,18 +205,18 @@ Default lists: Work 💼, Personal 🏠, Learning 📚
## 🎨 Customization ## 🎨 Customization
### Adding Custom Lists ### Adding Custom Lists
1. Go to Settings → Focus Task → Lists 1. Go to Settings → Immerse → Lists
2. Click "+ Add List" 2. Click "+ Add List"
3. Set the name, emoji, and color 3. Set the name, emoji, and color
4. Click Save 4. Click Save
### Theming ### Theming
Focus Task respects your Obsidian theme and adapts to both light and dark modes automatically. Immerse respects your Obsidian theme and adapts to both light and dark modes automatically.
## 📁 Project Structure ## 📁 Project Structure
``` ```
focus-task/ immerse/
├── src/ ├── src/
│ ├── main.ts # Main plugin class │ ├── main.ts # Main plugin class
│ ├── types.ts # TypeScript interfaces │ ├── types.ts # TypeScript interfaces
@@ -193,7 +241,7 @@ Blitzit's approach to productivity resonated with me:
- Satisfying task completion experience - Satisfying task completion experience
- Progress insights - 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. I wanted to bring this experience directly into Obsidian, where I already manage my notes and knowledge. Immerse 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/)!** **If you're looking for a dedicated productivity app, I highly recommend checking out [Blitzit](https://www.blitzit.app/)!**
@@ -211,8 +259,8 @@ Contributions are welcome! Feel free to:
```bash ```bash
# Clone the repo # Clone the repo
git clone https://git.cribdev.com/crib/focus-task.git git clone https://git.cribdev.com/crib/immerse.git
cd focus-task cd immerse
# Install dependencies # Install dependencies
npm install npm install
@@ -221,7 +269,7 @@ npm install
npm run dev npm run dev
# Create symlink to your test vault # Create symlink to your test vault
ln -s $(pwd) /path/to/vault/.obsidian/plugins/focus-task ln -s $(pwd) /path/to/vault/.obsidian/plugins/immerse
``` ```
## 📜 License ## 📜 License
@@ -230,8 +278,8 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
## 🔗 Links ## 🔗 Links
- **Repository**: [https://git.cribdev.com/crib/focus-task](https://git.cribdev.com/crib/focus-task) - **Repository**: [https://git.cribdev.com/crib/immerse](https://git.cribdev.com/crib/immerse)
- **Issues**: [https://git.cribdev.com/crib/focus-task/issues](https://git.cribdev.com/crib/focus-task/issues) - **Issues**: [https://git.cribdev.com/crib/immerse/issues](https://git.cribdev.com/crib/immerse/issues)
- **Inspiration**: [Blitzit App](https://www.blitzit.app/) - **Inspiration**: [Blitzit App](https://www.blitzit.app/)
--- ---
@@ -240,4 +288,6 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
Made with ❤️ for the Obsidian community Made with ❤️ for the Obsidian community
<br> <br>
Inspired by <a href="https://www.blitzit.app/">Blitzit</a> ⚡ Inspired by <a href="https://www.blitzit.app/">Blitzit</a> ⚡
<br><br>
<em>✨ Vibe coded with assistance from <a href="https://claude.ai">Claude.ai</a></em>
</p> </p>

1033
main.js

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"id": "focus-task", "id": "immerse",
"name": "Focus Task", "name": "Immerse",
"version": "1.0.3", "version": "1.1.1",
"minAppVersion": "0.15.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.", "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", "author": "Crib",

62
package-release.mjs Normal file
View File

@@ -0,0 +1,62 @@
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const RELEASE_DIR = 'release';
const FILES_TO_INCLUDE = [
'main.js',
'manifest.json',
'styles.css'
];
async function packageRelease() {
try {
console.log('📦 Packaging release files...\n');
// Create release directory if it doesn't exist
try {
await fs.access(RELEASE_DIR);
console.log(`✓ Release directory exists: ${RELEASE_DIR}`);
} catch {
await fs.mkdir(RELEASE_DIR);
console.log(`✓ Created release directory: ${RELEASE_DIR}`);
}
// Copy each file
for (const file of FILES_TO_INCLUDE) {
const sourcePath = path.join(__dirname, file);
const destPath = path.join(__dirname, RELEASE_DIR, file);
try {
await fs.copyFile(sourcePath, destPath);
console.log(`✓ Copied ${file}`);
} catch (error) {
console.error(`✗ Failed to copy ${file}:`, error.message);
process.exit(1);
}
}
// Read manifest to get version
const manifestPath = path.join(__dirname, 'manifest.json');
const manifestContent = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestContent);
console.log(`\n✅ Release package created successfully!`);
console.log(`📁 Location: ./${RELEASE_DIR}/`);
console.log(`📌 Version: ${manifest.version}`);
console.log(`\nFiles included:`);
FILES_TO_INCLUDE.forEach(file => console.log(` - ${file}`));
console.log(`\n💡 Tip: You can now upload these files from the '${RELEASE_DIR}' directory to your Gitea release.`);
console.log(`💡 Tip: To create a zip, run: cd ${RELEASE_DIR} && zip -r ../immerse-${manifest.version}.zip *`);
} catch (error) {
console.error('❌ Error packaging release:', error);
process.exit(1);
}
}
packageRelease();

View File

@@ -1,11 +1,12 @@
{ {
"name": "focus-task", "name": "immerse",
"version": "1.0.3", "version": "1.1.1",
"description": "A Blitzit-inspired task management and focus timer plugin for Obsidian", "description": "A Blitzit-inspired task management and focus timer plugin for Obsidian",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"package": "npm run build && node package-release.mjs",
"version": "node version-bump.mjs && git add manifest.json versions.json" "version": "node version-bump.mjs && git add manifest.json versions.json"
}, },
"keywords": [ "keywords": [
@@ -21,7 +22,7 @@
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.cribdev.com/crib/focus-task" "url": "https://git.cribdev.com/crib/immerse"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^16.11.6", "@types/node": "^16.11.6",

View File

@@ -9,25 +9,26 @@ import {
} from 'obsidian'; } from 'obsidian';
import { import {
FocusTask, ImmerseTask,
FocusTaskSettings, ImmerseSettings,
FocusTaskData, ImmerseData,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
DEFAULT_DATA, DEFAULT_DATA,
VIEW_TYPE_FOCUS_TASK, VIEW_TYPE_IMMERSE,
CELEBRATION_MESSAGES, CELEBRATION_MESSAGES,
EARLY_FINISH_MESSAGES, EARLY_FINISH_MESSAGES,
OVERTIME_MESSAGES, OVERTIME_MESSAGES,
} from './types'; } from './types';
import { FocusTaskView } from './view'; import { ImmerseView } from './view';
import { ReportView, VIEW_TYPE_REPORT } from './reportView';
import { QuickAddTaskModal } from './modals'; import { QuickAddTaskModal } from './modals';
// ============ Main Plugin Class ============ // ============ Main Plugin Class ============
export default class FocusTaskPlugin extends Plugin { export default class ImmersePlugin extends Plugin {
settings: FocusTaskSettings; settings: ImmerseSettings;
data: FocusTaskData; data: ImmerseData;
// Timer state // Timer state
timerInterval: number | null = null; timerInterval: number | null = null;
@@ -36,34 +37,55 @@ export default class FocusTaskPlugin extends Plugin {
isBreakMode: boolean = false; isBreakMode: boolean = false;
activeTaskId: string | null = null; activeTaskId: string | null = null;
pomodoroCount: number = 0; pomodoroCount: number = 0;
// Timestamp-based tracking for reliable background timing
private timerStartTimestamp: number = 0;
private pausedTimeRemaining: number = 0;
// Focus time tracking (in seconds for accuracy) // Focus time tracking (in seconds for accuracy)
private focusSecondsToday: number = 0; private focusSecondsToday: number = 0;
private secondsWorkedOnCurrentTask: number = 0;
// Status bar element // Status bar element
statusBarEl: HTMLElement | null = null; statusBarEl: HTMLElement | null = null;
// Reminder system
private reminderCheckInterval: number | null = null;
private notifiedReminders: Set<string> = new Set(); // Track which reminders have been shown
async onload() { async onload() {
await this.loadAllData(); await this.loadAllData();
// Check and reset daily stats // Check and reset daily stats
this.checkDailyReset(); this.checkDailyReset();
// Register the main view // Handle visibility changes to sync timer when app comes back to foreground
document.addEventListener('visibilitychange', () => {
if (!document.hidden && this.isTimerRunning) {
this.syncTimerFromTimestamp();
}
});
// Register views
this.registerView( this.registerView(
VIEW_TYPE_FOCUS_TASK, VIEW_TYPE_IMMERSE,
(leaf) => new FocusTaskView(leaf, this) (leaf) => new ImmerseView(leaf, this)
);
this.registerView(
VIEW_TYPE_REPORT,
(leaf) => new ReportView(leaf, this)
); );
// Add ribbon icon // Add ribbon icon
this.addRibbonIcon('zap', 'Open Focus Task', () => { this.addRibbonIcon('zap', 'Open Immerse', () => {
this.activateView(); this.activateView();
}); });
// Add commands // Add commands
this.addCommand({ this.addCommand({
id: 'open-focus-task', id: 'open-immerse',
name: 'Open Focus Task Panel', name: 'Open Immerse Panel',
callback: () => this.activateView(), callback: () => this.activateView(),
}); });
@@ -91,21 +113,42 @@ export default class FocusTaskPlugin extends Plugin {
callback: () => this.completeActiveTask(), callback: () => this.completeActiveTask(),
}); });
this.addCommand({
id: 'view-reports',
name: 'View Reports',
callback: () => this.activateReportView(),
});
// Add settings tab // Add settings tab
this.addSettingTab(new FocusTaskSettingTab(this.app, this)); this.addSettingTab(new ImmerseSettingTab(this.app, this));
// Create status bar timer // Create status bar timer
this.createStatusBar(); this.createStatusBar();
// Start reminder checking system
if (this.settings.enableReminders) {
this.startReminderSystem();
}
} }
onunload() { onunload() {
this.stopTimer(); this.stopTimer();
this.stopReminderSystem();
} }
async loadAllData() { async loadAllData() {
const loaded = await this.loadData(); const loaded = await this.loadData();
// Merge loaded data with defaults (defaults first, then override with loaded)
// This ensures new fields get default values even for existing installs
this.data = Object.assign({}, DEFAULT_DATA, loaded?.data || {}); this.data = Object.assign({}, DEFAULT_DATA, loaded?.data || {});
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded?.settings || {}); this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded?.settings || {});
// Ensure lists array exists and has at least the default lists
if (!this.settings.lists || this.settings.lists.length === 0) {
this.settings.lists = DEFAULT_SETTINGS.lists;
}
// Initialize seconds from stored minutes // Initialize seconds from stored minutes
this.focusSecondsToday = (this.data.totalFocusMinutesToday || 0) * 60; this.focusSecondsToday = (this.data.totalFocusMinutesToday || 0) * 60;
} }
@@ -144,14 +187,14 @@ export default class FocusTaskPlugin extends Plugin {
const { workspace } = this.app; const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null; let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_FOCUS_TASK); const leaves = workspace.getLeavesOfType(VIEW_TYPE_IMMERSE);
if (leaves.length > 0) { if (leaves.length > 0) {
leaf = leaves[0]; leaf = leaves[0];
} else { } else {
leaf = workspace.getRightLeaf(false); leaf = workspace.getRightLeaf(false);
if (leaf) { if (leaf) {
await leaf.setViewState({ type: VIEW_TYPE_FOCUS_TASK, active: true }); await leaf.setViewState({ type: VIEW_TYPE_IMMERSE, active: true });
} }
} }
@@ -160,9 +203,29 @@ export default class FocusTaskPlugin extends Plugin {
} }
} }
async activateReportView() {
const { workspace } = this.app;
// Check if report view is already open
const existingLeaves = workspace.getLeavesOfType(VIEW_TYPE_REPORT);
if (existingLeaves.length > 0) {
// If already open, just reveal it
workspace.revealLeaf(existingLeaves[0]);
} else {
// Open in a new tab in the main area
const leaf = workspace.getLeaf('tab');
await leaf.setViewState({
type: VIEW_TYPE_REPORT,
active: true,
});
workspace.revealLeaf(leaf);
}
}
// ============ Task Management ============ // ============ Task Management ============
createTask(text: string, estimatedMinutes: number = this.settings.defaultEstimateMinutes, list: string = 'work'): FocusTask { createTask(text: string, estimatedMinutes: number = this.settings.defaultEstimateMinutes, list: string = 'work'): ImmerseTask {
return { return {
id: this.generateId(), id: this.generateId(),
text, text,
@@ -180,13 +243,13 @@ export default class FocusTaskPlugin extends Plugin {
return Date.now().toString(36) + Math.random().toString(36).substr(2); return Date.now().toString(36) + Math.random().toString(36).substr(2);
} }
addTask(task: FocusTask) { addTask(task: ImmerseTask) {
this.data.tasks.push(task); this.data.tasks.push(task);
this.saveAllData(); this.saveAllData();
this.refreshView(); this.refreshView();
} }
updateTask(taskId: string, updates: Partial<FocusTask>) { updateTask(taskId: string, updates: Partial<ImmerseTask>) {
const task = this.data.tasks.find(t => t.id === taskId); const task = this.data.tasks.find(t => t.id === taskId);
if (task) { if (task) {
Object.assign(task, updates); Object.assign(task, updates);
@@ -211,30 +274,36 @@ export default class FocusTaskPlugin extends Plugin {
task.completed = true; task.completed = true;
task.completedAt = Date.now(); task.completedAt = Date.now();
task.isActive = false; task.isActive = false;
this.data.completedToday++; this.data.completedToday++;
this.data.lastActiveDate = new Date().toDateString(); this.data.lastActiveDate = new Date().toDateString();
// Archive task for historical reporting
this.archiveCompletedTask(task);
// Update daily stats
this.updateDailyStats(task);
// Show celebration // Show celebration
if (this.settings.enableCelebrations) { if (this.settings.enableCelebrations) {
this.showCelebration(task); this.showCelebration(task);
} }
// Play sound // Play sound
if (this.settings.enableSounds) { if (this.settings.enableSounds) {
this.playCompletionSound(); this.playCompletionSound();
} }
// Log to daily note // Log to daily note
if (this.settings.logToDaily) { if (this.settings.logToDaily) {
this.logTaskToDailyNote(task); this.logTaskToDailyNote(task);
} }
if (this.activeTaskId === taskId) { if (this.activeTaskId === taskId) {
this.stopTimer(); this.stopTimer();
this.activeTaskId = null; this.activeTaskId = null;
} }
this.saveAllData(); this.saveAllData();
this.refreshView(); this.refreshView();
} }
@@ -248,14 +317,235 @@ export default class FocusTaskPlugin extends Plugin {
} }
} }
// ============ Data Archiving & Statistics ============
archiveCompletedTask(task: ImmerseTask) {
// Check if task was overdue
const wasOverdue = task.scheduledDate && task.scheduledTime &&
new Date(`${task.scheduledDate}T${task.scheduledTime}`).getTime() < (task.completedAt || Date.now());
const record: import('./types').CompletedTaskRecord = {
id: task.id,
text: task.text,
list: task.list,
estimatedMinutes: task.estimatedMinutes,
actualMinutes: task.actualMinutes,
createdAt: task.createdAt,
completedAt: task.completedAt || Date.now(),
scheduledDate: task.scheduledDate,
wasOverdue: wasOverdue || false,
};
this.data.completedTasksArchive.push(record);
}
updateDailyStats(task: ImmerseTask) {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
// Find or create today's stats
let todayStats = this.data.dailyStats.find(s => s.date === today);
if (!todayStats) {
todayStats = {
date: today,
tasksCompleted: 0,
totalMinutes: 0,
pomodorosCompleted: 0,
tasksByList: {},
minutesByList: {},
};
this.data.dailyStats.push(todayStats);
}
// Update stats
todayStats.tasksCompleted++;
todayStats.totalMinutes += task.actualMinutes;
todayStats.pomodorosCompleted = this.data.pomodorosCompleted;
// Update list-specific stats
todayStats.tasksByList[task.list] = (todayStats.tasksByList[task.list] || 0) + 1;
todayStats.minutesByList[task.list] = (todayStats.minutesByList[task.list] || 0) + task.actualMinutes;
// Keep only last 365 days of stats to prevent data bloat
if (this.data.dailyStats.length > 365) {
this.data.dailyStats.sort((a, b) => b.date.localeCompare(a.date));
this.data.dailyStats = this.data.dailyStats.slice(0, 365);
}
}
// ============ Report Generation ============
generateReport(filters: import('./types').ReportFilters): import('./types').ReportData {
const { startDate, endDate, listIds } = filters;
// Filter daily stats by date range
const filteredStats = this.data.dailyStats.filter(stat => {
return stat.date >= startDate && stat.date <= endDate;
});
// Filter completed tasks by date range and lists
const filteredTasks = this.data.completedTasksArchive.filter(task => {
const taskDate = new Date(task.completedAt).toISOString().split('T')[0];
const inDateRange = taskDate >= startDate && taskDate <= endDate;
const inList = !listIds || listIds.includes(task.list);
return inDateRange && inList;
});
// Calculate summary stats
const totalTasks = filteredTasks.length;
const totalMinutes = filteredTasks.reduce((sum, task) => sum + task.actualMinutes, 0);
const totalPomodoros = filteredStats.reduce((sum, stat) => sum + stat.pomodorosCompleted, 0);
// Calculate number of days with data
const daysWithData = filteredStats.length || 1; // Avoid division by zero
const tasksPerDay = totalTasks / daysWithData;
const hoursPerDay = (totalMinutes / 60) / daysWithData;
const minsPerTask = totalTasks > 0 ? totalMinutes / totalTasks : 0;
// Time breakdown by list
const timeByListMap: Record<string, { minutes: number; taskCount: number }> = {};
filteredTasks.forEach(task => {
if (!timeByListMap[task.list]) {
timeByListMap[task.list] = { minutes: 0, taskCount: 0 };
}
timeByListMap[task.list].minutes += task.actualMinutes;
timeByListMap[task.list].taskCount++;
});
const timeByList = this.settings.lists.map(list => {
const data = timeByListMap[list.id] || { minutes: 0, taskCount: 0 };
const percentage = totalMinutes > 0 ? (data.minutes / totalMinutes) * 100 : 0;
return {
listId: list.id,
listName: list.name,
listIcon: list.icon,
listColor: list.color,
minutes: data.minutes,
taskCount: data.taskCount,
percentage: Math.round(percentage * 10) / 10, // Round to 1 decimal
};
}).filter(item => item.minutes > 0); // Only show lists with data
// Daily breakdown
const dailyBreakdown = filteredStats.map(stat => ({
date: stat.date,
tasks: stat.tasksCompleted,
hours: Math.round((stat.totalMinutes / 60) * 10) / 10,
pomodoros: stat.pomodorosCompleted,
}));
// Productivity insights
const mostProductiveHour = this.calculateMostProductiveHour(filteredTasks);
const mostProductiveDay = this.calculateMostProductiveDay(filteredStats);
const mostProductiveMonth = this.calculateMostProductiveMonth(filteredStats);
return {
totalTasks,
totalMinutes,
totalPomodoros,
tasksPerDay: Math.round(tasksPerDay * 10) / 10,
hoursPerDay: Math.round(hoursPerDay * 10) / 10,
minsPerTask: Math.round(minsPerTask),
currentStreak: this.data.streak,
timeByList,
dailyBreakdown,
mostProductiveHour,
mostProductiveDay,
mostProductiveMonth,
};
}
calculateMostProductiveHour(tasks: import('./types').CompletedTaskRecord[]): number | undefined {
if (tasks.length === 0) return undefined;
const hourCounts: Record<number, number> = {};
tasks.forEach(task => {
const hour = new Date(task.completedAt).getHours();
hourCounts[hour] = (hourCounts[hour] || 0) + 1;
});
let maxHour = 0;
let maxCount = 0;
for (const [hour, count] of Object.entries(hourCounts)) {
if (count > maxCount) {
maxCount = count;
maxHour = parseInt(hour);
}
}
return maxCount > 0 ? maxHour : undefined;
}
calculateMostProductiveDay(stats: import('./types').DailyStats[]): string | undefined {
if (stats.length === 0) return undefined;
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const dayCounts: Record<string, number> = {};
stats.forEach(stat => {
const dayOfWeek = new Date(stat.date).getDay();
const dayName = dayNames[dayOfWeek];
dayCounts[dayName] = (dayCounts[dayName] || 0) + stat.tasksCompleted;
});
let maxDay = '';
let maxCount = 0;
for (const [day, count] of Object.entries(dayCounts)) {
if (count > maxCount) {
maxCount = count;
maxDay = day;
}
}
return maxCount > 0 ? maxDay : undefined;
}
calculateMostProductiveMonth(stats: import('./types').DailyStats[]): string | undefined {
if (stats.length === 0) return undefined;
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const monthCounts: Record<string, number> = {};
stats.forEach(stat => {
const month = new Date(stat.date).getMonth();
const monthName = monthNames[month];
monthCounts[monthName] = (monthCounts[monthName] || 0) + stat.tasksCompleted;
});
let maxMonth = '';
let maxCount = 0;
for (const [month, count] of Object.entries(monthCounts)) {
if (count > maxCount) {
maxCount = count;
maxMonth = month;
}
}
return maxCount > 0 ? maxMonth : undefined;
}
// ============ Timer Management ============ // ============ Timer Management ============
// Sync timer based on timestamp when app returns from background
syncTimerFromTimestamp() {
// Since all intervals now calculate from timestamps directly,
// we just need to trigger an update when coming back to foreground
if (!this.isTimerRunning) return;
// The interval will automatically calculate correct values on next tick
// Just update the display immediately to show current state
this.updateStatusBar();
this.updateTimerDisplay();
}
startTimer(taskId: string) { startTimer(taskId: string) {
const task = this.data.tasks.find(t => t.id === taskId); const task = this.data.tasks.find(t => t.id === taskId);
if (!task) return; if (!task) return;
// Stop any existing timer // Stop any existing timer, preserving actual time
this.stopTimer(); this.stopTimer(true);
// Set active task // Set active task
this.activeTaskId = taskId; this.activeTaskId = taskId;
@@ -263,6 +553,15 @@ export default class FocusTaskPlugin extends Plugin {
this.isBreakMode = false; this.isBreakMode = false;
this.currentTimerSeconds = 0; this.currentTimerSeconds = 0;
this.isTimerRunning = true; this.isTimerRunning = true;
this.secondsWorkedOnCurrentTask = task.actualMinutes * 60;
// Set timestamp for background tracking (stopwatch mode)
this.timerStartTimestamp = Date.now();
this.pausedTimeRemaining = 0; // 0 indicates stopwatch mode
// Store initial values
const initialSecondsWorked = this.secondsWorkedOnCurrentTask;
let alertShown = false;
// Full refresh to show the active task card // Full refresh to show the active task card
this.refreshView(); this.refreshView();
@@ -270,18 +569,29 @@ export default class FocusTaskPlugin extends Plugin {
// Start interval (count up mode - stopwatch) // Start interval (count up mode - stopwatch)
this.timerInterval = window.setInterval(() => { this.timerInterval = window.setInterval(() => {
this.currentTimerSeconds++; // Calculate elapsed time from timestamp
task.actualMinutes = Math.floor(this.currentTimerSeconds / 60); const now = Date.now();
const elapsedMs = now - this.timerStartTimestamp;
// Track focus time const elapsedSeconds = Math.floor(elapsedMs / 1000);
this.focusSecondsToday++;
// Update timer (count up)
this.currentTimerSeconds = elapsedSeconds;
// Update actual time worked
this.secondsWorkedOnCurrentTask = initialSecondsWorked + elapsedSeconds;
task.actualMinutes = Math.floor(this.secondsWorkedOnCurrentTask / 60);
// Update focus time
const newFocusSeconds = Math.floor((this.data.totalFocusMinutesToday || 0) * 60) + elapsedSeconds;
this.focusSecondsToday = newFocusSeconds;
// Light update - only timer display, no full refresh // Light update - only timer display, no full refresh
this.updateStatusBar(); this.updateStatusBar();
this.updateTimerDisplay(); this.updateTimerDisplay();
// Check if over estimate // Check if over estimate (only alert once)
if (this.currentTimerSeconds === task.estimatedMinutes * 60) { if (!alertShown && this.currentTimerSeconds >= task.estimatedMinutes * 60) {
alertShown = true;
if (this.settings.enableSounds) { if (this.settings.enableSounds) {
this.playAlertSound(); this.playAlertSound();
} }
@@ -296,30 +606,51 @@ export default class FocusTaskPlugin extends Plugin {
const task = this.data.tasks.find(t => t.id === taskId); const task = this.data.tasks.find(t => t.id === taskId);
if (!task) return; if (!task) return;
this.stopTimer(); this.stopTimer(true);
this.activeTaskId = taskId; this.activeTaskId = taskId;
task.isActive = true; task.isActive = true;
this.isBreakMode = false; this.isBreakMode = false;
this.currentTimerSeconds = this.settings.pomodoroWorkMinutes * 60; this.currentTimerSeconds = this.settings.pomodoroWorkMinutes * 60;
this.isTimerRunning = true; this.isTimerRunning = true;
// Initialize from existing actual time to preserve progress across breaks
// Store as seconds for precision
this.secondsWorkedOnCurrentTask = Math.floor(task.actualMinutes * 60);
// Set timestamp for background tracking (pomodoro countdown mode)
this.timerStartTimestamp = Date.now();
this.pausedTimeRemaining = this.currentTimerSeconds;
// Store the initial seconds worked to calculate delta
const initialSecondsWorked = this.secondsWorkedOnCurrentTask;
// Full refresh to show the active task card // Full refresh to show the active task card
this.refreshView(); this.refreshView();
this.updateStatusBar(); this.updateStatusBar();
// Track seconds worked for accurate focus time
let secondsWorked = 0;
this.timerInterval = window.setInterval(() => { this.timerInterval = window.setInterval(() => {
this.currentTimerSeconds--; // Calculate elapsed time from timestamp (more accurate than counting ticks)
const now = Date.now();
const elapsedMs = now - this.timerStartTimestamp;
const elapsedSeconds = Math.floor(elapsedMs / 1000);
// Update countdown timer
this.currentTimerSeconds = Math.max(0, this.pausedTimeRemaining - elapsedSeconds);
if (!this.isBreakMode) { if (!this.isBreakMode) {
secondsWorked++; // Update actual time worked based on real elapsed time
task.actualMinutes = Math.floor(secondsWorked / 60); this.secondsWorkedOnCurrentTask = initialSecondsWorked + elapsedSeconds;
// Increment focus time by 1 second const actualMinutes = Math.floor(this.secondsWorkedOnCurrentTask / 60);
this.focusSecondsToday++;
if (task.actualMinutes !== actualMinutes) {
task.actualMinutes = actualMinutes;
}
// Update focus time based on elapsed seconds
const newFocusSeconds = Math.floor((this.data.totalFocusMinutesToday || 0) * 60) + elapsedSeconds;
this.focusSecondsToday = newFocusSeconds;
} }
// Light update - only timer display, no full refresh // Light update - only timer display, no full refresh
this.updateStatusBar(); this.updateStatusBar();
this.updateTimerDisplay(); this.updateTimerDisplay();
@@ -331,48 +662,75 @@ export default class FocusTaskPlugin extends Plugin {
} }
handlePomodoroEnd() { handlePomodoroEnd() {
// Stop the timer interval to prevent going into negative
if (this.timerInterval) {
window.clearInterval(this.timerInterval);
this.timerInterval = null;
}
// Set timer to 0 to ensure it doesn't show negative
this.currentTimerSeconds = 0;
this.isTimerRunning = false;
// Update displays immediately
this.updateStatusBar();
this.updateTimerDisplay();
if (!this.isBreakMode) { if (!this.isBreakMode) {
// Work session ended // Work session ended
this.pomodoroCount++; this.pomodoroCount++;
this.data.pomodorosCompleted++; this.data.pomodorosCompleted++;
if (this.settings.enableSounds) { if (this.settings.enableSounds) {
this.playAlertSound(); this.playAlertSound();
} }
new Notice('🍅 Pomodoro complete! Time for a break.'); new Notice('🍅 Pomodoro complete! Time for a break.');
if (this.settings.autoStartBreak) { if (this.settings.autoStartBreak) {
this.startBreak(); this.startBreak();
} else { } else {
this.stopTimer(); this.refreshView();
} }
} else { } else {
// Break ended // Break ended - keep timer at 0 until user resumes
if (this.settings.enableSounds) { if (this.settings.enableSounds) {
this.playAlertSound(); this.playAlertSound();
} }
new Notice('⚡ Break over! Ready to focus?'); new Notice('⚡ Break over! Ready to focus?');
this.isBreakMode = false;
this.stopTimer(); // Keep the break card visible with timer at 0:00
this.refreshView();
} }
this.saveAllData(); this.saveAllData();
} }
startBreak() { startBreak() {
this.isBreakMode = true; this.isBreakMode = true;
this.isTimerRunning = true;
const isLongBreak = this.pomodoroCount % this.settings.longBreakInterval === 0; const isLongBreak = this.pomodoroCount % this.settings.longBreakInterval === 0;
this.currentTimerSeconds = (isLongBreak ? this.settings.longBreakMinutes : this.settings.pomodoroBreakMinutes) * 60; this.currentTimerSeconds = (isLongBreak ? this.settings.longBreakMinutes : this.settings.pomodoroBreakMinutes) * 60;
// Set timestamp for background tracking (break countdown mode)
this.timerStartTimestamp = Date.now();
this.pausedTimeRemaining = this.currentTimerSeconds;
new Notice(isLongBreak ? '☕ Long break time!' : '☕ Short break time!'); new Notice(isLongBreak ? '☕ Long break time!' : '☕ Short break time!');
// Full refresh to show break state // Full refresh to show break state
this.refreshView(); this.refreshView();
if (!this.timerInterval) { if (!this.timerInterval) {
this.timerInterval = window.setInterval(() => { this.timerInterval = window.setInterval(() => {
this.currentTimerSeconds--; // Calculate elapsed time from timestamp
const now = Date.now();
const elapsedMs = now - this.timerStartTimestamp;
const elapsedSeconds = Math.floor(elapsedMs / 1000);
// Update countdown timer
this.currentTimerSeconds = Math.max(0, this.pausedTimeRemaining - elapsedSeconds);
// Light update - only timer display // Light update - only timer display
this.updateStatusBar(); this.updateStatusBar();
this.updateTimerDisplay(); this.updateTimerDisplay();
@@ -382,28 +740,45 @@ export default class FocusTaskPlugin extends Plugin {
} }
}, 1000); }, 1000);
} }
this.updateStatusBar(); this.updateStatusBar();
} }
toggleTimer() { toggleTimer() {
if (this.isTimerRunning && this.timerInterval) { if (this.isTimerRunning && this.timerInterval) {
// Pause // Pause - save current state
window.clearInterval(this.timerInterval); window.clearInterval(this.timerInterval);
this.timerInterval = null; this.timerInterval = null;
this.isTimerRunning = false; this.isTimerRunning = false;
this.pausedTimeRemaining = this.currentTimerSeconds;
} else if (this.activeTaskId) { } else if (this.activeTaskId) {
// Resume // Resume - restart with new timestamp
this.isTimerRunning = true; this.isTimerRunning = true;
this.timerStartTimestamp = Date.now();
const task = this.data.tasks.find(t => t.id === this.activeTaskId); const task = this.data.tasks.find(t => t.id === this.activeTaskId);
// Store initial values for resume
const initialSecondsWorked = this.secondsWorkedOnCurrentTask;
this.timerInterval = window.setInterval(() => { this.timerInterval = window.setInterval(() => {
this.currentTimerSeconds--; // Calculate elapsed time from timestamp
const now = Date.now();
const elapsedMs = now - this.timerStartTimestamp;
const elapsedSeconds = Math.floor(elapsedMs / 1000);
// Update timer (countdown from paused position)
this.currentTimerSeconds = Math.max(0, this.pausedTimeRemaining - elapsedSeconds);
if (task && !this.isBreakMode) { if (task && !this.isBreakMode) {
task.actualMinutes = Math.floor((this.settings.pomodoroWorkMinutes * 60 - this.currentTimerSeconds) / 60); // Update actual time worked
// Track focus time this.secondsWorkedOnCurrentTask = initialSecondsWorked + elapsedSeconds;
this.focusSecondsToday++; task.actualMinutes = Math.floor(this.secondsWorkedOnCurrentTask / 60);
// Update focus time
const newFocusSeconds = Math.floor((this.data.totalFocusMinutesToday || 0) * 60) + elapsedSeconds;
this.focusSecondsToday = newFocusSeconds;
} }
// Light update - only timer display // Light update - only timer display
this.updateStatusBar(); this.updateStatusBar();
this.updateTimerDisplay(); this.updateTimerDisplay();
@@ -415,27 +790,34 @@ export default class FocusTaskPlugin extends Plugin {
} else { } else {
new Notice('No active task. Select a task first.'); new Notice('No active task. Select a task first.');
} }
// Full refresh to update pause/resume button state // Full refresh to update pause/resume button state
this.updateStatusBar(); this.updateStatusBar();
this.refreshView(); this.refreshView();
} }
stopTimer() { stopTimer(preserveActualTime: boolean = false) {
if (this.timerInterval) { if (this.timerInterval) {
window.clearInterval(this.timerInterval); window.clearInterval(this.timerInterval);
this.timerInterval = null; this.timerInterval = null;
} }
if (this.activeTaskId) { if (this.activeTaskId) {
const task = this.data.tasks.find(t => t.id === this.activeTaskId); const task = this.data.tasks.find(t => t.id === this.activeTaskId);
if (task) { if (task) {
task.isActive = false; task.isActive = false;
// Only reset actual time when manually stopping (not when resuming after break)
if (!preserveActualTime) {
task.actualMinutes = 0;
}
} }
} }
this.isTimerRunning = false; this.isTimerRunning = false;
this.activeTaskId = null; this.activeTaskId = null;
this.secondsWorkedOnCurrentTask = 0;
this.timerStartTimestamp = 0;
this.pausedTimeRemaining = 0;
this.updateStatusBar(); this.updateStatusBar();
this.saveAllData(); this.saveAllData();
this.refreshView(); this.refreshView();
@@ -454,9 +836,9 @@ export default class FocusTaskPlugin extends Plugin {
createStatusBar() { createStatusBar() {
this.statusBarEl = this.addStatusBarItem(); this.statusBarEl = this.addStatusBarItem();
this.statusBarEl.addClass('focus-task-status-bar'); this.statusBarEl.addClass('immerse-status-bar');
this.updateStatusBar(); this.updateStatusBar();
// Click to open panel // Click to open panel
this.statusBarEl.addEventListener('click', () => { this.statusBarEl.addEventListener('click', () => {
this.activateView(); this.activateView();
@@ -471,18 +853,96 @@ export default class FocusTaskPlugin extends Plugin {
const taskName = this.isBreakMode ? '☕ Break' : (task?.text.substring(0, 20) || 'Task'); const taskName = this.isBreakMode ? '☕ Break' : (task?.text.substring(0, 20) || 'Task');
const timeStr = this.formatTime(this.currentTimerSeconds); const timeStr = this.formatTime(this.currentTimerSeconds);
const icon = this.isTimerRunning ? '▶' : '⏸'; const icon = this.isTimerRunning ? '▶' : '⏸';
this.statusBarEl.setText(`${icon} ${timeStr} - ${taskName}${task && task.text.length > 20 ? '...' : ''}`); this.statusBarEl.setText(`${icon} ${timeStr} - ${taskName}${task && task.text.length > 20 ? '...' : ''}`);
this.statusBarEl.addClass('focus-task-status-active'); this.statusBarEl.addClass('immerse-status-active');
} else { } else {
this.statusBarEl.setText('⚡ Focus Task'); this.statusBarEl.setText('⚡ Immerse');
this.statusBarEl.removeClass('focus-task-status-active'); this.statusBarEl.removeClass('immerse-status-active');
}
}
// ============ Reminder System ============
startReminderSystem() {
// Check for reminders every 30 seconds
this.reminderCheckInterval = window.setInterval(() => {
this.checkReminders();
}, 30000);
// Also check immediately
this.checkReminders();
}
stopReminderSystem() {
if (this.reminderCheckInterval) {
window.clearInterval(this.reminderCheckInterval);
this.reminderCheckInterval = null;
}
}
checkReminders() {
if (!this.settings.enableReminders) return;
const now = new Date();
const currentTime = now.getTime();
// Check each incomplete task with scheduling
this.data.tasks
.filter(task => !task.completed && task.scheduledDate && task.scheduledTime)
.forEach(task => {
const reminderKey = `${task.id}-${task.scheduledDate}-${task.scheduledTime}`;
// Skip if we already notified for this reminder
if (this.notifiedReminders.has(reminderKey)) return;
// Parse the scheduled date and time
const scheduledDateTime = new Date(`${task.scheduledDate}T${task.scheduledTime}`);
const scheduledTime = scheduledDateTime.getTime();
// Check for overdue tasks (scheduled time has passed)
if (currentTime > scheduledTime) {
this.showOverdueNotice(task);
this.notifiedReminders.add(reminderKey);
return;
}
// Check for tasks with reminders set
if (task.reminderMinutes) {
const reminderTime = scheduledTime - (task.reminderMinutes * 60 * 1000);
// Show reminder if it's past the reminder time but before the scheduled time
if (currentTime >= reminderTime) {
this.showReminder(task);
this.notifiedReminders.add(reminderKey);
}
}
});
}
showReminder(task: ImmerseTask) {
const timeStr = task.scheduledTime;
new Notice(`🔔 Reminder: "${task.text}" is scheduled for ${timeStr}`, 8000);
// Play alert sound if enabled
if (this.settings.enableSounds) {
this.playAlertSound();
}
}
showOverdueNotice(task: ImmerseTask) {
const dateStr = task.scheduledDate;
const timeStr = task.scheduledTime;
new Notice(`⚠️ Overdue: "${task.text}" was scheduled for ${dateStr} ${timeStr}`, 10000);
// Play alert sound if enabled
if (this.settings.enableSounds) {
this.playAlertSound();
} }
} }
// ============ Sounds & Celebrations ============ // ============ Sounds & Celebrations ============
showCelebration(task: FocusTask) { showCelebration(task: ImmerseTask) {
let messages = CELEBRATION_MESSAGES; let messages = CELEBRATION_MESSAGES;
let extraMessage = ''; let extraMessage = '';
@@ -549,7 +1009,7 @@ export default class FocusTaskPlugin extends Plugin {
// ============ Daily Note Logging ============ // ============ Daily Note Logging ============
async logTaskToDailyNote(task: FocusTask) { async logTaskToDailyNote(task: ImmerseTask) {
try { try {
const list = this.settings.lists.find(l => l.id === task.list); const list = this.settings.lists.find(l => l.id === task.list);
@@ -720,9 +1180,9 @@ export default class FocusTaskPlugin extends Plugin {
} }
refreshView() { refreshView() {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_FOCUS_TASK); const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_IMMERSE);
leaves.forEach(leaf => { leaves.forEach(leaf => {
if (leaf.view instanceof FocusTaskView) { if (leaf.view instanceof ImmerseView) {
leaf.view.refresh(); leaf.view.refresh();
} }
}); });
@@ -730,23 +1190,23 @@ export default class FocusTaskPlugin extends Plugin {
// Light refresh - only updates timer display without rebuilding DOM // Light refresh - only updates timer display without rebuilding DOM
updateTimerDisplay() { updateTimerDisplay() {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_FOCUS_TASK); const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_IMMERSE);
leaves.forEach(leaf => { leaves.forEach(leaf => {
if (leaf.view instanceof FocusTaskView) { if (leaf.view instanceof ImmerseView) {
leaf.view.updateTimerDisplay(); leaf.view.updateTimerDisplay();
} }
}); });
} }
getTasksByList(listId: string): FocusTask[] { getTasksByList(listId: string): ImmerseTask[] {
return this.data.tasks.filter(t => t.list === listId); return this.data.tasks.filter(t => t.list === listId);
} }
getPendingTasks(): FocusTask[] { getPendingTasks(): ImmerseTask[] {
return this.data.tasks.filter(t => !t.completed); return this.data.tasks.filter(t => !t.completed);
} }
getTodaysTasks(): FocusTask[] { getTodaysTasks(): ImmerseTask[] {
const today = new Date().toDateString(); const today = new Date().toDateString();
return this.data.tasks.filter(t => { return this.data.tasks.filter(t => {
if (t.scheduledDate === today) return true; if (t.scheduledDate === today) return true;
@@ -777,10 +1237,10 @@ export default class FocusTaskPlugin extends Plugin {
// ============ Settings Tab ============ // ============ Settings Tab ============
class FocusTaskSettingTab extends PluginSettingTab { class ImmerseSettingTab extends PluginSettingTab {
plugin: FocusTaskPlugin; plugin: ImmersePlugin;
constructor(app: App, plugin: FocusTaskPlugin) { constructor(app: App, plugin: ImmersePlugin) {
super(app, plugin); super(app, plugin);
this.plugin = plugin; this.plugin = plugin;
} }
@@ -789,7 +1249,7 @@ class FocusTaskSettingTab extends PluginSettingTab {
const { containerEl } = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
containerEl.createEl('h1', { text: '⚡ Focus Task Settings' }); containerEl.createEl('h1', { text: '⚡ Immerse Settings' });
// Pomodoro Settings // Pomodoro Settings
containerEl.createEl('h2', { text: '🍅 Pomodoro Timer' }); containerEl.createEl('h2', { text: '🍅 Pomodoro Timer' });
@@ -965,14 +1425,14 @@ class FocusTaskSettingTab extends PluginSettingTab {
// About section // About section
containerEl.createEl('h2', { text: '📖 About' }); containerEl.createEl('h2', { text: '📖 About' });
const aboutDiv = containerEl.createDiv({ cls: 'focus-task-about' }); const aboutDiv = containerEl.createDiv({ cls: 'immerse-about' });
aboutDiv.innerHTML = ` aboutDiv.innerHTML = `
<p><strong>Focus Task</strong> is heavily inspired by <a href="https://www.blitzit.app/">Blitzit</a>, <p><strong>Immerse</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> 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, <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> use the Pomodoro technique, and track your productivity without leaving your notes.</p>
<p> <p>
<a href="https://git.cribdev.com/crib/focus-task">Source Code</a> <a href="https://git.cribdev.com/crib/immerse">Source Code</a>
</p> </p>
`; `;
} }

View File

@@ -5,21 +5,25 @@ import {
Setting, Setting,
} from 'obsidian'; } from 'obsidian';
import { FocusTask } from './types'; import { ImmerseTask } from './types';
import FocusTaskPlugin from './main'; import ImmersePlugin from './main';
// ============ Quick Add Task Modal ============ // ============ Quick Add Task Modal ============
export class QuickAddTaskModal extends Modal { export class QuickAddTaskModal extends Modal {
plugin: FocusTaskPlugin; plugin: ImmersePlugin;
taskText: string = ''; taskText: string = '';
estimatedMinutes: number; estimatedMinutes: number;
selectedList: string = 'work'; selectedList: string = 'work';
scheduledDate: string = '';
scheduledTime: string = '';
reminderMinutes: number = 0;
constructor(app: App, plugin: FocusTaskPlugin) { constructor(app: App, plugin: ImmersePlugin) {
super(app); super(app);
this.plugin = plugin; this.plugin = plugin;
this.estimatedMinutes = plugin.settings.defaultEstimateMinutes; this.estimatedMinutes = plugin.settings.defaultEstimateMinutes;
this.reminderMinutes = plugin.settings.defaultReminderMinutes;
if (plugin.settings.lists.length > 0) { if (plugin.settings.lists.length > 0) {
this.selectedList = plugin.settings.lists[0].id; this.selectedList = plugin.settings.lists[0].id;
} }
@@ -27,7 +31,7 @@ export class QuickAddTaskModal extends Modal {
onOpen() { onOpen() {
const { contentEl } = this; const { contentEl } = this;
contentEl.addClass('focus-task-modal'); contentEl.addClass('immerse-modal');
contentEl.createEl('h2', { text: '⚡ Add New Task' }); contentEl.createEl('h2', { text: '⚡ Add New Task' });
@@ -82,19 +86,68 @@ export class QuickAddTaskModal extends Modal {
dropdown.onChange(value => this.selectedList = value); dropdown.onChange(value => this.selectedList = value);
}); });
// Scheduled date
new Setting(contentEl)
.setName('📅 Scheduled Date')
.setDesc('Optional: When do you plan to work on this?')
.addText(text => {
text.setPlaceholder('YYYY-MM-DD')
.setValue(this.scheduledDate)
.onChange(value => this.scheduledDate = value);
text.inputEl.type = 'date';
});
// Scheduled time
new Setting(contentEl)
.setName('⏰ Scheduled Time')
.setDesc('Optional: What time?')
.addText(text => {
text.setPlaceholder('HH:mm')
.setValue(this.scheduledTime)
.onChange(value => this.scheduledTime = value);
text.inputEl.type = 'time';
});
// Reminder
if (this.plugin.settings.enableReminders) {
new Setting(contentEl)
.setName('🔔 Reminder')
.setDesc('Remind me before the scheduled time')
.addDropdown(dropdown => {
dropdown.addOption('0', 'No reminder');
dropdown.addOption('5', '5 minutes before');
dropdown.addOption('10', '10 minutes before');
dropdown.addOption('15', '15 minutes before');
dropdown.addOption('30', '30 minutes before');
dropdown.addOption('60', '1 hour before');
dropdown.setValue(this.reminderMinutes.toString());
dropdown.onChange(value => this.reminderMinutes = parseInt(value));
});
}
// Buttons // Buttons
const buttonContainer = contentEl.createEl('div', { cls: 'focus-task-modal-buttons' }); const buttonContainer = contentEl.createEl('div', { cls: 'immerse-modal-buttons' });
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'focus-task-btn' }); const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'immerse-btn' });
cancelBtn.addEventListener('click', () => this.close()); cancelBtn.addEventListener('click', () => this.close());
const addBtn = buttonContainer.createEl('button', { text: 'Add Task', cls: 'focus-task-btn focus-task-btn-primary' }); const addBtn = buttonContainer.createEl('button', { text: 'Add Task', cls: 'immerse-btn immerse-btn-primary' });
addBtn.addEventListener('click', () => this.submitTask()); addBtn.addEventListener('click', () => this.submitTask());
} }
submitTask() { submitTask() {
if (this.taskText.trim()) { if (this.taskText.trim()) {
const task = this.plugin.createTask(this.taskText, this.estimatedMinutes, this.selectedList); const task = this.plugin.createTask(this.taskText, this.estimatedMinutes, this.selectedList);
// Add scheduling data if provided
if (this.scheduledDate) {
task.scheduledDate = this.scheduledDate;
}
if (this.scheduledTime) {
task.scheduledTime = this.scheduledTime;
}
if (this.reminderMinutes > 0 && this.scheduledDate && this.scheduledTime) {
task.reminderMinutes = this.reminderMinutes;
}
this.plugin.addTask(task); this.plugin.addTask(task);
new Notice('✅ Task added!'); new Notice('✅ Task added!');
this.close(); this.close();
@@ -112,10 +165,10 @@ export class QuickAddTaskModal extends Modal {
// ============ Edit Task Modal ============ // ============ Edit Task Modal ============
export class EditTaskModal extends Modal { export class EditTaskModal extends Modal {
plugin: FocusTaskPlugin; plugin: ImmersePlugin;
task: FocusTask; task: ImmerseTask;
constructor(app: App, plugin: FocusTaskPlugin, task: FocusTask) { constructor(app: App, plugin: ImmersePlugin, task: ImmerseTask) {
super(app); super(app);
this.plugin = plugin; this.plugin = plugin;
this.task = { ...task }; this.task = { ...task };
@@ -123,7 +176,7 @@ export class EditTaskModal extends Modal {
onOpen() { onOpen() {
const { contentEl } = this; const { contentEl } = this;
contentEl.addClass('focus-task-modal'); contentEl.addClass('immerse-modal');
contentEl.createEl('h2', { text: '✏️ Edit Task' }); contentEl.createEl('h2', { text: '✏️ Edit Task' });
@@ -177,6 +230,48 @@ export class EditTaskModal extends Modal {
textarea.inputEl.rows = 4; textarea.inputEl.rows = 4;
}); });
// Scheduled date
new Setting(contentEl)
.setName('📅 Scheduled Date')
.setDesc('Optional: When do you plan to work on this?')
.addText(text => {
text.setPlaceholder('YYYY-MM-DD')
.setValue(this.task.scheduledDate || '')
.onChange(value => this.task.scheduledDate = value || undefined);
text.inputEl.type = 'date';
});
// Scheduled time
new Setting(contentEl)
.setName('⏰ Scheduled Time')
.setDesc('Optional: What time?')
.addText(text => {
text.setPlaceholder('HH:mm')
.setValue(this.task.scheduledTime || '')
.onChange(value => this.task.scheduledTime = value || undefined);
text.inputEl.type = 'time';
});
// Reminder
if (this.plugin.settings.enableReminders) {
new Setting(contentEl)
.setName('🔔 Reminder')
.setDesc('Remind me before the scheduled time')
.addDropdown(dropdown => {
dropdown.addOption('0', 'No reminder');
dropdown.addOption('5', '5 minutes before');
dropdown.addOption('10', '10 minutes before');
dropdown.addOption('15', '15 minutes before');
dropdown.addOption('30', '30 minutes before');
dropdown.addOption('60', '1 hour before');
dropdown.setValue((this.task.reminderMinutes || 0).toString());
dropdown.onChange(value => {
const minutes = parseInt(value);
this.task.reminderMinutes = minutes > 0 ? minutes : undefined;
});
});
}
// Show actual time if task has been worked on // Show actual time if task has been worked on
if (this.task.actualMinutes > 0) { if (this.task.actualMinutes > 0) {
new Setting(contentEl) new Setting(contentEl)
@@ -184,12 +279,12 @@ export class EditTaskModal extends Modal {
.setDesc(`You've worked on this task for ${this.plugin.formatTimeHuman(this.task.actualMinutes)}`); .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 buttonContainer = contentEl.createEl('div', { cls: 'immerse-modal-buttons' });
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'focus-task-btn' }); const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'immerse-btn' });
cancelBtn.addEventListener('click', () => this.close()); cancelBtn.addEventListener('click', () => this.close());
const saveBtn = buttonContainer.createEl('button', { text: 'Save', cls: 'focus-task-btn focus-task-btn-primary' }); const saveBtn = buttonContainer.createEl('button', { text: 'Save', cls: 'immerse-btn immerse-btn-primary' });
saveBtn.addEventListener('click', () => { saveBtn.addEventListener('click', () => {
this.plugin.updateTask(this.task.id, this.task); this.plugin.updateTask(this.task.id, this.task);
new Notice('✅ Task updated!'); new Notice('✅ Task updated!');
@@ -197,6 +292,257 @@ export class EditTaskModal extends Modal {
}); });
} }
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
// ============ Report Modal ============
export class ReportModal extends Modal {
plugin: ImmersePlugin;
startDate: string;
endDate: string;
selectedListIds: string[] = [];
constructor(app: App, plugin: ImmersePlugin) {
super(app);
this.plugin = plugin;
// Default to last 7 days
const today = new Date();
this.endDate = today.toISOString().split('T')[0];
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
this.startDate = weekAgo.toISOString().split('T')[0];
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('immerse-modal', 'immerse-report-modal');
contentEl.empty();
// Header
const header = contentEl.createEl('div', { cls: 'immerse-report-header' });
header.createEl('h2', { text: '📊 Reports', cls: 'immerse-report-title' });
// Filters section
this.renderFilters(contentEl);
// Generate button
const generateBtn = contentEl.createEl('button', {
text: '🔄 Generate Report',
cls: 'immerse-btn immerse-btn-primary immerse-report-generate-btn'
});
generateBtn.addEventListener('click', () => this.renderReport(contentEl));
// Initial report render
this.renderReport(contentEl);
}
renderFilters(container: Element) {
const filtersSection = container.createEl('div', { cls: 'immerse-report-filters' });
// Date range
const dateRow = filtersSection.createEl('div', { cls: 'immerse-report-filter-row' });
new Setting(dateRow)
.setName('Start Date')
.addText(text => {
text.setValue(this.startDate)
.onChange(value => this.startDate = value);
text.inputEl.type = 'date';
});
new Setting(dateRow)
.setName('End Date')
.addText(text => {
text.setValue(this.endDate)
.onChange(value => this.endDate = value);
text.inputEl.type = 'date';
});
// Quick filters
const quickFilters = filtersSection.createEl('div', { cls: 'immerse-report-quick-filters' });
quickFilters.createEl('span', { text: 'Quick select: ', cls: 'immerse-filter-label' });
const filters = [
{ label: 'Today', days: 0 },
{ label: 'Last 7 days', days: 7 },
{ label: 'Last 30 days', days: 30 },
{ label: 'Last 90 days', days: 90 },
];
filters.forEach(filter => {
const btn = quickFilters.createEl('button', {
text: filter.label,
cls: 'immerse-quick-filter-btn'
});
btn.addEventListener('click', () => {
const today = new Date();
this.endDate = today.toISOString().split('T')[0];
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - filter.days);
this.startDate = startDate.toISOString().split('T')[0];
this.renderReport(container);
});
});
}
renderReport(container: Element) {
// Remove old report if exists
const oldReport = container.querySelector('.immerse-report-content');
if (oldReport) oldReport.remove();
// Generate report data
const filters: import('./types').ReportFilters = {
startDate: this.startDate,
endDate: this.endDate,
listIds: this.selectedListIds.length > 0 ? this.selectedListIds : undefined,
};
const reportData = this.plugin.generateReport(filters);
// Create report content
const reportContent = container.createEl('div', { cls: 'immerse-report-content' });
// Check if we have data
if (reportData.totalTasks === 0) {
reportContent.createEl('div', {
text: 'No data available for the selected period. Complete some tasks to see your stats!',
cls: 'immerse-no-data-message'
});
return;
}
// Summary stats
this.renderSummaryStats(reportContent, reportData);
// Time by list (donut chart)
if (reportData.timeByList.length > 0) {
this.renderTimeByList(reportContent, reportData);
}
// Productivity insights
this.renderInsights(reportContent, reportData);
// Daily breakdown (bar chart - simplified text version)
if (reportData.dailyBreakdown.length > 0) {
this.renderDailyBreakdown(reportContent, reportData);
}
}
renderSummaryStats(container: Element, data: import('./types').ReportData) {
const statsGrid = container.createEl('div', { cls: 'immerse-stats-grid' });
const stats = [
{ label: 'TASKS DONE', value: data.totalTasks.toString(), icon: '✓' },
{ label: 'TASKS PER DAY', value: data.tasksPerDay.toFixed(1), icon: '📅' },
{ label: 'HOURS PER DAY', value: data.hoursPerDay.toFixed(1), icon: '⏰' },
{ label: 'MINS PER TASK', value: data.minsPerTask.toString(), icon: '⏱️' },
{ label: 'DAY STREAK', value: data.currentStreak.toString(), icon: '🔥' },
{ label: 'TOTAL HOURS', value: (data.totalMinutes / 60).toFixed(1), icon: '⌚' },
];
stats.forEach(stat => {
const statCard = statsGrid.createEl('div', { cls: 'immerse-stat-card' });
statCard.createEl('div', { text: stat.label, cls: 'immerse-stat-label' });
const valueRow = statCard.createEl('div', { cls: 'immerse-stat-value-row' });
valueRow.createEl('span', { text: stat.icon, cls: 'immerse-stat-icon' });
valueRow.createEl('span', { text: stat.value, cls: 'immerse-stat-value' });
});
}
renderTimeByList(container: Element, data: import('./types').ReportData) {
const section = container.createEl('div', { cls: 'immerse-report-section' });
section.createEl('h3', { text: 'Time by List', cls: 'immerse-report-section-title' });
const listContainer = section.createEl('div', { cls: 'immerse-time-by-list' });
data.timeByList.forEach(item => {
const listItem = listContainer.createEl('div', { cls: 'immerse-list-stat-item' });
// List info
const listInfo = listItem.createEl('div', { cls: 'immerse-list-info' });
listInfo.createEl('span', { text: item.listIcon, cls: 'immerse-list-icon' });
listInfo.createEl('span', { text: item.listName, cls: 'immerse-list-name' });
// Progress bar
const progressBar = listItem.createEl('div', { cls: 'immerse-list-progress' });
const progress = progressBar.createEl('div', { cls: 'immerse-list-progress-fill' });
progress.style.width = `${item.percentage}%`;
progress.style.backgroundColor = item.listColor;
// Stats
const stats = listItem.createEl('div', { cls: 'immerse-list-stats' });
stats.createEl('span', {
text: `${item.taskCount} tasks`,
cls: 'immerse-list-stat-text'
});
stats.createEl('span', {
text: `${this.plugin.formatTimeHuman(item.minutes)}`,
cls: 'immerse-list-stat-text'
});
stats.createEl('span', {
text: `${item.percentage.toFixed(1)}%`,
cls: 'immerse-list-stat-percentage'
});
});
}
renderInsights(container: Element, data: import('./types').ReportData) {
const section = container.createEl('div', { cls: 'immerse-report-section' });
section.createEl('h3', { text: 'Productivity Insights', cls: 'immerse-report-section-title' });
const insightsGrid = section.createEl('div', { cls: 'immerse-insights-grid' });
if (data.mostProductiveHour !== undefined) {
const card = insightsGrid.createEl('div', { cls: 'immerse-insight-card' });
card.createEl('div', { text: 'MOST PRODUCTIVE HOUR', cls: 'immerse-insight-label' });
card.createEl('div', {
text: `${data.mostProductiveHour}:00 - ${data.mostProductiveHour + 1}:00`,
cls: 'immerse-insight-value'
});
}
if (data.mostProductiveDay) {
const card = insightsGrid.createEl('div', { cls: 'immerse-insight-card' });
card.createEl('div', { text: 'MOST PRODUCTIVE DAY', cls: 'immerse-insight-label' });
card.createEl('div', { text: data.mostProductiveDay, cls: 'immerse-insight-value' });
}
if (data.mostProductiveMonth) {
const card = insightsGrid.createEl('div', { cls: 'immerse-insight-card' });
card.createEl('div', { text: 'MOST PRODUCTIVE MONTH', cls: 'immerse-insight-label' });
card.createEl('div', { text: data.mostProductiveMonth, cls: 'immerse-insight-value' });
}
}
renderDailyBreakdown(container: Element, data: import('./types').ReportData) {
const section = container.createEl('div', { cls: 'immerse-report-section' });
section.createEl('h3', { text: 'Daily Breakdown', cls: 'immerse-report-section-title' });
const table = section.createEl('table', { cls: 'immerse-daily-table' });
const thead = table.createEl('thead');
const headerRow = thead.createEl('tr');
headerRow.createEl('th', { text: 'Date' });
headerRow.createEl('th', { text: 'Tasks' });
headerRow.createEl('th', { text: 'Hours' });
headerRow.createEl('th', { text: 'Pomodoros' });
const tbody = table.createEl('tbody');
// Show last 14 days max
const recentData = data.dailyBreakdown.slice(-14);
recentData.forEach(day => {
const row = tbody.createEl('tr');
row.createEl('td', { text: day.date });
row.createEl('td', { text: day.tasks.toString() });
row.createEl('td', { text: day.hours.toFixed(1) });
row.createEl('td', { text: day.pomodoros.toString() });
});
}
onClose() { onClose() {
const { contentEl } = this; const { contentEl } = this;
contentEl.empty(); contentEl.empty();

394
src/reportView.ts Normal file
View File

@@ -0,0 +1,394 @@
import {
ItemView,
WorkspaceLeaf,
Setting,
} from 'obsidian';
import { ReportFilters, ReportData } from './types';
import ImmersePlugin from './main';
export const VIEW_TYPE_REPORT = 'immerse-report-view';
export class ReportView extends ItemView {
plugin: ImmersePlugin;
startDate: string;
endDate: string;
selectedListIds: string[] = [];
constructor(leaf: WorkspaceLeaf, plugin: ImmersePlugin) {
super(leaf);
this.plugin = plugin;
// Default to last 7 days
const today = new Date();
this.endDate = today.toISOString().split('T')[0];
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
this.startDate = weekAgo.toISOString().split('T')[0];
}
getViewType(): string {
return VIEW_TYPE_REPORT;
}
getDisplayText(): string {
return '📊 Reports';
}
getIcon(): string {
return 'bar-chart-2';
}
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass('immerse-report-view');
this.renderContent();
}
async onClose() {
// Nothing to clean up
}
renderContent() {
const container = this.containerEl.children[1];
container.empty();
// Header
const header = container.createEl('div', { cls: 'immerse-report-header' });
header.createEl('h2', { text: '📊 Reports', cls: 'immerse-report-title' });
// Filters section
this.renderFilters(container);
// Generate button
const generateBtn = container.createEl('button', {
text: '🔄 Generate Report',
cls: 'immerse-btn immerse-btn-primary immerse-report-generate-btn'
});
generateBtn.addEventListener('click', () => this.renderReport(container));
// Initial report render
this.renderReport(container);
}
renderFilters(container: Element) {
const filtersSection = container.createEl('div', { cls: 'immerse-report-filters' });
// Date range
const dateRow = filtersSection.createEl('div', { cls: 'immerse-report-filter-row' });
new Setting(dateRow)
.setName('Start Date')
.addText(text => {
text.setValue(this.startDate)
.onChange(value => this.startDate = value);
text.inputEl.type = 'date';
});
new Setting(dateRow)
.setName('End Date')
.addText(text => {
text.setValue(this.endDate)
.onChange(value => this.endDate = value);
text.inputEl.type = 'date';
});
// Quick filters
const quickFilters = filtersSection.createEl('div', { cls: 'immerse-report-quick-filters' });
quickFilters.createEl('span', { text: 'Quick select: ', cls: 'immerse-filter-label' });
const filters = [
{ label: 'Today', days: 0 },
{ label: 'Last 7 days', days: 7 },
{ label: 'Last 30 days', days: 30 },
{ label: 'Last 90 days', days: 90 },
];
filters.forEach(filter => {
const btn = quickFilters.createEl('button', {
text: filter.label,
cls: 'immerse-quick-filter-btn'
});
btn.addEventListener('click', () => {
const today = new Date();
this.endDate = today.toISOString().split('T')[0];
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - filter.days);
this.startDate = startDate.toISOString().split('T')[0];
this.renderReport(container);
});
});
}
renderReport(container: Element) {
// Remove old report if exists
const oldReport = container.querySelector('.immerse-report-content');
if (oldReport) oldReport.remove();
// Generate report data
const filters: ReportFilters = {
startDate: this.startDate,
endDate: this.endDate,
listIds: this.selectedListIds.length > 0 ? this.selectedListIds : undefined,
};
const reportData = this.plugin.generateReport(filters);
// Create report content
const reportContent = container.createEl('div', { cls: 'immerse-report-content' });
// Check if we have data
if (reportData.totalTasks === 0) {
reportContent.createEl('div', {
text: 'No data available for the selected period. Complete some tasks to see your stats!',
cls: 'immerse-no-data-message'
});
return;
}
// Summary stats
this.renderSummaryStats(reportContent, reportData);
// Time by list
if (reportData.timeByList.length > 0) {
this.renderTimeByList(reportContent, reportData);
}
// Productivity insights
this.renderInsights(reportContent, reportData);
// Daily breakdown
if (reportData.dailyBreakdown.length > 0) {
this.renderDailyBreakdown(reportContent, reportData);
}
}
renderSummaryStats(container: Element, data: ReportData) {
const statsGrid = container.createEl('div', { cls: 'immerse-stats-grid' });
const stats = [
{ label: 'TASKS DONE', value: data.totalTasks.toString(), icon: '✓' },
{ label: 'TASKS PER DAY', value: data.tasksPerDay.toFixed(1), icon: '📅' },
{ label: 'HOURS PER DAY', value: data.hoursPerDay.toFixed(1), icon: '⏰' },
{ label: 'MINS PER TASK', value: data.minsPerTask.toString(), icon: '⏱️' },
{ label: 'DAY STREAK', value: data.currentStreak.toString(), icon: '🔥' },
{ label: 'TOTAL HOURS', value: (data.totalMinutes / 60).toFixed(1), icon: '⌚' },
];
stats.forEach(stat => {
const statCard = statsGrid.createEl('div', { cls: 'immerse-stat-card' });
statCard.createEl('div', { text: stat.label, cls: 'immerse-stat-label' });
const valueRow = statCard.createEl('div', { cls: 'immerse-stat-value-row' });
valueRow.createEl('span', { text: stat.icon, cls: 'immerse-stat-icon' });
valueRow.createEl('span', { text: stat.value, cls: 'immerse-stat-value' });
});
}
renderTimeByList(container: Element, data: ReportData) {
const section = container.createEl('div', { cls: 'immerse-report-section' });
section.createEl('h3', { text: 'Time by List', cls: 'immerse-report-section-title' });
const listContainer = section.createEl('div', { cls: 'immerse-time-by-list' });
data.timeByList.forEach(item => {
const listItem = listContainer.createEl('div', { cls: 'immerse-list-stat-item' });
// List info
const listInfo = listItem.createEl('div', { cls: 'immerse-list-info' });
listInfo.createEl('span', { text: item.listIcon, cls: 'immerse-list-icon' });
listInfo.createEl('span', { text: item.listName, cls: 'immerse-list-name' });
// Progress bar
const progressBar = listItem.createEl('div', { cls: 'immerse-list-progress' });
const progress = progressBar.createEl('div', { cls: 'immerse-list-progress-fill' });
progress.style.width = `${item.percentage}%`;
progress.style.background = item.listColor;
// Stats
const stats = listItem.createEl('div', { cls: 'immerse-list-stats' });
stats.createEl('span', {
text: `${item.taskCount} tasks`,
cls: 'immerse-list-stat-text'
});
stats.createEl('span', {
text: `${this.plugin.formatTimeHuman(item.minutes)}`,
cls: 'immerse-list-stat-text'
});
stats.createEl('span', {
text: `${item.percentage.toFixed(1)}%`,
cls: 'immerse-list-stat-percentage'
});
});
}
renderInsights(container: Element, data: ReportData) {
const section = container.createEl('div', { cls: 'immerse-report-section' });
section.createEl('h3', { text: 'Productivity Insights', cls: 'immerse-report-section-title' });
const insightsGrid = section.createEl('div', { cls: 'immerse-insights-grid' });
if (data.mostProductiveHour !== undefined) {
const card = insightsGrid.createEl('div', { cls: 'immerse-insight-card' });
card.createEl('span', { text: '🕐', cls: 'immerse-insight-icon' });
card.createEl('span', { text: 'MOST PRODUCTIVE HOUR', cls: 'immerse-insight-label' });
card.createEl('span', {
text: `${data.mostProductiveHour}:00 - ${data.mostProductiveHour + 1}:00`,
cls: 'immerse-insight-value'
});
}
if (data.mostProductiveDay) {
const card = insightsGrid.createEl('div', { cls: 'immerse-insight-card' });
card.createEl('span', { text: '📅', cls: 'immerse-insight-icon' });
card.createEl('span', { text: 'MOST PRODUCTIVE DAY', cls: 'immerse-insight-label' });
card.createEl('span', { text: data.mostProductiveDay, cls: 'immerse-insight-value' });
}
if (data.mostProductiveMonth) {
const card = insightsGrid.createEl('div', { cls: 'immerse-insight-card' });
card.createEl('span', { text: '🗓️', cls: 'immerse-insight-icon' });
card.createEl('span', { text: 'MOST PRODUCTIVE MONTH', cls: 'immerse-insight-label' });
card.createEl('span', { text: data.mostProductiveMonth, cls: 'immerse-insight-value' });
}
}
renderPieChart(container: Element, data: ReportData) {
const pieContainer = container.createEl('div', { cls: 'immerse-daily-pie-container' });
// Calculate totals - normalize to minutes for fair comparison
const totalTasks = data.totalTasks;
const totalMinutes = data.totalMinutes;
const totalPomodoros = data.totalPomodoros;
// For pie chart, let's use minutes as the base unit
// Assume average task takes data.minsPerTask minutes
const taskMinutes = totalTasks * data.minsPerTask;
const pomodoroMinutes = totalPomodoros * 25; // Standard pomodoro is 25 minutes
const totalTime = taskMinutes + totalMinutes + pomodoroMinutes;
if (totalTime === 0) return;
// Calculate percentages based on time
const tasksPercent = (taskMinutes / totalTime) * 100;
const hoursPercent = (totalMinutes / totalTime) * 100;
const pomodorosPercent = (pomodoroMinutes / totalTime) * 100;
// Calculate degrees for conic gradient
const tasksDeg = (tasksPercent / 100) * 360;
const hoursDeg = tasksDeg + (hoursPercent / 100) * 360;
// Create pie chart with segments
const pieChart = pieContainer.createEl('div', { cls: 'immerse-daily-pie-chart' });
// Build conic-gradient string with explicit color stops
const gradient = `conic-gradient(from 0deg, #6366f1 0deg ${tasksDeg}deg, #22c55e ${tasksDeg}deg ${hoursDeg}deg, #f59e0b ${hoursDeg}deg 360deg)`;
pieChart.style.background = gradient;
// Center circle with total
const center = pieChart.createEl('div', { cls: 'immerse-daily-pie-center' });
center.createEl('div', { text: data.totalTasks.toString(), cls: 'immerse-daily-pie-center-value' });
center.createEl('div', { text: 'TOTAL TASKS', cls: 'immerse-daily-pie-center-label' });
// Legend
const legend = pieContainer.createEl('div', { cls: 'immerse-daily-pie-legend' });
// Tasks legend item
const tasksItem = legend.createEl('div', { cls: 'immerse-daily-pie-legend-item' });
const tasksColor = tasksItem.createEl('div', { cls: 'immerse-daily-pie-legend-color' });
tasksColor.style.background = '#6366f1';
const tasksInfo = tasksItem.createEl('div', { cls: 'immerse-daily-pie-legend-info' });
const tasksLabel = tasksInfo.createEl('div', { cls: 'immerse-daily-pie-legend-label' });
tasksLabel.createEl('span', { text: '✓' });
tasksLabel.appendText('Tasks Completed');
tasksInfo.createEl('div', { text: totalTasks.toString(), cls: 'immerse-daily-pie-legend-value' });
tasksInfo.createEl('div', { text: `${tasksPercent.toFixed(1)}%`, cls: 'immerse-daily-pie-legend-percentage' });
// Hours legend item
const hoursItem = legend.createEl('div', { cls: 'immerse-daily-pie-legend-item' });
const hoursColor = hoursItem.createEl('div', { cls: 'immerse-daily-pie-legend-color' });
hoursColor.style.background = '#22c55e';
const hoursInfo = hoursItem.createEl('div', { cls: 'immerse-daily-pie-legend-info' });
const hoursLabel = hoursInfo.createEl('div', { cls: 'immerse-daily-pie-legend-label' });
hoursLabel.createEl('span', { text: '⏱️' });
hoursLabel.appendText('Total Hours');
hoursInfo.createEl('div', { text: (totalMinutes / 60).toFixed(1), cls: 'immerse-daily-pie-legend-value' });
hoursInfo.createEl('div', { text: `${hoursPercent.toFixed(1)}%`, cls: 'immerse-daily-pie-legend-percentage' });
// Pomodoros legend item
const pomodorosItem = legend.createEl('div', { cls: 'immerse-daily-pie-legend-item' });
const pomodorosColor = pomodorosItem.createEl('div', { cls: 'immerse-daily-pie-legend-color' });
pomodorosColor.style.background = '#f59e0b';
const pomodorosInfo = pomodorosItem.createEl('div', { cls: 'immerse-daily-pie-legend-info' });
const pomodorosLabel = pomodorosInfo.createEl('div', { cls: 'immerse-daily-pie-legend-label' });
pomodorosLabel.createEl('span', { text: '🍅' });
pomodorosLabel.appendText('Pomodoros');
pomodorosInfo.createEl('div', { text: totalPomodoros.toString(), cls: 'immerse-daily-pie-legend-value' });
pomodorosInfo.createEl('div', { text: `${pomodorosPercent.toFixed(1)}%`, cls: 'immerse-daily-pie-legend-percentage' });
}
renderDailyBreakdown(container: Element, data: ReportData) {
const section = container.createEl('div', { cls: 'immerse-report-section' });
section.createEl('h3', { text: 'Daily Breakdown', cls: 'immerse-report-section-title' });
// Add pie chart for overall summary
this.renderPieChart(section, data);
const breakdownContainer = section.createEl('div', { cls: 'immerse-daily-breakdown-container' });
// Show last 10 days max
const recentData = data.dailyBreakdown.slice(-10);
// Find max values for scaling bars
const maxTasks = Math.max(...recentData.map(d => d.tasks), 1);
const maxHours = Math.max(...recentData.map(d => d.hours), 1);
const maxPomodoros = Math.max(...recentData.map(d => d.pomodoros), 1);
recentData.forEach(day => {
const row = breakdownContainer.createEl('div', { cls: 'immerse-daily-row' });
// Date column with formatted date
const dateEl = row.createEl('div', { cls: 'immerse-daily-date' });
const date = new Date(day.date + 'T00:00:00');
const dayName = date.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase();
const monthDay = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
dateEl.createEl('div', { text: dayName, cls: 'immerse-daily-date-day' });
dateEl.createEl('div', { text: monthDay, cls: 'immerse-daily-date-num' });
// Bars column
const barsContainer = row.createEl('div', { cls: 'immerse-daily-bars' });
// Tasks bar
const tasksRow = barsContainer.createEl('div', { cls: 'immerse-daily-bar-row' });
const tasksLabel = tasksRow.createEl('span', { cls: 'immerse-daily-bar-label' });
tasksLabel.createEl('span', { text: '✓', cls: 'immerse-daily-bar-icon' });
tasksLabel.appendText('Tasks');
const tasksTrack = tasksRow.createEl('div', { cls: 'immerse-daily-bar-track' });
const tasksFill = tasksTrack.createEl('div', { cls: 'immerse-daily-bar-fill tasks' });
tasksFill.style.width = `${(day.tasks / maxTasks) * 100}%`;
tasksRow.createEl('span', { text: day.tasks.toString(), cls: 'immerse-daily-bar-value' });
// Hours bar
const hoursRow = barsContainer.createEl('div', { cls: 'immerse-daily-bar-row' });
const hoursLabel = hoursRow.createEl('span', { cls: 'immerse-daily-bar-label' });
hoursLabel.createEl('span', { text: '⏱️', cls: 'immerse-daily-bar-icon' });
hoursLabel.appendText('Hours');
const hoursTrack = hoursRow.createEl('div', { cls: 'immerse-daily-bar-track' });
const hoursFill = hoursTrack.createEl('div', { cls: 'immerse-daily-bar-fill hours' });
hoursFill.style.width = `${(day.hours / maxHours) * 100}%`;
hoursRow.createEl('span', { text: day.hours.toFixed(1), cls: 'immerse-daily-bar-value' });
// Pomodoros bar
const pomodorosRow = barsContainer.createEl('div', { cls: 'immerse-daily-bar-row' });
const pomodorosLabel = pomodorosRow.createEl('span', { cls: 'immerse-daily-bar-label' });
pomodorosLabel.createEl('span', { text: '🍅', cls: 'immerse-daily-bar-icon' });
pomodorosLabel.appendText('Pomodoros');
const pomodorosTrack = pomodorosRow.createEl('div', { cls: 'immerse-daily-bar-track' });
const pomodorosFill = pomodorosTrack.createEl('div', { cls: 'immerse-daily-bar-fill pomodoros' });
pomodorosFill.style.width = `${(day.pomodoros / maxPomodoros) * 100}%`;
pomodorosRow.createEl('span', { text: day.pomodoros.toString(), cls: 'immerse-daily-bar-value' });
});
}
}

View File

@@ -1,6 +1,6 @@
// ============ Types & Interfaces ============ // ============ Types & Interfaces ============
export interface FocusTask { export interface ImmerseTask {
id: string; id: string;
text: string; text: string;
completed: boolean; completed: boolean;
@@ -10,7 +10,9 @@ export interface FocusTask {
completedAt?: number; completedAt?: number;
list: string; list: string;
notes: string; notes: string;
scheduledDate?: string; scheduledDate?: string; // Date in YYYY-MM-DD format
scheduledTime?: string; // Time in HH:mm format (24-hour)
reminderMinutes?: number; // Minutes before scheduled time to remind (0 = no reminder)
isActive: boolean; isActive: boolean;
} }
@@ -21,7 +23,7 @@ export interface TaskList {
icon: string; icon: string;
} }
export interface FocusTaskSettings { export interface ImmerseSettings {
pomodoroWorkMinutes: number; pomodoroWorkMinutes: number;
pomodoroBreakMinutes: number; pomodoroBreakMinutes: number;
longBreakMinutes: number; longBreakMinutes: number;
@@ -34,18 +36,47 @@ export interface FocusTaskSettings {
tickSoundEnabled: boolean; tickSoundEnabled: boolean;
// Daily note logging // Daily note logging
logToDaily: boolean; logToDaily: boolean;
// Task reminders
enableReminders: boolean;
defaultReminderMinutes: number; // Default minutes before task to remind
} }
export interface FocusTaskData { // Daily statistics snapshot
tasks: FocusTask[]; export interface DailyStats {
date: string; // YYYY-MM-DD format
tasksCompleted: number;
totalMinutes: number;
pomodorosCompleted: number;
tasksByList: Record<string, number>; // listId -> count
minutesByList: Record<string, number>; // listId -> minutes
}
// Archived completed task (for historical reporting)
export interface CompletedTaskRecord {
id: string;
text: string;
list: string;
estimatedMinutes: number;
actualMinutes: number;
createdAt: number;
completedAt: number;
scheduledDate?: string;
wasOverdue: boolean; // was it completed after scheduled time?
}
export interface ImmerseData {
tasks: ImmerseTask[];
completedToday: number; completedToday: number;
totalFocusMinutesToday: number; totalFocusMinutesToday: number;
streak: number; streak: number;
lastActiveDate: string; lastActiveDate: string;
pomodorosCompleted: number; pomodorosCompleted: number;
// Historical data for reporting
dailyStats: DailyStats[]; // Array of daily statistics
completedTasksArchive: CompletedTaskRecord[]; // All completed tasks history
} }
export const DEFAULT_SETTINGS: FocusTaskSettings = { export const DEFAULT_SETTINGS: ImmerseSettings = {
pomodoroWorkMinutes: 25, pomodoroWorkMinutes: 25,
pomodoroBreakMinutes: 5, pomodoroBreakMinutes: 5,
longBreakMinutes: 15, longBreakMinutes: 15,
@@ -58,22 +89,70 @@ export const DEFAULT_SETTINGS: FocusTaskSettings = {
{ id: 'personal', name: 'Personal', color: '#22c55e', icon: '🏠' }, { id: 'personal', name: 'Personal', color: '#22c55e', icon: '🏠' },
{ id: 'learning', name: 'Learning', color: '#f59e0b', icon: '📚' }, { id: 'learning', name: 'Learning', color: '#f59e0b', icon: '📚' },
], ],
autoStartBreak: false, autoStartBreak: true,
tickSoundEnabled: false, tickSoundEnabled: false,
// Daily note logging // Daily note logging
logToDaily: false, logToDaily: false,
// Task reminders
enableReminders: true,
defaultReminderMinutes: 15,
}; };
export const DEFAULT_DATA: FocusTaskData = { export const DEFAULT_DATA: ImmerseData = {
tasks: [], tasks: [],
completedToday: 0, completedToday: 0,
totalFocusMinutesToday: 0, totalFocusMinutesToday: 0,
streak: 0, streak: 0,
lastActiveDate: '', lastActiveDate: '',
pomodorosCompleted: 0, pomodorosCompleted: 0,
dailyStats: [],
completedTasksArchive: [],
}; };
export const VIEW_TYPE_FOCUS_TASK = 'focus-task-view'; export const VIEW_TYPE_IMMERSE = 'immerse-view';
// ============ Reporting Types ============
export interface ReportFilters {
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
listIds?: string[]; // Filter by specific lists (undefined = all)
}
export interface ReportData {
// Summary stats
totalTasks: number;
totalMinutes: number;
totalPomodoros: number;
tasksPerDay: number;
hoursPerDay: number;
minsPerTask: number;
currentStreak: number;
// Time breakdown by list
timeByList: Array<{
listId: string;
listName: string;
listIcon: string;
listColor: string;
minutes: number;
taskCount: number;
percentage: number;
}>;
// Daily breakdown for charts
dailyBreakdown: Array<{
date: string; // YYYY-MM-DD
tasks: number;
hours: number;
pomodoros: number;
}>;
// Productivity insights
mostProductiveHour?: number; // 0-23
mostProductiveDay?: string; // day name
mostProductiveMonth?: string; // month name
}
// ============ Celebration Messages ============ // ============ Celebration Messages ============

View File

@@ -3,33 +3,33 @@ import {
WorkspaceLeaf, WorkspaceLeaf,
} from 'obsidian'; } from 'obsidian';
import { VIEW_TYPE_FOCUS_TASK, FocusTask } from './types'; import { VIEW_TYPE_IMMERSE, ImmerseTask } from './types';
import { QuickAddTaskModal, EditTaskModal } from './modals'; import { QuickAddTaskModal, EditTaskModal } from './modals';
import FocusTaskPlugin from './main'; import ImmersePlugin from './main';
// ============ Main View ============ // ============ Main View ============
export class FocusTaskView extends ItemView { export class ImmerseView extends ItemView {
plugin: FocusTaskPlugin; plugin: ImmersePlugin;
currentFilter: string = 'all'; currentFilter: string = 'all';
// References to elements that need frequent updates // References to elements that need frequent updates
private timerTimeEl: HTMLElement | null = null; private timerTimeEl: HTMLElement | null = null;
private progressBarEl: HTMLElement | null = null; private progressBarEl: HTMLElement | null = null;
private actualTimeEl: HTMLElement | null = null; private actualTimeEl: HTMLElement | null = null;
private pauseBtnEl: HTMLElement | null = null; private pauseBtnEl: HTMLElement | null = null;
constructor(leaf: WorkspaceLeaf, plugin: FocusTaskPlugin) { constructor(leaf: WorkspaceLeaf, plugin: ImmersePlugin) {
super(leaf); super(leaf);
this.plugin = plugin; this.plugin = plugin;
} }
getViewType(): string { getViewType(): string {
return VIEW_TYPE_FOCUS_TASK; return VIEW_TYPE_IMMERSE;
} }
getDisplayText(): string { getDisplayText(): string {
return 'Focus Task'; return 'Immerse';
} }
getIcon(): string { getIcon(): string {
@@ -74,7 +74,7 @@ export class FocusTaskView extends ItemView {
refresh() { refresh() {
const container = this.containerEl.children[1]; const container = this.containerEl.children[1];
container.empty(); container.empty();
container.addClass('focus-task-container'); container.addClass('immerse-container');
// Reset element references // Reset element references
this.timerTimeEl = null; this.timerTimeEl = null;
@@ -96,18 +96,24 @@ export class FocusTaskView extends ItemView {
} }
renderHeader(container: Element) { renderHeader(container: Element) {
const header = container.createEl('div', { cls: 'focus-task-header' }); const header = container.createEl('div', { cls: 'immerse-header' });
const titleSection = header.createEl('div', { cls: 'focus-task-title-section' }); const titleSection = header.createEl('div', { cls: 'immerse-title-section' });
titleSection.createEl('h2', { text: '⚡ Focus Task', cls: 'focus-task-title' }); titleSection.createEl('h2', { text: '⚡ Immerse', cls: 'immerse-title' });
const today = new Date(); const today = new Date();
const dateStr = today.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }); const dateStr = today.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
titleSection.createEl('div', { text: dateStr, cls: 'focus-task-date' }); titleSection.createEl('div', { text: dateStr, cls: 'immerse-date' });
const actions = header.createEl('div', { cls: 'focus-task-header-actions' }); const actions = header.createEl('div', { cls: 'immerse-header-actions' });
const addBtn = actions.createEl('button', { cls: 'focus-task-btn focus-task-btn-primary' }); const reportsBtn = actions.createEl('button', { cls: 'immerse-btn' });
reportsBtn.innerHTML = '📊 Reports';
reportsBtn.addEventListener('click', () => {
this.plugin.activateReportView();
});
const addBtn = actions.createEl('button', { cls: 'immerse-btn immerse-btn-primary' });
addBtn.innerHTML = '+ Add Task'; addBtn.innerHTML = '+ Add Task';
addBtn.addEventListener('click', () => { addBtn.addEventListener('click', () => {
new QuickAddTaskModal(this.app, this.plugin).open(); new QuickAddTaskModal(this.app, this.plugin).open();
@@ -116,52 +122,54 @@ export class FocusTaskView extends ItemView {
renderStatsBar(container: Element) { renderStatsBar(container: Element) {
const stats = this.plugin.getStats(); const stats = this.plugin.getStats();
const statsBar = container.createEl('div', { cls: 'focus-task-stats-bar' }); const statsBar = container.createEl('div', { cls: 'immerse-stats-bar' });
const statItems = [ const statItems = [
{ label: 'Pending', value: stats.pendingCount.toString(), icon: '📋' }, { label: 'Pending', value: stats.pendingCount.toString(), icon: '📋' },
{ label: 'Done Today', value: stats.completedToday.toString(), icon: '✅' }, { label: 'Done Today', value: stats.completedToday.toString(), icon: '✅' },
{ label: 'Focus Time', value: this.plugin.formatTimeHuman(stats.totalFocusMinutesToday), icon: '⏱️' }, { label: 'Today\'s Focus', value: this.plugin.formatTimeHuman(stats.totalFocusMinutesToday), icon: '⏱️' },
{ label: 'Streak', value: `${stats.streak} days`, icon: '🔥' }, { label: 'Streak', value: `${stats.streak} days`, icon: '🔥' },
]; ];
statItems.forEach(stat => { statItems.forEach(stat => {
const item = statsBar.createEl('div', { cls: 'focus-task-stat-item' }); const item = statsBar.createEl('div', { cls: 'immerse-stat-item' });
item.createEl('div', { cls: 'focus-task-stat-icon', text: stat.icon }); item.createEl('div', { cls: 'immerse-stat-icon', text: stat.icon });
item.createEl('div', { cls: 'focus-task-stat-value', text: stat.value }); item.createEl('div', { cls: 'immerse-stat-value', text: stat.value });
item.createEl('div', { cls: 'focus-task-stat-label', text: stat.label }); item.createEl('div', { cls: 'immerse-stat-label', text: stat.label });
}); });
} }
renderActiveTask(container: Element) { renderActiveTask(container: Element) {
const activeSection = container.createEl('div', { cls: 'focus-task-active-section' }); const activeSection = container.createEl('div', { cls: 'immerse-active-section' });
if (this.plugin.activeTaskId) { if (this.plugin.activeTaskId) {
const task = this.plugin.data.tasks.find(t => t.id === this.plugin.activeTaskId); const task = this.plugin.data.tasks.find(t => t.id === this.plugin.activeTaskId);
if (task) { if (task) {
activeSection.addClass('focus-task-has-active'); activeSection.addClass('immerse-has-active');
const activeCard = activeSection.createEl('div', { cls: 'focus-task-active-card' }); const activeCard = activeSection.createEl('div', { cls: 'immerse-active-card' });
if (this.plugin.isBreakMode) { if (this.plugin.isBreakMode) {
activeCard.addClass('focus-task-break-card'); activeCard.addClass('immerse-break-card');
activeCard.createEl('div', { cls: 'focus-task-active-label', text: '☕ BREAK TIME' }); const breakLabel = this.plugin.currentTimerSeconds > 0 ? '☕ BREAK TIME' : '✨ BREAK COMPLETE';
activeCard.createEl('div', { cls: 'immerse-active-label', text: breakLabel });
} else { } else {
activeCard.createEl('div', { cls: 'focus-task-active-label', text: '🎯 FOCUSING ON' }); const workLabel = this.plugin.currentTimerSeconds > 0 ? '🎯 FOCUSING ON' : '🍅 POMODORO COMPLETE';
activeCard.createEl('div', { cls: 'immerse-active-label', text: workLabel });
} }
activeCard.createEl('div', { cls: 'focus-task-active-task-name', text: task.text }); activeCard.createEl('div', { cls: 'immerse-active-task-name', text: task.text });
// Timer display - store reference for updates // Timer display - store reference for updates
const timerDisplay = activeCard.createEl('div', { cls: 'focus-task-timer-display' }); const timerDisplay = activeCard.createEl('div', { cls: 'immerse-timer-display' });
this.timerTimeEl = timerDisplay.createEl('span', { this.timerTimeEl = timerDisplay.createEl('span', {
cls: 'focus-task-timer-time', cls: 'immerse-timer-time',
text: this.plugin.formatTime(this.plugin.currentTimerSeconds) text: this.plugin.formatTime(this.plugin.currentTimerSeconds)
}); });
// Progress bar - store reference for updates // Progress bar - store reference for updates
const progressWrap = activeCard.createEl('div', { cls: 'focus-task-progress-wrap' }); const progressWrap = activeCard.createEl('div', { cls: 'immerse-progress-wrap' });
this.progressBarEl = progressWrap.createEl('div', { cls: 'focus-task-progress-bar' }); this.progressBarEl = progressWrap.createEl('div', { cls: 'immerse-progress-bar' });
let progressPercent = 0; let progressPercent = 0;
if (this.plugin.isBreakMode) { if (this.plugin.isBreakMode) {
@@ -175,56 +183,104 @@ export class FocusTaskView extends ItemView {
} }
this.progressBarEl.style.width = `${Math.min(Math.max(progressPercent, 0), 100)}%`; this.progressBarEl.style.width = `${Math.min(Math.max(progressPercent, 0), 100)}%`;
if (progressPercent >= 100) this.progressBarEl.addClass('focus-task-overtime'); if (progressPercent >= 100) this.progressBarEl.addClass('immerse-overtime');
// Time info - store reference for actual time updates // Time info - store reference for actual time updates
if (!this.plugin.isBreakMode) { if (!this.plugin.isBreakMode) {
const timeInfo = activeCard.createEl('div', { cls: 'focus-task-time-info' }); const timeInfo = activeCard.createEl('div', { cls: 'immerse-time-info' });
timeInfo.createEl('span', { text: `Est: ${this.plugin.formatTimeHuman(task.estimatedMinutes)}` }); timeInfo.createEl('span', { text: `Est: ${this.plugin.formatTimeHuman(task.estimatedMinutes)}` });
this.actualTimeEl = timeInfo.createEl('span', { text: `Actual: ${this.plugin.formatTimeHuman(task.actualMinutes)}` }); this.actualTimeEl = timeInfo.createEl('span', { text: `Actual: ${this.plugin.formatTimeHuman(task.actualMinutes)}` });
} }
// Controls // Controls
const controls = activeCard.createEl('div', { cls: 'focus-task-active-controls' }); const controls = activeCard.createEl('div', { cls: 'immerse-active-controls' });
this.pauseBtnEl = controls.createEl('button', { cls: 'focus-task-btn focus-task-btn-secondary' });
this.pauseBtnEl.innerHTML = this.plugin.isTimerRunning ? '⏸ Pause' : '▶ Resume';
this.pauseBtnEl.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) { if (this.plugin.isBreakMode) {
const skipBreakBtn = controls.createEl('button', { cls: 'focus-task-btn' }); // Break mode controls
skipBreakBtn.innerHTML = '⏭ Skip Break'; if (this.plugin.currentTimerSeconds > 0) {
skipBreakBtn.addEventListener('click', () => { // Break is still counting down
this.plugin.isBreakMode = false; this.pauseBtnEl = controls.createEl('button', { cls: 'immerse-btn immerse-btn-secondary' });
this.plugin.stopTimer(); this.pauseBtnEl.innerHTML = this.plugin.isTimerRunning ? '⏸ Pause' : '▶ Resume';
this.refresh(); this.pauseBtnEl.addEventListener('click', () => this.plugin.toggleTimer());
});
const skipBreakBtn = controls.createEl('button', { cls: 'immerse-btn immerse-btn-primary' });
skipBreakBtn.innerHTML = '⏭ Skip Break';
skipBreakBtn.addEventListener('click', () => {
this.plugin.isBreakMode = false;
this.plugin.startPomodoro(task.id);
});
const stopBtn = controls.createEl('button', { cls: 'immerse-btn immerse-btn-danger' });
stopBtn.innerHTML = '✕ Stop';
stopBtn.addEventListener('click', () => {
this.plugin.isBreakMode = false;
this.plugin.stopTimer();
});
} else {
// Break timer finished - show resume button
const resumeWorkBtn = controls.createEl('button', { cls: 'immerse-btn immerse-btn-success' });
resumeWorkBtn.innerHTML = '▶ Resume Work';
resumeWorkBtn.addEventListener('click', () => {
this.plugin.isBreakMode = false;
this.plugin.startPomodoro(task.id);
});
const stopBtn = controls.createEl('button', { cls: 'immerse-btn immerse-btn-danger' });
stopBtn.innerHTML = '✕ Stop';
stopBtn.addEventListener('click', () => {
this.plugin.isBreakMode = false;
this.plugin.stopTimer();
});
}
} else {
// Work mode controls
if (this.plugin.currentTimerSeconds > 0) {
// Work session still running
this.pauseBtnEl = controls.createEl('button', { cls: 'immerse-btn immerse-btn-secondary' });
this.pauseBtnEl.innerHTML = this.plugin.isTimerRunning ? '⏸ Pause' : '▶ Resume';
this.pauseBtnEl.addEventListener('click', () => this.plugin.toggleTimer());
const completeBtn = controls.createEl('button', { cls: 'immerse-btn immerse-btn-success' });
completeBtn.innerHTML = '✓ Complete';
completeBtn.addEventListener('click', () => this.plugin.completeTask(task.id));
const stopBtn = controls.createEl('button', { cls: 'immerse-btn immerse-btn-danger' });
stopBtn.innerHTML = '✕ Stop';
stopBtn.addEventListener('click', () => this.plugin.stopTimer());
} else {
// Work session finished - show break and completion options
const startBreakBtn = controls.createEl('button', { cls: 'immerse-btn immerse-btn-secondary' });
startBreakBtn.innerHTML = '☕ Start Break';
startBreakBtn.addEventListener('click', () => this.plugin.startBreak());
const continueBtn = controls.createEl('button', { cls: 'immerse-btn immerse-btn-primary' });
continueBtn.innerHTML = '▶ Continue Working';
continueBtn.addEventListener('click', () => this.plugin.startPomodoro(task.id));
const completeBtn = controls.createEl('button', { cls: 'immerse-btn immerse-btn-success' });
completeBtn.innerHTML = '✓ Complete';
completeBtn.addEventListener('click', () => this.plugin.completeTask(task.id));
const stopBtn = controls.createEl('button', { cls: 'immerse-btn immerse-btn-danger' });
stopBtn.innerHTML = '✕ Stop';
stopBtn.addEventListener('click', () => this.plugin.stopTimer());
}
} }
} }
} else { } else {
// No active task - show start focus prompt // No active task - show start focus prompt
const startPrompt = activeSection.createEl('div', { cls: 'focus-task-start-prompt' }); const startPrompt = activeSection.createEl('div', { cls: 'immerse-start-prompt' });
startPrompt.createEl('div', { cls: 'focus-task-prompt-icon', text: '⚡' }); startPrompt.createEl('div', { cls: 'immerse-prompt-icon', text: '⚡' });
startPrompt.createEl('div', { cls: 'focus-task-prompt-text', text: 'Ready to focus?' }); startPrompt.createEl('div', { cls: 'immerse-prompt-text', text: 'Ready to focus?' });
startPrompt.createEl('div', { cls: 'focus-task-prompt-hint', text: 'Click ▶ on a task to start a Pomodoro session' }); startPrompt.createEl('div', { cls: 'immerse-prompt-hint', text: 'Click ▶ on a task to start a Pomodoro session' });
} }
} }
renderTaskList(container: Element) { renderTaskList(container: Element) {
const listSection = container.createEl('div', { cls: 'focus-task-list-section' }); const listSection = container.createEl('div', { cls: 'immerse-list-section' });
// Filters // Filters
const filters = listSection.createEl('div', { cls: 'focus-task-filters' }); const filters = listSection.createEl('div', { cls: 'immerse-filters' });
const filterOptions = [ const filterOptions = [
{ id: 'all', label: 'All Tasks' }, { id: 'all', label: 'All Tasks' },
@@ -235,7 +291,7 @@ export class FocusTaskView extends ItemView {
filterOptions.forEach(opt => { filterOptions.forEach(opt => {
const btn = filters.createEl('button', { const btn = filters.createEl('button', {
cls: `focus-task-filter-btn ${this.currentFilter === opt.id ? 'active' : ''}`, cls: `immerse-filter-btn ${this.currentFilter === opt.id ? 'active' : ''}`,
text: opt.label, text: opt.label,
}); });
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -245,7 +301,7 @@ export class FocusTaskView extends ItemView {
}); });
// Task items // Task items
const taskList = listSection.createEl('div', { cls: 'focus-task-task-list' }); const taskList = listSection.createEl('div', { cls: 'immerse-task-list' });
let tasks = this.plugin.data.tasks; let tasks = this.plugin.data.tasks;
@@ -265,24 +321,28 @@ export class FocusTaskView extends ItemView {
}); });
if (tasks.length === 0) { if (tasks.length === 0) {
const emptyState = taskList.createEl('div', { cls: 'focus-task-empty-state' }); const emptyState = taskList.createEl('div', { cls: 'immerse-empty-state' });
emptyState.createEl('div', { cls: 'focus-task-empty-icon', text: '📝' }); emptyState.createEl('div', { cls: 'immerse-empty-icon', text: '📝' });
emptyState.createEl('div', { cls: 'focus-task-empty-text', text: 'No tasks yet' }); emptyState.createEl('div', { cls: 'immerse-empty-text', text: 'No tasks yet' });
emptyState.createEl('div', { cls: 'focus-task-empty-hint', text: 'Add a task to get started!' }); emptyState.createEl('div', { cls: 'immerse-empty-hint', text: 'Add a task to get started!' });
} else { } else {
tasks.forEach(task => this.renderTaskItem(taskList, task)); tasks.forEach(task => this.renderTaskItem(taskList, task));
} }
} }
renderTaskItem(container: Element, task: FocusTask) { renderTaskItem(container: Element, task: ImmerseTask) {
const list = this.plugin.settings.lists.find(l => l.id === task.list); const list = this.plugin.settings.lists.find(l => l.id === task.list);
const taskEl = container.createEl('div', { // Check if task is overdue
cls: `focus-task-task-item ${task.completed ? 'completed' : ''} ${task.isActive ? 'active' : ''}` const isOverdue = !task.completed && task.scheduledDate && task.scheduledTime &&
new Date(`${task.scheduledDate}T${task.scheduledTime}`).getTime() < Date.now();
const taskEl = container.createEl('div', {
cls: `immerse-task-item ${task.completed ? 'completed' : ''} ${task.isActive ? 'active' : ''} ${isOverdue ? 'overdue' : ''}`
}); });
// Checkbox // Checkbox
const checkbox = taskEl.createEl('div', { cls: 'focus-task-checkbox' }); const checkbox = taskEl.createEl('div', { cls: 'immerse-checkbox' });
checkbox.innerHTML = task.completed ? '✓' : ''; checkbox.innerHTML = task.completed ? '✓' : '';
checkbox.style.borderColor = list?.color || '#6366f1'; checkbox.style.borderColor = list?.color || '#6366f1';
if (task.completed) { if (task.completed) {
@@ -297,37 +357,54 @@ export class FocusTaskView extends ItemView {
}); });
// Task content // Task content
const content = taskEl.createEl('div', { cls: 'focus-task-task-content' }); const content = taskEl.createEl('div', { cls: 'immerse-task-content' });
const taskHeader = content.createEl('div', { cls: 'focus-task-task-header' }); const taskHeader = content.createEl('div', { cls: 'immerse-task-header' });
taskHeader.createEl('span', { cls: 'focus-task-task-text', text: task.text }); taskHeader.createEl('span', { cls: 'immerse-task-text', text: task.text });
if (list) { if (list) {
const listBadge = taskHeader.createEl('span', { const listBadge = taskHeader.createEl('span', {
cls: 'focus-task-list-badge', cls: 'immerse-list-badge',
text: `${list.icon} ${list.name}`, text: `${list.icon} ${list.name}`,
}); });
listBadge.style.backgroundColor = list.color + '20'; listBadge.style.backgroundColor = list.color + '20';
listBadge.style.color = list.color; listBadge.style.color = list.color;
} }
const taskMeta = content.createEl('div', { cls: 'focus-task-task-meta' }); const taskMeta = content.createEl('div', { cls: 'immerse-task-meta' });
taskMeta.createEl('span', { text: `Est: ${this.plugin.formatTimeHuman(task.estimatedMinutes)}` }); taskMeta.createEl('span', { text: `Est: ${this.plugin.formatTimeHuman(task.estimatedMinutes)}` });
if (task.actualMinutes > 0) { if (task.actualMinutes > 0) {
const actualSpan = taskMeta.createEl('span'); const actualSpan = taskMeta.createEl('span');
actualSpan.setText(`Actual: ${this.plugin.formatTimeHuman(task.actualMinutes)}`); actualSpan.setText(`Actual: ${this.plugin.formatTimeHuman(task.actualMinutes)}`);
if (task.actualMinutes > task.estimatedMinutes) { if (task.actualMinutes > task.estimatedMinutes) {
actualSpan.addClass('focus-task-overtime-text'); actualSpan.addClass('immerse-overtime-text');
}
}
// Show scheduled date/time if set
if (task.scheduledDate) {
const scheduleSpan = taskMeta.createEl('span', {
cls: `immerse-schedule-badge ${isOverdue ? 'overdue' : ''}`
});
const dateStr = task.scheduledDate;
const timeStr = task.scheduledTime || '';
if (isOverdue) {
scheduleSpan.setText(`⚠️ OVERDUE: ${dateStr}${timeStr ? ' ' + timeStr : ''}`);
} else {
scheduleSpan.setText(`📅 ${dateStr}${timeStr ? ' ' + timeStr : ''}`);
}
if (task.reminderMinutes) {
scheduleSpan.title = `Reminder set for ${task.reminderMinutes} min before`;
} }
} }
// Actions // Actions
const actions = taskEl.createEl('div', { cls: 'focus-task-task-actions' }); const actions = taskEl.createEl('div', { cls: 'immerse-task-actions' });
if (!task.completed) { if (!task.completed) {
// Start pomodoro button // Start pomodoro button
const startBtn = actions.createEl('button', { cls: 'focus-task-task-btn', attr: { 'aria-label': 'Start Pomodoro' } }); const startBtn = actions.createEl('button', { cls: 'immerse-task-btn', attr: { 'aria-label': 'Start Pomodoro' } });
startBtn.innerHTML = '▶'; startBtn.innerHTML = '▶';
startBtn.addEventListener('click', (e) => { startBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -335,7 +412,7 @@ export class FocusTaskView extends ItemView {
}); });
// Stopwatch mode button // Stopwatch mode button
const stopwatchBtn = actions.createEl('button', { cls: 'focus-task-task-btn', attr: { 'aria-label': 'Start Stopwatch' } }); const stopwatchBtn = actions.createEl('button', { cls: 'immerse-task-btn', attr: { 'aria-label': 'Start Stopwatch' } });
stopwatchBtn.innerHTML = '⏱'; stopwatchBtn.innerHTML = '⏱';
stopwatchBtn.addEventListener('click', (e) => { stopwatchBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -344,7 +421,7 @@ export class FocusTaskView extends ItemView {
} }
// Edit button // Edit button
const editBtn = actions.createEl('button', { cls: 'focus-task-task-btn', attr: { 'aria-label': 'Edit' } }); const editBtn = actions.createEl('button', { cls: 'immerse-task-btn', attr: { 'aria-label': 'Edit' } });
editBtn.innerHTML = '✏️'; editBtn.innerHTML = '✏️';
editBtn.addEventListener('click', (e) => { editBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -352,7 +429,7 @@ export class FocusTaskView extends ItemView {
}); });
// Delete button // Delete button
const deleteBtn = actions.createEl('button', { cls: 'focus-task-task-btn focus-task-delete-btn', attr: { 'aria-label': 'Delete' } }); const deleteBtn = actions.createEl('button', { cls: 'immerse-task-btn immerse-delete-btn', attr: { 'aria-label': 'Delete' } });
deleteBtn.innerHTML = '🗑'; deleteBtn.innerHTML = '🗑';
deleteBtn.addEventListener('click', (e) => { deleteBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();

1009
styles.css

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,8 @@
{ {
"1.0.3": "0.15.0" "1.0.4": "0.15.0",
"1.0.5": "0.15.0",
"1.0.6": "0.15.0",
"1.0.7": "0.15.0",
"1.0.8": "0.15.0",
"1.0.9": "0.15.0"
} }