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.
Files changed (32) hide show
  1. {klok_cli-1.1.0/klok_cli.egg-info → klok_cli-1.2.1}/PKG-INFO +30 -1
  2. {klok_cli-1.1.0 → klok_cli-1.2.1}/README.md +29 -0
  3. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/__init__.py +1 -1
  4. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/cli.py +175 -7
  5. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/dashboard.html +82 -67
  6. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/dashboard.py +2 -18
  7. klok_cli-1.2.1/klok/insights.py +107 -0
  8. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/mindful.py +37 -0
  9. klok_cli-1.2.1/klok/pairing.py +39 -0
  10. {klok_cli-1.1.0 → klok_cli-1.2.1/klok_cli.egg-info}/PKG-INFO +30 -1
  11. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok_cli.egg-info/SOURCES.txt +4 -0
  12. {klok_cli-1.1.0 → klok_cli-1.2.1}/pyproject.toml +1 -1
  13. {klok_cli-1.1.0 → klok_cli-1.2.1}/tests/test_cli.py +291 -2
  14. klok_cli-1.2.1/tests/test_insights.py +144 -0
  15. klok_cli-1.2.1/tests/test_pairing.py +91 -0
  16. {klok_cli-1.1.0 → klok_cli-1.2.1}/LICENSE +0 -0
  17. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/__main__.py +0 -0
  18. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/config.py +0 -0
  19. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/focus.py +0 -0
  20. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/formats.py +0 -0
  21. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/model.py +0 -0
  22. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/report.py +0 -0
  23. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/storage.py +0 -0
  24. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/timeparse.py +0 -0
  25. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok/utils.py +0 -0
  26. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok_cli.egg-info/dependency_links.txt +0 -0
  27. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok_cli.egg-info/entry_points.txt +0 -0
  28. {klok_cli-1.1.0 → klok_cli-1.2.1}/klok_cli.egg-info/top_level.txt +0 -0
  29. {klok_cli-1.1.0 → klok_cli-1.2.1}/setup.cfg +0 -0
  30. {klok_cli-1.1.0 → klok_cli-1.2.1}/tests/test_mindful.py +0 -0
  31. {klok_cli-1.1.0 → klok_cli-1.2.1}/tests/test_storage.py +0 -0
  32. {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.0
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
@@ -1,3 +1,3 @@
1
1
  """klok - a command-line time tracker with a plaintext backend."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.2.1"
@@ -20,6 +20,8 @@ from klok.mindful import (BREATHING_TAG, MEDITATION_TAG, PATTERNS, Checkin,
20
20
  parse_pattern, pattern_phases, rounds_for, sit_plan,
21
21
  sparkline, streak)
22
22
  from klok.model import Frame, normalise_tags
23
+ from klok.insights import drain_findings
24
+ from klok.pairing import frame_at, is_practice
23
25
  from klok.report import (Renderer, by_day, by_project, by_sheet, by_tag,
24
26
  render_day_chart, render_gaps, render_log,
25
27
  render_punchcard, render_report, render_summary,
@@ -38,11 +40,25 @@ TIME_OPTIONS = {"-a", "--at", "--from", "--to", "--start", "--stop", "--now",
38
40
  "--for", "--interval-bell", "--warmup"}
39
41
 
40
42
 
43
+ def _default_ask(prompt: str):
44
+ """The real input seam: one line from the user, or ``None`` at EOF.
45
+
46
+ ``review`` reads through this rather than calling ``input`` directly, the
47
+ same spirit as the ``sleep=``/``clock=`` seams in ``focus``/``mindful``,
48
+ so the in-process test suite can feed scripted answers with no real TTY.
49
+ """
50
+ try:
51
+ return input(prompt)
52
+ except EOFError:
53
+ return None
54
+
55
+
41
56
  class Context:
42
57
  """Everything a command needs: config, store, clock and renderer."""
43
58
 
44
- def __init__(self, args):
59
+ def __init__(self, args, ask=None):
45
60
  self.config = Config(args.home)
61
+ self.ask = ask or _default_ask
46
62
  self.store = Store(self.config)
47
63
  self.now = parse_datetime(args.now) if args.now else now_local()
48
64
  color_mode = "never" if args.no_color else (args.color or self.config.get("general.color", "auto"))
@@ -198,6 +214,7 @@ def cmd_start(ctx: Context) -> int:
198
214
 
199
215
  def cmd_stop(ctx: Context) -> int:
200
216
  args = ctx.args
217
+ felt = check_score("felt", getattr(args, "felt", None)) # validate before stopping
201
218
  at = resolve_time(ctx, args.at)
202
219
  sheet = None if args.all else ctx.sheet_filter()
203
220
  stopped = stop_running(ctx, at, note=args.note or "", sheet=sheet)
@@ -205,10 +222,17 @@ def cmd_stop(ctx: Context) -> int:
205
222
  ctx.echo("Nothing is running.")
206
223
  return 1
207
224
  ctx.store.save("stop")
225
+ if felt is not None:
226
+ store = CheckinStore(ctx.config)
227
+ for frame in stopped:
228
+ store.replace(store.energy_at(frame.start, frame.end_or(ctx.now)),
229
+ Checkin(at=frame.start, energy=felt))
208
230
  for frame in stopped:
209
231
  ctx.echo("Stopped %s after %s [%s]" % (describe(ctx, frame),
210
232
  format_duration(frame.duration(ctx.now)),
211
233
  ctx.term.paint(frame.id, "grey")))
234
+ if felt is not None:
235
+ ctx.echo("Noted energy %d for %s." % (felt, "it" if len(stopped) == 1 else "them"))
212
236
  return 0
213
237
 
214
238
 
@@ -1173,9 +1197,14 @@ def cmd_checkin(ctx: Context) -> int:
1173
1197
  % (removed.id, removed.at.strftime("%Y-%m-%d %H:%M")))
1174
1198
  return 0
1175
1199
 
1200
+ words = list(args.words)
1176
1201
  mood = check_score("mood", args.mood)
1177
1202
  energy = check_score("energy", args.energy)
1178
1203
  stress = check_score("stress", args.stress)
1204
+ # `klok checkin 4` shorthand: a bare leading integer is an energy rating,
1205
+ # as long as --energy was not also given. Everything else stays the note.
1206
+ if energy is None and words and re.fullmatch(r"\d+", words[0]):
1207
+ energy = check_score("energy", words.pop(0))
1179
1208
  if mood is None and energy is None and stress is None and sys.stdin.isatty():
1180
1209
  mood = check_score("mood", _ask("Mood 1-5 (1 low, 5 good)"))
1181
1210
  energy = check_score("energy", _ask("Energy 1-5"))
@@ -1184,7 +1213,7 @@ def cmd_checkin(ctx: Context) -> int:
1184
1213
  raise MindfulError("give at least one of --mood, --energy or --stress")
1185
1214
 
1186
1215
  checkin = Checkin(at=resolve_time(ctx, args.at), mood=mood, energy=energy, stress=stress,
1187
- note=" ".join(args.words).strip(), tags=normalise_tags(args.tag or []))
1216
+ note=" ".join(words).strip(), tags=normalise_tags(args.tag or []))
1188
1217
  store.add(checkin)
1189
1218
  store.save()
1190
1219
  parts = ["%s %d" % (name, value) for name, value in
@@ -1325,6 +1354,134 @@ def cmd_mindful(ctx: Context) -> int:
1325
1354
  return 0
1326
1355
 
1327
1356
 
1357
+ def cmd_review(ctx: Context) -> int:
1358
+ """Walk the range's tracked blocks and rate each one's energy, 1-5.
1359
+
1360
+ One block at a time: a digit then Enter records it, a blank line skips,
1361
+ ``b`` steps back to re-rate the previous block, ``q`` (or EOF) stops. By
1362
+ default it walks the active sheet only and the blocks that have no energy
1363
+ rating yet; ``--all-sheets`` widens the scope and ``--redo`` re-walks the
1364
+ already-rated ones too. Practice sessions and a still-running frame are
1365
+ left out. Each rating is one energy ``Checkin`` stamped at the block's
1366
+ start instant, so the timestamp join associates it with that block.
1367
+ """
1368
+ start, end = ctx.range_of(":day")
1369
+ sheet = ctx.sheet_filter()
1370
+ practice = ctx.config.get("mindful.sheet", "wellbeing")
1371
+ frames = ctx.store.select(start, end, sheet=sheet, now=ctx.now)
1372
+ blocks = sorted((frame for frame in frames
1373
+ if not frame.running and not is_practice(frame, practice)
1374
+ and frame.start < frame.end_or(ctx.now)),
1375
+ key=lambda frame: frame.start)
1376
+ store = CheckinStore(ctx.config)
1377
+ redo = ctx.args.redo
1378
+ pending = [frame for frame in blocks
1379
+ if redo or store.energy_at(frame.start, frame.end_or(ctx.now)) is None]
1380
+ if not pending:
1381
+ if blocks:
1382
+ ctx.echo("Every block here is already rated. Use `klok review --redo` to re-rate.")
1383
+ else:
1384
+ ctx.echo("No blocks to review in this range.")
1385
+ return 0
1386
+
1387
+ rated = 0
1388
+ index = 0
1389
+ while index < len(pending):
1390
+ frame = pending[index]
1391
+ current = store.energy_at(frame.start, frame.end_or(ctx.now))
1392
+ marker = ctx.term.paint(" [energy %d]" % current.energy, "grey") if current else ""
1393
+ ctx.echo("%s %s (%s-%s)%s" % (
1394
+ frame.label(), format_duration(frame.duration(ctx.now)),
1395
+ ctx.renderer.fmt_time(frame.start), ctx.renderer.fmt_time(frame.stop), marker))
1396
+ answer = ctx.ask(" Energy 1-5 (Enter skips, b back, q quit): ")
1397
+ if answer is None:
1398
+ break
1399
+ answer = answer.strip().lower()
1400
+ if answer == "":
1401
+ index += 1
1402
+ continue
1403
+ if answer == "q":
1404
+ break
1405
+ if answer == "b":
1406
+ index = max(0, index - 1)
1407
+ continue
1408
+ try:
1409
+ value = check_score("energy", answer)
1410
+ except MindfulError as exc:
1411
+ ctx.echo(" %s" % exc)
1412
+ continue
1413
+ store.replace(current, Checkin(at=frame.start, energy=value))
1414
+ rated += 1
1415
+ index += 1
1416
+
1417
+ ctx.echo("Rated %d block%s." % (rated, "" if rated == 1 else "s"))
1418
+ return 0
1419
+
1420
+
1421
+ def cmd_digest(ctx: Context) -> int:
1422
+ """What your energy ratings add up to: which work drains or lifts you.
1423
+
1424
+ Reads the frames and energy check-ins in the range, joins each rating to
1425
+ the block it falls in, and reports the projects and tags whose energy sits
1426
+ clearly above or below your overall average. It only reports a pattern
1427
+ once there is enough data to trust it, so an honest footer always says how
1428
+ many rated blocks it is working from. ``-o`` writes the same text to a
1429
+ file to share.
1430
+ """
1431
+ start, end = ctx.range_of(":week")
1432
+ sheet = ctx.sheet_filter()
1433
+ now = min(ctx.now, end - timedelta(seconds=1))
1434
+ frames = ctx.store.select(start, end, sheet=sheet, now=now)
1435
+ checkins = CheckinStore(ctx.config).select(start, end)
1436
+
1437
+ findings = drain_findings(frames, checkins, now)
1438
+
1439
+ # Rated blocks and the days they span, joined the same way insights does.
1440
+ rated = {}
1441
+ for checkin in checkins:
1442
+ if checkin.energy is None:
1443
+ continue
1444
+ frame = frame_at(frames, checkin.at, now)
1445
+ if frame is not None:
1446
+ rated[id(frame)] = frame # by identity, matching insights._rated_blocks
1447
+ block_count = len(rated)
1448
+ day_count = len({frame.start.date() for frame in rated.values()})
1449
+
1450
+ header = "Energy digest %s to %s" % (
1451
+ start.date().isoformat(), (end - timedelta(seconds=1)).date().isoformat())
1452
+ body = [] # (kind, text): kind "head" is a finding headline, "plain" is as-is
1453
+ if findings:
1454
+ for finding in findings:
1455
+ body.append(("head", finding.headline))
1456
+ body.append(("plain", " %s" % finding.detail))
1457
+ elif block_count:
1458
+ body.append(("plain", "No clear pattern yet. Keep rating blocks with `klok review`;"))
1459
+ body.append(("plain", "a project or tag needs several rated blocks before it shows up here."))
1460
+ else:
1461
+ body.append(("plain", "Nothing rated yet. Rate a day's blocks with `klok review` and come back."))
1462
+ footer = "Based on %d rated block%s across %d day%s." % (
1463
+ block_count, "" if block_count == 1 else "s",
1464
+ day_count, "" if day_count == 1 else "s")
1465
+ if block_count:
1466
+ footer += " Rate more with `klok review` for sharper results."
1467
+
1468
+ def render(colored: bool) -> str:
1469
+ paint = ctx.term.paint if colored else (lambda text, *styles: text)
1470
+ out = [paint(header, "bold"), ""]
1471
+ for kind, text in body:
1472
+ out.append(paint(text, "bold") if kind == "head" else text)
1473
+ out += ["", paint(footer, "grey")]
1474
+ return "\n".join(out)
1475
+
1476
+ if ctx.args.output:
1477
+ with open(ctx.args.output, "w", encoding="utf-8") as handle:
1478
+ handle.write(render(False) + "\n")
1479
+ ctx.echo("Wrote the digest to %s" % ctx.args.output)
1480
+ else:
1481
+ print(render(True))
1482
+ return 0
1483
+
1484
+
1328
1485
  # ------------------------------------------------------------------- checks
1329
1486
  def cmd_check(ctx: Context) -> int:
1330
1487
  problems = []
@@ -1361,7 +1518,7 @@ _klok_completions() {
1361
1518
  local commands="start stop cancel status restart switch track add stretch edit annotate tag untag
1362
1519
  rename move lengthen shorten fill join split delete undo log summary report chart gaps stats projects
1363
1520
  tags sheet sheets archive export import config where timer pomodoro breathe meditate checkin mood
1364
- mindful check completion version"
1521
+ mindful review digest check completion version"
1365
1522
  COMPREPLY=($(compgen -W "$commands" -- "${COMP_WORDS[COMP_CWORD]}"))
1366
1523
  }
1367
1524
  complete -F _klok_completions klok
@@ -1373,7 +1530,7 @@ _klok() {
1373
1530
  commands=(start stop cancel status restart switch track add stretch edit annotate tag untag rename
1374
1531
  move lengthen shorten fill join split delete undo log summary report chart gaps stats projects tags
1375
1532
  sheet sheets archive export import config where timer pomodoro breathe meditate checkin mood
1376
- mindful check completion version)
1533
+ mindful review digest check completion version)
1377
1534
  _describe 'klok command' commands
1378
1535
  }
1379
1536
  compdef _klok klok
@@ -1382,7 +1539,7 @@ compdef _klok klok
1382
1539
  for cmd in start stop cancel status restart switch track add stretch edit annotate tag untag rename \\
1383
1540
  move lengthen shorten fill join split delete undo log summary report chart gaps stats projects \\
1384
1541
  tags sheet sheets archive export import config where timer pomodoro breathe meditate \\
1385
- checkin mood mindful check completion version
1542
+ checkin mood mindful review digest check completion version
1386
1543
  complete -c klok -n __fish_use_subcommand -a $cmd
1387
1544
  end
1388
1545
  """,
@@ -1564,6 +1721,7 @@ def build_parser() -> argparse.ArgumentParser:
1564
1721
  stop.add_argument("-a", "--at", metavar="TIME", help="stop at this time (e.g. 17:30, -5m)")
1565
1722
  stop.add_argument("-n", "--note", metavar="TEXT", help="append an annotation while stopping")
1566
1723
  stop.add_argument("--all", action="store_true", help="stop running entries on every sheet")
1724
+ stop.add_argument("--felt", metavar="1-5", help="also record how the stretch felt, as an energy check-in")
1567
1725
 
1568
1726
  add("cancel", "Discard the running entry without recording it.",
1569
1727
  aliases=["abort"], parents=[sheet_opts], func=cmd_cancel)
@@ -1826,6 +1984,16 @@ def build_parser() -> argparse.ArgumentParser:
1826
1984
  parents=[range_opts, sheet_opts], func=cmd_mindful)
