mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 15:43:02 +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]
|
[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 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=*-*-* 08:30:00
|
||||||
|
OnCalendar=*-*-* 09:05:00
|
||||||
Unit=workout-locker.service
|
Unit=workout-locker.service
|
||||||
Persistent=false
|
Persistent=false
|
||||||
AccuracySec=1s
|
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_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
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_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)
|
||||||
|
|||||||
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,
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user