Initial commit
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Build output
|
||||||
|
main.js
|
||||||
|
*.js.map
|
||||||
|
|
||||||
|
# npm
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Obsidian
|
||||||
|
data.json
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Crib
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
254
README.md
Normal file
254
README.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# ⚡ Focus Task
|
||||||
|
|
||||||
|
A powerful task management and focus timer plugin for [Obsidian](https://obsidian.md), heavily inspired by [Blitzit](https://www.blitzit.app/).
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 🎯 Overview
|
||||||
|
|
||||||
|
Focus Task brings the power of time-boxed task management directly into your Obsidian vault. Plan your day, track time with the Pomodoro technique, and crush your tasks with satisfying checkoffs - all without leaving your notes.
|
||||||
|
|
||||||
|
### Why Focus Task?
|
||||||
|
|
||||||
|
- **Stay in Flow**: No need to switch between apps - manage tasks where you take notes
|
||||||
|
- **Time Awareness**: Know exactly how long tasks take vs. your estimates
|
||||||
|
- **Pomodoro Built-in**: Work in focused sprints with automatic break reminders
|
||||||
|
- **Satisfying Feedback**: Celebratory messages and sounds when you complete tasks
|
||||||
|
- **Visual Progress**: See your daily progress with stats and streaks
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
### 📋 Task Management
|
||||||
|
- Create tasks with time estimates
|
||||||
|
- Organize tasks into customizable lists (Work, Personal, Learning, etc.)
|
||||||
|
- Add notes and details to tasks
|
||||||
|
- Filter tasks by list, today's tasks, or completed items
|
||||||
|
- Drag-and-drop task reordering
|
||||||
|
|
||||||
|
### ⏱️ Dual Timer Modes
|
||||||
|
|
||||||
|
#### Pomodoro Timer
|
||||||
|
- Configurable work sessions (default: 25 minutes)
|
||||||
|
- Short breaks (default: 5 minutes)
|
||||||
|
- Long breaks after a set number of pomodoros (default: 15 minutes every 4 pomodoros)
|
||||||
|
- Auto-start break option
|
||||||
|
- Visual countdown with progress bar
|
||||||
|
|
||||||
|
#### Stopwatch Mode
|
||||||
|
- Free-form time tracking
|
||||||
|
- Alerts when you exceed your estimate
|
||||||
|
- Track actual time vs. estimated time
|
||||||
|
|
||||||
|
### 📊 Progress Tracking
|
||||||
|
- **Daily Stats**: See tasks completed, focus time, and more
|
||||||
|
- **Streak Counter**: Build momentum with consecutive productive days
|
||||||
|
- **Time Comparison**: Compare estimated vs. actual time to improve planning
|
||||||
|
- **Pomodoro Count**: Track total pomodoros completed
|
||||||
|
|
||||||
|
### 🎨 User Experience
|
||||||
|
- **Floating Timer Widget**: Draggable timer that stays visible while you work
|
||||||
|
- **Celebration Messages**: Fun, randomized messages when you complete tasks
|
||||||
|
- **Sound Notifications**: Audio alerts for timer completion and task completion
|
||||||
|
- **Keyboard Shortcuts**: Quick access to common actions
|
||||||
|
- **Responsive Design**: Works great in any panel size
|
||||||
|
|
||||||
|
## 🚀 Installation
|
||||||
|
|
||||||
|
### From Obsidian Community Plugins (Coming Soon)
|
||||||
|
1. Open Obsidian Settings
|
||||||
|
2. Go to Community Plugins
|
||||||
|
3. Search for "Focus Task"
|
||||||
|
4. Click Install, then Enable
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
1. Download the latest release from the [releases page](https://git.cribdev.com/crib/focus-task/releases)
|
||||||
|
2. Extract the files to your vault's `.obsidian/plugins/focus-task/` folder
|
||||||
|
3. Reload Obsidian
|
||||||
|
4. Enable the plugin in Settings → Community Plugins
|
||||||
|
|
||||||
|
### Building from Source
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://git.cribdev.com/crib/focus-task.git
|
||||||
|
cd focus-task
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build the plugin
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Copy to your vault
|
||||||
|
cp main.js manifest.json styles.css /path/to/your/vault/.obsidian/plugins/focus-task/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 Usage
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. **Open Focus Task**: Click the ⚡ icon in the ribbon or use the command palette (`Ctrl/Cmd + P` → "Open Focus Task Panel")
|
||||||
|
|
||||||
|
2. **Add a Task**: Click "+ Add Task" and fill in:
|
||||||
|
- Task description
|
||||||
|
- Time estimate
|
||||||
|
- List category
|
||||||
|
|
||||||
|
3. **Start Focusing**: Click ▶ on any task to start a Pomodoro session, or ⏱ for stopwatch mode
|
||||||
|
|
||||||
|
4. **Complete Tasks**: Check off tasks when done and enjoy the celebration!
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Action | Command |
|
||||||
|
|--------|---------|
|
||||||
|
| Open Panel | `Ctrl/Cmd + P` → "Open Focus Task Panel" |
|
||||||
|
| Quick Add Task | `Ctrl/Cmd + P` → "Quick Add Task" |
|
||||||
|
| Toggle Timer | `Ctrl/Cmd + P` → "Toggle Timer" |
|
||||||
|
| Complete Task | `Ctrl/Cmd + P` → "Complete Current Task" |
|
||||||
|
| Start Focus | `Ctrl/Cmd + P` → "Start Focus Mode on Next Task" |
|
||||||
|
|
||||||
|
### Timer Modes Explained
|
||||||
|
|
||||||
|
#### 🍅 Pomodoro Mode (▶ button)
|
||||||
|
Best for: Maintaining focus on challenging tasks
|
||||||
|
|
||||||
|
1. Timer counts down from your configured work duration
|
||||||
|
2. When time's up, you'll get a notification
|
||||||
|
3. Take a break (short or long, based on your settings)
|
||||||
|
4. Repeat until the task is complete
|
||||||
|
|
||||||
|
#### ⏱️ Stopwatch Mode (⏱ button)
|
||||||
|
Best for: Tracking time on open-ended tasks
|
||||||
|
|
||||||
|
1. Timer counts up from zero
|
||||||
|
2. Get alerted when you exceed your estimate
|
||||||
|
3. Stop whenever the task is complete
|
||||||
|
4. See exactly how long the task took
|
||||||
|
|
||||||
|
## ⚙️ Settings
|
||||||
|
|
||||||
|
### Pomodoro Timer
|
||||||
|
| Setting | Description | Default |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| Work Duration | Length of each work session | 25 min |
|
||||||
|
| Short Break | Length of short breaks | 5 min |
|
||||||
|
| Long Break | Length of long breaks | 15 min |
|
||||||
|
| Long Break Interval | Pomodoros before a long break | 4 |
|
||||||
|
| Auto-start Breaks | Automatically start break timer | Off |
|
||||||
|
|
||||||
|
### General
|
||||||
|
| Setting | Description | Default |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| Default Time Estimate | Default estimate for new tasks | 30 min |
|
||||||
|
| Enable Sounds | Play completion/alert sounds | On |
|
||||||
|
| Enable Celebrations | Show celebration messages | On |
|
||||||
|
| Show Floating Timer | Display draggable timer widget | On |
|
||||||
|
|
||||||
|
### Lists
|
||||||
|
Customize your task lists with:
|
||||||
|
- Custom names
|
||||||
|
- Emoji icons
|
||||||
|
- Color coding
|
||||||
|
|
||||||
|
Default lists: Work 💼, Personal 🏠, Learning 📚
|
||||||
|
|
||||||
|
## 🎨 Customization
|
||||||
|
|
||||||
|
### Adding Custom Lists
|
||||||
|
1. Go to Settings → Focus Task → Lists
|
||||||
|
2. Click "+ Add List"
|
||||||
|
3. Set the name, emoji, and color
|
||||||
|
4. Click Save
|
||||||
|
|
||||||
|
### Theming
|
||||||
|
Focus Task respects your Obsidian theme and adapts to both light and dark modes automatically.
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
focus-task/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.ts # Main plugin class
|
||||||
|
│ ├── types.ts # TypeScript interfaces
|
||||||
|
│ ├── view.ts # Main UI view
|
||||||
|
│ └── modals.ts # Task modals
|
||||||
|
├── styles.css # Plugin styles
|
||||||
|
├── manifest.json # Obsidian plugin manifest
|
||||||
|
├── package.json # npm configuration
|
||||||
|
├── tsconfig.json # TypeScript config
|
||||||
|
├── esbuild.config.mjs # Build configuration
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🙏 Inspiration & Credits
|
||||||
|
|
||||||
|
This plugin is **heavily inspired by [Blitzit](https://www.blitzit.app/)**, a fantastic standalone productivity app that combines task management with focused time tracking.
|
||||||
|
|
||||||
|
Blitzit's approach to productivity resonated with me:
|
||||||
|
- Simple, focused interface
|
||||||
|
- Time estimation and tracking
|
||||||
|
- Pomodoro technique integration
|
||||||
|
- Satisfying task completion experience
|
||||||
|
- Progress insights
|
||||||
|
|
||||||
|
I wanted to bring this experience directly into Obsidian, where I already manage my notes and knowledge. Focus Task is my attempt to capture the essence of what makes Blitzit great while leveraging Obsidian's powerful ecosystem.
|
||||||
|
|
||||||
|
**If you're looking for a dedicated productivity app, I highly recommend checking out [Blitzit](https://www.blitzit.app/)!**
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Feel free to:
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repo
|
||||||
|
git clone https://git.cribdev.com/crib/focus-task.git
|
||||||
|
cd focus-task
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development build (watches for changes)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Create symlink to your test vault
|
||||||
|
ln -s $(pwd) /path/to/vault/.obsidian/plugins/focus-task
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Roadmap
|
||||||
|
|
||||||
|
- [ ] Integration with Obsidian Tasks plugin
|
||||||
|
- [ ] Calendar view for scheduled tasks
|
||||||
|
- [ ] Weekly/Monthly reports
|
||||||
|
- [ ] Task templates
|
||||||
|
- [ ] Sync with external task managers
|
||||||
|
- [ ] Mobile optimizations
|
||||||
|
- [ ] Task dependencies
|
||||||
|
- [ ] Time blocking in daily notes
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## 🔗 Links
|
||||||
|
|
||||||
|
- **Repository**: [https://git.cribdev.com/crib/focus-task](https://git.cribdev.com/crib/focus-task)
|
||||||
|
- **Issues**: [https://git.cribdev.com/crib/focus-task/issues](https://git.cribdev.com/crib/focus-task/issues)
|
||||||
|
- **Inspiration**: [Blitzit App](https://www.blitzit.app/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Made with ❤️ for the Obsidian community
|
||||||
|
<br>
|
||||||
|
Inspired by <a href="https://www.blitzit.app/">Blitzit</a> ⚡
|
||||||
|
</p>
|
||||||
48
esbuild.config.mjs
Normal file
48
esbuild.config.mjs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import esbuild from "esbuild";
|
||||||
|
import process from "process";
|
||||||
|
import builtins from "builtin-modules";
|
||||||
|
|
||||||
|
const banner =
|
||||||
|
`/*
|
||||||
|
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||||
|
if you want to view the source, please visit the github repository of this plugin
|
||||||
|
*/
|
||||||
|
`;
|
||||||
|
|
||||||
|
const prod = (process.argv[2] === 'production');
|
||||||
|
|
||||||
|
const context = await esbuild.context({
|
||||||
|
banner: {
|
||||||
|
js: banner,
|
||||||
|
},
|
||||||
|
entryPoints: ['src/main.ts'],
|
||||||
|
bundle: true,
|
||||||
|
external: [
|
||||||
|
'obsidian',
|
||||||
|
'electron',
|
||||||
|
'@codemirror/autocomplete',
|
||||||
|
'@codemirror/collab',
|
||||||
|
'@codemirror/commands',
|
||||||
|
'@codemirror/language',
|
||||||
|
'@codemirror/lint',
|
||||||
|
'@codemirror/search',
|
||||||
|
'@codemirror/state',
|
||||||
|
'@codemirror/view',
|
||||||
|
'@lezer/common',
|
||||||
|
'@lezer/highlight',
|
||||||
|
'@lezer/lr',
|
||||||
|
...builtins],
|
||||||
|
format: 'cjs',
|
||||||
|
target: 'es2018',
|
||||||
|
logLevel: "info",
|
||||||
|
sourcemap: prod ? false : 'inline',
|
||||||
|
treeShaking: true,
|
||||||
|
outfile: 'main.js',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prod) {
|
||||||
|
await context.rebuild();
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
await context.watch();
|
||||||
|
}
|
||||||
11
manifest.json
Normal file
11
manifest.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "focus-task",
|
||||||
|
"name": "Focus Task",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minAppVersion": "0.15.0",
|
||||||
|
"description": "A Blitzit-inspired task management and focus timer plugin. Plan your day, track time with Pomodoro technique, and crush your tasks with satisfying checkoffs.",
|
||||||
|
"author": "Crib",
|
||||||
|
"authorUrl": "https://git.cribdev.com/crib",
|
||||||
|
"fundingUrl": "",
|
||||||
|
"isDesktopOnly": false
|
||||||
|
}
|
||||||
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "focus-task",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A Blitzit-inspired task management and focus timer plugin for Obsidian",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node esbuild.config.mjs",
|
||||||
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
|
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"obsidian",
|
||||||
|
"plugin",
|
||||||
|
"productivity",
|
||||||
|
"pomodoro",
|
||||||
|
"tasks",
|
||||||
|
"timer",
|
||||||
|
"focus"
|
||||||
|
],
|
||||||
|
"author": "Crib",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.cribdev.com/crib/focus-task"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^16.11.6",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.29.0",
|
||||||
|
"@typescript-eslint/parser": "^5.29.0",
|
||||||
|
"builtin-modules": "^3.3.0",
|
||||||
|
"esbuild": "0.17.3",
|
||||||
|
"obsidian": "latest",
|
||||||
|
"tslib": "2.4.0",
|
||||||
|
"typescript": "4.7.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
843
src/main.ts
Normal file
843
src/main.ts
Normal file
@@ -0,0 +1,843 @@
|
|||||||
|
import {
|
||||||
|
App,
|
||||||
|
Notice,
|
||||||
|
Plugin,
|
||||||
|
PluginSettingTab,
|
||||||
|
Setting,
|
||||||
|
WorkspaceLeaf,
|
||||||
|
} from 'obsidian';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FocusTask,
|
||||||
|
FocusTaskSettings,
|
||||||
|
FocusTaskData,
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
DEFAULT_DATA,
|
||||||
|
VIEW_TYPE_FOCUS_TASK,
|
||||||
|
CELEBRATION_MESSAGES,
|
||||||
|
EARLY_FINISH_MESSAGES,
|
||||||
|
OVERTIME_MESSAGES,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
import { FocusTaskView } from './view';
|
||||||
|
import { QuickAddTaskModal } from './modals';
|
||||||
|
|
||||||
|
// ============ Main Plugin Class ============
|
||||||
|
|
||||||
|
export default class FocusTaskPlugin extends Plugin {
|
||||||
|
settings: FocusTaskSettings;
|
||||||
|
data: FocusTaskData;
|
||||||
|
|
||||||
|
// Timer state
|
||||||
|
timerInterval: number | null = null;
|
||||||
|
currentTimerSeconds: number = 0;
|
||||||
|
isTimerRunning: boolean = false;
|
||||||
|
isBreakMode: boolean = false;
|
||||||
|
activeTaskId: string | null = null;
|
||||||
|
pomodoroCount: number = 0;
|
||||||
|
|
||||||
|
// Floating timer element
|
||||||
|
floatingTimerEl: HTMLElement | null = null;
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
await this.loadAllData();
|
||||||
|
|
||||||
|
// Check and reset daily stats
|
||||||
|
this.checkDailyReset();
|
||||||
|
|
||||||
|
// Register the main view
|
||||||
|
this.registerView(
|
||||||
|
VIEW_TYPE_FOCUS_TASK,
|
||||||
|
(leaf) => new FocusTaskView(leaf, this)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add ribbon icon
|
||||||
|
this.addRibbonIcon('zap', 'Open Focus Task', () => {
|
||||||
|
this.activateView();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add commands
|
||||||
|
this.addCommand({
|
||||||
|
id: 'open-focus-task',
|
||||||
|
name: 'Open Focus Task Panel',
|
||||||
|
callback: () => this.activateView(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'quick-add-task',
|
||||||
|
name: 'Quick Add Task',
|
||||||
|
callback: () => new QuickAddTaskModal(this.app, this).open(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'start-focus-mode',
|
||||||
|
name: 'Start Focus Mode on Next Task',
|
||||||
|
callback: () => this.startFocusOnNextTask(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'toggle-timer',
|
||||||
|
name: 'Toggle Timer (Play/Pause)',
|
||||||
|
callback: () => this.toggleTimer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addCommand({
|
||||||
|
id: 'complete-current-task',
|
||||||
|
name: 'Complete Current Task',
|
||||||
|
callback: () => this.completeActiveTask(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add settings tab
|
||||||
|
this.addSettingTab(new FocusTaskSettingTab(this.app, this));
|
||||||
|
|
||||||
|
// Create floating timer if enabled
|
||||||
|
if (this.settings.showFloatingTimer) {
|
||||||
|
this.createFloatingTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onunload() {
|
||||||
|
this.stopTimer();
|
||||||
|
this.removeFloatingTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAllData() {
|
||||||
|
const loaded = await this.loadData();
|
||||||
|
this.data = Object.assign({}, DEFAULT_DATA, loaded?.data || {});
|
||||||
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded?.settings || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAllData() {
|
||||||
|
await this.saveData({
|
||||||
|
settings: this.settings,
|
||||||
|
data: this.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDailyReset() {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
if (this.data.lastActiveDate !== today) {
|
||||||
|
// Check streak
|
||||||
|
const yesterday = new Date();
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
if (this.data.lastActiveDate === yesterday.toDateString()) {
|
||||||
|
this.data.streak++;
|
||||||
|
} else if (this.data.lastActiveDate !== today) {
|
||||||
|
this.data.streak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset daily stats
|
||||||
|
this.data.completedToday = 0;
|
||||||
|
this.data.totalFocusMinutesToday = 0;
|
||||||
|
this.data.lastActiveDate = today;
|
||||||
|
this.saveAllData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateView() {
|
||||||
|
const { workspace } = this.app;
|
||||||
|
|
||||||
|
let leaf: WorkspaceLeaf | null = null;
|
||||||
|
const leaves = workspace.getLeavesOfType(VIEW_TYPE_FOCUS_TASK);
|
||||||
|
|
||||||
|
if (leaves.length > 0) {
|
||||||
|
leaf = leaves[0];
|
||||||
|
} else {
|
||||||
|
leaf = workspace.getRightLeaf(false);
|
||||||
|
if (leaf) {
|
||||||
|
await leaf.setViewState({ type: VIEW_TYPE_FOCUS_TASK, active: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leaf) {
|
||||||
|
workspace.revealLeaf(leaf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Task Management ============
|
||||||
|
|
||||||
|
createTask(text: string, estimatedMinutes: number = this.settings.defaultEstimateMinutes, list: string = 'work'): FocusTask {
|
||||||
|
return {
|
||||||
|
id: this.generateId(),
|
||||||
|
text,
|
||||||
|
completed: false,
|
||||||
|
estimatedMinutes,
|
||||||
|
actualMinutes: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
list,
|
||||||
|
notes: '',
|
||||||
|
isActive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
generateId(): string {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
addTask(task: FocusTask) {
|
||||||
|
this.data.tasks.push(task);
|
||||||
|
this.saveAllData();
|
||||||
|
this.refreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTask(taskId: string, updates: Partial<FocusTask>) {
|
||||||
|
const task = this.data.tasks.find(t => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
Object.assign(task, updates);
|
||||||
|
this.saveAllData();
|
||||||
|
this.refreshView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTask(taskId: string) {
|
||||||
|
this.data.tasks = this.data.tasks.filter(t => t.id !== taskId);
|
||||||
|
if (this.activeTaskId === taskId) {
|
||||||
|
this.stopTimer();
|
||||||
|
this.activeTaskId = null;
|
||||||
|
}
|
||||||
|
this.saveAllData();
|
||||||
|
this.refreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
completeTask(taskId: string) {
|
||||||
|
const task = this.data.tasks.find(t => t.id === taskId);
|
||||||
|
if (task && !task.completed) {
|
||||||
|
task.completed = true;
|
||||||
|
task.completedAt = Date.now();
|
||||||
|
task.isActive = false;
|
||||||
|
|
||||||
|
this.data.completedToday++;
|
||||||
|
this.data.lastActiveDate = new Date().toDateString();
|
||||||
|
|
||||||
|
// Show celebration
|
||||||
|
if (this.settings.enableCelebrations) {
|
||||||
|
this.showCelebration(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play sound
|
||||||
|
if (this.settings.enableSounds) {
|
||||||
|
this.playCompletionSound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeTaskId === taskId) {
|
||||||
|
this.stopTimer();
|
||||||
|
this.activeTaskId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveAllData();
|
||||||
|
this.refreshView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completeActiveTask() {
|
||||||
|
if (this.activeTaskId) {
|
||||||
|
this.completeTask(this.activeTaskId);
|
||||||
|
} else {
|
||||||
|
new Notice('No active task to complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Timer Management ============
|
||||||
|
|
||||||
|
startTimer(taskId: string) {
|
||||||
|
const task = this.data.tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
// Stop any existing timer
|
||||||
|
this.stopTimer();
|
||||||
|
|
||||||
|
// Set active task
|
||||||
|
this.activeTaskId = taskId;
|
||||||
|
task.isActive = true;
|
||||||
|
this.isBreakMode = false;
|
||||||
|
this.currentTimerSeconds = 0;
|
||||||
|
this.isTimerRunning = true;
|
||||||
|
|
||||||
|
// Start interval (count up mode - stopwatch)
|
||||||
|
this.timerInterval = window.setInterval(() => {
|
||||||
|
this.currentTimerSeconds++;
|
||||||
|
task.actualMinutes = Math.floor(this.currentTimerSeconds / 60);
|
||||||
|
|
||||||
|
// Update floating timer
|
||||||
|
this.updateFloatingTimer();
|
||||||
|
this.refreshView();
|
||||||
|
|
||||||
|
// Check if over estimate
|
||||||
|
if (this.currentTimerSeconds === task.estimatedMinutes * 60) {
|
||||||
|
if (this.settings.enableSounds) {
|
||||||
|
this.playAlertSound();
|
||||||
|
}
|
||||||
|
new Notice(`⏰ Time's up for: ${task.text}`);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
this.saveAllData();
|
||||||
|
this.refreshView();
|
||||||
|
this.updateFloatingTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
startPomodoro(taskId: string) {
|
||||||
|
const task = this.data.tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
this.stopTimer();
|
||||||
|
this.activeTaskId = taskId;
|
||||||
|
task.isActive = true;
|
||||||
|
this.isBreakMode = false;
|
||||||
|
this.currentTimerSeconds = this.settings.pomodoroWorkMinutes * 60;
|
||||||
|
this.isTimerRunning = true;
|
||||||
|
|
||||||
|
this.timerInterval = window.setInterval(() => {
|
||||||
|
this.currentTimerSeconds--;
|
||||||
|
|
||||||
|
if (!this.isBreakMode) {
|
||||||
|
task.actualMinutes = Math.floor((this.settings.pomodoroWorkMinutes * 60 - this.currentTimerSeconds) / 60);
|
||||||
|
this.data.totalFocusMinutesToday = Math.floor(this.data.totalFocusMinutesToday + 1/60);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateFloatingTimer();
|
||||||
|
this.refreshView();
|
||||||
|
|
||||||
|
if (this.currentTimerSeconds <= 0) {
|
||||||
|
this.handlePomodoroEnd();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
this.updateFloatingTimer();
|
||||||
|
this.refreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePomodoroEnd() {
|
||||||
|
if (!this.isBreakMode) {
|
||||||
|
// Work session ended
|
||||||
|
this.pomodoroCount++;
|
||||||
|
this.data.pomodorosCompleted++;
|
||||||
|
|
||||||
|
if (this.settings.enableSounds) {
|
||||||
|
this.playAlertSound();
|
||||||
|
}
|
||||||
|
|
||||||
|
new Notice('🍅 Pomodoro complete! Time for a break.');
|
||||||
|
|
||||||
|
if (this.settings.autoStartBreak) {
|
||||||
|
this.startBreak();
|
||||||
|
} else {
|
||||||
|
this.stopTimer();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Break ended
|
||||||
|
if (this.settings.enableSounds) {
|
||||||
|
this.playAlertSound();
|
||||||
|
}
|
||||||
|
new Notice('⚡ Break over! Ready to focus?');
|
||||||
|
this.isBreakMode = false;
|
||||||
|
this.stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveAllData();
|
||||||
|
}
|
||||||
|
|
||||||
|
startBreak() {
|
||||||
|
this.isBreakMode = true;
|
||||||
|
const isLongBreak = this.pomodoroCount % this.settings.longBreakInterval === 0;
|
||||||
|
this.currentTimerSeconds = (isLongBreak ? this.settings.longBreakMinutes : this.settings.pomodoroBreakMinutes) * 60;
|
||||||
|
|
||||||
|
new Notice(isLongBreak ? '☕ Long break time!' : '☕ Short break time!');
|
||||||
|
|
||||||
|
if (!this.timerInterval) {
|
||||||
|
this.timerInterval = window.setInterval(() => {
|
||||||
|
this.currentTimerSeconds--;
|
||||||
|
this.updateFloatingTimer();
|
||||||
|
this.refreshView();
|
||||||
|
|
||||||
|
if (this.currentTimerSeconds <= 0) {
|
||||||
|
this.handlePomodoroEnd();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateFloatingTimer();
|
||||||
|
this.refreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTimer() {
|
||||||
|
if (this.isTimerRunning && this.timerInterval) {
|
||||||
|
// Pause
|
||||||
|
window.clearInterval(this.timerInterval);
|
||||||
|
this.timerInterval = null;
|
||||||
|
this.isTimerRunning = false;
|
||||||
|
} else if (this.activeTaskId) {
|
||||||
|
// Resume
|
||||||
|
this.isTimerRunning = true;
|
||||||
|
const task = this.data.tasks.find(t => t.id === this.activeTaskId);
|
||||||
|
|
||||||
|
this.timerInterval = window.setInterval(() => {
|
||||||
|
this.currentTimerSeconds--;
|
||||||
|
if (task && !this.isBreakMode) {
|
||||||
|
task.actualMinutes = Math.floor((this.settings.pomodoroWorkMinutes * 60 - this.currentTimerSeconds) / 60);
|
||||||
|
}
|
||||||
|
this.updateFloatingTimer();
|
||||||
|
this.refreshView();
|
||||||
|
|
||||||
|
if (this.currentTimerSeconds <= 0) {
|
||||||
|
this.handlePomodoroEnd();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
new Notice('No active task. Select a task first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateFloatingTimer();
|
||||||
|
this.refreshView();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTimer() {
|
||||||
|
if (this.timerInterval) {
|
||||||
|
window.clearInterval(this.timerInterval);
|
||||||
|
this.timerInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeTaskId) {
|
||||||
|
const task = this.data.tasks.find(t => t.id === this.activeTaskId);
|
||||||
|
if (task) {
|
||||||
|
task.isActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isTimerRunning = false;
|
||||||
|
this.activeTaskId = null;
|
||||||
|
this.updateFloatingTimer();
|
||||||
|
this.saveAllData();
|
||||||
|
}
|
||||||
|
|
||||||
|
startFocusOnNextTask() {
|
||||||
|
const pendingTasks = this.data.tasks.filter(t => !t.completed);
|
||||||
|
if (pendingTasks.length > 0) {
|
||||||
|
this.startPomodoro(pendingTasks[0].id);
|
||||||
|
} else {
|
||||||
|
new Notice('No pending tasks. Add a task first!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Floating Timer ============
|
||||||
|
|
||||||
|
createFloatingTimer() {
|
||||||
|
if (this.floatingTimerEl) return;
|
||||||
|
|
||||||
|
this.floatingTimerEl = document.body.createEl('div', {
|
||||||
|
cls: 'focus-task-floating-timer',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.floatingTimerEl.innerHTML = `
|
||||||
|
<div class="focus-task-floating-inner">
|
||||||
|
<div class="focus-task-floating-task">No active task</div>
|
||||||
|
<div class="focus-task-floating-time">00:00</div>
|
||||||
|
<div class="focus-task-floating-controls">
|
||||||
|
<button class="focus-task-float-btn play-pause">▶</button>
|
||||||
|
<button class="focus-task-float-btn complete">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Make draggable
|
||||||
|
this.makeDraggable(this.floatingTimerEl);
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
const playPauseBtn = this.floatingTimerEl.querySelector('.play-pause');
|
||||||
|
const completeBtn = this.floatingTimerEl.querySelector('.complete');
|
||||||
|
|
||||||
|
playPauseBtn?.addEventListener('click', () => this.toggleTimer());
|
||||||
|
completeBtn?.addEventListener('click', () => this.completeActiveTask());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFloatingTimer() {
|
||||||
|
if (this.floatingTimerEl) {
|
||||||
|
this.floatingTimerEl.remove();
|
||||||
|
this.floatingTimerEl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFloatingTimer() {
|
||||||
|
if (!this.floatingTimerEl) return;
|
||||||
|
|
||||||
|
const taskEl = this.floatingTimerEl.querySelector('.focus-task-floating-task');
|
||||||
|
const timeEl = this.floatingTimerEl.querySelector('.focus-task-floating-time');
|
||||||
|
const playPauseBtn = this.floatingTimerEl.querySelector('.play-pause');
|
||||||
|
|
||||||
|
if (this.activeTaskId) {
|
||||||
|
const task = this.data.tasks.find(t => t.id === this.activeTaskId);
|
||||||
|
if (task && taskEl) {
|
||||||
|
taskEl.textContent = this.isBreakMode ? '☕ Break Time' : task.text;
|
||||||
|
}
|
||||||
|
} else if (taskEl) {
|
||||||
|
taskEl.textContent = 'No active task';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeEl) {
|
||||||
|
timeEl.textContent = this.formatTime(this.currentTimerSeconds);
|
||||||
|
timeEl.classList.toggle('focus-task-overtime', this.currentTimerSeconds < 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playPauseBtn) {
|
||||||
|
playPauseBtn.textContent = this.isTimerRunning ? '⏸' : '▶';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update color based on state
|
||||||
|
this.floatingTimerEl.classList.toggle('focus-task-break-mode', this.isBreakMode);
|
||||||
|
this.floatingTimerEl.classList.toggle('focus-task-active', this.isTimerRunning);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeDraggable(el: HTMLElement) {
|
||||||
|
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
|
||||||
|
|
||||||
|
el.onmousedown = dragMouseDown;
|
||||||
|
|
||||||
|
function dragMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).tagName === 'BUTTON') return;
|
||||||
|
e.preventDefault();
|
||||||
|
pos3 = e.clientX;
|
||||||
|
pos4 = e.clientY;
|
||||||
|
document.onmouseup = closeDragElement;
|
||||||
|
document.onmousemove = elementDrag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elementDrag(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
pos1 = pos3 - e.clientX;
|
||||||
|
pos2 = pos4 - e.clientY;
|
||||||
|
pos3 = e.clientX;
|
||||||
|
pos4 = e.clientY;
|
||||||
|
el.style.top = (el.offsetTop - pos2) + "px";
|
||||||
|
el.style.left = (el.offsetLeft - pos1) + "px";
|
||||||
|
el.style.right = 'auto';
|
||||||
|
el.style.bottom = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDragElement() {
|
||||||
|
document.onmouseup = null;
|
||||||
|
document.onmousemove = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Sounds & Celebrations ============
|
||||||
|
|
||||||
|
showCelebration(task: FocusTask) {
|
||||||
|
let messages = CELEBRATION_MESSAGES;
|
||||||
|
let extraMessage = '';
|
||||||
|
|
||||||
|
if (task.actualMinutes < task.estimatedMinutes) {
|
||||||
|
const saved = task.estimatedMinutes - task.actualMinutes;
|
||||||
|
messages = EARLY_FINISH_MESSAGES;
|
||||||
|
extraMessage = ` (${saved} min early!)`;
|
||||||
|
} else if (task.actualMinutes > task.estimatedMinutes * 1.5) {
|
||||||
|
messages = OVERTIME_MESSAGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
const celebration = messages[Math.floor(Math.random() * messages.length)];
|
||||||
|
new Notice(`${celebration.emoji} ${celebration.message}${extraMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
playCompletionSound() {
|
||||||
|
try {
|
||||||
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
|
||||||
|
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
||||||
|
oscillator.frequency.setValueAtTime(1000, audioContext.currentTime + 0.1);
|
||||||
|
oscillator.frequency.setValueAtTime(1200, audioContext.currentTime + 0.2);
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
|
||||||
|
|
||||||
|
oscillator.start(audioContext.currentTime);
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.3);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Audio not available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playAlertSound() {
|
||||||
|
try {
|
||||||
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
|
||||||
|
oscillator.frequency.setValueAtTime(440, audioContext.currentTime);
|
||||||
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
oscillator.frequency.setValueAtTime(440, audioContext.currentTime + i * 0.3);
|
||||||
|
oscillator.frequency.setValueAtTime(550, audioContext.currentTime + i * 0.3 + 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.9);
|
||||||
|
|
||||||
|
oscillator.start(audioContext.currentTime);
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.9);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Audio not available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Utilities ============
|
||||||
|
|
||||||
|
formatTime(seconds: number): string {
|
||||||
|
const absSeconds = Math.abs(seconds);
|
||||||
|
const mins = Math.floor(absSeconds / 60);
|
||||||
|
const secs = absSeconds % 60;
|
||||||
|
const sign = seconds < 0 ? '-' : '';
|
||||||
|
return `${sign}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTimeHuman(minutes: number): string {
|
||||||
|
if (minutes < 60) return `${minutes}min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
return mins > 0 ? `${hours}hr ${mins}min` : `${hours}hr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshView() {
|
||||||
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_FOCUS_TASK);
|
||||||
|
leaves.forEach(leaf => {
|
||||||
|
if (leaf.view instanceof FocusTaskView) {
|
||||||
|
leaf.view.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getTasksByList(listId: string): FocusTask[] {
|
||||||
|
return this.data.tasks.filter(t => t.list === listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingTasks(): FocusTask[] {
|
||||||
|
return this.data.tasks.filter(t => !t.completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTodaysTasks(): FocusTask[] {
|
||||||
|
const today = new Date().toDateString();
|
||||||
|
return this.data.tasks.filter(t => {
|
||||||
|
if (t.scheduledDate === today) return true;
|
||||||
|
if (!t.scheduledDate && !t.completed) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
const pending = this.getPendingTasks();
|
||||||
|
const totalEstimate = pending.reduce((sum, t) => sum + t.estimatedMinutes, 0);
|
||||||
|
const completedTasks = this.data.tasks.filter(t => t.completed);
|
||||||
|
const avgAccuracy = completedTasks.length > 0
|
||||||
|
? completedTasks.reduce((sum, t) => sum + (t.estimatedMinutes / Math.max(t.actualMinutes, 1)), 0) / completedTasks.length
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingCount: pending.length,
|
||||||
|
completedToday: this.data.completedToday,
|
||||||
|
totalEstimatedMinutes: totalEstimate,
|
||||||
|
totalFocusMinutesToday: Math.floor(this.data.totalFocusMinutesToday),
|
||||||
|
streak: this.data.streak,
|
||||||
|
pomodorosCompleted: this.data.pomodorosCompleted,
|
||||||
|
avgAccuracy: Math.round(avgAccuracy * 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Settings Tab ============
|
||||||
|
|
||||||
|
class FocusTaskSettingTab extends PluginSettingTab {
|
||||||
|
plugin: FocusTaskPlugin;
|
||||||
|
|
||||||
|
constructor(app: App, plugin: FocusTaskPlugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
display(): void {
|
||||||
|
const { containerEl } = this;
|
||||||
|
containerEl.empty();
|
||||||
|
|
||||||
|
containerEl.createEl('h1', { text: '⚡ Focus Task Settings' });
|
||||||
|
|
||||||
|
// Pomodoro Settings
|
||||||
|
containerEl.createEl('h2', { text: '🍅 Pomodoro Timer' });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Work Duration')
|
||||||
|
.setDesc('Length of each work session in minutes')
|
||||||
|
.addSlider(slider => slider
|
||||||
|
.setLimits(5, 60, 5)
|
||||||
|
.setValue(this.plugin.settings.pomodoroWorkMinutes)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.pomodoroWorkMinutes = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Short Break Duration')
|
||||||
|
.setDesc('Length of short breaks in minutes')
|
||||||
|
.addSlider(slider => slider
|
||||||
|
.setLimits(1, 15, 1)
|
||||||
|
.setValue(this.plugin.settings.pomodoroBreakMinutes)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.pomodoroBreakMinutes = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Long Break Duration')
|
||||||
|
.setDesc('Length of long breaks in minutes')
|
||||||
|
.addSlider(slider => slider
|
||||||
|
.setLimits(5, 30, 5)
|
||||||
|
.setValue(this.plugin.settings.longBreakMinutes)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.longBreakMinutes = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Long Break Interval')
|
||||||
|
.setDesc('Number of pomodoros before a long break')
|
||||||
|
.addSlider(slider => slider
|
||||||
|
.setLimits(2, 6, 1)
|
||||||
|
.setValue(this.plugin.settings.longBreakInterval)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.longBreakInterval = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Auto-start Breaks')
|
||||||
|
.setDesc('Automatically start break timer after work session')
|
||||||
|
.addToggle(toggle => toggle
|
||||||
|
.setValue(this.plugin.settings.autoStartBreak)
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.autoStartBreak = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// General Settings
|
||||||
|
containerEl.createEl('h2', { text: '⚙️ General' });
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Default Time Estimate')
|
||||||
|
.setDesc('Default estimated time for new tasks in minutes')
|
||||||
|
.addSlider(slider => slider
|
||||||
|
.setLimits(5, 120, 5)
|
||||||
|
.setValue(this.plugin.settings.defaultEstimateMinutes)
|
||||||
|
.setDynamicTooltip()
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.defaultEstimateMinutes = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Enable Sounds')
|
||||||
|
.setDesc('Play sounds for timer completion and task completion')
|
||||||
|
.addToggle(toggle => toggle
|
||||||
|
.setValue(this.plugin.settings.enableSounds)
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.enableSounds = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Enable Celebrations')
|
||||||
|
.setDesc('Show celebration messages when completing tasks')
|
||||||
|
.addToggle(toggle => toggle
|
||||||
|
.setValue(this.plugin.settings.enableCelebrations)
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.enableCelebrations = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}));
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName('Show Floating Timer')
|
||||||
|
.setDesc('Display a draggable floating timer widget')
|
||||||
|
.addToggle(toggle => toggle
|
||||||
|
.setValue(this.plugin.settings.showFloatingTimer)
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.showFloatingTimer = value;
|
||||||
|
if (value) {
|
||||||
|
this.plugin.createFloatingTimer();
|
||||||
|
} else {
|
||||||
|
this.plugin.removeFloatingTimer();
|
||||||
|
}
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Lists Management
|
||||||
|
containerEl.createEl('h2', { text: '📋 Lists' });
|
||||||
|
|
||||||
|
this.plugin.settings.lists.forEach((list, index) => {
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(`${list.icon} ${list.name}`)
|
||||||
|
.addText(text => text
|
||||||
|
.setValue(list.name)
|
||||||
|
.setPlaceholder('List name')
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.lists[index].name = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}))
|
||||||
|
.addText(text => text
|
||||||
|
.setValue(list.icon)
|
||||||
|
.setPlaceholder('Emoji')
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.lists[index].icon = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}))
|
||||||
|
.addColorPicker(picker => picker
|
||||||
|
.setValue(list.color)
|
||||||
|
.onChange(async value => {
|
||||||
|
this.plugin.settings.lists[index].color = value;
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
}))
|
||||||
|
.addButton(btn => btn
|
||||||
|
.setIcon('trash')
|
||||||
|
.setTooltip('Delete list')
|
||||||
|
.onClick(async () => {
|
||||||
|
this.plugin.settings.lists.splice(index, 1);
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
this.display();
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.addButton(btn => btn
|
||||||
|
.setButtonText('+ Add List')
|
||||||
|
.onClick(async () => {
|
||||||
|
this.plugin.settings.lists.push({
|
||||||
|
id: this.plugin.generateId(),
|
||||||
|
name: 'New List',
|
||||||
|
color: '#6366f1',
|
||||||
|
icon: '📁',
|
||||||
|
});
|
||||||
|
await this.plugin.saveAllData();
|
||||||
|
this.display();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// About section
|
||||||
|
containerEl.createEl('h2', { text: '📖 About' });
|
||||||
|
|
||||||
|
const aboutDiv = containerEl.createDiv({ cls: 'focus-task-about' });
|
||||||
|
aboutDiv.innerHTML = `
|
||||||
|
<p><strong>Focus Task</strong> is heavily inspired by <a href="https://www.blitzit.app/">Blitzit</a>,
|
||||||
|
a fantastic productivity app that combines task management with focused time tracking.</p>
|
||||||
|
<p>This plugin brings similar functionality directly into Obsidian, allowing you to manage tasks,
|
||||||
|
use the Pomodoro technique, and track your productivity without leaving your notes.</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://git.cribdev.com/crib/focus-task">Source Code</a>
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/modals.ts
Normal file
204
src/modals.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
App,
|
||||||
|
Modal,
|
||||||
|
Notice,
|
||||||
|
Setting,
|
||||||
|
} from 'obsidian';
|
||||||
|
|
||||||
|
import { FocusTask } from './types';
|
||||||
|
import FocusTaskPlugin from './main';
|
||||||
|
|
||||||
|
// ============ Quick Add Task Modal ============
|
||||||
|
|
||||||
|
export class QuickAddTaskModal extends Modal {
|
||||||
|
plugin: FocusTaskPlugin;
|
||||||
|
taskText: string = '';
|
||||||
|
estimatedMinutes: number;
|
||||||
|
selectedList: string = 'work';
|
||||||
|
|
||||||
|
constructor(app: App, plugin: FocusTaskPlugin) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.estimatedMinutes = plugin.settings.defaultEstimateMinutes;
|
||||||
|
if (plugin.settings.lists.length > 0) {
|
||||||
|
this.selectedList = plugin.settings.lists[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.addClass('focus-task-modal');
|
||||||
|
|
||||||
|
contentEl.createEl('h2', { text: '⚡ Add New Task' });
|
||||||
|
|
||||||
|
// Task text input
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Task')
|
||||||
|
.addText(text => {
|
||||||
|
text.setPlaceholder('What do you need to do?')
|
||||||
|
.onChange(value => this.taskText = value);
|
||||||
|
text.inputEl.focus();
|
||||||
|
text.inputEl.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && this.taskText.trim()) {
|
||||||
|
this.submitTask();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Time estimate
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Estimated Time')
|
||||||
|
.setDesc('How long do you think this will take?')
|
||||||
|
.addDropdown(dropdown => {
|
||||||
|
const options: Record<string, string> = {
|
||||||
|
'5': '5 min',
|
||||||
|
'10': '10 min',
|
||||||
|
'15': '15 min',
|
||||||
|
'20': '20 min',
|
||||||
|
'25': '25 min (1 pomodoro)',
|
||||||
|
'30': '30 min',
|
||||||
|
'45': '45 min',
|
||||||
|
'50': '50 min (2 pomodoros)',
|
||||||
|
'60': '1 hour',
|
||||||
|
'90': '1.5 hours',
|
||||||
|
'120': '2 hours',
|
||||||
|
'180': '3 hours',
|
||||||
|
};
|
||||||
|
Object.entries(options).forEach(([value, label]) => {
|
||||||
|
dropdown.addOption(value, label);
|
||||||
|
});
|
||||||
|
dropdown.setValue(this.estimatedMinutes.toString());
|
||||||
|
dropdown.onChange(value => this.estimatedMinutes = parseInt(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
// List selection
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('List')
|
||||||
|
.addDropdown(dropdown => {
|
||||||
|
this.plugin.settings.lists.forEach(list => {
|
||||||
|
dropdown.addOption(list.id, `${list.icon} ${list.name}`);
|
||||||
|
});
|
||||||
|
dropdown.setValue(this.selectedList);
|
||||||
|
dropdown.onChange(value => this.selectedList = value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
const buttonContainer = contentEl.createEl('div', { cls: 'focus-task-modal-buttons' });
|
||||||
|
|
||||||
|
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'focus-task-btn' });
|
||||||
|
cancelBtn.addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
const addBtn = buttonContainer.createEl('button', { text: 'Add Task', cls: 'focus-task-btn focus-task-btn-primary' });
|
||||||
|
addBtn.addEventListener('click', () => this.submitTask());
|
||||||
|
}
|
||||||
|
|
||||||
|
submitTask() {
|
||||||
|
if (this.taskText.trim()) {
|
||||||
|
const task = this.plugin.createTask(this.taskText, this.estimatedMinutes, this.selectedList);
|
||||||
|
this.plugin.addTask(task);
|
||||||
|
new Notice('✅ Task added!');
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
new Notice('Please enter a task description');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Edit Task Modal ============
|
||||||
|
|
||||||
|
export class EditTaskModal extends Modal {
|
||||||
|
plugin: FocusTaskPlugin;
|
||||||
|
task: FocusTask;
|
||||||
|
|
||||||
|
constructor(app: App, plugin: FocusTaskPlugin, task: FocusTask) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.task = { ...task };
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.addClass('focus-task-modal');
|
||||||
|
|
||||||
|
contentEl.createEl('h2', { text: '✏️ Edit Task' });
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Task')
|
||||||
|
.addText(text => text
|
||||||
|
.setValue(this.task.text)
|
||||||
|
.onChange(value => this.task.text = value));
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Estimated Time')
|
||||||
|
.addDropdown(dropdown => {
|
||||||
|
const options: Record<string, string> = {
|
||||||
|
'5': '5 min',
|
||||||
|
'10': '10 min',
|
||||||
|
'15': '15 min',
|
||||||
|
'20': '20 min',
|
||||||
|
'25': '25 min',
|
||||||
|
'30': '30 min',
|
||||||
|
'45': '45 min',
|
||||||
|
'50': '50 min',
|
||||||
|
'60': '1 hour',
|
||||||
|
'90': '1.5 hours',
|
||||||
|
'120': '2 hours',
|
||||||
|
'180': '3 hours',
|
||||||
|
};
|
||||||
|
Object.entries(options).forEach(([value, label]) => {
|
||||||
|
dropdown.addOption(value, label);
|
||||||
|
});
|
||||||
|
dropdown.setValue(this.task.estimatedMinutes.toString());
|
||||||
|
dropdown.onChange(value => this.task.estimatedMinutes = parseInt(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('List')
|
||||||
|
.addDropdown(dropdown => {
|
||||||
|
this.plugin.settings.lists.forEach(list => {
|
||||||
|
dropdown.addOption(list.id, `${list.icon} ${list.name}`);
|
||||||
|
});
|
||||||
|
dropdown.setValue(this.task.list);
|
||||||
|
dropdown.onChange(value => this.task.list = value);
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Notes')
|
||||||
|
.setDesc('Add any additional details or links')
|
||||||
|
.addTextArea(textarea => {
|
||||||
|
textarea
|
||||||
|
.setValue(this.task.notes)
|
||||||
|
.onChange(value => this.task.notes = value);
|
||||||
|
textarea.inputEl.rows = 4;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show actual time if task has been worked on
|
||||||
|
if (this.task.actualMinutes > 0) {
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName('Time Tracked')
|
||||||
|
.setDesc(`You've worked on this task for ${this.plugin.formatTimeHuman(this.task.actualMinutes)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonContainer = contentEl.createEl('div', { cls: 'focus-task-modal-buttons' });
|
||||||
|
|
||||||
|
const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'focus-task-btn' });
|
||||||
|
cancelBtn.addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
const saveBtn = buttonContainer.createEl('button', { text: 'Save', cls: 'focus-task-btn focus-task-btn-primary' });
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
this.plugin.updateTask(this.task.id, this.task);
|
||||||
|
new Notice('✅ Task updated!');
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
const { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/types.ts
Normal file
101
src/types.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// ============ Types & Interfaces ============
|
||||||
|
|
||||||
|
export interface FocusTask {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
estimatedMinutes: number;
|
||||||
|
actualMinutes: number;
|
||||||
|
createdAt: number;
|
||||||
|
completedAt?: number;
|
||||||
|
list: string;
|
||||||
|
notes: string;
|
||||||
|
scheduledDate?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskList {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FocusTaskSettings {
|
||||||
|
pomodoroWorkMinutes: number;
|
||||||
|
pomodoroBreakMinutes: number;
|
||||||
|
longBreakMinutes: number;
|
||||||
|
longBreakInterval: number;
|
||||||
|
enableSounds: boolean;
|
||||||
|
enableCelebrations: boolean;
|
||||||
|
defaultEstimateMinutes: number;
|
||||||
|
lists: TaskList[];
|
||||||
|
showFloatingTimer: boolean;
|
||||||
|
autoStartBreak: boolean;
|
||||||
|
tickSoundEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FocusTaskData {
|
||||||
|
tasks: FocusTask[];
|
||||||
|
completedToday: number;
|
||||||
|
totalFocusMinutesToday: number;
|
||||||
|
streak: number;
|
||||||
|
lastActiveDate: string;
|
||||||
|
pomodorosCompleted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: FocusTaskSettings = {
|
||||||
|
pomodoroWorkMinutes: 25,
|
||||||
|
pomodoroBreakMinutes: 5,
|
||||||
|
longBreakMinutes: 15,
|
||||||
|
longBreakInterval: 4,
|
||||||
|
enableSounds: true,
|
||||||
|
enableCelebrations: true,
|
||||||
|
defaultEstimateMinutes: 30,
|
||||||
|
lists: [
|
||||||
|
{ id: 'work', name: 'Work', color: '#6366f1', icon: '💼' },
|
||||||
|
{ id: 'personal', name: 'Personal', color: '#22c55e', icon: '🏠' },
|
||||||
|
{ id: 'learning', name: 'Learning', color: '#f59e0b', icon: '📚' },
|
||||||
|
],
|
||||||
|
showFloatingTimer: true,
|
||||||
|
autoStartBreak: false,
|
||||||
|
tickSoundEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_DATA: FocusTaskData = {
|
||||||
|
tasks: [],
|
||||||
|
completedToday: 0,
|
||||||
|
totalFocusMinutesToday: 0,
|
||||||
|
streak: 0,
|
||||||
|
lastActiveDate: '',
|
||||||
|
pomodorosCompleted: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VIEW_TYPE_FOCUS_TASK = 'focus-task-view';
|
||||||
|
|
||||||
|
// ============ Celebration Messages ============
|
||||||
|
|
||||||
|
export const CELEBRATION_MESSAGES = [
|
||||||
|
{ emoji: '💥', message: 'Crushed it!' },
|
||||||
|
{ emoji: '🔥', message: 'On fire!' },
|
||||||
|
{ emoji: '⚡', message: 'Lightning fast!' },
|
||||||
|
{ emoji: '🎯', message: 'Bullseye!' },
|
||||||
|
{ emoji: '🚀', message: 'Blasting through!' },
|
||||||
|
{ emoji: '💪', message: 'Strong work!' },
|
||||||
|
{ emoji: '🌟', message: 'Stellar!' },
|
||||||
|
{ emoji: '✨', message: 'Brilliant!' },
|
||||||
|
{ emoji: '🏆', message: 'Champion!' },
|
||||||
|
{ emoji: '🎉', message: 'Well done!' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EARLY_FINISH_MESSAGES = [
|
||||||
|
{ emoji: '⚡', message: 'Speed demon! Finished early!' },
|
||||||
|
{ emoji: '🏎️', message: 'Faster than expected!' },
|
||||||
|
{ emoji: '🎯', message: 'Under budget! Nice work!' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OVERTIME_MESSAGES = [
|
||||||
|
{ emoji: '💪', message: 'Persistence pays off!' },
|
||||||
|
{ emoji: '🏃', message: 'Marathon runner!' },
|
||||||
|
{ emoji: '🔥', message: 'The grind is real!' },
|
||||||
|
];
|
||||||
319
src/view.ts
Normal file
319
src/view.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import {
|
||||||
|
ItemView,
|
||||||
|
WorkspaceLeaf,
|
||||||
|
} from 'obsidian';
|
||||||
|
|
||||||
|
import { VIEW_TYPE_FOCUS_TASK, FocusTask } from './types';
|
||||||
|
import { QuickAddTaskModal, EditTaskModal } from './modals';
|
||||||
|
import FocusTaskPlugin from './main';
|
||||||
|
|
||||||
|
// ============ Main View ============
|
||||||
|
|
||||||
|
export class FocusTaskView extends ItemView {
|
||||||
|
plugin: FocusTaskPlugin;
|
||||||
|
currentFilter: string = 'all';
|
||||||
|
|
||||||
|
constructor(leaf: WorkspaceLeaf, plugin: FocusTaskPlugin) {
|
||||||
|
super(leaf);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
getViewType(): string {
|
||||||
|
return VIEW_TYPE_FOCUS_TASK;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayText(): string {
|
||||||
|
return 'Focus Task';
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon(): string {
|
||||||
|
return 'zap';
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen() {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
const container = this.containerEl.children[1];
|
||||||
|
container.empty();
|
||||||
|
container.addClass('focus-task-container');
|
||||||
|
|
||||||
|
// Header
|
||||||
|
this.renderHeader(container);
|
||||||
|
|
||||||
|
// Stats bar
|
||||||
|
this.renderStatsBar(container);
|
||||||
|
|
||||||
|
// Active task / Timer
|
||||||
|
this.renderActiveTask(container);
|
||||||
|
|
||||||
|
// Task list
|
||||||
|
this.renderTaskList(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader(container: Element) {
|
||||||
|
const header = container.createEl('div', { cls: 'focus-task-header' });
|
||||||
|
|
||||||
|
const titleSection = header.createEl('div', { cls: 'focus-task-title-section' });
|
||||||
|
titleSection.createEl('h2', { text: '⚡ Focus Task', cls: 'focus-task-title' });
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const dateStr = today.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
|
||||||
|
titleSection.createEl('div', { text: dateStr, cls: 'focus-task-date' });
|
||||||
|
|
||||||
|
const actions = header.createEl('div', { cls: 'focus-task-header-actions' });
|
||||||
|
|
||||||
|
const addBtn = actions.createEl('button', { cls: 'focus-task-btn focus-task-btn-primary' });
|
||||||
|
addBtn.innerHTML = '+ Add Task';
|
||||||
|
addBtn.addEventListener('click', () => {
|
||||||
|
new QuickAddTaskModal(this.app, this.plugin).open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStatsBar(container: Element) {
|
||||||
|
const stats = this.plugin.getStats();
|
||||||
|
const statsBar = container.createEl('div', { cls: 'focus-task-stats-bar' });
|
||||||
|
|
||||||
|
const statItems = [
|
||||||
|
{ label: 'Pending', value: stats.pendingCount.toString(), icon: '📋' },
|
||||||
|
{ label: 'Done Today', value: stats.completedToday.toString(), icon: '✅' },
|
||||||
|
{ label: 'Focus Time', value: this.plugin.formatTimeHuman(stats.totalFocusMinutesToday), icon: '⏱️' },
|
||||||
|
{ label: 'Streak', value: `${stats.streak} days`, icon: '🔥' },
|
||||||
|
];
|
||||||
|
|
||||||
|
statItems.forEach(stat => {
|
||||||
|
const item = statsBar.createEl('div', { cls: 'focus-task-stat-item' });
|
||||||
|
item.createEl('div', { cls: 'focus-task-stat-icon', text: stat.icon });
|
||||||
|
item.createEl('div', { cls: 'focus-task-stat-value', text: stat.value });
|
||||||
|
item.createEl('div', { cls: 'focus-task-stat-label', text: stat.label });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderActiveTask(container: Element) {
|
||||||
|
const activeSection = container.createEl('div', { cls: 'focus-task-active-section' });
|
||||||
|
|
||||||
|
if (this.plugin.activeTaskId) {
|
||||||
|
const task = this.plugin.data.tasks.find(t => t.id === this.plugin.activeTaskId);
|
||||||
|
if (task) {
|
||||||
|
activeSection.addClass('focus-task-has-active');
|
||||||
|
|
||||||
|
const activeCard = activeSection.createEl('div', { cls: 'focus-task-active-card' });
|
||||||
|
|
||||||
|
if (this.plugin.isBreakMode) {
|
||||||
|
activeCard.addClass('focus-task-break-card');
|
||||||
|
activeCard.createEl('div', { cls: 'focus-task-active-label', text: '☕ BREAK TIME' });
|
||||||
|
} else {
|
||||||
|
activeCard.createEl('div', { cls: 'focus-task-active-label', text: '🎯 FOCUSING ON' });
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCard.createEl('div', { cls: 'focus-task-active-task-name', text: task.text });
|
||||||
|
|
||||||
|
// Timer display
|
||||||
|
const timerDisplay = activeCard.createEl('div', { cls: 'focus-task-timer-display' });
|
||||||
|
timerDisplay.createEl('span', {
|
||||||
|
cls: 'focus-task-timer-time',
|
||||||
|
text: this.plugin.formatTime(this.plugin.currentTimerSeconds)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
const progressWrap = activeCard.createEl('div', { cls: 'focus-task-progress-wrap' });
|
||||||
|
const progress = progressWrap.createEl('div', { cls: 'focus-task-progress-bar' });
|
||||||
|
|
||||||
|
let progressPercent = 0;
|
||||||
|
if (this.plugin.isBreakMode) {
|
||||||
|
const breakDuration = this.plugin.pomodoroCount % this.plugin.settings.longBreakInterval === 0
|
||||||
|
? this.plugin.settings.longBreakMinutes * 60
|
||||||
|
: this.plugin.settings.pomodoroBreakMinutes * 60;
|
||||||
|
progressPercent = ((breakDuration - this.plugin.currentTimerSeconds) / breakDuration) * 100;
|
||||||
|
} else {
|
||||||
|
const workDuration = this.plugin.settings.pomodoroWorkMinutes * 60;
|
||||||
|
progressPercent = ((workDuration - this.plugin.currentTimerSeconds) / workDuration) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.style.width = `${Math.min(Math.max(progressPercent, 0), 100)}%`;
|
||||||
|
if (progressPercent >= 100) progress.addClass('focus-task-overtime');
|
||||||
|
|
||||||
|
// Time info
|
||||||
|
if (!this.plugin.isBreakMode) {
|
||||||
|
const timeInfo = activeCard.createEl('div', { cls: 'focus-task-time-info' });
|
||||||
|
timeInfo.createEl('span', { text: `Est: ${this.plugin.formatTimeHuman(task.estimatedMinutes)}` });
|
||||||
|
timeInfo.createEl('span', { text: `Actual: ${this.plugin.formatTimeHuman(task.actualMinutes)}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
const controls = activeCard.createEl('div', { cls: 'focus-task-active-controls' });
|
||||||
|
|
||||||
|
const pauseBtn = controls.createEl('button', { cls: 'focus-task-btn focus-task-btn-secondary' });
|
||||||
|
pauseBtn.innerHTML = this.plugin.isTimerRunning ? '⏸ Pause' : '▶ Resume';
|
||||||
|
pauseBtn.addEventListener('click', () => this.plugin.toggleTimer());
|
||||||
|
|
||||||
|
if (!this.plugin.isBreakMode) {
|
||||||
|
const completeBtn = controls.createEl('button', { cls: 'focus-task-btn focus-task-btn-success' });
|
||||||
|
completeBtn.innerHTML = '✓ Complete';
|
||||||
|
completeBtn.addEventListener('click', () => this.plugin.completeTask(task.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopBtn = controls.createEl('button', { cls: 'focus-task-btn focus-task-btn-danger' });
|
||||||
|
stopBtn.innerHTML = '✕ Stop';
|
||||||
|
stopBtn.addEventListener('click', () => this.plugin.stopTimer());
|
||||||
|
|
||||||
|
if (this.plugin.isBreakMode) {
|
||||||
|
const skipBreakBtn = controls.createEl('button', { cls: 'focus-task-btn' });
|
||||||
|
skipBreakBtn.innerHTML = '⏭ Skip Break';
|
||||||
|
skipBreakBtn.addEventListener('click', () => {
|
||||||
|
this.plugin.isBreakMode = false;
|
||||||
|
this.plugin.stopTimer();
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No active task - show start focus prompt
|
||||||
|
const startPrompt = activeSection.createEl('div', { cls: 'focus-task-start-prompt' });
|
||||||
|
startPrompt.createEl('div', { cls: 'focus-task-prompt-icon', text: '⚡' });
|
||||||
|
startPrompt.createEl('div', { cls: 'focus-task-prompt-text', text: 'Ready to focus?' });
|
||||||
|
startPrompt.createEl('div', { cls: 'focus-task-prompt-hint', text: 'Click ▶ on a task to start a Pomodoro session' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTaskList(container: Element) {
|
||||||
|
const listSection = container.createEl('div', { cls: 'focus-task-list-section' });
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const filters = listSection.createEl('div', { cls: 'focus-task-filters' });
|
||||||
|
|
||||||
|
const filterOptions = [
|
||||||
|
{ id: 'all', label: 'All Tasks' },
|
||||||
|
{ id: 'today', label: 'Today' },
|
||||||
|
{ id: 'completed', label: 'Completed' },
|
||||||
|
...this.plugin.settings.lists.map(l => ({ id: l.id, label: `${l.icon} ${l.name}` })),
|
||||||
|
];
|
||||||
|
|
||||||
|
filterOptions.forEach(opt => {
|
||||||
|
const btn = filters.createEl('button', {
|
||||||
|
cls: `focus-task-filter-btn ${this.currentFilter === opt.id ? 'active' : ''}`,
|
||||||
|
text: opt.label,
|
||||||
|
});
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.currentFilter = opt.id;
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task items
|
||||||
|
const taskList = listSection.createEl('div', { cls: 'focus-task-task-list' });
|
||||||
|
|
||||||
|
let tasks = this.plugin.data.tasks;
|
||||||
|
|
||||||
|
// Filter tasks
|
||||||
|
if (this.currentFilter === 'today') {
|
||||||
|
tasks = this.plugin.getTodaysTasks();
|
||||||
|
} else if (this.currentFilter === 'completed') {
|
||||||
|
tasks = this.plugin.data.tasks.filter(t => t.completed);
|
||||||
|
} else if (this.currentFilter !== 'all') {
|
||||||
|
tasks = this.plugin.getTasksByList(this.currentFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: incomplete first, then by creation date
|
||||||
|
tasks = [...tasks].sort((a, b) => {
|
||||||
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||||
|
return b.createdAt - a.createdAt;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
const emptyState = taskList.createEl('div', { cls: 'focus-task-empty-state' });
|
||||||
|
emptyState.createEl('div', { cls: 'focus-task-empty-icon', text: '📝' });
|
||||||
|
emptyState.createEl('div', { cls: 'focus-task-empty-text', text: 'No tasks yet' });
|
||||||
|
emptyState.createEl('div', { cls: 'focus-task-empty-hint', text: 'Add a task to get started!' });
|
||||||
|
} else {
|
||||||
|
tasks.forEach(task => this.renderTaskItem(taskList, task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTaskItem(container: Element, task: FocusTask) {
|
||||||
|
const list = this.plugin.settings.lists.find(l => l.id === task.list);
|
||||||
|
|
||||||
|
const taskEl = container.createEl('div', {
|
||||||
|
cls: `focus-task-task-item ${task.completed ? 'completed' : ''} ${task.isActive ? 'active' : ''}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkbox
|
||||||
|
const checkbox = taskEl.createEl('div', { cls: 'focus-task-checkbox' });
|
||||||
|
checkbox.innerHTML = task.completed ? '✓' : '';
|
||||||
|
checkbox.style.borderColor = list?.color || '#6366f1';
|
||||||
|
if (task.completed) {
|
||||||
|
checkbox.style.backgroundColor = list?.color || '#6366f1';
|
||||||
|
checkbox.style.color = 'white';
|
||||||
|
}
|
||||||
|
checkbox.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!task.completed) {
|
||||||
|
this.plugin.completeTask(task.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task content
|
||||||
|
const content = taskEl.createEl('div', { cls: 'focus-task-task-content' });
|
||||||
|
|
||||||
|
const taskHeader = content.createEl('div', { cls: 'focus-task-task-header' });
|
||||||
|
taskHeader.createEl('span', { cls: 'focus-task-task-text', text: task.text });
|
||||||
|
|
||||||
|
if (list) {
|
||||||
|
const listBadge = taskHeader.createEl('span', {
|
||||||
|
cls: 'focus-task-list-badge',
|
||||||
|
text: `${list.icon} ${list.name}`,
|
||||||
|
});
|
||||||
|
listBadge.style.backgroundColor = list.color + '20';
|
||||||
|
listBadge.style.color = list.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskMeta = content.createEl('div', { cls: 'focus-task-task-meta' });
|
||||||
|
taskMeta.createEl('span', { text: `Est: ${this.plugin.formatTimeHuman(task.estimatedMinutes)}` });
|
||||||
|
|
||||||
|
if (task.actualMinutes > 0) {
|
||||||
|
const actualSpan = taskMeta.createEl('span');
|
||||||
|
actualSpan.setText(`Actual: ${this.plugin.formatTimeHuman(task.actualMinutes)}`);
|
||||||
|
if (task.actualMinutes > task.estimatedMinutes) {
|
||||||
|
actualSpan.addClass('focus-task-overtime-text');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const actions = taskEl.createEl('div', { cls: 'focus-task-task-actions' });
|
||||||
|
|
||||||
|
if (!task.completed) {
|
||||||
|
// Start pomodoro button
|
||||||
|
const startBtn = actions.createEl('button', { cls: 'focus-task-task-btn', attr: { 'aria-label': 'Start Pomodoro' } });
|
||||||
|
startBtn.innerHTML = '▶';
|
||||||
|
startBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.plugin.startPomodoro(task.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stopwatch mode button
|
||||||
|
const stopwatchBtn = actions.createEl('button', { cls: 'focus-task-task-btn', attr: { 'aria-label': 'Start Stopwatch' } });
|
||||||
|
stopwatchBtn.innerHTML = '⏱';
|
||||||
|
stopwatchBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.plugin.startTimer(task.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit button
|
||||||
|
const editBtn = actions.createEl('button', { cls: 'focus-task-task-btn', attr: { 'aria-label': 'Edit' } });
|
||||||
|
editBtn.innerHTML = '✏️';
|
||||||
|
editBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
new EditTaskModal(this.app, this.plugin, task).open();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
const deleteBtn = actions.createEl('button', { cls: 'focus-task-task-btn focus-task-delete-btn', attr: { 'aria-label': 'Delete' } });
|
||||||
|
deleteBtn.innerHTML = '🗑';
|
||||||
|
deleteBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.plugin.deleteTask(task.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
698
styles.css
Normal file
698
styles.css
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
/* ============================================
|
||||||
|
FOCUS TASK - Obsidian Plugin Styles
|
||||||
|
Inspired by https://www.blitzit.app/
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* ============ CSS Variables ============ */
|
||||||
|
.focus-task-container {
|
||||||
|
--ft-primary: #6366f1;
|
||||||
|
--ft-primary-light: #818cf8;
|
||||||
|
--ft-primary-dark: #4f46e5;
|
||||||
|
--ft-success: #22c55e;
|
||||||
|
--ft-warning: #f59e0b;
|
||||||
|
--ft-danger: #ef4444;
|
||||||
|
--ft-break: #06b6d4;
|
||||||
|
|
||||||
|
--ft-bg: var(--background-primary);
|
||||||
|
--ft-bg-secondary: var(--background-secondary);
|
||||||
|
--ft-bg-tertiary: var(--background-modifier-hover);
|
||||||
|
--ft-text: var(--text-normal);
|
||||||
|
--ft-text-muted: var(--text-muted);
|
||||||
|
--ft-border: var(--background-modifier-border);
|
||||||
|
|
||||||
|
--ft-radius: 12px;
|
||||||
|
--ft-radius-sm: 8px;
|
||||||
|
--ft-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
--ft-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Container ============ */
|
||||||
|
.focus-task-container {
|
||||||
|
padding: 16px;
|
||||||
|
font-family: var(--font-interface);
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Header ============ */
|
||||||
|
.focus-task-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--ft-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-title-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--ft-primary) 0%, var(--ft-primary-light) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-date {
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Buttons ============ */
|
||||||
|
.focus-task-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--ft-radius-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
background: var(--ft-bg-tertiary);
|
||||||
|
color: var(--ft-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-btn:hover {
|
||||||
|
background: var(--ft-border);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--ft-primary) 0%, var(--ft-primary-dark) 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--ft-primary-light) 0%, var(--ft-primary) 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-btn-success {
|
||||||
|
background: var(--ft-success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-btn-success:hover {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-btn-secondary {
|
||||||
|
background: var(--ft-bg-secondary);
|
||||||
|
border: 1px solid var(--ft-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ft-danger);
|
||||||
|
border: 1px solid var(--ft-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-btn-danger:hover {
|
||||||
|
background: var(--ft-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Stats Bar ============ */
|
||||||
|
.focus-task-stats-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.focus-task-stats-bar {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-stat-item {
|
||||||
|
background: var(--ft-bg-secondary);
|
||||||
|
border-radius: var(--ft-radius);
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-stat-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--ft-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-stat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ft-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Active Task Section ============ */
|
||||||
|
.focus-task-active-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-start-prompt {
|
||||||
|
background: linear-gradient(135deg, var(--ft-bg-secondary) 0%, var(--ft-bg-tertiary) 100%);
|
||||||
|
border: 2px dashed var(--ft-border);
|
||||||
|
border-radius: var(--ft-radius);
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-prompt-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
animation: ft-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ft-pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 1; }
|
||||||
|
50% { transform: scale(1.1); opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-prompt-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ft-text);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-prompt-hint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Active Task Card ============ */
|
||||||
|
.focus-task-active-card {
|
||||||
|
background: linear-gradient(135deg, var(--ft-primary) 0%, var(--ft-primary-dark) 100%);
|
||||||
|
border-radius: var(--ft-radius);
|
||||||
|
padding: 24px;
|
||||||
|
color: white;
|
||||||
|
box-shadow: var(--ft-shadow-lg);
|
||||||
|
animation: ft-slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ft-slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-break-card {
|
||||||
|
background: linear-gradient(135deg, var(--ft-break) 0%, #0891b2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-active-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-active-task-name {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-timer-display {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-timer-time {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-timer-time.focus-task-overtime {
|
||||||
|
color: var(--ft-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.focus-task-progress-wrap {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-progress-bar.focus-task-overtime {
|
||||||
|
background: var(--ft-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-time-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-active-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-active-controls .focus-task-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-active-controls .focus-task-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-active-controls .focus-task-btn-success {
|
||||||
|
background: var(--ft-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-active-controls .focus-task-btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Task List Section ============ */
|
||||||
|
.focus-task-list-section {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.focus-task-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-filter-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--ft-bg-secondary);
|
||||||
|
border: 1px solid var(--ft-border);
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-filter-btn:hover {
|
||||||
|
background: var(--ft-bg-tertiary);
|
||||||
|
color: var(--ft-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-filter-btn.active {
|
||||||
|
background: var(--ft-primary);
|
||||||
|
border-color: var(--ft-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task List */
|
||||||
|
.focus-task-task-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.focus-task-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-empty-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-empty-hint {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Task Item ============ */
|
||||||
|
.focus-task-task-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--ft-bg-secondary);
|
||||||
|
border-radius: var(--ft-radius-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-item:hover {
|
||||||
|
border-color: var(--ft-primary);
|
||||||
|
box-shadow: var(--ft-shadow);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-item.active {
|
||||||
|
border-color: var(--ft-primary);
|
||||||
|
background: linear-gradient(90deg, rgba(99, 102, 241, 0.1) 0%, var(--ft-bg-secondary) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-item.completed {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-item.completed .focus-task-task-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox */
|
||||||
|
.focus-task-checkbox {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
border: 2px solid var(--ft-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-checkbox:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task Content */
|
||||||
|
.focus-task-task-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ft-text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-list-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-overtime-text {
|
||||||
|
color: var(--ft-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task Actions */
|
||||||
|
.focus-task-task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-item:hover .focus-task-task-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--ft-bg-tertiary);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-btn:hover {
|
||||||
|
background: var(--ft-primary);
|
||||||
|
color: white;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-delete-btn:hover {
|
||||||
|
background: var(--ft-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Floating Timer ============ */
|
||||||
|
.focus-task-floating-timer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--background-primary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
cursor: move;
|
||||||
|
min-width: 180px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-floating-timer:hover {
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-floating-timer.focus-task-active {
|
||||||
|
border-color: var(--ft-primary);
|
||||||
|
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-floating-timer.focus-task-break-mode {
|
||||||
|
border-color: var(--ft-break);
|
||||||
|
box-shadow: 0 8px 32px rgba(6, 182, 212, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-floating-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-floating-task {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-floating-time {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--ft-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-floating-time.focus-task-overtime {
|
||||||
|
color: var(--ft-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-floating-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-float-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--ft-bg-secondary);
|
||||||
|
border: 1px solid var(--ft-border);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-float-btn:hover {
|
||||||
|
background: var(--ft-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--ft-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Modal Styles ============ */
|
||||||
|
.focus-task-modal {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-modal h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-modal-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--ft-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* About section in settings */
|
||||||
|
.focus-task-about {
|
||||||
|
background: var(--ft-bg-secondary);
|
||||||
|
border-radius: var(--ft-radius);
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-about p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-about p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-about a {
|
||||||
|
color: var(--ft-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Scrollbar Styling ============ */
|
||||||
|
.focus-task-container::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-container::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-container::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--ft-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--ft-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Animations ============ */
|
||||||
|
@keyframes ft-checkComplete {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.3); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-checkbox.completing {
|
||||||
|
animation: ft-checkComplete 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Dark mode adjustments ============ */
|
||||||
|
.theme-dark .focus-task-floating-timer {
|
||||||
|
background: var(--background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Mobile Responsive ============ */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.focus-task-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-active-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-active-controls .focus-task-btn {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-task-task-actions {
|
||||||
|
opacity: 1;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"inlineSourceMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ES6",
|
||||||
|
"allowJs": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"ES5",
|
||||||
|
"ES6",
|
||||||
|
"ES7"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
14
version-bump.mjs
Normal file
14
version-bump.mjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const targetVersion = process.env.npm_package_version;
|
||||||
|
|
||||||
|
// read minAppVersion from manifest.json and bump version to target version
|
||||||
|
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||||
|
const { minAppVersion } = manifest;
|
||||||
|
manifest.version = targetVersion;
|
||||||
|
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||||
|
|
||||||
|
// update versions.json with target version and minAppVersion from manifest.json
|
||||||
|
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||||
|
versions[targetVersion] = minAppVersion;
|
||||||
|
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||||
3
versions.json
Normal file
3
versions.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"1.0.0": "0.15.0"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user