1827
1985
  mindful_cmd.add_argument("--no-chart", action="store_true", help="skip the per-day chart")
1828
1986
 
1987
+ review_cmd = add("review", "Rate each of a day's tracked blocks for energy, one at a time.",
1988
+ parents=[range_opts, sheet_opts], func=cmd_review)
1989
+ review_cmd.add_argument("--redo", action="store_true",
1990
+ help="re-walk blocks that are already rated and replace their rating")
1991
+
1992
+ digest_cmd = add("digest", "What your energy ratings add up to: what drains or lifts you.",
1993
+ parents=[range_opts, sheet_opts], func=cmd_digest)
1994
+ digest_cmd.add_argument("-o", "--output", metavar="FILE",
1995
+ help="write the digest to a file instead of stdout")
1996
+
1829
1997
  current_group[0] = "Other"
1830
1998
  add("check", "Look for overlaps and other suspicious entries.", aliases=["sanity", "doctor"],
1831
1999
  func=cmd_check)
@@ -1840,7 +2008,7 @@ def build_parser() -> argparse.ArgumentParser:
1840
2008
  return parser
1841
2009
 
1842
2010
 
1843
- def main(argv=None) -> int:
2011
+ def main(argv=None, ask=None) -> int:
1844
2012
  argv = list(sys.argv[1:] if argv is None else argv)
