users/ryan/modules/taskwarrior-ubersicht/taskwarrior-widget-source/taskwarrior.jsx
b43dfa30df8d9ac27491b6df6ec6b84f9afc8761
· 7.6 KB · 203 lines
raw
| 1 | // Originally inspired by Taskwarrior Widget by Kevin Bockelandt: |
| 2 | // https://github.com/KevinBockelandt/TaskwarriorWidget |
| 3 | // Rewritten from scratch using the new React + JSX framework. |
| 4 | |
| 5 | 'use strict'; |
| 6 | |
| 7 | export const command = "task +READY -PARENT export"; |
| 8 | |
| 9 | export const refreshFrequency = 10000; |
| 10 | |
| 11 | // Ridiculously high number of days (in ms), used to indicate there is no due date |
| 12 | export const maxDue = 100000 * 60 * 60 * 24 * 1000; |
| 13 | |
| 14 | export const className = ` |
| 15 | left: 30px; |
| 16 | top: 45px; |
| 17 | font-family: Helvetica Neue; |
| 18 | font-size: 10pt; |
| 19 | font-weight: 400; |
| 20 | background: rgba(0, 0, 0, 0.5); |
| 21 | border-radius: 7px; |
| 22 | padding: 5px; |
| 23 | `; |
| 24 | |
| 25 | // Configuration |
| 26 | // Note date differences are in ms |
| 27 | const millisecondsInDay = 24 * 60 * 60 * 1000; |
| 28 | const millisecondsInWeek = millisecondsInDay * 7; |
| 29 | const millisecondsInMonth = millisecondsInDay * 30.42; // on average |
| 30 | const millisecondsInYear = millisecondsInDay * 365.25; // appproximately |
| 31 | // Maximum number of entries to display |
| 32 | const maxEntries = 20; |
| 33 | // Indicator for tasks that are started |
| 34 | const startedIndicator = "🟊"; |
| 35 | |
| 36 | // Colours |
| 37 | const headerColour = Object.freeze({ r: 255, g: 255, b: 255, a: 1 }); |
| 38 | // The opacity is determined dynamically for all the following |
| 39 | const overDueColour = Object.freeze({ r: 255, g: 100, b: 100 }); |
| 40 | const dueColour = Object.freeze({ r: 255, g: 200, b: 0 }); |
| 41 | const normalColour = Object.freeze({ r: 255, g: 255, b: 255 }); |
| 42 | const tagsColour = Object.freeze({ r: 50, g: 225, b: 50 }); |
| 43 | |
| 44 | // Tasks fade out towards the bottom of the table in steps of 1 / maxEntries |
| 45 | const opacities = Object.freeze((() => { |
| 46 | let values = []; |
| 47 | for (let i = 0; i < maxEntries; i++) { |
| 48 | values.push(((i / maxEntries * -1) + 1).toFixed(2)); |
| 49 | } |
| 50 | return values; |
| 51 | })()); |
| 52 | |
| 53 | // Reformat and compute task values ready for output |
| 54 | const processTask = (task, index) => { |
| 55 | // Compute number of days to due date |
| 56 | if (task.due) { |
| 57 | let dueDateOffset = maxDue; |
| 58 | // Taskwarrior date strings aren't quite in ISO 8601 format |
| 59 | // e.g.: 20191007T110000Z, which should be 2019-10-07T11:00:00Z |
| 60 | let dateParts = [task.due.slice(0, 4), task.due.slice(4, 6), task.due.slice(6, 8)].join("-"); |
| 61 | let timeParts = [task.due.slice(8, 11), task.due.slice(11, 13), task.due.slice(13)].join(":"); |
| 62 | let dueDate = new Date(dateParts + timeParts); |
| 63 | let today = new Date(); |
| 64 | // Truncate the offset between the due date and today to the nearest day |
| 65 | today.setHours(0); |
| 66 | today.setMinutes(0); |
| 67 | today.setSeconds(0); |
| 68 | today.setMilliseconds(0); |
| 69 | dueDate.setMinutes(0); |
| 70 | dueDate.setSeconds(0); |
| 71 | dueDate.setMilliseconds(0); |
| 72 | |
| 73 | let difference = dueDate.getTime() - today.getTime(); |
| 74 | if (Math.abs(difference) > millisecondsInYear) { |
| 75 | dueDateOffset = Math.floor(difference / millisecondsInYear); |
| 76 | task.units = dueDateOffset + "y"; |
| 77 | } else if (Math.abs(difference) > millisecondsInMonth) { |
| 78 | dueDateOffset = Math.floor(difference / millisecondsInMonth); |
| 79 | task.units = dueDateOffset + "m"; |
| 80 | } else if (Math.abs(difference) > millisecondsInWeek) { |
| 81 | dueDateOffset = Math.floor(difference / millisecondsInWeek); |
| 82 | task.units = dueDateOffset + "w"; |
| 83 | } else { |
| 84 | dueDateOffset = Math.floor(difference / millisecondsInDay); |
| 85 | task.units = dueDateOffset + "d"; |
| 86 | } |
| 87 | task.due = difference; |
| 88 | } |
| 89 | |
| 90 | // If the task has tags, merge them into a single string |
| 91 | if (task.tags) { |
| 92 | task.tags = task.tags.map((tag) => { return "+" + tag; }).join(" "); |
| 93 | } |
| 94 | |
| 95 | // Mark started tasks |
| 96 | if (task.start) { |
| 97 | task.start = startedIndicator; |
| 98 | } |
| 99 | |
| 100 | // don't need to check for 0 here as it'll get set to 0.0 anyway |
| 101 | task.urgency = task.urgency ? parseFloat(task.urgency).toFixed(2) : 0.0; |
| 102 | |
| 103 | return task; |
| 104 | }; |
| 105 | |
| 106 | // Colourise the tasks in the output list |
| 107 | const colourTask = (task, index) => { |
| 108 | // Colour row text according to due date |
| 109 | if (task.due < 0) { |
| 110 | task.colour = Object.assign({}, overDueColour); |
| 111 | } |
| 112 | else if (task.due == 0) { |
| 113 | task.colour = Object.assign({}, dueColour); |
| 114 | } |
| 115 | else { |
| 116 | task.colour = Object.assign({}, normalColour); |
| 117 | } |
| 118 | task.colour.a = opacities[index]; |
| 119 | |
| 120 | return task; |
| 121 | } |
| 122 | |
| 123 | const rgbaString = (colour, a) => { |
| 124 | if (colour.a) { |
| 125 | return "rgba(" + colour.r + ", " + colour.g + ", " + colour.b + ", " + colour.a + ")"; |
| 126 | } |
| 127 | else { |
| 128 | return "rgba(" + colour.r + ", " + colour.g + ", " + colour.b + ", " + a + ")"; |
| 129 | } |
| 130 | }; |
| 131 | |
| 132 | export const render = ({ output, error }) => { |
| 133 | // What if error is already set? |
| 134 | // Will the following give an exception anyway? |
| 135 | try { |
| 136 | // Get the JSON object containing all the tasks and process them. |
| 137 | let taskList = JSON.parse(output).map(processTask); |
| 138 | // Sort the tasks by due date ascending, then by urgency descending. |
| 139 | taskList.sort((a, b) => { |
| 140 | // Adjust for no due date |
| 141 | // CAUTION: 0 is "falsy" but valid, so needs to be explicitly checked for |
| 142 | let aDue = (a.due || a.due === 0) ? a.due : maxDue; |
| 143 | let bDue = (b.due || b.due === 0) ? b.due : maxDue; |
| 144 | // Need urgency as a fraction. Maximum possible urgency is 60.7 |
| 145 | // with default settings, so let’s assume it’s less than 1000. |
| 146 | return (aDue - a.urgency/1000) - (bDue - b.urgency/1000); |
| 147 | }); |
| 148 | // We have to colourise after the sort. |
| 149 | // Only display the first maxEntries tasks. |
| 150 | taskList = taskList.slice(0, maxEntries).map(colourTask); |
| 151 | |
| 152 | return ( |
| 153 | <div id="taskwarrior-widget-container"> |
| 154 | <link rel="stylesheet" type="text/css" href="taskwarrior.widget/style.css" /> |
| 155 | <table> |
| 156 | <thead> |
| 157 | <tr className="header" style={{ color: rgbaString(headerColour) }}> |
| 158 | <th className="star">{startedIndicator}</th> |
| 159 | <th className="num">ID</th> |
| 160 | <th className="num">DUE</th> |
| 161 | <th>DESCRIPTION</th> |
| 162 | <th>PROJECT</th> |
| 163 | <th style={{ color: rgbaString(tagsColour, 1) }}>TAGS</th> |
| 164 | <th className="num">URG</th> |
| 165 | </tr> |
| 166 | </thead> |
| 167 | <tbody> |
| 168 | {taskList.map((task, index) => { |
| 169 | return ( |
| 170 | <tr style={{ color: rgbaString(task.colour) }} key={index}> |
| 171 | <td className="star">{task.start}</td> |
| 172 | <td className="num">{task.id}</td> |
| 173 | <td className="num">{task.units}</td> |
| 174 | <td>{task.description}</td> |
| 175 | <td>{task.project}</td> |
| 176 | <td style={{ color: rgbaString(tagsColour, task.colour.a) }}>{task.tags}</td> |
| 177 | <td className="num">{task.urgency}</td> |
| 178 | </tr> |
| 179 | ); |
| 180 | })} |
| 181 | </tbody> |
| 182 | </table> |
| 183 | </div> |
| 184 | ) |
| 185 | } |
| 186 | catch (e) { |
| 187 | if (!output) { |
| 188 | return ( |
| 189 | <div id="taskwarrior-widget-container"> |
| 190 | <p><strong>No tasks found.</strong></p> |
| 191 | </div> |
| 192 | ) |
| 193 | } |
| 194 | else { |
| 195 | console.error(e) |
| 196 | return ( |
| 197 | <div id="taskwarrior-widget-container"> |
| 198 | <p><strong class="error">Error: {e}.</strong></p> |
| 199 | </div> |
| 200 | ) |
| 201 | } |
| 202 | } |
| 203 | }; |