"""Shared utilities for BrainRotGuard.""" import logging import re from datetime import datetime, timezone from i18n import format_time as locale_format_time, normalize_locale, t logger = logging.getLogger(__name__) DAY_NAMES = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") DAY_GROUPS = {"weekdays": DAY_NAMES[:5], "weekend": DAY_NAMES[5:]} CAT_LABELS = {"edu": "Educational", "fun": "Entertainment"} def get_weekday(tz_name: str = "") -> str: """Get today's short day name (mon-sun) in the given timezone. Falls back to UTC if tz_name is empty or invalid. """ if tz_name: try: from zoneinfo import ZoneInfo tz = ZoneInfo(tz_name) return DAY_NAMES[datetime.now(tz).weekday()] except Exception: logger.warning("Invalid timezone %r, falling back to UTC", tz_name) return DAY_NAMES[datetime.now(timezone.utc).weekday()] # Matches: 800, 0800, 8:00, 800am, 8:00am, 800pm, 8:00PM, 2000, 20:00 _TIME_RE = re.compile( r'^(\d{1,2}):?(\d{2})\s*(am|pm)?$', re.IGNORECASE, ) # Matches hour-only with am/pm: 8am, 12pm, 9PM _TIME_HOUR_RE = re.compile( r'^(\d{1,2})\s*(am|pm)$', re.IGNORECASE, ) def get_today_str(tz_name: str = "") -> str: """Get today's date as YYYY-MM-DD in the given timezone. Falls back to UTC if tz_name is empty or invalid. """ if tz_name: try: from zoneinfo import ZoneInfo tz = ZoneInfo(tz_name) return datetime.now(tz).strftime("%Y-%m-%d") except Exception: logger.warning("Invalid timezone %r, falling back to UTC", tz_name) return datetime.now(timezone.utc).strftime("%Y-%m-%d") def get_day_utc_bounds(date_str: str, tz_name: str = "") -> tuple[str, str]: """Convert a local date (YYYY-MM-DD) to UTC start/end timestamps. Returns (start_utc, end_utc) as ISO strings for use in SQL queries against UTC-stored watched_at timestamps. """ from datetime import timedelta local_date = datetime.strptime(date_str, "%Y-%m-%d") if tz_name: try: from zoneinfo import ZoneInfo tz = ZoneInfo(tz_name) start_local = local_date.replace(tzinfo=tz) end_local = (local_date + timedelta(days=1)).replace(tzinfo=tz) start_utc = start_local.astimezone(timezone.utc) end_utc = end_local.astimezone(timezone.utc) return (start_utc.strftime("%Y-%m-%d %H:%M:%S"), end_utc.strftime("%Y-%m-%d %H:%M:%S")) except Exception: logger.warning("Invalid timezone %r for day bounds, using date as-is", tz_name) next_day = (local_date + timedelta(days=1)).strftime("%Y-%m-%d") return (date_str, next_day) def parse_time_input(raw: str) -> str | None: """Parse flexible time input into 24-hour "HH:MM" format. Accepts: 800, 0800, 8:00, 800am, 8:00am, 800pm, 8:00PM, 2000, 20:00 Returns normalized "HH:MM" string or None if invalid. """ raw = raw.strip() m = _TIME_RE.match(raw) if m: hour = int(m.group(1)) minute = int(m.group(2)) meridiem = (m.group(3) or "").lower() else: m = _TIME_HOUR_RE.match(raw) if not m: return None hour = int(m.group(1)) minute = 0 meridiem = m.group(2).lower() if meridiem == "am": if hour == 12: hour = 0 elif hour > 12: return None elif meridiem == "pm": if hour == 12: pass # 12pm = 12 elif hour > 12: return None else: hour += 12 else: # 24-hour format if hour > 23: return None if minute > 59: return None return f"{hour:02d}:{minute:02d}" def format_time_12h(hhmm: str) -> str: """Convert "HH:MM" to human-readable 12-hour format. "08:00" -> "8 AM", "08:30" -> "8:30 AM", "20:00" -> "8 PM", "00:00" -> "12 AM" Omits minutes when they are :00 for shorter display. """ try: h, m = map(int, hhmm.split(":")) except (ValueError, AttributeError): return hhmm suffix = "AM" if h < 12 else "PM" display_h = h % 12 or 12 if m == 0: return f"{display_h} {suffix}" return f"{display_h}:{m:02d} {suffix}" def is_within_schedule( start_str: str, end_str: str, tz_name: str = "", locale: str = "en", time_format: str | None = None, ) -> tuple[bool, str]: """Check if current time falls within the scheduled access window. Returns (allowed, unlock_time_display). - Both empty → (True, "") - Only start set → allowed from start to midnight (blocked before start) - Only end set → allowed from midnight to end (blocked after end) - Both set → allowed within [start, end), handles overnight wrap """ if not start_str and not end_str: return (True, "") locale = normalize_locale(locale) # Get current local time if tz_name: try: from zoneinfo import ZoneInfo now = datetime.now(ZoneInfo(tz_name)) except Exception: now = datetime.now(timezone.utc) else: now = datetime.now(timezone.utc) now_minutes = now.hour * 60 + now.minute # Only start set: blocked before start, allowed from start onward if start_str and not end_str: try: sh, sm = map(int, start_str.split(":")) except (ValueError, AttributeError): return (True, "") allowed = now_minutes >= sh * 60 + sm unlock_time = ( t(locale, "at {time}", time=locale_format_time(start_str, locale, time_format=time_format)) if not allowed else "" ) return (allowed, unlock_time) # Only end set: allowed until end, blocked after if end_str and not start_str: try: eh, em = map(int, end_str.split(":")) except (ValueError, AttributeError): return (True, "") allowed = now_minutes < eh * 60 + em unlock_time = t(locale, "tomorrow") if not allowed else "" return (allowed, unlock_time) # Both set try: start_h, start_m = map(int, start_str.split(":")) end_h, end_m = map(int, end_str.split(":")) except (ValueError, AttributeError): return (True, "") start_minutes = start_h * 60 + start_m end_minutes = end_h * 60 + end_m if start_minutes <= end_minutes: # Normal range (e.g. 08:00 - 20:00) allowed = start_minutes <= now_minutes < end_minutes else: # Overnight range (e.g. 22:00 - 06:00) allowed = now_minutes >= start_minutes or now_minutes < end_minutes unlock_time = ( t(locale, "at {time}", time=locale_format_time(start_str, locale, time_format=time_format)) if not allowed else "" ) return (allowed, unlock_time) def get_bonus_minutes(store, today_str: str) -> int: """Get today's bonus minutes from store. Returns 0 if none or date mismatch.""" bonus_date = store.get_setting("daily_bonus_date", "") if bonus_date == today_str: return int(store.get_setting("daily_bonus_minutes", "0") or "0") return 0 def resolve_setting(base_key: str, store, tz_name: str = "", default: str = "") -> str: """Resolve a setting with per-day override support. Checks {day}_{base_key} first; falls back to {base_key}. """ day = get_weekday(tz_name) day_val = store.get_setting(f"{day}_{base_key}", "") if day_val: return day_val return store.get_setting(base_key, default)