내가 가는 여정을 담는 그릇

[나의 작은 프로그래밍] TodoTracker (v.1.2.1)

세계4대진미_돼지국밥 2022. 3. 26. 02:17

 이 프로그램을 더욱 향상된 GUI 환경에서 사용해보고 싶다는 생각이 들었다. 기왕 Python을 사용한 김에 언어를 통일하는게 용이하겠다 싶어 tkinter 라이브러리를 사용했다. 프로토타입 버전의 프로그램을 빨리 개발하는 데에는 부족함이 없을 정도로 잘 만들어진 라이브러리였다. 익숙한 언어 체계다 보니 배우는 일은 어렵지 않았지만, 레퍼런스가 많이 없어서 의도한 대로 구현하기가 쉽지 않았고, 특히 오류를 잡기는 더 어려웠다.

요약

GUI가 포함된 프로토타입 프로그램 작성

사용 언어

Python

라이브러리

  1. csv
  2. datetime
  3. pytz
  4. numpy
  5. pandas
  6. matplotlib
  7. seaborn
  8. tkinter

새로운 기능

  1. GUI 제공
    1. 리모컨 방식의 기존 메인 프롬프트에서, 프레임에 따라 다른 기능을 제공하는 형태로 개선함
    2. 유저의 명령 수행 및 화면 전환이 키보드 입력에 의존하여 이뤄지던 방식에서, 인터페이스와의 직관적인 상호작용이 가능한 형태로 개선함

 

오늘의 할 일을 관리하는 화면이다
이렇게 할 일을 입력하고 엔터키를 누르면
할 일 목록에 추가가 된다. 지금 보니 '할 일 목록'과 '해낸 일 목록'을 구분하는 라벨을 표시해줘야 겠다.
할 일 목록에서 해낸 일들을 'Ctrl/Shift + 방향키/마우스'로 다중 선택할 수 있다. 라이브러리의 도움 없이 직접 구현하려면 아주 어려울 것 같은 기능이다.

 

항목들을 선택한 채로 [완료] 버튼을 누르면 해당 항목들이 '해낸 일 목록'으로 옮겨 간다.

 

[저장] 버튼을 누르면 데이터 파일에 오늘의 데이터가 저장된다.
[리셋] 버튼을 누르면 두 개의 목록이 초기화된다.
지금까지의 달성률을 그래프로 확인하는 화면이다.
오늘로부터 며칠 전까지의 데이터를 그래프로 나타낼 것인지를 Scale이라는 횡 스크롤바 처럼 생긴 인터페이스를 통해 선택할 수 있다.

 

[조회] 버튼을 누르면 선택된 기간의 그래프가 새 창에서 열린다.
지금으로부터 7일간의 그래프를 본다면
왜 배경색이 달라진 것 같지?
플래그 스트릭을 볼 수 있는 화면이다.

더보기
# © 2022 starsein <dbtjd1928@gmail.com>
import csv
import datetime as dt
import pytz
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import tkinter
import tkinter.font
import tkinter.ttk
from typing import List, Tuple

VERSION = "v1.2.1"

# 입출력에 사용되는 파일 목록
DATA_CSV = 'data.csv'
TODO_LIST_CSV = 'todoList.csv'
COMPLETED_TASK_LIST_CSV = 'completedTaskList.csv'


# data.csv 의 컬럼별 항목 분류
DATE = 0
WEEKDAY = 1
UNCOMPLETED_TASK_LIST = 2
UNCOMPLETED_TASK_NUM = 3
COMPLETED_TASK_LIST = 4
COMPLETED_TASK_NUM = 5


def get_todo() -> List[str]:
    with open(TODO_LIST_CSV, 'r', encoding='utf-8-sig') as rf:
        todo_list = []
        reader = csv.reader(rf)
        for todo in reader:
            todo_list.append(*todo)
    return todo_list


def get_completed_task() -> List[str]:
    with open(COMPLETED_TASK_LIST_CSV, 'r', encoding='utf-8-sig') as rf:
        ct_list = []
        reader = csv.reader(rf)
        for completed_task in reader:
            ct_list.append(*completed_task)
    return ct_list


