workfold 0.1.0a1__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.
workfold/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """workfold command-line package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("workfold")
7
+ except PackageNotFoundError: # pragma: no cover - only used from an unpackaged source tree
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
workfold/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Support ``python -m workfold`` and PyInstaller."""
2
+
3
+ from workfold.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,394 @@
1
+ """Renderer-neutral sparse weekly layout and activity summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ from collections import Counter
7
+ from collections.abc import Iterable
8
+ from dataclasses import dataclass
9
+ from datetime import timedelta
10
+ from typing import TypeVar
11
+
12
+ from workfold.models import ClassifiedMarker, RecordKind, Source, Weekday
13
+
14
+ MINUTES_PER_DAY = 24 * 60
15
+ NANOSECONDS_PER_SECOND = 1_000_000_000
16
+ NANOSECONDS_PER_MINUTE = 60 * NANOSECONDS_PER_SECOND
17
+ NANOSECONDS_PER_DAY = 24 * 60 * NANOSECONDS_PER_MINUTE
18
+ _CountKey = TypeVar("_CountKey", Source, RecordKind)
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ClusterCell:
23
+ """The exact, ordered events for one weekday in a time cluster."""
24
+
25
+ weekday: Weekday
26
+ markers: tuple[ClassifiedMarker, ...]
27
+
28
+ def __post_init__(self) -> None:
29
+ if not self.markers:
30
+ raise ValueError("a cluster cell must contain at least one marker")
31
+ if any(marker.weekday is not self.weekday for marker in self.markers):
32
+ raise ValueError("cluster cell markers must match its weekday")
33
+ if len({marker.marker.marker_id for marker in self.markers}) != len(self.markers):
34
+ raise ValueError("a cluster cell cannot contain duplicate markers")
35
+ if self.markers != tuple(sorted(self.markers, key=_marker_order_key)):
36
+ raise ValueError("cluster cell markers must use deterministic event order")
37
+
38
+ @property
39
+ def event_count(self) -> int:
40
+ """Return the number of individually renderable events in this cell."""
41
+
42
+ return len(self.markers)
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class TimeCluster:
47
+ """One globally aligned, greedily anchored occupied wall-clock row.
48
+
49
+ ``start_time_ns`` and ``end_time_ns`` are the first and last observed
50
+ local times, not an estimate of activity duration. The clustering window
51
+ itself is stored once on :class:`Aggregation`.
52
+ """
53
+
54
+ start_time_ns: int
55
+ end_time_ns: int
56
+ cells: tuple[ClusterCell, ...]
57
+
58
+ def __post_init__(self) -> None:
59
+ if not 0 <= self.start_time_ns <= self.end_time_ns < NANOSECONDS_PER_DAY:
60
+ raise ValueError("cluster times must form a non-empty range within one day")
61
+ if not self.cells:
62
+ raise ValueError("a time cluster must contain at least one occupied cell")
63
+ weekdays = tuple(cell.weekday for cell in self.cells)
64
+ if weekdays != tuple(sorted(weekdays)) or len(set(weekdays)) != len(weekdays):
65
+ raise ValueError("cluster cells must be unique and ordered by weekday")
66
+ markers = tuple(marker for cell in self.cells for marker in cell.markers)
67
+ observed_times = tuple(marker.time_of_day_ns for marker in markers)
68
+ if min(observed_times) != self.start_time_ns or max(observed_times) != self.end_time_ns:
69
+ raise ValueError("cluster bounds must match its observed event times")
70
+
71
+ @property
72
+ def event_count(self) -> int:
73
+ """Return the number of individually renderable events in this row."""
74
+
75
+ return sum(cell.event_count for cell in self.cells)
76
+
77
+ def cell(self, weekday: Weekday) -> ClusterCell | None:
78
+ """Return the occupied cell for *weekday*, if one exists."""
79
+
80
+ return next((cell for cell in self.cells if cell.weekday is weekday), None)
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class HiddenMarkers:
85
+ """Markers hidden on one side of an explicit display crop."""
86
+
87
+ total: int = 0
88
+ source_counts: tuple[tuple[Source, int], ...] = ()
89
+
90
+ def count_for_source(self, source: Source) -> int:
91
+ """Return the hidden marker count for *source*."""
92
+
93
+ return _lookup_count(self.source_counts, source)
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class Aggregation:
98
+ """Sparse occupied rows plus exact full-scope summary state."""
99
+
100
+ cluster_window: timedelta
101
+ display_start_minute: int
102
+ display_end_minute: int
103
+ display_is_explicit: bool
104
+ clusters: tuple[TimeCluster, ...]
105
+ event_count: int
106
+ within_schedule_count: int
107
+ outside_schedule_count: int
108
+ weekend_count: int
109
+ source_counts: tuple[tuple[Source, int], ...]
110
+ record_kind_counts: tuple[tuple[RecordKind, int], ...]
111
+ hidden_before: HiddenMarkers
112
+ hidden_after: HiddenMarkers
113
+ retained_outside_markers: tuple[ClassifiedMarker, ...]
114
+ outside_marker_count: int
115
+
116
+ @property
117
+ def outside_omitted_count(self) -> int:
118
+ """Return outside markers not retained because of the list limit."""
119
+
120
+ return self.outside_marker_count - len(self.retained_outside_markers)
121
+
122
+ @property
123
+ def displayed_event_count(self) -> int:
124
+ """Return the count represented by the sparse chart rows."""
125
+
126
+ return sum(cluster.event_count for cluster in self.clusters)
127
+
128
+ def count_for_source(self, source: Source) -> int:
129
+ """Return the total marker count for *source*."""
130
+
131
+ return _lookup_count(self.source_counts, source)
132
+
133
+ def count_for_record_kind(self, record_kind: RecordKind) -> int:
134
+ """Return the total marker count for *record_kind*."""
135
+
136
+ return _lookup_count(self.record_kind_counts, record_kind)
137
+
138
+
139
+ def aggregate_markers(
140
+ markers: Iterable[ClassifiedMarker],
141
+ *,
142
+ cluster_window: timedelta,
143
+ schedule_bounds: tuple[int, int] | None = None,
144
+ display_range: tuple[int, int] | None = None,
145
+ outside_limit: int = 50,
146
+ ) -> Aggregation:
147
+ """Summarize markers and build globally aligned sparse time clusters.
148
+
149
+ Visible events are sorted by exact localized time of day. Each cluster is
150
+ anchored at the earliest unassigned event and contains the half-open range
151
+ ``[anchor, anchor + cluster_window)``. This intentionally prevents
152
+ transitive chaining from making a row wider than the requested duration.
153
+
154
+ ``display_range`` remains a half-open wall-clock minute range. Cropping is
155
+ applied before clustering, so hidden events cannot affect visible rows.
156
+ All summary counts continue to describe the complete marker input.
157
+ """
158
+
159
+ cluster_window_ns = _cluster_window_ns(cluster_window)
160
+ _validate_schedule_bounds(schedule_bounds)
161
+ _validate_display_range(display_range)
162
+ if outside_limit < 0:
163
+ raise ValueError("outside_limit must not be negative")
164
+
165
+ visible_markers: list[ClassifiedMarker] = []
166
+ source_counts: Counter[Source] = Counter()
167
+ record_kind_counts: Counter[RecordKind] = Counter()
168
+ hidden_before_sources: Counter[Source] = Counter()
169
+ hidden_after_sources: Counter[Source] = Counter()
170
+ hidden_before_total = 0
171
+ hidden_after_total = 0
172
+ event_count = 0
173
+ within_schedule_count = 0
174
+ outside_schedule_count = 0
175
+ weekend_count = 0
176
+ outside_marker_count = 0
177
+ outside_heap: list[tuple[tuple[int, str], int, ClassifiedMarker]] = []
178
+ occupied_start_ns: int | None = None
179
+ occupied_end_ns: int | None = None
180
+
181
+ display_start_ns = display_range[0] * NANOSECONDS_PER_MINUTE if display_range is not None else None
182
+ display_end_ns = display_range[1] * NANOSECONDS_PER_MINUTE if display_range is not None else None
183
+
184
+ for classified in markers:
185
+ _validate_classified_marker(classified)
186
+ source, record_kind = _marker_dimensions(classified)
187
+ time_of_day_ns = classified.time_of_day_ns
188
+
189
+ event_count += 1
190
+ source_counts[source] += 1
191
+ record_kind_counts[record_kind] += 1
192
+ occupied_start_ns = time_of_day_ns if occupied_start_ns is None else min(occupied_start_ns, time_of_day_ns)
193
+ occupied_end_ns = time_of_day_ns if occupied_end_ns is None else max(occupied_end_ns, time_of_day_ns)
194
+
195
+ if classified.within_schedule:
196
+ within_schedule_count += 1
197
+ else:
198
+ outside_schedule_count += 1
199
+ outside_marker_count += 1
200
+ _retain_recent(outside_heap, classified, outside_limit, event_count)
201
+ if classified.weekend:
202
+ weekend_count += 1
203
+
204
+ if display_start_ns is None or display_end_ns is None:
205
+ visible_markers.append(classified)
206
+ elif display_start_ns <= time_of_day_ns < display_end_ns:
207
+ visible_markers.append(classified)
208
+ elif time_of_day_ns < display_start_ns:
209
+ hidden_before_total += 1
210
+ hidden_before_sources[source] += 1
211
+ else:
212
+ hidden_after_total += 1
213
+ hidden_after_sources[source] += 1
214
+
215
+ display_start, display_end = _resolve_display_range(
216
+ display_range=display_range,
217
+ schedule_bounds=schedule_bounds,
218
+ occupied_start_ns=occupied_start_ns,
219
+ occupied_end_ns=occupied_end_ns,
220
+ )
221
+ clusters = _cluster_markers(visible_markers, cluster_window_ns)
222
+ retained_outside = tuple(item[2] for item in sorted(outside_heap, key=lambda item: (item[0], item[1])))
223
+
224
+ aggregation = Aggregation(
225
+ cluster_window=cluster_window,
226
+ display_start_minute=display_start,
227
+ display_end_minute=display_end,
228
+ display_is_explicit=display_range is not None,
229
+ clusters=clusters,
230
+ event_count=event_count,
231
+ within_schedule_count=within_schedule_count,
232
+ outside_schedule_count=outside_schedule_count,
233
+ weekend_count=weekend_count,
234
+ source_counts=_freeze_counter(source_counts),
235
+ record_kind_counts=_freeze_counter(record_kind_counts),
236
+ hidden_before=HiddenMarkers(hidden_before_total, _freeze_counter(hidden_before_sources)),
237
+ hidden_after=HiddenMarkers(hidden_after_total, _freeze_counter(hidden_after_sources)),
238
+ retained_outside_markers=retained_outside,
239
+ outside_marker_count=outside_marker_count,
240
+ )
241
+ if aggregation.displayed_event_count + hidden_before_total + hidden_after_total != event_count:
242
+ raise RuntimeError("displayed and hidden marker totals do not reconcile")
243
+ return aggregation
244
+
245
+
246
+ def _cluster_markers(markers: Iterable[ClassifiedMarker], window_ns: int) -> tuple[TimeCluster, ...]:
247
+ ordered = sorted(markers, key=_marker_order_key)
248
+ clusters: list[TimeCluster] = []
249
+ cursor = 0
250
+ while cursor < len(ordered):
251
+ anchor = ordered[cursor].time_of_day_ns
252
+ window_end = anchor + window_ns
253
+ end = cursor + 1
254
+ while end < len(ordered) and ordered[end].time_of_day_ns < window_end:
255
+ end += 1
256
+ cluster_markers = ordered[cursor:end]
257
+ by_weekday: dict[Weekday, list[ClassifiedMarker]] = {}
258
+ for marker in cluster_markers:
259
+ by_weekday.setdefault(marker.weekday, []).append(marker)
260
+ cells = tuple(ClusterCell(weekday, tuple(by_weekday[weekday])) for weekday in sorted(by_weekday))
261
+ clusters.append(
262
+ TimeCluster(
263
+ start_time_ns=anchor,
264
+ end_time_ns=cluster_markers[-1].time_of_day_ns,
265
+ cells=cells,
266
+ )
267
+ )
268
+ cursor = end
269
+ return tuple(clusters)
270
+
271
+
272
+ def _marker_order_key(classified: ClassifiedMarker) -> tuple[int, int, int, str]:
273
+ """Order events by wall time, instant, source, then provenance.
274
+
275
+ Source is only consulted for genuinely simultaneous folded events, where
276
+ no chronological ordering exists. Keeping Git before filesystem makes
277
+ mixed cells visually stable instead of exposing hash-derived marker IDs.
278
+ """
279
+
280
+ return (
281
+ classified.time_of_day_ns,
282
+ classified.marker.occurred_at_utc_ns,
283
+ 0 if classified.marker.origin.source is Source.GIT else 1,
284
+ classified.marker.marker_id,
285
+ )
286
+
287
+
288
+ def _marker_dimensions(classified: ClassifiedMarker) -> tuple[Source, RecordKind]:
289
+ observations = classified.marker.observations
290
+ if not observations:
291
+ raise ValueError("an activity marker must contain at least one observation")
292
+ source = observations[0].origin.source
293
+ record_kind = observations[0].origin.record_kind
294
+ for observation in observations[1:]:
295
+ if observation.origin.source is not source or observation.origin.record_kind is not record_kind:
296
+ raise ValueError("coalesced marker observations must share source and record kind")
297
+ return source, record_kind
298
+
299
+
300
+ def _validate_classified_marker(classified: ClassifiedMarker) -> None:
301
+ if not 0 <= classified.time_of_day_ns < NANOSECONDS_PER_DAY:
302
+ raise ValueError("classified marker local time must fall within one day")
303
+
304
+
305
+ def _cluster_window_ns(cluster_window: object) -> int:
306
+ if not isinstance(cluster_window, timedelta):
307
+ raise TypeError("cluster_window must be a datetime.timedelta")
308
+ nanoseconds = (
309
+ cluster_window.days * 86_400 * NANOSECONDS_PER_SECOND
310
+ + cluster_window.seconds * NANOSECONDS_PER_SECOND
311
+ + cluster_window.microseconds * 1_000
312
+ )
313
+ if not 0 < nanoseconds < NANOSECONDS_PER_DAY:
314
+ raise ValueError("cluster_window must be greater than zero and less than 24 hours")
315
+ return nanoseconds
316
+
317
+
318
+ def _retain_recent(
319
+ heap: list[tuple[tuple[int, str], int, ClassifiedMarker]],
320
+ classified: ClassifiedMarker,
321
+ limit: int,
322
+ ordinal: int,
323
+ ) -> None:
324
+ if limit == 0:
325
+ return
326
+ key = (classified.marker.occurred_at_utc_ns, classified.marker.marker_id)
327
+ item = (key, ordinal, classified)
328
+ if len(heap) < limit:
329
+ heapq.heappush(heap, item)
330
+ elif key > heap[0][0]:
331
+ heapq.heapreplace(heap, item)
332
+
333
+
334
+ def _resolve_display_range(
335
+ *,
336
+ display_range: tuple[int, int] | None,
337
+ schedule_bounds: tuple[int, int] | None,
338
+ occupied_start_ns: int | None,
339
+ occupied_end_ns: int | None,
340
+ ) -> tuple[int, int]:
341
+ if display_range is not None:
342
+ return display_range
343
+
344
+ starts: list[int] = []
345
+ ends: list[int] = []
346
+ if schedule_bounds is not None:
347
+ starts.append(schedule_bounds[0])
348
+ ends.append(schedule_bounds[1])
349
+ if occupied_start_ns is not None and occupied_end_ns is not None:
350
+ starts.append(occupied_start_ns // NANOSECONDS_PER_MINUTE)
351
+ ends.append(occupied_end_ns // NANOSECONDS_PER_MINUTE + 1)
352
+ if not starts:
353
+ return (0, MINUTES_PER_DAY)
354
+
355
+ start = max(0, (min(starts) // 60) * 60)
356
+ end = min(MINUTES_PER_DAY, ((max(ends) + 59) // 60) * 60)
357
+ if start == end:
358
+ end = min(MINUTES_PER_DAY, start + 60)
359
+ if start == end:
360
+ start = max(0, end - 60)
361
+ return (start, end)
362
+
363
+
364
+ def _validate_schedule_bounds(bounds: tuple[int, int] | None) -> None:
365
+ if bounds is None:
366
+ return
367
+ start, end = bounds
368
+ if not 0 <= start < end <= MINUTES_PER_DAY:
369
+ raise ValueError("schedule_bounds must be within 00:00-24:00 and non-empty")
370
+
371
+
372
+ def _validate_display_range(display_range: tuple[int, int] | None) -> None:
373
+ if display_range is None:
374
+ return
375
+ start, end = display_range
376
+ if not 0 <= start < end <= MINUTES_PER_DAY:
377
+ raise ValueError("display_range must be within 00:00-24:00 and non-empty")
378
+
379
+
380
+ def _freeze_counter(counter: Counter[_CountKey]) -> tuple[tuple[_CountKey, int], ...]:
381
+ return tuple(sorted(((key, count) for key, count in counter.items() if count), key=lambda item: item[0].value))
382
+
383
+
384
+ def _lookup_count(pairs: tuple[tuple[_CountKey, int], ...], key: _CountKey) -> int:
385
+ return next((count for candidate, count in pairs if candidate is key), 0)
386
+
387
+
388
+ __all__ = [
389
+ "Aggregation",
390
+ "ClusterCell",
391
+ "HiddenMarkers",
392
+ "TimeCluster",
393
+ "aggregate_markers",
394
+ ]