klok-cli 1.1.0__tar.gz → 1.2.0__tar.gz

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.
Files changed (32) hide show
  1. {klok_cli-1.1.0/klok_cli.egg-info → klok_cli-1.2.0}/PKG-INFO +30 -1
  2. {klok_cli-1.1.0 → klok_cli-1.2.0}/README.md +29 -0
  3. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/__init__.py +1 -1
  4. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/cli.py +175 -7
  5. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/dashboard.py +2 -18
  6. klok_cli-1.2.0/klok/insights.py +107 -0
  7. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/mindful.py +37 -0
  8. klok_cli-1.2.0/klok/pairing.py +39 -0
  9. {klok_cli-1.1.0 → klok_cli-1.2.0/klok_cli.egg-info}/PKG-INFO +30 -1
  10. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok_cli.egg-info/SOURCES.txt +4 -0
  11. {klok_cli-1.1.0 → klok_cli-1.2.0}/pyproject.toml +1 -1
  12. {klok_cli-1.1.0 → klok_cli-1.2.0}/tests/test_cli.py +291 -2
  13. klok_cli-1.2.0/tests/test_insights.py +144 -0
  14. klok_cli-1.2.0/tests/test_pairing.py +91 -0
  15. {klok_cli-1.1.0 → klok_cli-1.2.0}/LICENSE +0 -0
  16. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/__main__.py +0 -0
  17. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/config.py +0 -0
  18. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/dashboard.html +0 -0
  19. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/focus.py +0 -0
  20. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/formats.py +0 -0
  21. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/model.py +0 -0
  22. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/report.py +0 -0
  23. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/storage.py +0 -0
  24. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/timeparse.py +0 -0
  25. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/utils.py +0 -0
  26. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok_cli.egg-info/dependency_links.txt +0 -0
  27. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok_cli.egg-info/entry_points.txt +0 -0
  28. {klok_cli-1.1.0 → klok_cli-1.2.0}/klok_cli.egg-info/top_level.txt +0 -0
  29. {klok_cli-1.1.0 → klok_cli-1.2.0}/setup.cfg +0 -0
  30. {klok_cli-1.1.0 → klok_cli-1.2.0}/tests/test_mindful.py +0 -0
  31. {klok_cli-1.1.0 → klok_cli-1.2.0}/tests/test_storage.py +0 -0
  32. {klok_cli-1.1.0 → klok_cli-1.2.0}/tests/test_timeparse.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: klok-cli
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: A batteries-included command-line time tracker with a plaintext backend.
5
5
  Author: Rajat Singh
6
6
  License: MIT
@@ -131,6 +131,35 @@ klok checkin --mood 4 --energy 3 --stress 2
131
131
  klok mindful :month # sessions, time, averages, streaks
132
132
  ```
133
133
 
134
+ ## What drains you, what lifts you
135
+
136
+ The thing no other tracker does: klok joins *how you felt* to *what you were
137
+ doing*. Rate a day's blocks in about twenty seconds, then let klok tell you which
138
+ work costs you energy and which gives it back — from your own private, local data,
139
+ no cloud and no screen surveillance.
140
+
141
+ ```sh
142
+ klok review # walk today's blocks, rate each 1–5 (a digit, then Enter)
143
+ klok stop --felt 4 # or rate a block the moment you stop it
144
+ klok digest :week # what drained vs lifted you, in plain language
145
+ klok digest :month -o energy.txt # write a shareable copy
146
+ ```
147
+
148
+ `klok review` asks one quick energy rating per tracked block (blank skips, `b`
149
+ goes back, `q` quits), so the data stays cheap to keep. `klok digest` then reports
150
+ the projects and tags whose energy sits clearly above or below your average, and
151
+ only once there's enough rated data to trust it — the footer always says how many
152
+ blocks it's working from.
153
+
154
+ ```text
155
+ +reviews drains you
156
+ averages 1.3 points below your overall energy over 7 blocks
157
+ deep work lifts you
158
+ averages 0.9 points above your overall energy over 11 blocks
159
+
160
+ Based on 34 rated blocks across 12 days. Rate more with `klok review` for sharper results.
161
+ ```
162
+
134
163
  ## A web dashboard
135
164
 
136
165
  ```sh
@@ -105,6 +105,35 @@ klok checkin --mood 4 --energy 3 --stress 2
105
105
  klok mindful :month # sessions, time, averages, streaks
106
106
  ```
107
107
 
108
+ ## What drains you, what lifts you
109
+
110
+ The thing no other tracker does: klok joins *how you felt* to *what you were
111
+ doing*. Rate a day's blocks in about twenty seconds, then let klok tell you which
112
+ work costs you energy and which gives it back — from your own private, local data,
113
+ no cloud and no screen surveillance.
114
+
115
+ ```sh
116
+ klok review # walk today's blocks, rate each 1–5 (a digit, then Enter)
117
+ klok stop --felt 4 # or rate a block the moment you stop it
118
+ klok digest :week # what drained vs lifted you, in plain language
119
+ klok digest :month -o energy.txt # write a shareable copy
120
+ ```
121
+
122
+ `klok review` asks one quick energy rating per tracked block (blank skips, `b`
123
+ goes back, `q` quits), so the data stays cheap to keep. `klok digest` then reports
124
+ the projects and tags whose energy sits clearly above or below your average, and
125
+ only once there's enough rated data to trust it — the footer always says how many
126
+ blocks it's working from.
127
+
128
+ ```text
129
+ +reviews drains you
130
+ averages 1.3 points below your overall energy over 7 blocks
131
+ deep work lifts you
132
+ averages 0.9 points above your overall energy over 11 blocks
133
+
134
+ Based on 34 rated blocks across 12 days. Rate more with `klok review` for sharper results.
135
+ ```
136
+
108
137
  ## A web dashboard
109
138
 
110
139
  ```sh