def update_data():
    with open(DATA_CSV, 'r', encoding='utf-8-sig') as rf:
        total_data = rf.readlines()
        if len(total_data) == 0:
            return
        recent_date_str = total_data[-1].split(',')[DATE]
        today_obj = dt.date.fromisoformat(get_today_weekday()[0])
        target_day_obj = dt.date.fromisoformat(recent_date_str) + dt.timedelta(days=1)
        updating_data_list = []
        while target_day_obj < today_obj:
            target_day_str = target_day_obj.strftime("%Y-%m-%d")
            target_weekday_str = weekday_en_to_kr(target_day_obj.isoweekday())
            updating_data_list.append([target_day_str, target_weekday_str, [], 0, [], 0])
            target_day_obj += dt.timedelta(days=1)

    if len(updating_data_list):
        unsaved_todo = get_todo()
        unsaved_completed_task = get_completed_task()
        updating_data_list[0][2] = unsaved_todo
        updating_data_list[0][3] = len(unsaved_todo)
        updating_data_list[0][4] = unsaved_completed_task
        updating_data_list[0][5] = len(unsaved_completed_task)

        with open(TODO_LIST_CSV, 'w', encoding='utf-8-sig', newline=''):
            pass
        with open(COMPLETED_TASK_LIST_CSV, 'w', encoding='utf-8-sig', newline=''):
            pass

    with open(DATA_CSV, 'a', encoding='utf-8-sig', newline='') as af:
        writer = csv.writer(af)
        writer.writerows(updating_data_list)


def show_todo():
    listbox_todo.delete(0, listbox_todo.size())
    with open(TODO_LIST_CSV, 'r', encoding='utf-8-sig') as rf:
        reader = csv.reader(rf)
        for idx, todo in enumerate(reader):
            listbox_todo.insert(idx, *todo)


def show_completed_task():
    listbox_ct.delete(0, listbox_ct.size())
    with open(COMPLETED_TASK_LIST_CSV, 'r', encoding='utf-8-sig') as rf:
        reader = csv.reader(rf)
        for idx, ct in enumerate(reader):
            listbox_ct.insert(idx, *ct)


def add_todo(event):
    inp_todo = entry_todo.get().rstrip()
    if not inp_todo:
        return

    with open(TODO_LIST_CSV, 'a', encoding='utf-8-sig', newline='') as af:
        writer = csv.writer(af)
        writer.writerow([inp_todo])

    show_todo()
    entry_todo.delete(0, len(inp_todo))


def move_todo_to_ct():
    add_idx_list = list(listbox_todo.curselection())
    del_idx_set = set(add_idx_list)
    with open(COMPLETED_TASK_LIST_CSV, 'a', encoding='utf-8-sig', newline='') as af:
        writer = csv.writer(af)
        writer.writerows([[listbox_todo.get(add_idx)] for add_idx in add_idx_list])

    with open(TODO_LIST_CSV, 'r', encoding='utf-8-sig') as rf:
        reader = csv.reader(rf)
        todo_list = []
        for idx, todo in enumerate(reader):
            if idx not in del_idx_set:
                todo_list.append(todo)

    with open(TODO_LIST_CSV, 'w', encoding='utf-8-sig', newline='') as wf:
        writer = csv.writer(wf)
        writer.writerows(todo_list)

    show_todo()
    show_completed_task()


def check_data(today_str: str) -> Tuple[bool, List[List[str]]]:
    with open(DATA_CSV, "r", encoding='utf-8-sig') as rf:
        is_overlap = False
        data_list = []
        reader = csv.reader(rf)
        for day_data in reader:
            data_list.append(day_data)
        if data_list[-1][0] == today_str:
            is_overlap = True
    return is_overlap, data_list


def save_data():
    today_str, weekday_str = get_today_weekday()
    todo_list = get_todo()
    ct_list = get_completed_task()

    is_overlap, data_list = check_data(today_str)
    if is_overlap:
        data_list.pop()
    data_list.append([today_str, weekday_str, todo_list, len(todo_list), ct_list, len(ct_list)])

    with open(DATA_CSV, 'w', encoding='utf-8-sig', newline='') as wf:
        writer = csv.writer(wf)
        writer.writerows(data_list)


def reset_todo_and_ct():
    if listbox_todo.size():
        listbox_todo.delete(0, listbox_todo.size() - 1)
    if listbox_ct.size():
        listbox_ct.delete(0, listbox_ct.size() - 1)

    with open(TODO_LIST_CSV, 'w', encoding='utf-8-sig', newline=''):
        pass
    with open(COMPLETED_TASK_LIST_CSV, 'w', encoding='utf-8-sig', newline=''):
        pass


def weekday_en_to_kr(weekday_int: int) -> str:
    translate_table = {1: "월",
                       2: "화",
                       3: "수",
                       4: "목",
                       5: "금",
                       6: "토",
                       7: "일"}
    return translate_table.get(weekday_int)


def get_today_weekday() -> Tuple[str, str]:
    KST = pytz.timezone('Asia/Seoul')
    info = dt.datetime.now(KST)
    today_str = info.strftime("%Y-%m-%d")
    weekday_str = weekday_en_to_kr(info.isoweekday())
    return today_str, weekday_str


