Ile czasu marnujemy na spotkaniach?
Jak z poziomu Pythona dostać się do zawartości Outlooka (poczta, kalendarz)?
W niedawnym tekście opisałem drogę do aktualnego miejsca i zajęcia w pracy. Pisałem (chyba tak, sprawdź!), że sporo czasu zajmują mi spotkania? Sprawdźmy!

Photo by Estée Janssens on Unsplash
Poprzednio nie było też kodu, a przecież kod jest najciekawszy.
Gdzieś po drodze miałem styczność z VBA, a niedawno odkryłem, że w sumie z narzędziami MS Office można postępować dość podobnie z poziomu Pythona jak z poziomu VBA. Dzisiaj więc trochę o narzędziach MS Office dotykanych Pythonem.
Excela lepiej zastąpić Pandasem, więc się nim nie zajmiemy :P Ale może istnieć potrzeba sięgnięcia do Outlooka - albo do poczty albo do kalendarza. Tutaj będzie o odczytywaniu danych, ale zapis teź jest możliwy - poczytajcie dokumentację, o CreateItem() głownie chodzi).
Zaczniemy od początku - ogółu. Oczywiście całość przewidziana jest do działania w środowisku Windows (jest Office na Linuxy? Na MacOS nie miałem okazji sprawdzić jak i czy to zadziała). Użyjemy pythonowej biblioteki win32com, która jest swego rodzaju wrotami do systemowych DLLi dla Office, w tym przypadku podepniemy się do Outlooka.
from win32com.client import Dispatch
outlook = Dispatch("Outlook.Application")
ns = outlook.GetNamespace("MAPI")
Po tych trzech linijkach w obiekcie ns mamy czubek naszego API outlookowego. Korzystając z metody GetDefaultFolder() możemy dostać się do odpowiedniego elementu (poczty, kalendarza, listy zadań) podając jako parametr odpowiedni numerek. Jaki? A sprawdźmy za co odpowiadają kolejne numeru uruchamiając kolejny fragment kodu:
for i in range(255):
try:
box = ns.GetDefaultFolder(i)
name = box.Name
print(i, name)
except:
pass
Najbardziej interesujące nas elementy to skrzynki emailowe:
- 3 Elementy usunięte
- 5 Elementy wysłane
- 6 Skrzynka odbiorcza
oraz dodatkowo:
- 9 Kalendarz
- 10 Kontakty
- 13 Zadania
Wiadomości email
Żeby dostać się do elementów danego boxa wykorzystamy parametr Items, czyli - żeby dostać wszystkie maile ze skrzynki odbiorczej użyjemy:
emails = ns.GetDefaultFolder(6).Items
a później w pętli jedziemy po elementach listy emails. Każdy z nich posiada odpowiednie właściwości (lista i opis w dokumentacji, które sobie będziemy czytać i coś z nimi robić (w tym przypadku tylko zapiszemy listę maili do pliku CSV). Każda z list (wiadomości, spotkań w kalendarzu, zadań) posiada różne elementy, a te elementy mogą mieć różny typ (być obiektem różnej klasy) - lista jest oczywiście w dokumentacji (co by nie mówić o Microsoft to dokumentację online mają spoko). Krótko mówiąc: wiadomościami jest to, co ma IPM.Note na początku. Wyjęcie interesujących elementów dla każdej z wiadomości to po prostu odczytanie jej właściwości:
import pandas as pd
email_formats = ['Unspecified', 'Plain text', 'HTML', 'RTF']
# 6 = Skrzynka odbiorcza
emails = ns.GetDefaultFolder(6).Items
# lista na wszystkie emaile
all_rows = []
for el in emails:
row = {}
if el.MessageClass[:8] == 'IPM.Note':
row['MessageClass'] = el.MessageClass # klasa obiektu
row['UnRead'] = el.UnRead # czy wiadomość przeczytana?
row['ReceivedTime'] = str(el.ReceivedTime.Format('%d/%m/%Y %H:%M')) # data otrzymania wiadomości jako string
row['Size'] = el.Size # rozmiar wiadomości
row['Importance'] = el.Importance # priorytet
row['ConversationID'] = el.ConversationID # ID wątku wiadomości
row['EntryID'] = el.EntryID # ID wiadomości
row['Subject'] = el.Subject # temat maila
row['BodyFormat'] = email_formats[el.BodyFormat] # format maila
row['SenderName'] = el.SenderName # nadawca
row['To'] = el.To # adresaci
row['CC'] = el.CC # odbiorcy w CC
row['Attachments'] = len(el.Attachments) # liczba załączników
row['Body'] = el.Body # treść
row['HTMLBody'] = el.HTMLBody # treść w HTMLu
all_rows.append(row)
all_table = pd.DataFrame(all_rows)
W efekcie w all_table mamy dataframe’a z zawartością naszej skrzynki odbiorczej. Co z tym zrobimy to już nasza sprawa :)
Dodatkowo jeśli chcemy dostać się do załączników do wiadomości możemy to zrobić przez (dla każdej wiadomości, więc gdzieś tam w tej pętli):
for a in el.Attachments:
print(a.DisplayName, a.FileName, a.Size)
zaś sam załącznik można zapisać korzystając z a.SaveAsFile(ścieżka_pliku_docelowego). Oczywiście printowanie nie ma większego sensu, lepiej byłoby zapisać listę załączników do nowego elementu słownika row.
Kalendarz
Podobnie możemy potraktować kalendarz, wyjmując poszczególnie spotkania i zapisując je w CSV. Będzie to wyglądało tak:
import datetime
# 9 = Kalendarz
appointments = ns.GetDefaultFolder(9).Items
appointments.IncludeRecurrences = "True"
appointments.Sort("[Start]")
# ograniczamy zakres pobieranych wydarzeń - od początku świata do dnia "za 30 dni od dzisiaj"
# begin = datetime.date.today()
end = begin + datetime.timedelta(days=30)
# tak możemy ograniczyć początek:
restriction = "[Start] >= '" + begin.strftime("%d/%m/%Y %H:%M") + "' "
restriction = "[End] <= '" + end.strftime("%d/%m/%Y %H:%M") + "'"
restrictedItems = appointments.Restrict(restriction)
# lista wszystkich spotkań
all_rows = []
# przebiegamy przez całą listę i zapisujemy interesujące nas elementy per spotkanie
for appointmentItem in restrictedItems:
row = {}
row['Title'] = appointmentItem.Subject
row['Organizer'] = appointmentItem.Organizer
row['Start'] = appointmentItem.Start.Format("%d/%m/%Y %H:%M")
row['End'] = appointmentItem.End.Format("%d/%m/%Y %H:%M")
row['Duration'] = appointmentItem.Duration
all_rows.append(row)
# budujemy tabelę pandasową i zapisujemy do CSV
all_table = pd.DataFrame(all_rows)
all_table.to_csv("calendar.csv", index=False)
Właściwości wydarzeń znajdziecie oczywiście w dokumentacji.
Jak widzicie mechanika jest ta sama: z folderu pobrać listę elementów, a potem dla każdego z elementów jego metadane.
Kalendarz analityka
Obiecałem, że powiem ile czasu zajmują mi spotkania. No to zobaczmy co wyjęliśmy z kalendarza - na podstawie zgromadzonych w CSV metadanych. Trochę więc przeanalizujemy, już w R (a co, ładniejsze są obrazki).
library(tidyverse)
library(tidytext) # reorder_within() & scale_y_reordered()
library(lubridate)
cal_df <- read_csv("calendar.csv")
Mając wczytane dane możemy przejść do ich czyszczenia - wyrzucimy jakieś stałe zdania statusowe, bloki na obiad (tak, planuję sobie obiady w kalendarzu - głównie po to, żeby była szansa, że nikt tego czasu nie zabierze) i podobne:
# powtarzalne i prywatne zadania
cal_df <- cal_df %>%
filter(!str_detect(Title, "obiad|Obiad")) %>%
filter(!Title %in% c("Spotkanie statusowe", "Poranny standup", "Status BigData"))
Po odrzuceniu zaśmiecających wydarzeń odrobina obróbki, głównie elementów związanych z czasem (datą początku i końca, długością trwania) spotkań:
cal_df <- cal_df %>%
mutate(Duration = Duration / 60) %>%
# max 12h spotkania - żeby nie było urlopów, świąt itd
filter(between(Duration, 0, 12)) %>%
mutate(
Start = dmy_hm(Start),
End = dmy_hm(End)
) %>%
arrange(Start) %>%
mutate(
wday = wday(Start, week_start = 1),
date = date(Start),
date_week = round_date(Start, unit = "week", week_start = 1),
date_month = month(date),
date_year = year(date),
start_h = hour(Start) + minute(Start) / 60,
end_h = hour(End) + minute(End) / 60
) %>%
# bez jakichś ekstremów typu wyjście na piwo do północy czy weekendowe wdrożenia ;)
filter(between(end_h, 8, 18) & between(start_h, 6, 18) & (wday <= 5)) %>%
mutate(Title = str_remove(Title, "PD: ")) %>%
mutate(Title = str_remove(Title, "FW: ")) %>%
mutate(wday = factor(x = wday, labels = c("pn", "wt", "śr", "cz", "pt"), levels = 1:5))
Zobaczmy jak wyglądają wszystkie moje spotkania wrzucone na jeden wykres:
# wszystkie spotkania na linii czasu
cal_df %>%
ggplot() +
geom_segment(aes(
x = date, xend = date,
y = start_h, yend = end_h
), alpha = 0.5) +
scale_y_continuous(trans = "reverse", breaks = 6:18) +
labs(
title = "Wszystkie spotkania od początku zatrudnienia",
x = "", y = "Czas spotkania"
)

