tklr-dgraham 0.0.0rc22__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.
tklr/shared.py ADDED
@@ -0,0 +1,739 @@
1
+ import inspect
2
+ import textwrap
3
+ import shutil
4
+ import re
5
+ from rich import print as rich_print
6
+ from datetime import date, datetime, timedelta, timezone
7
+ from typing import Literal, Tuple
8
+ from dateutil import tz
9
+ from pathlib import Path
10
+ from dateutil.parser import parse as dateutil_parse
11
+ from dateutil.parser import parserinfo
12
+ from zoneinfo import ZoneInfo
13
+ from .versioning import get_version
14
+
15
+ from tklr.tklr_env import TklrEnvironment
16
+
17
+ env = TklrEnvironment()
18
+ AMPM = env.config.ui.ampm
19
+ HRS_MINS = "12" if AMPM else "24"
20
+
21
+ ELLIPSIS_CHAR = "…"
22
+
23
+ REPEATING = "↻" # Flag for @r and/or @+ reminders
24
+ OFFFSET = "⌁" # Flag for offset task
25
+
26
+ CORNSILK = "#FFF8DC"
27
+ DARK_GRAY = "#A9A9A9"
28
+ DARK_GREY = "#A9A9A9" # same as DARK_GRAY
29
+ DARK_OLIVEGREEN = "#556B2F"
30
+ DARK_ORANGE = "#FF8C00"
31
+ DARK_SALMON = "#E9967A"
32
+ GOLD = "#FFD700"
33
+ GOLDENROD = "#DAA520"
34
+ KHAKI = "#F0E68C"
35
+ LAWN_GREEN = "#7CFC00"
36
+ LEMON_CHIFFON = "#FFFACD"
37
+ LIGHT_CORAL = "#F08080"
38
+ LIGHT_SKY_BLUE = "#87CEFA"
39
+ LIME_GREEN = "#32CD32"
40
+ ORANGE_RED = "#FF4500"
41
+ PALE_GREEN = "#98FB98"
42
+ PEACHPUFF = "#FFDAB9"
43
+ SALMON = "#FA8072"
44
+ SANDY_BROWN = "#F4A460"
45
+ SEA_GREEN = "#2E8B57"
46
+ SLATE_GREY = "#708090"
47
+ TOMATO = "#FF6347"
48
+
49
+ # Colors for UI elements
50
+ DAY_COLOR = LEMON_CHIFFON
51
+ FRAME_COLOR = KHAKI
52
+ HEADER_COLOR = LIGHT_SKY_BLUE
53
+ DIM_COLOR = DARK_GRAY
54
+ ALLDAY_COLOR = SANDY_BROWN
55
+ EVENT_COLOR = LIME_GREEN
56
+ NOTE_COLOR = DARK_SALMON
57
+ PASSED_EVENT = DARK_OLIVEGREEN
58
+ ACTIVE_EVENT = LAWN_GREEN
59
+ TASK_COLOR = LIGHT_SKY_BLUE
60
+ AVAILABLE_COLOR = LIGHT_SKY_BLUE
61
+ WAITING_COLOR = SLATE_GREY
62
+ FINISHED_COLOR = DARK_GREY
63
+ GOAL_COLOR = GOLDENROD
64
+ BIN_COLOR = GOLDENROD
65
+ ACTIVE_BIN = GOLD
66
+ CHORE_COLOR = KHAKI
67
+ PASTDUE_COLOR = DARK_ORANGE
68
+ NOTICE_COLOR = GOLD
69
+ DRAFT_COLOR = ORANGE_RED
70
+ TODAY_COLOR = TOMATO
71
+ SELECTED_BACKGROUND = "#566573"
72
+ MATCH_COLOR = TOMATO
73
+ TITLE_COLOR = CORNSILK
74
+ BUSY_COLOR = "#9acd32"
75
+ BUSY_COLOR = "#adff2f"
76
+ CONF_COLOR = TOMATO
77
+ BUSY_FRAME_COLOR = "#5d5d5d"
78
+
79
+ TYPE_TO_COLOR = {
80
+ "*": EVENT_COLOR, # event
81
+ "~": AVAILABLE_COLOR, # available task
82
+ "x": FINISHED_COLOR, # finished task
83
+ "^": AVAILABLE_COLOR, # available task
84
+ "+": WAITING_COLOR, # waiting task
85
+ "%": NOTE_COLOR, # note
86
+ "<": PASTDUE_COLOR, # past due task
87
+ ">": NOTICE_COLOR, # begin
88
+ "!": GOAL_COLOR, # draft
89
+ "?": DRAFT_COLOR, # draft
90
+ "b": BIN_COLOR,
91
+ "B": ACTIVE_BIN,
92
+ }
93
+
94
+ # class datetimeChar:
95
+ # VSEP = "⏐" # U+23D0 this will be a de-emphasized color
96
+ # FREE = "─" # U+2500 this will be a de-emphasized color
97
+ # HSEP = "┈" #
98
+ # BUSY = "■" # U+25A0 this will be busy (event) color
99
+ # CONF = "▦" # U+25A6 this will be conflict color
100
+ # TASK = "▩" # U+25A9 this will be busy (task) color
101
+ # ADAY = "━" # U+2501 for all day events ━
102
+ # RSKIP = "▶" # U+25E6 for used time
103
+ # LSKIP = "◀" # U+25E6 for used time
104
+ # USED = "◦" # U+25E6 for used time
105
+ # REPS = "↻" # Flag for repeating items
106
+ # FINISHED_CHAR = "✓"
107
+ # SKIPPED_CHAR = "✗"
108
+ # SLOW_CHAR = "∾"
109
+ # LATE_CHAR = "∿"
110
+ # INACTIVE_CHAR = "≁"
111
+ # # INACTIVE_CHAR='∽'
112
+ # ENDED_CHAR = "≀"
113
+ # UPDATE_CHAR = "𝕦"
114
+ # INBASKET_CHAR = "𝕚"
115
+ # KONNECT_CHAR = "k"
116
+ # LINK_CHAR = "g"
117
+ # PIN_CHAR = "p"
118
+ # ELLIPSIS_CHAR = "…"
119
+ # LINEDOT = " · " # ܁ U+00B7 (middle dot),
120
+ # ELECTRIC = "⌁"
121
+
122
+
123
+ def get_anchor(aware: bool) -> datetime:
124
+ dt = datetime(1970, 1, 1, 0, 0, 0)
125
+ if aware:
126
+ return dt.replace(tzinfo=ZoneInfo("UTC"))
127
+ return dt
128
+
129
+
130
+ def fmt_user(dt_str: str) -> str:
131
+ """
132
+ User friendly formatting for dates and datetimes using env settings
133
+ for ampm, yearfirst, dayfirst and two_digit year.
134
+ """
135
+ if not dt_str:
136
+ return "unscheduled"
137
+ try:
138
+ dt = dateutil_parse(dt_str)
139
+ except Exception as e:
140
+ return f"error parsing {dt_str}: {e}"
141
+ if dt_str.endswith("T0000"):
142
+ return dt.strftime("%Y-%m-%d")
143
+ return dt.strftime("%Y-%m-%d %H:%M")
144
+
145
+
146
+ def parse(s, yearfirst: bool = True, dayfirst: bool = False):
147
+ # enable pi when read by main and settings is available
148
+ pi = parserinfo(
149
+ dayfirst=dayfirst, yearfirst=yearfirst
150
+ ) # FIXME: should come from config
151
+ # logger.debug(f"parsing {s = } with {kwd = }")
152
+ dt = dateutil_parse(s, parserinfo=pi)
153
+ if isinstance(dt, date) and not isinstance(dt, datetime):
154
+ return dt
155
+ if isinstance(dt, datetime):
156
+ if dt.hour == dt.minute == 0:
157
+ return dt.date()
158
+ return dt
159
+ return ""
160
+
161
+
162
+ def _to_local_naive(dt: datetime) -> datetime:
163
+ """Convert aware dt to local naive; leave naive as is."""
164
+ if not isinstance(dt, date) and isinstance(dt, datetime) and dt.tzinfo:
165
+ local = dt.astimezone(tz.tzlocal()).replace(tzinfo=None)
166
+ else:
167
+ local = dt
168
+ return local
169
+
170
+
171
+ def dt_as_utc_timestamp(dt: datetime) -> int:
172
+ if not isinstance(dt, datetime):
173
+ return 0
174
+ return round(dt.astimezone(tz.UTC).timestamp())
175
+
176
+
177
+ def timedelta_str_to_seconds(time_str: str) -> tuple[bool, int]:
178
+ """
179
+ Converts a time string composed of integers followed by 'w', 'd', 'h', or 'm'
180
+ into the total number of seconds.
181
+ Args:
182
+ time_str (str): The time string (e.g., '3h15s').
183
+ Returns:
184
+ int: The total number of seconds.
185
+ Raises:
186
+ ValueError: If the input string is not in the expected format.
187
+ """
188
+ # Define time multipliers for each unit
189
+ multipliers = {
190
+ "w": 7 * 24 * 60 * 60, # Weeks to seconds
191
+ "d": 24 * 60 * 60, # Days to seconds
192
+ "h": 60 * 60, # Hours to seconds
193
+ "m": 60, # Minutes to seconds
194
+ "s": 1, # Seconds to seconds
195
+ }
196
+ # Match all integer-unit pairs (e.g., "3h", "15s")
197
+ matches = re.findall(r"(\d+)([wdhms])", time_str)
198
+ if not matches:
199
+ return (
200
+ False,
201
+ "Invalid time string format. Expected integers followed by 'w', 'd', 'h', or 'm'.",
202
+ )
203
+ # Convert each match to seconds and sum them
204
+ total_seconds = sum(int(value) * multipliers[unit] for value, unit in matches)
205
+ return True, total_seconds
206
+
207
+
208
+ # ---------- DateTimes (local-naive, minute precision) ----------
209
+ def fmt_local_compact(dt: datetime) -> str:
210
+ """Local-naive → 'YYYYMMDD' or 'YYYYMMDDTHHMM' (no seconds)."""
211
+ if dt.hour == dt.minute == dt.second == 0:
212
+ return dt.strftime("%Y%m%d")
213
+ return dt.strftime("%Y%m%dT%H%M")
214
+
215
+
216
+ def parse_local_compact(s: str) -> datetime:
217
+ """'YYYYMMDD' or 'YYYYMMDDTHHMM' → local-naive datetime."""
218
+ if len(s) == 8:
219
+ return datetime.strptime(s, "%Y%m%d")
220
+ if len(s) == 13 and s[8] == "T":
221
+ return datetime.strptime(s, "%Y%m%dT%H%M")
222
+ raise ValueError(f"Bad local-compact datetime: {s!r}")
223
+
224
+
225
+ # FIXME: not needed without seconds
226
+ # ---------- Alerts (local-naive, second precision) ----------
227
+ # def fmt_local_seconds(dt: datetime) -> str:
228
+ # """Local-naive → 'YYYYMMDDTHHMMSS'."""
229
+ # return dt.strftime("%Y%m%dT%H%M%S")
230
+ #
231
+ #
232
+ # def parse_local_seconds(s: str) -> datetime:
233
+ # """'YYYYMMDDTHHMMSS' → local-naive datetime."""
234
+ # return datetime.strptime(s, "%Y%m%dT%H%M%S")
235
+ #
236
+
237
+
238
+ # ---------- Aware UTC (with trailing 'Z', minute precision) ----------
239
+ def fmt_utc_z(dt: datetime) -> str:
240
+ """Aware/naive → UTC aware → 'YYYYMMDDTHHMMZ' (no seconds)."""
241
+ if dt.tzinfo is None:
242
+ dt = dt.replace(tzinfo=timezone.utc) # or attach your local tz then convert
243
+ dt = dt.astimezone(timezone.utc)
244
+ return dt.strftime("%Y%m%dT%H%MZ")
245
+
246
+
247
+ def parse_utc_z(s: str) -> datetime:
248
+ """
249
+ 'YYYYMMDDTHHMMZ' or 'YYYYMMDDTHHMMSSZ' → aware datetime in UTC.
250
+ Accept seconds if present; normalize to tz-aware UTC object.
251
+ """
252
+ if not s.endswith("Z"):
253
+ raise ValueError(f"UTC-Z string must end with 'Z': {s!r}")
254
+ body = s[:-1]
255
+ fmt = "%Y%m%dT%H%M"
256
+ dt = datetime.strptime(body, fmt)
257
+ return dt.replace(tzinfo=timezone.utc)
258
+
259
+
260
+ def truncate_string(s: str, max_length: int) -> str:
261
+ # log_msg(f"Truncating string '{s}' to {max_length} characters")
262
+ if len(s) > max_length:
263
+ return f"{s[: max_length - 2]} {ELLIPSIS_CHAR}"
264
+ else:
265
+ return s
266
+
267
+
268
+ def log_msg(msg: str, file_path: str = "log_msg.md", print_output: bool = False):
269
+ """
270
+ Log a message and save it directly to a specified file.
271
+
272
+ Args:
273
+ msg (str): The message to log.
274
+ file_path (str, optional): Path to the log file. Defaults to "log_msg.md".
275
+ print_output (bool, optional): If True, also print to console.
276
+ """
277
+ frame = inspect.stack()[1].frame
278
+ func_name = frame.f_code.co_name
279
+
280
+ # Default: just function name
281
+ caller_name = func_name
282
+
283
+ # Detect instance/class/static context
284
+ if "self" in frame.f_locals: # instance method
285
+ cls_name = frame.f_locals["self"].__class__.__name__
286
+ caller_name = f"{cls_name}.{func_name}"
287
+ elif "cls" in frame.f_locals: # classmethod
288
+ cls_name = frame.f_locals["cls"].__name__
289
+ caller_name = f"{cls_name}.{func_name}"
290
+
291
+ # Format the line header
292
+ lines = [
293
+ f"- {datetime.now().strftime('%y-%m-%d %H:%M:%S')} log_msg ({caller_name}): ",
294
+ ]
295
+ # Wrap the message text
296
+ lines.extend(
297
+ [
298
+ f"\n{x}"
299
+ for x in textwrap.wrap(
300
+ msg.strip(),
301
+ width=shutil.get_terminal_size()[0] - 6,
302
+ initial_indent=" ",
303
+ subsequent_indent=" ",
304
+ )
305
+ ]
306
+ )
307
+ lines.append("\n\n")
308
+
309
+ # Save the message to the file
310
+ with open(file_path, "a") as f:
311
+ f.writelines(lines)
312
+
313
+ # Optional console print
314
+ if print_output:
315
+ print("".join(lines))
316
+
317
+
318
+ def bug_msg(msg: str, file_path: str = "bug_msg.md", print_output: bool = False):
319
+ """
320
+ A different name for log_msg for temp debugging usage - easier to find and remove.
321
+ By default writes to "bug_msg.md" instead of "log_msg.md"
322
+ """
323
+ frame = inspect.stack()[1].frame
324
+ func_name = frame.f_code.co_name
325
+
326
+ # Default: just function name
327
+ caller_name = func_name
328
+
329
+ # Detect instance/class/static context
330
+ if "self" in frame.f_locals: # instance method
331
+ cls_name = frame.f_locals["self"].__class__.__name__
332
+ caller_name = f"{cls_name}.{func_name}"
333
+ elif "cls" in frame.f_locals: # classmethod
334
+ cls_name = frame.f_locals["cls"].__name__
335
+ caller_name = f"{cls_name}.{func_name}"
336
+
337
+ # Format the line header
338
+ lines = [
339
+ f"- {datetime.now().strftime('%y-%m-%d %H:%M:%S')} bug_msg ({caller_name}): ",
340
+ ]
341
+ # Wrap the message text
342
+ lines.extend(
343
+ [
344
+ f"\n{x}"
345
+ for x in textwrap.wrap(
346
+ msg.strip(),
347
+ width=shutil.get_terminal_size()[0] - 6,
348
+ initial_indent=" ",
349
+ subsequent_indent=" ",
350
+ )
351
+ ]
352
+ )
353
+ lines.append("\n\n")
354
+
355
+ # Save the message to the file
356
+ with open(file_path, "a") as f:
357
+ f.writelines(lines)
358
+
359
+ # Optional console print
360
+ if print_output:
361
+ print("".join(lines))
362
+
363
+
364
+ def print_msg(msg: str, file_path: str = "log_msg.md", print_output: bool = False):
365
+ """
366
+ Log a message and save it directly to a specified file.
367
+
368
+ Args:
369
+ msg (str): The message to log.
370
+ file_path (str, optional): Path to the log file. Defaults to "log_msg.txt".
371
+ """
372
+ caller_name = inspect.stack()[1].function
373
+ lines = [
374
+ f"{caller_name}",
375
+ ]
376
+ lines.extend(
377
+ [
378
+ f"\n{x}"
379
+ for x in textwrap.wrap(
380
+ msg.strip(),
381
+ width=shutil.get_terminal_size()[0] - 6,
382
+ initial_indent=" ",
383
+ subsequent_indent=" ",
384
+ )
385
+ ]
386
+ )
387
+
388
+ # Save the message to the file
389
+ # print("".join(lines))
390
+ for line in lines:
391
+ rich_print(line)
392
+
393
+
394
+ def display_messages(file_path: str = "log_msg.md"):
395
+ """
396
+ Display all logged messages from the specified file.
397
+
398
+ Args:
399
+ file_path (str, optional): Path to the log file. Defaults to "log_msg.txt".
400
+ """
401
+ try:
402
+ # Read messages from the file
403
+ with open(file_path, "r") as f:
404
+ markdown_content = f.read()
405
+ markdown = Markdown(markdown_content)
406
+ console = Console()
407
+ console.print(markdown)
408
+ except FileNotFoundError:
409
+ print(f"Error: Log file '{file_path}' not found.")
410
+
411
+
412
+ def format_time_range(start_time: str, end_time: str, ampm: bool = False) -> str:
413
+ """Format time range respecting ampm setting."""
414
+ start_dt = datetime_from_timestamp(start_time)
415
+ end_dt = datetime_from_timestamp(end_time) if end_time else None
416
+ # log_msg(f"{start_dt = }, {end_dt = }")
417
+
418
+ if not end_dt:
419
+ end_dt = start_dt
420
+
421
+ extent = start_dt != end_dt
422
+
423
+ if start_dt == end_dt and start_dt.hour == 0 and start_dt.minute == 0:
424
+ return ""
425
+
426
+ if ampm:
427
+ start_fmt = "%-I:%M%p" if start_dt.hour < 12 and end_dt.hour >= 12 else "%-I:%M"
428
+ start_hour = start_dt.strftime(f"{start_fmt}").lower().replace(":00", "")
429
+ end_hour = (
430
+ end_dt.strftime("%-I:%M%p").lower().replace(":00", "") # .replace("m", "")
431
+ )
432
+ # log_msg(f"{start_hour = }, {end_hour = }")
433
+ return f"{start_hour}-{end_hour}" if extent else f"{end_hour}"
434
+ else:
435
+ start_hour = start_dt.strftime("%H:%M").replace(":00", "")
436
+ if start_hour.startswith("0"):
437
+ start_hour = start_hour[1:]
438
+ end_hour = end_dt.strftime("%H:%M") # .replace(":00", "")
439
+ if end_hour.startswith("0"):
440
+ end_hour = end_hour[1:]
441
+ # log_msg(f"{start_hour = }, {end_hour = }")
442
+ return f"{start_hour}-{end_hour}" if extent else f"{end_hour}"
443
+
444
+
445
+ def speak_time(time_int: int, mode: Literal["24", "12"]) -> str:
446
+ """Convert time into a spoken phrase for 24-hour or 12-hour format."""
447
+ dt = datetime.fromtimestamp(time_int)
448
+ hour = dt.hour
449
+ minute = dt.minute
450
+
451
+ if mode == "24":
452
+ if minute == 0:
453
+ return f"{hour} hours"
454
+ else:
455
+ return f"{hour} {minute} hours"
456
+ else:
457
+ return dt.strftime("%-I:%M %p").lower().replace(":00", "")
458
+
459
+
460
+ def duration_in_words(seconds: int, short=False):
461
+ """
462
+ Convert a duration in seconds into a human-readable string (weeks, days, hours, minutes).
463
+
464
+ Args:
465
+ seconds (int): Duration in seconds.
466
+ short (bool): If True, return a shortened version (max 2 components).
467
+
468
+ Returns:
469
+ str: Human-readable duration (e.g., "1 week 2 days", "3 hours 27 minutes").
470
+ """
471
+ try:
472
+ # Handle sign for negative durations
473
+ sign = "" if seconds >= 0 else "- "
474
+ total_seconds = abs(int(seconds))
475
+
476
+ # Define time units in seconds
477
+ units = [
478
+ ("week", 604800), # 7 * 24 * 60 * 60
479
+ ("day", 86400), # 24 * 60 * 60
480
+ ("hour", 3600), # 60 * 60
481
+ ("minute", 60), # 60
482
+ ("second", 1), # 1
483
+ ]
484
+
485
+ # Compute time components
486
+ result = []
487
+ for name, unit_seconds in units:
488
+ value, total_seconds = divmod(total_seconds, unit_seconds)
489
+ if value:
490
+ result.append(f"{sign}{value} {name}{'s' if value > 1 else ''}")
491
+
492
+ # Handle case where duration is zero
493
+ if not result:
494
+ return "zero minutes"
495
+
496
+ # Return formatted duration
497
+ return " ".join(result[:2]) if short else " ".join(result)
498
+
499
+ except Exception as e:
500
+ log_msg(f"{seconds = } raised exception: {e}")
501
+ return None
502
+
503
+
504
+ def format_timedelta(seconds: int, short=False):
505
+ """
506
+ Convert a duration in seconds into a human-readable string (weeks, days, hours, minutes).
507
+
508
+ Args:
509
+ seconds (int): Duration in seconds.
510
+ short (bool): If True, return a shortened version (max 2 components).
511
+
512
+ Returns:
513
+ str: Human-readable duration (e.g., "1 week 2 days", "3 hours 27 minutes").
514
+ """
515
+ try:
516
+ # Handle sign for negative durations
517
+ sign = "+" if seconds >= 0 else "-"
518
+ total_seconds = abs(int(seconds))
519
+
520
+ # Define time units in seconds
521
+ units = [
522
+ ("w", 604800), # 7 * 24 * 60 * 60
523
+ ("d", 86400), # 24 * 60 * 60
524
+ ("h", 3600), # 60 * 60
525
+ ("m", 60), # 60
526
+ ("s", 1), # 1
527
+ ]
528
+
529
+ # Compute time components
530
+ result = []
531
+ for name, unit_seconds in units:
532
+ value, total_seconds = divmod(total_seconds, unit_seconds)
533
+ if value:
534
+ result.append(f"{value}{name}")
535
+
536
+ # Handle case where duration is zero
537
+ if not result:
538
+ return "now"
539
+
540
+ # Return formatted duration
541
+ return sign + ("".join(result[:2]) if short else "".join(result))
542
+
543
+ except Exception as e:
544
+ log_msg(f"{seconds = } raised exception: {e}")
545
+ return None
546
+
547
+
548
+ # def format_datetime(
549
+ # seconds: int,
550
+ # mode: Literal["24", "12"] = HRS_MINS,
551
+ # ) -> str:
552
+ # """Return the date and time components of a timestamp using 12 or 24 hour format."""
553
+ # date_time = datetime.fromtimestamp(seconds)
554
+ #
555
+ # date_part = date_time.strftime("%Y-%m-%d")
556
+ #
557
+ # if mode == "24":
558
+ # time_part = date_time.strftime("%H:%Mh").lstrip("0").replace(":00", "")
559
+ # else:
560
+ # time_part = (
561
+ # date_time.strftime("%-I:%M%p").lower().replace(":00", "").rstrip("m")
562
+ # )
563
+ # return date_part, time_part
564
+
565
+
566
+ # def format_datetime(fmt_dt: str, ampm: bool = False) -> str:
567
+ # """
568
+ # Convert a timestamp into a human-readable phrase based on the current time.
569
+ #
570
+ # Args:
571
+ # seconds (int): Timestamp in seconds since the epoch.
572
+ # mode (str): "24" for 24-hour time (e.g., "15 30 hours"), "12" for 12-hour time (e.g., "3 30 p m").
573
+ #
574
+ # Returns:
575
+ # str: Formatted datetime phrase.
576
+ # """
577
+ # dt = datetime.fromtimestamp(seconds)
578
+ # today = date.today()
579
+ # delta_days = (dt.date() - today).days
580
+ #
581
+ # time_str = (
582
+ # dt.strftime("%I:%M%p").lower() if ampm else dt.strftime("%H:%Mh")
583
+ # ).replace(":00", "")
584
+ # if time_str.startswith("0"):
585
+ # time_str = "".join(time_str[1:])
586
+ #
587
+ # # ✅ Case 1: Today → "3 30 p m" or "15 30 hours"
588
+ # if delta_days == 0:
589
+ # return time_str
590
+ #
591
+ # # ✅ Case 2: Within the past/future 6 days → "Monday at 3 30 p m"
592
+ # elif -6 <= delta_days <= 6:
593
+ # day_of_week = dt.strftime("%A")
594
+ # return f"{day_of_week} at {time_str}"
595
+ #
596
+ # # ✅ Case 3: Beyond 6 days → "January 1, 2022 at 3 30 p m"
597
+ # else:
598
+ # date_str = dt.strftime("%B %-d, %Y") # "January 1, 2022"
599
+ # return f"{date_str} at {time_str}"
600
+ #
601
+
602
+ # def datetime_in_words(seconds: int, mode: Literal["24", "12"]) -> str:
603
+ # """Convert a timestamp into a human-readable phrase.
604
+ # If the datetime is today, return the time only, e.g. "3 30 p m" or "15 30 hours".
605
+ # Else if the datetime is within 6 days, return the day of the week and time. e.g. "Monday at 3 30 p m".
606
+ # Else return the full date and time, e.g. "January 1, 2022 at 3 30 p m".
607
+ # """
608
+ #
609
+ # date_time = datetime.fromtimestamp(seconds)
610
+ # date_part = date_time.strftime("%A, %B %d, %Y")
611
+ # time_part = date_time.strftime("%-I:%M %p").lower().replace(":00", "")
612
+ # return f"{date_part} at {time_part}"
613
+ #
614
+
615
+
616
+ def datetime_from_timestamp(fmt_dt: str) -> str:
617
+ if isinstance(fmt_dt, datetime):
618
+ return fmt_dt
619
+ if fmt_dt is None:
620
+ return None
621
+ try:
622
+ if "T" in fmt_dt:
623
+ dt = datetime.strptime(fmt_dt, "%Y%m%dT%H%M")
624
+ # is_date_only = False
625
+ else:
626
+ dt = datetime.strptime(fmt_dt, "%Y%m%d")
627
+ # is_date_only = True
628
+ except ValueError:
629
+ print(f"could not parse {fmt_dt}")
630
+ return None
631
+ return dt
632
+
633
+
634
+ def format_datetime(fmt_dt: str, ampm: bool = False) -> str:
635
+ """
636
+ Convert a compact naive-local datetime string into a human-readable phrase.
637
+
638
+ Args:
639
+ fmt_dt: 'YYYYMMDD' (date) or 'YYYYMMDDTHHMMSS' (naive datetime, local).
640
+ ampm: True -> '3:30pm' / False -> '15h30'.
641
+
642
+ Returns:
643
+ str: Human-readable phrase like 'today', 'Monday at 3pm', or
644
+ 'January 5, 2026 at 15h'.
645
+ """
646
+ # Parse
647
+ if "T" in fmt_dt:
648
+ dt = datetime.strptime(fmt_dt, "%Y%m%dT%H%M")
649
+ is_date_only = False
650
+ else:
651
+ dt = datetime.strptime(fmt_dt, "%Y%m%d")
652
+ is_date_only = True
653
+
654
+ today = date.today()
655
+ delta_days = (dt.date() - today).days
656
+
657
+ # Date-only cases
658
+ if is_date_only:
659
+ if delta_days == 0:
660
+ return "today"
661
+ elif -6 <= delta_days <= 6:
662
+ return dt.strftime("%A")
663
+ else:
664
+ # Note: %-d is POSIX; if you need Windows support, use an alternate path.
665
+ return dt.strftime("%B %-d, %Y")
666
+
667
+ suffix = dt.strftime("%p").lower() if ampm else ""
668
+ hours = dt.strftime("%-I") if ampm else dt.strftime("%H")
669
+ minutes = dt.strftime(":%M") if not ampm or dt.minute else ""
670
+ seconds = dt.strftime(":%S") if dt.second else ""
671
+ time_str = hours + minutes + seconds + suffix
672
+
673
+ # Time string
674
+ # time_str = dt.strftime("%I:%M%p").lower() if ampm else dt.strftime("%H:%M")
675
+ # Drop :00 minutes
676
+ # if time_str.endswith(":00pm") or time_str.endswith(":00am"):
677
+ # if ampm:
678
+ # time_str = time_str.replace(":00", "")
679
+ # # else:
680
+ # # time_str = time_str.replace(":00", "h")
681
+ # # Drop leading zero for 12-hour format
682
+ # if ampm and time_str.startswith("0"):
683
+ # time_str = time_str[1:]
684
+
685
+ # Phrasing
686
+ if delta_days == 0:
687
+ return time_str
688
+ elif -6 <= delta_days <= 6:
689
+ return f"{dt.strftime('%A')} at {time_str}"
690
+ else:
691
+ return f"{dt.strftime('%B %-d, %Y')} at {time_str}"
692
+
693
+
694
+ def datetime_in_words(fmt_dt: str, ampm: bool = False) -> str:
695
+ """
696
+ Convert a compact datetime string into a human-readable phrase based on the current time.
697
+
698
+ Args:
699
+ fmt_dt: 'YYYYMMDD' (date) or 'YYYYMMDDTHHMMSS' (naive datetime, local).
700
+ ampm: True -> '3:30pm' / False -> '15h30'.
701
+
702
+ Returns:
703
+ str: Human-readable phrase like 'today', 'Monday at 3pm', or
704
+ 'January 5, 2026 at 15h'.
705
+ """
706
+ if "T" in fmt_dt:
707
+ dt = datetime.strptime(fmt_dt, "%Y%m%dT%H%M%S")
708
+ is_date_only = False
709
+ else:
710
+ dt = datetime.strptime(fmt_dt, "%Y%m%d")
711
+ is_date_only = True
712
+ today = date.today()
713
+ delta_days = (dt.date() - today).days
714
+
715
+ # ✅ Format time based on mode
716
+ minutes = dt.minute
717
+ minutes_str = (
718
+ "" if minutes == 0 else f" o {minutes}" if minutes < 10 else f" {minutes}"
719
+ )
720
+ hours_str = dt.strftime("%H") if ampm else dt.strftime("%I")
721
+ if hours_str.startswith("0"):
722
+ hours_str = hours_str[1:] # Remove leading zero
723
+ suffix = " hours" if ampm else " a m" if dt.hour < 12 else " p m"
724
+
725
+ time_str = f"{hours_str}{minutes_str}{suffix}"
726
+
727
+ # ✅ Case 1: Today → "3 30 p m" or "15 30 hours"
728
+ if delta_days == 0:
729
+ return time_str
730
+
731
+ # ✅ Case 2: Within the past/future 6 days → "Monday at 3 30 p m"
732
+ elif -6 <= delta_days <= 6:
733
+ day_of_week = dt.strftime("%A")
734
+ return f"{day_of_week} at {time_str}"
735
+
736
+ # ✅ Case 3: Beyond 6 days → "January 1, 2022 at 3 30 p m"
737
+ else:
738
+ date_str = dt.strftime("%B %-d, %Y") # "January 1, 2022"
739
+ return f"{date_str} at {time_str}"