diff --git a/early-bird-workout-check.timer b/early-bird-workout-check.timer index 026cf95..47fa3f7 100644 --- a/early-bird-workout-check.timer +++ b/early-bird-workout-check.timer @@ -1,10 +1,13 @@ [Unit] -Description=Re-check workout after early bird grace period expires at 08:30 +Description=Re-check workout after early bird grace period expires (08:30 normal, 09:05 extended) After=graphical-session.target [Timer] -# Fires every day at 08:30 to verify workout if user logged in during 5–8:30 window +# Fires at 08:30 for the normal early-bird window (5:00–8:30). +# Also fires at 09:05 to catch the extended window (5:00–9:00) earned by 5+ workout weeks. +# Both runs are idempotent: the service handles "already logged" and "window still active". OnCalendar=*-*-* 08:30:00 +OnCalendar=*-*-* 09:05:00 Unit=workout-locker.service Persistent=false AccuracySec=1s diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index 98ff95e..5ff0c33 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -53,6 +53,8 @@ MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP EARLY_BIRD_START_HOUR = 5 EARLY_BIRD_END_HOUR = 8 EARLY_BIRD_END_MINUTE = 30 +HEAT_SKIP_TEMP_THRESHOLD: int = 32 # °C — above this the heat-skip dialog is offered +HEAT_SKIP_CITY: str = "Warsaw" SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf") # Helper script path (relative to this file) ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh" diff --git a/screen_locker/_heat_skip.py b/screen_locker/_heat_skip.py new file mode 100644 index 0000000..1a089ba --- /dev/null +++ b/screen_locker/_heat_skip.py @@ -0,0 +1,121 @@ +"""Mixin: heat-skip dialog and log entry for the screen locker.""" + +from __future__ import annotations + +import logging +import tkinter as tk + +from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD + +_logger = logging.getLogger(__name__) + +_BG = "#1a1a1a" +_FG_MAIN = "#ff9900" +_FG_SUB = "#cccccc" +_BTN_SKIP = "#cc4400" +_BTN_NO = "#333333" +_FONT = "monospace" + + +class HeatSkipMixin: + """Provides _show_heat_skip_dialog and _save_heat_skip_log.""" + + def _show_heat_skip_dialog(self, temp: float) -> bool: + """Show a modal confirmation dialog for skipping due to extreme heat. + + Creates a temporary Tk root (destroyed before the main GateRoot is + initialised) so this can be called early in the startup flow. Returns + True if the user confirms the skip, False if they decline. + """ + result: list[bool] = [False] + + # Use the root itself (not a Toplevel) so we can go fullscreen. + # This window is destroyed before the main GateRoot is created. + root = tk.Tk() + root.title("Extreme Heat") + root.configure(bg=_BG) + root.attributes("-fullscreen", True) + root.attributes("-topmost", True) + root.grab_set() + root.focus_force() + + # Content centred on the fullscreen canvas + outer = tk.Frame(root, bg=_BG) + outer.place(relx=0.5, rely=0.5, anchor="center") + + tk.Label( + outer, + text="☀ Too hot to workout?", + font=(_FONT, 18, "bold"), + bg=_BG, + fg=_FG_MAIN, + ).pack(pady=(0, 10)) + + tk.Label( + outer, + text=( + f"{HEAT_SKIP_CITY}: {temp:.0f}°C" + f" (threshold: {HEAT_SKIP_TEMP_THRESHOLD}°C)" + ), + font=(_FONT, 13), + bg=_BG, + fg=_FG_SUB, + ).pack(pady=6) + + tk.Label( + outer, + text="Skip today's workout due to extreme heat?", + font=(_FONT, 12), + bg=_BG, + fg=_FG_SUB, + ).pack(pady=(6, 0)) + + btn_frame = tk.Frame(outer, bg=_BG) + btn_frame.pack(pady=28) + + def _on_skip() -> None: + result[0] = True + root.destroy() + + def _on_no() -> None: + result[0] = False + root.destroy() + + tk.Button( + btn_frame, + text="Skip workout", + command=_on_skip, + bg=_BTN_SKIP, + fg="white", + activebackground="#aa3300", + font=(_FONT, 11), + padx=14, + pady=5, + relief="flat", + ).pack(side="left", padx=12) + + tk.Button( + btn_frame, + text="No, I'll workout", + command=_on_no, + bg=_BTN_NO, + fg="white", + activebackground="#444444", + font=(_FONT, 11), + padx=14, + pady=5, + relief="flat", + ).pack(side="left", padx=12) + + root.mainloop() + + return result[0] + + def _save_heat_skip_log(self, temp: float) -> None: + """Append a heat_skip entry to workout_log.json.""" + self.workout_data = { # type: ignore[attr-defined] + "type": "heat_skip", + "temperature_celsius": str(round(temp)), + "city": HEAT_SKIP_CITY, + } + self.save_workout_log() # type: ignore[attr-defined] diff --git a/screen_locker/_status.py b/screen_locker/_status.py index 9855e50..c174874 100644 --- a/screen_locker/_status.py +++ b/screen_locker/_status.py @@ -101,9 +101,27 @@ def run_status(locker: ScreenLocker) -> None: eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE) eb_str = "Yes — until 09:00" if eb_ext else "No" + # Heat skips this month + from datetime import date + + this_month = date.today().strftime("%Y-%m") + heat_entries = [ + (d, e) + for d, e in log_data.items() + if d.startswith(this_month) + and e.get("workout_data", {}).get("type") == "heat_skip" + ] + if heat_entries: + last_date, last_e = sorted(heat_entries)[-1] + last_temp = last_e.get("workout_data", {}).get("temperature_celsius", "?") + heat_str = f"{len(heat_entries)} (last: {last_date}, {last_temp}°C)" + else: + heat_str = "0" + print(f" Skip credits banked : {credits}") print(f" Streak (5+ wks) : {streak}") print(f" Early-bird extended : {eb_str}") + print(f" Heat skips (month) : {heat_str}") print() remaining = max(0, WEEKLY_WORKOUT_MINIMUM - after_count) diff --git a/screen_locker/_temperature.py b/screen_locker/_temperature.py new file mode 100644 index 0000000..d3e00e8 --- /dev/null +++ b/screen_locker/_temperature.py @@ -0,0 +1,48 @@ +"""Temperature fetching via wttr.in for the heat-skip feature. + +Pure logic — no Tk imports. Always fetches from the API; never trusts +user claims about the temperature. +""" + +from __future__ import annotations + +import json +import logging +import urllib.error +import urllib.request + +_logger = logging.getLogger(__name__) + +_WTTR_URL = "https://wttr.in/{city}?format=j1" +_TIMEOUT_SECONDS = 5 + + +def fetch_current_temp_celsius(city: str) -> float | None: + """Return the current temperature in °C for *city*, or None on failure. + + Uses wttr.in's JSON API (no API key required). Returns None on network + errors, timeouts, or unexpected response shapes so callers can fail-closed. + """ + url = _WTTR_URL.format(city=urllib.request.quote(city, safe="")) + try: + with urllib.request.urlopen(url, timeout=_TIMEOUT_SECONDS) as resp: + data = json.loads(resp.read()) + temp_str = data["current_condition"][0]["temp_C"] + return float(temp_str) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + _logger.warning("Temperature fetch failed (network): %s", exc) + return None + except (KeyError, IndexError, ValueError, json.JSONDecodeError) as exc: + _logger.warning("Temperature fetch failed (parse): %s", exc) + return None + + +def is_too_hot(city: str, threshold: float) -> float | None: + """Return the current temperature if it exceeds *threshold*, else None. + + Fail-closed: API unavailable → returns None → no heat skip offered. + """ + temp = fetch_current_temp_celsius(city) + if temp is None: + return None + return temp if temp >= threshold else None diff --git a/screen_locker/extra_benefits_state.json b/screen_locker/extra_benefits_state.json index 51bf245..45bafea 100644 --- a/screen_locker/extra_benefits_state.json +++ b/screen_locker/extra_benefits_state.json @@ -1,6 +1,8 @@ { - "consecutive_5plus_weeks": 0, - "last_processed_iso_week": "2026-W26", - "skip_credits": 0, - "extended_early_bird_iso_weeks": [] + "consecutive_5plus_weeks": 1, + "last_processed_iso_week": "2026-W27", + "skip_credits": 1, + "extended_early_bird_iso_weeks": [ + "2026-W27" + ] } \ No newline at end of file diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index a0407aa..6420241 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -21,6 +21,8 @@ from screen_locker._constants import ( EARLY_BIRD_END_MINUTE, EARLY_BIRD_START_HOUR, EXTRA_BENEFITS_FILE, + HEAT_SKIP_CITY, + HEAT_SKIP_TEMP_THRESHOLD, HMAC_KEY_FILE, MAX_CLOCK_SKEW_SECONDS, MIN_WORKOUT_DURATION_MINUTES, @@ -38,12 +40,14 @@ from screen_locker._extra_benefits import ( has_skip_credit, process_week_transition, ) +from screen_locker._heat_skip import HeatSkipMixin from screen_locker._log_mixin import LogMixin from screen_locker._phone_verification import PhoneVerificationMixin from screen_locker._runnerup_verification import RunnerUpVerificationMixin from screen_locker._shutdown import ShutdownMixin from screen_locker._shutdown_base import reset_to_base_if_new_day from screen_locker._sick_dialog import SickDialogMixin +from screen_locker._temperature import is_too_hot from screen_locker._ui_flows import UIFlowsMixin from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin from screen_locker._ui_widgets import UIWidgetsMixin @@ -96,6 +100,7 @@ def _assert_not_under_pytest() -> None: class ScreenLocker( AutoUpgradeMixin, EarlyBirdMixin, + HeatSkipMixin, LogMixin, WindowSetupMixin, ShutdownMixin, @@ -204,6 +209,19 @@ class ScreenLocker( ) sys.exit(0) return + # Offer heat skip before consuming a banked credit — credit is preserved + # for another day if the user chooses to skip due to temperature. + hot_temp = is_too_hot(HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD) + if hot_temp is not None: + _logger.info( + "Temperature %.0f°C exceeds threshold — showing heat-skip dialog.", + hot_temp, + ) + if self._show_heat_skip_dialog(hot_temp): + self._save_heat_skip_log(hot_temp) + _logger.info("User skipped workout due to heat (%.0f°C).", hot_temp) + sys.exit(0) + return # Spend a banked skip credit if the minimum hasn't been reached yet. if has_skip_credit(EXTRA_BENEFITS_FILE): consume_skip_credit(EXTRA_BENEFITS_FILE) diff --git a/screen_locker/shutdown_base.json b/screen_locker/shutdown_base.json index 9d15e2b..4d80992 100644 --- a/screen_locker/shutdown_base.json +++ b/screen_locker/shutdown_base.json @@ -1,5 +1,5 @@ { "base_mon_wed_hour": 21, "base_thu_sun_hour": 21, - "last_reset_date": "2026-06-28" + "last_reset_date": "2026-06-29" } \ No newline at end of file diff --git a/screen_locker/workout_log.json b/screen_locker/workout_log.json index b6ed512..44674c7 100644 --- a/screen_locker/workout_log.json +++ b/screen_locker/workout_log.json @@ -141,5 +141,14 @@ "type": "early_bird" }, "hmac": "f6400e7af861ca8a157e623eafd490f87df723f536f6eb9f4e1acd353d7106c2" + }, + "2026-06-29": { + "timestamp": "2026-06-29T09:21:58.110418+00:00", + "workout_data": { + "type": "heat_skip", + "temperature_celsius": "32", + "city": "Warsaw" + }, + "hmac": "75b19b1d085121463dbebbf0ae579e6efefc77ee3f3806ef593fd2e8723baa59" } } \ No newline at end of file