W drugiej części 2020 roku przybyło, prawda? Ile spotkań tygodniowo miałem na przestrzeni ostatnich lat?
# liczba spotkań per tydzień
cal_df %>%
count(date_week) %>%
ggplot() +
geom_smooth(aes(date_week, n)) +
geom_point(aes(date_week, n)) +
labs(
title = "Tygodniowa liczba spotkań",
x = "", y = "Liczba spotkań w tygodniu"
)

Jak widać nie ma lekko - mniej więcej jesienią 2019 roku zamieniłem zespół i trochę też zajęcie (więcej spotkań, mniej pracy samodzielnej) ale również inaczej zacząłem zarządzać kalendarzem - zacząłem bukować sobie w kalendarzu czas na robienie tych samodzielnych rzeczy, chociażby po to żeby nie być od nich odrywany.
Jeśli nie wiesz jak zarządzać swoim czasem - obejrzyj HRejterów (wiem, że to robisz) i ich szkolenie.
Widać też początek pandemii i przejście na pracę zdalną. To sprawiło znaczny wzrost spotkań (nie potrzeba salek, wystarczy Skype/Teams). U mnie skorelowało się to z pracami nad kilkoma sporymi projektami, więc trudno na moim przykładzie pokazać czysty wpływ lockdownu na liczbę spotkań.
# czas zajęty przez spotkania per tydzień
cal_df %>%
group_by(date_week) %>%
summarise(Duration = sum(Duration)) %>%
ungroup() %>%
ggplot() +
geom_smooth(aes(date_week, Duration)) +
geom_point(aes(date_week, Duration)) +
geom_hline(yintercept = c(10, 20, 30, 40), color = "red") +
labs(
title = "Łączny czas spotkań w tygodniu",
x = "Data", y = "Łączny czas spotkań [h]"
)

