sparkletown/sparkle

View on GitHub
reporting/makegraphs.py

Summary

Maintainability
F
1 wk
Test Coverage
#!/usr/bin/env python3

import csv
from datetime import datetime, timedelta
import holoviews as hv
from holoviews import dim, opts
import logging
import matplotlib.backends.backend_pdf
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import numpy as np
import pandas as pd
import os
import sys
import time


def usage():
    print(
        """{0}: make graphs of zoom meetings from UserQos... CSVs.

Must add a new first column to the header row of each CSV: Room Name.
Export CSV of each meeting from the dashboard and add the new    at the left of the header.

Usage: {0} csv1.csv [csv2.csv csv3.csv...]
""".format(sys.argv[0]))


if len(sys.argv) <= 1:
    usage()
    sys.exit()

# Holoviews init
hv.extension('matplotlib')

# REVISIT: move to args
# Uncomment for info logging
logging.basicConfig(level=logging.INFO)
# Uncomment for debug logging
# logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger(__name__)


class TimeRange(object):
    def __init__(self, start, end):
        self.start = start
        self.end = end
        self.duration = self.end - self.start

    def is_overlapped(self, time_range):
        return max(self.start, time_range.start) < min(self.end, time_range.end)

    def total_minutes(self):
        return (self.end - self.start).total_seconds() / 60

    def __repr__(self):
        return '{} -> {}'.format(self.start, self.end)


