[나의 작은 프로그래밍] TodoTracker (v.1.2.1)
이 프로그램을 더욱 향상된 GUI 환경에서 사용해보고 싶다는 생각이 들었다. 기왕 Python을 사용한 김에 언어를 통일하는게 용이하겠다 싶어 tkinter 라이브러리를 사용했다. 프로토타입 버전의 프로그램을 빨리 개발하는 데에는 부족함이 없을 정도로 잘 만들어진 라이브러리였다. 익숙한 언어 체계다 보니 배우는 일은 어렵지 않았지만, 레퍼런스가 많이 없어서 의도한 대로 구현하기가 쉽지 않았고, 특히 오류를 잡기는 더 어려웠다.
요약
GUI가 포함된 프로토타입 프로그램 작성
사용 언어
Python
라이브러리
- csv
- datetime
- pytz
- numpy
- pandas
- matplotlib
- seaborn
- tkinter
새로운 기능
- GUI 제공
- 리모컨 방식의 기존 메인 프롬프트에서, 프레임에 따라 다른 기능을 제공하는 형태로 개선함
- 유저의 명령 수행 및 화면 전환이 키보드 입력에 의존하여 이뤄지던 방식에서, 인터페이스와의 직관적인 상호작용이 가능한 형태로 개선함
# © 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()
표준출력으로만 구현했을 때는 한계가 정해져 있어서 그런지, 사용자 경험의 관점에서 손 봐야 할 곳이 눈에 띄지 않았는데, 이번에는 정말 많다. 굳이 아이디어를 떠올리지 않아도 될 만큼 고칠 곳 투성이다.