1845
2013
  if not argv:
1846
2014
  argv = ["status"]
@@ -1850,7 +2018,7 @@ def main(argv=None) -> int:
1850
2018
  parser.print_help()
1851
2019
  return 1
1852
2020
  try:
1853
- ctx = Context(args)
2021
+ ctx = Context(args, ask=ask)
1854
2022
  return args.func(ctx) or 0
1855
2023
  except (StoreError, TimeParseError, ValueError, KeyError) as exc:
1856
2024
  return die(str(exc))
@@ -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
- <link rel="preconnect" href="https://fonts.googleapis.com">
9
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
- <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
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 — "day paper" */
16
- --plane: #efede7; /* page plane */
17
- --surface: #faf9f5; /* card surface */
18
- --surface-2: #f3f1ea; /* inset / track */
19
- --ink: #201e19; /* primary text (warm near-black) */
20
- --ink-2: #57544c; /* secondary */
21
- --muted: #8d8a80; /* axis / captions */
22
- --line: #e2dfd4; /* hairline */
23
- --line-2: #d6d2c5;
24
- --accent: #3a567f; /* instrument accent — hours / magnitude / chrome */
25
- --accent-2: #2d4568;
26
- --clay: #b8473a; /* stress hue */
27
- --teal: #2f8f6b; /* mood hue */
28
- --tip-bg: #201e19;
29
- --tip-fg: #faf9f5;
30
- --shadow: 0 1px 2px rgba(32,30,25,.05), 0 8px 28px rgba(32,30,25,.06);
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
- --plane: #100f0d;
36
- --surface: #1a1917;
37
- --surface-2: #232220;
38
- --ink: #f0eee7;
39
- --ink-2: #bbb6aa;
40
- --muted: #8f8c82;
41
- --line: #2b2a27;
42
- --line-2: #35332f;
43
- --accent: #8aa6d6;
44
- --accent-2: #a7bde3;
45
- --clay: #d9685a;
46
- --teal: #46a885;
47
- --tip-bg: #f0eee7;
48
- --tip-fg: #1a1917;
49
- --shadow: 0 1px 2px rgba(0,0,0,.3), 0 10px 32px rgba(0,0,0,.4);
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
- --plane: #100f0d;
55
- --surface: #1a1917;
56
- --surface-2: #232220;
57
- --ink: #f0eee7;
58
- --ink-2: #bbb6aa;
59
- --muted: #8f8c82;
60
- --line: #2b2a27;
61
- --line-2: #35332f;
62
- --accent: #8aa6d6;
63
- --accent-2: #a7bde3;
64
- --clay: #d9685a;
65
- --teal: #46a885;
66
- --tip-bg: #f0eee7;
67
- --tip-fg: #1a1917;
68
- --shadow: 0 1px 2px rgba(0,0,0,.3), 0 10px 32px rgba(0,0,0,.4);
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; background:var(--plane); color:var(--ink);
75
- font-family:"IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
76
- font-size:15px; line-height:1.55; -webkit-font-smoothing:antialiased;
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 72px; }
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:"Fraunces", Georgia, "Times New Roman", serif; }
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:"Fraunces", Georgia, serif; font-weight:500; letter-spacing:-.01em; }
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(--teal); font-weight:600; }
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(--teal); }
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:26px 0 8px; }
114
- .hero h1{ font-size:clamp(30px,6vw,52px); line-height:1.04; letter-spacing:-.02em; }
115
- .hero h1 em{ font-style:italic; color:var(--teal); }
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(20px,3vw,26px); }
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:"Fraunces",serif; font-weight:500; font-size:16px; }
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:"Fraunces",serif; font-weight:500; font-size:22px; color:var(--ink); }
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:"Fraunces",serif; font-style:italic; font-size:16px; line-height:1.5; color:var(--ink-2); text-align:center; max-width:42ch; min-height:24px; }
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 BREATHING_TAG, MEDITATION_TAG, Checkin
15
+ from klok.mindful import Checkin
16
16
  from klok.model import Frame