Oczywiście im więcej spotkań tym więcej czasu zajmują. Ale 5 spotkań po 30 minut to nie to samo co trzy godzinne spotkania. Powyższy wykres obrazuje zajętość czasu, a precyzyjniej jaki procent tygodnia zajmują spotkania. Bywało, że 3/4… ale czasem jest tak, że jest się na 2 spotkaniach równolegle. Na prawdę jest się na jednym z nich, ale w kalendarzu zostają oba. Pamiętacie też, że wypadły z listy obiady i poranne daily.
Koniec końców: jakaś 1/3 tygodnia to spotkania.
Ile trwają spotkania?
# rozkład długości spotkań
cal_df %>%
ggplot() +
geom_histogram(aes(Duration),
binwidth = 0.25,
fill = "lightgreen", color = "black"
) +
labs(
title = "Rozkład długości spotkań",
x = "Czas trwania spotkania [h]", y = "Liczba spotkań"
)

Standardowo godzinę, a ewentualne dogrywki 30 minut. Ta reguła nie wynika wprost z powyższego wykresu, ale z wykresu popartego doświadczeniem. A bierze się to pewnie ze standardowego ustawienia Outlooka, gdzie szybko da się ustawić spotkania dla których kwant czasu to 30 minut. Idę o zakład, że gdyby każdy miał domyślnie siatkę 15 minutową to byłoby więcej spotkań 45 minutowych i zaczynałyby się one o pełnej godzinie. Tak działa nasz mózg.
Szkoda, że nie zebrałem liczby osób zaproszonych na spotkania. To też byłaby ciekawa statystyka.
# liczba spotkań per miesiąc
cal_df %>%
count(date_year, date_month) %>%
mutate(date_month = factor(date_month,
levels = 1:12,
labels = month.abb
)) %>%
ggplot() +
geom_col(aes(date_month, n),
fill = "lightgreen", color = "black"
) +
facet_wrap(~date_year, ncol = 1) +
labs(
title = "W których miesiącach odbywa się najwięcej spotkań?",
x = "Miesiąc", y = "Liczba spotkań"
)

