Skip to content

Instantly share code, notes, and snippets.

@kerbyfc
Created April 24, 2020 09:29
Show Gist options
  • Save kerbyfc/12a1b76ba2f0fe4b0a93dc6544ccde7c to your computer and use it in GitHub Desktop.
Save kerbyfc/12a1b76ba2f0fe4b0a93dc6544ccde7c to your computer and use it in GitHub Desktop.
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