17
+ from klok.pairing import frame_at, is_practice
17
18
 
18
19
  TEMPLATE = "dashboard.html"
19
20
  DATA_MARKER = "/*KLOK_DATA*/null"
20
- PRACTICE_TAGS = {BREATHING_TAG, MEDITATION_TAG}
21
21
  NO_PROJECT = "(no project)"
22
22
 
23
23
 
@@ -29,22 +29,6 @@ def _iso(moment: datetime) -> str:
29
29
  return moment.isoformat(timespec="seconds")
30
30
 
31
31
 
32
- def is_practice(frame: Frame, practice_sheet: str = "") -> bool:
33
- """Breathing and sitting sessions, however they were recorded."""
34
- return bool(PRACTICE_TAGS.intersection(frame.tags)) or (
35
- bool(practice_sheet) and frame.sheet == practice_sheet)
36
-
37
-
38
- def frame_at(frames: List[Frame], moment: datetime, now: datetime) -> Optional[Frame]:
39
- """The frame that was running at ``moment``; the latest-started one wins."""
40
- found = None
41
- for frame in frames:
42
- if frame.start <= moment < frame.end_or(now):
43
- if found is None or frame.start >= found.start:
44
- found = frame
45
- return found
46
-
47
-
48
32
  def _mean(values) -> Optional[float]:
49
33
  present = [value for value in values if value is not None]
50
34
  return round(sum(present) / len(present), 2) if present else None