@@ -1,3 +1,3 @@
1
1
  """klok - a command-line time tracker with a plaintext backend."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.2.0"
@@ -20,6 +20,8 @@ from klok.mindful import (BREATHING_TAG, MEDITATION_TAG, PATTERNS, Checkin,
20
20
  parse_pattern, pattern_phases, rounds_for, sit_plan,
21
21
  sparkline, streak)
22
22
  from klok.model import Frame, normalise_tags
23
+ from klok.insights import drain_findings
24
+ from klok.pairing import frame_at, is_practice
23
25
  from klok.report import (Renderer, by_day, by_project, by_sheet, by_tag,
24
26
  render_day_chart, render_gaps, render_log,
25
27
  render_punchcard, render_report, render_summary,
@@ -38,11 +40,25 @@ TIME_OPTIONS = {"-a", "--at", "--from", "--to", "--start", "--stop", "--now",
38
40
  "--for", "--interval-bell", "--warmup"}
39
41
 
40
42
 
43
+ def _default_ask(prompt: str):
44
+ """The real input seam: one line from the user, or ``None`` at EOF.
45
+
46
+ ``review`` reads through this rather than calling ``input`` directly, the
47
+ same spirit as the ``sleep=``/``clock=`` seams in ``focus``/``mindful``,
48
+ so the in-process test suite can feed scripted answers with no real TTY.
49
+ """
50
+ try:
51
+ return input(prompt)
52
+ except EOFError:
53
+ return None
54
+
55
+
41
56
  class Context:
42
57
  """Everything a command needs: config, store, clock and renderer."""
43
58
 
44
- def __init__(self, args):
59
+ def __init__(self, args, ask=None):
45
60
  self.config = Config(args.home)
61
+ self.ask = ask or _default_ask
46
62
  self.store = Store(self.config)
47
63
  self.now = parse_datetime(args.now) if args.now else now_local()
48
64
  color_mode = "never" if args.no_color else (args.color or self.config.get("general.color", "auto"))
@@ -198,6 +214,7 @@ def cmd_start(ctx: Context) -> int:
198
214
 
199
215
  def cmd_stop(ctx: Context) -> int:
200
216
  args = ctx.args
217
+ felt = check_score("felt", getattr(args, "felt", None)) # validate before stopping
201
218
  at = resolve_time(ctx, args.at)
202
219
  sheet = None if args.all else ctx.sheet_filter()
203
220
  stopped = stop_running(ctx, at, note=args.note or "", sheet=sheet)
@@ -205,10 +222,17 @@ def cmd_stop(ctx: Context) -> int:
205
222
  ctx.echo("Nothing is running.")
206
223
  return 1
207
224
  ctx.store.save("stop")
225
+ if felt is not None:
226
+ store = CheckinStore(ctx.config)
227
+ for frame in stopped:
228
+ store.replace(store.energy_at(frame.start, frame.end_or(ctx.now)),
229
+ Checkin(at=frame.start, energy=felt))
208
230
  for frame in stopped:
209
231
  ctx.echo("Stopped %s after %s [%s]" % (describe(ctx, frame),
210
232
  format_duration(frame.duration(ctx.now)),
211
233
  ctx.term.paint(frame.id, "grey")))
234
+ if felt is not None:
235
+ ctx.echo("Noted energy %d for %s." % (felt, "it" if len(stopped) == 1 else "them"))
212
236
  return 0
213
237
 
214
238
 
@@ -1173,9 +1197,14 @@ def cmd_checkin(ctx: Context) -> int:
1173
1197
  % (removed.id, removed.at.strftime("%Y-%m-%d %H:%M")))
1174
1198
  return 0
1175
1199
 
1200
+ words = list(args.words)
1176
1201
  mood = check_score("mood", args.mood)
1177
1202
  energy = check_score("energy", args.energy)
1178
1203
  stress = check_score("stress", args.stress)
1204
+ # `klok checkin 4` shorthand: a bare leading integer is an energy rating,
1205
+ # as long as --energy was not also given. Everything else stays the note.
1206
+ if energy is None and words and re.fullmatch(r"\d+", words[0]):
1207
+ energy = check_score("energy", words.pop(0))
1179
1208
  if mood is None and energy is None and stress is None and sys.stdin.isatty():
1180
1209
  mood = check_score("mood", _ask("Mood 1-5 (1 low, 5 good)"))
1181
1210
  energy = check_score("energy", _ask("Energy 1-5"))
@@ -1184,7 +1213,7 @@ def cmd_checkin(ctx: Context) -> int:
1184
1213
  raise MindfulError("give at least one of --mood, --energy or --stress")
1185
1214
 
1186
1215
  checkin = Checkin(at=resolve_time(ctx, args.at), mood=mood, energy=energy, stress=stress,
1187
- note=" ".join(args.words).strip(), tags=normalise_tags(args.tag or []))
1216
+ note=" ".join(words).strip(), tags=normalise_tags(args.tag or []))
1188
1217
  store.add(checkin)
1189
1218
  store.save()
1190
1219
  parts = ["%s %d" % (name, value) for name, value in
@@ -1325,6 +1354,134 @@ def cmd_mindful(ctx: Context) -> int:
1325
1354
  return 0
1326
1355
 
1327
1356
 
1357
+ def cmd_review(ctx: Context) -> int:
1358
+ """Walk the range's tracked blocks and rate each one's energy, 1-5.
1359
+
1360
+ One block at a time: a digit then Enter records it, a blank line skips,
1361
+ ``b`` steps back to re-rate the previous block, ``q`` (or EOF) stops. By
1362
+ default it walks the active sheet only and the blocks that have no energy
1363
+ rating yet; ``--all-sheets`` widens the scope and ``--redo`` re-walks the
1364
+ already-rated ones too. Practice sessions and a still-running frame are
1365
+ left out. Each rating is one energy ``Checkin`` stamped at the block's
1366
+ start instant, so the timestamp join associates it with that block.
1367
+ """
1368
+ start, end = ctx.range_of(":day")
1369
+ sheet = ctx.sheet_filter()
1370
+ practice = ctx.config.get("mindful.sheet", "wellbeing")
1371
+ frames = ctx.store.select(start, end, sheet=sheet, now=ctx.now)
1372
+ blocks = sorted((frame for frame in frames
1373
+ if not frame.running and not is_practice(frame, practice)
1374
+ and frame.start < frame.end_or(ctx.now)),
1375
+ key=lambda frame: frame.start)
1376
+ store = CheckinStore(ctx.config)
1377
+ redo = ctx.args.redo
1378
+ pending = [frame for frame in blocks
1379
+ if redo or store.energy_at(frame.start, frame.end_or(ctx.now)) is None]
1380
+ if not pending:
1381
+ if blocks:
1382
+ ctx.echo("Every block here is already rated. Use `klok review --redo` to re-rate.")
1383
+ else:
1384
+ ctx.echo("No blocks to review in this range.")
1385
+ return 0
1386
+
1387
+ rated = 0
1388
+ index = 0
1389
+ while index < len(pending):
1390
+ frame = pending[index]
1391
+ current = store.energy_at(frame.start, frame.end_or(ctx.now))
1392
+ marker = ctx.term.paint(" [energy %d]" % current.energy, "grey") if current else ""
1393
+ ctx.echo("%s %s (%s-%s)%s" % (
1394
+ frame.label(), format_duration(frame.duration(ctx.now)),
1395
+ ctx.renderer.fmt_time(frame.start), ctx.renderer.fmt_time(frame.stop), marker))
1396
+ answer = ctx.ask(" Energy 1-5 (Enter skips, b back, q quit): ")
1397
+ if answer is None:
1398
+ break
1399
+ answer = answer.strip().lower()
1400
+ if answer == "":
1401
+ index += 1
1402
+ continue
1403
+ if answer == "q":
1404
+ break
1405
+ if answer == "b":
1406
+ index = max(0, index - 1)
1407
+ continue
1408
+ try:
1409
+ value = check_score("energy", answer)
1410
+ except MindfulError as exc:
1411
+ ctx.echo(" %s" % exc)
1412
+ continue
1413
+ store.replace(current, Checkin(at=frame.start, energy=value))
1414
+ rated += 1
1415
+ index += 1
1416
+
1417
+ ctx.echo("Rated %d block%s." % (rated, "" if rated == 1 else "s"))
1418
+ return 0
1419
+
1420
+
1421
+ def cmd_digest(ctx: Context) -> int:
1422
+ """What your energy ratings add up to: which work drains or lifts you.
1423
+
1424
+ Reads the frames and energy check-ins in the range, joins each rating to
1425
+ the block it falls in, and reports the projects and tags whose energy sits
1426
+ clearly above or below your overall average. It only reports a pattern
1427
+ once there is enough data to trust it, so an honest footer always says how
1428
+ many rated blocks it is working from. ``-o`` writes the same text to a
1429
+ file to share.
1430
+ """
1431
+ start, end = ctx.range_of(":week")
1432
+ sheet = ctx.sheet_filter()
1433
+ now = min(ctx.now, end - timedelta(seconds=1))
1434
+ frames = ctx.store.select(start, end, sheet=sheet, now=now)
1435
+ checkins = CheckinStore(ctx.config).select(start, end)
1436
+
1437
+ findings = drain_findings(frames, checkins, now)
1438
+
1439
+ # Rated blocks and the days they span, joined the same way insights does.
1440
+ rated = {}
1441
+ for checkin in checkins:
1442
+ if checkin.energy is None:
1443
+ continue
1444
+ frame = frame_at(frames, checkin.at, now)
1445
+ if frame is not None:
1446
+ rated[id(frame)] = frame # by identity, matching insights._rated_blocks
1447
+ block_count = len(rated)
1448
+ day_count = len({frame.start.date() for frame in rated.values()})
1449
+
1450
+ header = "Energy digest %s to %s" % (
1451
+ start.date().isoformat(), (end - timedelta(seconds=1)).date().isoformat())
1452
+ body = [] # (kind, text): kind "head" is a finding headline, "plain" is as-is
1453
+ if findings:
1454
+ for finding in findings:
1455
+ body.append(("head", finding.headline))
1456
+ body.append(("plain", " %s" % finding.detail))
1457
+ elif block_count:
1458
+ body.append(("plain", "No clear pattern yet. Keep rating blocks with `klok review`;"))
1459
+ body.append(("plain", "a project or tag needs several rated blocks before it shows up here."))
1460
+ else:
1461
+ body.append(("plain", "Nothing rated yet. Rate a day's blocks with `klok review` and come back."))
1462
+ footer = "Based on %d rated block%s across %d day%s." % (
1463
+ block_count, "" if block_count == 1 else "s",
1464
+ day_count, "" if day_count == 1 else "s")
1465
+ if block_count:
1466
+ footer += " Rate more with `klok review` for sharper results."
1467
+
1468
+ def render(colored: bool) -> str:
1469
+ paint = ctx.term.paint if colored else (lambda text, *styles: text)
1470
+ out = [paint(header, "bold"), ""]
1471
+ for kind, text in body:
1472
+ out.append(paint(text, "bold") if kind == "head" else text)
1473
+ out += ["", paint(footer, "grey")]
1474
+ return "\n".join(out)
1475
+
1476
+ if ctx.args.output:
1477
+ with open(ctx.args.output, "w", encoding="utf-8") as handle:
1478
+ handle.write(render(False) + "\n")
1479
+ ctx.echo("Wrote the digest to %s" % ctx.args.output)
1480
+ else:
1481
+ print(render(True))
1482
+ return 0
1483
+
1484
+
1328
1485
  # ------------------------------------------------------------------- checks
1329
1486
  def cmd_check(ctx: Context) -> int:
1330
1487
  problems = []
@@ -1361,7 +1518,7 @@ _klok_completions() {
1361
1518
  local commands="start stop cancel status restart switch track add stretch edit annotate tag untag
1362
1519
  rename move lengthen shorten fill join split delete undo log summary report chart gaps stats projects
1363
1520
  tags sheet sheets archive export import config where timer pomodoro breathe meditate checkin mood
1364
- mindful check completion version"
1521
+ mindful review digest check completion version"
1365
1522
  COMPREPLY=($(compgen -W "$commands" -- "${COMP_WORDS[COMP_CWORD]}"))
1366
1523
  }
1367
1524
  complete -F _klok_completions klok
@@ -1373,7 +1530,7 @@ _klok() {
1373
1530
  commands=(start stop cancel status restart switch track add stretch edit annotate tag untag rename
1374
1531
  move lengthen shorten fill join split delete undo log summary report chart gaps stats projects tags
1375
1532
  sheet sheets archive export import config where timer pomodoro breathe meditate checkin mood
1376
- mindful check completion version)
1533
+ mindful review digest check completion version)
1377
1534
  _describe 'klok command' commands
1378
1535
  }
1379
1536
  compdef _klok klok
@@ -1382,7 +1539,7 @@ compdef _klok klok
1382
1539
  for cmd in start stop cancel status restart switch track add stretch edit annotate tag untag rename \\
1383
1540
  move lengthen shorten fill join split delete undo log summary report chart gaps stats projects \\
1384
1541
  tags sheet sheets archive export import config where timer pomodoro breathe meditate \\
1385
- checkin mood mindful check completion version
1542
+ checkin mood mindful review digest check completion version
1386
1543
  complete -c klok -n __fish_use_subcommand -a $cmd
1387
1544
  end
1388
1545
  """,
