時間追跡アプリ
プロジェクト・タスクごとに作業時間を記録するタイムトラッカー。SQLiteで履歴を管理し週次レポートを生成します。
1. アプリ概要
プロジェクト・タスクごとに作業時間を記録するタイムトラッカー。SQLiteで履歴を管理し週次レポートを生成します。
このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★☆ です。
Pythonでは tkinter を使うことで、クロスプラットフォームなGUIアプリを簡単に作成できます。このアプリを通じて、ウィジェットの配置・イベント処理・データ管理など、GUI開発の実践的なスキルを習得できます。
ソースコードは完全な動作状態で提供しており、コピーしてそのまま実行できます。まずは実行して動作を確認し、その後コードを読んで仕組みを理解していきましょう。カスタマイズセクションでは機能拡張のアイデアも紹介しています。
GUIアプリ開発は、プログラミングの楽しさを実感できる最も効果的な学習方法のひとつです。アプリを作ることで、変数・関数・クラス・イベント処理など、プログラミングの重要な概念が自然と身についていきます。このアプリをきっかけに、オリジナルアプリの開発にも挑戦してみてください。
2. 機能一覧
- 時間追跡アプリのメイン機能
- 直感的なGUIインターフェース
- 入力値のバリデーション
- エラーハンドリング
- 結果の見やすい表示
- キーボードショートカット対応
3. 事前準備・環境
Python 3.10 以上 / Windows・Mac・Linux すべて対応
以下の環境で動作確認しています。
- Python 3.10 以上
- OS: Windows 10/11・macOS 12+・Ubuntu 20.04+
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
import time
import threading
from datetime import datetime, timedelta
class App24:
"""時間追跡アプリ"""
DB_PATH = os.path.join(os.path.dirname(__file__), "timelog.db")
def __init__(self, root):
self.root = root
self.root.title("時間追跡アプリ")
self.root.geometry("860x580")
self.root.configure(bg="#f8f9fc")
self.running = False
self.start_time = None
self.elapsed = 0
self._timer_id = None
self._init_db()
self._build_ui()
self._load_projects()
self._load_logs()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _init_db(self):
self.conn = sqlite3.connect(self.DB_PATH)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
color TEXT DEFAULT '#3776ab'
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS time_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
task TEXT,
start_time TEXT,
end_time TEXT,
duration INTEGER,
notes TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id)
)
""")
self.conn.commit()
if not self.conn.execute("SELECT 1 FROM projects").fetchone():
for name in ["開発", "デザイン", "会議", "学習", "その他"]:
self.conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
self.conn.commit()
def _build_ui(self):
title_frame = tk.Frame(self.root, bg="#0f3460", pady=10)
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="⏱️ 時間追跡アプリ",
font=("Noto Sans JP", 15, "bold"),
bg="#0f3460", fg="white").pack(side=tk.LEFT, padx=12)
# タイマーパネル
timer_f = tk.Frame(self.root, bg="#16213e", pady=16)
timer_f.pack(fill=tk.X, padx=8, pady=8)
self.timer_label = tk.Label(timer_f, text="00:00:00",
font=("Courier New", 36, "bold"),
bg="#16213e", fg="#e2b96f")
self.timer_label.pack()
ctrl_f = tk.Frame(timer_f, bg="#16213e")
ctrl_f.pack(pady=8)
btn_style = {"bg": "#0f3460", "fg": "#e2b96f", "relief": tk.FLAT,
"font": ("Arial", 12), "padx": 16, "pady": 6,
"activebackground": "#1a2a5e"}
self.start_btn = tk.Button(ctrl_f, text="▶ 開始",
command=self._toggle_timer, **btn_style)
self.start_btn.pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="⏹ リセット",
command=self._reset_timer, **btn_style).pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="💾 保存",
command=self._save_log, **btn_style).pack(side=tk.LEFT, padx=6)
# プロジェクト・タスク選択
sel_f = tk.Frame(timer_f, bg="#16213e")
sel_f.pack(pady=4)
tk.Label(sel_f, text="プロジェクト:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=4)
self.proj_var = tk.StringVar()
self.proj_cb = ttk.Combobox(sel_f, textvariable=self.proj_var,
state="readonly", width=14)
self.proj_cb.pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="タスク:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.task_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.task_var, width=20).pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="メモ:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.notes_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.notes_var, width=20).pack(side=tk.LEFT)
# メインエリア
paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
# 左: ログ一覧
left = ttk.LabelFrame(paned, text="作業ログ", padding=4)
# フィルター
filter_f = tk.Frame(left, bg=left.cget("background"))
filter_f.pack(fill=tk.X, pady=2)
tk.Label(filter_f, text="プロジェクト:").pack(side=tk.LEFT)
self.log_proj_var = tk.StringVar(value="すべて")
self.log_proj_cb = ttk.Combobox(filter_f, textvariable=self.log_proj_var,
state="readonly", width=12)
self.log_proj_cb.pack(side=tk.LEFT, padx=4)
self.log_proj_cb.bind("<<ComboboxSelected>>", lambda e: self._load_logs())
cols = ("date", "project", "task", "duration", "notes")
self.log_tree = ttk.Treeview(left, columns=cols, show="headings", height=14)
for c, h, w in [("date", "日時", 130), ("project", "プロジェクト", 80),
("task", "タスク", 140), ("duration", "時間", 70),
("notes", "メモ", 120)]:
self.log_tree.heading(c, text=h)
self.log_tree.column(c, width=w, minwidth=40)
sb = ttk.Scrollbar(left, command=self.log_tree.yview)
self.log_tree.configure(yscrollcommand=sb.set)
sb.pack(side=tk.RIGHT, fill=tk.Y)
self.log_tree.pack(fill=tk.BOTH, expand=True)
ttk.Button(left, text="🗑️ 削除",
command=self._delete_log).pack(anchor="w", pady=2)
paned.add(left, weight=3)
# 右: 週次レポート
right = ttk.LabelFrame(paned, text="週次レポート", padding=8)
self.report_text = tk.Text(right, font=("Courier New", 10),
bg="#1e1e1e", fg="#c9d1d9",
relief=tk.FLAT, state=tk.DISABLED)
self.report_text.pack(fill=tk.BOTH, expand=True)
ttk.Button(right, text="🔄 レポート更新",
command=self._update_report).pack(pady=4)
paned.add(right, weight=2)
self.status_var = tk.StringVar(value="")
tk.Label(self.root, textvariable=self.status_var,
bg="#dde", font=("Arial", 9), anchor="w", padx=8
).pack(fill=tk.X, side=tk.BOTTOM)
def _load_projects(self):
rows = self.conn.execute(
"SELECT name FROM projects ORDER BY name").fetchall()
names = [r[0] for r in rows]
self.proj_cb.configure(values=names)
self.log_proj_cb.configure(values=["すべて"] + names)
if names:
self.proj_var.set(names[0])
def _toggle_timer(self):
if self.running:
self.running = False
self.elapsed += time.time() - self.start_time
self.start_btn.config(text="▶ 開始", bg="#0f3460")
if self._timer_id:
self.root.after_cancel(self._timer_id)
else:
self.running = True
self.start_time = time.time()
self.start_btn.config(text="⏸ 一時停止", bg="#c62828")
self._tick()
def _tick(self):
if self.running:
total = self.elapsed + (time.time() - self.start_time)
h = int(total // 3600)
m = int((total % 3600) // 60)
s = int(total % 60)
self.timer_label.config(text=f"{h:02d}:{m:02d}:{s:02d}")
self._timer_id = self.root.after(1000, self._tick)
def _reset_timer(self):
if self.running:
self._toggle_timer()
self.elapsed = 0
self.start_time = None
self.timer_label.config(text="00:00:00")
def _save_log(self):
if self.elapsed < 1:
messagebox.showwarning("警告", "タイマーを使用してください")
return
proj_name = self.proj_var.get()
if not proj_name:
messagebox.showwarning("警告", "プロジェクトを選択してください")
return
row = self.conn.execute(
"SELECT id FROM projects WHERE name=?", (proj_name,)).fetchone()
if not row:
return
proj_id = row[0]
duration = int(self.elapsed)
now = datetime.now()
start_dt = now - timedelta(seconds=duration)
self.conn.execute(
"INSERT INTO time_logs (project_id,task,start_time,end_time,"
"duration,notes) VALUES (?,?,?,?,?,?)",
(proj_id, self.task_var.get(),
start_dt.isoformat(), now.isoformat(),
duration, self.notes_var.get()))
self.conn.commit()
self._reset_timer()
self._load_logs()
self.status_var.set(
f"保存: {proj_name} - {self._fmt_dur(duration)}")
def _fmt_dur(self, seconds):
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:02d}"
def _load_logs(self):
proj_f = self.log_proj_var.get()
sql = ("SELECT l.id, l.start_time, p.name, l.task, l.duration, l.notes "
"FROM time_logs l "
"JOIN projects p ON l.project_id=p.id WHERE 1=1")
params = []
if proj_f != "すべて":
sql += " AND p.name=?"
params.append(proj_f)
sql += " ORDER BY l.start_time DESC LIMIT 100"
rows = self.conn.execute(sql, params).fetchall()
self.log_tree.delete(*self.log_tree.get_children())
for row in rows:
lid, start, proj, task, dur, notes = row
self.log_tree.insert("", "end", iid=str(lid),
values=(start[:16], proj, task or "",
self._fmt_dur(dur), notes or ""))
self._update_report()
def _delete_log(self):
sel = self.log_tree.selection()
if sel:
if messagebox.askyesno("確認", "削除しますか?"):
for item in sel:
self.conn.execute("DELETE FROM time_logs WHERE id=?",
(int(item),))
self.conn.commit()
self._load_logs()
def _update_report(self):
today = datetime.now().date()
week_start = today - timedelta(days=today.weekday())
rows = self.conn.execute(
"SELECT p.name, SUM(l.duration) FROM time_logs l "
"JOIN projects p ON l.project_id=p.id "
"WHERE l.start_time >= ? GROUP BY p.name ORDER BY SUM(l.duration) DESC",
(str(week_start),)).fetchall()
total = sum(r[1] for r in rows)
text = f"【週次レポート】{week_start} 〜 {today}\n"
text += f"{'='*36}\n"
for name, dur in rows:
pct = dur / total * 100 if total else 0
bar = "█" * int(pct / 5)
text += f"{name:<12} {self._fmt_dur(dur):>10} {pct:5.1f}% {bar}\n"
text += f"{'='*36}\n"
text += f"合計: {self._fmt_dur(total)}\n"
# 日別
text += "\n【日別ログ】\n"
daily = {}
for i in range(7):
d = str(week_start + timedelta(days=i))
r = self.conn.execute(
"SELECT COALESCE(SUM(duration),0) FROM time_logs "
"WHERE DATE(start_time)=?", (d,)).fetchone()
daily[d] = r[0]
text += f" {d}: {self._fmt_dur(r[0])}\n"
self.report_text.config(state=tk.NORMAL)
self.report_text.delete("1.0", tk.END)
self.report_text.insert("1.0", text)
self.report_text.config(state=tk.DISABLED)
def _on_close(self):
if self.running:
self._toggle_timer()
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = App24(root)
root.mainloop()
5. コード解説
時間追跡アプリのコードを詳しく解説します。クラスベースの設計で各機能を整理して実装しています。
クラス設計とコンストラクタ
App24クラスにアプリの全機能をまとめています。__init__メソッドでウィンドウの基本設定を行い、_build_ui()でUI構築、process()でメイン処理を担当します。この分離により、各メソッドの責任が明確になりコードが読みやすくなります。
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
import time
import threading
from datetime import datetime, timedelta
class App24:
"""時間追跡アプリ"""
DB_PATH = os.path.join(os.path.dirname(__file__), "timelog.db")
def __init__(self, root):
self.root = root
self.root.title("時間追跡アプリ")
self.root.geometry("860x580")
self.root.configure(bg="#f8f9fc")
self.running = False
self.start_time = None
self.elapsed = 0
self._timer_id = None
self._init_db()
self._build_ui()
self._load_projects()
self._load_logs()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _init_db(self):
self.conn = sqlite3.connect(self.DB_PATH)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
color TEXT DEFAULT '#3776ab'
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS time_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
task TEXT,
start_time TEXT,
end_time TEXT,
duration INTEGER,
notes TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id)
)
""")
self.conn.commit()
if not self.conn.execute("SELECT 1 FROM projects").fetchone():
for name in ["開発", "デザイン", "会議", "学習", "その他"]:
self.conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
self.conn.commit()
def _build_ui(self):
title_frame = tk.Frame(self.root, bg="#0f3460", pady=10)
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="⏱️ 時間追跡アプリ",
font=("Noto Sans JP", 15, "bold"),
bg="#0f3460", fg="white").pack(side=tk.LEFT, padx=12)
# タイマーパネル
timer_f = tk.Frame(self.root, bg="#16213e", pady=16)
timer_f.pack(fill=tk.X, padx=8, pady=8)
self.timer_label = tk.Label(timer_f, text="00:00:00",
font=("Courier New", 36, "bold"),
bg="#16213e", fg="#e2b96f")
self.timer_label.pack()
ctrl_f = tk.Frame(timer_f, bg="#16213e")
ctrl_f.pack(pady=8)
btn_style = {"bg": "#0f3460", "fg": "#e2b96f", "relief": tk.FLAT,
"font": ("Arial", 12), "padx": 16, "pady": 6,
"activebackground": "#1a2a5e"}
self.start_btn = tk.Button(ctrl_f, text="▶ 開始",
command=self._toggle_timer, **btn_style)
self.start_btn.pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="⏹ リセット",
command=self._reset_timer, **btn_style).pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="💾 保存",
command=self._save_log, **btn_style).pack(side=tk.LEFT, padx=6)
# プロジェクト・タスク選択
sel_f = tk.Frame(timer_f, bg="#16213e")
sel_f.pack(pady=4)
tk.Label(sel_f, text="プロジェクト:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=4)
self.proj_var = tk.StringVar()
self.proj_cb = ttk.Combobox(sel_f, textvariable=self.proj_var,
state="readonly", width=14)
self.proj_cb.pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="タスク:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.task_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.task_var, width=20).pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="メモ:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.notes_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.notes_var, width=20).pack(side=tk.LEFT)
# メインエリア
paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
# 左: ログ一覧
left = ttk.LabelFrame(paned, text="作業ログ", padding=4)
# フィルター
filter_f = tk.Frame(left, bg=left.cget("background"))
filter_f.pack(fill=tk.X, pady=2)
tk.Label(filter_f, text="プロジェクト:").pack(side=tk.LEFT)
self.log_proj_var = tk.StringVar(value="すべて")
self.log_proj_cb = ttk.Combobox(filter_f, textvariable=self.log_proj_var,
state="readonly", width=12)
self.log_proj_cb.pack(side=tk.LEFT, padx=4)
self.log_proj_cb.bind("<<ComboboxSelected>>", lambda e: self._load_logs())
cols = ("date", "project", "task", "duration", "notes")
self.log_tree = ttk.Treeview(left, columns=cols, show="headings", height=14)
for c, h, w in [("date", "日時", 130), ("project", "プロジェクト", 80),
("task", "タスク", 140), ("duration", "時間", 70),
("notes", "メモ", 120)]:
self.log_tree.heading(c, text=h)
self.log_tree.column(c, width=w, minwidth=40)
sb = ttk.Scrollbar(left, command=self.log_tree.yview)
self.log_tree.configure(yscrollcommand=sb.set)
sb.pack(side=tk.RIGHT, fill=tk.Y)
self.log_tree.pack(fill=tk.BOTH, expand=True)
ttk.Button(left, text="🗑️ 削除",
command=self._delete_log).pack(anchor="w", pady=2)
paned.add(left, weight=3)
# 右: 週次レポート
right = ttk.LabelFrame(paned, text="週次レポート", padding=8)
self.report_text = tk.Text(right, font=("Courier New", 10),
bg="#1e1e1e", fg="#c9d1d9",
relief=tk.FLAT, state=tk.DISABLED)
self.report_text.pack(fill=tk.BOTH, expand=True)
ttk.Button(right, text="🔄 レポート更新",
command=self._update_report).pack(pady=4)
paned.add(right, weight=2)
self.status_var = tk.StringVar(value="")
tk.Label(self.root, textvariable=self.status_var,
bg="#dde", font=("Arial", 9), anchor="w", padx=8
).pack(fill=tk.X, side=tk.BOTTOM)
def _load_projects(self):
rows = self.conn.execute(
"SELECT name FROM projects ORDER BY name").fetchall()
names = [r[0] for r in rows]
self.proj_cb.configure(values=names)
self.log_proj_cb.configure(values=["すべて"] + names)
if names:
self.proj_var.set(names[0])
def _toggle_timer(self):
if self.running:
self.running = False
self.elapsed += time.time() - self.start_time
self.start_btn.config(text="▶ 開始", bg="#0f3460")
if self._timer_id:
self.root.after_cancel(self._timer_id)
else:
self.running = True
self.start_time = time.time()
self.start_btn.config(text="⏸ 一時停止", bg="#c62828")
self._tick()
def _tick(self):
if self.running:
total = self.elapsed + (time.time() - self.start_time)
h = int(total // 3600)
m = int((total % 3600) // 60)
s = int(total % 60)
self.timer_label.config(text=f"{h:02d}:{m:02d}:{s:02d}")
self._timer_id = self.root.after(1000, self._tick)
def _reset_timer(self):
if self.running:
self._toggle_timer()
self.elapsed = 0
self.start_time = None
self.timer_label.config(text="00:00:00")
def _save_log(self):
if self.elapsed < 1:
messagebox.showwarning("警告", "タイマーを使用してください")
return
proj_name = self.proj_var.get()
if not proj_name:
messagebox.showwarning("警告", "プロジェクトを選択してください")
return
row = self.conn.execute(
"SELECT id FROM projects WHERE name=?", (proj_name,)).fetchone()
if not row:
return
proj_id = row[0]
duration = int(self.elapsed)
now = datetime.now()
start_dt = now - timedelta(seconds=duration)
self.conn.execute(
"INSERT INTO time_logs (project_id,task,start_time,end_time,"
"duration,notes) VALUES (?,?,?,?,?,?)",
(proj_id, self.task_var.get(),
start_dt.isoformat(), now.isoformat(),
duration, self.notes_var.get()))
self.conn.commit()
self._reset_timer()
self._load_logs()
self.status_var.set(
f"保存: {proj_name} - {self._fmt_dur(duration)}")
def _fmt_dur(self, seconds):
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:02d}"
def _load_logs(self):
proj_f = self.log_proj_var.get()
sql = ("SELECT l.id, l.start_time, p.name, l.task, l.duration, l.notes "
"FROM time_logs l "
"JOIN projects p ON l.project_id=p.id WHERE 1=1")
params = []
if proj_f != "すべて":
sql += " AND p.name=?"
params.append(proj_f)
sql += " ORDER BY l.start_time DESC LIMIT 100"
rows = self.conn.execute(sql, params).fetchall()
self.log_tree.delete(*self.log_tree.get_children())
for row in rows:
lid, start, proj, task, dur, notes = row
self.log_tree.insert("", "end", iid=str(lid),
values=(start[:16], proj, task or "",
self._fmt_dur(dur), notes or ""))
self._update_report()
def _delete_log(self):
sel = self.log_tree.selection()
if sel:
if messagebox.askyesno("確認", "削除しますか?"):
for item in sel:
self.conn.execute("DELETE FROM time_logs WHERE id=?",
(int(item),))
self.conn.commit()
self._load_logs()
def _update_report(self):
today = datetime.now().date()
week_start = today - timedelta(days=today.weekday())
rows = self.conn.execute(
"SELECT p.name, SUM(l.duration) FROM time_logs l "
"JOIN projects p ON l.project_id=p.id "
"WHERE l.start_time >= ? GROUP BY p.name ORDER BY SUM(l.duration) DESC",
(str(week_start),)).fetchall()
total = sum(r[1] for r in rows)
text = f"【週次レポート】{week_start} 〜 {today}\n"
text += f"{'='*36}\n"
for name, dur in rows:
pct = dur / total * 100 if total else 0
bar = "█" * int(pct / 5)
text += f"{name:<12} {self._fmt_dur(dur):>10} {pct:5.1f}% {bar}\n"
text += f"{'='*36}\n"
text += f"合計: {self._fmt_dur(total)}\n"
# 日別
text += "\n【日別ログ】\n"
daily = {}
for i in range(7):
d = str(week_start + timedelta(days=i))
r = self.conn.execute(
"SELECT COALESCE(SUM(duration),0) FROM time_logs "
"WHERE DATE(start_time)=?", (d,)).fetchone()
daily[d] = r[0]
text += f" {d}: {self._fmt_dur(r[0])}\n"
self.report_text.config(state=tk.NORMAL)
self.report_text.delete("1.0", tk.END)
self.report_text.insert("1.0", text)
self.report_text.config(state=tk.DISABLED)
def _on_close(self):
if self.running:
self._toggle_timer()
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = App24(root)
root.mainloop()
LabelFrameによるセクション分け
ttk.LabelFrame を使うことで、入力エリアと結果エリアを視覚的に分けられます。padding引数でフレーム内の余白を設定し、見やすいレイアウトを実現しています。
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
import time
import threading
from datetime import datetime, timedelta
class App24:
"""時間追跡アプリ"""
DB_PATH = os.path.join(os.path.dirname(__file__), "timelog.db")
def __init__(self, root):
self.root = root
self.root.title("時間追跡アプリ")
self.root.geometry("860x580")
self.root.configure(bg="#f8f9fc")
self.running = False
self.start_time = None
self.elapsed = 0
self._timer_id = None
self._init_db()
self._build_ui()
self._load_projects()
self._load_logs()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _init_db(self):
self.conn = sqlite3.connect(self.DB_PATH)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
color TEXT DEFAULT '#3776ab'
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS time_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
task TEXT,
start_time TEXT,
end_time TEXT,
duration INTEGER,
notes TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id)
)
""")
self.conn.commit()
if not self.conn.execute("SELECT 1 FROM projects").fetchone():
for name in ["開発", "デザイン", "会議", "学習", "その他"]:
self.conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
self.conn.commit()
def _build_ui(self):
title_frame = tk.Frame(self.root, bg="#0f3460", pady=10)
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="⏱️ 時間追跡アプリ",
font=("Noto Sans JP", 15, "bold"),
bg="#0f3460", fg="white").pack(side=tk.LEFT, padx=12)
# タイマーパネル
timer_f = tk.Frame(self.root, bg="#16213e", pady=16)
timer_f.pack(fill=tk.X, padx=8, pady=8)
self.timer_label = tk.Label(timer_f, text="00:00:00",
font=("Courier New", 36, "bold"),
bg="#16213e", fg="#e2b96f")
self.timer_label.pack()
ctrl_f = tk.Frame(timer_f, bg="#16213e")
ctrl_f.pack(pady=8)
btn_style = {"bg": "#0f3460", "fg": "#e2b96f", "relief": tk.FLAT,
"font": ("Arial", 12), "padx": 16, "pady": 6,
"activebackground": "#1a2a5e"}
self.start_btn = tk.Button(ctrl_f, text="▶ 開始",
command=self._toggle_timer, **btn_style)
self.start_btn.pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="⏹ リセット",
command=self._reset_timer, **btn_style).pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="💾 保存",
command=self._save_log, **btn_style).pack(side=tk.LEFT, padx=6)
# プロジェクト・タスク選択
sel_f = tk.Frame(timer_f, bg="#16213e")
sel_f.pack(pady=4)
tk.Label(sel_f, text="プロジェクト:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=4)
self.proj_var = tk.StringVar()
self.proj_cb = ttk.Combobox(sel_f, textvariable=self.proj_var,
state="readonly", width=14)
self.proj_cb.pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="タスク:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.task_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.task_var, width=20).pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="メモ:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.notes_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.notes_var, width=20).pack(side=tk.LEFT)
# メインエリア
paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
# 左: ログ一覧
left = ttk.LabelFrame(paned, text="作業ログ", padding=4)
# フィルター
filter_f = tk.Frame(left, bg=left.cget("background"))
filter_f.pack(fill=tk.X, pady=2)
tk.Label(filter_f, text="プロジェクト:").pack(side=tk.LEFT)
self.log_proj_var = tk.StringVar(value="すべて")
self.log_proj_cb = ttk.Combobox(filter_f, textvariable=self.log_proj_var,
state="readonly", width=12)
self.log_proj_cb.pack(side=tk.LEFT, padx=4)
self.log_proj_cb.bind("<<ComboboxSelected>>", lambda e: self._load_logs())
cols = ("date", "project", "task", "duration", "notes")
self.log_tree = ttk.Treeview(left, columns=cols, show="headings", height=14)
for c, h, w in [("date", "日時", 130), ("project", "プロジェクト", 80),
("task", "タスク", 140), ("duration", "時間", 70),
("notes", "メモ", 120)]:
self.log_tree.heading(c, text=h)
self.log_tree.column(c, width=w, minwidth=40)
sb = ttk.Scrollbar(left, command=self.log_tree.yview)
self.log_tree.configure(yscrollcommand=sb.set)
sb.pack(side=tk.RIGHT, fill=tk.Y)
self.log_tree.pack(fill=tk.BOTH, expand=True)
ttk.Button(left, text="🗑️ 削除",
command=self._delete_log).pack(anchor="w", pady=2)
paned.add(left, weight=3)
# 右: 週次レポート
right = ttk.LabelFrame(paned, text="週次レポート", padding=8)
self.report_text = tk.Text(right, font=("Courier New", 10),
bg="#1e1e1e", fg="#c9d1d9",
relief=tk.FLAT, state=tk.DISABLED)
self.report_text.pack(fill=tk.BOTH, expand=True)
ttk.Button(right, text="🔄 レポート更新",
command=self._update_report).pack(pady=4)
paned.add(right, weight=2)
self.status_var = tk.StringVar(value="")
tk.Label(self.root, textvariable=self.status_var,
bg="#dde", font=("Arial", 9), anchor="w", padx=8
).pack(fill=tk.X, side=tk.BOTTOM)
def _load_projects(self):
rows = self.conn.execute(
"SELECT name FROM projects ORDER BY name").fetchall()
names = [r[0] for r in rows]
self.proj_cb.configure(values=names)
self.log_proj_cb.configure(values=["すべて"] + names)
if names:
self.proj_var.set(names[0])
def _toggle_timer(self):
if self.running:
self.running = False
self.elapsed += time.time() - self.start_time
self.start_btn.config(text="▶ 開始", bg="#0f3460")
if self._timer_id:
self.root.after_cancel(self._timer_id)
else:
self.running = True
self.start_time = time.time()
self.start_btn.config(text="⏸ 一時停止", bg="#c62828")
self._tick()
def _tick(self):
if self.running:
total = self.elapsed + (time.time() - self.start_time)
h = int(total // 3600)
m = int((total % 3600) // 60)
s = int(total % 60)
self.timer_label.config(text=f"{h:02d}:{m:02d}:{s:02d}")
self._timer_id = self.root.after(1000, self._tick)
def _reset_timer(self):
if self.running:
self._toggle_timer()
self.elapsed = 0
self.start_time = None
self.timer_label.config(text="00:00:00")
def _save_log(self):
if self.elapsed < 1:
messagebox.showwarning("警告", "タイマーを使用してください")
return
proj_name = self.proj_var.get()
if not proj_name:
messagebox.showwarning("警告", "プロジェクトを選択してください")
return
row = self.conn.execute(
"SELECT id FROM projects WHERE name=?", (proj_name,)).fetchone()
if not row:
return
proj_id = row[0]
duration = int(self.elapsed)
now = datetime.now()
start_dt = now - timedelta(seconds=duration)
self.conn.execute(
"INSERT INTO time_logs (project_id,task,start_time,end_time,"
"duration,notes) VALUES (?,?,?,?,?,?)",
(proj_id, self.task_var.get(),
start_dt.isoformat(), now.isoformat(),
duration, self.notes_var.get()))
self.conn.commit()
self._reset_timer()
self._load_logs()
self.status_var.set(
f"保存: {proj_name} - {self._fmt_dur(duration)}")
def _fmt_dur(self, seconds):
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:02d}"
def _load_logs(self):
proj_f = self.log_proj_var.get()
sql = ("SELECT l.id, l.start_time, p.name, l.task, l.duration, l.notes "
"FROM time_logs l "
"JOIN projects p ON l.project_id=p.id WHERE 1=1")
params = []
if proj_f != "すべて":
sql += " AND p.name=?"
params.append(proj_f)
sql += " ORDER BY l.start_time DESC LIMIT 100"
rows = self.conn.execute(sql, params).fetchall()
self.log_tree.delete(*self.log_tree.get_children())
for row in rows:
lid, start, proj, task, dur, notes = row
self.log_tree.insert("", "end", iid=str(lid),
values=(start[:16], proj, task or "",
self._fmt_dur(dur), notes or ""))
self._update_report()
def _delete_log(self):
sel = self.log_tree.selection()
if sel:
if messagebox.askyesno("確認", "削除しますか?"):
for item in sel:
self.conn.execute("DELETE FROM time_logs WHERE id=?",
(int(item),))
self.conn.commit()
self._load_logs()
def _update_report(self):
today = datetime.now().date()
week_start = today - timedelta(days=today.weekday())
rows = self.conn.execute(
"SELECT p.name, SUM(l.duration) FROM time_logs l "
"JOIN projects p ON l.project_id=p.id "
"WHERE l.start_time >= ? GROUP BY p.name ORDER BY SUM(l.duration) DESC",
(str(week_start),)).fetchall()
total = sum(r[1] for r in rows)
text = f"【週次レポート】{week_start} 〜 {today}\n"
text += f"{'='*36}\n"
for name, dur in rows:
pct = dur / total * 100 if total else 0
bar = "█" * int(pct / 5)
text += f"{name:<12} {self._fmt_dur(dur):>10} {pct:5.1f}% {bar}\n"
text += f"{'='*36}\n"
text += f"合計: {self._fmt_dur(total)}\n"
# 日別
text += "\n【日別ログ】\n"
daily = {}
for i in range(7):
d = str(week_start + timedelta(days=i))
r = self.conn.execute(
"SELECT COALESCE(SUM(duration),0) FROM time_logs "
"WHERE DATE(start_time)=?", (d,)).fetchone()
daily[d] = r[0]
text += f" {d}: {self._fmt_dur(r[0])}\n"
self.report_text.config(state=tk.NORMAL)
self.report_text.delete("1.0", tk.END)
self.report_text.insert("1.0", text)
self.report_text.config(state=tk.DISABLED)
def _on_close(self):
if self.running:
self._toggle_timer()
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = App24(root)
root.mainloop()
Entryウィジェットとイベントバインド
ttk.Entryで入力フィールドを作成します。bind('
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
import time
import threading
from datetime import datetime, timedelta
class App24:
"""時間追跡アプリ"""
DB_PATH = os.path.join(os.path.dirname(__file__), "timelog.db")
def __init__(self, root):
self.root = root
self.root.title("時間追跡アプリ")
self.root.geometry("860x580")
self.root.configure(bg="#f8f9fc")
self.running = False
self.start_time = None
self.elapsed = 0
self._timer_id = None
self._init_db()
self._build_ui()
self._load_projects()
self._load_logs()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _init_db(self):
self.conn = sqlite3.connect(self.DB_PATH)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
color TEXT DEFAULT '#3776ab'
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS time_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
task TEXT,
start_time TEXT,
end_time TEXT,
duration INTEGER,
notes TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id)
)
""")
self.conn.commit()
if not self.conn.execute("SELECT 1 FROM projects").fetchone():
for name in ["開発", "デザイン", "会議", "学習", "その他"]:
self.conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
self.conn.commit()
def _build_ui(self):
title_frame = tk.Frame(self.root, bg="#0f3460", pady=10)
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="⏱️ 時間追跡アプリ",
font=("Noto Sans JP", 15, "bold"),
bg="#0f3460", fg="white").pack(side=tk.LEFT, padx=12)
# タイマーパネル
timer_f = tk.Frame(self.root, bg="#16213e", pady=16)
timer_f.pack(fill=tk.X, padx=8, pady=8)
self.timer_label = tk.Label(timer_f, text="00:00:00",
font=("Courier New", 36, "bold"),
bg="#16213e", fg="#e2b96f")
self.timer_label.pack()
ctrl_f = tk.Frame(timer_f, bg="#16213e")
ctrl_f.pack(pady=8)
btn_style = {"bg": "#0f3460", "fg": "#e2b96f", "relief": tk.FLAT,
"font": ("Arial", 12), "padx": 16, "pady": 6,
"activebackground": "#1a2a5e"}
self.start_btn = tk.Button(ctrl_f, text="▶ 開始",
command=self._toggle_timer, **btn_style)
self.start_btn.pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="⏹ リセット",
command=self._reset_timer, **btn_style).pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="💾 保存",
command=self._save_log, **btn_style).pack(side=tk.LEFT, padx=6)
# プロジェクト・タスク選択
sel_f = tk.Frame(timer_f, bg="#16213e")
sel_f.pack(pady=4)
tk.Label(sel_f, text="プロジェクト:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=4)
self.proj_var = tk.StringVar()
self.proj_cb = ttk.Combobox(sel_f, textvariable=self.proj_var,
state="readonly", width=14)
self.proj_cb.pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="タスク:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.task_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.task_var, width=20).pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="メモ:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.notes_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.notes_var, width=20).pack(side=tk.LEFT)
# メインエリア
paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
# 左: ログ一覧
left = ttk.LabelFrame(paned, text="作業ログ", padding=4)
# フィルター
filter_f = tk.Frame(left, bg=left.cget("background"))
filter_f.pack(fill=tk.X, pady=2)
tk.Label(filter_f, text="プロジェクト:").pack(side=tk.LEFT)
self.log_proj_var = tk.StringVar(value="すべて")
self.log_proj_cb = ttk.Combobox(filter_f, textvariable=self.log_proj_var,
state="readonly", width=12)
self.log_proj_cb.pack(side=tk.LEFT, padx=4)
self.log_proj_cb.bind("<<ComboboxSelected>>", lambda e: self._load_logs())
cols = ("date", "project", "task", "duration", "notes")
self.log_tree = ttk.Treeview(left, columns=cols, show="headings", height=14)
for c, h, w in [("date", "日時", 130), ("project", "プロジェクト", 80),
("task", "タスク", 140), ("duration", "時間", 70),
("notes", "メモ", 120)]:
self.log_tree.heading(c, text=h)
self.log_tree.column(c, width=w, minwidth=40)
sb = ttk.Scrollbar(left, command=self.log_tree.yview)
self.log_tree.configure(yscrollcommand=sb.set)
sb.pack(side=tk.RIGHT, fill=tk.Y)
self.log_tree.pack(fill=tk.BOTH, expand=True)
ttk.Button(left, text="🗑️ 削除",
command=self._delete_log).pack(anchor="w", pady=2)
paned.add(left, weight=3)
# 右: 週次レポート
right = ttk.LabelFrame(paned, text="週次レポート", padding=8)
self.report_text = tk.Text(right, font=("Courier New", 10),
bg="#1e1e1e", fg="#c9d1d9",
relief=tk.FLAT, state=tk.DISABLED)
self.report_text.pack(fill=tk.BOTH, expand=True)
ttk.Button(right, text="🔄 レポート更新",
command=self._update_report).pack(pady=4)
paned.add(right, weight=2)
self.status_var = tk.StringVar(value="")
tk.Label(self.root, textvariable=self.status_var,
bg="#dde", font=("Arial", 9), anchor="w", padx=8
).pack(fill=tk.X, side=tk.BOTTOM)
def _load_projects(self):
rows = self.conn.execute(
"SELECT name FROM projects ORDER BY name").fetchall()
names = [r[0] for r in rows]
self.proj_cb.configure(values=names)
self.log_proj_cb.configure(values=["すべて"] + names)
if names:
self.proj_var.set(names[0])
def _toggle_timer(self):
if self.running:
self.running = False
self.elapsed += time.time() - self.start_time
self.start_btn.config(text="▶ 開始", bg="#0f3460")
if self._timer_id:
self.root.after_cancel(self._timer_id)
else:
self.running = True
self.start_time = time.time()
self.start_btn.config(text="⏸ 一時停止", bg="#c62828")
self._tick()
def _tick(self):
if self.running:
total = self.elapsed + (time.time() - self.start_time)
h = int(total // 3600)
m = int((total % 3600) // 60)
s = int(total % 60)
self.timer_label.config(text=f"{h:02d}:{m:02d}:{s:02d}")
self._timer_id = self.root.after(1000, self._tick)
def _reset_timer(self):
if self.running:
self._toggle_timer()
self.elapsed = 0
self.start_time = None
self.timer_label.config(text="00:00:00")
def _save_log(self):
if self.elapsed < 1:
messagebox.showwarning("警告", "タイマーを使用してください")
return
proj_name = self.proj_var.get()
if not proj_name:
messagebox.showwarning("警告", "プロジェクトを選択してください")
return
row = self.conn.execute(
"SELECT id FROM projects WHERE name=?", (proj_name,)).fetchone()
if not row:
return
proj_id = row[0]
duration = int(self.elapsed)
now = datetime.now()
start_dt = now - timedelta(seconds=duration)
self.conn.execute(
"INSERT INTO time_logs (project_id,task,start_time,end_time,"
"duration,notes) VALUES (?,?,?,?,?,?)",
(proj_id, self.task_var.get(),
start_dt.isoformat(), now.isoformat(),
duration, self.notes_var.get()))
self.conn.commit()
self._reset_timer()
self._load_logs()
self.status_var.set(
f"保存: {proj_name} - {self._fmt_dur(duration)}")
def _fmt_dur(self, seconds):
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:02d}"
def _load_logs(self):
proj_f = self.log_proj_var.get()
sql = ("SELECT l.id, l.start_time, p.name, l.task, l.duration, l.notes "
"FROM time_logs l "
"JOIN projects p ON l.project_id=p.id WHERE 1=1")
params = []
if proj_f != "すべて":
sql += " AND p.name=?"
params.append(proj_f)
sql += " ORDER BY l.start_time DESC LIMIT 100"
rows = self.conn.execute(sql, params).fetchall()
self.log_tree.delete(*self.log_tree.get_children())
for row in rows:
lid, start, proj, task, dur, notes = row
self.log_tree.insert("", "end", iid=str(lid),
values=(start[:16], proj, task or "",
self._fmt_dur(dur), notes or ""))
self._update_report()
def _delete_log(self):
sel = self.log_tree.selection()
if sel:
if messagebox.askyesno("確認", "削除しますか?"):
for item in sel:
self.conn.execute("DELETE FROM time_logs WHERE id=?",
(int(item),))
self.conn.commit()
self._load_logs()
def _update_report(self):
today = datetime.now().date()
week_start = today - timedelta(days=today.weekday())
rows = self.conn.execute(
"SELECT p.name, SUM(l.duration) FROM time_logs l "
"JOIN projects p ON l.project_id=p.id "
"WHERE l.start_time >= ? GROUP BY p.name ORDER BY SUM(l.duration) DESC",
(str(week_start),)).fetchall()
total = sum(r[1] for r in rows)
text = f"【週次レポート】{week_start} 〜 {today}\n"
text += f"{'='*36}\n"
for name, dur in rows:
pct = dur / total * 100 if total else 0
bar = "█" * int(pct / 5)
text += f"{name:<12} {self._fmt_dur(dur):>10} {pct:5.1f}% {bar}\n"
text += f"{'='*36}\n"
text += f"合計: {self._fmt_dur(total)}\n"
# 日別
text += "\n【日別ログ】\n"
daily = {}
for i in range(7):
d = str(week_start + timedelta(days=i))
r = self.conn.execute(
"SELECT COALESCE(SUM(duration),0) FROM time_logs "
"WHERE DATE(start_time)=?", (d,)).fetchone()
daily[d] = r[0]
text += f" {d}: {self._fmt_dur(r[0])}\n"
self.report_text.config(state=tk.NORMAL)
self.report_text.delete("1.0", tk.END)
self.report_text.insert("1.0", text)
self.report_text.config(state=tk.DISABLED)
def _on_close(self):
if self.running:
self._toggle_timer()
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = App24(root)
root.mainloop()
Textウィジェットでの結果表示
結果表示にはtk.Textウィジェットを使います。state=tk.DISABLEDでユーザーが直接編集できないようにし、表示前にNORMALに切り替えてからinsert()で内容を更新します。
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
import time
import threading
from datetime import datetime, timedelta
class App24:
"""時間追跡アプリ"""
DB_PATH = os.path.join(os.path.dirname(__file__), "timelog.db")
def __init__(self, root):
self.root = root
self.root.title("時間追跡アプリ")
self.root.geometry("860x580")
self.root.configure(bg="#f8f9fc")
self.running = False
self.start_time = None
self.elapsed = 0
self._timer_id = None
self._init_db()
self._build_ui()
self._load_projects()
self._load_logs()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _init_db(self):
self.conn = sqlite3.connect(self.DB_PATH)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
color TEXT DEFAULT '#3776ab'
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS time_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
task TEXT,
start_time TEXT,
end_time TEXT,
duration INTEGER,
notes TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id)
)
""")
self.conn.commit()
if not self.conn.execute("SELECT 1 FROM projects").fetchone():
for name in ["開発", "デザイン", "会議", "学習", "その他"]:
self.conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
self.conn.commit()
def _build_ui(self):
title_frame = tk.Frame(self.root, bg="#0f3460", pady=10)
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="⏱️ 時間追跡アプリ",
font=("Noto Sans JP", 15, "bold"),
bg="#0f3460", fg="white").pack(side=tk.LEFT, padx=12)
# タイマーパネル
timer_f = tk.Frame(self.root, bg="#16213e", pady=16)
timer_f.pack(fill=tk.X, padx=8, pady=8)
self.timer_label = tk.Label(timer_f, text="00:00:00",
font=("Courier New", 36, "bold"),
bg="#16213e", fg="#e2b96f")
self.timer_label.pack()
ctrl_f = tk.Frame(timer_f, bg="#16213e")
ctrl_f.pack(pady=8)
btn_style = {"bg": "#0f3460", "fg": "#e2b96f", "relief": tk.FLAT,
"font": ("Arial", 12), "padx": 16, "pady": 6,
"activebackground": "#1a2a5e"}
self.start_btn = tk.Button(ctrl_f, text="▶ 開始",
command=self._toggle_timer, **btn_style)
self.start_btn.pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="⏹ リセット",
command=self._reset_timer, **btn_style).pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="💾 保存",
command=self._save_log, **btn_style).pack(side=tk.LEFT, padx=6)
# プロジェクト・タスク選択
sel_f = tk.Frame(timer_f, bg="#16213e")
sel_f.pack(pady=4)
tk.Label(sel_f, text="プロジェクト:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=4)
self.proj_var = tk.StringVar()
self.proj_cb = ttk.Combobox(sel_f, textvariable=self.proj_var,
state="readonly", width=14)
self.proj_cb.pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="タスク:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.task_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.task_var, width=20).pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="メモ:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.notes_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.notes_var, width=20).pack(side=tk.LEFT)
# メインエリア
paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
# 左: ログ一覧
left = ttk.LabelFrame(paned, text="作業ログ", padding=4)
# フィルター
filter_f = tk.Frame(left, bg=left.cget("background"))
filter_f.pack(fill=tk.X, pady=2)
tk.Label(filter_f, text="プロジェクト:").pack(side=tk.LEFT)
self.log_proj_var = tk.StringVar(value="すべて")
self.log_proj_cb = ttk.Combobox(filter_f, textvariable=self.log_proj_var,
state="readonly", width=12)
self.log_proj_cb.pack(side=tk.LEFT, padx=4)
self.log_proj_cb.bind("<<ComboboxSelected>>", lambda e: self._load_logs())
cols = ("date", "project", "task", "duration", "notes")
self.log_tree = ttk.Treeview(left, columns=cols, show="headings", height=14)
for c, h, w in [("date", "日時", 130), ("project", "プロジェクト", 80),
("task", "タスク", 140), ("duration", "時間", 70),
("notes", "メモ", 120)]:
self.log_tree.heading(c, text=h)
self.log_tree.column(c, width=w, minwidth=40)
sb = ttk.Scrollbar(left, command=self.log_tree.yview)
self.log_tree.configure(yscrollcommand=sb.set)
sb.pack(side=tk.RIGHT, fill=tk.Y)
self.log_tree.pack(fill=tk.BOTH, expand=True)
ttk.Button(left, text="🗑️ 削除",
command=self._delete_log).pack(anchor="w", pady=2)
paned.add(left, weight=3)
# 右: 週次レポート
right = ttk.LabelFrame(paned, text="週次レポート", padding=8)
self.report_text = tk.Text(right, font=("Courier New", 10),
bg="#1e1e1e", fg="#c9d1d9",
relief=tk.FLAT, state=tk.DISABLED)
self.report_text.pack(fill=tk.BOTH, expand=True)
ttk.Button(right, text="🔄 レポート更新",
command=self._update_report).pack(pady=4)
paned.add(right, weight=2)
self.status_var = tk.StringVar(value="")
tk.Label(self.root, textvariable=self.status_var,
bg="#dde", font=("Arial", 9), anchor="w", padx=8
).pack(fill=tk.X, side=tk.BOTTOM)
def _load_projects(self):
rows = self.conn.execute(
"SELECT name FROM projects ORDER BY name").fetchall()
names = [r[0] for r in rows]
self.proj_cb.configure(values=names)
self.log_proj_cb.configure(values=["すべて"] + names)
if names:
self.proj_var.set(names[0])
def _toggle_timer(self):
if self.running:
self.running = False
self.elapsed += time.time() - self.start_time
self.start_btn.config(text="▶ 開始", bg="#0f3460")
if self._timer_id:
self.root.after_cancel(self._timer_id)
else:
self.running = True
self.start_time = time.time()
self.start_btn.config(text="⏸ 一時停止", bg="#c62828")
self._tick()
def _tick(self):
if self.running:
total = self.elapsed + (time.time() - self.start_time)
h = int(total // 3600)
m = int((total % 3600) // 60)
s = int(total % 60)
self.timer_label.config(text=f"{h:02d}:{m:02d}:{s:02d}")
self._timer_id = self.root.after(1000, self._tick)
def _reset_timer(self):
if self.running:
self._toggle_timer()
self.elapsed = 0
self.start_time = None
self.timer_label.config(text="00:00:00")
def _save_log(self):
if self.elapsed < 1:
messagebox.showwarning("警告", "タイマーを使用してください")
return
proj_name = self.proj_var.get()
if not proj_name:
messagebox.showwarning("警告", "プロジェクトを選択してください")
return
row = self.conn.execute(
"SELECT id FROM projects WHERE name=?", (proj_name,)).fetchone()
if not row:
return
proj_id = row[0]
duration = int(self.elapsed)
now = datetime.now()
start_dt = now - timedelta(seconds=duration)
self.conn.execute(
"INSERT INTO time_logs (project_id,task,start_time,end_time,"
"duration,notes) VALUES (?,?,?,?,?,?)",
(proj_id, self.task_var.get(),
start_dt.isoformat(), now.isoformat(),
duration, self.notes_var.get()))
self.conn.commit()
self._reset_timer()
self._load_logs()
self.status_var.set(
f"保存: {proj_name} - {self._fmt_dur(duration)}")
def _fmt_dur(self, seconds):
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:02d}"
def _load_logs(self):
proj_f = self.log_proj_var.get()
sql = ("SELECT l.id, l.start_time, p.name, l.task, l.duration, l.notes "
"FROM time_logs l "
"JOIN projects p ON l.project_id=p.id WHERE 1=1")
params = []
if proj_f != "すべて":
sql += " AND p.name=?"
params.append(proj_f)
sql += " ORDER BY l.start_time DESC LIMIT 100"
rows = self.conn.execute(sql, params).fetchall()
self.log_tree.delete(*self.log_tree.get_children())
for row in rows:
lid, start, proj, task, dur, notes = row
self.log_tree.insert("", "end", iid=str(lid),
values=(start[:16], proj, task or "",
self._fmt_dur(dur), notes or ""))
self._update_report()
def _delete_log(self):
sel = self.log_tree.selection()
if sel:
if messagebox.askyesno("確認", "削除しますか?"):
for item in sel:
self.conn.execute("DELETE FROM time_logs WHERE id=?",
(int(item),))
self.conn.commit()
self._load_logs()
def _update_report(self):
today = datetime.now().date()
week_start = today - timedelta(days=today.weekday())
rows = self.conn.execute(
"SELECT p.name, SUM(l.duration) FROM time_logs l "
"JOIN projects p ON l.project_id=p.id "
"WHERE l.start_time >= ? GROUP BY p.name ORDER BY SUM(l.duration) DESC",
(str(week_start),)).fetchall()
total = sum(r[1] for r in rows)
text = f"【週次レポート】{week_start} 〜 {today}\n"
text += f"{'='*36}\n"
for name, dur in rows:
pct = dur / total * 100 if total else 0
bar = "█" * int(pct / 5)
text += f"{name:<12} {self._fmt_dur(dur):>10} {pct:5.1f}% {bar}\n"
text += f"{'='*36}\n"
text += f"合計: {self._fmt_dur(total)}\n"
# 日別
text += "\n【日別ログ】\n"
daily = {}
for i in range(7):
d = str(week_start + timedelta(days=i))
r = self.conn.execute(
"SELECT COALESCE(SUM(duration),0) FROM time_logs "
"WHERE DATE(start_time)=?", (d,)).fetchone()
daily[d] = r[0]
text += f" {d}: {self._fmt_dur(r[0])}\n"
self.report_text.config(state=tk.NORMAL)
self.report_text.delete("1.0", tk.END)
self.report_text.insert("1.0", text)
self.report_text.config(state=tk.DISABLED)
def _on_close(self):
if self.running:
self._toggle_timer()
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = App24(root)
root.mainloop()
例外処理とmessagebox
try-except で ValueError と Exception を捕捉し、messagebox.showerror() でユーザーにわかりやすいエラーメッセージを表示します。入力バリデーションは必ず実装しましょう。
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
import time
import threading
from datetime import datetime, timedelta
class App24:
"""時間追跡アプリ"""
DB_PATH = os.path.join(os.path.dirname(__file__), "timelog.db")
def __init__(self, root):
self.root = root
self.root.title("時間追跡アプリ")
self.root.geometry("860x580")
self.root.configure(bg="#f8f9fc")
self.running = False
self.start_time = None
self.elapsed = 0
self._timer_id = None
self._init_db()
self._build_ui()
self._load_projects()
self._load_logs()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _init_db(self):
self.conn = sqlite3.connect(self.DB_PATH)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
color TEXT DEFAULT '#3776ab'
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS time_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
task TEXT,
start_time TEXT,
end_time TEXT,
duration INTEGER,
notes TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id)
)
""")
self.conn.commit()
if not self.conn.execute("SELECT 1 FROM projects").fetchone():
for name in ["開発", "デザイン", "会議", "学習", "その他"]:
self.conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
self.conn.commit()
def _build_ui(self):
title_frame = tk.Frame(self.root, bg="#0f3460", pady=10)
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="⏱️ 時間追跡アプリ",
font=("Noto Sans JP", 15, "bold"),
bg="#0f3460", fg="white").pack(side=tk.LEFT, padx=12)
# タイマーパネル
timer_f = tk.Frame(self.root, bg="#16213e", pady=16)
timer_f.pack(fill=tk.X, padx=8, pady=8)
self.timer_label = tk.Label(timer_f, text="00:00:00",
font=("Courier New", 36, "bold"),
bg="#16213e", fg="#e2b96f")
self.timer_label.pack()
ctrl_f = tk.Frame(timer_f, bg="#16213e")
ctrl_f.pack(pady=8)
btn_style = {"bg": "#0f3460", "fg": "#e2b96f", "relief": tk.FLAT,
"font": ("Arial", 12), "padx": 16, "pady": 6,
"activebackground": "#1a2a5e"}
self.start_btn = tk.Button(ctrl_f, text="▶ 開始",
command=self._toggle_timer, **btn_style)
self.start_btn.pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="⏹ リセット",
command=self._reset_timer, **btn_style).pack(side=tk.LEFT, padx=6)
tk.Button(ctrl_f, text="💾 保存",
command=self._save_log, **btn_style).pack(side=tk.LEFT, padx=6)
# プロジェクト・タスク選択
sel_f = tk.Frame(timer_f, bg="#16213e")
sel_f.pack(pady=4)
tk.Label(sel_f, text="プロジェクト:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=4)
self.proj_var = tk.StringVar()
self.proj_cb = ttk.Combobox(sel_f, textvariable=self.proj_var,
state="readonly", width=14)
self.proj_cb.pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="タスク:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.task_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.task_var, width=20).pack(side=tk.LEFT, padx=4)
tk.Label(sel_f, text="メモ:",
bg="#16213e", fg="#ccc").pack(side=tk.LEFT, padx=(12, 4))
self.notes_var = tk.StringVar()
ttk.Entry(sel_f, textvariable=self.notes_var, width=20).pack(side=tk.LEFT)
# メインエリア
paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
# 左: ログ一覧
left = ttk.LabelFrame(paned, text="作業ログ", padding=4)
# フィルター
filter_f = tk.Frame(left, bg=left.cget("background"))
filter_f.pack(fill=tk.X, pady=2)
tk.Label(filter_f, text="プロジェクト:").pack(side=tk.LEFT)
self.log_proj_var = tk.StringVar(value="すべて")
self.log_proj_cb = ttk.Combobox(filter_f, textvariable=self.log_proj_var,
state="readonly", width=12)
self.log_proj_cb.pack(side=tk.LEFT, padx=4)
self.log_proj_cb.bind("<<ComboboxSelected>>", lambda e: self._load_logs())
cols = ("date", "project", "task", "duration", "notes")
self.log_tree = ttk.Treeview(left, columns=cols, show="headings", height=14)
for c, h, w in [("date", "日時", 130), ("project", "プロジェクト", 80),
("task", "タスク", 140), ("duration", "時間", 70),
("notes", "メモ", 120)]:
self.log_tree.heading(c, text=h)
self.log_tree.column(c, width=w, minwidth=40)
sb = ttk.Scrollbar(left, command=self.log_tree.yview)
self.log_tree.configure(yscrollcommand=sb.set)
sb.pack(side=tk.RIGHT, fill=tk.Y)
self.log_tree.pack(fill=tk.BOTH, expand=True)
ttk.Button(left, text="🗑️ 削除",
command=self._delete_log).pack(anchor="w", pady=2)
paned.add(left, weight=3)
# 右: 週次レポート
right = ttk.LabelFrame(paned, text="週次レポート", padding=8)
self.report_text = tk.Text(right, font=("Courier New", 10),
bg="#1e1e1e", fg="#c9d1d9",
relief=tk.FLAT, state=tk.DISABLED)
self.report_text.pack(fill=tk.BOTH, expand=True)
ttk.Button(right, text="🔄 レポート更新",
command=self._update_report).pack(pady=4)
paned.add(right, weight=2)
self.status_var = tk.StringVar(value="")
tk.Label(self.root, textvariable=self.status_var,
bg="#dde", font=("Arial", 9), anchor="w", padx=8
).pack(fill=tk.X, side=tk.BOTTOM)
def _load_projects(self):
rows = self.conn.execute(
"SELECT name FROM projects ORDER BY name").fetchall()
names = [r[0] for r in rows]
self.proj_cb.configure(values=names)
self.log_proj_cb.configure(values=["すべて"] + names)
if names:
self.proj_var.set(names[0])
def _toggle_timer(self):
if self.running:
self.running = False
self.elapsed += time.time() - self.start_time
self.start_btn.config(text="▶ 開始", bg="#0f3460")
if self._timer_id:
self.root.after_cancel(self._timer_id)
else:
self.running = True
self.start_time = time.time()
self.start_btn.config(text="⏸ 一時停止", bg="#c62828")
self._tick()
def _tick(self):
if self.running:
total = self.elapsed + (time.time() - self.start_time)
h = int(total // 3600)
m = int((total % 3600) // 60)
s = int(total % 60)
self.timer_label.config(text=f"{h:02d}:{m:02d}:{s:02d}")
self._timer_id = self.root.after(1000, self._tick)
def _reset_timer(self):
if self.running:
self._toggle_timer()
self.elapsed = 0
self.start_time = None
self.timer_label.config(text="00:00:00")
def _save_log(self):
if self.elapsed < 1:
messagebox.showwarning("警告", "タイマーを使用してください")
return
proj_name = self.proj_var.get()
if not proj_name:
messagebox.showwarning("警告", "プロジェクトを選択してください")
return
row = self.conn.execute(
"SELECT id FROM projects WHERE name=?", (proj_name,)).fetchone()
if not row:
return
proj_id = row[0]
duration = int(self.elapsed)
now = datetime.now()
start_dt = now - timedelta(seconds=duration)
self.conn.execute(
"INSERT INTO time_logs (project_id,task,start_time,end_time,"
"duration,notes) VALUES (?,?,?,?,?,?)",
(proj_id, self.task_var.get(),
start_dt.isoformat(), now.isoformat(),
duration, self.notes_var.get()))
self.conn.commit()
self._reset_timer()
self._load_logs()
self.status_var.set(
f"保存: {proj_name} - {self._fmt_dur(duration)}")
def _fmt_dur(self, seconds):
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:02d}"
def _load_logs(self):
proj_f = self.log_proj_var.get()
sql = ("SELECT l.id, l.start_time, p.name, l.task, l.duration, l.notes "
"FROM time_logs l "
"JOIN projects p ON l.project_id=p.id WHERE 1=1")
params = []
if proj_f != "すべて":
sql += " AND p.name=?"
params.append(proj_f)
sql += " ORDER BY l.start_time DESC LIMIT 100"
rows = self.conn.execute(sql, params).fetchall()
self.log_tree.delete(*self.log_tree.get_children())
for row in rows:
lid, start, proj, task, dur, notes = row
self.log_tree.insert("", "end", iid=str(lid),
values=(start[:16], proj, task or "",
self._fmt_dur(dur), notes or ""))
self._update_report()
def _delete_log(self):
sel = self.log_tree.selection()
if sel:
if messagebox.askyesno("確認", "削除しますか?"):
for item in sel:
self.conn.execute("DELETE FROM time_logs WHERE id=?",
(int(item),))
self.conn.commit()
self._load_logs()
def _update_report(self):
today = datetime.now().date()
week_start = today - timedelta(days=today.weekday())
rows = self.conn.execute(
"SELECT p.name, SUM(l.duration) FROM time_logs l "
"JOIN projects p ON l.project_id=p.id "
"WHERE l.start_time >= ? GROUP BY p.name ORDER BY SUM(l.duration) DESC",
(str(week_start),)).fetchall()
total = sum(r[1] for r in rows)
text = f"【週次レポート】{week_start} 〜 {today}\n"
text += f"{'='*36}\n"
for name, dur in rows:
pct = dur / total * 100 if total else 0
bar = "█" * int(pct / 5)
text += f"{name:<12} {self._fmt_dur(dur):>10} {pct:5.1f}% {bar}\n"
text += f"{'='*36}\n"
text += f"合計: {self._fmt_dur(total)}\n"
# 日別
text += "\n【日別ログ】\n"
daily = {}
for i in range(7):
d = str(week_start + timedelta(days=i))
r = self.conn.execute(
"SELECT COALESCE(SUM(duration),0) FROM time_logs "
"WHERE DATE(start_time)=?", (d,)).fetchone()
daily[d] = r[0]
text += f" {d}: {self._fmt_dur(r[0])}\n"
self.report_text.config(state=tk.NORMAL)
self.report_text.delete("1.0", tk.END)
self.report_text.insert("1.0", text)
self.report_text.config(state=tk.DISABLED)
def _on_close(self):
if self.running:
self._toggle_timer()
self.root.destroy()
if __name__ == "__main__":
root = tk.Tk()
app = App24(root)
root.mainloop()
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app24.py と保存します。
-
2クラスの骨格を作る
App24クラスを定義し、__init__とmainloop()の最小構成を作ります。
-
3タイトルバーを作る
Frameを使ってカラーバー付きのタイトルエリアを作ります。
-
4入力フォームを実装する
LabelFrameとEntryウィジェットで入力エリアを作ります。
-
5処理ロジックを実装する
_calculate()メソッドに計算・処理ロジックを実装します。
-
6結果表示を実装する
TextウィジェットかLabelに結果を表示する_show_result()を実装します。
-
7エラー処理を追加する
try-exceptとmessageboxでエラーハンドリングを追加します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 データのエクスポート機能
計算結果をCSV・TXTファイルに保存するエクスポート機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。
💡 入力履歴機能
以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。
8. よくある問題と解決法
❌ 日本語フォントが表示されない
原因:システムに日本語フォントが見つからない場合があります。
解決法:font引数を省略するかシステムに合ったフォントを指定してください。
❌ ウィンドウのサイズが変更できない
原因:resizable(False, False)が設定されています。
解決法:resizable(True, True)に変更してください。
9. 練習問題
アプリの理解を深めるための練習問題です。難易度順に挑戦してみてください。
-
課題1:機能拡張
時間追跡アプリに新しい機能を1つ追加してみましょう。どんな機能があると便利か考えてから実装してください。
-
課題2:UIの改善
色・フォント・レイアウトを変更して、より使いやすいUIにカスタマイズしてみましょう。
-
課題3:保存機能の追加
入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。