Tutaj może lepiej widać początek pandemii niż na którymś z wykresów wcześniej. Marzec 2020 to ten przełomowy moment i jak widać słupki z późniejszych miesięcy są wyższe. Bardzo jestem ciekaw jak to się zmieni po powrocie do biura… Dobrze widać też sierpniowe wakacje w 2020.
# kiedy są spotkania - pod względem sztuk
cal_df %>%
count(wday) %>%
ggplot() +
geom_col(aes(wday, n),
fill = "lightgreen", color = "black"
) +
labs(
title = "W jakich dniach tygodnia odbywa się najwięcej spotkań?",
x = "", y = "Skumulowana liczba spotkań"
)

Rytm tygodnia jest taki, że w pierwszej połowie się ustala co trzeba zrobić w drugiej. I to doskonale widać na powyższym wykresie. Ale czy to jest prawda? Bo wykres pokazuje wszystkie spotkania łącznie. Jak to wygląda jeśli chodzi o czas?
# kiedy są spotkania - pod względem czasu
cal_df %>%
group_by(wday) %>%
summarise(
Duration = sum(Duration),
n = n()
) %>%
ungroup() %>%
mutate(p = Duration / (8 * n)) %>%
ggplot() +
geom_col(aes(wday, p),
fill = "lightgreen", color = "black"
) +
scale_y_continuous(labels = scales::percent) +
labs(
title = "W jakich dniach tygodnia spotkania zjadają najwięcej czasu?",
x = "", y = "Średnia część dnia na spotkaniach"
)

Tutaj mamy inny obrazek. Nadal najwięcej czasu zajmują spotkania wtorkowe, ale piątki wcale nie są najlżejsze. W piątki jest mniej ale dłużej. Bo piątki do dobry czas na rzeczy typu szkolenia wewnętrzne (bach, dwie godzinki), demo rzeczy powstałych w zakończonym sprincie (bach, 3 godziny), albo bloki praca własna (czytaj: nie wsadzajcie mi tu już niczego, bo może urwę się wcześniej z pracy na weekend ;-).
# rozkład czasu wg dni tygodnia
cal_df %>%
ggplot() +
geom_violin(aes(wday, Duration),
fill = "lightgreen", color = "black"
) +
scale_y_continuous(breaks = seq(0, 8, 0.5)) +
labs(
title = "Rozkład długości spotkań w zależności od dnia tygodnia",
x = "", y = "Czas trwania spotkań [h]"
)