@@ -1564,6 +1721,7 @@ def build_parser() -> argparse.ArgumentParser:
1564
1721
  stop.add_argument("-a", "--at", metavar="TIME", help="stop at this time (e.g. 17:30, -5m)")
1565
1722
  stop.add_argument("-n", "--note", metavar="TEXT", help="append an annotation while stopping")
1566
1723
  stop.add_argument("--all", action="store_true", help="stop running entries on every sheet")
1724
+ stop.add_argument("--felt", metavar="1-5", help="also record how the stretch felt, as an energy check-in")
1567
1725
 
1568
1726
  add("cancel", "Discard the running entry without recording it.",
1569
1727
  aliases=["abort"], parents=[sheet_opts], func=cmd_cancel)
@@ -1826,6 +1984,16 @@ def build_parser() -> argparse.ArgumentParser:
1826
1984
  parents=[range_opts, sheet_opts], func=cmd_mindful)
1827
1985
  mindful_cmd.add_argument("--no-chart", action="store_true", help="skip the per-day chart")
1828
1986
 
1987
+ review_cmd = add("review", "Rate each of a day's tracked blocks for energy, one at a time.",
1988
+ parents=[range_opts, sheet_opts], func=cmd_review)
1989
+ review_cmd.add_argument("--redo", action="store_true",
1990
+ help="re-walk blocks that are already rated and replace their rating")
1991
+
1992
+ digest_cmd = add("digest", "What your energy ratings add up to: what drains or lifts you.",
1993
+ parents=[range_opts, sheet_opts], func=cmd_digest)
1994
+ digest_cmd.add_argument("-o", "--output", metavar="FILE",
1995
+ help="write the digest to a file instead of stdout")
1996
+
1829
1997
  current_group[0] = "Other"
1830
1998
  add("check", "Look for overlaps and other suspicious entries.", aliases=["sanity", "doctor"],
1831
1999
  func=cmd_check)
@@ -1840,7 +2008,7 @@ def build_parser() -> argparse.ArgumentParser:
1840
2008
  return parser
1841
2009
 
1842
2010
 
1843
- def main(argv=None) -> int:
2011
+ def main(argv=None, ask=None) -> int:
1844
2012
  argv = list(sys.argv[1:] if argv is None else argv)
1845
2013
  if not argv:
1846
2014
  argv = ["status"]
@@ -1850,7 +2018,7 @@ def main(argv=None) -> int:
1850
2018
  parser.print_help()
1851
2019
  return 1
1852
2020
  try:
1853
- ctx = Context(args)
2021
+ ctx = Context(args, ask=ask)
1854
2022
  return args.func(ctx) or 0
1855
2023
  except (StoreError, TimeParseError, ValueError, KeyError) as exc:
1856
2024
  return die(str(exc))
@@ -12,12 +12,12 @@ from pathlib import Path
12
12
  from typing import List, Optional
13
13
 
14
14
  from klok import __version__
15
- from klok.mindful import BREATHING_TAG, MEDITATION_TAG, Checkin
15
+ from klok.mindful import Checkin
16
16
  from klok.model import Frame
17
+ from klok.pairing import frame_at, is_practice
17
18
 
18
19
  TEMPLATE = "dashboard.html"
19
20
  DATA_MARKER = "/*KLOK_DATA*/null"
20
- PRACTICE_TAGS = {BREATHING_TAG, MEDITATION_TAG}
21
21
  NO_PROJECT = "(no project)"
22
22
 
23
23
 
@@ -29,22 +29,6 @@ def _iso(moment: datetime) -> str:
29
29
  return moment.isoformat(timespec="seconds")
30
30
 
31
31
 
32
- def is_practice(frame: Frame, practice_sheet: str = "") -> bool:
33
- """Breathing and sitting sessions, however they were recorded."""
34
- return bool(PRACTICE_TAGS.intersection(frame.tags)) or (
35
- bool(practice_sheet) and frame.sheet == practice_sheet)
36
-
37
-
38
- def frame_at(frames: List[Frame], moment: datetime, now: datetime) -> Optional[Frame]:
39
- """The frame that was running at ``moment``; the latest-started one wins."""
40
- found = None
41
- for frame in frames:
42
- if frame.start <= moment < frame.end_or(now):
43
- if found is None or frame.start >= found.start:
44
- found = frame
45
- return found
46
-
47
-
48
32
  def _mean(values) -> Optional[float]:
49
33
  present = [value for value in values if value is not None]
50
34
  return round(sum(present) / len(present), 2) if present else None
