mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 10:23:39 +02:00
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:
parent
2ab3de4d45
commit
67a8cf5b17
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
121
screen_locker/_heat_skip.py
Normal file
121
screen_locker/_heat_skip.py
Normal 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]
|
||||
@ -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)
|
||||
|
||||
48
screen_locker/_temperature.py
Normal file
48
screen_locker/_temperature.py
Normal 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
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user