screen-locker/screen_locker/_heat_skip.py
Krzysztof kuhy Rudnicki 67a8cf5b17 Add heat-skip feature and fix early-bird 9:00 re-check gap
Heat skip: if Warsaw temperature >= 32°C at locker startup, a fullscreen
dark-themed dialog asks the user to confirm skipping. Temperature is always
fetched from wttr.in automatically (user cannot self-report). Fail-closed:
API unavailable → no dialog, normal lock. Placed before skip-credit
consumption so credits are preserved when heat skip is used instead.
Logs a heat_skip entry (with temperature) to workout_log.json; does not
count toward weekly minimum. Visible in --status as "Heat skips (month)".

Early-bird gap fix: the re-check timer now fires at both 08:30 (normal
5:00–8:30 window) and 09:05 (extended 5:00–9:00 window earned by 5+
workout weeks). Previously the 08:30 run would see the window still active
on extended weeks and never re-check after 9:00.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QCx1roriuXzFgrzFXtugb
2026-06-29 11:23:19 +02:00

122 lines
3.3 KiB
Python

"""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]