@@ -0,0 +1,107 @@
1
+ """Energy insights: which projects and tags drain or lift you.
2
+
3
+ A single deterministic analyzer over the frame/check-in join. For every
4
+ energy-bearing check-in (``energy is not None``) we find the frame it fell
5
+ inside (``pairing.frame_at``); a *rated block* is a frame holding at least one
6
+ such check-in, and its energy is the mean of those check-ins. Each project
7
+ and each tag is then scored by how far the mean energy of its rated blocks
8
+ sits from your overall mean -- a drain (below) or a lift (above).
9
+
10
+ Honesty over count (see docs/designs/energy-aware-klok.md): a project or tag
11
+ is reported only when it has at least ``MIN_BLOCKS`` rated blocks *and* an
12
+ effect of at least ``MIN_EFFECT`` energy points, so sparse data stays silent.
13
+ This is the first-slice analyzer; the per-hour, per-weekday and fade analyzers
14
+ are deferred behind the go/no-go gate.
15
+ """
16
+
17
+ from dataclasses import dataclass
18
+ from datetime import datetime
19
+ from statistics import mean
20
+ from typing import Dict, List, Tuple
21
+
22
+ from klok import pairing
23
+ from klok.mindful import Checkin
24
+ from klok.model import Frame
25
+
26
+ MIN_BLOCKS = 5
27
+ MIN_EFFECT = 0.5
28
+ NO_PROJECT = "(no project)"
29
+
30
+
31
+ @dataclass
32
+ class Finding:
33
+ """One drain-or-lift result, ready to print in the digest."""
34
+
35
+ headline: str
36
+ detail: str
37
+ effect_size: float
38
+ n: int
39
+
40
+
41
+ def _rated_blocks(frames: List[Frame], checkins: List[Checkin],
42
+ now: datetime) -> List[Tuple[Frame, float]]:
43
+ """Join once. Every frame carrying at least one energy-bearing check-in,
44
+ paired with the mean energy of those check-ins. Mood/stress-only
45
+ check-ins (``energy is None``) and check-ins that match no frame are
46
+ ignored, so neither creates or inflates a block.
47
+ """
48
+ energies: Dict[int, List[int]] = {} # id(frame) -> raw energies
49
+ frame_of: Dict[int, Frame] = {} # id(frame) -> the frame itself
50
+ for checkin in checkins:
51
+ if checkin.energy is None:
52
+ continue
53
+ frame = pairing.frame_at(frames, checkin.at, now)
54
+ if frame is None:
55
+ continue
56
+ energies.setdefault(id(frame), []).append(checkin.energy)
57
+ frame_of[id(frame)] = frame
58
+ return [(frame_of[key], float(mean(values)))
59
+ for key, values in energies.items()]
60
+
61
+
62
+ def _findings_for(groups: Dict[str, List[float]], overall: float,
63
+ prefix: str = "") -> List[Finding]:
64
+ """Turn one grouping (by project or by tag) into surviving findings."""
65
+ findings = []
66
+ for name, block_energies in groups.items():
67
+ n = len(block_energies)
68
+ if n < MIN_BLOCKS:
69
+ continue
70
+ effect = round(float(mean(block_energies)) - overall, 2)
71
+ if abs(effect) < MIN_EFFECT:
72
+ continue
73
+ drains = effect < 0
74
+ findings.append(Finding(
75
+ headline="%s%s %s" % (prefix, name,
76
+ "drains you" if drains else "lifts you"),
77
+ detail="averages %.1f points %s your overall energy over %d blocks"
78
+ % (abs(effect), "below" if drains else "above", n),
79
+ effect_size=effect,
80
+ n=n,
81
+ ))
82
+ return findings
83
+
84
+
85
+ def drain_findings(frames: List[Frame], checkins: List[Checkin],
86
+ now: datetime) -> List[Finding]:
87
+ """Projects and tags that drain or lift you, most draining first.
88
+
89
+ Returns ``[]`` when there is nothing honest to say: no rated blocks, or
90
+ none that clear the ``MIN_BLOCKS``/``MIN_EFFECT`` suppression rule.
91
+ """
92
+ rated = _rated_blocks(frames, checkins, now)
93
+ if not rated:
94
+ return []
95
+ overall = float(mean(energy for _, energy in rated))
96
+
97
+ by_project: Dict[str, List[float]] = {}
98
+ by_tag: Dict[str, List[float]] = {}
99
+ for frame, energy in rated:
100
+ by_project.setdefault(frame.project or NO_PROJECT, []).append(energy)
101
+ for tag in frame.tags:
102
+ by_tag.setdefault(tag, []).append(energy)
103
+
104
+ findings = _findings_for(by_project, overall)
105
+ findings += _findings_for(by_tag, overall, prefix="+")
106
+ findings.sort(key=lambda finding: (finding.effect_size, finding.headline))
107
+ return findings
@@ -369,6 +369,43 @@ class CheckinStore:
369
369
  return [item for item in self.items
370
370
  if (start is None or item.at >= start) and (end is None or item.at < end)]
371
371
 
372
+ def energy_at(self, start: datetime, end: datetime) -> Optional[Checkin]:
373
+ """The energy-bearing check-in inside ``[start, end)``, latest wins.
374
+
375
+ "Energy-bearing" means ``energy is not None``; a mood- or stress-only
376
+ check-in in the same interval is ignored, so it never masks a block
377
+ that has not actually been rated for energy.
378
+ """
379
+ found = [item for item in self.items
380
+ if item.energy is not None and start <= item.at < end]
381
+ return found[-1] if found else None
382
+
383
+ def replace(self, old: Optional[Checkin], new: Checkin) -> Checkin:
384
+ """Re-rate in place: drop ``old`` (if any) then add ``new``, one save.
385
+
386
+ ``CheckinStore`` has no update, so re-rating a block is remove-then-add;
387
+ doing both before a single atomic save keeps the file consistent and
388
+ never leaves a duplicate energy check-in behind.
389
+
390
+ Re-rating changes the *energy* of a block, not the rest of a check-in.
391
+ When ``old`` carried a mood, stress, note or tags the user logged, those
392
+ are kept on ``new`` unless ``new`` sets its own, so a quick energy
393
+ re-rate never silently discards earlier entries.
394
+ """
395
+ if old is not None:
396
+ if new.mood is None:
397
+ new.mood = old.mood
398
+ if new.stress is None:
399
+ new.stress = old.stress
400
+ if not new.note:
401
+ new.note = old.note
402
+ if not new.tags:
403
+ new.tags = list(old.tags)
404
+ self._items = [item for item in self.items if item.id != old.id]
405
+ self.add(new)
406
+ self.save()
407
+ return new
408
+
372
409
 
373
410
  # ----------------------------------------------------------------- reporting
374
411
  def sparkline(values) -> str:
@@ -0,0 +1,39 @@
1
+ """Pairing helpers: match a check-in to the frame that was running at a
2
+ given moment, and classify practice (breathing/meditation) sessions.
3
+
4
+ These were born in ``dashboard.py``; they live here so the dashboard, the
5
+ ``review`` ritual and the ``insights`` digest all share one join and agree
6
+ on what "the block this check-in belongs to" means. The join is
7
+ timestamp-based and half-open (``start <= moment < end``), and ties between
8
+ overlapping frames go to the latest-started one.
9
+ """
10
+
11
+ from datetime import datetime
12
+ from typing import List, Optional
13
+
14
+ from klok.mindful import BREATHING_TAG, MEDITATION_TAG
15
+ from klok.model import Frame
16
+
17
+ PRACTICE_TAGS = {BREATHING_TAG, MEDITATION_TAG}
18
+
19
+
20
+ def is_practice(frame: Frame, practice_sheet: str = "") -> bool:
21
+ """Breathing and sitting sessions, however they were recorded."""
22
+ return bool(PRACTICE_TAGS.intersection(frame.tags)) or (
23
+ bool(practice_sheet) and frame.sheet == practice_sheet)
24
+
25
+
26
+ def frame_at(frames: List[Frame], moment: datetime, now: datetime) -> Optional[Frame]:
27
+ """The frame that was running at ``moment``; the latest-started one wins.
28
+
29
+ Half-open: a frame covers ``start <= moment < end``. ``now`` resolves the
30
+ end of a still-running frame. When frames overlap, the one that started
31
+ most recently takes the moment, which is why callers that want an
32
+ unambiguous reverse lookup timestamp a check-in at the block's *start*.
33
+ """
34
+ found = None
35
+ for frame in frames:
36
+ if frame.start <= moment < frame.end_or(now):
37
+ if found is None or frame.start >= found.start:
38
+ found = frame
39
+ return found
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: klok-cli
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: A batteries-included command-line time tracker with a plaintext backend.
5
5
  Author: Rajat Singh
