3 года заметок в Notion: анализ и визуализация
Столкнувшись со шквалом задач разной степени важности, 3 года назад я принял решение начать записывать задачи в планер/to do list который было бы удобно вести и с телефона, и с ноутбука. Выбор пал на Notion, как на популярную межоперационную платформу. За время использования планера было выполнено множество разных задач, и стало интересно провести некоторый анализ того, как и на что уходило время.
1. Устройство Notion планера
Notion планер является одной большой страничкой Database
с форматом просмотра By Status
, наполненной задачами, реализованными как page
. Именно Status является ключевой фичей, характеризующей то, в каком состояние находиться каждая задача.
Для удобства статусы у меня принимают разные значение, главные 6 из которых это:
Top Priority для короткой задачи требующей незамедлительного внимания
Goal как долгая задача/цель, требующая последовательного подхода, но приводящая к серьезному результату
и соответвующие им 4 скрытых статуса:
Чтобы не плодить лишние сущности, прямо в планере есть статусы для задач второго плана, персональных дел, возможных идей для проектов и планов на совместный досуг с девушкой. А также, с недавнего времени добавилась колонка повторяющихся задач, которую нужно было добавить, чтобы она вечно напоминала о себе, но не засоряла поток Top priority
Общий вид планера. Как можно догадаться из названия, проект начинался как реализация GTD.
По началу я также заполнял такие параметры как Date и Tag, чтобы фильтровать задачи по их профилю, но со временем надобность в последнем почти отпала, так что Tag носит только декоративный характер, позволяя визуально отделить задачи друг от друга. А вот Date показывает себя особенно удобно в случае необходимости в кратчейшее сроки решить сразу много задач с близким дедлайном.
Наиболее удобно это использовать следующим образом: в базу данных можно добавить способ просмотра Timeline
, где каждую задачу представялет прямоугольник интервал времени. Из overlap’а задач можно составить оптимальный маршрут решения, тем самым уменьшив возможные издержки и сохранив хрупкий баланс (в моем случае между учебой, работой и наукой).
Некоторые задачи зацензурены в силу деловой этики
2. Наработка данных
Выгрузить данные из Notion можно разными способами. Первый, и наверное самый простой способ, это скачать их напрямую через сайт. Так можно наработать .csv
файл со всеми основными фичами. Второй способ, это использовать официальный API Notion. Мне нравиться именно второй способ, т.к помимо базовых фич страничек в планере, он умеет парсить и два не менее важных показателя: created_time
, last_edited_time
.
Для парсинга использовался python + requests, за выгрузку в .csv отвечал pandas
Для взаимодействия с официальным API Notion необходимо создать специальный токен. Как это делается детально описанно в спойлере:
Создание секретного токена API Notion
Для получения уникального ключа для API необходимо создать интеграцию по ссылке
Вот тут вам нужно на Create new integration
Создав интеграцию и получив код:
Его нужно подключить к страничке с планером. Перед этим рекомендую установить вашей интеграции какую-то узнаваемую картинку, так чтобы не спутать с чужой при добавлении. Для этого на странице с планером жмем на 3 точки, потом на Connect to, где в выпадающем списке ищем нашу интеграцию.
Вот так выглядит интеграция после добавления
Получив токен, смело заполняем headers
для requests:
headers = {
"Authorization": "Bearer " + "YOUR TOKEN",
"Content-Type": "application/json",
"Notion-Version": "2022-02-22"
}
Стоит отметить, что «Notion-Version» должен быть строго определенный, если с ним ошибиться, с сайта вернеться с ошибкам с указанием на возможные верные версии.
Парсинг выполняется с помощью requests.request('POST', url, json=payload, headers=headers)
, где url
это адресс на базу данных, а payload
указывает с какой page
нужно записывать. По умолчанию Notion выдает максимум 100 page
, что можно решить простым циклом.
Полная функция парсера db
def readEntireDatabase(databaseID, headers):
url = f"https://api.notion.com/v1/databases/{databaseID}/query"
page_size = 100
payload = {"page_size": page_size}
response = requests.request('POST', url, json=payload, headers=headers)
data = response.json()
results = data["results"]
while data["has_more"]:
payload = {"page_size": page_size, "start_cursor": data["next_cursor"]}
response = requests.request("POST", url, json=payload, headers=headers)
data = response.json()
results.extend(data["results"])
return results
Полученный файл содержит себе множество json
'ов страничек, у каждой из которой в наличие следующие данные (ключи словаря):
['object', 'id', 'created_time', 'last_edited_time', 'created_by', 'last_edited_by', 'cover', 'icon', 'parent', 'archived', 'in_trash', 'properties', 'url', 'public_url']
Из них особенный интерес для меня представяют created_time
, last_edited_time
, in_trash
, а также основной словарь лежащий по ключу properties
. Именно он содержит все основые фичи страничек, в том числе и название со статусом.
Из json основные фичи вытаскиваются следующим образом (try-except соотвествует случаю когда нужное свойство не было указано в Notion):
if not page['in_trash']:
try:
names.append(page['properties']['Name']['title'][0]['plain_text'])
except:
names.append('')
try:
statuses.append(page['properties']['Status']['select']['name'])
except:
statuses.append('')
try:
tags.append(page['properties']['Tags']['multi_select'])
except:
tags.append('')
try:
dates.append(page['properties']['Date']['date'])
except:
dates.append('')
Собрав из полученных листов pd.DataFrame
и сохранив в .csv
можем переходить к анализу.
3. Анализ данных и визуализация
Для анализа полученных данных воспользуемся datetime, nltk и pymorphy2. Для визуализации и построения графиков matplotlib и wordcloud.
3.1 Временной анализ
Для начала разберемся с датами полученными из created_by
и last_edited_by
. Записываются они в нечитабельном формате 2024-07-06T18:18:00.000Z
. Обрежем ненужное количество значащих цифр для миллисекунд с правого конца и заменем символы T
на -
для consistency
def format_datetime(string : str):
return string[:-5].replace('T', '-')
Такой формат даты уже можно считать с помощью datetime.
datetime.strptime(date_str, '%Y-%m-%d-%H:%M:%S')
Переписав в pd.DataFrame
колонки CREATION_TIME и EDITION_TIME в читабельном формате и получив их datetime репрезентации можем немного поиграть с анализом данных, посмотрев на то, как выглядит распределение решенных/заваленных задач на масштабах в дни/месяцы/годы.
Построив распредление задач от месяца можно увидеть закономерный провал летом, и в принципе ожидаемый подъем в апреле и ноябре (эти два пика совпадают с максимумами учебной нагрзуки в институте). Подсчитав среднее время выполнения и провала (изменения статуса на Unaccomplished) задачи получились соотвественно 13 и 57 дней.
Распределение количеств задач созданных и оконченных (статус Succesfully accomplished) от месяца в году
Код построения графика
creation_months = np.zeros(12)
completion_months = np.zeros(12)
for date in data[data['STATUS'] == 'Succesfully accomplished'].CREATION_TIME:
creation_months[datetime.strptime(date, '%Y-%m-%d-%H:%M:%S').month - 1] += 1
for date in data[data['STATUS'] == 'Succesfully accomplished'].EDITION_TIME:
completion_months[datetime.strptime(date, '%Y-%m-%d-%H:%M:%S').month - 1] += 1
plt.figure(figsize=(7, 5))
plt.style.use('dark_background')
plt.bar(x=np.arange(1, 13), height=creation_months, color='white', label='начато')
plt.bar(x=np.arange(1, 13), height=completion_months, color='lightgray', label='сделано', alpha=0.5)
plt.xlabel('Месяц',fontsize=24)
plt.xticks(np.arange(1, 13)[::2], fontsize=14)
plt.ylabel('Кол-во задач',fontsize=24)
plt.yticks(np.arange(0, 151, 30), fontsize=14)
plt.legend(fontsize=14)
plt.tight_layout()
plt.savefig('months_tasks.jpg')
На масштабе в 4 года, видна явная тендция роста количества заверешнных задач (и проектов) от года к году. За начало 2024 года выполнено в 2.9 раз больше задач и в 3.33 раза больше проектов чем за соответствующий период 2023-го.
Количество оконченных задач в году
Код построения графика
# data_years_i полученно как value_counts to_dict()
fig, ax = plt.subplots(1, 2, figsize=(20, 7))
plt.rcParams.update({'font.size': 20})
ax[0].barh(width=list(data_years_1.values()), y=list(data_years_1.keys()), color='white')
ax[0].set_yticks([key for key in sorted(data_years_1.keys())])#, fontsize=20)
ax[0].set_xticks(range(0, 451, 90))#, fontsize=20)
ax[0].set_ylabel('Год', fontsize=24)
ax[0].set_xlabel('Количество задач', fontsize=24)
ax[1].barh(width=list(data_years_2.values()), y=list(data_years_2.keys()), color='white', )
ax[1].set_yticks([key for key in sorted(data_years_2.keys())])#, fontsize=20)
ax[1].set_xticks(range(0, 11, 2))#, fontsize=20)
ax[1].set_ylabel('Год', fontsize=24)
Самым приятным глазу, конечно же, является карта активности за целый год, постоенная в маштабе дней (источником вдохновения послужил GitHub).
Код построения графика
data_for_map = sc_data[sc_data['year'] == 2024]
activity = np.zeros((7, 53))
for day in np.arange(7):
for week in np.arange(53):
activity[day][week] += len(data_for_map[data_for_map['week'] == week][data_for_map['weekday'] == day + 1])
fig, ax = plt.subplots(figsize=(14, 11))
ax.set_aspect("equal")
plt.style.use('dark_background')
orig_map = plt.cm.get_cmap('Grays') # инверсия чтобы maximum совпадал с белым
reversed_map = orig_map.reversed()
plt.title('Карта активности 2024', fontsize=24)
plt.pcolormesh(activity, cmap=reversed_map, edgecolor="w")
plt.xticks(np.arange(0, 53 ,5), fontsize=16)
plt.yticks(np.arange(0, 7, 2), fontsize=16)
plt.ylabel('Номер дня', fontsize=20)
plt.xlabel('Номер недели', fontsize=20)
3.2 Текстовый анализ
Проведем первичный анализ текстов задач. Посчитаем: 1) среднее числов слов в задаче, 2) среднее число символов в задаче и 3) средню длину слова.
sc_data = data[data['STATUS'] == 'Succesfully accomplished']
average_number_words = sc_data.NAME.str.split().str.len().mean()
average_number_symbols = sc_data.NAME.str.replace(' ', '').str.len().mean() # пробелы не считаем
def avg_word_length(sentence : str):
words = sentence.split()
return (sum(len(str(word)) for word in words) / len(words))
average_word_length = sc_data.NAME.apply(lambda x: avg_word_length(str(x))).mean()
В решенных задачах в среднем 4.2 слова и 24 символа. Средняя длина слова 6.7 букв.
Для анализа текстов задач проведем следующий препроцессинг.
Заменим все знаки препинания на пробелы
Приведем все слова к нижнему регистру
Разобьем предложение на списки, а все списки объединим в один
Удалим все слова с длиной меньше 4
Код препроцессинга. Часть 1
def clear_punctuation(string : str):
for punct in ['.', ':', ',', '!', '?', ';', '\\', '/', '-', ')', '(']:
string = string.replace(punct, ' ')
return string
def filter(text : str):
filtered = [word for word in text if len(word) > 3]
return filtered
text = sc_data.NAME.astype(str)
text = text.apply(lambda x: clear_punctuation(x))
text = text.str.lower()
text = ' '.join(text.to_list()).split()
text = filter(text)
Полученный список слов можно поместить в pd.Series
и используя value_counts()
получить словарь с частотностью. Полученный словарь красиво представить в качестве wordcloud:
Wordcloud чашка с кофе для сделанных дел
Код Wordcloud
mask = np.array(Image.open('coffee.jpg'))
wordcloud = WordCloud(mask=mask).generate(' '.join(text))
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 7))
plt.imshow(wordcloud.recolor(color_func=grey_color_func, random_state=3),
interpolation="bilinear")
plt.axis("off")
plt.savefig('completed.png')
И из картинки, и из словаря частот видно, что самыми популярным словами являются глаголы, призывающие к незамедлительному действию. И вместо того, чтобы удалять самые частые слова, можно определить для каждого слова его часть речи (c помощью pymorphy2), и удалить все ненужные.
Итого, добавим в препроцессинг еще два этапа:
Удаление стоп-слов
Приведение к начальной форме и удаление глаголов
Код препроцессинга. Часть 2
from nltk.corpus import stopwords
import nltk
import pymorphy2
morph = pymorphy2.MorphAnalyzer()
# Create a set of stop words
stop_words = set(stopwords.words('russian'))
def remove_stop_words(sentence : list, filter_array=stop_words):
filtered_words = [word for word in sentence if word not in filter_array]
return filtered_words
def filter_verbs(text: list):
filtered = [word for word in text if morph.parse(word)[0].tag.POS != 'INFN' and morph.parse(word)[0].tag.POS != 'VERB']
return filtered
Воспользовавшись препроцессингом, получим вот такое симпатичное лингвистическое облако решенных задач:
Wordcloud мозг сделанных существительных
Самые популярные слова: неделя, билет, лекция, статья.
4. Планы на будущее
Продолжая темы, поднятые в этом мини-проекте, можно выделить два основных возможных направления развития идеи.
Первое — это автоматизация сбора и представления данных, возможно в формате Streamlit платформы. Это бы приятно дополнило опыт работы с Notion, особенно если придумать как добавить элементы такого анализа в качестве виджетов прямо на страничку с базой данных.
Второе- это работа с лингвистическими моделями для семантической классификации проделанных задач на категории, к примеру на науку (мои подкатегории были бы физика и ML) искусство (кино и литература), здоровье и т.д
С такой задачей справился бы слегка доубученный BERT, но я не нашел датасетов с задачами по типу to-do, а среди моих 1500 задач, размечено от силы 60 (и то ~20 это reading и ~40 Stuying), так что оставим это на будущее.