class Config():
    # REVISIT: move to command-line args
    # Party start/duration/end
    # (YYYY, MM, DD, HH)
    START = datetime(2021, 12, 9, 9, 0, 0, 0)
    END = START + timedelta(hours=12)
    SEGMENTS = {
        'Whole time': TimeRange(START, START + timedelta(hours=12)),
        'Main Party': TimeRange(START + timedelta(hours=6), START + timedelta(hours=9)),
    }
    MEETING_ID_RENAMES = {
        '81259280306': 'Shrinking Station',
        '82383634053': 'Tip of the Tongue',
        '89072177227': 'Heart of the Party',
        '85105150305': 'All Ears',
        '84119671594': 'Court-X',
        '85485729711': 'Third Eye',
        '85030810493': 'The Voice Box',
        '83776728154': 'InDigest-Inn',
        '88193100121': 'Womb For Improvement',
        '87127042202': 'Funk in the Trunk',
        '86578482268': 'The Clitoris',
        '86901109362': 'Try Your Hand',
        '81896872245': 'The Bladder',
        '89718158675': 'Funny Bones'
    }
    ROOM_RENAMES = {
        'Welcome to the Tree of Life 1': 'Rabbit Hole - Entrance Experience',
        'Welcome to the Tree of Life 2': 'The Roots',
        'Welcome to the Tree of Life 3': 'Upside Down Room - The Bat Cave',
        'Welcome to the Tree of Life 4': 'Funk in the Trunk',
        'Welcome to the Tree of Life 5': 'Heart Room',
        'Welcome to the Tree of Life 6': 'The Mood Swing',
        'Welcome to the Tree of Life 7': 'The Treehouse',
        'Welcome to the Tree of Life 8': 'The Beehive',
        'Welcome to the Tree of Life 9': 'The Canopy',
        'Welcome to the Tree of Life 10': 'The Compost Heap (Toilets Here)',
        'Welcome to the Tree of Life 11': 'Sunset Room',
        'Welcome to the Tree of Life 12': 'Lost Soul Chamber',
        'Late May Party 1': 'The Dock of the Bay',
        'Late May Party 2': 'Friend Ship',
        'Late May Party 3': 'Funk on a Junk',
        'Late May Party 4': 'Tropicana Fanta Sea',
        'Late May Party 5': 'Heart of the Sea',
        'Late May Party 6': 'Yellow Submarine',
        'Late May Party 7': 'Boaty McBoatface',
        'Late May Party 8': 'The Lifeboat',
        'Late May Party 9': 'Sirens\' Call',
        'Late May Party 10': 'Lighthouse',
        'Late May Party 11': 'Castaway Raft',
        'Late May Party 12': 'The Ark',
        'Late May Party 13': 'Great Barrier Grief',
        'Island Festival Mid-June 1': 'The Bermuda Party Portal',
        'Island Festival Mid-June 2': 'W*A*R*S Subsurface Disco',
        'Island Festival Mid-June 3': 'Klub Krabi',
        'Island Festival Mid-June 4': 'Tropicana Fantasea Island Resort',
        'Island Festival Mid-June 5': 'The Love Lagoon',
        'Island Festival Mid-June 6': 'Survivor Island',
        'Island Festival Mid-June 8': 'XNN Research Bunker',
        'Island Festival Mid-June 9': 'Sirens\' Call / Castaway Clowns',
        'Island Festival Mid-June 10': 'The Worldpool',
        'Island Festival Mid-June 11': 'Mystic EyeLand',
        'Island Festival Mid-June 12': 'Deserted Island',
        'Island Festival Mid-June 13': 'Great Barrier Grief',
        'Journey to the Center of the Earth 1': 'Vulcanic Lift Shaft',
        'Journey to the Center of the Earth 3': 'The Stalag Might?',
        'Journey to the Center of the Earth 4': 'TECHNO-TONICS / Richter RPM / Centre of Groovity',
        'Journey to the Center of the Earth 5': 'Core-Reality',
        'Journey to the Center of the Earth 6': 'The Mantle Peace',
        'Journey to the Center of the Earth 7': 'Fantasea Lava Spa',
        'Journey to the Center of the Earth 8': 'Magnetic Feels Cabaret',
        'Journey to the Center of the Earth 9': 'Wishing Well',
        'Journey to the Center of the Earth 10': 'The Last Campfire',
        'Journey to the Center of the Earth 11': 'The Landfill / Sedi Mental / Meta More Peak',
        'Journey to the Center of the Earth 12': 'The International Grace Station',
        'Time Warp Party 1': 'Temporal-Mental Time Machine',
        'Time Warp Party 2': 'The Infinite Theatre',
        'Time Warp Party 3': 'Time Heals',
        'Time Warp Party 4': 'World of Woodstock',
        'Time Warp Party 5': 'Remember the Times',
        'Time Warp Party 6': 'Utopiyeah',
        'Time Warp Party 7': 'Antimatter Evacuation Chute',
        'Time Warp Party 8': 'The Present',
        'Time Warp Party 9': 'DDP Disco Doge',
        'Time Warp Party 10': 'The Centuripede',
        'Time Warp Party 11': 'Pre-Pocalyptic Pussy Cat Pirates',
        'Time Warp Party 12': 'Entrance Experience Part 2',
        'End of the Universe 1': 'Apocalyse Meow Cabaret',
        'End of the Universe 2': 'Worm Whole',
        'End of the Universe 3': 'Wish Upon A Star',
        'End of the Universe 4': 'XNN Colony Fantasea Rocket',
        'End of the Universe 5': 'Stardust',
        'End of the Universe 6': 'Space Loo',
        'End of the Universe 7': 'Goddess Galaxy',
        'End of the Universe 8': 'Shake Your Assteroid',
        'End of the Universe 10': 'The Oat Milky Way',
        'End of the Universe 11': 'Powered By SparkleVerse',
        'End of the Universe 12': 'Escape Pod',
    }
    ROOM_TIMEZONE_OFFSETS = {
        'Moon Rock Room- Live performers and deep space hitchhikers': timedelta(hours=-1),
        'Midnight Reception': timedelta(hours=-1)
    }


