klok-cli 1.1.0__tar.gz → 1.2.1__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.1}/PKG-INFO +30 -1
- {klok_cli-1.1.0 → klok_cli-1.2.1}/README.md +29 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/__init__.py +1 -1
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/cli.py +175 -7
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/dashboard.html +82 -67
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/dashboard.py +2 -18
- klok_cli-1.2.1/klok/insights.py +107 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/mindful.py +37 -0
- klok_cli-1.2.1/klok/pairing.py +39 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1/klok_cli.egg-info}/PKG-INFO +30 -1
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok_cli.egg-info/SOURCES.txt +4 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/pyproject.toml +1 -1
- {klok_cli-1.1.0 → klok_cli-1.2.1}/tests/test_cli.py +291 -2
- klok_cli-1.2.1/tests/test_insights.py +144 -0
- klok_cli-1.2.1/tests/test_pairing.py +91 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/LICENSE +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/__main__.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/config.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/focus.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/formats.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/model.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/report.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/storage.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/timeparse.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/utils.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok_cli.egg-info/dependency_links.txt +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok_cli.egg-info/entry_points.txt +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/klok_cli.egg-info/top_level.txt +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/setup.cfg +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/tests/test_mindful.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/tests/test_storage.py +0 -0
- {klok_cli-1.1.0 → klok_cli-1.2.1}/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
|
|
3
|
+
Version: 1.2.1
|
|
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))
|
|
@@ -5,82 +5,96 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
6
|
<title>Hours & Feelings</title>
|
|
7
7
|
<meta name="description" content="A klok dashboard that shows not just where your time went, but how it felt.">
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
<!-- Offline-first: the whole page is one self-contained file with no network
|
|
9
|
+
fetches. Type is the system font stack (thin Helvetica / system mono), so
|
|
10
|
+
the dashboard opens straight from disk with nothing to load. -->
|
|
11
11
|
|
|
12
12
|
<style>
|
|
13
13
|
/* ---- layout concept: single-column calm instrument panel; one color language (feeling ramp) tints every block of time; serif = feeling, mono = measurement ---- */
|
|
14
14
|
:root{
|
|
15
|
-
/* light — "
|
|
16
|
-
--
|
|
17
|
-
--
|
|
18
|
-
--
|
|
19
|
-
--
|
|
20
|
-
--
|
|
21
|
-
--
|
|
22
|
-
--
|
|
23
|
-
--
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
--
|
|
27
|
-
--
|
|
28
|
-
--
|
|
29
|
-
--
|
|
30
|
-
--
|
|
15
|
+
/* light — "Renaissance daylight": parchment + sky, muted & earthy, one magenta pop */
|
|
16
|
+
--paper: #e7e1d3; /* base parchment */
|
|
17
|
+
--sky: #cdd7da; /* upper light wash */
|
|
18
|
+
--land: #c6ccb0; /* lower foliage warmth */
|
|
19
|
+
--plane: #e7e1d3;
|
|
20
|
+
--surface: rgba(250,248,242,.70); /* frosted-glass card */
|
|
21
|
+
--surface-2: rgba(238,235,227,.78); /* inset / track */
|
|
22
|
+
--ink: #26231c; /* primary text (warm near-black) */
|
|
23
|
+
--ink-2: #595345; /* secondary */
|
|
24
|
+
--muted: #6b6452; /* axis / captions (AA on the parchment ground) */
|
|
25
|
+
--line: rgba(92,80,58,.20); /* hairline */
|
|
26
|
+
--line-2: rgba(92,80,58,.32);
|
|
27
|
+
--accent: #cf1d84; /* the magenta pop — chrome / magnitude / highlight */
|
|
28
|
+
--accent-2: #a8166a;
|
|
29
|
+
--clay: #b05a3f; /* stress hue — terracotta */
|
|
30
|
+
--teal: #5f7d53; /* mood hue — sage / olive */
|
|
31
|
+
--tip-bg: #26231c;
|
|
32
|
+
--tip-fg: #faf8f2;
|
|
33
|
+
--shadow: 0 2px 6px rgba(40,35,25,.06), 0 18px 44px rgba(40,35,25,.10);
|
|
31
34
|
color-scheme: light;
|
|
32
35
|
}
|
|
33
36
|
@media (prefers-color-scheme: dark){
|
|
34
37
|
:root:not([data-theme="light"]){
|
|
35
|
-
--
|
|
36
|
-
--
|
|
37
|
-
--
|
|
38
|
-
--
|
|
39
|
-
--
|
|
40
|
-
--
|
|
41
|
-
--
|
|
42
|
-
--
|
|
43
|
-
--
|
|
44
|
-
--
|
|
45
|
-
--
|
|
46
|
-
--
|
|
47
|
-
--
|
|
48
|
-
--
|
|
49
|
-
--
|
|
38
|
+
--paper: #121813;
|
|
39
|
+
--sky: #27313a;
|
|
40
|
+
--land: #182414;
|
|
41
|
+
--plane: #121813;
|
|
42
|
+
--surface: rgba(28,34,26,.52);
|
|
43
|
+
--surface-2: rgba(20,26,19,.62);
|
|
44
|
+
--ink: #ece7da;
|
|
45
|
+
--ink-2: #b6afa0;
|
|
46
|
+
--muted: #85806f;
|
|
47
|
+
--line: rgba(225,215,190,.14);
|
|
48
|
+
--line-2: rgba(225,215,190,.22);
|
|
49
|
+
--accent: #ef4aa6;
|
|
50
|
+
--accent-2: #f472b8;
|
|
51
|
+
--clay: #cf7a5a;
|
|
52
|
+
--teal: #83a271;
|
|
53
|
+
--tip-bg: #ece7da;
|
|
54
|
+
--tip-fg: #141b16;
|
|
55
|
+
--shadow: 0 2px 6px rgba(0,0,0,.35), 0 18px 44px rgba(0,0,0,.45);
|
|
50
56
|
color-scheme: dark;
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
59
|
:root[data-theme="dark"]{
|
|
54
|
-
--
|
|
55
|
-
--
|
|
56
|
-
--
|
|
57
|
-
--
|
|
58
|
-
--
|
|
59
|
-
--
|
|
60
|
-
--
|
|
61
|
-
--
|
|
62
|
-
--
|
|
63
|
-
--
|
|
64
|
-
--
|
|
65
|
-
--
|
|
66
|
-
--
|
|
67
|
-
--
|
|
68
|
-
--
|
|
60
|
+
--paper: #121813;
|
|
61
|
+
--sky: #27313a;
|
|
62
|
+
--land: #182414;
|
|
63
|
+
--plane: #121813;
|
|
64
|
+
--surface: rgba(28,34,26,.52);
|
|
65
|
+
--surface-2: rgba(20,26,19,.62);
|
|
66
|
+
--ink: #ece7da;
|
|
67
|
+
--ink-2: #b6afa0;
|
|
68
|
+
--muted: #85806f;
|
|
69
|
+
--line: rgba(225,215,190,.14);
|
|
70
|
+
--line-2: rgba(225,215,190,.22);
|
|
71
|
+
--accent: #ef4aa6;
|
|
72
|
+
--accent-2: #f472b8;
|
|
73
|
+
--clay: #cf7a5a;
|
|
74
|
+
--teal: #83a271;
|
|
75
|
+
--tip-bg: #ece7da;
|
|
76
|
+
--tip-fg: #141b16;
|
|
77
|
+
--shadow: 0 2px 6px rgba(0,0,0,.35), 0 18px 44px rgba(0,0,0,.45);
|
|
69
78
|
color-scheme: dark;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
*{ box-sizing:border-box; }
|
|
73
82
|
body{
|
|
74
|
-
margin:0;
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
margin:0; color:var(--ink);
|
|
84
|
+
background:
|
|
85
|
+
radial-gradient(135% 95% at 50% -20%, var(--sky) 0%, transparent 56%),
|
|
86
|
+
radial-gradient(110% 75% at 85% 118%, var(--land) 0%, transparent 60%),
|
|
87
|
+
var(--paper);
|
|
88
|
+
background-attachment:fixed;
|
|
89
|
+
font-family:"Helvetica Neue", Helvetica, -apple-system, system-ui, "Segoe UI", "Noto Sans JP", Arial, sans-serif;
|
|
90
|
+
font-size:15px; line-height:1.55; font-weight:380; -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
|
|
77
91
|
}
|
|
78
|
-
.wrap{ max-width:1080px; margin:0 auto; padding-inline:20px; padding-block:0
|
|
92
|
+
.wrap{ max-width:1080px; margin:0 auto; padding-inline:20px; padding-block:0 96px; counter-reset:sec; }
|
|
79
93
|
.mono{ font-family:"IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace; font-variant-numeric:tabular-nums; }
|
|
80
|
-
.serif{ font-family:"
|
|
94
|
+
.serif{ font-family:Georgia,"Iowan Old Style","Palatino Linotype","Times New Roman",serif; }
|
|
81
95
|
|
|
82
96
|
a{ color:var(--accent); }
|
|
83
|
-
h1,h2,h3{ margin:0; text-wrap:balance; font-family:"
|
|
97
|
+
h1,h2,h3{ margin:0; text-wrap:balance; font-family:Georgia,"Iowan Old Style","Palatino Linotype","Times New Roman",serif; font-weight:400; letter-spacing:-.005em; }
|
|
84
98
|
|
|
85
99
|
/* ---------- header ---------- */
|
|
86
100
|
header.top{
|
|
@@ -92,9 +106,9 @@ header.top{
|
|
|
92
106
|
}
|
|
93
107
|
.brand{ display:flex; align-items:baseline; gap:12px; min-width:0; }
|
|
94
108
|
.logo{ font-family:"IBM Plex Mono",monospace; font-weight:600; font-size:19px; letter-spacing:.02em; color:var(--ink); }
|
|
95
|
-
.logo b{ color:var(--
|
|
109
|
+
.logo b{ color:var(--accent); font-weight:600; }
|
|
96
110
|
.brand .cmd{ font-family:"IBM Plex Mono",monospace; font-size:12px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
97
|
-
.brand .cmd .caret{ color:var(--
|
|
111
|
+
.brand .cmd .caret{ color:var(--accent); }
|
|
98
112
|
.controls{ display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
|
99
113
|
.seg{ display:inline-flex; background:var(--surface-2); border:1px solid var(--line); border-radius:999px; padding:3px; }
|
|
100
114
|
.seg button{
|
|
@@ -110,15 +124,15 @@ header.top{
|
|
|
110
124
|
button:focus-visible, a:focus-visible, [tabindex]:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; border-radius:4px; }
|
|
111
125
|
|
|
112
126
|
/* ---------- hero ---------- */
|
|
113
|
-
.hero{ padding:
|
|
114
|
-
.hero h1{ font-size:clamp(
|
|
115
|
-
.hero h1 em{ font-style:italic; color:var(--
|
|
127
|
+
.hero{ padding:40px 0 12px; }
|
|
128
|
+
.hero h1{ font-size:clamp(38px,8vw,84px); line-height:1.0; letter-spacing:-.02em; font-weight:400; }
|
|
129
|
+
.hero h1 em{ font-style:italic; color:var(--accent); }
|
|
116
130
|
.lede{ margin:18px 0 0; max-width:60ch; color:var(--ink-2); font-size:clamp(15px,2.2vw,18px); line-height:1.5; }
|
|
117
131
|
.lede strong{ color:var(--ink); font-weight:600; }
|
|
118
132
|
|
|
119
133
|
/* ---------- stat tiles ---------- */
|
|
120
134
|
.tiles{ display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-top:26px; }
|
|
121
|
-
.tile{ background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:16px 16px 14px; box-shadow:var(--shadow); min-width:0; }
|
|
135
|
+
.tile{ background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:16px 16px 14px; box-shadow:var(--shadow); min-width:0; backdrop-filter:blur(14px) saturate(1.15); -webkit-backdrop-filter:blur(14px) saturate(1.15); }
|
|
122
136
|
.tile .k{ font-family:"IBM Plex Mono",monospace; font-size:11px; letter-spacing:.06em; text-transform:lowercase; color:var(--muted); }
|
|
123
137
|
.tile .v{ font-family:"IBM Plex Mono",monospace; font-weight:500; font-size:clamp(22px,3.3vw,30px); margin-top:6px; line-height:1; color:var(--ink); }
|
|
124
138
|
.tile .v small{ font-size:.5em; color:var(--muted); font-weight:400; margin-left:3px; }
|
|
@@ -127,11 +141,12 @@ button:focus-visible, a:focus-visible, [tabindex]:focus-visible{ outline:2px sol
|
|
|
127
141
|
|
|
128
142
|
/* ---------- section + cards ---------- */
|
|
129
143
|
section{ margin-top:40px; }
|
|
130
|
-
.sec-head{ display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; }
|
|
131
|
-
.sec-head h2{ font-size:clamp(
|
|
144
|
+
.sec-head{ display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; counter-increment:sec; }
|
|
145
|
+
.sec-head h2{ font-size:clamp(22px,3vw,28px); }
|
|
146
|
+
.sec-head h2::before{ content:counter(sec,upper-roman) "\00a0\00a0"; font-family:"IBM Plex Mono",monospace; font-weight:600; font-size:.5em; letter-spacing:.1em; color:var(--accent-2); vertical-align:.2em; }
|
|
132
147
|
.sec-head p{ margin:4px 0 0; color:var(--ink-2); font-size:13.5px; max-width:52ch; }
|
|
133
148
|
.sec-head .hint{ font-family:"IBM Plex Mono",monospace; font-size:11px; color:var(--muted); white-space:nowrap; }
|
|
134
|
-
.card{ background:var(--surface); border:1px solid var(--line); border-radius:16px; padding:20px; box-shadow:var(--shadow); }
|
|
149
|
+
.card{ background:var(--surface); border:1px solid var(--line); border-radius:16px; padding:20px; box-shadow:var(--shadow); backdrop-filter:blur(14px) saturate(1.15); -webkit-backdrop-filter:blur(14px) saturate(1.15); }
|
|
135
150
|
|
|
136
151
|
/* ---------- bubble (energy–time map) ---------- */
|
|
137
152
|
.map-wrap{ position:relative; }
|
|
@@ -191,7 +206,7 @@ table.feel tr:last-child td{ border-bottom:0; }
|
|
|
191
206
|
.trend-grid{ display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }
|
|
192
207
|
.trend{ background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:14px 14px 10px; box-shadow:var(--shadow); min-width:0; }
|
|
193
208
|
.trend .th{ display:flex; align-items:baseline; justify-content:space-between; gap:8px; }
|
|
194
|
-
.trend .th b{ font-family:"
|
|
209
|
+
.trend .th b{ font-family:"Helvetica Neue",Helvetica,-apple-system,system-ui,sans-serif; font-weight:500; font-size:16px; }
|
|
195
210
|
.trend .th .now{ font-family:"IBM Plex Mono",monospace; font-size:17px; font-weight:500; }
|
|
196
211
|
.trend .cap{ font-size:11.5px; color:var(--muted); margin-top:1px; }
|
|
197
212
|
.trend svg{ width:100%; height:auto; display:block; margin-top:8px; }
|
|
@@ -218,7 +233,7 @@ table.feel tr:last-child td{ border-bottom:0; }
|
|
|
218
233
|
background:radial-gradient(circle at 36% 32%, color-mix(in srgb, var(--accent) 48%, #ffffff) 0%, var(--accent) 72%);
|
|
219
234
|
box-shadow:0 0 55px -6px var(--accent); opacity:.94; transform:scale(.66); }
|
|
220
235
|
.b-now{ text-align:center; min-height:34px; }
|
|
221
|
-
.b-now .phase{ font-family:"
|
|
236
|
+
.b-now .phase{ font-family:"Helvetica Neue",Helvetica,-apple-system,system-ui,sans-serif; font-weight:500; font-size:22px; color:var(--ink); }
|
|
222
237
|
.b-now .count{ font-family:"IBM Plex Mono",monospace; font-size:14px; color:var(--muted); margin-left:8px; }
|
|
223
238
|
.breathe-controls{ display:flex; align-items:center; gap:14px; flex-wrap:wrap; justify-content:center; }
|
|
224
239
|
.b-begin{ font-family:"IBM Plex Mono",monospace; font-size:14px; letter-spacing:.02em; background:var(--accent); color:#fff; border:0; border-radius:999px; padding:11px 28px; cursor:pointer; }
|
|
@@ -226,7 +241,7 @@ table.feel tr:last-child td{ border-bottom:0; }
|
|
|
226
241
|
.pattern-seg{ display:inline-flex; background:var(--surface-2); border:1px solid var(--line); border-radius:999px; padding:3px; }
|
|
227
242
|
.pattern-seg button{ font-family:"IBM Plex Mono",monospace; font-size:12px; border:0; background:transparent; color:var(--ink-2); padding:6px 14px; border-radius:999px; cursor:pointer; }
|
|
228
243
|
.pattern-seg button[aria-pressed="true"]{ background:var(--surface); color:var(--ink); box-shadow:0 1px 2px rgba(0,0,0,.08); }
|
|
229
|
-
.breathe-guide{ font-family:"
|
|
244
|
+
.breathe-guide{ font-family:"Helvetica Neue",Helvetica,-apple-system,system-ui,sans-serif; font-style:italic; font-size:16px; line-height:1.5; color:var(--ink-2); text-align:center; max-width:42ch; min-height:24px; }
|
|
230
245
|
@media (prefers-reduced-motion:reduce){ .orb{ transform:scale(.82) !important; } }
|
|
231
246
|
|
|
232
247
|
/* ---------- footer ---------- */
|
|
@@ -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
|