6
6
  License: MIT
@@ -131,6 +131,35 @@ klok checkin --mood 4 --energy 3 --stress 2
131
131
  klok mindful :month # sessions, time, averages, streaks
132
132
  ```
133
133
 
134
+ ## What drains you, what lifts you
135
+
136
+ The thing no other tracker does: klok joins *how you felt* to *what you were
137
+ doing*. Rate a day's blocks in about twenty seconds, then let klok tell you which
138
+ work costs you energy and which gives it back — from your own private, local data,
139
+ no cloud and no screen surveillance.
140
+
141
+ ```sh
142
+ klok review # walk today's blocks, rate each 1–5 (a digit, then Enter)
143
+ klok stop --felt 4 # or rate a block the moment you stop it
144
+ klok digest :week # what drained vs lifted you, in plain language
145
+ klok digest :month -o energy.txt # write a shareable copy
146
+ ```
147
+
148
+ `klok review` asks one quick energy rating per tracked block (blank skips, `b`
149
+ goes back, `q` quits), so the data stays cheap to keep. `klok digest` then reports
150
+ the projects and tags whose energy sits clearly above or below your average, and
151
+ only once there's enough rated data to trust it — the footer always says how many
152
+ blocks it's working from.
153
+
154
+ ```text
155
+ +reviews drains you
156
+ averages 1.3 points below your overall energy over 7 blocks
157
+ deep work lifts you
158
+ averages 0.9 points above your overall energy over 11 blocks
159
+
160
+ Based on 34 rated blocks across 12 days. Rate more with `klok review` for sharper results.
161
+ ```
162
+
134
163
  ## A web dashboard
135
164
 
136
165
  ```sh
@@ -9,8 +9,10 @@ klok/dashboard.html
9
9
  klok/dashboard.py
10
10
  klok/focus.py
11
11
  klok/formats.py
12
+ klok/insights.py
12
13
  klok/mindful.py
13
14
  klok/model.py
15
+ klok/pairing.py
14
16
  klok/report.py
15
17
  klok/storage.py
16
18
  klok/timeparse.py
@@ -21,6 +23,8 @@ klok_cli.egg-info/dependency_links.txt
21
23
  klok_cli.egg-info/entry_points.txt
22
24
  klok_cli.egg-info/top_level.txt
23
25
  tests/test_cli.py
26
+ tests/test_insights.py
24
27
  tests/test_mindful.py
28
+ tests/test_pairing.py
25
29
  tests/test_storage.py
26
30
  tests/test_timeparse.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # by an unrelated, empty placeholder package. The installed command is
8
8
  # still plain `klok` - see [project.scripts] below.
9
9
  name = "klok-cli"
10
- version = "1.1.0"
10
+ version = "1.2.0"
11
11
  description = "A batteries-included command-line time tracker with a plaintext backend."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.9"
@@ -9,6 +9,23 @@ from pathlib import Path
9
9
  from klok.cli import main
10
10
 
11
11
 
12
+ def scripted_input(answers):
13
+ """A stand-in for the ``Context`` input seam that replays a script.
14
+
15
+ Each prompt returns the next scripted answer; once they run out it returns
16
+ ``None`` the way a real read does at EOF, so a `review` walk ends cleanly.
17
+ """
18
+ pending = iter(answers)
19
+
20
+ def ask(prompt):
21
+ try:
22
+ return next(pending)
23
+ except StopIteration:
24
+ return None
25
+
26
+ return ask
27
+
28
+
12
29
  class CliTestCase(unittest.TestCase):
13
30
  """Drives the real entry point and reads what a user would see."""
14
31
 
@@ -20,11 +37,12 @@ class CliTestCase(unittest.TestCase):
20
37
  def tearDown(self):
21
38
  shutil.rmtree(self.home, ignore_errors=True)
22
39
 
23
- def run_klok(self, *args, clock=None):
40
+ def run_klok(self, *args, clock=None, answers=None):
24
41
  argv = ["--home", str(self.home), "--no-color", "--now", clock or self.clock] + [str(a) for a in args]
42
+ ask = scripted_input(answers) if answers is not None else None
25
43
  out, err = io.StringIO(), io.StringIO()
26
44
  with redirect_stdout(out), redirect_stderr(err):
27
- code = main(argv)
45
+ code = main(argv, ask=ask)
28
46
  return code, out.getvalue() + err.getvalue()
29
47
 
30
48
  def assertOk(self, *args, **kwargs):
@@ -542,6 +560,191 @@ class MindfulnessTests(CliTestCase):
542
560
  self.assertNotIn("klok breathe", self.assertOk("status"))
543
561
 
544
562
 