class ZoomReportParser():
    def __init__(self, filenames):
        self._all_visits = []
        self._visits_by_user = {}
        self._all_joins = []
        self._all_leaves = []
        self._rooms = []
        self._room_joins = {}
        self._room_leaves = {}
        self._all_visit_durations = []
        self._visit_durations = {}
        self.parse(filenames)

        # Generate x for every second in the data
        self.x = [Config.START + timedelta(seconds=x)
                  for x in range(0, (Config.END - Config.START).seconds)]

    def parse(self, filenames):
        for f in filenames:
            with open(f) as csvfile:
                filename = os.path.basename(csvfile.name)
                log.info("Opening file {}".format(filename))
                if filename.startswith('UserQos_'):
                    self.process_admin_report(csvfile)
                elif filename.startswith('participants_'):
                    self.process_user_report(csvfile)
                else:
                    raise Error(
                        'unknown csv format based on filename: {} for {}'.format(filename, f))

        self.sort_data()
        log.info("Parsing completed. Total visits: {}, Total users: {}".format(
            len(self._all_visits), len(self._visits_by_user.keys())))
        self.log_debug_stats()
        self.write_visits()

    def sort_data(self):
        self._all_joins.sort()
        self._all_leaves.sort()
        for room in self._rooms:
            self._room_joins[room].sort()
            self._room_leaves[room].sort()
        for user in self._visits_by_user:
            self._visits_by_user[user].sort(
                key=lambda user_visit: user_visit['visit'].start)

    def write_visits(self):
        with open("visits.txt", "w") as visits_file:
            for user in self._visits_by_user.keys():
                visits_file.write("{}: {}\n".format(user, ",".join(
                    [user_visit['room'] for user_visit in self._visits_by_user[user]])))

    @staticmethod
    def real_room_name(room, meeting_id):
        if meeting_id in Config.MEETING_ID_RENAMES:
            return Config.MEETING_ID_RENAMES[meeting_id]
        if room in Config.ROOM_RENAMES:
            return Config.ROOM_RENAMES[room]
        return room

    def record_visit(self, user, room, meeting_id, join_time, leave_time):
        room = self.real_room_name(room, meeting_id)
        log.debug("record_visit: Room {}; User: {}; Entered: {}; Left: {}".format(
            room, user, join_time, leave_time))

        if room in Config.ROOM_TIMEZONE_OFFSETS:
            log.debug("Room {} has an offset of {}; correcting join and leave times".format(
                room, Config.ROOM_TIMEZONE_OFFSETS[room]))
            join_time = join_time + Config.ROOM_TIMEZONE_OFFSETS[room]
            leave_time = leave_time + Config.ROOM_TIMEZONE_OFFSETS[room]

        if leave_time < join_time:
            log.debug("Error: leave time is before join time")
            exit(1)

        if leave_time < Config.START:
            log.debug("### Discarding visit, it ends before the party started")
            return

        if join_time > Config.END:
            log.debug("### Discarding visit, it starts after the party ended")
            return

        if join_time < Config.START:
            log.debug(
                "@@@ Bumping visit start time {} to party start {}".format(join_time, Config.START))
            join_time = Config.START

        if leave_time > Config.END:
            log.debug(
                "@@@ Bumping visit end time {} to party end {}".format(leave_time, Config.END))
            leave_time = Config.END

        visit = TimeRange(join_time, leave_time)
        self._all_visits.append({'room': room, 'user': user, 'visit': visit})

        if user not in self._visits_by_user:
            self._visits_by_user[user] = []
        self._visits_by_user[user].append({'room': room, 'visit': visit})

        self._all_joins.append(join_time)
        self._all_leaves.append(leave_time)

        if room not in self._rooms:
            self._rooms.append(room)

        if room not in self._room_joins:
            self._room_joins[room] = []
        self._room_joins[room].append(join_time)

        if room not in self._room_leaves:
            self._room_leaves[room] = []
        self._room_leaves[room].append(leave_time)

        self._all_visit_durations.append(visit.total_minutes())
        for segment in Config.SEGMENTS:
            for room in self._rooms:
                if visit.is_overlapped(Config.SEGMENTS[segment]):
                    if segment not in self._visit_durations:
                        self._visit_durations[segment] = {}
                    if room not in self._visit_durations[segment]:
                        self._visit_durations[segment][room] = []
                    self._visit_durations[segment][room].append(
                        visit.total_minutes())

    def process_admin_report(self, csvfile):
        log.debug("process_admin_report: Processing {}...".format(csvfile.name))
        # Ignore header row
        csvfile.readline()
        header_values = csvfile.readline().split(',')
        room = header_values[0]
        log.debug("Got room name: {}".format(room))
        meeting_id = header_values[1]
        log.debug("Got meeting ID: {}".format(meeting_id))
        meeting_start_time_str = header_values[8] + header_values[9]
        meeting_start_date = datetime.strptime(
            meeting_start_time_str, '"%b %d %Y %I:%M %p"').date()
        meeting_start_datetime = datetime(
            year=meeting_start_date.year, month=meeting_start_date.month, day=meeting_start_date.day)
        log.debug("Got meeting start day: {} (raw: {})".format(
            meeting_start_datetime, meeting_start_time_str))

        # Slurp a character from the CSV, to make DictReader behave.
        # Perhaps an intern built Zoom's CSV export,
        csvfile.read(1)

        r = csv.DictReader(csvfile)
        for row in r:
            participant = row['Participant']
            location = row['Location'].replace(' )', ')')
            user = "{} from {}".format(participant, location)
            join_time_str = row.get('Join Time')
            leave_time_str = row.get('Leave Time').split('(')[0]

            join_time_parsed = datetime.strptime(join_time_str, '%I:%M %p')
            join_time = meeting_start_datetime + \
                timedelta(hours=join_time_parsed.hour,
                          minutes=join_time_parsed.minute, seconds=join_time_parsed.second)

            leave_time_parsed = datetime.strptime(leave_time_str, '%I:%M %p')
            leave_time = meeting_start_datetime + \
                timedelta(hours=leave_time_parsed.hour,
                          minutes=leave_time_parsed.minute, seconds=leave_time_parsed.second)

            self.record_visit(user, room, meeting_id, join_time, leave_time)

    def process_user_report(self, csvfile):
        log.debug("process_user_report: Processing {}...".format(csvfile.name))
        # Ignore header row
        csvfile.readline()
        header_values = csvfile.readline().split(',')
        meeting_id = header_values[0]
        log.debug("Got meeting ID: {}".format(meeting_id))
        room = header_values[1]
        log.debug("Got room topic: {}".format(room))

        # Slurp a character from the CSV, to make DictReader behave.
        # Perhaps an intern built Zoom's CSV export,
        csvfile.read(1)

        r = csv.DictReader(csvfile)
        for row in r:
            user = row['Name (Original Name)']
            email = row['User Email']
            if len(email) > 0:
                user = email

            join_time_str = row.get('Join Time')
            leave_time_str = row.get('Leave Time')

            join_time = datetime.strptime(
                join_time_str, '%m/%d/%Y %I:%M:%S %p')
            leave_time = datetime.strptime(
                leave_time_str, '%m/%d/%Y %I:%M:%S %p')

            self.record_visit(user, room, meeting_id, join_time, leave_time)

    def log_debug_stats(self):
        overlapping_visits = []
        for user in self._visits_by_user.keys():
            for visit1 in self._visits_by_user[user]:
                for visit2 in self._visits_by_user[user]:
                    if visit1 != visit2 and visit1['visit'].is_overlapped(visit2['visit']):
                        overlapping_visits.append(
                            {'user': user, 'visit': visit1})
        log.debug("Total overlapping visits: {}".format(
            len(overlapping_visits)))

        log.debug("Total joins: {}".format(len(self._all_joins)))
        log.debug("Total leaves: {}".format(len(self._all_leaves)))
        log.debug("Rooms joined: {}".format(self._room_joins.keys()))
        log.debug("Rooms left: {}".format(self._room_joins.keys()))
        for room in self._rooms:
            log.debug("Room {0}: {1} joins, {2} leaves".format(
                room, len(self._room_joins[room]), len(self._room_leaves[room])))
        log.debug("Average duration per room per segment:")
        for segment in Config.SEGMENTS:
            log.debug("Segment: {0}".format(segment))
            if segment in self._visit_durations:
                for room in self._rooms:
                    if room in self._visit_durations[segment]:
                        log.debug("Room: {0}, Average: {1}".format(room, sum(
                            self._visit_durations[segment][room])/len(self._visit_durations[segment][room])))

        log.debug("Average visit duration: {0}".format(
            sum(self._all_visit_durations)/len(self._all_visit_durations)))

        first_visit_rooms = {}
        last_visit_rooms = {}
        for user in self._visits_by_user:
            first_room = self._visits_by_user[user][0]['room']
            if first_room not in first_visit_rooms:
                first_visit_rooms[first_room] = []
            first_visit_rooms[first_room].append(user)

            last_room = self._visits_by_user[user][len(
                self._visits_by_user[user])-1]['room']
            if last_room not in last_visit_rooms:
                last_visit_rooms[last_room] = []
            last_visit_rooms[last_room].append(user)

        for room in first_visit_rooms:
            log.debug("Users whose first visit was to {0} ({1}): {2}".format(
                room, len(first_visit_rooms[room]), ','.join(first_visit_rooms[room])))
        for room in last_visit_rooms:
            log.debug("Users whose last visit was to {0} ({1}): {2}".format(
                room, len(last_visit_rooms[room]), ','.join(last_visit_rooms[room])))

    def rooms(self):
        return self._rooms

    def visit_durations(self):
        return self._all_visit_durations, self._visit_durations

    def stay_durations(self):
        stay_durations = []
        for user in self._visits_by_user:
            visits = self._visits_by_user[user]
            if len(visits) == 1:
                stay_durations.append(visits[0]['visit'].total_minutes())
            if len(visits) > 2:
                user_stay_duration = TimeRange(
                    visits[0]['visit'].start, visits[len(visits)-1]['visit'].end).total_minutes()
                stay_durations.append(user_stay_duration)
        return stay_durations

    def rooms_visited(self):
        room_counts = []
        for user in self._visits_by_user:
            visits = self._visits_by_user[user]
            if len(visits) > 0:
                rooms = {}
                for visit in visits:
                    rooms[visit['room']] = True
                room_counts.append(len(rooms))
        return room_counts

    def moves(self):
        ENTERED = '(entered)'
        EXITED = '(exited)'
        moves = {}
        moves[ENTERED] = {}

        enters_by_room = {}
        exits_by_room = {}

        for user in self._visits_by_user:
            for i in range(len(self._visits_by_user[user])+1):
                if i == 0:
                    from_room = ENTERED
                else:
                    prev_visit = self._visits_by_user[user][i-1]
                    from_room = prev_visit['room']

                if i < len(self._visits_by_user[user]):
                    to_room = self._visits_by_user[user][i]['room']

                    # Eliminate "moves to the same room" from the data
                    while from_room == to_room:
                        i += 1
                        if i < len(self._visits_by_user[user]):
                            to_room = self._visits_by_user[user][i]['room']
                        else:
                            to_room = EXITED
                else:
                    to_room = EXITED

                if from_room == ENTERED:
                    visit = self._visits_by_user[user][i]
                    room = visit['room']
                    if room not in enters_by_room:
                        enters_by_room[room] = []
                    enters_by_room[room].append(visit['visit'].start)

                if to_room == EXITED:
                    visit = self._visits_by_user[user][i-1]
                    room = visit['room']
                    if room not in exits_by_room:
                        exits_by_room[room] = []
                    exits_by_room[room].append(visit['visit'].end)

                if from_room not in moves:
                    moves[from_room] = {}
                if to_room not in moves[from_room]:
                    moves[from_room][to_room] = 0

                moves[from_room][to_room] = moves[from_room][to_room] + 1

        moves_list = []
        for from_room in moves.keys():
            for to_room in moves[from_room].keys():
                moves_list.append(
                    (from_room + ' ', to_room, moves[from_room][to_room]))

        enters = []
        exits = []
        for room in self._rooms:
            if not room in enters_by_room:
                enters_by_room[room] = []
            enters_by_room[room].sort()
            if not room in exits_by_room:
                exits_by_room[room] = []
            exits_by_room[room].sort()
            room_enters = []
            room_exits = []
            enter_count = 0
            exit_count = 0
            for i in self.x:
                if enter_count < len(enters_by_room[room]) and i >= enters_by_room[room][enter_count]:
                    enter_count += 1
                if exit_count < len(exits_by_room[room]) and i >= exits_by_room[room][exit_count]:
                    exit_count += 1
                room_enters.append(enter_count)
                room_exits.append(exit_count)
            enters.append(room_enters)
            exits.append(room_exits)

        return moves_list, enters, exits

    def attendances(self):
        y_rooms = []
        for room in self._rooms:
            y = []
            tally = 0
            join_index = 0
            leave_index = 0
            total_joins = 0
            total_leaves = 0
            log.debug("Room {0}: first join: {1}, last join: {2}, first leave: {3}, last leave: {4}".format(room, self._room_joins[room][0], self._room_joins[room][len(
                self._room_joins[room])-1], self._room_leaves[room][0], self._room_leaves[room][len(self._room_leaves[room])-1]))
            for i in self.x:
                if join_index < len(self._room_joins[room]) and i >= self._room_joins[room][join_index]:
                    tally += 1
                    join_index += 1
                    total_joins += 1
                if leave_index < len(self._room_leaves[room]) and i >= self._room_leaves[room][leave_index]:
                    tally -= 1
                    leave_index += 1
                    total_leaves += 1
                y.append(tally)
            log.debug("Recorded {0} joins and {1} leaves for room {2}".format(
                total_joins, total_leaves, room))
            y_rooms.append(y)

        y_all = []
        tally = 0
        join_index = 0
        leave_index = 0
        total_joins = 0
        total_leaves = 0
        log.debug("All joins/leaves: first join: {0}, last join: {1}, first leave: {2}, last leave: {3}".format(
            self._all_joins[0], self._all_joins[len(self._all_joins)-1], self._all_leaves[0], self._all_leaves[len(self._all_leaves)-1]))
        for i in self.x:
            if join_index < len(self._all_joins) and i >= self._all_joins[join_index]:
                tally += 1
                join_index += 1
                total_joins += 1
            if leave_index < len(self._all_leaves) and i >= self._all_leaves[leave_index]:
                tally -= 1
                leave_index += 1
                total_leaves += 1
            y_all.append(tally)
        log.debug("Recorded {0} joins and {1} leaves for all rooms".format(
            total_joins, total_leaves))

        return self.x, y_rooms, y_all


