跳到主要内容

专注时间与日程规划

收藏0

把一天的任务按专注工作与零碎事务分组,排出有缓冲的时间块,并可在本地记录专注时长。

让 AI 安装

github最新版本 0.1.1更新于 2026-10-21下载 2 次2 个版本

scripts/focus_session_logger.py

10.4 KB
1#!/usr/bin/env python3
2"""focus_session_logger.py — JSON-backed focus-session log: deep hours this week, streaks, targets.
3
4You cannot improve a deep-work practice you do not measure. This script keeps a tiny local
5ledger of focus sessions and answers two questions:
6
7 status — how many deep hours this week (ISO week, Mon-Sun) vs --target (default 15)?
8 streak — how many consecutive days (ending today, with a one-day grace) have at least
9 one logged session?
10
11Subcommands:
12 log --minutes N --label X [--date YYYY-MM-DD] append one focus session
13 status [--target HOURS] [--date YYYY-MM-DD] this week's deep hours vs target
14 streak [--date YYYY-MM-DD] consecutive-day streak
15
16State lives in a single JSON file (--state, default ~/.deep-work/sessions.json), created on
17demand. Writes are atomic (tmp file + os.replace) so a crash never corrupts the ledger.
18The top-level --sample flag prints a canned status report WITHOUT touching disk.
19
20NO LLM CALLS. Stdlib only. Deterministic given the same state file and --date.
21
22Exit codes:
23 0 success (log appended / report printed)
24 1 usage error, bad date, or unreadable/corrupt state file
25
26Usage:
27 python focus_session_logger.py log --minutes 90 --label "Write product spec"
28 python focus_session_logger.py status --target 15
29 python focus_session_logger.py streak
30 python focus_session_logger.py --sample
31"""
32
33import argparse
34import datetime as dt
35import json
36import os
37import sys
38import tempfile
39from typing import Any, Dict, List, Tuple
40
41DEFAULT_STATE = os.path.join(os.path.expanduser("~"), ".deep-work", "sessions.json")
42DEFAULT_TARGET_HOURS = 15.0
43
44
45def parse_date(value: str, flag: str) -> dt.date:
46 try:
47 return dt.date.fromisoformat(value)
48 except ValueError:
49 raise ValueError(f"{flag} must be YYYY-MM-DD, got: {value!r}")
50
51
52def load_state(path: str) -> Dict[str, Any]:
53 if not os.path.exists(path):
54 return {"sessions": []}
55 try:
56 with open(path, "r", encoding="utf-8") as f:
57 state = json.load(f)
58 except (OSError, json.JSONDecodeError) as exc:
59 raise ValueError(f"cannot read state file {path}: {exc}")
60 if not isinstance(state, dict) or not isinstance(state.get("sessions"), list):
61 raise ValueError(f"state file {path} is not a valid session ledger")
62 return state
63
64
65def save_state(path: str, state: Dict[str, Any]) -> None:
66 """Atomic write: temp file in the same directory, then os.replace."""
67 directory = os.path.dirname(os.path.abspath(path))
68 os.makedirs(directory, exist_ok=True)
69 fd, tmp = tempfile.mkstemp(prefix=".sessions-", suffix=".tmp", dir=directory)
70 try:
71 with os.fdopen(fd, "w", encoding="utf-8") as f:
72 json.dump(state, f, indent=2)
73 f.write("\n")
74 os.replace(tmp, path)
75 except BaseException:
76 if os.path.exists(tmp):
77 os.unlink(tmp)
78 raise
79
80
81def week_bounds(day: dt.date) -> Tuple[dt.date, dt.date]:
82 monday = day - dt.timedelta(days=day.weekday())
83 return monday, monday + dt.timedelta(days=6)
84
85
86def cmd_log(state: Dict[str, Any], minutes: int, label: str, day: dt.date) -> Dict[str, Any]:
87 state["sessions"].append({"date": day.isoformat(), "minutes": minutes, "label": label})
88 total_today = sum(s["minutes"] for s in state["sessions"] if s["date"] == day.isoformat())
89 return {"logged": {"date": day.isoformat(), "minutes": minutes, "label": label},
90 "sessions_total": len(state["sessions"]),
91 "minutes_today": total_today}
92
93
94def cmd_status(state: Dict[str, Any], target_hours: float, day: dt.date) -> Dict[str, Any]:
95 monday, sunday = week_bounds(day)
96 week = [s for s in state["sessions"]
97 if monday.isoformat() <= s["date"] <= sunday.isoformat()]
98 minutes = sum(s["minutes"] for s in week)
99 hours = round(minutes / 60.0, 2)
100 by_day: Dict[str, int] = {}
101 for s in week:
102 by_day[s["date"]] = by_day.get(s["date"], 0) + s["minutes"]
103 remaining = round(max(0.0, target_hours - hours), 2)
104 on_track = hours >= target_hours
105 return {
106 "week": f"{monday.isoformat()} .. {sunday.isoformat()}",
107 "as_of": day.isoformat(),
108 "sessions_this_week": len(week),
109 "deep_hours_this_week": hours,
110 "target_hours": target_hours,
111 "remaining_hours": remaining,
112 "on_track": on_track,
113 "by_day_minutes": dict(sorted(by_day.items())),
114 "headline": (f"{hours}h of deep work this week vs a {target_hours:g}h target — "
115 + ("target met. Protect the streak."
116 if on_track else f"{remaining}h still to block.")),
117 }
118
119
120def cmd_streak(state: Dict[str, Any], day: dt.date) -> Dict[str, Any]:
121 days = {s["date"] for s in state["sessions"]}
122 cursor = day
123 if cursor.isoformat() not in days: # one-day grace: today not logged yet
124 cursor = cursor - dt.timedelta(days=1)
125 streak = 0
126 while cursor.isoformat() in days:
127 streak += 1
128 cursor = cursor - dt.timedelta(days=1)
129 return {
130 "as_of": day.isoformat(),
131 "streak_days": streak,
132 "headline": (f"{streak} consecutive day(s) with at least one focus session."
133 if streak else "No active streak — one 90-minute block today starts it."),
134 }
135
136
137def render_human(kind: str, r: Dict[str, Any]) -> str:
138 out: List[str] = []
139 if kind == "log":
140 s = r["logged"]
141 out.append(f"Logged: {s['minutes']} min — \"{s['label']}\" on {s['date']}")
142 out.append(f" Today so far: {r['minutes_today']} min · Ledger total: {r['sessions_total']} session(s)")
143 elif kind == "status":
144 out.append(f"Deep-Work Status — week {r['week']} (as of {r['as_of']})")
145 out.append("=" * 64)
146 tail = "ON TRACK" if r["on_track"] else f"{r['remaining_hours']}h remaining"
147 out.append(f" Deep hours: {r['deep_hours_this_week']}h / {r['target_hours']:g}h target ({tail})")
148 for d, m in r["by_day_minutes"].items():
149 out.append(f" {d}: {m} min")
150 out.append(f" {r['headline']}")
151 else: # streak
152 out.append(f"Deep-Work Streak (as of {r['as_of']})")
153 out.append("=" * 64)
154 out.append(f" {r['headline']}")
155 return "\n".join(out)
156
157
158SAMPLE_STATUS = {
159 "kind": "status",
160 "sample": True,
161 "week": "2026-07-13 .. 2026-07-19",
162 "as_of": "2026-07-16",
163 "deep_hours_this_week": 8.5,
164 "target_hours": 15.0,
165 "on_track": False,
166 "remaining_hours": 6.5,
167 "by_day_minutes": {"2026-07-13": 120, "2026-07-14": 180,
168 "2026-07-15": 90, "2026-07-16": 120},
169 "streak_days": 4,
170 "headline": "8.5h of deep work this week vs a 15h target — 6.5h still to block.",
171}
172
173SAMPLE_REPORT = """Deep-Work Status — week 2026-07-13 .. 2026-07-19 (sample, no disk touched)
174================================================================
175 Deep hours: 8.5h / 15h target (6.5h remaining)
176 2026-07-13: 120 min
177 2026-07-14: 180 min
178 2026-07-15: 90 min
179 2026-07-16: 120 min
180 8.5h of deep work this week vs a 15h target — 6.5h still to block.
181 Streak: 4 consecutive day(s) with at least one focus session."""
182
183
184def main(argv: List[str]) -> int:
185 p = argparse.ArgumentParser(
186 description="JSON-backed focus-session log: deep hours this week, streaks, targets.",
187 formatter_class=argparse.RawDescriptionHelpFormatter,
188 epilog=("Exit codes:\n"
189 " 0 success (log appended / report printed)\n"
190 " 1 usage error, bad date, or unreadable/corrupt state file"),
191 )
192 p.add_argument("--state", default=DEFAULT_STATE,
193 help=f"Path to the JSON ledger (default {DEFAULT_STATE})")
194 p.add_argument("--sample", action="store_true",
195 help="Print a canned status report without touching disk")
196 p.add_argument("--json", action="store_true",
197 help="Emit JSON instead of the report (also honored with --sample)")
198 common = argparse.ArgumentParser(add_help=False)
199 common.add_argument("--json", action="store_true", help="Emit JSON instead of the report")
200 sub = p.add_subparsers(dest="cmd")
201
202 p_log = sub.add_parser("log", help="Append one focus session", parents=[common])
203 p_log.add_argument("--minutes", type=int, required=True, help="Session length in minutes")
204 p_log.add_argument("--label", default="deep work", help="What the session was spent on")
205 p_log.add_argument("--date", help="Session date YYYY-MM-DD (default today)")
206
207 p_status = sub.add_parser("status", help="This week's deep hours vs target", parents=[common])
208 p_status.add_argument("--target", type=float, default=DEFAULT_TARGET_HOURS,
209 help=f"Weekly deep-hours target (default {DEFAULT_TARGET_HOURS:g})")
210 p_status.add_argument("--date", help="As-of date YYYY-MM-DD (default today)")
211
212 p_streak = sub.add_parser("streak", help="Consecutive days with at least one session", parents=[common])
213 p_streak.add_argument("--date", help="As-of date YYYY-MM-DD (default today)")
214
215 args = p.parse_args(argv)
216
217 if args.sample:
218 if args.json:
219 print(json.dumps(SAMPLE_STATUS, indent=2))
220 else:
221 print(SAMPLE_REPORT)
222 return 0
223 if not args.cmd:
224 p.print_help()
225 print("\nerror: a subcommand is required (log / status / streak) or --sample",
226 file=sys.stderr)
227 return 1
228
229 try:
230 day = parse_date(args.date, "--date") if getattr(args, "date", None) else dt.date.today()
231 state = load_state(args.state)
232 if args.cmd == "log":
233 if args.minutes <= 0:
234 raise ValueError("--minutes must be a positive integer")
235 result = cmd_log(state, args.minutes, args.label, day)
236 save_state(args.state, state)
237 elif args.cmd == "status":
238 result = cmd_status(state, args.target, day)
239 else:
240 result = cmd_streak(state, day)
241 except ValueError as exc:
242 print(f"error: {exc}", file=sys.stderr)
243 return 1
244
245 if args.json:
246 print(json.dumps(result, indent=2))
247 else:
248 print(render_human(args.cmd, result))
249 return 0
250
251
252if __name__ == "__main__":
253 sys.exit(main(sys.argv[1:]))

文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。