563
+ class CheckinShorthandTests(CliTestCase):
564
+ """`klok checkin 4`, `klok stop --felt` and the flag forms coexisting."""
565
+
566
+ def energies(self):
567
+ """Every energy check-in, as (energy, HH:MM) pairs in time order."""
568
+ path = self.home / "checkins.jsonl"
569
+ if not path.exists():
570
+ return []
571
+ out = []
572
+ for line in path.read_text(encoding="utf-8").splitlines():
573
+ line = line.strip()
574
+ if not line:
575
+ continue
576
+ item = json.loads(line)
577
+ if item.get("energy") is not None:
578
+ out.append((item["energy"], item["at"][11:16]))
579
+ return sorted(out, key=lambda pair: pair[1])
580
+
581
+ def test_bare_integer_sets_energy(self):
582
+ output = self.assertOk("checkin", "4")
583
+ self.assertIn("energy 4", output)
584
+ self.assertEqual([e for e, _ in self.energies()], [4])
585
+
586
+ def test_flag_forms_still_work_alongside_the_positional(self):
587
+ # The positional shorthand must not have broken --mood/--energy/--stress.
588
+ self.assertOk("checkin", "--mood", "4")
589
+ self.assertOk("checkin", "--energy", "2", "--mood", "3")
590
+ self.assertEqual([e for e, _ in self.energies()], [2])
591
+
592
+ def test_bare_integer_keeps_the_rest_as_a_note(self):
593
+ self.assertOk("checkin", "4", "after", "lunch")
594
+ output = self.assertOk("mood", ":day")
595
+ self.assertIn("after lunch", output)
596
+
597
+ def test_leading_dash_value_is_not_swallowed_as_the_positional(self):
598
+ # `--energy 2 --mood 3` keeps parsing; words stays empty, no crash.
599
+ self.assertOk("checkin", "--energy", "2", "--mood", "3")
600
+ rows = json.loads(self.assertOk("mood", ":day", "--json"))
601
+ self.assertEqual(rows[0]["energy"], 2)
602
+ self.assertEqual(rows[0]["mood"], 3)
603
+
604
+ def test_non_integer_first_word_is_a_note_not_a_rating(self):
605
+ code, output = self.run_klok("checkin", "just", "a", "note")
606
+ self.assertEqual(code, 1)
607
+ self.assertIn("at least one", output)
608
+
609
+ def test_stop_felt_records_an_energy_checkin_at_the_block_start(self):
610
+ self.assertOk("start", "acme", "--at", "09:00")
611
+ output = self.assertOk("stop", "--at", "10:30", "--felt", "4")
612
+ self.assertIn("energy 4", output)
613
+ self.assertEqual(self.energies(), [(4, "09:00")])
614
+
615
+ def test_stop_felt_rejects_an_out_of_range_value_without_stopping(self):
616
+ self.assertOk("start", "acme", "--at", "09:00")
617
+ code, output = self.run_klok("stop", "--at", "10:30", "--felt", "9")
618
+ self.assertEqual(code, 1)
619
+ self.assertIn("between 1 and 5", output)
620
+ # The frame was not stopped and no check-in was written.
621
+ self.assertEqual(self.energies(), [])
622
+ payload = json.loads(self.assertOk("status", "--json"))
623
+ self.assertTrue(payload["running"])
624
+
625
+
626
+ class ReviewTests(CliTestCase):
627
+ """The end-of-day `klok review` energy ritual."""
628
+
629
+ def energies(self):
630
+ path = self.home / "checkins.jsonl"
631
+ if not path.exists():
632
+ return []
633
+ out = []
634
+ for line in path.read_text(encoding="utf-8").splitlines():
635
+ line = line.strip()
636
+ if not line:
637
+ continue
638
+ item = json.loads(line)
639
+ out.append(item)
640
+ return sorted(out, key=lambda item: item["at"])
641
+
642
+ def two_blocks(self):
643
+ self.assertOk("track", "09:00", "to", "11:00", "acme", "+deep")
644
+ self.assertOk("track", "11:00", "to", "11:20", "email")
645
+
646
+ def test_happy_path_rates_each_block_at_its_start(self):
647
+ self.two_blocks()
648
+ output = self.assertOk("review", answers=["4", "2"])
649
+ self.assertIn("acme +deep", output)
650
+ self.assertIn("(09:00-11:00)", output)
651
+ self.assertIn("Rated 2 blocks", output)
652
+ energies = [(c["energy"], c["at"][11:16]) for c in self.energies()]
653
+ self.assertEqual(energies, [(4, "09:00"), (2, "11:00")])
654
+
655
+ def test_blank_line_skips_a_block(self):
656
+ self.two_blocks()
657
+ self.assertOk("review", answers=["", "3"])
658
+ energies = [(c["energy"], c["at"][11:16]) for c in self.energies()]
659
+ self.assertEqual(energies, [(3, "11:00")])
660
+
661
+ def test_q_quits_the_walk(self):
662
+ self.two_blocks()
663
+ output = self.assertOk("review", answers=["4", "q"])
664
+ self.assertIn("Rated 1 block.", output)
665
+ energies = [c["energy"] for c in self.energies()]
666
+ self.assertEqual(energies, [4])
667
+
668
+ def test_back_re_rates_without_duplicating(self):
669
+ self.two_blocks()
670
+ # Rate block 0 as 3, step back from block 1, re-rate block 0 as 5, skip block 1.
671
+ self.assertOk("review", answers=["3", "b", "5", ""])
672
+ energies = [(c["energy"], c["at"][11:16]) for c in self.energies()]
673
+ self.assertEqual(energies, [(5, "09:00")])
674
+
675
+ def test_redo_replaces_an_existing_rating(self):
676
+ self.two_blocks()
677
+ self.assertOk("review", answers=["4", "2"])
678
+ self.assertOk("review", "--redo", answers=["1", "5"])
679
+ energies = [(c["energy"], c["at"][11:16]) for c in self.energies()]
680
+ self.assertEqual(energies, [(1, "09:00"), (5, "11:00")])
681
+
682
+ def test_already_rated_blocks_are_skipped_without_redo(self):
683
+ self.two_blocks()
684
+ self.assertOk("review", answers=["4"]) # rate only block 0
685
+ # A second plain review only offers the still-unrated block.
686
+ output = self.assertOk("review", answers=["3"])
687
+ self.assertIn("email", output)
688
+ self.assertNotIn("acme +deep", output)
689
+ energies = [(c["energy"], c["at"][11:16]) for c in self.energies()]
690
+ self.assertEqual(energies, [(4, "09:00"), (3, "11:00")])
691
+
692
+ def test_mood_only_checkin_does_not_mask_an_unrated_block(self):
693
+ self.assertOk("track", "09:00", "to", "11:00", "acme")
694
+ # A mood-only check-in inside the block has energy None.
695
+ self.assertOk("checkin", "--mood", "4", "--at", "10:00")
696
+ output = self.assertOk("review", answers=["3"])
697
+ self.assertIn("acme", output)
698
+ self.assertIn("Rated 1 block", output)
699
+ energies = [(c["energy"], c["at"][11:16]) for c in self.energies()
700
+ if c["energy"] is not None]
701
+ self.assertEqual(energies, [(3, "09:00")])
702
+
703
+ def test_practice_and_running_frames_are_left_out(self):
704
+ self.assertOk("track", "09:00", "to", "10:00", "acme")
705
+ # A practice session (meditation tag) on the default work sheet.
706
+ self.assertOk("track", "10:00", "to", "10:20", "mindfulness", "+meditation")
707
+ # A still-running frame.
708
+ self.assertOk("start", "later", "--at", "12:00")
709
+ output = self.assertOk("review", answers=["4", "4", "4"])
710
+ self.assertIn("acme", output)
711
+ self.assertNotIn("mindfulness", output)
712
+ self.assertNotIn("later", output)
713
+ self.assertIn("Rated 1 block", output)
714
+ energies = [(c["energy"], c["at"][11:16]) for c in self.energies()]
715
+ self.assertEqual(energies, [(4, "09:00")])
716
+
717
+ def test_default_scope_is_the_active_sheet_only(self):
718
+ self.assertOk("track", "09:00", "to", "10:00", "acme")
719
+ self.assertOk("track", "10:00", "to", "11:00", "sideproj", "--sheet", "personal")
720
+ # Plain review walks the active sheet (default) only.
721
+ output = self.assertOk("review", answers=["4"])
722
+ self.assertIn("acme", output)
723
+ self.assertNotIn("sideproj", output)
724
+ self.assertEqual([c["energy"] for c in self.energies()], [4])
725
+
726
+ def test_all_sheets_widens_the_scope(self):
727
+ self.assertOk("track", "09:00", "to", "10:00", "acme")
728
+ self.assertOk("track", "10:00", "to", "11:00", "sideproj", "--sheet", "personal")
729
+ output = self.assertOk("review", "--all-sheets", answers=["4", "5"])
730
+ self.assertIn("acme", output)
731
+ self.assertIn("sideproj", output)
732
+ energies = [(c["energy"], c["at"][11:16]) for c in self.energies()]
733
+ self.assertEqual(energies, [(4, "09:00"), (5, "10:00")])
734
+
735
+ def test_invalid_answer_reprompts_the_same_block(self):
736
+ self.two_blocks()
737
+ # "9" is out of range: re-prompt the same block, then accept 4.
738
+ output = self.assertOk("review", answers=["9", "4", "2"])
739
+ self.assertIn("between 1 and 5", output)
740
+ energies = [(c["energy"], c["at"][11:16]) for c in self.energies()]
741
+ self.assertEqual(energies, [(4, "09:00"), (2, "11:00")])
742
+
743
+ def test_nothing_to_review_is_a_clean_no_op(self):
744
+ output = self.assertOk("review")
745
+ self.assertIn("No blocks to review", output)
746
+
747
+
545
748
  class DashboardTests(CliTestCase):
546
749
  def payload(self, *args):
547
750
  out = self.home / "dash.html"
@@ -659,5 +862,91 @@ class HelpAndErrorsTests(CliTestCase):
659
862
  self.assertNotIn("Nothing tracked yet", output)
660
863
 
661
864
 