class PdfGenerator():
    hours = mdates.HourLocator()
    ten_minutes = mdates.MinuteLocator(byminute=range(0, 60, 10))
    hours_fmt = mdates.DateFormatter('%H:00')

    def __init__(self, filename):
        self.filename = filename

    def start(self):
        log.info("Creating {}...".format(self.filename))
        self.pdf = matplotlib.backends.backend_pdf.PdfPages(self.filename)
        return self

    def attendances(self, x, y_rooms, y_all, rooms):
        log.info("Generating attendances...")

        fig, ax = plt.subplots()
        ax.stackplot(x, y_rooms, labels=rooms)
        ax.legend(loc='upper left', prop={'size': 4})
        ax.xaxis.set_major_locator(PdfGenerator.hours)
        ax.xaxis.set_major_formatter(PdfGenerator.hours_fmt)
        ax.xaxis.set_minor_locator(PdfGenerator.ten_minutes)
        ax.set_xlabel('Time (PT)')
        ax.set_ylabel('Attendance (Stacked)')
        ax.grid(True)
        fig.autofmt_xdate()
        self.pdf.savefig(fig)

        fig, ax = plt.subplots()
        ax.plot(x, y_all)
        ax.xaxis.set_major_locator(PdfGenerator.hours)
        ax.xaxis.set_major_formatter(PdfGenerator.hours_fmt)
        ax.xaxis.set_minor_locator(PdfGenerator.ten_minutes)
        ax.set_xlabel('Time (PT)')
        ax.set_ylabel('Attendance (All)')
        ax.grid(True)
        fig.autofmt_xdate()
        self.pdf.savefig(fig)

        for i in range(len(rooms)):
            fig, ax = plt.subplots()
            ax.plot(x, y_rooms[i])
            ax.xaxis.set_major_locator(PdfGenerator.hours)
            ax.xaxis.set_major_formatter(PdfGenerator.hours_fmt)
            ax.xaxis.set_minor_locator(PdfGenerator.ten_minutes)
            ax.set_xlabel('Time (PT)')
            ax.set_ylabel("Attendance ({0})".format(rooms[i]))
            ax.grid(True)
            fig.autofmt_xdate()
            self.pdf.savefig(fig)
        return self

    def stay_durations(self, user_stay_durations):
        log.info("Generating stay durations...")
        longest_stay = int(max(user_stay_durations))
        fig, ax = plt.subplots()
        ax.hist(user_stay_durations, bins=int(longest_stay/5)+1)
        ax.set_xlabel("Duration (minutes)")
        plt.xlim(left=0)
        ax.set_ylabel("Quantity")
        plt.title("Stay Durations (First to Last Sighting)")
        self.pdf.savefig(fig)
        return self

    def rooms_visited(self, rooms_visited, rooms):
        log.info("Generating rooms visited...")
        number_of_rooms = len(rooms)
        fig, ax = plt.subplots()
        ax.hist(rooms_visited, bins=number_of_rooms)
        ax.set_xlabel("Rooms Visited During Stay")
        plt.xlim(left=1)
        ax.set_ylabel("Quantity of Users")
        plt.title("How Many Rooms Users Visited")
        self.pdf.savefig(fig)
        return self

    def visit_durations(self, all_visit_durations, visit_durations, rooms):
        log.info("Generating visit durations...")
        longest_visit = int(max(all_visit_durations))

        for segment in Config.SEGMENTS:
            fig, ax = plt.subplots()
            for room in rooms:
                if segment in visit_durations and room in visit_durations[segment]:
                    ax.hist(
                        visit_durations[segment][room], label=room, histtype='step', bins=longest_visit)
            ax.legend(loc='upper right', prop={'size': 4})
            ax.set_xlabel("Duration (minutes)")
            plt.xlim(left=0, right=120)
            ax.set_ylabel("Quantity")
            plt.title("Visit Durations ({0})".format(segment))
            self.pdf.savefig(fig)

        for room in rooms:
            fig, ax = plt.subplots()
            for segment in Config.SEGMENTS:
                if segment in visit_durations and room in visit_durations[segment]:
                    ax.hist(
                        visit_durations[segment][room], label=segment, histtype='step', bins=longest_visit)
            ax.legend(loc='upper right', prop={'size': 4})
            ax.set_xlabel("Duration (minutes)")
            plt.xlim(left=0, right=120)
            ax.set_ylabel("Quantity")
            plt.title("Visit Durations ({0})".format(room))
            self.pdf.savefig(fig)
        return self

    def moves(self, moves_list):
        log.info("Generating moves...")
        df = pd.DataFrame(moves_list, columns=['from', 'to', 'value'])
        fig = hv.render(hv.Sankey(moves_list, ['from', 'to'], vdims='value').opts(
            cmap='Dark2', edge_color='to', node_color='index'))
        self.pdf.savefig(fig)
        return self

    def enters(self, x, ys, rooms):
        log.info("Generating entrances...")
        fig, ax = plt.subplots()
        ax.stackplot(x, ys, labels=rooms)
        ax.legend(loc='upper left', prop={'size': 4})
        ax.xaxis.set_major_locator(PdfGenerator.hours)
        ax.xaxis.set_major_formatter(PdfGenerator.hours_fmt)
        ax.xaxis.set_minor_locator(PdfGenerator.ten_minutes)
        ax.set_xlabel('Time (PT)')
        ax.set_ylabel('Users Seen For First Time (Stacked, Cumulative)')
        ax.grid(True)
        fig.autofmt_xdate()
        self.pdf.savefig(fig)
        return self

    def exits(self, x, ys, rooms):
        log.info("Generating exits...")
        fig, ax = plt.subplots()
        ax.stackplot(x, ys, labels=rooms)
        ax.legend(loc='upper left', prop={'size': 4})
        ax.xaxis.set_major_locator(PdfGenerator.hours)
        ax.xaxis.set_major_formatter(PdfGenerator.hours_fmt)
        ax.xaxis.set_minor_locator(PdfGenerator.ten_minutes)
        ax.set_xlabel('Time (PT)')
        ax.set_ylabel('Users Seen For Last Time (Stacked, Cumulative)')
        ax.grid(True)
        fig.autofmt_xdate()
        self.pdf.savefig(fig)
        return self

    def finish(self):
        log.info("Saving...")
        self.pdf.close()


def main():
    parser = ZoomReportParser(sys.argv[1:])
    rooms = parser.rooms()
    x, y_rooms, y_all = parser.attendances()
    stay_durations = parser.stay_durations()
    rooms_visited = parser.rooms_visited()
    all_visit_durations, visit_durations = parser.visit_durations()
    moves, enters, exits = parser.moves()

    generator = PdfGenerator('analytics.pdf')
    generator \
        .start() \
        .attendances(x, y_rooms, y_all, rooms) \
        .stay_durations(stay_durations) \
        .rooms_visited(rooms_visited, rooms) \
        .visit_durations(all_visit_durations, visit_durations, rooms) \
        .moves(moves) \
        .enters(x, enters, rooms) \
        .exits(x, exits, rooms) \
        .finish()


if __name__ == "__main__":
    main()