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/__init__.py +3 -0
- klok/__main__.py +4 -0
- klok/cli.py +1816 -0
- klok/config.py +162 -0
- klok/focus.py +169 -0
- klok/formats.py +169 -0
- klok/mindful.py +434 -0
- klok/model.py +111 -0
- klok/report.py +308 -0
- klok/storage.py +327 -0
- klok/timeparse.py +417 -0
- klok/utils.py +115 -0
- klok_cli-1.0.0.dist-info/METADATA +466 -0
- klok_cli-1.0.0.dist-info/RECORD +18 -0
- klok_cli-1.0.0.dist-info/WHEEL +5 -0
- klok_cli-1.0.0.dist-info/entry_points.txt +2 -0
- klok_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- klok_cli-1.0.0.dist-info/top_level.txt +1 -0
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
|