klok-cli 1.0.0__py3-none-any.whl

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/mindful.py ADDED
@@ -0,0 +1,434 @@
1
+ """Breathing pacers, sitting timers and mood check-ins.
2
+
3
+ Practice is recorded as ordinary frames - on their own sheet by default,
4
+ so a week of sitting never lands in a client's invoice - which means
5
+ `klok report` and friends work on it unchanged. Mood check-ins are
6
+ points in time rather than intervals, so they get a small store of their
7
+ own next to the frames file.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timedelta
16
+ from pathlib import Path
17
+ from typing import List, Optional
18
+
19
+ from klok.focus import notify
20
+ from klok.timeparse import format_duration, from_iso, to_iso
21
+ from klok.utils import bar
22
+
23
+ MEDITATION_TAG = "meditation"
24
+ BREATHING_TAG = "breathing"
25
+
26
+ # name -> (inhale, hold, exhale, hold-out, one-line description)
27
+ PATTERNS = {
28
+ "box": (4, 4, 4, 4, "Equal counts all round - steady and easy to hold on to."),
29
+ "calm": (4, 0, 6, 0, "A longer out-breath than in-breath."),
30
+ "coherent": (5.5, 0, 5.5, 0, "About five and a half breaths a minute, evenly."),
31
+ "relax": (4, 7, 8, 0, "The 4-7-8 count, with a long exhale."),
32
+ "triangle": (4, 4, 4, 0, "Inhale, hold, exhale - no pause at the bottom."),
33
+ "even": (5, 0, 5, 0, "Plain, symmetrical breathing."),
34
+ }
35
+
36
+ PHASE_LABELS = [("inhale", "Breathe in"), ("hold", "Hold"),
37
+ ("exhale", "Breathe out"), ("hold_out", "Rest")]
38
+
39
+ GUIDANCE_START = [
40
+ "Settle in. Let the eyes close or rest on one spot.",
41
+ "Nothing to solve for the next {length}.",
42
+ ]
43
+ GUIDANCE_INTERVAL = [
44
+ "Notice where the attention went, and walk it back.",
45
+ "Feel the weight of the body where it rests.",
46
+ "Let the out-breath be a little longer than the in-breath.",
47
+ "Sounds arrive and pass. No need to follow them.",
48
+ "Soften the jaw, the shoulders, the hands.",
49
+ "Nothing to add. Just this breath.",
50
+ ]
51
+ GUIDANCE_END = "That is the sit. One more breath before you move."
52
+
53
+ SCALES = {
54
+ "mood": "how you feel",
55
+ "energy": "how much you have in the tank",
56
+ "stress": "how wound up you are",
57
+ }
58
+ SPARKS = "▁▂▃▄▅▆▇█"
59
+
60
+
61
+ class MindfulError(ValueError):
62
+ """Raised for bad breathing patterns or check-in values."""
63
+
64
+
65
+ # ------------------------------------------------------------------ patterns
66
+ def parse_pattern(text: str):
67
+ """``box`` or ``4-7-8`` or ``4-4-4-4`` -> ``(in, hold, out, hold_out, name)``."""
68
+ raw = (text or "box").strip().lower()
69
+ if raw in PATTERNS:
70
+ inhale, hold, exhale, hold_out, _ = PATTERNS[raw]
71
+ return inhale, hold, exhale, hold_out, raw
72
+ normalised = raw.replace(":", "-").replace(",", "-")
73
+ if not re.fullmatch(r"\d+(?:\.\d+)?(?:-\d+(?:\.\d+)?){1,3}", normalised):
74
+ raise MindfulError(
75
+ "unknown pattern %r; use a name (%s) or counts like 4-7-8"
76
+ % (text, ", ".join(sorted(PATTERNS))))
77
+ counts = [float(part) for part in normalised.split("-")]
78
+ if sum(counts) <= 0:
79
+ raise MindfulError("a breathing pattern needs positive counts")
80
+ if len(counts) == 2:
81
+ # Two counts are the in-breath and the out-breath, with no pauses.
82
+ counts = [counts[0], 0.0, counts[1], 0.0]
83
+ elif len(counts) == 3:
84
+ counts = counts + [0.0]
85
+ return counts[0], counts[1], counts[2], counts[3], normalised
86
+
87
+
88
+ def pattern_phases(pattern):
89
+ """The non-zero phases of one full breath, as ``(key, label, seconds)``."""
90
+ inhale, hold, exhale, hold_out = pattern[:4]
91
+ lengths = {"inhale": inhale, "hold": hold, "exhale": exhale, "hold_out": hold_out}
92
+ return [(key, label, float(lengths[key])) for key, label in PHASE_LABELS if lengths[key] > 0]
93
+
94
+
95
+ def cycle_length(pattern) -> timedelta:
96
+ return timedelta(seconds=sum(pattern[:4]))
97
+
98
+
99
+ def rounds_for(pattern, duration: timedelta) -> int:
100
+ """How many whole breaths fit in a duration (at least one)."""
101
+ seconds = cycle_length(pattern).total_seconds()
102
+ if seconds <= 0:
103
+ return 1
104
+ return max(1, int(round(duration.total_seconds() / seconds)))
105
+
106
+
107
+ def describe_pattern(pattern) -> str:
108
+ inhale, hold, exhale, hold_out, name = pattern
109
+ counts = "-".join(("%g" % value) for value in (inhale, hold, exhale, hold_out))
110
+ known = PATTERNS.get(name)
111
+ if not known:
112
+ return counts
113
+ return "%s (%s) - %s" % (name, counts, known[4])
114
+
115
+
116
+ def breath_plan(pattern, rounds: int):
117
+ """The whole session as ``(round, key, label, seconds)`` tuples."""
118
+ phases = pattern_phases(pattern)
119
+ plan = []
120
+ for index in range(1, rounds + 1):
121
+ for key, label, seconds in phases:
122
+ plan.append((index, key, label, seconds))
123
+ return plan
124
+
125
+
126
+ def _phase_fill(key: str, progress: float) -> float:
127
+ """How full the breath bar should be, 0 to 1, part way through a phase."""
128
+ if key == "inhale":
129
+ return progress
130
+ if key == "exhale":
131
+ return 1.0 - progress
132
+ return 1.0 if key == "hold" else 0.0
133
+
134
+
135
+ def breathe(pattern, rounds: int, term, quiet: bool = False, bell: bool = True,
136
+ notify_on_end: bool = True, stream=None, width: int = 28,
137
+ sleep=time.sleep, clock=time.monotonic) -> bool:
138
+ """Run the pacer. Returns False if the user stops it early."""
139
+ import sys as _sys
140
+
141
+ stream = stream or _sys.stdout
142
+ interactive = bool(getattr(stream, "isatty", lambda: False)()) and not quiet
143
+ plan = breath_plan(pattern, rounds)
144
+ drawn = 0
145
+ try:
146
+ for index, key, label, seconds in plan:
147
+ started = clock()
148
+ while True:
149
+ elapsed = clock() - started
150
+ progress = min(1.0, elapsed / seconds) if seconds > 0 else 1.0
151
+ if interactive:
152
+ filled = _phase_fill(key, progress)
153
+ lines = [
154
+ "%s %s" % (term.paint("Round %d/%d" % (index, rounds), "bold"),
155
+ term.paint(label, "cyan", "bold")),
156
+ "%s %s" % (term.paint(bar(filled, 1.0, width, filled="●", empty="·"), "cyan"),
157
+ term.paint("%0.0fs" % max(0, seconds - elapsed), "yellow")),
158
+ ]
159
+ if drawn:
160
+ stream.write("\033[%dA" % drawn)
161
+ for line in lines:
162
+ stream.write("\033[2K" + line + "\n")
163
+ drawn = len(lines)
164
+ stream.flush()
165
+ if elapsed >= seconds:
166
+ break
167
+ sleep(min(0.1, seconds - elapsed))
168
+ except KeyboardInterrupt:
169
+ if interactive:
170
+ stream.write("\n")
171
+ return False
172
+ if not interactive and not quiet:
173
+ stream.write("Finished %d rounds.\n" % rounds)
174
+ notify("klok", "Breathing finished: %d rounds" % rounds,
175
+ enabled=notify_on_end, bell=bell)
176
+ return True
177
+
178
+
179
+ # ------------------------------------------------------------------- sitting
180
+ def sit_plan(duration: timedelta, interval: Optional[timedelta], warmup: timedelta = None):
181
+ """Bell times, in seconds from the start, including the closing bell."""
182
+ total = duration.total_seconds()
183
+ marks = []
184
+ if warmup and warmup.total_seconds() > 0:
185
+ marks.append(warmup.total_seconds())
186
+ if interval and interval.total_seconds() > 0:
187
+ step = interval.total_seconds()
188
+ mark = step
189
+ while mark < total - 1:
190
+ marks.append(mark)
191
+ mark += step
192
+ marks.append(total)
193
+ return sorted(set(marks))
194
+
195
+
196
+ def meditate(duration: timedelta, term, interval: timedelta = None, warmup: timedelta = None,
197
+ guidance: bool = True, quiet: bool = False, bell: bool = True,
198
+ notify_on_end: bool = True, stream=None, sleep=time.sleep,
199
+ clock=time.monotonic) -> bool:
200
+ """A silent sit with a bell at the end and optionally along the way."""
201
+ import sys as _sys
202
+
203
+ stream = stream or _sys.stdout
204
+ interactive = bool(getattr(stream, "isatty", lambda: False)()) and not quiet
205
+ total = duration.total_seconds()
206
+ marks = sit_plan(duration, interval, warmup)
207
+ closing = marks[-1]
208
+
209
+ if guidance and not quiet:
210
+ for line in GUIDANCE_START:
211
+ stream.write(term.paint(line.format(length=format_duration(duration)), "dim") + "\n")
212
+ stream.write("\n")
213
+
214
+ prompt_index = 0
215
+ struck = set()
216
+ drawn = 0
217
+ try:
218
+ started = clock()
219
+ while True:
220
+ elapsed = clock() - started
221
+ for mark in marks:
222
+ if mark not in struck and elapsed >= mark:
223
+ struck.add(mark)
224
+ if mark < closing:
225
+ notify("klok", "Interval bell", enabled=False, bell=bell)
226
+ if guidance and not quiet:
227
+ if drawn:
228
+ stream.write("\033[%dA" % drawn)
229
+ drawn = 0
230
+ line = GUIDANCE_INTERVAL[prompt_index % len(GUIDANCE_INTERVAL)]
231
+ prompt_index += 1
232
+ stream.write("\033[2K" + term.paint(" " + line, "dim") + "\n")
233
+ stream.flush()
234
+ if interactive:
235
+ remaining = max(0, total - elapsed)
236
+ lines = [
237
+ "%s %s" % (term.paint("Sitting", "cyan", "bold"),
238
+ term.paint(format_duration(timedelta(seconds=int(remaining))), "yellow")),
239
+ term.paint(bar(elapsed, total, 30, filled="─", empty=" "), "cyan"),
240
+ ]
241
+ if drawn:
242
+ stream.write("\033[%dA" % drawn)
243
+ for line in lines:
244
+ stream.write("\033[2K" + line + "\n")
245
+ drawn = len(lines)
246
+ stream.flush()
247
+ if elapsed >= total:
248
+ break
249
+ sleep(min(0.2, total - elapsed))
250
+ except KeyboardInterrupt:
251
+ if interactive:
252
+ stream.write("\n")
253
+ return False
254
+
255
+ notify("klok", "Sit finished (%s)" % format_duration(duration),
256
+ enabled=notify_on_end, bell=bell)
257
+ if guidance and not quiet:
258
+ stream.write("\n" + term.paint(GUIDANCE_END, "dim") + "\n")
259
+ return True
260
+
261
+
262
+ # ---------------------------------------------------------------- check-ins
263
+ @dataclass
264
+ class Checkin:
265
+ """A point-in-time note on how you are doing."""
266
+
267
+ at: datetime
268
+ mood: Optional[int] = None
269
+ energy: Optional[int] = None
270
+ stress: Optional[int] = None
271
+ note: str = ""
272
+ tags: List[str] = field(default_factory=list)
273
+ id: str = ""
274
+
275
+ def __post_init__(self):
276
+ if not self.id:
277
+ import uuid
278
+ self.id = uuid.uuid4().hex[:8]
279
+
280
+ def to_dict(self) -> dict:
281
+ return {"id": self.id, "at": to_iso(self.at), "mood": self.mood,
282
+ "energy": self.energy, "stress": self.stress,
283
+ "note": self.note, "tags": list(self.tags)}
284
+
285
+ @classmethod
286
+ def from_dict(cls, data: dict) -> "Checkin":
287
+ return cls(at=from_iso(data["at"]), mood=_as_score(data.get("mood")),
288
+ energy=_as_score(data.get("energy")), stress=_as_score(data.get("stress")),
289
+ note=data.get("note") or "", tags=list(data.get("tags") or []),
290
+ id=str(data.get("id") or ""))
291
+
292
+
293
+ def _as_score(value):
294
+ if value in (None, ""):
295
+ return None
296
+ return int(value)
297
+
298
+
299
+ def check_score(name: str, value):
300
+ """Validate one of the 1-5 scales."""
301
+ if value is None:
302
+ return None
303
+ try:
304
+ number = int(value)
305
+ except (TypeError, ValueError):
306
+ raise MindfulError("%s must be a whole number from 1 to 5" % name)
307
+ if not 1 <= number <= 5:
308
+ raise MindfulError("%s must be between 1 and 5, got %s" % (name, value))
309
+ return number
310
+
311
+
312
+ class CheckinStore:
313
+ """Append-mostly JSONL store for check-ins, alongside the frames file."""
314
+
315
+ def __init__(self, config):
316
+ self.config = config
317
+ self.path = Path(config.home) / "checkins.jsonl"
318
+ self._items = None
319
+
320
+ @property
321
+ def items(self) -> List[Checkin]:
322
+ if self._items is None:
323
+ self._items = self._load()
324
+ return self._items
325
+
326
+ def _load(self) -> List[Checkin]:
327
+ if not self.path.exists():
328
+ return []
329
+ found = []
330
+ with self.path.open("r", encoding="utf-8") as handle:
331
+ for number, line in enumerate(handle, 1):
332
+ line = line.strip()
333
+ if not line or line.startswith("#"):
334
+ continue
335
+ try:
336
+ found.append(Checkin.from_dict(json.loads(line)))
337
+ except (ValueError, KeyError) as exc:
338
+ raise MindfulError("%s line %d is not a valid check-in: %s"
339
+ % (self.path, number, exc))
340
+ found.sort(key=lambda item: item.at)
341
+ return found
342
+
343
+ def add(self, checkin: Checkin) -> Checkin:
344
+ self.items.append(checkin)
345
+ self._items.sort(key=lambda item: item.at)
346
+ return checkin
347
+
348
+ def remove(self, reference: str) -> Checkin:
349
+ matches = [item for item in self.items if item.id == reference
350
+ or item.id.startswith(reference.lower())]
351
+ if reference == "@" and self.items:
352
+ matches = [self.items[-1]]
353
+ if not matches:
354
+ raise MindfulError("no check-in matching %r" % reference)
355
+ if len(matches) > 1:
356
+ raise MindfulError("%r is ambiguous (%s)" % (reference, ", ".join(m.id for m in matches)))
357
+ self._items = [item for item in self.items if item.id != matches[0].id]
358
+ return matches[0]
359
+
360
+ def save(self) -> None:
361
+ self.config.ensure_home()
362
+ tmp = self.path.with_suffix(".jsonl.tmp")
363
+ with tmp.open("w", encoding="utf-8") as handle:
364
+ for item in sorted(self.items, key=lambda entry: entry.at):
365
+ handle.write(json.dumps(item.to_dict(), ensure_ascii=False) + "\n")
366
+ os.replace(tmp, self.path)
367
+
368
+ def select(self, start: datetime = None, end: datetime = None) -> List[Checkin]:
369
+ return [item for item in self.items
370
+ if (start is None or item.at >= start) and (end is None or item.at < end)]
371
+
372
+
373
+ # ----------------------------------------------------------------- reporting
374
+ def sparkline(values) -> str:
375
+ """A one-line trend for a list of numbers, gaps allowed as ``None``."""
376
+ present = [value for value in values if value is not None]
377
+ if not present:
378
+ return ""
379
+ low, high = min(present), max(present)
380
+ span = (high - low) or 1
381
+ out = []
382
+ for value in values:
383
+ if value is None:
384
+ out.append(" ")
385
+ else:
386
+ index = int(round((value - low) / span * (len(SPARKS) - 1)))
387
+ out.append(SPARKS[index])
388
+ return "".join(out)
389
+
390
+
391
+ def daily_averages(checkins, field_name: str):
392
+ """``{date: average}`` for one scale, skipping days with no answer."""
393
+ buckets = {}
394
+ for item in checkins:
395
+ value = getattr(item, field_name)
396
+ if value is None:
397
+ continue
398
+ buckets.setdefault(item.at.date(), []).append(value)
399
+ return {day: sum(values) / len(values) for day, values in sorted(buckets.items())}
400
+
401
+
402
+ def streak(days, today) -> int:
403
+ """Consecutive days with practice, counting back from today or yesterday."""
404
+ known = set(days)
405
+ if not known:
406
+ return 0
407
+ cursor = today
408
+ if cursor not in known:
409
+ cursor = today - timedelta(days=1)
410
+ if cursor not in known:
411
+ return 0
412
+ count = 0
413
+ while cursor in known:
414
+ count += 1
415
+ cursor -= timedelta(days=1)
416
+ return count
417
+
418
+
419
+ def longest_streak(days) -> int:
420
+ ordered = sorted(set(days))
421
+ best = run = 0
422
+ previous = None
423
+ for day in ordered:
424
+ run = run + 1 if previous is not None and day - previous == timedelta(days=1) else 1
425
+ best = max(best, run)
426
+ previous = day
427
+ return best
428
+
429
+
430
+ def break_due(frame, after: timedelta, now: datetime) -> bool:
431
+ """True when a single stretch of work has run past the break threshold."""
432
+ if frame is None or after.total_seconds() <= 0:
433
+ return False
434
+ return frame.duration(now) >= after
klok/model.py ADDED
@@ -0,0 +1,111 @@
1
+ """The single record type klok stores: a Frame."""
2
+
3
+ import uuid
4
+ from dataclasses import dataclass, field, replace
5
+ from datetime import datetime, timedelta
6
+ from typing import List, Optional
7
+
8
+ from klok.timeparse import from_iso, to_iso
9
+
10
+ DEFAULT_SHEET = "default"
11
+
12
+
13
+ def new_id() -> str:
14
+ return uuid.uuid4().hex[:8]
15
+
16
+
17
+ @dataclass
18
+ class Frame:
19
+ """One tracked interval. ``stop`` is ``None`` while it is running."""
20
+
21
+ start: datetime
22
+ stop: Optional[datetime] = None
23
+ project: str = ""
24
+ tags: List[str] = field(default_factory=list)
25
+ note: str = ""
26
+ sheet: str = DEFAULT_SHEET
27
+ id: str = field(default_factory=new_id)
28
+ updated_at: Optional[datetime] = None
29
+
30
+ # -- derived -------------------------------------------------------
31
+ @property
32
+ def running(self) -> bool:
33
+ return self.stop is None
34
+
35
+ def end_or(self, now: datetime) -> datetime:
36
+ """Stop time, or ``now`` for a frame that is still running."""
37
+ return self.stop if self.stop is not None else now
38
+
39
+ def duration(self, now: Optional[datetime] = None) -> timedelta:
40
+ end = self.stop
41
+ if end is None:
42
+ if now is None:
43
+ return timedelta(0)
44
+ end = now
45
+ span = end - self.start
46
+ return span if span.total_seconds() > 0 else timedelta(0)
47
+
48
+ def overlaps(self, other: "Frame", now: datetime) -> bool:
49
+ a_end = self.end_or(now)
50
+ b_end = other.end_or(now)
51
+ return self.start < b_end and other.start < a_end
52
+
53
+ def spans(self, start: datetime, end: datetime, now: datetime) -> bool:
54
+ """True when any part of the frame falls inside [start, end)."""
55
+ return self.start < end and start < self.end_or(now)
56
+
57
+ def label(self) -> str:
58
+ parts = [self.project or "(no project)"]
59
+ if self.tags:
60
+ parts.append(" ".join("+" + tag for tag in self.tags))
61
+ return " ".join(parts)
62
+
63
+ def touch(self, when: datetime) -> None:
64
+ self.updated_at = when
65
+
66
+ def copy(self, **changes) -> "Frame":
67
+ return replace(self, **changes)
68
+
69
+ # -- serialisation -------------------------------------------------
70
+ def to_dict(self) -> dict:
71
+ data = {
72
+ "id": self.id,
73
+ "start": to_iso(self.start),
74
+ "stop": to_iso(self.stop) if self.stop else None,
75
+ "project": self.project,
76
+ "tags": list(self.tags),
77
+ "note": self.note,
78
+ "sheet": self.sheet,
79
+ }
80
+ if self.updated_at:
81
+ data["updated_at"] = to_iso(self.updated_at)
82
+ return data
83
+
84
+ @classmethod
85
+ def from_dict(cls, data: dict) -> "Frame":
86
+ stop = data.get("stop") or data.get("end")
87
+ updated = data.get("updated_at")
88
+ tags = data.get("tags") or []
89
+ if isinstance(tags, str):
90
+ tags = [t for t in tags.replace(",", " ").split() if t]
91
+ return cls(
92
+ start=from_iso(data["start"]),
93
+ stop=from_iso(stop) if stop else None,
94
+ project=(data.get("project") or "").strip(),
95
+ tags=[str(t).lstrip("+") for t in tags],
96
+ note=data.get("note") or data.get("annotation") or "",
97
+ sheet=(data.get("sheet") or DEFAULT_SHEET).strip() or DEFAULT_SHEET,
98
+ id=str(data.get("id") or new_id()),
99
+ updated_at=from_iso(updated) if updated else None,
100
+ )
101
+
102
+
103
+ def normalise_tags(tags) -> List[str]:
104
+ """De-duplicate and strip a ``+``/``-`` prefix, preserving order."""
105
+ seen, out = set(), []
106
+ for tag in tags or []:
107
+ clean = str(tag).strip().lstrip("+")
108
+ if clean and clean not in seen:
109
+ seen.add(clean)
110
+ out.append(clean)
111
+ return out