865
+ class DigestTests(CliTestCase):
866
+ """`klok digest`: project/tag drain-vs-lift from the energy ratings."""
867
+
868
+ def ten_rated_blocks(self):
869
+ # Five draining acme blocks, five lifting writing blocks, same day.
870
+ for hour in range(1, 6):
871
+ self.assertOk("track", "0%d:00" % hour, "to", "0%d:45" % hour, "acme", "+reviews")
872
+ for hour in range(1, 6):
873
+ self.assertOk("track", "1%d:00" % hour, "to", "1%d:45" % hour, "writing", "+deep")
874
+ # review walks in start order: the five acme blocks, then the five writing blocks.
875
+ self.assertOk("review", answers=["2"] * 5 + ["5"] * 5)
876
+
877
+ def test_digest_surfaces_drain_and_lift(self):
878
+ self.ten_rated_blocks()
879
+ output = self.assertOk("digest")
880
+ self.assertIn("acme drains you", output)
881
+ self.assertIn("writing lifts you", output)
882
+ self.assertIn("+reviews drains you", output)
883
+ self.assertIn("Based on 10 rated blocks across 1 day.", output)
884
+
885
+ def test_digest_with_no_ratings_says_so(self):
886
+ output = self.assertOk("digest")
887
+ self.assertIn("Nothing rated yet", output)
888
+ self.assertIn("Based on 0 rated blocks across 0 days.", output)
889
+
890
+ def test_too_few_blocks_report_no_pattern(self):
891
+ self.assertOk("track", "09:00", "to", "10:00", "acme")
892
+ self.assertOk("review", answers=["5"])
893
+ output = self.assertOk("digest")
894
+ self.assertIn("No clear pattern yet", output)
895
+ self.assertIn("Based on 1 rated block across 1 day.", output)
896
+
897
+ def test_digest_writes_a_plain_file(self):
898
+ self.ten_rated_blocks()
899
+ path = self.home / "digest.txt"
900
+ self.assertOk("digest", "-o", str(path))
901
+ text = path.read_text(encoding="utf-8")
902
+ self.assertIn("drains you", text)
903
+ self.assertIn("Based on 10 rated blocks", text)
904
+ self.assertNotIn("\x1b[", text) # no terminal colour codes in the shared copy
905
+
906
+
907
+ class CheckinReplaceRegressionTests(CliTestCase):
908
+ """Re-rating a block must not destroy the rest of a check-in, and
909
+ degenerate (zero-duration) blocks must not be rated or duplicated."""
910
+
911
+ def checkins(self):
912
+ path = self.home / "checkins.jsonl"
913
+ if not path.exists():
914
+ return []
915
+ out = []
916
+ for line in path.read_text(encoding="utf-8").splitlines():
917
+ line = line.strip()
918
+ if line:
919
+ out.append(json.loads(line))
920
+ return sorted(out, key=lambda item: item["at"])
921
+
922
+ def test_stop_felt_keeps_a_logged_note(self):
923
+ # The flagship repro: a rich check-in inside the block, then `stop --felt`.
924
+ self.assertOk("start", "acme", "--at", "09:00")
925
+ self.assertOk("checkin", "4", "rough", "meeting", "--at", "09:30")
926
+ self.assertOk("stop", "--at", "11:00", "--felt", "2")
927
+ items = self.checkins()
928
+ self.assertEqual(len(items), 1) # no duplicate
929
+ self.assertEqual(items[0]["energy"], 2) # energy re-rated
930
+ self.assertEqual(items[0]["note"], "rough meeting") # note preserved
931
+
932
+ def test_review_redo_keeps_a_logged_note(self):
933
+ self.assertOk("track", "09:00", "to", "11:00", "acme")
934
+ self.assertOk("checkin", "3", "standup", "notes", "--at", "09:30")
935
+ self.assertOk("review", "--redo", answers=["5"])
936
+ items = self.checkins()
937
+ self.assertEqual(len(items), 1)
938
+ self.assertEqual(items[0]["energy"], 5)
939
+ self.assertEqual(items[0]["note"], "standup notes")
940
+
941
+ def test_zero_duration_block_is_skipped(self):
942
+ # A block that starts and stops at the same instant has an empty window;
943
+ # review must leave it out rather than orphan or duplicate a rating.
944
+ self.assertOk("start", "acme", "--at", "10:00")
945
+ self.assertOk("stop", "--at", "10:00")
946
+ output = self.assertOk("review", answers=["4"])
947
+ self.assertIn("No blocks to review", output)
948
+ self.assertEqual(self.checkins(), [])
949
+
950
+
662
951
  if __name__ == "__main__":
663
952
  unittest.main()
