pyworklog 0.3.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.
@@ -0,0 +1,558 @@
1
+ """worklog commands: views group."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import sqlite3
9
+ import sys
10
+ from datetime import datetime, timedelta
11
+ from pathlib import Path
12
+
13
+ from .. import render
14
+ from ..helpers import _ORDER_BY_PRI_ID, _TIME_KINDS # noqa: F401
15
+ from ..helpers import (
16
+ _apply_top_limit,
17
+ _fmt_dur,
18
+ _is_brief,
19
+ _log_full,
20
+ _norm_sched,
21
+ _resolve_at_ts,
22
+ _resolve_concrete_date,
23
+ _resolve_log_tail,
24
+ _resolve_window,
25
+ _sched_anchor,
26
+ _sched_display,
27
+ _sched_kind,
28
+ _sched_sort_key,
29
+ _status_marker,
30
+ _term_width,
31
+ _truncate_log_body,
32
+ GENERIC_TAGS,
33
+ )
34
+ from ..queries import (
35
+ _ancestors_chain,
36
+ _check_ids_exist,
37
+ _collect_descendants,
38
+ _has_tag,
39
+ _insert_log,
40
+ _node_bucket,
41
+ _node_clock_min,
42
+ _node_exists,
43
+ _node_plan,
44
+ _node_project,
45
+ _node_tags,
46
+ _project_members,
47
+ _sec_group,
48
+ _status_filter_sql,
49
+ _upsert_prop,
50
+ )
51
+ from ..render import (
52
+ _PRI_STYLE,
53
+ _STATUS_STYLE,
54
+ _RICH_AVAIL,
55
+ _resolve_theme,
56
+ THEMES,
57
+ _c,
58
+ _hl,
59
+ _node_line,
60
+ _print_truncation_hint,
61
+ _snippet,
62
+ out,
63
+ )
64
+ from ..xdg import _resolve_db_path, _resolve_aliases_path, _xdg_data_home, _xdg_config_home
65
+
66
+ # Lazy access to cli module (for DB wrappers + module state).
67
+ # Used at function call time (not at import) to avoid the cli ↔ commands
68
+ # import cycle.
69
+ from .. import cli as _cli # noqa: E402
70
+
71
+
72
+
73
+ def cmd_tree(args, con):
74
+ inc_cancel = getattr(args, "show_canceled", False)
75
+ log_tail = _resolve_log_tail(args, _is_brief(args, "no_logs"), default_tail=3)
76
+ if args.by:
77
+ _tree_by(con, args.by)
78
+ return
79
+ full = _log_full(args)
80
+ if args.root is None and args.kind is None and args.depth is None:
81
+ # bare wl tree: areas one level + timeline up to today
82
+ _print_default_tree(con, include_canceled=inc_cancel, log_tail=log_tail, full=full)
83
+ return
84
+ if args.root is not None:
85
+ # expand subtree from a specified node as root (no longer requires parent_id IS NULL)
86
+ root = con.execute("SELECT * FROM node WHERE id = ?", (args.root,)).fetchone()
87
+ if not root:
88
+ sys.exit(f"✗ node #{args.root} not found")
89
+ roots = [root]
90
+ else:
91
+ root_sql = "SELECT * FROM node WHERE parent_id IS NULL"
92
+ params_root = []
93
+ if args.kind:
94
+ root_sql += " AND kind = ?"
95
+ params_root.append(args.kind)
96
+ if not inc_cancel:
97
+ frag, p = _status_filter_sql(include_canceled=False)
98
+ if frag:
99
+ root_sql += " AND " + frag
100
+ params_root.extend(p)
101
+ roots = list(con.execute(root_sql, params_root))
102
+
103
+ if not roots:
104
+ print("(no root nodes)")
105
+ return
106
+
107
+ # default depth limit to avoid flooding: full tree default 2 (area->project / year->quarter overview), --root default 3 (one extra level for drill-down)
108
+ max_depth = args.depth if args.depth is not None else (3 if args.root is not None else 2)
109
+ for root in roots:
110
+ _print_tree(con, root, depth=0, max_depth=max_depth,
111
+ include_canceled=inc_cancel, log_tail=log_tail, full=full)
112
+
113
+ def cmd_day(args, con):
114
+ """Reproduce a single day's progress (default today): bucket by work/personal -> project -> task -> that day's logs.
115
+ Driven by log dates (not the day node), so it works for historical days too."""
116
+ from datetime import date as _date
117
+
118
+ if args.date:
119
+ try:
120
+ target = _resolve_concrete_date(args.date)
121
+ except ValueError:
122
+ sys.exit(f"✗ invalid date '{args.date}' (use YYYY-MM-DD / today / yesterday / day-before-yesterday / tomorrow / day-after-tomorrow)")
123
+ else:
124
+ target = _date.today().isoformat()
125
+ day = con.execute(
126
+ "SELECT * FROM node WHERE kind = 'day' AND title LIKE ? ORDER BY id LIMIT 1",
127
+ (target + "%",),
128
+ ).fetchone()
129
+ # date context: date + auto-computed weekday + date_meta label (holiday/vacation/working-day swap)
130
+ wd = _cn_weekday(target)
131
+ label = _date_label(con, target)
132
+ head = target + (f" {wd}" if wd else "") + (f" · {label}" if label else "")
133
+ out(_c(head, "header"))
134
+ # meta (stored as props on the day node): goal / recap / Top5; plus parent week node's overview
135
+ if day:
136
+ meta = {r["key"]: r["value"] for r in con.execute("SELECT key, value FROM prop WHERE node_id = ?", (day["id"],))}
137
+ if meta.get("goal"):
138
+ out(_c(" > 🎯 " + meta["goal"], "meta"))
139
+ if meta.get("summary"):
140
+ at = meta.get("summary_at")
141
+ when = _c(f" (written at {at[5:16]})", "meta") if at else ""
142
+ out(_c(" > Recap: " + meta["summary"], "meta") + when)
143
+ # stale check: after recap, if there are new non-CLOCK logs today, prompt to rewrite
144
+ if at:
145
+ newer = con.execute(
146
+ "SELECT COUNT(*) FROM log WHERE logged_at > ? "
147
+ "AND substr(logged_at, 1, 10) = ? AND body NOT LIKE 'CLOCK_%'",
148
+ (at, target),
149
+ ).fetchone()[0]
150
+ if newer:
151
+ out(_c(f" > ⚠ {newer} change(s) after recap; consider rewriting via wl recap", "doing"))
152
+ if meta.get("top5"):
153
+ out(_c(" > Top5: " + meta["top5"], "meta"))
154
+ wk = con.execute("SELECT id FROM node WHERE id = ? AND kind = 'week'", (day["parent_id"],)).fetchone()
155
+ if wk:
156
+ ov = con.execute("SELECT value FROM prop WHERE node_id = ? AND key = 'overview'", (wk["id"],)).fetchone()
157
+ if ov:
158
+ out(_c(" > This week: " + ov["value"], "meta"))
159
+
160
+ inc_cancel = getattr(args, "show_canceled", False)
161
+ cfrag, cparams = _status_filter_sql(include_canceled=inc_cancel, col="node.status")
162
+ cancel_sql = (" AND " + cfrag) if cfrag else ""
163
+ rows = con.execute(
164
+ rf"""SELECT log.node_id, log.logged_at, log.body,
165
+ node.title, node.status, node.priority, node.kind
166
+ FROM log JOIN node ON log.node_id = node.id
167
+ WHERE date(log.logged_at) = ?
168
+ AND log.body NOT LIKE 'CLOCK\_%' ESCAPE '\'
169
+ AND node.kind IN ('task', 'habit', 'meetlog')
170
+ {cancel_sql}
171
+ ORDER BY log.logged_at, log.node_id""",
172
+ [target] + cparams,
173
+ ).fetchall()
174
+
175
+ # items: tasks with logs + tasks scheduled but with no log yet today (planned items visible ahead of time)
176
+ items = {}
177
+ for r in rows:
178
+ items.setdefault(r["node_id"], {"node": r, "logs": []})["logs"].append(r["body"])
179
+ sched_ids = _scheduled_node_ids(con, target)
180
+ for nid in sched_ids:
181
+ if nid not in items:
182
+ nr = con.execute(
183
+ "SELECT id AS node_id, title, status, priority, kind FROM node WHERE id = ?", (nid,)
184
+ ).fetchone()
185
+ if nr and nr["kind"] in ("task", "habit", "meetlog"):
186
+ if not inc_cancel and nr["status"] == "CANCELED":
187
+ continue
188
+ items[nid] = {"node": nr, "logs": []}
189
+
190
+ if not items:
191
+ out(_c(f" (no log progress for {target}, and nothing planned)", "meta"))
192
+ return
193
+
194
+ # log_tail priority: --no-logs/--brief -> 0 / --all-logs -> None (full) /
195
+ # --log-tail N -> N / default 3 (elide middle, only the end visible to keep wl day from blowing up on long logs)
196
+ brief = _is_brief(args, "no_logs")
197
+ log_tail = _resolve_log_tail(args, brief, default_tail=3)
198
+ _render_day_group(con, items, by=getattr(args, "by", "plan"),
199
+ sched_ids=sched_ids, log_tail=log_tail,
200
+ full=_log_full(args))
201
+
202
+ # bottom stats: per-status distribution + planned-not-done count + CLOCK time
203
+ import re
204
+
205
+ logged = {r["node_id"]: (r["status"] or "TODO") for r in rows}
206
+ stats = {}
207
+ for s in logged.values():
208
+ stats[s] = stats.get(s, 0) + 1
209
+ done = stats.get("DONE", 0)
210
+ total = len(logged)
211
+ planned_undone = sum(1 for nid in items if not items[nid]["logs"])
212
+ parts = [f"{s} {stats[s]}" for s in ("DONE", "DOING", "TODO", "LATER", "WAIT", "DEFERRED", "CANCELED") if stats.get(s)]
213
+ clock = con.execute(
214
+ "SELECT body FROM log WHERE date(logged_at) = ? AND body LIKE 'CLOCK_OUT%'",
215
+ (target,),
216
+ ).fetchall()
217
+ total_min = sum(int(m.group(1)) for r in clock if (m := re.search(r"elapsed=(\d+)min", r["body"])))
218
+ print()
219
+ line = f" ── {target}: {done}/{total} tasks with progress"
220
+ if parts:
221
+ line += " · " + " · ".join(parts)
222
+ if planned_undone:
223
+ line += f" · planned·not-done {planned_undone}"
224
+ if total_min:
225
+ line += f" · CLOCK {total_min}min ({total_min // 60}h{total_min % 60}m)"
226
+ out(_c(line, "meta"))
227
+
228
+ def _tree_by(con, by):
229
+ """Flat 2-level view, regrouped by dimension (avoids deep time-layered nesting)."""
230
+ if by == "tag":
231
+ tags = [r["tag"] for r in con.execute("SELECT DISTINCT tag FROM tag ORDER BY tag")]
232
+ sem = [t for t in tags if t not in GENERIC_TAGS]
233
+ if not sem:
234
+ print("(no semantic tags)")
235
+ return
236
+ for tag in sem:
237
+ rows = con.execute(
238
+ "SELECT n.* FROM node n JOIN tag t ON n.id = t.node_id WHERE t.tag = ? "
239
+ "ORDER BY n.priority NULLS LAST, n.id",
240
+ (tag,),
241
+ ).fetchall()
242
+ out(_c(f"#{tag}", "tag") + " " + _c(f"({len(rows)})", "meta"))
243
+ for n in rows:
244
+ out(_node_line(con, n))
245
+
246
+ elif by == "project":
247
+ projects = con.execute(
248
+ f"SELECT * FROM node WHERE kind = 'project' {_ORDER_BY_PRI_ID}"
249
+ ).fetchall()
250
+ if not projects:
251
+ print("(no project nodes)")
252
+ return
253
+ claimed = set()
254
+ for proj in projects:
255
+ proj_tags = {r["tag"] for r in con.execute("SELECT tag FROM tag WHERE node_id = ?", (proj["id"],))} - GENERIC_TAGS
256
+ ids = set()
257
+ # (a) structural children
258
+ for r in con.execute("SELECT id FROM node WHERE parent_id = ?", (proj["id"],)):
259
+ ids.add(r["id"])
260
+ # (b) task/meetlog/habit sharing a semantic tag
261
+ if proj_tags:
262
+ qm = ",".join("?" * len(proj_tags))
263
+ for r in con.execute(
264
+ f"SELECT DISTINCT n.id FROM node n JOIN tag t ON n.id = t.node_id "
265
+ f"WHERE t.tag IN ({qm}) AND n.kind IN ('task','meetlog','habit')",
266
+ list(proj_tags),
267
+ ):
268
+ ids.add(r["id"])
269
+ pri = (" " + _c(f"[#{proj['priority']}]", _PRI_STYLE.get(proj["priority"]))) if proj["priority"] else ""
270
+ out("▸ " + _c(f"#{proj['id']}", "id") + pri + " " + _c(proj["title"], "header") + " " + _c(f"({len(ids)})", "meta"))
271
+ for nid in sorted(ids):
272
+ n = con.execute("SELECT * FROM node WHERE id = ?", (nid,)).fetchone()
273
+ claimed.add(nid)
274
+ out(_node_line(con, n))
275
+ if not ids:
276
+ out(" " + _c("(no linked tasks)", "meta"))
277
+ # orphans: task/meetlog/habit not attached to any project
278
+ orphans = con.execute(
279
+ f"SELECT * FROM node WHERE kind IN ('task','meetlog','habit') {_ORDER_BY_PRI_ID}"
280
+ ).fetchall()
281
+ orphans = [n for n in orphans if n["id"] not in claimed]
282
+ if orphans:
283
+ out("▸ " + _c("(unassigned)", "header") + " " + _c(f"({len(orphans)})", "meta"))
284
+ for n in orphans:
285
+ out(_node_line(con, n))
286
+
287
+ elif by == "direction":
288
+ for direction in ("work", "personal"):
289
+ rows = con.execute(
290
+ "SELECT n.* FROM node n JOIN tag t ON n.id = t.node_id WHERE t.tag = ? "
291
+ "AND n.kind IN ('task','meetlog','habit','project') "
292
+ "ORDER BY n.priority NULLS LAST, n.id",
293
+ (direction,),
294
+ ).fetchall()
295
+ out(_c(f"[{direction}]", "header") + " " + _c(f"({len(rows)})", "meta"))
296
+ for n in rows:
297
+ out(_node_line(con, n))
298
+
299
+ def _tree_children(con, node, include_canceled=False):
300
+ """Children ordering: time-kinds ascending by title (date); others by priority -> id. CANCELED excluded by default."""
301
+ sql = "SELECT * FROM node WHERE parent_id = ?"
302
+ sql_params = [node["id"]]
303
+ frag, p = _status_filter_sql(include_canceled=include_canceled)
304
+ if frag:
305
+ sql += " AND " + frag
306
+ sql_params.extend(p)
307
+ rows = list(con.execute(sql, sql_params))
308
+
309
+ def key(r):
310
+ if r["kind"] in _TIME_KINDS:
311
+ return (0, r["title"], 0)
312
+ pr = {"A": 0, "B": 1, "C": 2}.get(r["priority"], 3)
313
+ return (1, pr, r["id"])
314
+
315
+ return sorted(rows, key=key)
316
+
317
+ def _print_tree(con, node, depth, max_depth, *, include_canceled=False, log_tail=3, full=False):
318
+ out(_node_line(con, node, indent=" " * depth, sched=True))
319
+ if max_depth is not None and depth >= max_depth:
320
+ return
321
+ if node["kind"] == "day": # day has no real children (empty); expand today's log activity instead
322
+ _print_day_activity(con, node, depth, max_depth,
323
+ include_canceled=include_canceled, log_tail=log_tail, full=full)
324
+ return
325
+ for c in _tree_children(con, node, include_canceled=include_canceled):
326
+ _print_tree(con, c, depth + 1, max_depth,
327
+ include_canceled=include_canceled, log_tail=log_tail, full=full)
328
+
329
+
330
+ _BUCKET_ORDER = ["work", "personal", "other"]
331
+ _WEEKDAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
332
+
333
+ def _print_day_activity(con, day_node, depth, max_depth, *, include_canceled=False, log_tail=3, full=False):
334
+ """For a day node in the tree view, expand that day's activity: tasks with logs + that day's logs (today only, not others).
335
+ log_tail: None = full expansion / 0 = no log expansion / N = latest N per task (default 3, middle elided to keep wl tree compact)."""
336
+ from collections import OrderedDict
337
+
338
+ target = day_node["title"][:10]
339
+ cfrag, cparams = _status_filter_sql(include_canceled=include_canceled, col="node.status")
340
+ cancel_sql = (" AND " + cfrag) if cfrag else ""
341
+ rows = con.execute(
342
+ rf"""SELECT log.node_id, log.body, node.title, node.status, node.priority, node.kind
343
+ FROM log JOIN node ON log.node_id = node.id
344
+ WHERE date(log.logged_at) = ?
345
+ AND log.body NOT LIKE 'CLOCK\_%' ESCAPE '\'
346
+ AND node.kind IN ('task', 'habit', 'meetlog')
347
+ {cancel_sql}
348
+ ORDER BY log.node_id""",
349
+ [target] + cparams,
350
+ ).fetchall()
351
+ tasks = OrderedDict()
352
+ for r in rows:
353
+ tasks.setdefault(r["node_id"], {"r": r, "logs": []})["logs"].append(r["body"])
354
+ ind = " " * (depth + 1)
355
+ for nid, t in tasks.items():
356
+ n = t["r"]
357
+ # habit with a log today = done today (render-layer smarts, same as _render_day_group)
358
+ if n["kind"] == "habit" and t["logs"]:
359
+ mk = _c("[x]", "done")
360
+ else:
361
+ mk = _c(_status_marker(n["status"]), _STATUS_STYLE.get(n["status"], "todo"))
362
+ pri = (_c(f"[#{n['priority']}]", _PRI_STYLE.get(n["priority"])) + " ") if n["priority"] else ""
363
+ out(ind + mk + " " + _c(f"#{nid}", "id") + " " + pri + _c(n["title"]))
364
+ if log_tail != 0 and (max_depth is None or depth + 1 < max_depth):
365
+ logs = t["logs"]
366
+ shown = logs if log_tail is None else logs[-log_tail:]
367
+ omitted = 0 if log_tail is None else max(0, len(logs) - log_tail)
368
+ if omitted:
369
+ out(" " * (depth + 2) + _c(f"· … ({omitted} earlier logs elided)", "meta"))
370
+ for body in shown:
371
+ indent = " " * (depth + 2)
372
+ shown_body = _truncate_log_body(body, indent_cols=len(indent) + 2, full=full)
373
+ out(indent + _c("· " + shown_body, "meta"))
374
+
375
+ def _print_default_tree(con, *, include_canceled=False, log_tail=3, full=False):
376
+ """Default wl tree: areas one level (area name only) + timeline expanded up to today (year -> quarter -> month -> week -> today + today's activity).
377
+ To drill into an area's projects use --root <area>; for other days use --root <week/month>. CANCELED excluded by default."""
378
+ from datetime import date
379
+
380
+ life = con.execute("SELECT * FROM node WHERE kind = 'lifetime' ORDER BY id LIMIT 1").fetchone()
381
+ has_day = con.execute("SELECT 1 FROM node WHERE kind = 'day' LIMIT 1").fetchone()
382
+ has_month = con.execute("SELECT 1 FROM node WHERE kind = 'month' LIMIT 1").fetchone()
383
+ if not life and not has_day and not has_month:
384
+ print("(no root nodes)")
385
+ return
386
+ base = 0
387
+ if life:
388
+ out(_node_line(con, life))
389
+ base = 1
390
+
391
+ # timeline -> path to today (year -> quarter -> month -> week -> day) + today's activity; if no day node today, fall back to the latest month
392
+ today = date.today().isoformat()
393
+ dayn = con.execute(
394
+ "SELECT * FROM node WHERE kind = 'day' AND title LIKE ? ORDER BY id LIMIT 1", (today + "%",)
395
+ ).fetchone()
396
+ if dayn:
397
+ chain = [n for n in _ancestors_chain(con, dayn["id"]) if n["kind"] != "lifetime"]
398
+ for d, n in enumerate(chain):
399
+ out(_node_line(con, n, indent=" " * (base + d), sched=True))
400
+ # today: only tasks, no log expansion (logs are for drill-down: wl day / wl tree --root <day> --depth big)
401
+ day_depth = base + len(chain) - 1
402
+ _print_day_activity(con, dayn, day_depth, max_depth=day_depth + 1, log_tail=log_tail, full=full)
403
+ else:
404
+ mon = con.execute("SELECT * FROM node WHERE kind = 'month' ORDER BY title DESC LIMIT 1").fetchone()
405
+ if mon:
406
+ out(_node_line(con, mon, indent=" " * base, sched=True))
407
+
408
+ # areas one level only (no project expansion)
409
+ if life:
410
+ for a in _tree_children(con, life, include_canceled=include_canceled):
411
+ if a["kind"] == "area":
412
+ out(_node_line(con, a, indent=" " * base, sched=True))
413
+
414
+ def _render_day_group(con, items, by="plan", sched_ids=frozenset(), log_tail=None, full=False):
415
+ """Render a day: items = {nid: {"node": row(title/status/priority), "logs": [body...]}}.
416
+ Layout: bucket -> (plan/project/priority) -> task -> logs (indented). A task with no log but on a schedule is marked "planned·not-done".
417
+ log_tail: None = full / 0 = no expansion / N = latest N per task.
418
+ full: True keeps body untruncated (default truncates to one line by terminal width)."""
419
+ from collections import OrderedDict
420
+
421
+ buckets = OrderedDict()
422
+ for nid, it in items.items():
423
+ bucket = _node_bucket(con, nid)
424
+ key, title = _sec_group(con, nid, it["node"], by, sched_ids)
425
+ b = buckets.setdefault(bucket, OrderedDict())
426
+ g = b.setdefault(key, {"title": title, "tasks": OrderedDict()})
427
+ g["tasks"][nid] = it
428
+
429
+ sortk = _sec_sort_key(by)
430
+ for bucket in sorted(buckets, key=lambda x: _BUCKET_ORDER.index(x) if x in _BUCKET_ORDER else 99):
431
+ out(" " + _c(bucket, "header"))
432
+ groups = buckets[bucket].items()
433
+ if sortk:
434
+ groups = sorted(groups, key=lambda kv: sortk(kv[1]["title"]))
435
+ for _, g in groups:
436
+ out(" ▸ " + _c(g["title"], "kind"))
437
+ for nid, it in g["tasks"].items():
438
+ n = it["node"]
439
+ logs = it["logs"]
440
+ # habit with a log today = done today (render-layer smarts; resets next day; DB untouched)
441
+ if n["kind"] == "habit" and logs:
442
+ mk = _c("[x]", "done")
443
+ else:
444
+ mk = _c(_status_marker(n["status"]), _STATUS_STYLE.get(n["status"], "todo"))
445
+ pri = (_c(f"[#{n['priority']}]", _PRI_STYLE.get(n["priority"])) + " ") if n["priority"] else ""
446
+ hint = ""
447
+ if not logs:
448
+ hint = _c(" «planned·not-done»", "planned")
449
+ elif log_tail == 0:
450
+ # compact mode: don't expand body, attach a count hint after the title line
451
+ hint = _c(f" ({len(logs)} log)", "meta")
452
+ # total duration (CLOCK union log span); see _node_clock_min docstring
453
+ dur = _fmt_dur(_node_clock_min(con, nid))
454
+ dur_str = (" " + _c(dur, "clock")) if dur else ""
455
+ out(" " + mk + " " + _c(f"#{nid}", "id") + " " + pri + _c(n["title"]) + dur_str + hint)
456
+ if log_tail == 0:
457
+ continue
458
+ bodies = logs if log_tail is None else logs[-log_tail:]
459
+ if log_tail is not None and len(logs) > log_tail:
460
+ out(" " + _c(f"· … ({len(logs) - log_tail} earlier logs elided)", "meta"))
461
+ # log body rendering: indent 8 + "· " 2 = 10 cols, remaining width-10-2 for one line
462
+ for body in bodies:
463
+ shown = _truncate_log_body(body, indent_cols=10, full=full)
464
+ out(" " + _c("· " + shown, "meta"))
465
+
466
+ def _sec_sort_key(by):
467
+ if by == "priority":
468
+ return lambda lbl: _PRI_GROUP_ORDER.index(lbl) if lbl in _PRI_GROUP_ORDER else 99
469
+ if by == "plan":
470
+ return lambda lbl: _PLAN_ORDER.index(lbl) if lbl in _PLAN_ORDER else 99
471
+ return None
472
+
473
+ def _sched_fires(on_date, rrule, target):
474
+ """Whether this sched row fires on target (YYYY-MM-DD). Rules:
475
+ - daily: every day
476
+ - weekly:Mon,Wed,Fri | 1-7 | -1..-7: specific weekday(s) (number 1=Mon..7=Sun, -1=Sun..-7=Mon)
477
+ - monthly:5 | 5,15,25 | -1: day of month; -N counts from month end (-1=last day)
478
+ - quarterly:M-D | -1: M-th month in quarter (1-3), D-th day; -1 = quarter end (3/31, 6/30, 9/30, 12/31)
479
+ - yearly:03-21 | -1: every year MM-DD; -1 = year end (12-31)
480
+ """
481
+ from datetime import date
482
+ import calendar
483
+
484
+ if on_date:
485
+ return on_date == target
486
+ if not rrule:
487
+ return False
488
+ rule = rrule.strip()
489
+ if rule == "daily":
490
+ return True
491
+ y, m, d = (int(x) for x in target.split("-"))
492
+ if rule.startswith("weekly:"):
493
+ days = [x.strip() for x in rule[len("weekly:"):].split(",") if x.strip()]
494
+ return _WEEKDAY_ABBR[date(y, m, d).weekday()] in days
495
+ if rule.startswith("monthly:"):
496
+ tokens = [x.strip() for x in rule[len("monthly:"):].split(",") if x.strip()]
497
+ last = calendar.monthrange(y, m)[1]
498
+ for tok in tokens:
499
+ n = int(tok)
500
+ target_day = n if n > 0 else last + n + 1 # -1 → last, -2 → last-1
501
+ if 1 <= target_day <= last and target_day == d:
502
+ return True
503
+ return False
504
+ if rule.startswith("quarterly:"):
505
+ tokens = [x.strip() for x in rule[len("quarterly:"):].split(",") if x.strip()]
506
+ quarter_month_idx = (m - 1) % 3 + 1 # month offset within the quarter: 1/2/3
507
+ last = calendar.monthrange(y, m)[1]
508
+ for tok in tokens:
509
+ if tok == "-1":
510
+ # quarter end: last day of the quarter's 3rd month (3/6/9/12)
511
+ if quarter_month_idx == 3 and d == last:
512
+ return True
513
+ continue
514
+ mm, dd = (int(x) for x in tok.split("-"))
515
+ if mm == quarter_month_idx and dd == d and 1 <= dd <= last:
516
+ return True
517
+ return False
518
+ if rule.startswith("yearly:"):
519
+ tokens = [x.strip() for x in rule[len("yearly:"):].split(",") if x.strip()]
520
+ md = f"{m:02d}-{d:02d}"
521
+ for tok in tokens:
522
+ if tok == "-1" and md == "12-31":
523
+ return True
524
+ if tok == md:
525
+ return True
526
+ return False
527
+ return False
528
+
529
+ def _scheduled_node_ids(con, target):
530
+ """Set of node_ids hit by a schedule on target (forward planning -> planned bucket)."""
531
+ ids = set()
532
+ for r in con.execute("SELECT node_id, on_date, rrule FROM sched"):
533
+ if _sched_fires(r["on_date"], r["rrule"], target):
534
+ ids.add(r["node_id"])
535
+ return ids
536
+
537
+ def _date_label(con, target):
538
+ """Label (holiday/vacation/working-day-swap) for the date from date_meta, or None."""
539
+ r = con.execute("SELECT label FROM date_meta WHERE date = ?", (target,)).fetchone()
540
+ return r["label"] if r else None
541
+
542
+
543
+
544
+
545
+ _PLAN_ORDER = ["planned", "unplanned", "unplanned (untagged)"]
546
+ _PRI_GROUP_ORDER = ["P0", "P1", "P2", "—"]
547
+ _WEEKDAY_ABBR = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
548
+
549
+ def _cn_weekday(date_str):
550
+ """YYYY-MM-DD -> weekday name (computed, not stored)"""
551
+ from datetime import date
552
+
553
+ try:
554
+ y, m, d = (int(x) for x in date_str.split("-"))
555
+ return _WEEKDAY_NAMES[date(y, m, d).weekday()]
556
+ except (ValueError, IndexError):
557
+ return ""
558
+