├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── contribution-request.md │ └── feature_request.md ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── assets ├── buttons.png ├── dragging-example.gif ├── dragging-example.mov ├── tick.wav ├── time-ruler-cover.png ├── timer.png └── timeruler.RPP ├── dist ├── .hotreload ├── manifest.json └── styles.css ├── esbuild.config.mjs ├── manifest.json ├── obsidian-time-ruler.code-workspace ├── package-lock.json ├── package.json ├── packages └── types │ └── index.d.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── src ├── app │ └── store.ts ├── assets │ ├── assets.ts │ ├── pop.mp3 │ ├── start.mp3 │ ├── tick.mp3 │ └── timer.mp3 ├── components │ ├── App.tsx │ ├── Block.tsx │ ├── Button.tsx │ ├── Day.tsx │ ├── Droppable.tsx │ ├── Group.tsx │ ├── Hours.tsx │ ├── Logo.tsx │ ├── Minutes.tsx │ ├── NewTask.tsx │ ├── Search.tsx │ ├── Task.tsx │ ├── Timer.tsx │ ├── Toggle.tsx │ └── Unscheduled.tsx ├── index.tsx ├── main.ts ├── plugin │ └── SettingsTab.tsx ├── services │ ├── autoScroll.ts │ ├── calendarApi.ts │ ├── dragging.ts │ ├── obsidianApi.ts │ ├── parser.ts │ └── util.ts ├── styles.css ├── tests │ └── parser.test.ts └── types │ ├── enums.ts │ ├── index.d.ts │ └── modules.d.ts ├── tailwind.config.js └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Obsidian Version:** 27 | - [ ] Mobile 28 | - [ ] Desktop 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/contribution-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Contribution request 3 | about: If you would like to add to this plugin, please submit a contribution request 4 | describing your idea. 5 | title: 'CONTRIBUTION: ' 6 | labels: '' 7 | assignees: '' 8 | 9 | --- 10 | 11 | - Your name: 12 | - Describe your idea: 13 | - Why is this important for Time Ruler? 14 | - How long will it take to implement this feature? (ex: 1 week, 1 month) 15 | - Are there any other plugins that have this feature? 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | node_modules 3 | 4 | # Don't include the compiled main.js file in the repo. 5 | # They should be uploaded to GitHub releases instead. 6 | main.js 7 | 8 | # obsidian 9 | data.json 10 | 11 | # Exclude macOS Finder (System Explorer) View States 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "jsxSingleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Developing 2 | 3 | - Support markdown in Tasks 4 | - Support [Add hyperlink support for external calendar event locations · Issue #121 · j-palindrome/obsidian-time-ruler · GitHub](https://github.com/j-palindrome/obsidian-time-ruler/issues/121) 5 | - Fix Full Calendar meetings in [Daily Notes](https://github.com/j-palindrome/obsidian-time-ruler/issues/117) 6 | - Support [colors in calendar & tasks](https://github.com/joshuatazrein/obsidian-time-ruler/issues/72) 7 | 8 | ## Planned 9 | 10 | - Support [Task repeats](https://github.com/joshuatazrein/obsidian-time-ruler/issues/5) 11 | - Support [aliases](https://github.com/joshuatazrein/obsidian-time- 12 | - Option to support [Tasks API on click](https://github.com/joshuatazrein/obsidian-time-ruler/issues/74) 13 | - Add in ability to make queries in [Tasks as well](https://github.com/j-palindrome/obsidian-time-ruler/issues/123) 14 | 15 | ## Considering 16 | 17 | - [CalDAV calendars](https://github.com/joshuatazrein/obsidian-time-ruler/issues/34) 18 | 19 | # Changelog 20 | 21 | ## 2.7.1 (4/17/2025) 22 | 23 | **Added:** 24 | 25 | - Support links in tasks 26 | 27 | **Changed:** 28 | 29 | - Linked tasks `[[ ]]` no longer render the link's children 30 | 31 | **Fixed:** 32 | 33 | - Pressing Tab on New Task no longer indents the editor window 34 | 35 | ## 2.7.0 (4/1/2025) 36 | 37 | **Added:** 38 | 39 | - Improved Monthly view 40 | - Improved look for Tasks and dragging 41 | 42 | **Fixed:** 43 | 44 | - Fix bug with event times https://github.com/j-palindrome/obsidian-time-ruler/issues/134 45 | - Can't move task with children 46 | - Fix [timezones](https://github.com/joshuatazrein/obsidian-time-ruler/issues/70) in calendars 47 | - duplication error 48 | - fix deadlines being the wrong date sometimes 49 | 50 | ## 2.6.0 (2/17/2025) 51 | 52 | **Added:** 53 | 54 | - Open in main tab option https://github.com/j-palindrome/obsidian-time-ruler/issues/137 55 | 56 | **Changed:** 57 | 58 | - Group by last directory https://github.com/j-palindrome/obsidian-time-ruler/issues/128 59 | 60 | **Fixed:** 61 | 62 | - Duplicating documents that are moved https://github.com/j-palindrome/obsidian-time-ruler/issues/142 63 | - Duplicating emoji tags https://github.com/j-palindrome/obsidian-time-ruler/issues/139 64 | - Fix `max-width` headers https://github.com/j-palindrome/obsidian-time-ruler/issues/124 65 | - Tags leading to crash https://github.com/j-palindrome/obsidian-time-ruler/issues/136 66 | - fix errors with group tasks 67 | - set obsidian api before loading window 68 | - Filter by subheading in Unscheduled? 69 | - Group tasks by document as well as subheading (makes it easier to drag) 70 | 71 | ## 2.5.2 (10/14/2024) 72 | 73 | **Changed:** 74 | 75 | - "Do now" button is now "Do Today" 76 | 77 | **Fixed:** 78 | 79 | - No longer displaying [remaining days as decimal](https://github.com/j-palindrome/obsidian-time-ruler/issues/133) 80 | - Support [tag sorting](https://github.com/j-palindrome/obsidian-time-ruler/issues/96) 81 | 82 | ## 2.5.0 (9/28/2024) 83 | 84 | **Added:** 85 | 86 | - Add settings for [remaining days](https://github.com/joshuatazrein/obsidian-time-ruler/issues/112) in deadlines 87 | - Add support for [ICS Calendar repeats](https://github.com/joshuatazrein/obsidian-time-ruler/issues/50) 88 | 89 | **Fixed:** 90 | 91 | - Fixed this bug: [Empty/Cleared Interface when time ruler is moved to another window · Issue #102 · joshuatazrein/obsidian-time-ruler · GitHub](https://github.com/joshuatazrein/obsidian-time-ruler/issues/102?notification_referrer_id=NT_kwDOBQ8O87M5ODYzNDc1MzMyOjg0ODcyOTQ3) 92 | - [Done tasks marked by Time Ruler are marked with time · Issue #114 · j-palindrome/obsidian-time-ruler · GitHub](https://github.com/j-palindrome/obsidian-time-ruler/issues/114) 93 | - Fixed all-day events overflowing onto the next day's events 94 | 95 | ## 2.4.0 (6/1/2024) 96 | 97 | **Fixed:** 98 | 99 | - Fixed error where tasks didn't show beyond a [week in the future or past](https://github.com/j-palindrome/obsidian-time-ruler/issues/100) 100 | - Fixed error with adding [calendars](https://github.com/j-palindrome/obsidian-time-ruler/issues/109) 101 | - Fixed error with duplicating [durations](https://github.com/j-palindrome/obsidian-time-ruler/issues/111) 102 | - Full support for [emojis](https://github.com/j-palindrome/obsidian-time-ruler/issues/113) in task titles. 103 | - Fixed incorrect parsing of [tags in task titles](https://github.com/j-palindrome/obsidian-time-ruler/issues/116) 104 | 105 | **Changed:** 106 | 107 | - Show only first last part of task headings (enclosing file or folder) 108 | 109 | **Added:** 110 | 111 | - Add support for [ICS Calendar repeats](https://github.com/joshuatazrein/obsidian-time-ruler/issues/50) 112 | 113 | ## 2.3.0 (4/1/2023) 114 | 115 | **Added:** 116 | 117 | - Added move button (blue right arrow) to move tasks to different files 118 | - Added option to hide unscheduled subtasks from Time Ruler, streamlining the display 119 | 120 | **Changed:** 121 | 122 | - Removed "Upcoming" section, due tasks now show with their path/priority group 123 | 124 | **Fixed:** 125 | 126 | - Fixed bug with [length](https://github.com/joshuatazrein/obsidian-time-ruler/issues/101) field, it is now called "duration" (although "length" is still recognized) 127 | - Fixed bug with [links](https://github.com/joshuatazrein/obsidian-time-ruler/issues/107) being recognized as tags 128 | - Reorder headings within "Hybrid" sort mode 129 | 130 | ## 2.2.0 (1/31/2023) 131 | 132 | **Changed:** 133 | 134 | - Reintegrated "Now" view back to today, option to expand/collapse 135 | 136 | **Added:** 137 | 138 | - Option to switch between timer events (notification or sound) 139 | - Search sorted by best match 140 | - Fixed error reporting in Calendars 141 | - Mobile optimizations 142 | - Option to switch between sound and notification for timer 143 | 144 | **Fixed:** 145 | 146 | - Rescheduling high-priority tasks retains their priority 147 | 148 | ## 2.1.0 (1/1/2023) 149 | 150 | **Added:** 151 | 152 | - **Now** view: timer has been moved to its own pane, which collects all incomplete tasks scheduled for past times. Now is a focused view for current tasks. 153 | 154 | **Fixed:** 155 | 156 | - Bug where events crossing date boundaries render as 0 length 157 | - Smoothed out deleting multiple tasks at once 158 | - Improved look of dragged tasks 159 | 160 | ## 2.0.3 (12/25/2023) 161 | 162 | **Fixed:** 163 | 164 | - Quick fix for menu hiding too quickly 165 | 166 | **Added:** 167 | 168 | - Better UI for dragging 169 | 170 | ## 2.0.2 (12/25/2023) 171 | 172 | **Fixed:** 173 | 174 | - Quick fix for Daily Notes not existing. 175 | - Fixed React keys for `Buttons` 176 | 177 | ## 2.0.1 (12/25/2023) 178 | 179 | **Fixed:** 180 | 181 | - Scheduling tasks for today 182 | 183 | ## 2.0.0 (12/24/2023) 184 | 185 | **Major changes:** 186 | 187 | - Added **Queries!** `[query:: ]` tasks will auto-assign their children based on a Dataview query. Useful for automatic scheduling! 188 | - Search is now a "Jump to:" search a task to jump to it within Time Ruler. 189 | - Three layouts are now available: One (single date with split day/hours), Row (rolling view of days), and Grid (week view). 190 | - Four options for grouping: Path, Priority, Hybrid (priority first, then path if no priority set), or None 191 | - Times now exist alongside scheduled tasks, saving vertical space, and are no longer full-width. To schedule, drag tasks on top of the times to the right. 192 | - Tasks now have handles at the right for scheduling/dragging. 193 | 194 | **Added:** 195 | 196 | - Day start setting now sets when days transition, allowing days to extend past 12 AM 197 | - Use Daily Note template when creating new daily notes 198 | - Improved styling: [borders between dates](https://github.com/joshuatazrein/obsidian-time-ruler/issues/88#issuecomment-1846164954) and button layout for grid view 199 | - Option to toggle times moved to menu, turn on/off from within Time Ruler 200 | 201 | **Fixed:** 202 | 203 | - Parsing error with daily notes 204 | - New tasks in notes with headings create them before the first heading, unless one is selected 205 | 206 | ## 1.7.0 (12/5/2023) 207 | 208 | **Added:** 209 | 210 | - Drag target to delete tasks & their children 211 | - Collapse headings and events 212 | - Support [| based link text](https://github.com/joshuatazrein/obsidian-time-ruler/issues/81) 213 | - Option to extend blocks until next for easier time-blocking 214 | - [Show past dates](https://github.com/joshuatazrein/obsidian-time-ruler/issues/62?notification_referrer_id=NT_kwDOBQ8O87M3ODg1NjIyNTg3Ojg0ODcyOTQ3#issuecomment-1742924623) 215 | - Show completed tasks in Search 216 | - Calendar [grid view](https://github.com/joshuatazrein/obsidian-time-ruler/issues/84) 217 | 218 | **Improved:** 219 | 220 | - Timer View has simpler UI 221 | 222 | **Refactored:** 223 | 224 | - Moved settings in `AppStore` to consolidated object 225 | 226 | ## 1.6.0 (11/25/2023) 227 | 228 | **Fixed:** 229 | 230 | - Issue with not all [dates displaying](https://github.com/joshuatazrein/obsidian-time-ruler/issues/83) 231 | 232 | **Added:** 233 | 234 | - Option to collapse/expand subtasks 235 | 236 | **Improved:** 237 | 238 | - Subtasks of Page tasks show up as subtasks of that Page 239 | - Subtasks are also grouped by heading 240 | 241 | **Refactored:** 242 | 243 | - Streamlined `dailyNoteInfo` functions 244 | - Headings now are defined with a string, not an object 245 | - Easier task nesting 246 | 247 | ## 1.5.3 (11/20/2023) 248 | 249 | **Fixed:** 250 | 251 | - Can't find Daily Notes [config info](https://github.com/joshuatazrein/obsidian-time-ruler/issues/80) 252 | - can't click on untitled tasks 253 | - Glitch with dragging event durations 254 | - Don't show deadlines before their scheduled date 255 | - Major performance improvements for DOM 256 | 257 | ## 1.5.2 (10/25/2023) 258 | 259 | **Added:** 260 | 261 | - Support for [ICS Timezones](https://github.com/joshuatazrein/obsidian-time-ruler/issues/65) 262 | 263 | **Fixed:** 264 | 265 | - Error with default Dataview queries being [incorrect](https://github.com/joshuatazrein/obsidian-time-ruler/issues/71) 266 | 267 | ## 1.5.1 (10/22/2023) 268 | 269 | **Added:** 270 | 271 | - Add bulk edits for task times 272 | 273 | **Fixed:** 274 | 275 | - [Bug with lengths](https://github.com/joshuatazrein/obsidian-time-ruler/issues/68#event-10732474581) 276 | - Preserve [due times](https://github.com/joshuatazrein/obsidian-time-ruler/issues/66#issuecomment-1753184899) 277 | 278 | **Improved:** 279 | 280 | - Optimized [performance](https://github.com/joshuatazrein/obsidian-time-ruler/issues/48): now only changed files are loaded in. 281 | 282 | ## 1.5.0 (10/08/2023) 283 | 284 | **Added:** 285 | 286 | - Support for [full notes](https://github.com/joshuatazrein/obsidian-time-ruler/issues/10#issuecomment-1655804209) as tasks 287 | - Support for FullCalendar note events 288 | 289 | **Fixed:** 290 | 291 | - [Due date removal on reschedule](https://github.com/joshuatazrein/obsidian-time-ruler/issues/66) 292 | - Events no longer have leading extra tick 293 | 294 | ## 1.4.0 (9/22/2023) 295 | 296 | **Fixed:** 297 | 298 | - Fixed [tag search regex](https://github.com/joshuatazrein/obsidian-time-ruler/issues/58) 299 | - Fixed [completed tasks showing in scheduled](https://github.com/joshuatazrein/obsidian-time-ruler/issues/57) 300 | - Fixed [not loading tasks correctly](https://github.com/joshuatazrein/obsidian-time-ruler/issues/53#issuecomment-1731714675) 301 | 302 | **Added:** 303 | 304 | - Resizeable split between [all day and hourly view](https://github.com/joshuatazrein/obsidian-time-ruler/issues/45) 305 | - Add more sounds to [timer](https://github.com/joshuatazrein/obsidian-time-ruler/issues/43) 306 | - Option to [add tasks at start or end of headings](https://github.com/joshuatazrein/obsidian-time-ruler/issues/12) 307 | - Add custom Tasks [classes](https://github.com/joshuatazrein/obsidian-time-ruler/issues/46#issuecomment-1710172169): now `task-due`, `task-scheduled`, `data-task`, `task-priority`, and additional `task-length` and `task-reminder` classes are added to those parts of tasks, so you can style them with CSS snippets. Also added `time-ruler-heading`, and `time-ruler-block` classes to style headings and blocks. 308 | - Support [custom statuses](https://github.com/joshuatazrein/obsidian-time-ruler/issues/28) - you can now add your own custom status styling to Time Ruler. 309 | 310 | ## 1.3.3 (9/11/2023) 311 | 312 | **Fixed:** 313 | 314 | - Bug with emoji date keys 315 | 316 | **Added:** 317 | 318 | - 24-hour [format](https://github.com/joshuatazrein/obsidian-time-ruler/issues/51) 319 | - Custom JavaScript for [filtering](https://github.com/joshuatazrein/obsidian-time-ruler/issues/49) 320 | - Command to open in [main tab](https://github.com/joshuatazrein/obsidian-time-ruler/issues/52) 321 | 322 | ## 1.3.2 (9/7/2023) 323 | 324 | **Fixed:** 325 | 326 | - Fixed parser to allow for scheduling in Daily notes again 327 | - Made headings smaller to be less distracting 328 | 329 | **Added:** 330 | 331 | - Option to [hide/show headings](https://github.com/joshuatazrein/obsidian-time-ruler/issues/11#issuecomment-1655862428) 332 | - Priority sort [option](https://github.com/joshuatazrein/obsidian-time-ruler/issues/16): you can now sort tasks by due, scheduled, & priority 333 | 334 | ## 1.3.1 (9/2/2023) 335 | 336 | **Added:** 337 | 338 | - `scheduled` and `due` modes in Search now sort tasks by scheduled or due 339 | - Tasks without set lengths or deadlines still have a draggable handle to set them 340 | 341 | **Fixed:** 342 | 343 | - Removed automatic scroll on hover for date buttons, it was too fast and unpredictable. 344 | 345 | ## 1.3.0 (9/2/2023) 346 | 347 | ** Added:** 348 | 349 | - New quick-add for tasks: Click or drag the `+` button to a time, enter the task title, and select a file! 350 | - Red line to make seeing [current time easier](https://github.com/joshuatazrein/obsidian-time-ruler/issues/16) 351 | - Easier way to drag [task times]() 352 | - Option to set `Day Planner` format as default. 353 | - Use `Day Planner` format in any note (not just Daily) and add dates to it 354 | - Options to drag [deadlines](https://github.com/joshuatazrein/obsidian-time-ruler/issues/20) in addition to scheduled time 355 | 356 | ** Fixed:** 357 | 358 | - Day Planner parsing is now more consistent. 359 | 360 | ** Documented:** 361 | 362 | - How to format [query sources](https://github.com/joshuatazrein/obsidian-time-ruler/issues/37) 363 | - Better description of [task formats](https://github.com/joshuatazrein/obsidian-time-ruler/issues/38) 364 | 365 | ## 1.2.0 (8/20/2023) 366 | 367 | ** Added:** 368 | 369 | - Support for [changing day start and hours](https://github.com/joshuatazrein/obsidian-time-ruler/issues/30) 370 | - Formatting for [many tags in tasks](https://github.com/joshuatazrein/obsidian-time-ruler/issues/29#issuecomment-1680609684) 371 | - Formatting for [block references](https://github.com/joshuatazrein/obsidian-time-ruler/issues/29#issuecomment-1680609684) and [here](https://github.com/joshuatazrein/obsidian-time-ruler/issues/32) 372 | - Allow for [singe-digit hours](https://github.com/joshuatazrein/obsidian-time-ruler/issues/27) in simple mode 373 | - More specific Dataview custom filter [at task level](https://github.com/joshuatazrein/obsidian-time-ruler/issues/18) 374 | 375 | ## 1.1.0 (8/9/2023) 376 | 377 | ** Added:** 378 | 379 | - Right-click option to [schedule tasks for now](https://github.com/joshuatazrein/obsidian-time-ruler/issues/16#event-9959008621) and to unschedule tasks 380 | - Support [emoji and custom status](https://github.com/joshuatazrein/obsidian-time-ruler/issues/26) displaying in tasks 381 | - Filter by [custom status](https://github.com/joshuatazrein/obsidian-time-ruler/issues/25) 382 | - A [simple mode](https://github.com/joshuatazrein/obsidian-time-ruler/issues/21) with `HH:mm-HH:mm` formatting for scheduled times. 383 | - larger drop target for [dates](https://github.com/joshuatazrein/obsidian-time-ruler/issues/24) 384 | 385 | ** Improved:** 386 | 387 | - Moved search, refresh, and view buttons to a collapsible menu 388 | 389 | ## 1.0.5 (8/6/2023) 390 | 391 | ** Added:** 392 | 393 | - Support for [Obsidian Reminder](https://github.com/joshuatazrein/obsidian-time-ruler/issues/20) 394 | 395 | ** Improved:** 396 | 397 | - Time Ruler now auto-detects field formats (Dataview, Tasks, and Full Calendar) and will format changed tasks appropriately. When auto-detecting is impossible, defaults to selected setting. 398 | - Timer focus mode 399 | 400 | ** Fixed:** 401 | 402 | - Error with drag `id`s for all-day headings 403 | - Scheduled tasks with deadlines disappearing 404 | 405 | ## 1.0.4 (8/4/2023) 406 | 407 | ** Fixed:** 408 | 409 | - `Unscheduled` button is now full-width in Day view 410 | - Preserves [custom statuses](https://github.com/joshuatazrein/obsidian-time-ruler/issues/19) on edit 411 | - Certain notes get mistaken for daily notes 412 | 413 | ** Documented:** 414 | 415 | - Update documentation for [custom Dataview filters](https://github.com/joshuatazrein/obsidian-time-ruler/issues/18) and throw an error when they are invalid 416 | 417 | ## 1.0.3 (7/30/2023) 418 | 419 | ** Fixed:** 420 | 421 | - Issue with Time Ruler not updating when Dataview index wasn't ready yet 422 | - Search shows tasks in today's daily note under the [proper heading](https://github.com/joshuatazrein/obsidian-time-ruler/issues/14) 423 | - if daily notes folder is not set, headings still format daily notes nicely. 424 | 425 | ** Added:** 426 | 427 | - `Unscheduled` button to [drag tasks to](https://github.com/joshuatazrein/obsidian-time-ruler/issues/13) 428 | - [Filter](https://github.com/joshuatazrein/obsidian-time-ruler/issues/16#event-9959008621) by tag, priority, path, and heading in Search 429 | 430 | ** Documented:** 431 | 432 | - added `.gif` to explain dragging more, added pictures of features 433 | 434 | ** Refactored:** 435 | 436 | - Moved `Heading` and `Group` components to their own files 437 | - `SearchStatus` can now be set directly in the app store 438 | 439 | ## 1.0.2 (7/28/2023) 440 | 441 | ** Fixed:** 442 | 443 | - bug where notes and headings get mistaken for daily notes and titled "Daily: ..." (this was an issue with the Regex used to parse daily note titles), responding to [This issue](https://github.com/joshuatazrein/obsidian-time-ruler/issues/11#issuecomment-1655862428) 444 | - Saving tasks no longer [strips recurrence information](https://github.com/joshuatazrein/obsidian-time-ruler/issues/9#issuecomment-1655801314) 445 | - Saving tasks no longer [strips links](https://github.com/joshuatazrein/obsidian-time-ruler/issues/9#issuecomment-1655801314) 446 | 447 | ** Improved:** 448 | 449 | - Tasks without a scheduled time now show up as a [single block at the top of the day](https://github.com/joshuatazrein/obsidian-time-ruler/issues/11#issuecomment-1655862428), instead of separated into previous times. 450 | 451 | ** Refactored:** 452 | 453 | - Split up some of `ObsidianApi` class into independent functions. 454 | 455 | ## 1.0.1 (7/22/2023) 456 | 457 | ** Added:** 458 | 459 | - Support for including/excluding custom statuses 460 | - Ability to reorder headings with drag-and-drop 461 | - Calendar view (according to the [Calendar view?](https://github.com/joshuatazrein/obsidian-time-ruler/issues/1) request). Calendar View is daily instead of hourly, showing a vertical day-by-day list of your tasks and an expanded, calendar-style arrangement for switching dates. Switch between this and the hourly view to get a more or less granular view of your tasks. 462 | - Daily note shows at the top of the search box for easy access 463 | 464 | ** Improved:** 465 | 466 | - Removed Upcoming view, integrated due dates with rest of days. Now tasks with due dates will show up as links each day from when they are scheduled until they are due. 467 | - Removed Unscheduled view, improved search to show filterable tasks 468 | 469 | ## 1.0.0 (6/28/2023) 470 | 471 | ** Added:** 472 | 473 | - custom Dataview filter for tasks (according to the [Custom Statuses](https://github.com/joshuatazrein/obsidian-time-ruler/issues/3) request) 474 | - Plus buttons to add new tasks at specific times 475 | 476 | ** Fixed:** 477 | 478 | - [issue](https://github.com/joshuatazrein/obsidian-time-ruler/issues/2) with formatting tasks for Tasks plugin 479 | - [issue](https://github.com/joshuatazrein/obsidian-time-ruler/issues/4) with stripping tags from task when moved 480 | - issue where you can't drag length of tasks with children 481 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | joshuatreinier@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | If you would like to contribute: 2 | 1. Submit an issue proposing your changes. 3 | 2. Fork this repository. 4 | 3. Run `npm install`. 5 | 4. Run `npm run dev` to develop. When testing changes, just run `cp dist/* "/plugins/time-ruler"`. 6 | It's recommended to install hot-reload and include a `.hotreload` file in the `dist` directory so changes reload automatically. 7 | 5. When finished, please create a Pull Request describing your changes. 8 | 9 | Submit an issue on GitHub if you have any questions! 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Joshua Tazman Reinier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Time Ruler combines the best parts of a nested tasklist and an event-based calendar view. Drag-and-drop tasks to time-block and reschedule, and view tasks on top of read-only online calendars. Integrates well with the Tasks, FullCalendar, Reminder, and Obsidian Day Planner plugins. 2 | 3 | ![cover](assets/time-ruler-cover.png) 4 | 5 | # Features 6 | 7 | - **Reads and writes tasks** in a variety of formats (Dataview inline fields, Tasks plugin emojis, or Full Calendar task-events) 8 | - **Time-blocks** with nested tasks 9 | - **Search** and filter scheduled, unscheduled, and due tasks 10 | - **Drag 'n drop** tasks to reschedule and change duration 11 | - Show all **files and headings**, drag to create new tasks 12 | - **Create** new tasks at specific times via drag-and-drop 13 | - Read-only **online calendars** via sharing links 14 | - Integrated **timer/stopwatch** for Pomodoro and time-tracking 15 | - Play a **sound** when you check a task! 16 | 17 | # Documentation 18 | 19 | Time Ruler uses the [Dataview](obsidian://show-plugin?id=dataview) plugin to read tasks, so please install it before you use this plugin. 20 | 21 | ## Reading tasks 22 | 23 | Task metadata can be specified in any of the following formats: 24 | 25 | - **Day Planner**: `YYYY-MM-DD hh:mm - hh:mm task content > yyyy-mm-dd [query:: ] ?/!/!!/!!!` 26 | - Beginning date/times: scheduled & duration, `>`: due date. 27 | - In Daily Notes, you can omit the date from scheduled and Time Ruler will parse the date from the note title. Only 24-hour format times are understood. 28 | - You can omit minutes and only put `hh - hh` for times. 29 | - **Dataview**: `[scheduled:: yyyy-mm-ddThh:mm] [due:: yyyy-mm-dd] [duration:: #h#m] [priority:: lowest/low/medium/high/highest] [query:: ]` 30 | - `#h#m` examples: `1h`, `1h30m`, `1.5h`, etc. Any [Dataview duration](https://blacksmithgu.github.io/obsidian-dataview/annotation/types-of-metadata/#duration) will work. 31 | - **Tasks**: `[startTime:: hh:mm] [duration:: #h#m] [query:: ] ⏳ yyyy-mm-dd 📅 yyyy-mm-dd ⏬/🔽/🔼/⏫/🔺` 32 | - ⏳: scheduled, 📅: due. See the [Tasks docs](https://publish.obsidian.md/tasks/Getting+Started/Dates) for a full description. 33 | - Order matters: inline fields must go before tasks emojis or Tasks won't understand it. 34 | - **Full Calendar**: `[date:: yyyy-mm-dd] [startTime:: hh:mm] [endTime:: hh:mm] or [allDay:: true] [due:: yyyy-mm-dd] [priority:: lowest/low/medium/high/highest] [query:: ]` 35 | 36 | ### Queries 37 | 38 | As of version 2.0, Time ruler includes **queries**, tasks whose children are taken from a Dataview search. Some example searches: 39 | 40 | - `[query:: "Path/to/folder"]` (notice the double-quotes, which are used for Dataview folder sources. Use `#` to designate a heading afterwards) 41 | - `[query:: "#heading"]` (headings begin with #, but are framed in double-quotes). 42 | - `[query:: #tag]` 43 | - `[query:: incoming([[link to note]]) WHERE scheduled and !due]` 44 | - Time Ruler supports sources, as well as WHERE queries with task properties. Read the full reference [here](https://blacksmithgu.github.io/obsidian-dataview/). 45 | 46 | ### Pages (tasks from full notes) 47 | 48 | Time Ruler now reads **Full Calendar note events** as well as any page with `completed: false` or `completed: null` and the following optional Properties: 49 | 50 | - `scheduled: date` 51 | - `due: date` 52 | - `priority: highest/high/medium/low/lowest` 53 | - `duration: #h#m` (a Dataview duration - see above) 54 | - `start: date` 55 | 56 | ### Reminder 57 | 58 | You can specify any of the [Obsidian Reminder](https://obsidian-reminder.cf/guide/set-reminders.html#reminder-format) formats as well. 59 | 60 | When editing a task via drag-and-drop, tasks are converted back to the formatting detected in the task. If this is not possible, the user's preferred format (Day Planner, Dataview, Tasks, or Full Calendar) is used. This can be changed in Settings. 61 | 62 | _Note:_ Double-spaces are used between brackets because without them, Obsidian thinks they are markdown links. 63 | 64 | ## Scheduling tasks 65 | 66 | 67 | 68 | - To **reschedule** a task, drag-and-drop the task onto the target block or time. You can drag a task to one of the day buttons or a day's heading to reschedule to that day. Click on a task to jump to it in Obsidian. 69 | - To **create** a new scheduled task, drag the `+` button (top left) onto a time. For unscheduled, simply click the `+` button. 70 | - To **move** a task to a different file or heading, drag it to the blue `->` button (top left). 71 | - To change the **duration** of a task, drag its duration onto a time. 72 | - To change the **deadline** of a task, drag its deadline onto a time. 73 | - To **unschedule** a task, drag the task to the `Unscheduled` button. 74 | - You can also drag **groups, headings, and blocks** to reschedule all of the tasks contained in them. 75 | - Dragging and holding over a **date button** will scroll to that date, allowing you to drop the task there. 76 | 77 | ## Online calendars 78 | 79 | - To **import** a calendar, simply copy a shared link (iCal format) into Settings (Ensure that the Access Permission for your calendar is set to public!). 80 | - **Events** show as blocks which can contain tasks scheduled at the same time. You can drag an event to reschedule the tasks contained, but the event is read-only. 81 | - To **refresh** events, click the `Refresh` button (the circular arrow) in the toolbar. 82 | - For Google Calendar Users - You can find your iCal link at - "Calendar Settings\\[Your Calendar]\\Integrate Calendar\\Public address in iCal format\\". 83 | 84 | ## Buttons 85 | 86 | - **Search**: Jump to a specific task. 87 | - **Unscheduled:** Drag a task here to unschedule it. Click to show unscheduled tasks (shortcut to Search view). 88 | - **Dates:** Click to scroll to that date, drag a task on top to schedule it for that date. 89 | - **Quick add:** To create a task, drag the `+` button onto a time. By default, you will create in today's Daily note, but you can pick a specific heading or file. 90 | - Click the **menu** `...` button (top-left) to view settings. 91 | - **Past / Future**: show dates going to past or future. Past shows only completed tasks, while future shows uncompleted. 92 | - **Reload**: Reload Obsidian tasks and online calendars. 93 | - **Hide / Show Times**: Toggle the display of tic marks on Time Ruler. 94 | - **Hours / Days / Weeks view**: Toggle between daily and hourly views. In daily view, hours are hidden. 95 | 96 | ## Timer 97 | 98 | ![timer](assets/timer.png) 99 | 100 | - To start a **stopwatch**, click the play button without any time entered. 101 | - To start a **timer**, enter an amount in minutes and press the play button or "Enter." 102 | - You can **add or subtract** 5 minutes while the timer is playing by clicking the `+` and `-` buttons. 103 | - Click the `focus` button (outwards arrows) to expand the timer and focus on current tasks. 104 | 105 | ## Customization Settings 106 | 107 | - **Custom Filter**: This is passed to `dv.pages()`. It only filters out certain pages, and can't filter specific tasks within those. Use Custom Statuses to filter out tasks. See this [link](https://blacksmithgu.github.io/obsidian-dataview/api/code-reference/#dvpagessource) for `dv.pages()` and this [link](https://blacksmithgu.github.io/obsidian-dataview/reference/sources/) for how to format query sources. 108 | image 109 | 110 | **Note:** do not include `dv.pages()` in the entry box—only the query string passed to the function. 111 | 112 | - Include a folder and its children: `"folder"` 113 | - Exclude a folder and its children: `-"folder"` 114 | - Include two folders and exclude a third: `"folder" or "folder2" and -"folder3"` 115 | - Include tags: `#tag or #tag2 and -#tag3` 116 | - Include pages which link to a page: `[[page]]` 117 | - Include links from page: `outgoing([[page]])` 118 | 119 | - **Filter Function**: Provides a filtering function that uses the data passed from dv.pages()['file']['tasks'] 120 | image 121 | - Filtering out empty tasks: `(tasks) => tasks.where(task => task.text != "")` 122 | - Filtering out empty tags: ` (tasks) => tasks.where(task => task.tags.length===0)` 123 | - **Custom Status**: Either **include only** certain custom statuses, or **exclude all** specified custom statuses (characters between `[ ]` in tasks). 124 | - To style Time Ruler, the following classes are added: 125 | - `task-list-item`, `task-list-item-checkbox`, `task-due`, `task-scheduled`, `data-task`, and `task-priority` coincide with [Tasks plugin](https://publish.obsidian.md/tasks/Advanced/Styling) styling, and additional `task-duration` and `task-reminder` classes are added to those parts of tasks, so you can style them with CSS snippets (unfortunately, you will need to add your own custom status styling, due to custom themes being formatted for the Obsidian markdown editor, and not Time Ruler). 126 | - `time-ruler-heading` and `time-ruler-block` classes let you style headings and blocks. 127 | - `time-ruler-container` is added to the plugin's container element. 128 | 129 | # Credit 130 | 131 | - Many thanks to the [Dataview](obsidian://show-plugin?id=dataview), [Tasks](obsidian://show-plugin?id=obsidian-tasks-plugin), and [Full Calendar](obsidian://show-plugin?id=obsidian-full-calendar) plugins for setting the standards and formatting for managing tasks across the Obsidian vault. 132 | - The Dataview plugin's MetadataCache made Time Ruler possible, so a huge thanks for the automatic indexing and parsing of task metadata. 133 | 134 | # Network Usage 135 | 136 | Upon calendar refresh, the plugin makes a single GET request to any calendars you are subscribed to, which downloads their events in .ics format. 137 | 138 | # Changelog 139 | 140 | For more information on past and future updates, please consult the [roadmap and changelog](https://github.com/joshuatazrein/obsidian-time-ruler/blob/master/CHANGELOG.md). 141 | 142 | If you appreciate this plugin, I would love your support for further development! 143 | 144 | Buy Me A Coffee 145 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The latest release is supported. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | If you see a vulnerability, open an issue on GitHub. For more sensitive concerns you can email me at [joshuatreinier@gmail.com](mailto:joshuatreinier@gmail.com). 10 | -------------------------------------------------------------------------------- /assets/buttons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/assets/buttons.png -------------------------------------------------------------------------------- /assets/dragging-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/assets/dragging-example.gif -------------------------------------------------------------------------------- /assets/dragging-example.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/assets/dragging-example.mov -------------------------------------------------------------------------------- /assets/tick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/assets/tick.wav -------------------------------------------------------------------------------- /assets/time-ruler-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/assets/time-ruler-cover.png -------------------------------------------------------------------------------- /assets/timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/assets/timer.png -------------------------------------------------------------------------------- /assets/timeruler.RPP: -------------------------------------------------------------------------------- 1 | 4 | RIPPLE 0 5 | GROUPOVERRIDE 0 0 0 6 | AUTOXFADE 1 7 | ENVATTACH 3 8 | POOLEDENVATTACH 0 9 | MIXERUIFLAGS 11 48 10 | PEAKGAIN 1 11 | FEEDBACK 0 12 | PANLAW 1 13 | PROJOFFS 0 0 0 14 | MAXPROJLEN 0 0 15 | GRID 3199 8 1 8 1 0 0 0 16 | TIMEMODE 1 5 -1 30 0 0 -1 17 | VIDEO_CONFIG 0 0 256 18 | PANMODE 3 19 | CURSOR 0 20 | ZOOM 100 0 0 21 | VZOOMEX 6 0 22 | USE_REC_CFG 0 23 | RECMODE 1 24 | SMPTESYNC 0 30 100 40 1000 300 0 0 1 0 0 25 | LOOP 0 26 | LOOPGRAN 0 4 27 | RECORD_PATH "" "" 28 | 31 | 33 | RENDER_FILE "" 34 | RENDER_PATTERN "" 35 | RENDER_FMT 0 2 0 36 | RENDER_1X 0 37 | RENDER_RANGE 0 0 816 18 1000 38 | RENDER_RESAMPLE 3 0 1 39 | RENDER_ADDTOPROJ 0 40 | RENDER_STEMS 0 41 | RENDER_DITHER 0 42 | TIMELOCKMODE 1 43 | TEMPOENVLOCKMODE 1 44 | ITEMMIX 1 45 | DEFPITCHMODE 589824 0 46 | TAKELANE 1 47 | SAMPLERATE 44100 0 0 48 | 51 | LOCK 1 52 | 60 | GLOBAL_AUTO -1 61 | TEMPO 120 4 4 62 | PLAYRATE 1 0 0.25 4 63 | SELECTION 0 0 64 | SELECTION2 0 0 65 | MASTERAUTOMODE 0 66 | MASTERTRACKHEIGHT 0 0 67 | MASTERPEAKCOL 16576 68 | MASTERMUTESOLO 0 69 | MASTERTRACKVIEW 0 0.6667 0.5 0.5 0 -1 0 0 0 0 -1 -1 0 70 | MASTERHWOUT 0 0 1 0 0 0 0 -1 71 | MASTER_NCH 2 2 72 | MASTER_VOLUME 1 0 -1 -1 1 73 | MASTER_PANMODE 3 74 | MASTER_FX 1 75 | MASTER_SEL 0 76 | 84 | 92 | 94 | 118 | 120 | > 121 | -------------------------------------------------------------------------------- /dist/.hotreload: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/dist/.hotreload -------------------------------------------------------------------------------- /dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "time-ruler", 3 | "name": "Time Ruler", 4 | "version": "2.7.1", 5 | "minAppVersion": "0.15.0", 6 | "description": "A drag-and-drop time ruler combining the best of a task list and a calendar view (integrates with Tasks, Full Calendar, and Dataview).", 7 | "author": "Joshua Tazman Reinier", 8 | "authorUrl": "https://joshuareinier.com", 9 | "fundingUrl": "https://bmc.link/joshuatreinier", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild' 2 | import process from 'process' 3 | import builtins from 'builtin-modules' 4 | import postcss from 'esbuild-postcss' 5 | import esBuildCopyStaticFiles from 'esbuild-copy-static-files' 6 | 7 | const banner = `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | ` 12 | 13 | const MODE = process.env.MODE 14 | const VAULT = process.env.VAULT 15 | 16 | let entryPoints = [`src/main.ts`, `src/styles.css`] 17 | 18 | const plugins = [ 19 | postcss(), 20 | esBuildCopyStaticFiles({ 21 | src: `manifest.json`, 22 | dest: `dist/manifest.json`, 23 | dereference: true, 24 | errorOnExist: false, 25 | preserveTimestamps: true, 26 | }), 27 | ] 28 | 29 | if (VAULT) { 30 | plugins.push( 31 | esBuildCopyStaticFiles({ 32 | src: `dist`, 33 | dest: `${VAULT}/.obsidian/plugins/time-ruler`, 34 | dereference: true, 35 | errorOnExist: false, 36 | preserveTimestamps: true, 37 | }) 38 | ) 39 | } 40 | 41 | const context = await esbuild.context({ 42 | banner: { 43 | js: banner, 44 | }, 45 | entryPoints, 46 | bundle: true, 47 | external: [ 48 | 'obsidian', 49 | 'electron', 50 | '@codemirror/autocomplete', 51 | '@codemirror/collab', 52 | '@codemirror/commands', 53 | '@codemirror/language', 54 | '@codemirror/lint', 55 | '@codemirror/search', 56 | '@codemirror/state', 57 | '@codemirror/view', 58 | '@lezer/common', 59 | '@lezer/highlight', 60 | '@lezer/lr', 61 | ...builtins, 62 | ], 63 | format: 'cjs', 64 | target: 'es2018', 65 | logLevel: 'info', 66 | sourcemap: MODE === 'production' ? false : 'inline', 67 | treeShaking: true, 68 | outdir: 'dist', 69 | 70 | loader: { 71 | '.mp3': 'dataurl', 72 | '.svg': 'text', 73 | '.png': 'dataurl', 74 | }, 75 | plugins, 76 | }) 77 | 78 | if (MODE === 'production') { 79 | await context.rebuild() 80 | process.exit(0) 81 | } else { 82 | await context.watch() 83 | } 84 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "time-ruler", 3 | "name": "Time Ruler", 4 | "version": "2.7.1", 5 | "minAppVersion": "0.15.0", 6 | "description": "A drag-and-drop time ruler combining the best of a task list and a calendar view (integrates with Tasks, Full Calendar, and Dataview).", 7 | "author": "Joshua Tazman Reinier", 8 | "authorUrl": "https://joshuareinier.com", 9 | "fundingUrl": "https://bmc.link/joshuatreinier", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /obsidian-time-ruler.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "typewriterScrollMode.enable": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-time-ruler", 3 | "version": "2.6.0", 4 | "description": "Time Ruler", 5 | "main": "main.js", 6 | "keywords": [], 7 | "author": "Joshua Reinier", 8 | "license": "MIT", 9 | "scripts": { 10 | "dev": "node esbuild.config.mjs", 11 | "dev:local": "VAULT=\"$HOME/Documents/Joshua\" npm run dev", 12 | "build": "tsc -noEmit -skipLibCheck && MODE=production node esbuild.config.mjs", 13 | "check": "tsc -noEmit -skipLibCheck" 14 | }, 15 | "dependencies": { 16 | "@dnd-kit/core": "^6.0.8", 17 | "@types/luxon": "^3.3.6", 18 | "ical": "^0.8.0", 19 | "ical2json": "^3.2.0", 20 | "ics": "^3.7.2", 21 | "immer": "^10.0.3", 22 | "jquery": "^3.7.1", 23 | "lodash": "^4.17.21", 24 | "luxon": "^3.4.3", 25 | "moment": "^2.29.4", 26 | "obsidian": "^1.4.11", 27 | "obsidian-dataview": "^0.5.61", 28 | "postcss": "^8.4.23", 29 | "react": "^18.2.0", 30 | "react-dom": "^18.2.0", 31 | "react-timer-hook": "^3.0.7", 32 | "react-usestateref": "^1.0.8", 33 | "tiny-invariant": "^1.3.1", 34 | "typescript": "^5.6.3", 35 | "zustand": "^4.4.6" 36 | }, 37 | "devDependencies": { 38 | "@types/jquery": "^3.5.27", 39 | "@types/lodash": "^4.14.201", 40 | "@types/react": "^18.2.37", 41 | "@types/react-dom": "^18.2.17", 42 | "autoprefixer": "^10.4.16", 43 | "builtin-modules": "^3.3.0", 44 | "esbuild": "^0.19.5", 45 | "esbuild-copy-static-files": "^0.1.0", 46 | "esbuild-postcss": "^0.0.4", 47 | "tailwindcss": "^3.3.5" 48 | }, 49 | "packageManager": "pnpm@10.6.5+sha512.cdf928fca20832cd59ec53826492b7dc25dc524d4370b6b4adbf65803d32efaa6c1c88147c0ae4e8d579a6c9eec715757b50d4fa35eea179d868eada4ed043af" 50 | } 51 | -------------------------------------------------------------------------------- /packages/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' 2 | declare module '*.mp3' 3 | declare module '*.png' 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/app/store.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer' 2 | import { useRef } from 'react' 3 | import { createWithEqualityFn } from 'zustand/traditional' 4 | import CalendarAPI from '../services/calendarApi' 5 | import ObsidianAPI from '../services/obsidianApi' 6 | import { TaskActions } from '../types/enums' 7 | import TimeRulerPlugin, { DEFAULT_SETTINGS } from '../main' 8 | import { DateTime } from 'luxon' 9 | import _ from 'lodash' 10 | import { parseFileFromPath } from '../services/util' 11 | 12 | export type ViewMode = 13 | | 'all' 14 | | 'scheduled' 15 | | 'due' 16 | | 'unscheduled' 17 | | 'priority' 18 | | 'completed' 19 | export type AppState = { 20 | tasks: Record 21 | events: Record 22 | apis: { 23 | obsidian?: ObsidianAPI 24 | calendar?: CalendarAPI 25 | } 26 | dragData: DragData | null 27 | dragMode: 'ripple' | 'normal' 28 | findingTask: string | null 29 | inScroll: number 30 | searchStatus: boolean 31 | 32 | dailyNoteInfo: { 33 | format: string 34 | folder: string 35 | template: string 36 | } 37 | fileOrder: string[] 38 | newTask: null | { task: Partial; type: 'new' | 'move' } 39 | settings: Pick< 40 | TimeRulerPlugin['settings'], 41 | | 'dayStartEnd' 42 | | 'groupBy' 43 | | 'muted' 44 | | 'twentyFourHourFormat' 45 | | 'showCompleted' 46 | | 'extendBlocks' 47 | | 'hideTimes' 48 | | 'borders' 49 | | 'viewMode' 50 | | 'timerEvent' 51 | | 'scheduledSubtasks' 52 | > 53 | collapsed: Record 54 | showingPastDates: boolean 55 | searchWithinWeeks: [number, number] 56 | childWidth: number 57 | timer: { 58 | negative: boolean 59 | maxSeconds: number | null 60 | startISO?: string 61 | playing: boolean 62 | } 63 | recreateWindow: number 64 | dragOffset: number 65 | } 66 | 67 | export const useAppStore = createWithEqualityFn(() => ({ 68 | tasks: {}, 69 | events: {}, 70 | apis: {}, 71 | dragData: null, 72 | findingTask: null, 73 | inScroll: 0, 74 | searchStatus: false, 75 | viewMode: 'hour', 76 | dragMode: 'normal', 77 | fileOrder: [], 78 | dailyNoteInfo: { 79 | format: 'YYYY-MM-DD', 80 | folder: '', 81 | template: '', 82 | }, 83 | newTask: null, 84 | collapsed: {}, 85 | settings: { 86 | dayStartEnd: [0, 24], 87 | groupBy: 'path', 88 | muted: false, 89 | timerEvent: 'notification', 90 | twentyFourHourFormat: false, 91 | showCompleted: false, 92 | extendBlocks: false, 93 | hideTimes: false, 94 | borders: false, 95 | viewMode: 'day', 96 | scheduledSubtasks: false, 97 | }, 98 | showingPastDates: false, 99 | searchWithinWeeks: [-1, 1], 100 | childWidth: 1, 101 | timer: { 102 | negative: false, 103 | maxSeconds: null, 104 | startISO: undefined, 105 | playing: false, 106 | }, 107 | recreateWindow: 0, 108 | dragOffset: 0, 109 | })) 110 | 111 | export const useAppStoreRef = (callback: (state: AppState) => T) => { 112 | const storeValue = useAppStore(callback) 113 | const storeValueRef = useRef(storeValue) 114 | storeValueRef.current = storeValue 115 | return [storeValue, storeValueRef] as [ 116 | typeof storeValue, 117 | typeof storeValueRef 118 | ] 119 | } 120 | 121 | const modify = (modifier: (state: AppState) => void) => 122 | useAppStore.setState(produce(modifier)) 123 | 124 | export const setters = { 125 | set: (newState: Partial) => modify(() => newState), 126 | patchTasks: async (ids: string[], task: Partial) => { 127 | const obsidianAPI = getters.getObsidianAPI() 128 | for (let id of ids) { 129 | const savedTask = { ...getters.getTask(id), ...task } 130 | if (task.scheduled === TaskActions.DELETE) delete savedTask.scheduled 131 | await obsidianAPI.saveTask(savedTask) 132 | } 133 | if (task.completion) obsidianAPI.playComplete() 134 | }, 135 | patchCollapsed: async (ids: string[], collapsed: boolean) => { 136 | modify((state) => { 137 | for (let id of ids) { 138 | state.collapsed[id] = collapsed 139 | } 140 | }) 141 | }, 142 | updateFileOrder: (file: string, beforeFile: string) => { 143 | const obsidianAPI = getters.getObsidianAPI() 144 | obsidianAPI.updateFileOrder(file, beforeFile) 145 | }, 146 | patchTimer: (timer: Partial) => { 147 | modify((state) => { 148 | state.timer = { ...state.timer, ...timer } 149 | }) 150 | }, 151 | } 152 | 153 | export const getters = { 154 | getEvent: (id: string) => useAppStore.getState().events[id], 155 | getTask: (id: string) => useAppStore.getState().tasks[id], 156 | getObsidianAPI: () => useAppStore.getState().apis.obsidian as ObsidianAPI, 157 | getCalendarAPI: () => useAppStore.getState().apis.calendar as CalendarAPI, 158 | get: (key: T) => useAppStore.getState()[key], 159 | getApp: () => useAppStore.getState().apis.obsidian!.app, 160 | } 161 | -------------------------------------------------------------------------------- /src/assets/assets.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import pop from './pop.mp3' 3 | import start from './start.mp3' 4 | import timer from './timer.mp3' 5 | 6 | const popSnd = new Audio(pop) 7 | const startSnd = new Audio(start) 8 | const timerSnd = new Audio(timer) 9 | 10 | export const sounds = { 11 | pop: popSnd, 12 | start: startSnd, 13 | timer: timerSnd, 14 | } 15 | 16 | for (let sound of _.values(sounds)) { 17 | sound.autoplay = true 18 | sound.pause() 19 | sound.currentTime = 0 20 | } 21 | -------------------------------------------------------------------------------- /src/assets/pop.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/src/assets/pop.mp3 -------------------------------------------------------------------------------- /src/assets/start.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/src/assets/start.mp3 -------------------------------------------------------------------------------- /src/assets/tick.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/src/assets/tick.mp3 -------------------------------------------------------------------------------- /src/assets/timer.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-palindrome/obsidian-time-ruler/4a9a81f0e88d6c95d1a06b160dded708026cd977/src/assets/timer.mp3 -------------------------------------------------------------------------------- /src/components/Block.tsx: -------------------------------------------------------------------------------- 1 | import { useDraggable } from '@dnd-kit/core' 2 | import _ from 'lodash' 3 | import { DateTime } from 'luxon' 4 | import { useEffect, useState } from 'react' 5 | import { createPortal } from 'react-dom' 6 | import { TaskPriorities, priorityNumberToKey } from 'src/types/enums' 7 | import { shallow } from 'zustand/shallow' 8 | import { setters, useAppStore } from '../app/store' 9 | import { 10 | getChildren, 11 | getHeading, 12 | isDateISO, 13 | parseFileFromPath, 14 | roundMinutes, 15 | splitHeading, 16 | toISO, 17 | } from '../services/util' 18 | import Button from './Button' 19 | import Droppable from './Droppable' 20 | import Group from './Group' 21 | import Hours from './Hours' 22 | import Minutes from './Minutes' 23 | import { COLLAPSE_UNSCHEDULED } from './Unscheduled' 24 | 25 | export type BlockComponentProps = BlockProps & { 26 | hidePaths?: string[] 27 | type: BlockType 28 | id?: string 29 | dragContainer: string 30 | parentId?: string 31 | dragging?: true 32 | } 33 | 34 | export const UNGROUPED = '__ungrouped' 35 | export type BlockType = 36 | | 'event' 37 | | 'unscheduled' 38 | | 'child' 39 | | 'all-day' 40 | | 'upcoming' 41 | export type BlockProps = { 42 | startISO?: string 43 | endISO?: string 44 | tasks: TaskProps[] 45 | events: EventProps[] 46 | blocks: BlockProps[] 47 | title?: string 48 | } 49 | 50 | export default function Block({ 51 | hidePaths = [], 52 | tasks, 53 | type, 54 | id, 55 | dragContainer, 56 | startISO, 57 | endISO, 58 | parentId, 59 | events, 60 | dragging, 61 | blocks, 62 | title, 63 | }: BlockComponentProps) { 64 | let showingTasks = useAppStore((state) => { 65 | const children = _.flatMap(tasks, (task) => getChildren(task, state.tasks)) 66 | // filter out tasks which are children of other tasks 67 | return tasks.filter((task) => !children.includes(task.id)) 68 | }, shallow) 69 | 70 | const topLevel = _.sortBy(showingTasks, 'id') 71 | 72 | const groupedTasks = useAppStore((state) => { 73 | return _.groupBy(topLevel, (task) => 74 | getHeading( 75 | task, 76 | state.dailyNoteInfo, 77 | type === 'upcoming' ? false : state.settings.groupBy, 78 | hidePaths 79 | ) 80 | ) 81 | }) 82 | 83 | const sortedGroups = useAppStore((state) => { 84 | switch (state.settings.groupBy) { 85 | case false: 86 | return _.entries(groupedTasks) 87 | default: 88 | return _.sortBy( 89 | _.entries(groupedTasks), 90 | ([group]) => (group === UNGROUPED ? 0 : 1), 91 | '1.0.priority', 92 | ([group, _tasks]) => { 93 | return state.fileOrder.indexOf(parseFileFromPath(group)) 94 | }, 95 | '1.0.position.start.line' 96 | ) 97 | } 98 | }, shallow) 99 | 100 | const dragData: DragData = { 101 | dragType: 'block', 102 | hidePaths, 103 | tasks: showingTasks, 104 | type, 105 | id, 106 | dragContainer, 107 | startISO, 108 | endISO, 109 | blocks: [], 110 | parentId, 111 | events, 112 | } 113 | const { setNodeRef, attributes, listeners, setActivatorNodeRef } = 114 | useDraggable({ 115 | id: `${id}::${startISO}::${type}::${dragContainer}`, 116 | data: dragData, 117 | }) 118 | 119 | const twentyFourHourFormat = useAppStore( 120 | (state) => state.settings.twentyFourHourFormat 121 | ) 122 | 123 | const formatStart = (date: string) => { 124 | const isDate = isDateISO(date) 125 | return isDate 126 | ? 'all day' 127 | : DateTime.fromISO(date).toFormat(twentyFourHourFormat ? 'T' : 't') 128 | } 129 | 130 | const hideTimes = useAppStore( 131 | (state) => state.settings.hideTimes || state.settings.viewMode === 'week' 132 | ) 133 | const draggable = tasks.length > 0 134 | 135 | const showingPastDates = useAppStore((state) => state.showingPastDates) 136 | const firstEndISO = blocks[0]?.startISO || endISO 137 | const firstStartISO = 138 | showingPastDates || !startISO 139 | ? startISO 140 | : _.max([startISO, toISO(roundMinutes(DateTime.now()))]) 141 | 142 | const [unscheduledPortal, setUnscheduledPortal] = 143 | useState(null) 144 | 145 | useEffect(() => { 146 | if (type === 'unscheduled') { 147 | setUnscheduledPortal( 148 | document.getElementById(COLLAPSE_UNSCHEDULED) as HTMLDivElement 149 | ) 150 | } 151 | }, []) 152 | 153 | const collapsed = useAppStore((state) => { 154 | if (sortedGroups.length === 0) return false 155 | for (let heading of sortedGroups) { 156 | if (!state.collapsed[heading[0]]) return false 157 | } 158 | return true 159 | }) 160 | 161 | return ( 162 | <> 163 | {type === 'unscheduled' && 164 | unscheduledPortal && 165 | createPortal( 166 | 277 | 278 | 279 |
284 |
285 |
286 |
x.length > 20) 290 | ? 'break-all' 291 | : 'break-words' 292 | } leading-line whitespace-normal ${ 293 | [TaskPriorities.HIGHEST].includes(task.priority) 294 | ? 'text-accent' 295 | : renderType === 'deadline' 296 | ? '' 297 | : task.priority === TaskPriorities.LOW || 298 | isLink || 299 | task.status === 'x' || 300 | !task.title 301 | ? 'text-faint' 302 | : '' 303 | }`} 304 | onMouseDown={() => { 305 | openTask(task) 306 | return false 307 | }} 308 | onClick={() => false} 309 | onMouseUp={() => false} 310 | > 311 | {taskTitle()} 312 |
313 |
318 |
319 |
324 | {task.priority !== TaskPriorities.DEFAULT && ( 325 |
326 | {priorityNumberToSimplePriority[task.priority]} 327 |
328 | )} 329 | 330 | {!task.completed && task.reminder && ( 331 |
332 | 333 | {`${DateTime.fromISO( 334 | task.reminder.slice(0, 10) 335 | ).toFormat('M/d')}${task.reminder.slice(10)}`} 336 |
337 | )} 338 |
339 |
340 | {!dragging && ( 341 |
342 | {hasLengthDrag && ( 343 |
351 | {!task.duration 352 | ? 'length' 353 | : `${task.duration?.hour ? `${task.duration?.hour}h` : ''}${ 354 | task.duration?.minute ? `${task.duration?.minute}m` : '' 355 | }`} 356 |
357 | )} 358 | 359 | {!task.completed && ( 360 |
368 | {!task.due 369 | ? 'due' 370 | : `${Math.ceil( 371 | DateTime.fromISO(task.due) 372 | .diff( 373 | DateTime.fromISO( 374 | (startISO ?? 375 | new Date().toISOString().slice(0, 10)) as string 376 | ) 377 | ) 378 | .shiftTo('days').days 379 | )}d`} 380 |
381 | )} 382 |
383 | )} 384 | 385 | {!task.completed && task.reminder && !dragging && ( 386 |
387 | 388 | {`${DateTime.fromISO(task.reminder.slice(0, 10)).toFormat( 389 | 'M/d' 390 | )}${task.reminder.slice(10)}`} 391 |
392 | )} 393 |
394 | 395 | {task.tags.length > 0 && groupBy !== 'tags' && ( 396 |
397 | {task.tags.map((tag) => ( 398 |
402 | {tag.replace('#', '')} 403 |
404 | ))} 405 |
406 | )} 407 | {!isLink && task.notes && ( 408 |
409 | {task.notes} 410 |
411 | )} 412 | 413 | {subtasks && subtasks.length > 0 && ( 414 |
415 |
420 |
setters.patchCollapsed([task.id], !collapsed)} 423 | > 424 |
429 |
430 |
431 | 432 | {!collapsed && subtasks.length > 0 && ( 433 | 443 | )} 444 |
445 | )} 446 |
447 | ) 448 | } 449 | -------------------------------------------------------------------------------- /src/components/Timer.tsx: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | import { useEffect, useRef, useState } from 'react' 3 | import { useStopwatch, useTimer } from 'react-timer-hook' 4 | import { setters, useAppStore } from '../app/store' 5 | import { sounds } from '../assets/assets' 6 | import Button from './Button' 7 | 8 | export function Timer() { 9 | const pauseExpiration = useRef(true) 10 | const { negative, startISO, maxSeconds, playing } = useAppStore( 11 | (state) => state.timer 12 | ) 13 | const muted = useAppStore((state) => state.settings.muted) 14 | 15 | const timer = useTimer({ 16 | expiryTimestamp: startISO ? new Date(startISO) : new Date(), 17 | onExpire: () => { 18 | if (pauseExpiration.current) return 19 | setInput('') 20 | timer.pause() 21 | stopwatch.totalSeconds = 0 22 | stopwatch.start() 23 | }, 24 | autoStart: false, 25 | }) 26 | 27 | const stopwatch = useStopwatch({ 28 | autoStart: false, 29 | offsetTimestamp: startISO ? new Date(startISO) : new Date(), 30 | }) 31 | 32 | useEffect(() => { 33 | pauseExpiration.current = false 34 | if (!startISO) return 35 | if (playing && maxSeconds) timer.restart(new Date(startISO), true) 36 | else if (playing) { 37 | const seconds = DateTime.now() 38 | .diff(DateTime.fromISO(startISO)) 39 | .shiftTo('seconds').seconds 40 | stopwatch.reset(DateTime.now().plus({ seconds }).toJSDate(), true) 41 | } 42 | }, []) 43 | 44 | useEffect(() => { 45 | const newPlaying = stopwatch.isRunning || timer.isRunning 46 | if (newPlaying !== playing) setters.patchTimer({ playing: newPlaying }) 47 | }, [stopwatch.isRunning, timer.isRunning]) 48 | 49 | const [input, setInput] = useState('') 50 | const seconds = maxSeconds ? timer.seconds : stopwatch.seconds 51 | const minutes = maxSeconds ? timer.minutes : stopwatch.minutes 52 | const hours = maxSeconds ? timer.hours : stopwatch.hours 53 | const currentTime = maxSeconds ? timer.totalSeconds : stopwatch.totalSeconds 54 | 55 | const start = () => { 56 | setters.patchTimer({ negative: false }) 57 | let hours = 0 58 | let minutes = 0 59 | if (!input) { 60 | setters.patchTimer({ 61 | maxSeconds: null, 62 | startISO: new Date().toISOString(), 63 | }) 64 | stopwatch.start() 65 | } else { 66 | if (input.includes(':')) { 67 | const split = input.split(':').map((x) => parseInt(x)) 68 | hours = split[0] 69 | minutes = split[1] 70 | } else { 71 | minutes = parseFloat(input) 72 | } 73 | const endDate = DateTime.now().plus({ minutes, hours }).toJSDate() 74 | timer.restart(endDate) 75 | setters.patchTimer({ 76 | maxSeconds: minutes * 60 + hours * 60 * 60, 77 | startISO: endDate.toISOString(), 78 | }) 79 | } 80 | playSound() 81 | } 82 | 83 | const reset = () => { 84 | setters.patchTimer({ negative: false }) 85 | if (maxSeconds) { 86 | setters.patchTimer({ 87 | maxSeconds: null, 88 | }) 89 | timer.restart(new Date(), false) 90 | } else { 91 | stopwatch.reset(undefined, false) 92 | } 93 | setInput('') 94 | } 95 | 96 | const addTime = (minutes: number) => { 97 | if (maxSeconds) { 98 | const currentTime = DateTime.now() 99 | .plus({ seconds: timer.totalSeconds }) 100 | .plus({ minutes: minutes }) 101 | timer.restart(currentTime.toJSDate(), true) 102 | setters.patchTimer({ 103 | maxSeconds: maxSeconds + minutes * 60, 104 | startISO: currentTime.toJSDate().toISOString(), 105 | }) 106 | } else { 107 | const currentTime = DateTime.now() 108 | .plus({ 109 | seconds: stopwatch.totalSeconds, 110 | }) 111 | .plus({ minutes: minutes }) 112 | stopwatch.reset(currentTime.toJSDate(), true) 113 | setters.patchTimer({ startISO: currentTime.toJSDate().toISOString() }) 114 | } 115 | } 116 | 117 | let width = 0 118 | if (!maxSeconds) { 119 | const CYCLE_SEC = 60 120 | const modulus = (currentTime % CYCLE_SEC) / CYCLE_SEC 121 | width = modulus * 100 122 | } else { 123 | width = (currentTime / maxSeconds) * 100 124 | } 125 | 126 | const change: React.ChangeEventHandler = (ev) => { 127 | if (/\d*(:\d*)?/.test(ev.target.value)) { 128 | setInput(ev.target.value) 129 | } 130 | } 131 | 132 | const playSound = () => { 133 | if (!playing && !muted) { 134 | sounds.start.play() 135 | } 136 | } 137 | 138 | const togglePlaying = () => { 139 | if (currentTime <= 0) start() 140 | else { 141 | playing 142 | ? maxSeconds 143 | ? timer.pause() 144 | : stopwatch.pause() 145 | : maxSeconds 146 | ? timer.resume() 147 | : stopwatch.start() 148 | playSound() 149 | } 150 | } 151 | 152 | const borders = useAppStore((state) => state.settings.borders) 153 | 154 | return ( 155 |
160 |
168 | 169 | {!playing && currentTime <= 0 ? ( 170 | ev.key === 'Enter' && start()} 175 | onChange={change} 176 | className='w-[4em] !border-none bg-transparent text-center !shadow-none' 177 | > 178 | ) : ( 179 |
{`${negative ? '-' : ''}${
180 |           hours > 0 ? hours + ':' : ''
181 |         }${hours > 0 ? String(minutes).padStart(2, '0') : minutes}:${String(
182 |           seconds
183 |         ).padStart(2, '0')}`}
184 | )} 185 | 194 | 195 | 196 | )} 197 | {!playing && currentTime > 0 && ( 198 |
201 | ) 202 | } 203 | -------------------------------------------------------------------------------- /src/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Setting, ToggleComponent } from 'obsidian' 2 | import { useEffect, useRef } from 'react' 3 | import invariant from 'tiny-invariant' 4 | 5 | export default function Toggle({ 6 | callback, 7 | title, 8 | value, 9 | }: { 10 | callback: (state: boolean) => void 11 | title: string 12 | value: boolean 13 | }) { 14 | const frame = useRef(null) 15 | const thisToggle = useRef(null) 16 | const thisSetting = useRef(null) 17 | 18 | useEffect(() => { 19 | invariant(frame.current) 20 | if (!thisSetting.current) { 21 | thisSetting.current = new Setting(frame.current).setName('tasks') 22 | } 23 | thisSetting.current.addToggle((toggle) => { 24 | thisToggle.current = toggle 25 | toggle.setValue(value) 26 | toggle.onChange((state) => callback(state)) 27 | }) 28 | return () => { 29 | thisSetting.current?.clear() 30 | } 31 | }, []) 32 | 33 | useEffect(() => { 34 | thisToggle.current?.setValue(value) 35 | }, [value]) 36 | 37 | return
38 | } 39 | -------------------------------------------------------------------------------- /src/components/Unscheduled.tsx: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { memo } from 'react' 3 | import { useAppStore } from 'src/app/store' 4 | import { parseTaskDate } from 'src/services/util' 5 | import Block from './Block' 6 | import Droppable from './Droppable' 7 | 8 | const Unscheduled = memo(_Unscheduled, () => true) 9 | export default Unscheduled 10 | export const COLLAPSE_UNSCHEDULED = 'tr-collapse-unscheduled' 11 | 12 | function _Unscheduled() { 13 | const showCompleted = useAppStore((state) => state.settings.showCompleted) 14 | const showingPastDates = useAppStore((state) => state.showingPastDates) 15 | const tasks = useAppStore((state) => 16 | _.filter( 17 | state.tasks, 18 | (task) => 19 | (showCompleted || 20 | (showingPastDates ? task.completed : !task.completed)) && 21 | !task.parent && 22 | !task.queryParent && 23 | !parseTaskDate(task, state.tasks) && 24 | !task.due 25 | ) 26 | ) 27 | const childWidth = useAppStore((state) => 28 | (state.settings.viewMode === 'week' || 29 | state.settings.viewMode === 'hour') && 30 | state.childWidth > 1 31 | ? state.childWidth 32 | : 1 33 | ) 34 | 35 | return ( 36 |
37 |
38 |
42 | 43 |
{'Unscheduled'}
44 |
45 |
46 |
58 | 66 |
67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { ItemView, WorkspaceLeaf } from 'obsidian' 3 | import * as React from 'react' 4 | import { Root, createRoot } from 'react-dom/client' 5 | import App from './components/App' 6 | import TimeRulerPlugin from './main' 7 | import CalendarAPI from './services/calendarApi' 8 | import ObsidianAPI from './services/obsidianApi' 9 | import { getAPI } from 'obsidian-dataview' 10 | import invariant from 'tiny-invariant' 11 | import { getters, setters } from './app/store' 12 | 13 | export const TIME_RULER_VIEW = 'time-ruler-view' 14 | 15 | export default class TimeRulerView extends ItemView { 16 | plugin: TimeRulerPlugin 17 | obsidianAPI: ObsidianAPI 18 | calendarLinkAPI: CalendarAPI 19 | root: Root 20 | 21 | constructor(leaf: WorkspaceLeaf, plugin: TimeRulerPlugin) { 22 | super(leaf) 23 | this.plugin = plugin 24 | this.navigation = false 25 | this.icon = 'ruler' 26 | } 27 | 28 | getViewType() { 29 | return TIME_RULER_VIEW 30 | } 31 | 32 | getDisplayText() { 33 | return 'Time Ruler' 34 | } 35 | 36 | async onOpen() { 37 | this.obsidianAPI = new ObsidianAPI( 38 | this.plugin.settings, 39 | (settings) => { 40 | for (let key of _.keys(settings)) { 41 | this.plugin.settings[key] = settings[key] 42 | } 43 | this.plugin.saveSettings() 44 | setters.set({ 45 | settings: { ...this.plugin.settings }, 46 | }) 47 | }, 48 | this.app 49 | ) 50 | this.calendarLinkAPI = new CalendarAPI(this.plugin.settings, (calendar) => { 51 | _.pull(this.plugin.settings.calendars, calendar) 52 | this.plugin.saveSettings() 53 | }) 54 | 55 | this.obsidianAPI.load() 56 | this.calendarLinkAPI.load() 57 | 58 | this.root = createRoot(this.containerEl.children[1]) 59 | 60 | this.root.render( 61 | 62 | 68 | 69 | ) 70 | } 71 | 72 | async onClose() { 73 | this.root.unmount() 74 | this.obsidianAPI.unload() 75 | this.calendarLinkAPI.unload() 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | import { 3 | App, 4 | MarkdownFileInfo, 5 | MarkdownView, 6 | Menu, 7 | Notice, 8 | Plugin, 9 | setIcon, 10 | } from 'obsidian' 11 | import { getAPI } from 'obsidian-dataview' 12 | import TimeRulerView, { TIME_RULER_VIEW } from './index' 13 | import SettingsTab from './plugin/SettingsTab' 14 | import { openTaskInRuler } from './services/obsidianApi' 15 | import { ISO_MATCH, taskToText, textToTask } from './services/parser' 16 | import { getters, setters } from './app/store' 17 | import invariant from 'tiny-invariant' 18 | import { roundMinutes, toISO } from './services/util' 19 | 20 | // comment out for dev 21 | // import './tests/parser.test' 22 | 23 | type TimeRulerSettings = { 24 | calendars: string[] 25 | fieldFormat: FieldFormat['main'] 26 | muted: boolean 27 | timerEvent: 'notification' | 'sound' 28 | inbox: string | null 29 | search: string 30 | taskSearch: string 31 | fileOrder: string[] 32 | customStatus: { 33 | include: boolean 34 | statuses: string 35 | } 36 | showCompleted: boolean 37 | dayStartEnd: [number, number] 38 | groupBy: false | 'priority' | 'path' | 'hybrid' | 'tags' 39 | twentyFourHourFormat: boolean 40 | filterFunction: string 41 | addTaskToEnd: boolean 42 | extendBlocks: boolean 43 | hideTimes: boolean 44 | borders: boolean 45 | viewMode: 'hour' | 'day' | 'week' 46 | scheduledSubtasks: boolean 47 | openInMain: boolean 48 | } 49 | 50 | export const DEFAULT_SETTINGS: TimeRulerSettings = { 51 | calendars: [], 52 | fieldFormat: 'dataview', 53 | muted: false, 54 | timerEvent: 'notification', 55 | inbox: null, 56 | search: '', 57 | taskSearch: '', 58 | fileOrder: [], 59 | customStatus: { 60 | include: false, 61 | statuses: '-', 62 | }, 63 | showCompleted: false, 64 | groupBy: 'path', 65 | dayStartEnd: [0, 24], 66 | twentyFourHourFormat: false, 67 | filterFunction: '', 68 | addTaskToEnd: false, 69 | extendBlocks: false, 70 | hideTimes: false, 71 | borders: true, 72 | viewMode: 'day', 73 | scheduledSubtasks: true, 74 | openInMain: false, 75 | } 76 | 77 | export default class TimeRulerPlugin extends Plugin { 78 | settings: TimeRulerSettings 79 | 80 | constructor(app: App, manifest: any) { 81 | super(app, manifest) 82 | this.saveSettings = this.saveSettings.bind(this) 83 | } 84 | 85 | async onload() { 86 | await this.loadSettings() 87 | this.addSettingTab(new SettingsTab(this, this.app)) 88 | 89 | this.registerView(TIME_RULER_VIEW, (leaf) => new TimeRulerView(leaf, this)) 90 | 91 | this.addCommand({ 92 | icon: 'ruler', 93 | callback: () => this.activateView(this.settings.openInMain), 94 | id: 'activate-view', 95 | name: 'Open Time Ruler', 96 | }) 97 | 98 | this.addCommand({ 99 | icon: 'ruler', 100 | callback: () => this.activateView(true), 101 | id: 'activate-view-main', 102 | name: 'Open Time Ruler in Main Tab', 103 | }) 104 | 105 | this.addCommand({ 106 | icon: 'ruler', 107 | callback: () => this.activateView(false), 108 | id: 'activate-view-sidebar', 109 | name: 'Open Time Ruler in Sidebar', 110 | }) 111 | 112 | this.addRibbonIcon('ruler', 'Open Time Ruler', () => { 113 | this.activateView(this.settings.openInMain) 114 | }) 115 | 116 | this.registerEvent( 117 | this.app.workspace.on('editor-menu', (menu, _, context) => 118 | this.openMenu(menu, context) 119 | ) 120 | ) 121 | 122 | this.addCommand({ 123 | id: 'find-task', 124 | name: 'Reveal in Time Ruler', 125 | icon: 'ruler', 126 | checkCallback: () => { 127 | this.app.workspace.getActiveFile() 128 | }, 129 | editorCallback: (_, context) => this.jumpToTask(context), 130 | }) 131 | } 132 | 133 | async jumpToTask(context: MarkdownView | MarkdownFileInfo) { 134 | invariant(context.file) 135 | let path = context.file.path.replace('.md', '') 136 | if (!path) return 137 | invariant(context.editor) 138 | const cursor = context.editor.getCursor() 139 | if (!cursor) return 140 | const line = context.editor.getLine(cursor.line) 141 | if (!line || !/ *- \[ \] /.test(line)) { 142 | new Notice('cursor is not on task') 143 | return 144 | } 145 | 146 | const leaf = this.app.workspace.getLeavesOfType(TIME_RULER_VIEW)?.[0] 147 | if (!leaf) { 148 | await this.activateView() 149 | } else { 150 | this.app.workspace.revealLeaf(leaf) 151 | } 152 | 153 | openTaskInRuler(path + '::' + cursor.line) 154 | } 155 | 156 | openMenu(menu: Menu, context: MarkdownView | MarkdownFileInfo) { 157 | const cursor = context.editor?.getCursor() 158 | if (!cursor || !(context instanceof MarkdownView)) return 159 | const line = context.editor.getLine(cursor.line) 160 | if (!line || !/ *- \[ \] /.test(line)) return 161 | menu.addItem((item) => 162 | item 163 | .setIcon('ruler') 164 | .setTitle('Reveal in Time Ruler') 165 | .onClick(() => this.jumpToTask(context)) 166 | ) 167 | menu.addItem((menu) => { 168 | // @ts-ignore 169 | const submenu = menu.setTitle('Do').setIcon('ruler').setSubmenu() 170 | 171 | submenu.addItem((item) => 172 | item 173 | .setTitle('Today') 174 | .onClick(() => this.editTask(context, cursor.line, 'today')) 175 | ) 176 | submenu.addItem((item) => 177 | item 178 | .setTitle('Tomorrow') 179 | .onClick(() => this.editTask(context, cursor.line, 'tomorrow')) 180 | ) 181 | submenu.addItem((item) => 182 | item 183 | .setTitle('Now') 184 | .onClick(() => this.editTask(context, cursor.line, 'now')) 185 | ) 186 | submenu.addItem((item) => 187 | item 188 | .setTitle('Next week') 189 | .onClick(() => this.editTask(context, cursor.line, 'next-week')) 190 | ) 191 | if (line.match(new RegExp(ISO_MATCH))) { 192 | submenu.addItem((item) => 193 | item 194 | .setTitle('Unschedule') 195 | .onClick(() => this.editTask(context, cursor.line, 'unschedule')) 196 | ) 197 | } 198 | }) 199 | } 200 | 201 | async editTask( 202 | context: MarkdownView, 203 | line: number, 204 | modification: 'now' | 'today' | 'tomorrow' | 'next-week' | 'unschedule' 205 | ) { 206 | invariant(context.file) 207 | const id = context.file.path.replace('.md', '') + '::' + line 208 | let scheduled: TaskProps['scheduled'] 209 | switch (modification) { 210 | case 'now': 211 | scheduled = toISO(roundMinutes(DateTime.now())) 212 | break 213 | case 'today': 214 | scheduled = toISO(roundMinutes(DateTime.now()), true) 215 | break 216 | case 'tomorrow': 217 | scheduled = toISO(roundMinutes(DateTime.now().plus({ day: 1 })), true) 218 | break 219 | case 'next-week': 220 | scheduled = toISO(roundMinutes(DateTime.now().plus({ week: 1 })), true) 221 | break 222 | case 'unschedule': 223 | scheduled = '' 224 | break 225 | } 226 | setters.patchTasks([id], { scheduled }) 227 | } 228 | 229 | async activateView(main?: boolean) { 230 | let dataViewPlugin = getAPI(this.app) 231 | if (!dataViewPlugin) { 232 | // wait for Dataview plugin to load (usually <100ms) 233 | dataViewPlugin = await new Promise((resolve) => { 234 | setTimeout(() => resolve(getAPI(this.app)), 350) 235 | }) 236 | if (!dataViewPlugin) { 237 | new Notice('Please enable the DataView plugin for Time Ruler to work.') 238 | return 239 | } 240 | } 241 | 242 | this.app.workspace.detachLeavesOfType(TIME_RULER_VIEW) 243 | 244 | const leaf = main 245 | ? this.app.workspace.getLeaf(true) 246 | : this.app.workspace.getRightLeaf(false) 247 | 248 | invariant(leaf) 249 | 250 | await leaf.setViewState({ 251 | type: TIME_RULER_VIEW, 252 | active: true, 253 | }) 254 | 255 | this.app.workspace.revealLeaf(leaf) 256 | } 257 | 258 | async loadSettings() { 259 | this.settings = { ...DEFAULT_SETTINGS, ...(await this.loadData()) } 260 | } 261 | 262 | saveSettings() { 263 | this.saveData(this.settings) 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/plugin/SettingsTab.tsx: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import _ from 'lodash' 3 | import { 4 | App, 5 | Notice, 6 | PluginSettingTab, 7 | Setting, 8 | TextComponent, 9 | ValueComponent, 10 | request, 11 | setIcon, 12 | } from 'obsidian' 13 | import { useEffect, useRef } from 'react' 14 | import { Root, createRoot } from 'react-dom/client' 15 | import TimeRulerPlugin from '../main' 16 | 17 | const WEBCAL = 'webcal' 18 | 19 | function Calendars({ 20 | plugin, 21 | names, 22 | updateCalendars, 23 | }: { 24 | plugin: TimeRulerPlugin 25 | names: Record 26 | updateCalendars: () => void 27 | }) { 28 | useEffect(() => { 29 | $(frameRef.current as HTMLElement) 30 | .find('button') 31 | .each((_i, el) => setIcon(el, 'x')) 32 | }) 33 | const frameRef = useRef(null) 34 | 35 | return ( 36 |
37 | {plugin.settings.calendars.map((calendar) => ( 38 |
42 | 56 |
{names[calendar]}
57 |
58 | ))} 59 |
60 | ) 61 | } 62 | 63 | export default class SettingsTab extends PluginSettingTab { 64 | plugin: TimeRulerPlugin 65 | searcher: ValueComponent 66 | calendarDisplay: HTMLDivElement 67 | names: Record 68 | root: Root 69 | 70 | constructor(plugin: TimeRulerPlugin, app: App) { 71 | super(app, plugin) 72 | this.plugin = plugin 73 | this.names = {} 74 | } 75 | 76 | updateCalendars() { 77 | this.root.render( 78 | this.updateCalendars()} 82 | /> 83 | ) 84 | } 85 | 86 | async addCalendarName(calendar: string) { 87 | const data = await request(calendar) 88 | const name = data.match(/CALNAME:(.*)/)?.[1] ?? 'Default' 89 | this.names[calendar] = name 90 | } 91 | 92 | async display() { 93 | let { containerEl } = this 94 | containerEl.empty() 95 | 96 | new Setting(containerEl).setDesc( 97 | 'Reload the Time Ruler view for changes to take effect.' 98 | ) 99 | 100 | const format = new Setting(containerEl) 101 | .setName('Preferred Field Format') 102 | .setDesc( 103 | 'Choose which style of inline fields to use as a default (parses scheduled date/time, due, priority, completion, reminder, and start).' 104 | ) 105 | format.addDropdown((dropdown) => { 106 | dropdown.addOptions({ 107 | dataview: 'Dataview', 108 | 'full-calendar': 'Full Calendar', 109 | tasks: 'Tasks', 110 | simple: 'Day Planner', 111 | }) 112 | dropdown.setValue(this.plugin.settings.fieldFormat) 113 | dropdown.onChange((value: FieldFormat['main']) => { 114 | this.plugin.settings.fieldFormat = value 115 | this.plugin.saveSettings() 116 | }) 117 | }) 118 | 119 | new Setting(containerEl) 120 | .setName('Muted') 121 | .setDesc('Turn off playing sounds on task completion.') 122 | .addToggle((toggle) => { 123 | toggle.setValue(this.plugin.settings.muted) 124 | toggle.onChange((value) => { 125 | this.plugin.settings.muted = value 126 | this.plugin.saveSettings() 127 | }) 128 | }) 129 | 130 | new Setting(containerEl) 131 | .setName('Timer Event') 132 | .setDesc('Toggle the event triggered on timer end.') 133 | .addDropdown((dropdown) => { 134 | dropdown.addOptions({ 135 | notification: 'Notification', 136 | sound: 'Sound', 137 | }) 138 | dropdown.setValue(this.plugin.settings.timerEvent) 139 | dropdown.onChange((value: 'notification' | 'sound') => { 140 | this.plugin.settings.timerEvent = value 141 | this.plugin.saveSettings() 142 | }) 143 | }) 144 | 145 | new Setting(containerEl) 146 | .setName('Borders') 147 | .setDesc('Toggle borders around days.') 148 | .addToggle((toggle) => { 149 | toggle.setValue(this.plugin.settings.borders) 150 | toggle.onChange((value) => { 151 | this.plugin.settings.borders = value 152 | this.plugin.saveSettings() 153 | }) 154 | }) 155 | 156 | new Setting(containerEl) 157 | .setName('24 Hour Format') 158 | .setDesc( 159 | 'Toggle between AM/PM hours and 24-hour format in the Time Ruler.' 160 | ) 161 | .addToggle((toggle) => { 162 | toggle 163 | .setValue(this.plugin.settings.twentyFourHourFormat) 164 | .onChange((value) => { 165 | this.plugin.settings.twentyFourHourFormat = value 166 | this.plugin.saveSettings() 167 | }) 168 | }) 169 | 170 | const dayStartEnd = new Setting(containerEl) 171 | .setName('Day Start & End') 172 | .setDesc('Choose the boundaries of the Time Ruler hour tick-marks.') 173 | const hourStart = createSpan() 174 | hourStart.setText('start') 175 | dayStartEnd.controlEl.appendChild(hourStart) 176 | dayStartEnd.addDropdown((component) => { 177 | let options: Record = {} 178 | for (let i = 0; i < 13; i++) { 179 | options[`${i}`] = `${i}:00` 180 | } 181 | component 182 | .addOptions(options) 183 | .setValue(String(this.plugin.settings.dayStartEnd[0])) 184 | .onChange((newValue) => { 185 | this.plugin.settings.dayStartEnd = [ 186 | parseInt(newValue), 187 | this.plugin.settings.dayStartEnd[1], 188 | ] 189 | this.plugin.saveSettings() 190 | }) 191 | }) 192 | const hourEnd = createSpan() 193 | hourEnd.setText('end') 194 | dayStartEnd.controlEl.appendChild(hourEnd) 195 | dayStartEnd.addDropdown((component) => { 196 | let options: Record = {} 197 | for (let i = 0; i < 24; i++) { 198 | options[`${i}`] = `${i}:00` 199 | } 200 | component 201 | .addOptions(options) 202 | .setValue(String(this.plugin.settings.dayStartEnd[1])) 203 | .onChange((newValue) => { 204 | this.plugin.settings.dayStartEnd = [ 205 | this.plugin.settings.dayStartEnd[0], 206 | parseInt(newValue), 207 | ] 208 | this.plugin.saveSettings() 209 | }) 210 | }) 211 | 212 | new Setting(containerEl) 213 | .setName('Extend Blocks to Next') 214 | .setDesc( 215 | 'Extend blocks without defined length to the start of the next block.' 216 | ) 217 | .addToggle((toggle) => 218 | toggle.setValue(this.plugin.settings.extendBlocks).onChange((value) => { 219 | this.plugin.settings.extendBlocks = value 220 | this.plugin.saveSettings() 221 | }) 222 | ) 223 | 224 | new Setting(containerEl) 225 | .setName('Show Unscheduled Subtasks') 226 | .setDesc('Show subtasks without a set scheduled date.') 227 | .addToggle((toggle) => 228 | toggle 229 | .setValue(this.plugin.settings.scheduledSubtasks) 230 | .onChange((value) => { 231 | this.plugin.settings.scheduledSubtasks = value 232 | this.plugin.saveSettings() 233 | }) 234 | ) 235 | 236 | new Setting(containerEl) 237 | .setName('Custom Filter') 238 | .setDesc( 239 | `Enable a custom Dataview filter to filter tasks (at the document level) which is passed to dv.pages('')` 240 | ) 241 | .addText((text) => { 242 | text 243 | .setPlaceholder(`dv.pages('')`) 244 | .setValue(this.plugin.settings.search) 245 | .onChange((value) => { 246 | this.plugin.settings.search = value 247 | this.plugin.saveSettings() 248 | }) 249 | }) 250 | 251 | new Setting(containerEl) 252 | .setName('Filter Function') 253 | .setDesc( 254 | `Provide a filter function that takes a task DataArray from dv.pages()['file']['tasks'] and returns the filtered array.` 255 | ) 256 | .addTextArea((text) => { 257 | text 258 | .setPlaceholder( 259 | `example: (tasks) => tasks.where(task => task["customProperty"] === true)` 260 | ) 261 | .setValue(this.plugin.settings.filterFunction) 262 | .onChange((value) => { 263 | this.plugin.settings.filterFunction = value 264 | this.plugin.saveSettings() 265 | }) 266 | .inputEl.style.setProperty('width', '100%') 267 | }) 268 | .controlEl.style.setProperty('width', '100%') 269 | 270 | new Setting(containerEl) 271 | .setName('Task Filter') 272 | .setDesc('Only include tasks which match the following search.') 273 | .addText((text) => 274 | text 275 | .setPlaceholder('Match text in tasks') 276 | .setValue(this.plugin.settings.taskSearch) 277 | .onChange((value) => { 278 | this.plugin.settings.taskSearch = value 279 | this.plugin.saveSettings() 280 | }) 281 | ) 282 | 283 | const customStatuses = new Setting(containerEl) 284 | .setName('Custom Statuses') 285 | .setDesc( 286 | 'Include only, or exclude certain, characters between the double brackets [ ] of a task. Write characters with no separation.' 287 | ) 288 | customStatuses.controlEl.appendChild($(/*html*/ `Exclude`)[0]) 289 | customStatuses.addToggle((toggle) => 290 | toggle 291 | .setValue(this.plugin.settings.customStatus.include) 292 | .setTooltip('Exclude the current value') 293 | ) 294 | customStatuses.controlEl.appendChild($(/*html*/ `Include`)[0]) 295 | customStatuses.addText((text) => { 296 | text 297 | .setValue(this.plugin.settings.customStatus.statuses) 298 | .setPlaceholder('Statuses') 299 | .onChange((value) => { 300 | this.plugin.settings.customStatus.statuses = value 301 | this.plugin.saveSettings() 302 | }) 303 | }) 304 | 305 | new Setting(containerEl) 306 | .setName('Show Completed') 307 | .setDesc('Show completed tasks') 308 | .addToggle((toggle) => 309 | toggle 310 | .setValue(this.plugin.settings.showCompleted) 311 | .onChange((value) => { 312 | this.plugin.settings.showCompleted = value 313 | this.plugin.saveSettings() 314 | }) 315 | ) 316 | 317 | new Setting(containerEl) 318 | .setName('Add Tasks to End') 319 | .setDesc('Toggle adding new tasks to the start or end of headings/files.') 320 | .addToggle((toggle) => 321 | toggle.setValue(this.plugin.settings.addTaskToEnd).onChange((value) => { 322 | this.plugin.settings.addTaskToEnd = value 323 | this.plugin.saveSettings() 324 | }) 325 | ) 326 | 327 | new Setting(containerEl) 328 | .setName('Open in Main Tab') 329 | .setDesc('Toggle opening Time Ruler in the main view vs. the sidebar.') 330 | .addToggle((toggle) => 331 | toggle.setValue(this.plugin.settings.openInMain).onChange((value) => { 332 | this.plugin.settings.openInMain = value 333 | this.plugin.saveSettings() 334 | }) 335 | ) 336 | 337 | let newCalendarLink: TextComponent 338 | new Setting(containerEl) 339 | .setName('Calendars') 340 | .setDesc('View readonly calendars in Time Ruler.') 341 | .addText((text) => { 342 | newCalendarLink = text 343 | text.inputEl.style.width = '100%' 344 | text.setPlaceholder('Calendar Share Link (iCal format)') 345 | }) 346 | .addButton((button) => { 347 | button.setIcon('plus') 348 | button.onClick(async () => { 349 | let newValue = newCalendarLink.getValue() 350 | if (newValue.startsWith(WEBCAL)) { 351 | newValue = 'https' + newValue.slice(WEBCAL.length) 352 | } 353 | try { 354 | await this.addCalendarName(newValue) 355 | const newCalendars = [...this.plugin.settings.calendars] 356 | newCalendars.push(newValue) 357 | this.plugin.settings.calendars = _.uniq(newCalendars) 358 | this.plugin.saveSettings() 359 | newCalendarLink.setValue('') 360 | this.updateCalendars() 361 | } catch (err) { 362 | new Notice('Time Ruler: Error creating calendar - ' + err.message) 363 | } 364 | }) 365 | }) 366 | 367 | this.calendarDisplay = containerEl.appendChild(createEl('div')) 368 | this.root = createRoot(this.calendarDisplay) 369 | 370 | await Promise.all( 371 | this.plugin.settings.calendars.map((calendar) => 372 | this.addCalendarName(calendar) 373 | ) 374 | ) 375 | this.updateCalendars() 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /src/services/autoScroll.ts: -------------------------------------------------------------------------------- 1 | import { Arguments } from '@dnd-kit/core/dist/components/Accessibility/types' 2 | import $ from 'jquery' 3 | import { Platform } from 'obsidian' 4 | import { useEffect, useRef } from 'react' 5 | import { useAppStore } from 'src/app/store' 6 | import invariant from 'tiny-invariant' 7 | 8 | export const useAutoScroll = () => { 9 | const dragging = useAppStore((state) => !!state.dragData) 10 | let scrolling = useRef(false) 11 | let timeout = useRef(null) 12 | const WAIT_TIME = 500 13 | 14 | useEffect(() => { 15 | const scrollBy = (el: HTMLElement, object: Record) => { 16 | scrolling.current = true 17 | timeout.current = null 18 | el.scrollBy({ ...object, behavior: 'smooth' }) 19 | setTimeout(() => { 20 | scrolling.current = false 21 | }, 750) 22 | } 23 | 24 | const autoScroll = (ev: MouseEvent | TouchEvent) => { 25 | if (scrolling.current) return 26 | let pos: { x: number; y: number } = { x: 0, y: 0 } 27 | if (ev instanceof TouchEvent) { 28 | pos.x = ev.touches[0].clientX 29 | pos.y = ev.touches[0].clientY 30 | } else if (ev instanceof MouseEvent) { 31 | pos.x = ev.clientX 32 | pos.y = ev.clientY 33 | } 34 | 35 | const tr = document.getElementById('time-ruler') 36 | invariant(tr) 37 | let found = false 38 | 39 | for (let el of tr.findAll('[data-auto-scroll="y"]')) { 40 | const { left, right, top, bottom, height } = el.getBoundingClientRect() 41 | if (pos.x < left || pos.x > right || pos.y < top || pos.y > bottom) 42 | continue 43 | const MARGIN = 10 44 | if (pos.y < top + MARGIN) { 45 | found = true 46 | if (!timeout.current) 47 | timeout.current = setTimeout( 48 | () => scrollBy(el, { top: -height }), 49 | WAIT_TIME 50 | ) 51 | break 52 | } else if (pos.y > bottom - MARGIN) { 53 | found = true 54 | if (!timeout.current) 55 | timeout.current = setTimeout( 56 | () => scrollBy(el, { top: height }), 57 | WAIT_TIME 58 | ) 59 | break 60 | } 61 | } 62 | if (!found) { 63 | for (let el of tr.findAll('[data-auto-scroll="x"]')) { 64 | const { left, width, top, bottom, right } = el.getBoundingClientRect() 65 | 66 | if (pos.x < left || pos.x > right || pos.y < top || pos.y > bottom) 67 | continue 68 | const MARGIN = 10 69 | if (pos.x < left + MARGIN) { 70 | found = true 71 | if (!timeout.current) 72 | timeout.current = setTimeout( 73 | () => scrollBy(el, { left: -width }), 74 | WAIT_TIME 75 | ) 76 | break 77 | } else if (pos.x > right - MARGIN) { 78 | found = true 79 | if (!timeout.current) 80 | timeout.current = setTimeout( 81 | () => scrollBy(el, { left: width }), 82 | WAIT_TIME 83 | ) 84 | break 85 | } 86 | } 87 | } 88 | 89 | if (!found && timeout.current) { 90 | clearTimeout(timeout.current) 91 | timeout.current = null 92 | } 93 | } 94 | 95 | if (dragging) 96 | window.addEventListener( 97 | Platform.isMobile ? 'touchmove' : 'mousemove', 98 | autoScroll 99 | ) 100 | return () => { 101 | window.removeEventListener( 102 | Platform.isMobile ? 'touchmove' : 'mousemove', 103 | autoScroll 104 | ) 105 | } 106 | }, [dragging]) 107 | } 108 | -------------------------------------------------------------------------------- /src/services/calendarApi.ts: -------------------------------------------------------------------------------- 1 | import * as ical2json from 'ical2json' 2 | import ical from 'ical' 3 | import _ from 'lodash' 4 | import { DateTime } from 'luxon' 5 | import { Component, Notice, request, ToggleComponent } from 'obsidian' 6 | import { getters, setters } from '../app/store' 7 | import TimeRulerPlugin from '../main' 8 | import { toISO } from './util' 9 | import moment from 'moment' 10 | 11 | let reportedOffline = false 12 | export default class CalendarAPI extends Component { 13 | settings: TimeRulerPlugin['settings'] 14 | removeCalendar: (calendar: string) => void 15 | 16 | constructor( 17 | settings: CalendarAPI['settings'], 18 | removeCalendar: CalendarAPI['removeCalendar'] 19 | ) { 20 | super() 21 | this.settings = settings 22 | this.removeCalendar = removeCalendar 23 | } 24 | 25 | async loadEvents() { 26 | if (!window.navigator.onLine) { 27 | console.warn('Time Ruler: Calendars offline.') 28 | return 29 | } 30 | const events: Record = {} 31 | let i = 0 32 | 33 | let offline = false 34 | 35 | const searchWithinWeeks = getters.get('searchWithinWeeks') 36 | const showingPastDates = getters.get('showingPastDates') 37 | const dateBounds: [DateTime, DateTime] = showingPastDates 38 | ? [ 39 | DateTime.now().minus({ weeks: searchWithinWeeks[1] }), 40 | DateTime.now().plus({ days: 1 }), 41 | ] 42 | : [ 43 | DateTime.now().minus({ days: 1 }), 44 | DateTime.now().plus({ weeks: searchWithinWeeks[1] }), 45 | ] 46 | 47 | const calendarLoads = this.settings.calendars.map(async (calendar) => { 48 | try { 49 | const data = await request(calendar) 50 | const icsEvents = ical.parseICS(data) 51 | 52 | const calendarName = data.match(/CALNAME:(.*)/)?.[1] ?? 'Default' 53 | for (let [id, event] of _.entries(icsEvents) as [string, any][]) { 54 | if (!event.start || !event.end || event.type !== 'VEVENT') continue 55 | 56 | if (event.rrule) { 57 | var dates: Date[] = event.rrule.between( 58 | dateBounds[0].toJSDate(), 59 | dateBounds[1].toJSDate() 60 | ) 61 | if (dates.length > 0) { 62 | // patch for events convering between daylight savings time and the current time 63 | const timeDifference = 64 | event.start.getTimezoneOffset() - dates[0].getTimezoneOffset() 65 | dates.forEach((x: Date) => { 66 | x.setTime(x.getTime() - timeDifference * 60 * 1000) 67 | }) 68 | } 69 | 70 | if (event.recurrences != undefined) { 71 | for (var r in event.recurrences) { 72 | const dateTime = DateTime.fromJSDate(new Date(r)).setZone( 73 | 'local' 74 | ) 75 | 76 | // Only add dates that weren't already in the range we added from the rrule so that 77 | // we don't double-add those events. 78 | if (dateTime < dateBounds[1] && dateTime >= dateBounds[0]) { 79 | dates.push(new Date(r)) 80 | } 81 | } 82 | } 83 | let duration = 84 | DateTime.fromJSDate(event.end).toMillis() - 85 | DateTime.fromJSDate(event.start).toMillis() 86 | 87 | for (let date of dates) { 88 | // Use just the date of the recurrence to look up overrides and exceptions (i.e. chop off time information) 89 | const dateLookupKey = date.toISOString().substring(0, 10) 90 | let start: DateTime, end: DateTime 91 | // For each date that we're checking, it's possible that there is a recurrence override for that one day. 92 | if ( 93 | event.recurrences != undefined && 94 | event.recurrences[dateLookupKey] != undefined 95 | ) { 96 | // We found an override, so for this recurrence, use a potentially different title, start date, and duration. 97 | const currentEvent = event.recurrences[dateLookupKey] 98 | 99 | start = DateTime.fromJSDate(currentEvent.start).setZone('local') 100 | let curDuration = 101 | DateTime.fromJSDate(currentEvent.end).toMillis() - 102 | start.toMillis() 103 | end = start.plus({ millisecond: curDuration }).setZone('local') 104 | } else if ( 105 | // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule. 106 | event.exdate != undefined && 107 | event.exdate[dateLookupKey] != undefined 108 | ) { 109 | continue 110 | } else { 111 | start = DateTime.fromJSDate(date).setZone('local') 112 | end = start.plus({ milliseconds: duration }) 113 | } 114 | 115 | const startString = event.start['dateOnly'] 116 | ? (start.toISODate() as string) 117 | : toISO(start.setZone('local')) 118 | const endString = event.start['dateOnly'] 119 | ? (end.toISODate() as string) 120 | : toISO(end.setZone('local')) 121 | 122 | const thisId = `${id}-${startString}` 123 | const props: EventProps = { 124 | id: thisId, 125 | title: event.summary ?? '', 126 | startISO: startString, 127 | endISO: endString, 128 | type: 'event', 129 | calendarId: `${i}`, 130 | calendarName: calendarName, 131 | color: '', 132 | notes: event.description, 133 | location: event.location, 134 | } 135 | events[thisId] = props 136 | } 137 | } else { 138 | let end = DateTime.fromJSDate(event.end).setZone('local') 139 | if (end < dateBounds[0]) continue 140 | 141 | let start = DateTime.fromJSDate(event.start).setZone('local') 142 | if (start > dateBounds[1]) continue 143 | 144 | const startString = event.start['dateOnly'] 145 | ? (start.toISODate() as string) 146 | : toISO(start) 147 | const endString = event.start['dateOnly'] 148 | ? (end.toISODate() as string) 149 | : toISO(end) 150 | 151 | const props: EventProps = { 152 | id, 153 | title: event.summary ?? '', 154 | startISO: startString, 155 | endISO: endString, 156 | type: 'event', 157 | calendarId: `${i}`, 158 | calendarName: calendarName, 159 | color: '', 160 | notes: event.description, 161 | location: event.location, 162 | } 163 | 164 | events[id] = props 165 | } 166 | } 167 | 168 | i++ 169 | } catch (err) { 170 | console.error(err) 171 | 172 | offline = true 173 | } 174 | 175 | if (offline && !reportedOffline) { 176 | reportedOffline = true 177 | new Notice('Time Ruler: calendars offline.') 178 | } 179 | }) 180 | 181 | await Promise.all(calendarLoads) 182 | 183 | setters.set({ events }) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/services/dragging.ts: -------------------------------------------------------------------------------- 1 | import { DragEndEvent, DragStartEvent } from '@dnd-kit/core' 2 | import { getters, setters } from 'src/app/store' 3 | import { isTaskProps } from 'src/types/enums' 4 | import { DateTime, Duration } from 'luxon' 5 | import { useAppStoreRef } from '../app/store' 6 | import _ from 'lodash' 7 | import { 8 | roundMinutes, 9 | toISO, 10 | isDateISO, 11 | parseTaskDate, 12 | getChildren, 13 | } from './util' 14 | import invariant from 'tiny-invariant' 15 | import { parseFileFromPath } from './util' 16 | 17 | export const onDragEnd = async ( 18 | ev: DragEndEvent, 19 | activeDragRef: React.RefObject 20 | ) => { 21 | const dropData = ev.over?.data.current as DropData | undefined 22 | const dragData = activeDragRef.current 23 | const dragMode = getters.get('dragMode') 24 | 25 | if (ev.active.id === ev.over?.id) { 26 | setters.set({ dragData: null }) 27 | return 28 | } 29 | 30 | if (dragData?.dragType === 'task' && dropData?.type === 'move') { 31 | setters.set({ newTask: { task: dragData, type: 'move' } }) 32 | } else if (dropData && dragData) { 33 | if (!isTaskProps(dropData)) { 34 | switch (dropData.type) { 35 | case 'heading': 36 | if (dragData.dragType !== 'group') break 37 | setters.updateFileOrder( 38 | parseFileFromPath(dragData.headingPath), 39 | parseFileFromPath(dropData.heading) 40 | ) 41 | break 42 | case 'delete': 43 | const tasks = getters.get('tasks') 44 | let draggedTasks = 45 | dragData.dragType === 'block' || dragData.dragType === 'group' 46 | ? dragData.tasks 47 | : dragData.dragType === 'task' 48 | ? [dragData] 49 | : [] 50 | const children = _.sortBy( 51 | _.uniq( 52 | _.flatMap(draggedTasks, (task) => [ 53 | task.id, 54 | ...getChildren(task, tasks), 55 | ]) 56 | ), 57 | 'id' 58 | ) 59 | 60 | if (children.length > 1) { 61 | if (!confirm(`Delete ${children.length} tasks and children?`)) break 62 | } 63 | 64 | await getters.getObsidianAPI().deleteTasks(children.reverse()) 65 | break 66 | } 67 | } else { 68 | switch (dragData.dragType) { 69 | case 'new_button': 70 | setters.set({ 71 | newTask: { task: { scheduled: dropData.scheduled }, type: 'new' }, 72 | }) 73 | break 74 | case 'time': 75 | case 'task-length': 76 | if (!dropData.scheduled) return 77 | const { hours, minutes } = DateTime.fromISO(dropData.scheduled) 78 | .diff(DateTime.fromISO(dragData.start)) 79 | .shiftTo('hours', 'minutes') 80 | .toObject() as { hours: number; minutes: number } 81 | if (dragData.dragType === 'task-length') { 82 | setters.patchTasks([dragData.id], { 83 | duration: { hour: hours, minute: minutes }, 84 | }) 85 | } else { 86 | setters.set({ 87 | newTask: { 88 | task: { 89 | scheduled: dragData.start, 90 | duration: { hour: hours, minute: minutes }, 91 | }, 92 | type: 'new', 93 | }, 94 | }) 95 | } 96 | break 97 | 98 | case 'block': 99 | case 'group': 100 | setters.patchTasks( 101 | dragData.tasks.map((x) => x.id), 102 | dropData 103 | ) 104 | break 105 | case 'task': 106 | setters.patchTasks([dragData.id], dropData) 107 | break 108 | case 'due': 109 | setters.patchTasks([dragData.task.id], { due: dropData.scheduled }) 110 | break 111 | } 112 | } 113 | } 114 | 115 | setters.set({ dragData: null }) 116 | } 117 | 118 | export const onDragStart = (ev: DragStartEvent) => { 119 | setters.set({ dragData: ev.active.data.current as DragData }) 120 | } 121 | -------------------------------------------------------------------------------- /src/services/util.ts: -------------------------------------------------------------------------------- 1 | import _, { reject } from 'lodash' 2 | import { DateTime, Duration } from 'luxon' 3 | import moment from 'moment' 4 | import { Platform } from 'obsidian' 5 | import { useEffect } from 'react' 6 | import { BlockProps, UNGROUPED } from 'src/components/Block' 7 | import { 8 | TaskPriorities, 9 | priorityKeyToNumber, 10 | priorityNumberToSimplePriority, 11 | simplePriorityToNumber, 12 | } from 'src/types/enums' 13 | import invariant from 'tiny-invariant' 14 | import { 15 | AppState, 16 | getters, 17 | setters, 18 | useAppStore, 19 | useAppStoreRef, 20 | } from '../app/store' 21 | 22 | export function roundMinutes(date: DateTime) { 23 | return date.set({ 24 | minute: Math.floor(date.minute) - (Math.floor(date.minute) % 15), 25 | second: 0, 26 | millisecond: 0, 27 | }) 28 | } 29 | 30 | export function insertTextAtCaret(text: string) { 31 | const sel = window.getSelection() 32 | if (!sel) return 33 | const range = sel.getRangeAt(0) 34 | range.deleteContents() 35 | const node = document.createTextNode(text) 36 | range.insertNode(node) 37 | range.setStartAfter(node) 38 | range.setEndAfter(node) 39 | } 40 | 41 | export function deleteTextAtCaret(chars: number) { 42 | const sel = window.getSelection() 43 | if (!sel) return 44 | // @ts-ignore 45 | for (let i = 0; i < chars; i++) sel.modify('extend', 'backward', 'character') 46 | sel.deleteFromDocument() 47 | } 48 | 49 | export const isDateISO = (isoString: string) => isoString.length === 10 50 | 51 | const NaNtoZero = (numberTest: number) => 52 | isNaN(numberTest) ? 0 : typeof numberTest === 'number' ? numberTest : 0 53 | 54 | export const getEndISO = ({ tasks, events, startISO, endISO }: BlockProps) => { 55 | invariant(startISO, endISO) 56 | let startTime = DateTime.fromISO(startISO) 57 | 58 | for (let event of events) { 59 | const length = DateTime.fromISO(event.endISO).diff( 60 | DateTime.fromISO(event.startISO) 61 | ) 62 | startTime = startTime.plus(length) 63 | } 64 | for (let task of tasks) { 65 | if (!task.duration) continue 66 | const length = Duration.fromDurationLike(task.duration) 67 | startTime = startTime.plus(length) 68 | } 69 | 70 | return _.max([toISO(startTime), endISO]) as string 71 | } 72 | 73 | export const getTodayNote = () => { 74 | const dailyNoteInfo = getters.get('dailyNoteInfo') 75 | const dailyNote = 76 | dailyNoteInfo.folder + moment().format(dailyNoteInfo.format) + '.md' 77 | return dailyNote 78 | } 79 | 80 | export const parseFolderFromPath = (path: string) => { 81 | if (path.endsWith('/')) path = path.slice(0, path.length - 1) 82 | if (path.includes('/')) path = path.slice(0, path.lastIndexOf('/')) 83 | if (!path.includes('/')) return path 84 | return path.slice(path.lastIndexOf('/') + 1) 85 | } 86 | 87 | export const parseFileFromPath = (path: string) => { 88 | if (path.includes('#')) path = path.slice(0, path.indexOf('#')) 89 | if (path.includes('>')) path = path.slice(0, path.indexOf('>')) 90 | if (path.includes('::')) path = path.slice(0, path.indexOf('::')) 91 | if (path === 'Daily') { 92 | path = parsePathFromDate(new Date().toISOString().slice(0, 10)) 93 | } 94 | if (!path.endsWith('.md')) path += '.md' 95 | return path 96 | } 97 | 98 | export const parsePathFromDate = ( 99 | date: string, 100 | dailyNoteInfo?: AppState['dailyNoteInfo'] 101 | ) => { 102 | if (!dailyNoteInfo) dailyNoteInfo = getters.get('dailyNoteInfo') 103 | const formattedDate = moment(date).format(dailyNoteInfo.format) 104 | return dailyNoteInfo.folder + formattedDate + '.md' 105 | } 106 | 107 | export const parseDateFromPath = ( 108 | path: string, 109 | dailyNoteInfo: AppState['dailyNoteInfo'] 110 | ) => { 111 | const date = moment( 112 | parseFileFromPath(path.replace(dailyNoteInfo.folder, '')).replace( 113 | '.md', 114 | '' 115 | ), 116 | dailyNoteInfo.format, 117 | true 118 | ) 119 | if (!date.isValid()) return false 120 | return date 121 | } 122 | 123 | export const splitHeading = (heading: string) => { 124 | return ( 125 | heading.includes('>') 126 | ? heading.split('>', 2) 127 | : heading.includes('#') 128 | ? heading.split('#', 2) 129 | : heading.includes('/') 130 | ? [ 131 | heading.slice(0, heading.lastIndexOf('/')), 132 | heading.slice(heading.lastIndexOf('/') + 1), 133 | ] 134 | : ['', heading] 135 | ) as [string, string] 136 | } 137 | 138 | export const getSubHeading = ( 139 | task: TaskProps, 140 | groupBy: AppState['settings']['groupBy'], 141 | hidePaths: string[] 142 | ) => { 143 | switch (groupBy) { 144 | case 'hybrid': 145 | case 'path': 146 | case 'priority': 147 | if (!task.path.includes('#')) return UNGROUPED 148 | const slicedPath = task.path.slice(task.path.indexOf('#') + 1) 149 | if (hidePaths.find((path) => path.includes(slicedPath))) return UNGROUPED 150 | return slicedPath 151 | case 'tags': 152 | return UNGROUPED 153 | } 154 | } 155 | 156 | export const getHeading = ( 157 | { 158 | path, 159 | page, 160 | priority, 161 | tags, 162 | }: Pick, 163 | dailyNoteInfo: AppState['dailyNoteInfo'], 164 | groupBy: AppState['settings']['groupBy'], 165 | hidePaths: string[] = [] 166 | ): string => { 167 | path = path.replace('.md', '') 168 | let heading = path 169 | if ( 170 | groupBy === 'priority' || 171 | (groupBy === 'hybrid' && priority !== TaskPriorities.DEFAULT) 172 | ) { 173 | heading = priorityNumberToSimplePriority[priority] 174 | } else if (groupBy === 'tags') { 175 | heading = [...tags] 176 | .map((x) => x.replace('#', '')) 177 | .sort() 178 | .join(', ') 179 | } else if (groupBy === 'path' || groupBy === 'hybrid') { 180 | if (page) { 181 | heading = parseFolderFromPath(path) 182 | } else { 183 | // replace daily note 184 | const file = parseFileFromPath(heading) 185 | const date = parseDateFromPath(file, dailyNoteInfo) 186 | if (date) heading = 'Daily' + (heading.match(/#.+$/)?.[0] ?? '') 187 | else heading = file.replace('.md', '') 188 | } 189 | } else heading = UNGROUPED 190 | 191 | if (hidePaths.find((path) => path.includes(heading))) heading = UNGROUPED 192 | return heading 193 | } 194 | 195 | export const getTasksByHeading = ( 196 | tasks: AppState['tasks'], 197 | dailyNoteInfo: AppState['dailyNoteInfo'], 198 | fileOrder: string[], 199 | groupBy: AppState['settings']['groupBy'] 200 | ): [string, TaskProps[]][] => { 201 | return _.sortBy( 202 | _.entries( 203 | _.groupBy( 204 | _.filter(tasks, (task) => !task.completed), 205 | (task) => getHeading(task, dailyNoteInfo, groupBy) 206 | ) 207 | ), 208 | ([heading, _tasks]) => fileOrder.indexOf(parseFileFromPath(heading)) 209 | ) 210 | } 211 | 212 | export const convertSearchToRegExp = (search: string) => 213 | new RegExp( 214 | search 215 | .split('') 216 | .map((letter) => _.escapeRegExp(letter)) 217 | .join('.*?'), 218 | 'i' 219 | ) 220 | 221 | export const isLengthType = (type?: DragData['dragType']) => 222 | (type && type === 'task-length') || type === 'time' 223 | 224 | export const removeNestedChildren = (id: string, taskList: TaskProps[]) => { 225 | for (let child of taskList) { 226 | if (child.parent === id) { 227 | _.remove(taskList, child) 228 | } 229 | } 230 | } 231 | 232 | export const parseTaskDate = ( 233 | task: TaskProps, 234 | tasks: AppState['tasks'] 235 | ): string | undefined => { 236 | let currentParent = task 237 | const parseDate = (task) => task.scheduled || task.completion 238 | // parents with later scheduled dates "pull" their children forward 239 | while (currentParent.parent) { 240 | const nextScheduled = parseDate(tasks[currentParent.parent]) 241 | if (!nextScheduled || nextScheduled < parseDate(currentParent)) break 242 | currentParent = tasks[currentParent.parent] 243 | } 244 | return parseDate(currentParent) 245 | } 246 | 247 | export const toISO = (date: DateTime, isDate?: boolean) => { 248 | const d = date.toISO({ 249 | suppressMilliseconds: true, 250 | suppressSeconds: true, 251 | includeOffset: false, 252 | }) as string 253 | if (isDate) return d.slice(0, 10) 254 | else return d 255 | } 256 | 257 | export const useHourDisplay = (hours: number) => { 258 | const twentyFourHourFormat = useAppStore( 259 | (state) => state.settings.twentyFourHourFormat 260 | ) 261 | 262 | const hourDisplay = twentyFourHourFormat 263 | ? hours 264 | : [12, 0].includes(hours) 265 | ? '12' 266 | : hours % 12 267 | 268 | return hourDisplay 269 | } 270 | 271 | export const useChildWidth = () => { 272 | const [_childWidth, childWidthRef] = useAppStoreRef( 273 | (state) => state.childWidth 274 | ) 275 | const recreateWindow = useAppStore((state) => state.recreateWindow) 276 | const setChildWidth = (newChildWidth: number) => { 277 | if (newChildWidth !== childWidthRef.current) 278 | setters.set({ childWidth: newChildWidth }) 279 | } 280 | const [viewMode, viewModeRef] = useAppStoreRef( 281 | (state) => state.settings.viewMode 282 | ) 283 | const childWidthToClass = [ 284 | '', 285 | 'child:w-full', 286 | 'child:w-1/2', 287 | 'child:w-1/3', 288 | 'child:w-1/4', 289 | ] 290 | 291 | // this needs to be refreshed upon moving the app to new window... 292 | useEffect(() => { 293 | function outputSize() { 294 | const timeRuler = document.querySelector('#time-ruler') 295 | if (!timeRuler) { 296 | window.setTimeout(outputSize, 500) 297 | return 298 | } 299 | 300 | invariant(timeRuler) 301 | const width = timeRuler.clientWidth 302 | const newChildWidth = 303 | width < 500 ? 1 : width < 800 ? 2 : width < 1200 ? 3 : 4 304 | 305 | setChildWidth(newChildWidth) 306 | } 307 | 308 | outputSize() 309 | }, [recreateWindow, viewMode]) 310 | 311 | const appChildWidth = viewMode === 'hour' ? 1 : childWidthRef.current 312 | const appChildClass = childWidthToClass[appChildWidth] 313 | return { 314 | childWidth: appChildWidth, 315 | childClass: appChildClass, 316 | } 317 | } 318 | 319 | let scrolling = false 320 | export const scrollToSection = async (id: string) => { 321 | let scrollTimeout: number | null = null 322 | return new Promise((resolve) => { 323 | const child = document.getElementById(`time-ruler-${id}`) 324 | if (!child) { 325 | reject('child not found') 326 | return 327 | } 328 | 329 | child.scrollIntoView({ 330 | inline: 'start', 331 | behavior: 'smooth', 332 | }) 333 | 334 | setTimeout(() => { 335 | resolve() 336 | scrollTimeout = null 337 | scrolling = false 338 | }, 500) 339 | }) 340 | } 341 | 342 | export const isGreater = ( 343 | firstScheduled: string | undefined, 344 | lastScheduled: string | undefined 345 | ) => { 346 | if (!lastScheduled) return false 347 | else if (!firstScheduled && lastScheduled) return true 348 | else if (lastScheduled && firstScheduled) { 349 | return lastScheduled > firstScheduled 350 | } 351 | } 352 | export const queryTasks = ( 353 | id: string, 354 | query: string, 355 | tasks: Record 356 | ) => { 357 | const paths = query.match(/\"[^\"]+\"/g)?.map((x) => x.slice(1, -1)) 358 | if (paths) { 359 | for (let path of paths) query = query.replace(`"${path}"`, '') 360 | } 361 | const tags = query.match(/#([\w-]+)/g)?.map((x) => x.slice(1)) 362 | const fields = query.split(/ ?WHERE ?/)[1] 363 | 364 | type Comparison = '<' | '<=' | '=' | '>=' | '>' | '!=' 365 | type FieldTest = { 366 | key: string 367 | comparison: Comparison 368 | value: string | number | boolean 369 | } 370 | let fieldTests: FieldTest[] = [] 371 | const NOT_EXIST = 'NOT_EXIST' 372 | const EXIST = 'EXIST' 373 | if (fields) { 374 | fieldTests = fields.split(/ ?(AND|OR|&|\|) ?/).flatMap((test) => { 375 | const matches = test.match(/(.+?) ?(!=|<=|>=|=|<|>) ?(.+)/) 376 | let value = test.startsWith('!') 377 | ? NOT_EXIST 378 | : !matches 379 | ? EXIST 380 | : matches[3] 381 | let parsedValue: string | number | boolean = value 382 | let key = matches ? matches[1] : test 383 | if (matches && matches[1] === 'priority') { 384 | if (simplePriorityToNumber[value] !== undefined) 385 | parsedValue = simplePriorityToNumber[value] 386 | else if (priorityKeyToNumber[value] !== undefined) 387 | parsedValue = priorityKeyToNumber[value] 388 | } else if (value === 'true') parsedValue = true 389 | else if (value === 'false') parsedValue = false 390 | else parsedValue = value 391 | 392 | return { 393 | key, 394 | comparison: (matches?.[2] ?? '=') as Comparison, 395 | value: parsedValue, 396 | } 397 | }) 398 | } 399 | 400 | if (!paths && !tags && !fieldTests.length) return [] 401 | 402 | const testField = (test: FieldTest, task: TaskProps): boolean => { 403 | let value: string = task[test.key] ?? task.extraFields?.[test.key] 404 | if (test.value === EXIST) return !!value 405 | if (test.value === NOT_EXIST) return !value 406 | if (value === undefined) return false 407 | 408 | switch (test.comparison) { 409 | case '=': 410 | return test.value === value 411 | case '!=': 412 | return test.value !== value 413 | case '<': 414 | return test.value < value 415 | case '<=': 416 | return test.value <= value 417 | case '>': 418 | return test.value > value 419 | case '>=': 420 | return test.value >= value 421 | } 422 | } 423 | 424 | return _.filter(_.values(tasks), (subtask) => { 425 | const thisScheduled = getParentScheduled(tasks[id], tasks) 426 | const scheduled = getParentScheduled(subtask, tasks) 427 | if ( 428 | subtask.completed || 429 | subtask.id === id || 430 | !nestedScheduled(thisScheduled, scheduled) 431 | ) 432 | return false 433 | if ( 434 | paths && 435 | paths.map((path) => subtask.path.includes(path)).includes(false) 436 | ) 437 | return false 438 | 439 | if (tags && tags.map((tag) => subtask.tags.includes(tag)).includes(false)) 440 | return false 441 | if ( 442 | fieldTests && 443 | fieldTests.map((field) => testField(field, subtask)).includes(false) 444 | ) 445 | return false 446 | return true 447 | }) 448 | } 449 | 450 | export const getChildren = ( 451 | task: TaskProps, 452 | tasks: AppState['tasks'] 453 | ): string[] => 454 | !task 455 | ? [] 456 | : task.children 457 | .flatMap((id) => [id, ...getChildren(tasks[id], tasks)]) 458 | .concat(task.queryChildren ? task.queryChildren : []) 459 | 460 | export const getParents = (task: TaskProps, tasks: AppState['tasks']) => { 461 | const parents: TaskProps[] = [] 462 | let parent = task.parent ? tasks[task.parent] : undefined 463 | while (parent) { 464 | parents.push(parent) 465 | parent = parent.parent ? tasks[parent.parent] : undefined 466 | } 467 | return parents 468 | } 469 | 470 | export const getParentScheduled = ( 471 | task: TaskProps, 472 | tasks: AppState['tasks'] 473 | ) => { 474 | if (parseTaskDate(task, tasks)) return parseTaskDate(task, tasks) 475 | let parent = task.parent ?? task.queryParent 476 | while (parent) { 477 | task = tasks[parent] 478 | if (parseTaskDate(task, tasks)) return parseTaskDate(task, tasks) 479 | parent = task.parent ?? task.queryParent 480 | } 481 | return undefined 482 | } 483 | 484 | export const nestedScheduled = ( 485 | parentScheduled: TaskProps['scheduled'], 486 | childScheduled: TaskProps['scheduled'] 487 | ) => { 488 | const now = getToday() 489 | if (parentScheduled && parentScheduled < now) parentScheduled = now 490 | if (childScheduled && childScheduled < now) childScheduled = now 491 | return !parentScheduled && childScheduled 492 | ? false 493 | : parentScheduled && childScheduled && parentScheduled < childScheduled 494 | ? false 495 | : true 496 | } 497 | 498 | export const getToday = () => { 499 | return getStartDate(DateTime.now()) 500 | } 501 | 502 | export const getStartDate = (time: DateTime) => { 503 | const dayEnd = getters.get('settings').dayStartEnd[1] 504 | if (dayEnd < 12 && time.hour < dayEnd) 505 | return time.minus({ days: 1 }).toISODate() as string 506 | else return time.toISODate() as string 507 | } 508 | 509 | export const hasPriority = (task: TaskProps) => 510 | task.priority !== undefined && task.priority !== TaskPriorities.DEFAULT 511 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind utilities; 2 | @tailwind components; 3 | @tailwind base; 4 | 5 | @layer base { 6 | #time-ruler button { 7 | @apply selectable bg-transparent; 8 | } 9 | } 10 | 11 | @layer components { 12 | .selectable { 13 | @apply transition-colors duration-300 mouse:hover:bg-hover; 14 | } 15 | 16 | .tr-menu { 17 | @apply absolute left-0 top-full z-50 max-w-[80vw] p-2 text-sm; 18 | } 19 | 20 | .tr-menu > div { 21 | @apply rounded-icon border border-solid border-faint bg-primary p-2; 22 | } 23 | 24 | .tr-menu > div > div { 25 | @apply flex items-center !justify-start space-x-2; 26 | } 27 | div.unscheduled div.time-ruler-groups { 28 | @apply flex overflow-y-hidden overflow-x-auto flex-col flex-wrap !w-full !h-full snap-x snap-mandatory; 29 | } 30 | 31 | div.unscheduled div.time-ruler-group { 32 | @apply max-h-full !overflow-y-auto snap-start; 33 | } 34 | } 35 | 36 | @layer utilities { 37 | .obsidian-border { 38 | box-shadow: 0 0 0 1px var(--background-modifier-border); 39 | } 40 | 41 | .no-scrollbar::-webkit-scrollbar { 42 | display: none; 43 | } 44 | 45 | .force-hover:hover { 46 | opacity: var(--icon-opacity-hover) !important; 47 | color: var(--icon-color-hover) !important; 48 | background-color: var(--background-modifier-hover) !important; 49 | } 50 | 51 | .force-hover { 52 | @apply transition-colors duration-300; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/tests/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { getAPI } from 'obsidian-dataview' 2 | import invariant from 'tiny-invariant' 3 | import { textToTask } from '../services/parser' 4 | import { getDailyNoteInfo } from '../services/obsidianApi' 5 | import _ from 'lodash' 6 | 7 | window['app']['__timeRulerTests'] = async ( 8 | testPath: string, 9 | format = 'dataview' 10 | ) => { 11 | const dv = getAPI() 12 | invariant(dv, 'get Dataview') 13 | const tasks = dv.pages(`"${testPath}"`)['file']['tasks'] 14 | 15 | const dailyNoteInfo = await getDailyNoteInfo() 16 | invariant(dailyNoteInfo) 17 | 18 | for (let testTask of tasks) { 19 | try { 20 | const task = textToTask(testTask, dailyNoteInfo, 'dataview') 21 | 22 | const expected = JSON.parse(testTask['expect']) 23 | for (let key of Object.keys(expected)) { 24 | const parsedKey = 25 | expected[key] === 'undefined' ? undefined : expected[key] 26 | 27 | invariant( 28 | _.isEqual(task[key] ?? 0, parsedKey ?? 0), 29 | `${task[key]} is not ${parsedKey}` 30 | ) 31 | } 32 | } catch (err) { 33 | console.error('Failed:', err.message) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/types/enums.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export enum TaskActions { 4 | DELETE = 'DELETE', 5 | } 6 | 7 | export enum TaskPriorities { 8 | HIGHEST, 9 | HIGH, 10 | MEDIUM, 11 | DEFAULT, 12 | LOW, 13 | LOWEST, 14 | } 15 | 16 | export const priorityKeyToNumber = { 17 | lowest: TaskPriorities.LOWEST, 18 | low: TaskPriorities.LOW, 19 | medium: TaskPriorities.MEDIUM, 20 | high: TaskPriorities.HIGH, 21 | highest: TaskPriorities.HIGHEST, 22 | default: TaskPriorities.DEFAULT, 23 | } 24 | 25 | export const simplePriorityToNumber = { 26 | '...': TaskPriorities.LOWEST, 27 | '?': TaskPriorities.LOW, 28 | '-': TaskPriorities.DEFAULT, 29 | '!': TaskPriorities.MEDIUM, 30 | '!!': TaskPriorities.HIGH, 31 | '!!!': TaskPriorities.HIGHEST, 32 | } 33 | 34 | export const priorityNumberToSimplePriority = _.invert(simplePriorityToNumber) 35 | export const priorityNumberToKey = _.invert(priorityKeyToNumber) 36 | 37 | export const keyToTasksEmoji = { 38 | reminder: '⏰', 39 | scheduled: '⏳', 40 | due: '📅', 41 | start: '🛫', 42 | completion: '✅', 43 | created: '➕', 44 | repeat: '🔁', 45 | low: '🔽', 46 | medium: '🔼', 47 | high: '⏫', 48 | highest: '🔺', 49 | lowest: '⏬', 50 | } 51 | 52 | export const TasksEmojiToKey = _.invert(keyToTasksEmoji) 53 | 54 | const dataViewKeys = [ 55 | 'annotated', 56 | 'children', 57 | 'header', 58 | 'line', 59 | 'lineCount', 60 | 'link', 61 | 'list', 62 | 'outlinks', 63 | 'parent', 64 | 'path', 65 | 'position', 66 | 'real', 67 | 'section', 68 | 'status', 69 | 'subtasks', 70 | 'symbol', 71 | 'tags', 72 | 'task', 73 | 'text', 74 | ] 75 | const sTaskKeys = [ 76 | 'checked', 77 | 'completed', // boolean 78 | 'fullyCompleted', 79 | 'created', 80 | 'due', 81 | 'completion', 82 | 'start', 83 | 'scheduled', 84 | 'length', 85 | 'priority', 86 | ] 87 | const fullCalendarKeys = [ 88 | 'startTime', 89 | 'endTime', 90 | 'date', 91 | 'completed', // date 92 | 'type', 93 | 'allDay', 94 | 'title', 95 | ] 96 | const tasksKeys = [ 97 | 'start', 98 | 'completion', 99 | 'scheduled', 100 | 'priority', 101 | 'due', 102 | 'created', 103 | 'repeat', 104 | ] 105 | 106 | const timeRulerKeys = ['duration', 'query'] 107 | 108 | export const RESERVED_FIELDS = dataViewKeys.concat( 109 | sTaskKeys, 110 | fullCalendarKeys, 111 | tasksKeys, 112 | timeRulerKeys 113 | ) 114 | 115 | export const isTaskProps = (data: DropData): data is Partial => 116 | !data.type || !['heading', 'delete'].includes(data.type) 117 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { STask } from 'obsidian-dataview' 2 | import { GroupComponentProps } from '../components/Group' 3 | import { TaskComponentProps } from '../components/Task' 4 | import { BlockComponentProps } from 'src/components/Block' 5 | 6 | declare global { 7 | type FieldFormat = { 8 | main: 'dataview' | 'full-calendar' | 'tasks' | 'simple' | 'kanban' 9 | reminder: 'native' | 'tasks' | 'kanban' 10 | scheduled: 'default' | 'kanban' 11 | } 12 | 13 | type EventProps = { 14 | id: string 15 | startISO: string 16 | endISO: string 17 | title: string 18 | calendarName: string 19 | calendarId: string 20 | color: string 21 | notes?: string 22 | location?: string 23 | type: 'event' 24 | } 25 | 26 | type TaskProps = { 27 | type: 'task' 28 | id: string 29 | page: boolean 30 | title: string 31 | originalTitle: string 32 | originalText: string 33 | notes?: string 34 | tags: string[] 35 | children: string[] 36 | position: STask['position'] 37 | path: string 38 | parent?: string 39 | extraFields?: Record 40 | duration?: { hour: number; minute: number } 41 | status: string 42 | blockReference?: string 43 | fieldFormat: FieldFormat['main'] 44 | completed: boolean 45 | query?: string 46 | queryParent?: string 47 | queryChildren?: string[] 48 | links: string[] 49 | 50 | // Obsidian Reminder 51 | reminder?: string 52 | 53 | // TASKS values, to be translated to emojis if setting is enabled 54 | created?: string 55 | start?: string 56 | scheduled?: string 57 | priority: number 58 | due?: string 59 | completion?: string 60 | repeat?: string 61 | } 62 | 63 | type GoogleEvent = { 64 | id: string 65 | summary?: string 66 | description?: string 67 | location?: string 68 | } & ( 69 | | { 70 | start: { date: string; dateTime?: null } 71 | end: { date: string; dateTime?: null } 72 | } 73 | | { 74 | start: { dateTime: string; date?: null } 75 | end: { dateTime: string; date?: null } 76 | } 77 | ) 78 | 79 | type GoogleCalendar = { 80 | id: string 81 | summary: string 82 | backgroundColor: string 83 | primary: boolean 84 | deleted: boolean 85 | } 86 | 87 | type DragData = 88 | | ({ dragType: 'group' } & GroupComponentProps) 89 | | ({ dragType: 'task' } & TaskComponentProps) 90 | | ({ dragType: 'block' } & BlockComponentProps) 91 | | ({ dragType: 'task-length' } & { 92 | id: string 93 | start: string 94 | end?: string 95 | }) 96 | | ({ dragType: 'time' } & { start: string; end?: string }) 97 | | ({ dragType: 'due' } & { task: TaskProps }) 98 | | { dragType: 'new_button' } 99 | 100 | type DropData = 101 | | Partial 102 | | { type: 'heading'; heading: string } 103 | | { type: 'delete' } 104 | | { type: 'move' } 105 | } 106 | 107 | declare module 'obsidian' { 108 | interface App { 109 | isMobile: boolean 110 | } 111 | 112 | interface Vault { 113 | getConfig: (key: string) => string | string[] | undefined 114 | readConfigJson: ( 115 | path: string 116 | ) => Promise> 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/types/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' 2 | declare module '*.mp3' 3 | declare module '*.png' 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const plugin = require('tailwindcss/plugin') 2 | 3 | const INDENT = 28 4 | 5 | /** @type {import('tailwindcss').Config} */ 6 | module.exports = { 7 | content: [`src/**/*.{tsx,css,ts}`], 8 | plugins: [ 9 | plugin(function ({ addVariant }) { 10 | addVariant('child', '& > *') 11 | addVariant('ancestor', '& *') 12 | }), 13 | ], 14 | corePlugins: { 15 | preflight: false, 16 | }, 17 | important: '#time-ruler', 18 | theme: { 19 | extend: { 20 | padding: { 21 | indent: INDENT, 22 | }, 23 | margin: { 24 | indent: INDENT, 25 | }, 26 | width: { 27 | indent: INDENT, 28 | }, 29 | minWidth: { 30 | indent: INDENT, 31 | }, 32 | minHeight: { 33 | indent: INDENT, 34 | line: 'var(--font-text-size)', 35 | }, 36 | maxHeight: { 37 | line: 'var(--font-text-size)', 38 | }, 39 | lineHeight: { 40 | line: 'var(--line-height-normal)', 41 | }, 42 | height: { 43 | line: 'calc(var(--line-height-normal) * 1em)', 44 | }, 45 | fontSize: { 46 | base: 'var(--font-text-size)', 47 | }, 48 | fontFamily: { 49 | menu: 'var(--font-interface)', 50 | serif: 'var(--font-text)', 51 | sans: 'var(--font-text)', 52 | }, 53 | borderRadius: { 54 | icon: 'var(--clickable-icon-radius)', 55 | checkbox: 'var(--checkbox-radius)', 56 | }, 57 | colors: { 58 | primary: 'var(--background-primary)', 59 | code: 'var(--code-background)', 60 | error: 'var(--background-modifier-error)', 61 | border: 'var(--background-modifier-border)', 62 | hover: 'var(--background-modifier-hover)', 63 | selection: 'var(--text-selection)', 64 | normal: 'var(--text-normal)', 65 | muted: 'var(--text-muted)', 66 | faint: 'var(--text-faint)', 67 | accent: 'var(--text-accent)', 68 | divider: 'var(--divider-color)', 69 | }, 70 | screens: { 71 | mobile: { raw: '(hover: none)' }, 72 | mouse: { raw: '(hover: hover)' }, 73 | }, 74 | }, 75 | }, 76 | } 77 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES2019", 8 | "allowJs": true, 9 | "noImplicitAny": false, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": ["DOM", "ES5", "ES6", "ES7", "ES2019"], 15 | "jsx": "react-jsx", 16 | "allowSyntheticDefaultImports": true, 17 | "resolveJsonModule": true, 18 | "jsxImportSource": "react" 19 | }, 20 | "include": ["src", "packages"] 21 | } 22 | --------------------------------------------------------------------------------