@@ -0,0 +1,144 @@
1
+ """Tests for the project/tag drain analyzer (klok.insights).
2
+
3
+ The analyzer joins energy-bearing check-ins to the frame each fell inside,
4
+ means them per block, and reports a project or tag only when it clears the
5
+ suppression rule (``n >= MIN_BLOCKS`` and ``|effect| >= MIN_EFFECT``). These
6
+ build frames and check-ins directly and lock the exact Finding values.
7
+ """
8
+
9
+ import unittest
10
+ from datetime import datetime, timedelta, timezone
11
+
12
+ from klok.insights import MIN_BLOCKS, MIN_EFFECT, Finding, drain_findings
13
+ from klok.mindful import Checkin
14
+ from klok.model import Frame
15
+
16
+ TZ = timezone.utc
17
+ BASE = datetime(2026, 9, 1, tzinfo=TZ)
18
+ NOW = BASE + timedelta(days=2)
19
+
20
+
21
+ def block(index, project="", tags=(), energies=(3,)):
22
+ """One 30-minute frame plus its check-ins, stamped at the block start."""
23
+ start = BASE + timedelta(hours=index)
24
+ frame = Frame(start=start, stop=start + timedelta(minutes=30),
25
+ project=project, tags=list(tags))
26
+ checks = [Checkin(at=start, energy=energy) for energy in energies]
27
+ return frame, checks
28
+
29
+
30
+ def scenario(*blocks):
31
+ frames, checkins = [], []
32
+ for frame, checks in blocks:
33
+ frames.append(frame)
34
+ checkins.extend(checks)
35
+ return frames, checkins
36
+
37
+
38
+ class ConstantsTests(unittest.TestCase):
39
+ def test_thresholds_are_the_agreed_defaults(self):
40
+ self.assertEqual(MIN_BLOCKS, 5)
41
+ self.assertEqual(MIN_EFFECT, 0.5)
42
+
43
+
44
+ class SuppressionTests(unittest.TestCase):
45
+ def test_no_checkins_means_no_findings(self):
46
+ frames = [Frame(start=BASE, stop=BASE + timedelta(hours=1), project="acme")]
47
+ self.assertEqual(drain_findings(frames, [], NOW), [])
48
+
49
+ def test_checkin_outside_every_frame_is_ignored(self):
50
+ frame = Frame(start=BASE, stop=BASE + timedelta(hours=1), project="acme")
51
+ stray = Checkin(at=BASE + timedelta(hours=5), energy=1)
52
+ self.assertEqual(drain_findings([frame], [stray], NOW), [])
53
+
54
+ def test_single_rated_block_is_suppressed(self):
55
+ # one rated block: n = 1, well under MIN_BLOCKS
56
+ frames, checkins = scenario(block(0, project="acme", energies=(1,)))
57
+ self.assertEqual(drain_findings(frames, checkins, NOW), [])
58
+
59
+ def test_all_equal_energy_is_suppressed(self):
60
+ # six rated blocks, every one energy 3: the effect is exactly 0
61
+ frames, checkins = scenario(*[block(i, project="acme", energies=(3,))
62
+ for i in range(6)])
63
+ self.assertEqual(drain_findings(frames, checkins, NOW), [])
64
+
65
+
66
+ class ProjectFindingTests(unittest.TestCase):
67
+ def test_clear_drain_and_lift_surface_ranked(self):
68
+ blocks = []
69
+ # five draining acme blocks; each block averages (1 + 3) / 2 = 2.0
70
+ for i in range(5):
71
+ blocks.append(block(i, project="acme", energies=(1, 3)))
72
+ # five lifting writing blocks at energy 4
73
+ for i in range(5, 10):
74
+ blocks.append(block(i, project="writing", energies=(4,)))
75
+ frames, checkins = scenario(*blocks)
76
+
77
+ findings = drain_findings(frames, checkins, NOW)
78
+
79
+ # overall = (5 * 2.0 + 5 * 4.0) / 10 = 3.0; ranked most draining first
80
+ self.assertEqual(len(findings), 2)
81
+ drain, lift = findings
82
+ self.assertEqual(drain, Finding(
83
+ headline="acme drains you",
84
+ detail="averages 1.0 points below your overall energy over 5 blocks",
85
+ effect_size=-1.0, n=5))
86
+ self.assertEqual(lift, Finding(
87
+ headline="writing lifts you",
88
+ detail="averages 1.0 points above your overall energy over 5 blocks",
89
+ effect_size=1.0, n=5))
90
+
91
+
92
+ class TagFindingTests(unittest.TestCase):
93
+ def test_tag_findings_surface_while_the_uniform_project_is_suppressed(self):
94
+ blocks = []
95
+ for i in range(5):
96
+ blocks.append(block(i, project="work", tags=("reviews",), energies=(2,)))
97
+ for i in range(5, 10):
98
+ blocks.append(block(i, project="work", tags=("deep",), energies=(4,)))
99
+ frames, checkins = scenario(*blocks)
100
+
101
+ findings = drain_findings(frames, checkins, NOW)
102
+
103
+ # project "work" covers all ten blocks: mean 3.0 == overall, effect 0
104
+ headlines = [finding.headline for finding in findings]
105
+ self.assertNotIn("work drains you", headlines)
106
+ self.assertNotIn("work lifts you", headlines)
107
+ self.assertEqual(len(findings), 2)
108
+ drain, lift = findings
109
+ self.assertEqual(drain, Finding(
110
+ headline="+reviews drains you",
111
+ detail="averages 1.0 points below your overall energy over 5 blocks",
112
+ effect_size=-1.0, n=5))
113
+ self.assertEqual(lift, Finding(
114
+ headline="+deep lifts you",
115
+ detail="averages 1.0 points above your overall energy over 5 blocks",
116
+ effect_size=1.0, n=5))
117
+
118
+
119
+ class MoodOnlyTests(unittest.TestCase):
120
+ def test_mood_only_checkin_creates_no_block(self):
121
+ frame = Frame(start=BASE, stop=BASE + timedelta(hours=1), project="acme")
122
+ mood = Checkin(at=BASE + timedelta(minutes=10), mood=4) # energy is None
123
+ self.assertEqual(drain_findings([frame], [mood], NOW), [])
124
+
125
+ def test_mood_only_checkin_does_not_inflate_a_rated_project(self):
126
+ blocks = [block(i, project="acme", energies=(2,)) for i in range(5)]
127
+ blocks += [block(i, project="writing", energies=(4,)) for i in range(5, 10)]
128
+ frames, checkins = scenario(*blocks)
129
+ # a sixth acme frame whose only check-in is mood-only (energy None)
130
+ extra_start = BASE + timedelta(hours=20)
131
+ frames.append(Frame(start=extra_start, stop=extra_start + timedelta(minutes=30),
132
+ project="acme"))
133
+ checkins.append(Checkin(at=extra_start, mood=5))
134
+
135
+ findings = drain_findings(frames, checkins, NOW)
136
+
137
+ acme = next(finding for finding in findings
138
+ if finding.headline == "acme drains you")
139
+ self.assertEqual(acme.n, 5) # the mood-only block did not count
140
+ self.assertEqual(acme.effect_size, -1.0)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ unittest.main()
@@ -0,0 +1,91 @@
1
+ """Tests for the shared pairing helpers (klok.pairing).
2
+
3
+ frame_at and is_practice were extracted here from dashboard.py so the
4
+ dashboard, the review ritual and the insights digest share one join. These
5
+ lock the join's exact behavior (half-open, latest-started wins, practice
6
+ classification) and a dashboard-payload invariant so the extraction can be
7
+ proven behavior-preserving.
8
+ """
9
+
10
+ import unittest
11
+ from datetime import datetime, timedelta, timezone
12
+
13
+ from klok.dashboard import build_payload
14
+ from klok.mindful import BREATHING_TAG, Checkin
15
+ from klok.model import Frame
16
+ from klok.pairing import frame_at, is_practice
17
+
18
+ TZ = timezone.utc
19
+
20
+
21
+ def at(hour, minute=0):
22
+ return datetime(2026, 9, 1, hour, minute, tzinfo=TZ)
23
+
24
+
25
+ class FrameAtTests(unittest.TestCase):
26
+ def setUp(self):
27
+ self.now = at(23)
28
+ self.a = Frame(start=at(9), stop=at(11), project="acme")
29
+ self.b = Frame(start=at(11), stop=at(12), project="internal")
30
+
31
+ def test_moment_inside_a_frame_matches_it(self):
32
+ self.assertIs(frame_at([self.a, self.b], at(10), self.now), self.a)
33
+
34
+ def test_start_is_inclusive_end_is_exclusive(self):
35
+ # half-open: start belongs to the block, the stop instant does not.
36
+ self.assertIs(frame_at([self.a, self.b], at(9), self.now), self.a)
37
+ self.assertIs(frame_at([self.a, self.b], at(11), self.now), self.b)
38
+
39
+ def test_moment_outside_every_frame_is_none(self):
40
+ self.assertIsNone(frame_at([self.a, self.b], at(8), self.now))
41
+ self.assertIsNone(frame_at([self.a, self.b], at(12), self.now))
42
+
43
+ def test_overlap_goes_to_the_latest_started(self):
44
+ outer = Frame(start=at(9), stop=at(12), project="outer")
45
+ inner = Frame(start=at(10), stop=at(11), project="inner")
46
+ self.assertIs(frame_at([outer, inner], at(10, 30), self.now), inner)
47
+
48
+ def test_running_frame_uses_now_for_its_end(self):
49
+ running = Frame(start=at(9), stop=None, project="acme")
50
+ self.assertIs(frame_at([running], at(15), self.now), running)
51
+ self.assertIsNone(frame_at([running], at(23), at(23)))
52
+
53
+
54
+ class IsPracticeTests(unittest.TestCase):
55
+ def test_practice_tag_marks_it(self):
56
+ self.assertTrue(is_practice(Frame(start=at(9), tags=[BREATHING_TAG])))
57
+
58
+ def test_practice_sheet_marks_it(self):
59
+ frame = Frame(start=at(9), sheet="wellbeing")
60
+ self.assertTrue(is_practice(frame, practice_sheet="wellbeing"))
61
+ self.assertFalse(is_practice(frame, practice_sheet="default"))
62
+
63
+ def test_plain_work_is_not_practice(self):
64
+ self.assertFalse(is_practice(Frame(start=at(9), project="acme")))
65
+
66
+
67
+ class DashboardPayloadRegressionTests(unittest.TestCase):
68
+ """Locks the dashboard's join output so moving frame_at cannot drift it."""
69
+
70
+ def test_energy_mean_joins_checkins_to_their_frame(self):
71
+ now = at(23)
72
+ frames = [
73
+ Frame(start=at(9), stop=at(11), project="acme"),
74
+ Frame(start=at(11), stop=at(12), project="internal"),
75
+ ]
76
+ checkins = [
77
+ Checkin(at=at(10), energy=4), # inside acme
78
+ Checkin(at=at(10, 30), energy=2), # inside acme
79
+ Checkin(at=at(11, 30), energy=5), # inside internal
80
+ ]
81
+ payload = build_payload(frames, checkins, now)
82
+ energy = {p["name"]: p["energy"] for p in payload["projects"]}
83
+ self.assertEqual(energy["acme"], 3.0) # (4 + 2) / 2
84
+ self.assertEqual(energy["internal"], 5.0)
85
+ # each check-in carries the project it joined to
86
+ self.assertEqual([c["project"] for c in payload["checkins"]],
87
+ ["acme", "acme", "internal"])
88
+
89
+
90
+ if __name__ == "__main__":
91
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes