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.
- {klok_cli-1.1.0/klok_cli.egg-info → klok_cli-1.2.0}/PKG-INFO +30 -1
- {klok_cli-1.1.0 → klok_cli-1.2.0}/README.md +29 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/__init__.py +1 -1
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/cli.py +175 -7
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/dashboard.py +2 -18
- klok_cli-1.2.0/klok/insights.py +107 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/mindful.py +37 -0
- klok_cli-1.2.0/klok/pairing.py +39 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0/klok_cli.egg-info}/PKG-INFO +30 -1
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok_cli.egg-info/SOURCES.txt +4 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/pyproject.toml +1 -1
- {klok_cli-1.1.0 → klok_cli-1.2.0}/tests/test_cli.py +291 -2
- klok_cli-1.2.0/tests/test_insights.py +144 -0
- klok_cli-1.2.0/tests/test_pairing.py +91 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/LICENSE +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/__main__.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/config.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/dashboard.html +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/focus.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/formats.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/model.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/report.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/storage.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/timeparse.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok/utils.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok_cli.egg-info/dependency_links.txt +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok_cli.egg-info/entry_points.txt +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/klok_cli.egg-info/top_level.txt +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/setup.cfg +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/tests/test_mindful.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.0}/tests/test_storage.py +0 -0
- {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.
|
|
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
|
|
@@ -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(
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|