Teorię o dłuższych piątkowych spotkaniach potwierdza powyższy wykres. Krótkich spotkań (30 minutowych) najwięcej jest w poniedziałki (statusy projektów, których nie usunąłem w przeciwieństwie do statusów zespołów) i w czwartki (przygotowanie do sprint demo). I te krótkie przebiegi są prawdę mówiąc gorsze (więcej przełączania się między tematami).
# kiedy najczęściej rozpoczynają się spotkania?
cal_df %>%
mutate(start_h = round(start_h)) %>%
count(wday, start_h) %>%
mutate(p = n / sum(n)) %>%
ggplot() +
geom_tile(aes(wday, start_h, fill = n), color = "gray50", show.legend = FALSE) +
geom_text(aes(wday, start_h, label = sprintf("%0.1f%%", 100 * p))) +
scale_y_continuous(breaks = 6:18, trans = "reverse") +
scale_fill_distiller(palette = "OrRd", direction = 1) +
theme(legend.position = "bottom") +
labs(
title = "W jakich godzinach rozpoczynają się spotkania?",
x = "", y = "Godzina rozpoczęcia spotkania",
fill = "% wszystkich spotkań"
)

A teraz coś innego - jakie jest prawdopodobieństwo, że w danym momencie dnia mam spotkanie?
Przygotujmy siatkę wszystkich możliwych terminów (co 15 minut) i sprawdźmy ile spotkań odbywało się w każdym z nich. Poniższy sposób robi robotę, ale chyba nie jest najbardziej optymalny (przechodzimy przez kolejne terminy i zliczamy ile spotkań trwało w danej chwili), ale działa.
# ile % czasu w danym slocie jest zajęte?
min_d <- min(cal_df$Start)
max_d <- max(cal_df$End)
new_dates <- tibble(datetime = seq(make_datetime(year(min_d), month(min_d), 1),
make_datetime(year(max_d), month(max_d) + 1, 1),
by = "15 min"
)) %>%
mutate(
datetime_h = hour(datetime),
datetime_wday = wday(datetime, week_start = 1)
) %>%
filter(between(datetime_h, 8, 18) & (datetime_wday <= 5))
count_occupacy <- function(dt) {
return(cal_df %>% filter(Start <= dt) %>% filter(End > dt) %>% nrow())
}
new_dates$n_meetings <- lapply(new_dates$datetime, count_occupacy) %>% unlist()
Teraz pozostaje nam tylko tą całą siatkę pogrupować, posumować i narysować:
new_dates %>%
group_by(datetime_h) %>%
summarise(m = mean(n_meetings)) %>%
ungroup() %>%
ggplot() +
geom_col(aes(datetime_h, m),
fill = "lightgreen", color = "black"
) +
scale_x_continuous(breaks = 6:18) +
labs(
title = "Średnia liczba spotkań w zależności od godziny",
x = "", y = "Średnia liczba spotkań\n(prawdopodobieństwo, że jestem na spotkaniu)"
)

Dodajmy do tego wykresu jeszcze wymiar dnia tygodnia:
new_dates %>%
group_by(datetime_wday, datetime_h) %>%
summarise(m = mean(n_meetings)) %>%
ungroup() %>%
ggplot() +
geom_tile(aes(datetime_wday, datetime_h, fill = m), color = "gray50") +
geom_text(aes(datetime_wday, datetime_h, label = sprintf("%.1f%%", 100 * m))) +
scale_y_continuous(trans = "reverse", breaks = 6:18) +
scale_fill_distiller(palette = "OrRd", direction = 1) +
theme(legend.position = "bottom") +
labs(
title = "Obłożenie spotkaniami w zależności od pory tygodnia",
x = "", y = "",
fill = "Prawdopodobieństwo,\nże jestem na spotkaniu"
)

Chcesz się ze mną spotkać? Celuj w środek środy i od połowy czwartku. To, że o 8 rano jest stosunkowo wolne nie oznacza, że chętnie o tej godzinie się umawiam ;)