task-recorder-cui 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- task_recorder_cui/__init__.py +3 -0
- task_recorder_cui/__main__.py +5 -0
- task_recorder_cui/cli.py +162 -0
- task_recorder_cui/commands/__init__.py +0 -0
- task_recorder_cui/commands/_summary.py +246 -0
- task_recorder_cui/commands/add.py +63 -0
- task_recorder_cui/commands/all.py +37 -0
- task_recorder_cui/commands/cat.py +172 -0
- task_recorder_cui/commands/month.py +56 -0
- task_recorder_cui/commands/now.py +31 -0
- task_recorder_cui/commands/range.py +53 -0
- task_recorder_cui/commands/start.py +69 -0
- task_recorder_cui/commands/stop.py +38 -0
- task_recorder_cui/commands/today.py +71 -0
- task_recorder_cui/commands/week.py +57 -0
- task_recorder_cui/db.py +118 -0
- task_recorder_cui/io.py +33 -0
- task_recorder_cui/main.py +9 -0
- task_recorder_cui/menu.py +344 -0
- task_recorder_cui/models.py +51 -0
- task_recorder_cui/repo.py +252 -0
- task_recorder_cui/utils/__init__.py +0 -0
- task_recorder_cui/utils/time.py +122 -0
- task_recorder_cui/utils/validate.py +23 -0
- task_recorder_cui-1.0.0.dist-info/METADATA +207 -0
- task_recorder_cui-1.0.0.dist-info/RECORD +30 -0
- task_recorder_cui-1.0.0.dist-info/WHEEL +5 -0
- task_recorder_cui-1.0.0.dist-info/entry_points.txt +2 -0
- task_recorder_cui-1.0.0.dist-info/licenses/LICENSE +21 -0
- task_recorder_cui-1.0.0.dist-info/top_level.txt +1 -0
task_recorder_cui/cli.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""argparse ベースの CLI エントリポイント。
|
|
2
|
+
|
|
3
|
+
本モジュールはサブコマンドの受け口 (parser) と main ディスパッチャを持つ。
|
|
4
|
+
記録系 (start/stop/now/add)、参照系 (today/week/month/range/all)、カテゴリ管理
|
|
5
|
+
(cat ...) は commands/ 配下の実装に dispatch する。インタラクティブメニューは
|
|
6
|
+
`menu.run()` に dispatch する。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
|
|
11
|
+
from task_recorder_cui import __version__
|
|
12
|
+
from task_recorder_cui.commands import add as add_cmd
|
|
13
|
+
from task_recorder_cui.commands import all as all_cmd
|
|
14
|
+
from task_recorder_cui.commands import cat as cat_cmd
|
|
15
|
+
from task_recorder_cui.commands import month as month_cmd
|
|
16
|
+
from task_recorder_cui.commands import now as now_cmd
|
|
17
|
+
from task_recorder_cui.commands import range as range_cmd
|
|
18
|
+
from task_recorder_cui.commands import start as start_cmd
|
|
19
|
+
from task_recorder_cui.commands import stop as stop_cmd
|
|
20
|
+
from task_recorder_cui.commands import today as today_cmd
|
|
21
|
+
from task_recorder_cui.commands import week as week_cmd
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
"""tsk の引数パーサを構築する。
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
サブコマンドを含む設定済み ArgumentParser。
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="tsk",
|
|
33
|
+
description="日々の時間の使い方を記録して、週平均・月平均で可視化するCUIツール",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--version",
|
|
37
|
+
action="version",
|
|
38
|
+
version=f"tsk {__version__}",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
42
|
+
|
|
43
|
+
# --- 記録系 ---
|
|
44
|
+
p_start = sub.add_parser("start", help="新しいセッションを開始")
|
|
45
|
+
p_start.add_argument("category_key", help="カテゴリキー (例: game, study, dev)")
|
|
46
|
+
p_start.add_argument("description", nargs="?", default=None, help="活動内容 (任意)")
|
|
47
|
+
|
|
48
|
+
sub.add_parser("stop", help="記録中のセッションを終了")
|
|
49
|
+
|
|
50
|
+
p_add = sub.add_parser("add", help="事後に手動で追加")
|
|
51
|
+
p_add.add_argument("category_key", help="カテゴリキー")
|
|
52
|
+
p_add.add_argument("minutes", type=int, help="記録する分数")
|
|
53
|
+
p_add.add_argument("description", nargs="?", default=None, help="活動内容 (任意)")
|
|
54
|
+
|
|
55
|
+
sub.add_parser("now", help="記録中のセッションを表示")
|
|
56
|
+
|
|
57
|
+
# --- 参照系 ---
|
|
58
|
+
sub.add_parser("today", help="今日の記録一覧")
|
|
59
|
+
p_week = sub.add_parser("week", help="直近7日 (default) または今週 (--calendar)")
|
|
60
|
+
p_week.add_argument(
|
|
61
|
+
"--calendar",
|
|
62
|
+
action="store_true",
|
|
63
|
+
help="月曜〜今日の今週集計にする",
|
|
64
|
+
)
|
|
65
|
+
p_month = sub.add_parser("month", help="直近30日 (default) または今月 (--calendar)")
|
|
66
|
+
p_month.add_argument(
|
|
67
|
+
"--calendar",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="今月1日〜今日の集計にする",
|
|
70
|
+
)
|
|
71
|
+
p_range = sub.add_parser("range", help="任意期間の集計")
|
|
72
|
+
p_range.add_argument("--from", dest="from_date", required=True, help="開始日 YYYY-MM-DD")
|
|
73
|
+
p_range.add_argument(
|
|
74
|
+
"--to", dest="to_date", required=True, help="終了日 YYYY-MM-DD (inclusive)"
|
|
75
|
+
)
|
|
76
|
+
sub.add_parser("all", help="全累計")
|
|
77
|
+
|
|
78
|
+
# --- カテゴリ管理 ---
|
|
79
|
+
p_cat = sub.add_parser("cat", help="カテゴリ管理")
|
|
80
|
+
cat_sub = p_cat.add_subparsers(dest="cat_action", metavar="<action>", required=True)
|
|
81
|
+
|
|
82
|
+
p_cat_list = cat_sub.add_parser("list", help="カテゴリ一覧")
|
|
83
|
+
list_filter = p_cat_list.add_mutually_exclusive_group()
|
|
84
|
+
list_filter.add_argument(
|
|
85
|
+
"--active", action="store_true", help="archived でないカテゴリだけ表示"
|
|
86
|
+
)
|
|
87
|
+
list_filter.add_argument("--archived", action="store_true", help="archived のカテゴリだけ表示")
|
|
88
|
+
|
|
89
|
+
p_cat_add = cat_sub.add_parser(
|
|
90
|
+
"add", help="カテゴリを追加 (archived同名があれば復帰+表示名上書き)"
|
|
91
|
+
)
|
|
92
|
+
p_cat_add.add_argument("key", help="新しいカテゴリキー")
|
|
93
|
+
p_cat_add.add_argument("display_name", help="表示名")
|
|
94
|
+
p_cat_remove = cat_sub.add_parser("remove", help="カテゴリをアーカイブ")
|
|
95
|
+
p_cat_remove.add_argument("key", help="アーカイブするカテゴリキー")
|
|
96
|
+
p_cat_restore = cat_sub.add_parser("restore", help="アーカイブから復帰")
|
|
97
|
+
p_cat_restore.add_argument("key", help="復帰させるカテゴリキー")
|
|
98
|
+
p_cat_rename = cat_sub.add_parser("rename", help="表示名を変更")
|
|
99
|
+
p_cat_rename.add_argument("key", help="変更するカテゴリキー")
|
|
100
|
+
p_cat_rename.add_argument("new_display_name", help="新しい表示名")
|
|
101
|
+
|
|
102
|
+
return parser
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv: list[str] | None = None) -> int:
|
|
106
|
+
"""CLI エントリポイント。
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
argv: 引数リスト。Noneの場合は sys.argv[1:] が使われる。
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
終了コード (0: 成功)。
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
SystemExit: --help / --version 表示時、または usage error 時に
|
|
116
|
+
argparse が送出する。正常終了は code=0、usage error は code=2。
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
parser = build_parser()
|
|
120
|
+
args = parser.parse_args(argv)
|
|
121
|
+
|
|
122
|
+
if args.command is None:
|
|
123
|
+
from task_recorder_cui.menu import run as menu_run
|
|
124
|
+
|
|
125
|
+
return menu_run()
|
|
126
|
+
|
|
127
|
+
# --- 記録系 (Phase 3) ---
|
|
128
|
+
if args.command == "start":
|
|
129
|
+
return start_cmd.run(args.category_key, args.description)
|
|
130
|
+
if args.command == "stop":
|
|
131
|
+
return stop_cmd.run()
|
|
132
|
+
if args.command == "now":
|
|
133
|
+
return now_cmd.run()
|
|
134
|
+
if args.command == "add":
|
|
135
|
+
return add_cmd.run(args.category_key, args.minutes, args.description)
|
|
136
|
+
|
|
137
|
+
# --- 参照系 (Phase 4) ---
|
|
138
|
+
if args.command == "today":
|
|
139
|
+
return today_cmd.run()
|
|
140
|
+
if args.command == "week":
|
|
141
|
+
return week_cmd.run(calendar=args.calendar)
|
|
142
|
+
if args.command == "month":
|
|
143
|
+
return month_cmd.run(calendar=args.calendar)
|
|
144
|
+
if args.command == "range":
|
|
145
|
+
return range_cmd.run(args.from_date, args.to_date)
|
|
146
|
+
if args.command == "all":
|
|
147
|
+
return all_cmd.run()
|
|
148
|
+
|
|
149
|
+
# --- カテゴリ管理 (Phase 5) ---
|
|
150
|
+
if args.command == "cat":
|
|
151
|
+
if args.cat_action == "list":
|
|
152
|
+
return cat_cmd.list_categories(active_only=args.active, archived_only=args.archived)
|
|
153
|
+
if args.cat_action == "add":
|
|
154
|
+
return cat_cmd.add_category(args.key, args.display_name)
|
|
155
|
+
if args.cat_action == "remove":
|
|
156
|
+
return cat_cmd.remove_category(args.key)
|
|
157
|
+
if args.cat_action == "restore":
|
|
158
|
+
return cat_cmd.restore_category(args.key)
|
|
159
|
+
if args.cat_action == "rename":
|
|
160
|
+
return cat_cmd.rename_category(args.key, args.new_display_name)
|
|
161
|
+
|
|
162
|
+
return 0
|
|
File without changes
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""集計系コマンド (today/week/month/range/all) の共通ロジック。
|
|
2
|
+
|
|
3
|
+
SQLite からレコードを取得し、ローカル日付でグルーピングして日別×カテゴリの
|
|
4
|
+
集計を行う。表示は rich.Table を使って CJK 幅のズレを吸収する。
|
|
5
|
+
|
|
6
|
+
仕様:
|
|
7
|
+
- 日付境界はシステムのローカルタイムゾーンにおける 00:00〜翌00:00
|
|
8
|
+
- 日またぎレコードは started_at の日に計上 (分割しない)
|
|
9
|
+
- 記録中セッションは started_at が期間内のとき、現在時刻までの経過分を計上
|
|
10
|
+
- display_name は常に現在の categories テーブルから引く (rename 即時反映)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sqlite3
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import UTC, date, datetime, time, timedelta
|
|
17
|
+
|
|
18
|
+
from rich.box import SIMPLE
|
|
19
|
+
from rich.markup import escape
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
from task_recorder_cui.io import print_line, print_table
|
|
23
|
+
from task_recorder_cui.repo import find_active_record, row_to_record
|
|
24
|
+
from task_recorder_cui.utils.time import format_duration, now_utc, to_iso
|
|
25
|
+
|
|
26
|
+
WEEKDAY_EN: list[str] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class DayTotal:
|
|
31
|
+
"""1日分のカテゴリ別集計。
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
local_date: 対象のローカル日付。
|
|
35
|
+
per_category_minutes: カテゴリkey → 分数。
|
|
36
|
+
total_minutes: 全カテゴリ合計分。
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
local_date: date
|
|
41
|
+
per_category_minutes: dict[str, int]
|
|
42
|
+
total_minutes: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PeriodSummary:
|
|
47
|
+
"""期間全体の集計結果。
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
start_local: 集計期間の開始日 (inclusive)。
|
|
51
|
+
end_local: 集計期間の終了日 (inclusive)。
|
|
52
|
+
days: レコードがあった日の DayTotal リスト (昇順)。
|
|
53
|
+
per_category_minutes: カテゴリkey → 期間内分数合計。
|
|
54
|
+
total_minutes: 全カテゴリの期間内合計分。
|
|
55
|
+
display_names: カテゴリkey → display_name (現在の値)。
|
|
56
|
+
active_partial_minutes: 記録中セッションから計上された分数 (0 なら含まない)。
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
start_local: date
|
|
61
|
+
end_local: date
|
|
62
|
+
days: list[DayTotal]
|
|
63
|
+
per_category_minutes: dict[str, int]
|
|
64
|
+
total_minutes: int
|
|
65
|
+
display_names: dict[str, str]
|
|
66
|
+
active_partial_minutes: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def today_local() -> date:
|
|
70
|
+
"""システムのローカルタイムゾーンにおける今日の日付を返す。"""
|
|
71
|
+
return datetime.now().astimezone().date()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _to_local_date(dt: datetime) -> date:
|
|
75
|
+
"""tz付き datetime をローカル日付に変換する。"""
|
|
76
|
+
return dt.astimezone().date()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def period_bounds_utc(start_local_date: date, end_local_date: date) -> tuple[datetime, datetime]:
|
|
80
|
+
"""ローカル日付範囲を UTC の半開区間 [start_utc, end_next_utc) に変換。"""
|
|
81
|
+
start_utc = datetime.combine(start_local_date, time.min).astimezone().astimezone(UTC)
|
|
82
|
+
end_next_utc = (
|
|
83
|
+
datetime.combine(end_local_date + timedelta(days=1), time.min).astimezone().astimezone(UTC)
|
|
84
|
+
)
|
|
85
|
+
return start_utc, end_next_utc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def aggregate_period(
|
|
89
|
+
conn: sqlite3.Connection,
|
|
90
|
+
start_local_date: date,
|
|
91
|
+
end_local_date: date,
|
|
92
|
+
*,
|
|
93
|
+
include_active: bool = True,
|
|
94
|
+
) -> PeriodSummary:
|
|
95
|
+
"""指定ローカル日付区間 [start, end] の集計を返す (両端inclusive)。
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
conn: DB接続。
|
|
99
|
+
start_local_date: 集計開始日 (local)。
|
|
100
|
+
end_local_date: 集計終了日 (local, inclusive)。
|
|
101
|
+
include_active: 記録中セッションを期間に含めるか。
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
PeriodSummary。レコードが1件もなければ days=[]。
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
start_utc, end_next_utc = period_bounds_utc(start_local_date, end_local_date)
|
|
108
|
+
|
|
109
|
+
rows = conn.execute(
|
|
110
|
+
"SELECT * FROM records WHERE started_at >= ? AND started_at < ? ORDER BY started_at",
|
|
111
|
+
(to_iso(start_utc), to_iso(end_next_utc)),
|
|
112
|
+
).fetchall()
|
|
113
|
+
records = [row_to_record(r) for r in rows]
|
|
114
|
+
|
|
115
|
+
if include_active:
|
|
116
|
+
active = find_active_record(conn)
|
|
117
|
+
if (
|
|
118
|
+
active is not None
|
|
119
|
+
and start_utc <= active.started_at < end_next_utc
|
|
120
|
+
and all(r.id != active.id for r in records)
|
|
121
|
+
):
|
|
122
|
+
records.append(active)
|
|
123
|
+
|
|
124
|
+
now = now_utc()
|
|
125
|
+
day_buckets: dict[date, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
126
|
+
per_category: dict[str, int] = defaultdict(int)
|
|
127
|
+
active_partial_minutes = 0
|
|
128
|
+
|
|
129
|
+
for rec in records:
|
|
130
|
+
local_d = _to_local_date(rec.started_at)
|
|
131
|
+
if rec.duration_minutes is not None:
|
|
132
|
+
minutes = rec.duration_minutes
|
|
133
|
+
else:
|
|
134
|
+
minutes = max(0, int((now - rec.started_at).total_seconds() / 60))
|
|
135
|
+
active_partial_minutes += minutes
|
|
136
|
+
day_buckets[local_d][rec.category_key] += minutes
|
|
137
|
+
per_category[rec.category_key] += minutes
|
|
138
|
+
|
|
139
|
+
days = [
|
|
140
|
+
DayTotal(
|
|
141
|
+
local_date=d,
|
|
142
|
+
per_category_minutes=dict(buckets),
|
|
143
|
+
total_minutes=sum(buckets.values()),
|
|
144
|
+
)
|
|
145
|
+
for d, buckets in sorted(day_buckets.items())
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
category_keys = list(per_category.keys())
|
|
149
|
+
display_names: dict[str, str] = {}
|
|
150
|
+
if category_keys:
|
|
151
|
+
placeholders = ",".join("?" for _ in category_keys)
|
|
152
|
+
category_rows = conn.execute(
|
|
153
|
+
f"SELECT key, display_name FROM categories WHERE key IN ({placeholders})",
|
|
154
|
+
category_keys,
|
|
155
|
+
).fetchall()
|
|
156
|
+
display_names = {row["key"]: row["display_name"] for row in category_rows}
|
|
157
|
+
for key in category_keys:
|
|
158
|
+
display_names.setdefault(key, key)
|
|
159
|
+
|
|
160
|
+
return PeriodSummary(
|
|
161
|
+
start_local=start_local_date,
|
|
162
|
+
end_local=end_local_date,
|
|
163
|
+
days=days,
|
|
164
|
+
per_category_minutes=dict(per_category),
|
|
165
|
+
total_minutes=sum(per_category.values()),
|
|
166
|
+
display_names=display_names,
|
|
167
|
+
active_partial_minutes=active_partial_minutes,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _sorted_category_keys(summary: PeriodSummary) -> list[str]:
|
|
172
|
+
"""合計分数の降順でカテゴリキーを並べる (同点は key 昇順)。"""
|
|
173
|
+
return sorted(
|
|
174
|
+
summary.per_category_minutes.keys(),
|
|
175
|
+
key=lambda k: (-summary.per_category_minutes[k], k),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def render_breakdown_table(summary: PeriodSummary, title: str) -> Table:
|
|
180
|
+
"""日別×カテゴリの内訳テーブルを組み立てる。
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
summary: 集計結果。
|
|
184
|
+
title: テーブルタイトル。
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
そのまま print_table() に渡せる Table。
|
|
188
|
+
|
|
189
|
+
"""
|
|
190
|
+
keys = _sorted_category_keys(summary)
|
|
191
|
+
table = Table(title=title, box=SIMPLE, show_edge=True, pad_edge=False)
|
|
192
|
+
table.add_column("日付", justify="left", no_wrap=True)
|
|
193
|
+
for key in keys:
|
|
194
|
+
table.add_column(escape(summary.display_names.get(key, key)), justify="right")
|
|
195
|
+
table.add_column("合計", justify="right", style="bold")
|
|
196
|
+
|
|
197
|
+
for day in summary.days:
|
|
198
|
+
weekday = WEEKDAY_EN[day.local_date.weekday()]
|
|
199
|
+
date_cell = f"{day.local_date.strftime('%m-%d')} {weekday}"
|
|
200
|
+
row = [date_cell]
|
|
201
|
+
for key in keys:
|
|
202
|
+
minutes = day.per_category_minutes.get(key, 0)
|
|
203
|
+
row.append(format_duration(minutes) if minutes > 0 else "")
|
|
204
|
+
row.append(format_duration(day.total_minutes))
|
|
205
|
+
table.add_row(*row)
|
|
206
|
+
|
|
207
|
+
return table
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def render_category_totals(summary: PeriodSummary, *, with_daily_avg: bool = False) -> None:
|
|
211
|
+
"""カテゴリ別合計 (降順) を表示する。
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
summary: 集計結果。
|
|
215
|
+
with_daily_avg: True のとき "/ 日平均 1h30m" を付記する (week/month/range用)。
|
|
216
|
+
|
|
217
|
+
"""
|
|
218
|
+
total = summary.total_minutes
|
|
219
|
+
if not summary.per_category_minutes:
|
|
220
|
+
print_line("記録なし")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
day_count = (summary.end_local - summary.start_local).days + 1
|
|
224
|
+
keys = _sorted_category_keys(summary)
|
|
225
|
+
|
|
226
|
+
grid = Table.grid(padding=(0, 1))
|
|
227
|
+
grid.add_column() # label:
|
|
228
|
+
grid.add_column(justify="right") # duration
|
|
229
|
+
grid.add_column() # percentage
|
|
230
|
+
if with_daily_avg:
|
|
231
|
+
grid.add_column() # daily avg
|
|
232
|
+
|
|
233
|
+
for key in keys:
|
|
234
|
+
minutes = summary.per_category_minutes[key]
|
|
235
|
+
name = summary.display_names.get(key, key)
|
|
236
|
+
pct = round(minutes * 100 / total) if total > 0 else 0
|
|
237
|
+
label = f"{escape(name)}:"
|
|
238
|
+
duration = format_duration(minutes)
|
|
239
|
+
percent = f"({pct}%)"
|
|
240
|
+
if with_daily_avg:
|
|
241
|
+
avg = minutes // day_count if day_count > 0 else 0
|
|
242
|
+
grid.add_row(label, duration, percent, f"/ 日平均 {format_duration(avg)}")
|
|
243
|
+
else:
|
|
244
|
+
grid.add_row(label, duration, percent)
|
|
245
|
+
|
|
246
|
+
print_table(grid)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""tsk add: 事後に手動で時間記録を追加する。"""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from rich.markup import escape
|
|
6
|
+
|
|
7
|
+
from task_recorder_cui.db import open_db
|
|
8
|
+
from task_recorder_cui.io import print_error, print_line
|
|
9
|
+
from task_recorder_cui.repo import find_category, insert_record
|
|
10
|
+
from task_recorder_cui.utils.time import format_duration, now_utc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(category_key: str, minutes: int, description: str | None) -> int:
|
|
14
|
+
"""started_at = 現在 - minutes, ended_at = 現在 として1レコードを挿入する。
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
category_key: 対象カテゴリのkey。
|
|
18
|
+
minutes: 記録する分数 (1以上の整数)。
|
|
19
|
+
description: 活動内容 (任意)。
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
0: 追加成功 / 1: 未登録カテゴリ、または不正な minutes。
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
if minutes <= 0:
|
|
26
|
+
print_error(f"minutes は1以上である必要があります: {minutes}")
|
|
27
|
+
return 1
|
|
28
|
+
|
|
29
|
+
with open_db() as conn:
|
|
30
|
+
category = find_category(conn, category_key)
|
|
31
|
+
if category is None:
|
|
32
|
+
print_error(
|
|
33
|
+
f"カテゴリ '{category_key}' が存在しません。`tsk cat list` で一覧を確認してください"
|
|
34
|
+
)
|
|
35
|
+
return 1
|
|
36
|
+
if category.archived:
|
|
37
|
+
print_error(
|
|
38
|
+
f"カテゴリ '{category_key}' はアーカイブ済みです。 "
|
|
39
|
+
f"`tsk cat restore {category_key}` または "
|
|
40
|
+
f"`tsk cat add {category_key} <display_name>` で復帰させてください"
|
|
41
|
+
)
|
|
42
|
+
return 1
|
|
43
|
+
ended_at = now_utc()
|
|
44
|
+
started_at = ended_at - timedelta(minutes=minutes)
|
|
45
|
+
with conn:
|
|
46
|
+
insert_record(
|
|
47
|
+
conn,
|
|
48
|
+
category_key=category_key,
|
|
49
|
+
description=description,
|
|
50
|
+
started_at=started_at,
|
|
51
|
+
ended_at=ended_at,
|
|
52
|
+
duration_minutes=minutes,
|
|
53
|
+
)
|
|
54
|
+
display_name = category.display_name
|
|
55
|
+
|
|
56
|
+
started_hm = started_at.astimezone().strftime("%H:%M")
|
|
57
|
+
ended_hm = ended_at.astimezone().strftime("%H:%M")
|
|
58
|
+
detail = f" {escape(description)}" if description else ""
|
|
59
|
+
print_line(
|
|
60
|
+
f"追加: [{escape(display_name)}]{detail}"
|
|
61
|
+
f" ({started_hm}-{ended_hm}, {format_duration(minutes)})"
|
|
62
|
+
)
|
|
63
|
+
return 0
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""tsk all: 全累計。"""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
from task_recorder_cui.commands._summary import (
|
|
6
|
+
aggregate_period,
|
|
7
|
+
render_category_totals,
|
|
8
|
+
today_local,
|
|
9
|
+
)
|
|
10
|
+
from task_recorder_cui.db import open_db
|
|
11
|
+
from task_recorder_cui.io import print_line
|
|
12
|
+
from task_recorder_cui.utils.time import format_duration, from_iso
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run() -> int:
|
|
16
|
+
"""最古の記録日から今日までの全累計を表示する (日別内訳は出さない)。
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
常に 0。
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
today = today_local()
|
|
23
|
+
|
|
24
|
+
with open_db() as conn:
|
|
25
|
+
row = conn.execute("SELECT MIN(started_at) AS earliest FROM records").fetchone()
|
|
26
|
+
if row is None or row["earliest"] is None:
|
|
27
|
+
print_line("全累計")
|
|
28
|
+
print_line("記録なし")
|
|
29
|
+
return 0
|
|
30
|
+
start: date = from_iso(row["earliest"]).astimezone().date()
|
|
31
|
+
summary = aggregate_period(conn, start, today)
|
|
32
|
+
|
|
33
|
+
title = f"全累計 ({start.isoformat()} 以降)"
|
|
34
|
+
print_line(title)
|
|
35
|
+
print_line(f"合計: {format_duration(summary.total_minutes)}")
|
|
36
|
+
render_category_totals(summary, with_daily_avg=True)
|
|
37
|
+
return 0
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""tsk cat サブコマンド群 (list/add/remove/restore/rename)。
|
|
2
|
+
|
|
3
|
+
各関数は cli.py から `args.cat_action` に応じて呼び出される。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from rich.box import SIMPLE
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from task_recorder_cui.db import open_db
|
|
11
|
+
from task_recorder_cui.io import print_error, print_line, print_table
|
|
12
|
+
from task_recorder_cui.repo import (
|
|
13
|
+
find_category,
|
|
14
|
+
insert_category,
|
|
15
|
+
list_all_categories,
|
|
16
|
+
update_category_archived,
|
|
17
|
+
update_category_display_name,
|
|
18
|
+
)
|
|
19
|
+
from task_recorder_cui.utils.validate import validate_category_key
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_categories(*, active_only: bool = False, archived_only: bool = False) -> int:
|
|
23
|
+
"""カテゴリ一覧を表示する。
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
active_only: True なら archived=0 のみ表示。
|
|
27
|
+
archived_only: True なら archived=1 のみ表示。
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
常に 0。
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
with open_db() as conn:
|
|
34
|
+
categories = list_all_categories(conn, active_only=active_only, archived_only=archived_only)
|
|
35
|
+
|
|
36
|
+
if not categories:
|
|
37
|
+
if archived_only:
|
|
38
|
+
print_line("archived カテゴリはありません")
|
|
39
|
+
elif active_only:
|
|
40
|
+
print_line("active カテゴリはありません")
|
|
41
|
+
else:
|
|
42
|
+
print_line("カテゴリがありません")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
table = Table(title="カテゴリ", box=SIMPLE, show_edge=True, pad_edge=False)
|
|
46
|
+
table.add_column("key", style="bold")
|
|
47
|
+
table.add_column("表示名")
|
|
48
|
+
table.add_column("archived", justify="center")
|
|
49
|
+
for cat in categories:
|
|
50
|
+
table.add_row(escape(cat.key), escape(cat.display_name), "✓" if cat.archived else "")
|
|
51
|
+
print_table(table)
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add_category(key: str, display_name: str) -> int:
|
|
56
|
+
"""カテゴリを新規追加する。
|
|
57
|
+
|
|
58
|
+
同じ key が archived 状態で既に存在する場合は、archived=0 に戻し、
|
|
59
|
+
display_name を今回指定された値に更新する (実質 restore + rename)。
|
|
60
|
+
active 状態で存在する場合は重複エラー。
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
key: 新しいカテゴリキー (ASCII 英小文字+数字+_)。
|
|
64
|
+
display_name: 表示名。
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
0: 追加または再有効化成功 / 1: 不正key、空 display_name、重複。
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
validate_category_key(key)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
print_error(str(exc))
|
|
74
|
+
return 1
|
|
75
|
+
if not display_name:
|
|
76
|
+
print_error("display_name は空にできません")
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
with open_db() as conn:
|
|
80
|
+
existing = find_category(conn, key)
|
|
81
|
+
if existing is not None:
|
|
82
|
+
if not existing.archived:
|
|
83
|
+
print_error(
|
|
84
|
+
f"カテゴリ '{key}' は既に存在します (display_name='{existing.display_name}')"
|
|
85
|
+
)
|
|
86
|
+
return 1
|
|
87
|
+
with conn:
|
|
88
|
+
update_category_archived(conn, key, archived=False)
|
|
89
|
+
update_category_display_name(conn, key, display_name)
|
|
90
|
+
print_line(
|
|
91
|
+
f"再有効化: {key} → '{escape(display_name)}' "
|
|
92
|
+
"(以前 archived だったカテゴリを復帰、display_name を上書き)"
|
|
93
|
+
)
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
with conn:
|
|
97
|
+
insert_category(conn, key, display_name)
|
|
98
|
+
print_line(f"追加: {key} → '{escape(display_name)}'")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def remove_category(key: str) -> int:
|
|
103
|
+
"""カテゴリを archived にする (物理削除はしない)。
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
key: 対象カテゴリキー。
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
0: 成功、または既に archived (no-op) / 1: 未存在。
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
with open_db() as conn:
|
|
113
|
+
existing = find_category(conn, key)
|
|
114
|
+
if existing is None:
|
|
115
|
+
print_error(f"カテゴリ '{key}' が存在しません")
|
|
116
|
+
return 1
|
|
117
|
+
if existing.archived:
|
|
118
|
+
print_line(f"'{key}' は既にアーカイブ済みです")
|
|
119
|
+
return 0
|
|
120
|
+
with conn:
|
|
121
|
+
update_category_archived(conn, key, archived=True)
|
|
122
|
+
print_line(f"アーカイブ: {key} ('{escape(existing.display_name)}')")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def restore_category(key: str) -> int:
|
|
127
|
+
"""archived カテゴリを active に戻す。display_name は維持する。
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
key: 対象カテゴリキー。
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
0: 成功、または既に active (no-op) / 1: 未存在。
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
with open_db() as conn:
|
|
137
|
+
existing = find_category(conn, key)
|
|
138
|
+
if existing is None:
|
|
139
|
+
print_error(f"カテゴリ '{key}' が存在しません")
|
|
140
|
+
return 1
|
|
141
|
+
if not existing.archived:
|
|
142
|
+
print_line(f"'{key}' は既に有効です")
|
|
143
|
+
return 0
|
|
144
|
+
with conn:
|
|
145
|
+
update_category_archived(conn, key, archived=False)
|
|
146
|
+
print_line(f"復帰: {key} ('{escape(existing.display_name)}')")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def rename_category(key: str, new_display_name: str) -> int:
|
|
151
|
+
"""display_name のみを変更する。key は不変 (履歴保持のため)。
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
key: 対象カテゴリキー。
|
|
155
|
+
new_display_name: 新しい表示名。
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
0: 成功 / 1: 未存在、空 display_name。
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
if not new_display_name:
|
|
162
|
+
print_error("新しい display_name は空にできません")
|
|
163
|
+
return 1
|
|
164
|
+
with open_db() as conn:
|
|
165
|
+
existing = find_category(conn, key)
|
|
166
|
+
if existing is None:
|
|
167
|
+
print_error(f"カテゴリ '{key}' が存在しません")
|
|
168
|
+
return 1
|
|
169
|
+
with conn:
|
|
170
|
+
update_category_display_name(conn, key, new_display_name)
|
|
171
|
+
print_line(f"変更: {key} '{escape(existing.display_name)}' → '{escape(new_display_name)}'")
|
|
172
|
+
return 0
|