def get_data():
    _date_arr = np.array([])
    _num_t_arr = np.array([])
    _num_ct_arr = np.array([])
    _cr_arr = np.array([])

    with open(DATA_CSV, 'r') as rf:
        reader = csv.reader(rf)
        for data in reader:
            stored_date, stored_weekday, ut, num_ut, ct, num_ct = data
            num_ut = int(num_ut)
            num_ct = int(num_ct)
            _date_arr = np.append(_date_arr, stored_date[5:])
            _num_t_arr = np.append(_num_t_arr, num_ut + num_ct)
            _num_ct_arr = np.append(_num_ct_arr, num_ct)
            cr = round(num_ct / (num_ct + num_ut) * 100) if num_ct | num_ut != 0 else 0
            _cr_arr = np.append(_cr_arr, cr)
    return _date_arr, _num_t_arr, _num_ct_arr, _cr_arr


def visualize_data():
    period = scale_period.get()
    date_arr, num_t_arr, num_ct_arr, cr_arr = get_data()
    date_arr = date_arr[-period:]
    num_t_arr = num_t_arr[-period:]
    num_ct_arr = num_ct_arr[-period:]
    cr_arr = cr_arr[-period:]

    data_dict = {'date': date_arr,
                 'num_t': num_t_arr,
                 'num_ct': num_ct_arr,
                 'cr': cr_arr}
    data_df = pd.DataFrame(data=data_dict)

    plt.figure(figsize=(16, 8))
    sns.set_style('darkgrid')
    sns.set_context('notebook')
    sns.barplot(x='date', y='num_t', data=data_df, palette='OrRd')
    sns.barplot(x='date', y='num_ct', data=data_df, palette='GnBu')
    sns.lineplot(x='date', y='cr', data=data_df, color='limegreen', marker='o', linestyle='-.')
    for i, v in enumerate(date_arr):
        plt.text(v, cr_arr[i], f"{int(cr_arr[i])}%", fontsize=13, fontfamily='monospace', horizontalalignment='center',
                 verticalalignment='bottom', color='limegreen')
    plt.xlabel("Date", fontsize=15)
    plt.ylabel(" ")
    plt.xticks(rotation=45)
    plt.savefig('image/graph.png')

    toplevel_graph = tkinter.Toplevel(window)
    toplevel_graph.title("Todo Tracker Graph")
    toplevel_graph.geometry("1280x640+100+100")

    img = tkinter.PhotoImage(file='image/graph.png')
    label_graph = tkinter.Label(toplevel_graph, image=img, bg='ivory')
    label_graph.pack()

    toplevel_graph.mainloop()


def show_date_streak():
    streak = []
    presence_date_set = set()
    with open(DATA_CSV, 'r') as rf:
        reader = csv.reader(rf)

        curr_cnt = 0
        max_cnt = 0
        for day_data in reader:
            _date = day_data[0]
            presence_date_set.add(_date)
            if day_data[COMPLETED_TASK_NUM]:
                curr_cnt += 1
                max_cnt = max(max_cnt, curr_cnt)
            else:
                curr_cnt = 0

    NUM_WEEK = 53
    NUM_WEEKDAY = 7
    matrix = [["  " for row in range(NUM_WEEK + 2)] for col in range(NUM_WEEKDAY + 1)]
    matrix[0][0], matrix[1][0], matrix[2][0], matrix[3][0], matrix[4][0], matrix[5][0], matrix[6][0]\
        = "S ", "M ", "T ", "W ", "T ", "F ", "S "

    today_obj = dt.date.fromisoformat(get_today_weekday()[0])
    a_year_ago_obj = today_obj - dt.timedelta(days=364)
    a_year_ago_weekday = a_year_ago_obj.isoweekday() % 7
    col = a_year_ago_weekday
    row = 2
    curr_day_obj = a_year_ago_obj
    while curr_day_obj <= today_obj:
        curr_day_str = curr_day_obj.strftime("%Y-%m-%d")
        BLACK_FLAG = "\u2691 "
        WHITE_FLAG = "\u2690 "
        if curr_day_str in presence_date_set:
            matrix[col][row] = BLACK_FLAG
        else:
            matrix[col][row] = WHITE_FLAG

        if curr_day_obj.day == 1:
            if curr_day_obj.month == 1:
                matrix[7][row] = f"{curr_day_obj.year}"
                try:
                    matrix[7][row+1] = ""
                except IndexError:
                    pass
            else:
                matrix[7][row] = f"{curr_day_obj.month}월"
                try:
                    matrix[7][row+1] = " "
                except IndexError:
                    pass

        curr_day_obj += dt.timedelta(days=1)
        col += 1
        if col == 7:
            col = 0
            row += 1

    streak.append(f"최대 {max_cnt}일 연속 하루 관리, 현재 {curr_cnt}일")
    if max_cnt == curr_cnt:
        streak[-1] += " !!!"

    for c in range(NUM_WEEKDAY + 1):
        streak.append(''.join(matrix[c]))

    msg_streak = tkinter.Message(frame_streak, text='\n'.join(streak), font=streak_font, fg='spring green', width=800)
    msg_streak.pack(side='left')


if __name__ == '__main__':
    update_data()
    # 윈도우 프레임
    window = tkinter.Tk()
    label_font = tkinter.font.Font(family='Consolas', size=13, weight='bold', slant='roman')
    list_item_font = tkinter.font.Font(family='Nanum Gothic', weight='bold', slant='roman')
    streak_font = tkinter.font.Font(family='Courier', size=8)

    window.title('Todo Tracker')
    window.geometry('600x400+300+300')
    window.resizable(False, False)

    label_version = tkinter.Label(window, text=VERSION, font=label_font, fg='light slate gray')
    label_version.pack(side='top', anchor='e')

    notebook = tkinter.ttk.Notebook(window, width=800, height=700)
    notebook.pack()
    # 투데이 프레임
    frame_today = tkinter.Frame(window)
    notebook.add(frame_today, text='Today')

    listbox_todo = tkinter.Listbox(frame_today, selectmode='extended', selectbackground='spring green',
                                   selectforeground='light sea green', width=20, height=15, font=list_item_font)
    show_todo()
    listbox_todo.place(relx=0.05, rely=0.05, width=190)

    listbox_ct = tkinter.Listbox(frame_today, selectmode='extended', selectbackground='spring green',
                                 selectforeground='light sea green', width=20, height=15, font=list_item_font)
    show_completed_task()
    listbox_ct.place(relx=0.61, rely=0.05, width=190)

    entry_todo = tkinter.Entry(frame_today, width=150)
    entry_todo.bind('<Return>', add_todo)
    entry_todo.place(relx=0.05, rely=0.875, width=190)

    button_move = tkinter.Button(frame_today, text='완료', overrelief='solid', width=2, command=move_todo_to_ct,
                                 repeatdelay=1000, repeatinterval=100)
    button_move.place(relx=0.455, rely=0.1)

    button_save = tkinter.Button(frame_today, text='저장', overrelief='solid', width=2, command=save_data,
                                 repeatdelay=1000, repeatinterval=100)
    button_save.place(relx=0.455, rely=0.2)

    button_reset = tkinter.Button(frame_today, text='리셋', overrelief='solid', width=2, command=reset_todo_and_ct,
                                  repeatdelay=1000, repeatinterval=100)
    button_reset.place(relx=0.455, rely=0.3)

    message_prompt = tkinter.Message(frame_today, text=f"오늘은 {get_today_weekday()[0]} {get_today_weekday()[1]}요일\n좋은 하루 보내요 {chr(0x1F600)}",
                                     width=160, relief='solid')
    message_prompt.place(relx=0.61, rely=0.85, width=190)
    # 그래프 프레임
    frame_graph = tkinter.Frame(window)
    notebook.add(frame_graph, text='Graph')

    label_period = tkinter.Label(frame_graph, text='조회 기간: ')
    label_period.place(relx=0.06, rely=0.06)

    data_size = len(get_data()[0])

    def select_period(self):
        period = scale_period.get()
        label_period.config(text=f'조회 기간: {period}일')

    var_period = tkinter.IntVar()
    scale_period = tkinter.Scale(frame_graph, variable=var_period, command=select_period, orient='horizontal',
                                 showvalue=True, tickinterval=1, to=data_size, length=300)
    scale_period.place(relx=0.22, rely=0.02)

    button_display = tkinter.Button(frame_graph, text='조회', overrelief='solid', width=2, command=visualize_data,
                                    repeatdelay=1000, repeatinterval=100)
    button_display.place(relx=0.8, rely=0.06)

    # 스트릭 프레임
    frame_streak = tkinter.Frame(window)
    notebook.add(frame_streak, text='Streak')
    show_date_streak()

    window.mainloop()

 표준출력으로만 구현했을 때는 한계가 정해져 있어서 그런지, 사용자 경험의 관점에서 손 봐야 할 곳이 눈에 띄지 않았는데, 이번에는 정말 많다. 굳이 아이디어를 떠올리지 않아도 될 만큼 고칠 곳 투성이다.