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
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-29 11:23:19 +02:00
parent 2ab3de4d45
commit 67a8cf5b17
9 changed files with 228 additions and 7 deletions

View File

@ -1,10 +1,13 @@
[Unit] [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 After=graphical-session.target
[Timer] [Timer]
# Fires every day at 08:30 to verify workout if user logged in during 58:30 window # Fires at 08:30 for the normal early-bird window (5:008:30).
# Also fires at 09:05 to catch the extended window (5:009:00) earned by 5+ workout weeks.
# Both runs are idempotent: the service handles "already logged" and "window still active".
OnCalendar=*-*-* 08:30:00 OnCalendar=*-*-* 08:30:00
OnCalendar=*-*-* 09:05:00
Unit=workout-locker.service Unit=workout-locker.service
Persistent=false Persistent=false
AccuracySec=1s AccuracySec=1s

View File

@ -53,6 +53,8 @@ MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP
EARLY_BIRD_START_HOUR = 5 EARLY_BIRD_START_HOUR = 5
EARLY_BIRD_END_HOUR = 8 EARLY_BIRD_END_HOUR = 8
EARLY_BIRD_END_MINUTE = 30 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") SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
# Helper script path (relative to this file) # Helper script path (relative to this file)
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh" ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"

121
screen_locker/_heat_skip.py Normal file
View File

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

View File

@ -101,9 +101,27 @@ def run_status(locker: ScreenLocker) -> None:
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE) eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
eb_str = "Yes — until 09:00" if eb_ext else "No" 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" Skip credits banked : {credits}")
print(f" Streak (5+ wks) : {streak}") print(f" Streak (5+ wks) : {streak}")
print(f" Early-bird extended : {eb_str}") print(f" Early-bird extended : {eb_str}")
print(f" Heat skips (month) : {heat_str}")
print() print()
remaining = max(0, WEEKLY_WORKOUT_MINIMUM - after_count) remaining = max(0, WEEKLY_WORKOUT_MINIMUM - after_count)

View File

@ -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

View File

@ -1,6 +1,8 @@
{ {
"consecutive_5plus_weeks": 0, "consecutive_5plus_weeks": 1,
"last_processed_iso_week": "2026-W26", "last_processed_iso_week": "2026-W27",
"skip_credits": 0, "skip_credits": 1,
"extended_early_bird_iso_weeks": [] "extended_early_bird_iso_weeks": [
"2026-W27"
]
} }

View File

@ -21,6 +21,8 @@ from screen_locker._constants import (
EARLY_BIRD_END_MINUTE, EARLY_BIRD_END_MINUTE,
EARLY_BIRD_START_HOUR, EARLY_BIRD_START_HOUR,
EXTRA_BENEFITS_FILE, EXTRA_BENEFITS_FILE,
HEAT_SKIP_CITY,
HEAT_SKIP_TEMP_THRESHOLD,
HMAC_KEY_FILE, HMAC_KEY_FILE,
MAX_CLOCK_SKEW_SECONDS, MAX_CLOCK_SKEW_SECONDS,
MIN_WORKOUT_DURATION_MINUTES, MIN_WORKOUT_DURATION_MINUTES,
@ -38,12 +40,14 @@ from screen_locker._extra_benefits import (
has_skip_credit, has_skip_credit,
process_week_transition, process_week_transition,
) )
from screen_locker._heat_skip import HeatSkipMixin
from screen_locker._log_mixin import LogMixin from screen_locker._log_mixin import LogMixin
from screen_locker._phone_verification import PhoneVerificationMixin from screen_locker._phone_verification import PhoneVerificationMixin
from screen_locker._runnerup_verification import RunnerUpVerificationMixin from screen_locker._runnerup_verification import RunnerUpVerificationMixin
from screen_locker._shutdown import ShutdownMixin from screen_locker._shutdown import ShutdownMixin
from screen_locker._shutdown_base import reset_to_base_if_new_day from screen_locker._shutdown_base import reset_to_base_if_new_day
from screen_locker._sick_dialog import SickDialogMixin 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 import UIFlowsMixin
from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin
from screen_locker._ui_widgets import UIWidgetsMixin from screen_locker._ui_widgets import UIWidgetsMixin
@ -96,6 +100,7 @@ def _assert_not_under_pytest() -> None:
class ScreenLocker( class ScreenLocker(
AutoUpgradeMixin, AutoUpgradeMixin,
EarlyBirdMixin, EarlyBirdMixin,
HeatSkipMixin,
LogMixin, LogMixin,
WindowSetupMixin, WindowSetupMixin,
ShutdownMixin, ShutdownMixin,
@ -204,6 +209,19 @@ class ScreenLocker(
) )
sys.exit(0) sys.exit(0)
return 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. # Spend a banked skip credit if the minimum hasn't been reached yet.
if has_skip_credit(EXTRA_BENEFITS_FILE): if has_skip_credit(EXTRA_BENEFITS_FILE):
consume_skip_credit(EXTRA_BENEFITS_FILE) consume_skip_credit(EXTRA_BENEFITS_FILE)

View File

@ -1,5 +1,5 @@
{ {
"base_mon_wed_hour": 21, "base_mon_wed_hour": 21,
"base_thu_sun_hour": 21, "base_thu_sun_hour": 21,
"last_reset_date": "2026-06-28" "last_reset_date": "2026-06-29"
} }

View File

@ -141,5 +141,14 @@
"type": "early_bird" "type": "early_bird"
}, },
"hmac": "f6400e7af861ca8a157e623eafd490f87df723f536f6eb9f4e1acd353d7106c2" "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"
} }
} }