Created
April 24, 2020 09:29
-
-
Save kerbyfc/12a1b76ba2f0fe4b0a93dc6544ccde7c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const process = require('process'); | |
const axios = require("axios"); | |
const HOST = "https://api.todoist.com"; | |
const TOKEN = process.env.TODOIST_TOKEN; | |
const DURATION_LABEL_NAME_PATTERN = /^\d+[мч]+$/; | |
const MINUTE_DURATION_LABEL_NAME_PATTERN = /м$/; | |
const OBITER_LABEL_NAME_PATTERN = /Попутно/; | |
const MIN_TASK_DURATION_MINUTES = 5; | |
/** | |
Как настроить: | |
1. Создаем дневные фильтры (cм. примеры ниже, главное - название) | |
2. Создаем теги времени, с помощью которых будем назначать длительность задач, кратное 5 (MIN_TASK_DURATION_MINUTES), пример: 1ч, 5м, 15м, 30м, 45м | |
4. Создаем тег "Попутно" для тех дел, которые не нужно учитывать при расчете времени | |
5. Устанавливаем зависимости: `npm i axios` | |
6. Запускаем скрипт `DEBUG=true TODOIST_TOKEN=my_toodist_token node todoist.js` | |
7. Тегируем задачи с проставленной датой | |
Дневные фильтры с примером запроса | |
ПН (@*Встреча | Поиск: на встрече) & понедельник, (!@*Встреча & !@*Тудуист & !Поиск: на встрече) & понедельник, @*Тудуист & понедельник | |
ВТ (@*Встреча | Поиск: на встрече) & вторник, (!@*Встреча & !@*Тудуист & !Поиск: на встрече) & вторник, @*Тудуист &вторник | |
СР (@*Встреча | Поиск: на встрече) & среда, (!@*Встреча & !@*Тудуист & !Поиск: на встрече) & среда, @*Тудуист &среда | |
ЧТ (@*Встреча | Поиск: на встрече) & четверг, (!@*Встреча & !@*Тудуист & !Поиск: на встрече) & четверг, @*Тудуист &четверг | |
ПТ (@*Встреча | Поиск: на встрече) & пятница, (!@*Встреча & !@*Тудуист & !Поиск: на встрече) & пятница, @*Тудуист &пятница | |
Пример фильтра "сегодня" | |
@*БигРок & (сегодня | просрочено), (@*Встреча & (сегодня | просрочено)) | сегодня & Поиск: на встрече, просрочено, !@*Тудуист & !@*Встреча & !@*БигРок & сегодня & !Поиск: на встрече, @*Тудуист & сегодня | |
*/ | |
const MON = 'Mon'; | |
const TUE = 'Tue'; | |
const WED = 'Wed'; | |
const THU = 'Thu'; | |
const FRI = 'Fri'; | |
const SAT = 'Sat'; | |
const SUN = 'Sun'; | |
const DAY_NAMES = { | |
[MON]: 'пн', | |
[TUE]: 'вт', | |
[WED]: 'ср', | |
[THU]: 'чт', | |
[FRI]: 'пт', | |
[SAT]: 'сб', | |
[SUN]: 'вс', | |
}; | |
const DAY_KEYS_BY_NAME = { | |
[DAY_NAMES[MON]]: MON, | |
[DAY_NAMES[TUE]]: TUE, | |
[DAY_NAMES[WED]]: WED, | |
[DAY_NAMES[THU]]: THU, | |
[DAY_NAMES[FRI]]: FRI, | |
[DAY_NAMES[SAT]]: SAT, | |
[DAY_NAMES[SUN]]: SUN, | |
} | |
const DAY_START_TIME = { | |
[MON]: '11:00', // 10:00 + час дороги | |
[TUE]: '11:00', // 10:00 + час дороги | |
[WED]: '11:00', // 10:00 + час дороги | |
[THU]: '11:00', // 10:00 + час дороги | |
[FRI]: '09:30', // 08:00 + час дороги | |
[SAT]: '10:00', // 07:00 + 3 часа с Максом и Глебом | |
[SUN]: '10:30', // 10:00 + пол часа на завтрак | |
}; | |
const DAY_END_TIME = { | |
[MON]: '19:00', // 20:00 - 1 час дороги | |
[TUE]: '19:00', // 20:00 - 1 час дороги | |
[WED]: '19:00', // 20:00 - 1 час дороги | |
[THU]: '19:00', // 20:00 - 1 час дороги | |
[FRI]: '19:00', // 20:00 - 1 час дороги | |
[SAT]: '21:00', // время до укладываниия детей | |
[SUN]: '21:00', // время до укладываниия детей | |
}; | |
/** | |
* Сколько времени оставлять под текучку, на это время не планируются дела. | |
* Используется для планирования, для расчета времени текущего дня не учитывается | |
*/ | |
const FUTURE_DAYS_INCOME_TASKS_CAPACITY_FACTOR = { | |
Mon: 1 / 2, | |
Tue: 1 / 2, | |
Wed: 1 / 2, | |
Thu: 1 / 2, | |
Fri: 1 / 2, | |
Sat: 1 / 2, | |
Sun: 1 / 2, | |
}; | |
const DAY_FILTER_PATTERN = new RegExp(`^(?:.*)(${Object.values(DAY_NAMES).join('|')})+(?:\\s+.+|$)`, 'i') | |
// API HELPERS | |
const syncRequest = (data) => { | |
const options = { | |
url: `${HOST}/sync/v8/sync`, | |
method: "POST", | |
mode: "no-cors", | |
data: { | |
...data, | |
token: TOKEN | |
} | |
}; | |
return axios(options); | |
}; | |
const restRequest = ( | |
method, | |
path, | |
{ params = {}, headers = {} } | |
) => { | |
const options = { | |
url: `${HOST}/rest/v1/${path}`, | |
method, | |
params, | |
headers: { | |
...headers, | |
Authorization: `Bearer ${TOKEN}` | |
} | |
}; | |
return axios(options); | |
}; | |
const getRequest = (path, options = {}) => restRequest("GET", path, options); | |
// API METHODS | |
const fetchResources = (resourcesTypes = ["all"]) => | |
syncRequest({ | |
sync_token: "*", | |
resource_types: resourcesTypes | |
}); | |
const fetchFilterTasks = filter => | |
getRequest("tasks", { | |
params: { | |
filter: encodeFilterParam(filter), | |
lang: "rus" | |
} | |
}).then(({data}) => data); | |
// HELPERS | |
const all = promises => Promise.all(promises); | |
const debug = (argsGetter) => { | |
if (process.env.DEBUG) { | |
const data = argsGetter(); | |
if (data instanceof Array) { | |
console.log(...data); | |
} | |
} | |
}; | |
const getCurrentDayName = () => new Date().toString().split(' ')[0]; | |
const encodeFilterParam = filter => filter.query | |
.split(/\s*,\s*/) | |
.map(filterPart => `(${filterPart})`) | |
.join(' | '); | |
const getDayPlaningFilters = (filters = []) => { | |
return filters.filter(({ name }) => { | |
return DAY_FILTER_PATTERN.test(name); | |
}); | |
}; | |
const getCapacityLabels = capacityLabels => | |
capacityLabels.filter(label => DURATION_LABEL_NAME_PATTERN.test(label.name)); | |
const getTaskDuration = (data, taskId) => { | |
const task = data.entities.task[taskId]; | |
const capacityLabelId = task.label_ids.find(id => data.results.capacityLabels.includes(id)); | |
if (capacityLabelId) { | |
return getLabelCapacity(data.entities.label[capacityLabelId]); | |
}; | |
return 0; | |
}; | |
const getLabelCapacity = label => MINUTE_DURATION_LABEL_NAME_PATTERN.test(label.name) | |
? parseInt(label.name) | |
: parseInt(label.name) * 60 | |
const calcDayFiltersMetadata = data => { | |
data.results.filters.forEach(filterId => { | |
data.entities.filter[filterId].capacity = calcDayFilterBalance(data, filterId); | |
}); | |
return data; | |
}; | |
const getDayCapacity = (dayKey, fromCurrentTime = false) => { | |
const [endTimeHours, endTimeMinutes] = DAY_END_TIME[dayKey].split(':'); | |
let [startTimeHours, startTimeMinutes] = DAY_START_TIME[dayKey].split(':'); | |
if (fromCurrentTime) { | |
startTimeHours = new Date().getHours(); | |
startTimeMinutes = new Date().getMinutes(); | |
} | |
const startTime = parseInt(startTimeHours) * 60 + parseInt(startTimeMinutes); | |
const endTime = parseInt(endTimeHours) * 60 + parseInt(endTimeMinutes); | |
debug(() => [ | |
`Рассчет полной ёмкости дня ${DAY_NAMES[dayKey]}: ${startTimeHours}:${startTimeMinutes} - ${endTimeHours}:${endTimeMinutes} = ${endTime - startTime}` | |
]); | |
return endTime - startTime; | |
}; | |
const checkFilterDayIsCurrentDay = (filter) => { | |
const dayFilterName = getFilterDayName(filter).toLowerCase(); | |
return DAY_NAMES[getCurrentDayName()].toLowerCase() === dayFilterName; | |
} | |
const getFilterDayKey = (filter) => { | |
const dayFilterName = getFilterDayName(filter).toLowerCase(); | |
return DAY_KEYS_BY_NAME[dayFilterName]; | |
} | |
const getDayWorkTimeBalance = (data, filterId) => { | |
const filter = data.entities.filter[filterId]; | |
const currentDayName = getCurrentDayName(); | |
const dayFilterNameIsCurrentDay = checkFilterDayIsCurrentDay(filter); | |
const shouldConsiderCurrentTime = dayFilterNameIsCurrentDay; | |
const shouldConsiderIncomeCapacityFactor = !dayFilterNameIsCurrentDay; | |
const dayKey = getFilterDayKey(filter); | |
const dayCapacity = getDayCapacity(dayKey, shouldConsiderCurrentTime); | |
const incomeCapacityDelta = shouldConsiderIncomeCapacityFactor | |
? dayCapacity * FUTURE_DAYS_INCOME_TASKS_CAPACITY_FACTOR[currentDayName] | |
: 0; | |
const capacity = dayCapacity - incomeCapacityDelta; | |
debug(() => [ | |
`Подсчет рабочего времени ${getFilterDayName(filter)}: ${stringifyDuration(dayCapacity)} - ${stringifyDuration(incomeCapacityDelta)} = ${stringifyDuration(capacity)}` | |
]); | |
return capacity - (capacity % MIN_TASK_DURATION_MINUTES); | |
}; | |
const calcDayFilterBalance = (data, filterId) => { | |
const filterTasks = data.results.filterTasks[filterId]; | |
const balance = getDayWorkTimeBalance(data, filterId); | |
debug(() => [`Считаем остаток времени для ${getFilterDayName(data.entities.filter[filterId])}`]); | |
const result = filterTasks.reduce((acc, taskId) => { | |
const taskDuration = !isObiterTask(data, taskId) ? getTaskDuration(data, taskId) : 0; | |
debug(() => { | |
const task = data.entities.task[taskId]; | |
if (taskDuration > 0) { | |
return [`- ${taskDuration} = ${acc - taskDuration} (${task.content})`]; | |
} | |
}); | |
return acc - taskDuration; | |
}, balance); | |
debug(() => [result]); | |
return result; | |
} | |
const isObiterTask = (data, taskId) => { | |
const task = data.entities.task[taskId]; | |
return task.label_ids.find(id => id === data.results.obiterLabel); | |
}; | |
const getDurationSign = (duration) => { | |
switch (true) { | |
case duration <= -120: return '🔥'; | |
case duration <= -60: return '💥'; | |
case duration < 0: return '⚡️'; | |
case duration >= 60: return '🏖'; | |
default: return '🌈'; | |
} | |
} | |
const stringifyDuration = (duration, withIcons = false) => { | |
const hours = Math.floor(duration / 60); | |
const minutes = String(Math.abs(duration % 60)).padStart(2, '0'); | |
return `${withIcons ? getDurationSign(duration): ''} ${hours}:${minutes}`.trim(); | |
} | |
const getFilterDayName = (dayFilter) => { | |
return dayFilter.name.match(DAY_FILTER_PATTERN)[1].toLowerCase(); | |
}; | |
const updateFiltersNames = data => { | |
const commands = data.results.filters.map(filterId => { | |
const filter = data.entities.filter[filterId]; | |
const dayName = getFilterDayName(filter); | |
const queryWithoutExpiredTasks = filter.query.replace(/^Просрочено, /, ''); | |
const query = checkFilterDayIsCurrentDay(filter) | |
? `Просрочено, ${queryWithoutExpiredTasks}` | |
: queryWithoutExpiredTasks; | |
const args = { | |
query, | |
id: filterId, | |
name: `${dayName} ${stringifyDuration(filter.capacity, true)}`, | |
}; | |
return { | |
type: 'filter_update', | |
uuid: `kerby_uuid_${String(Math.random()).slice(2)}_${Date.now()}`, | |
args, | |
}; | |
}); | |
return syncRequest({ | |
commands: JSON.stringify(commands) | |
}); | |
}; | |
const getId = ({id}) => id; | |
const normalizeEntities = entities => | |
entities.reduce((acc, entity) => ({ | |
...acc, | |
[getId(entity)]: entity, | |
}), {}); | |
const fetchFiltersTasks = data => | |
all(data.results.filters.map(filterId => | |
fetchFilterTasks(data.entities.filter[filterId]) | |
)); | |
const normalizeFilterTasks = (data, filtersTasks) => | |
filtersTasks.reduce((acc, filterTasks, i) => ({ | |
results: { | |
...acc.results, | |
filterTasks: { | |
...acc.results.filterTasks, | |
[acc.results.filters[i]]: filterTasks.map(getId) | |
}, | |
}, | |
entities: { | |
...acc.entities, | |
task: { | |
...acc.entities.task, | |
...normalizeEntities(filterTasks), | |
}, | |
} | |
}), data); | |
const getObiterLabel = labels => labels.find(label => OBITER_LABEL_NAME_PATTERN.test(label.name)) | |
// MAIN | |
const main = () => { | |
fetchResources(['filters', 'labels']) | |
.then(({ data }) => { | |
const filters = getDayPlaningFilters(data.filters); | |
const capacityLabels = getCapacityLabels(data.labels); | |
const obiterLabel = getId(getObiterLabel(data.labels)); | |
const state = { | |
results: { | |
obiterLabel, | |
filters: filters.map(getId), | |
capacityLabels: capacityLabels.map(getId), | |
filterTasks: [], | |
}, | |
entities: { | |
filter: normalizeEntities(filters), | |
label: normalizeEntities(data.labels), | |
task: {}, | |
}, | |
}; | |
return state; | |
}) | |
.then(data => fetchFiltersTasks(data) | |
.then(filtersTasks => normalizeFilterTasks(data, filtersTasks)) | |
) | |
.then(calcDayFiltersMetadata) | |
.then(updateFiltersNames) | |
}; | |
setInterval(main, 60 * 1000); | |
main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment