diff --git a/README.md b/README.md index 56c16f6..697a01c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ services: - "8004:8000" environment: # FILL THESE IN - BEAVERHABITS_URL: "" + TIMEZONE: EST + BEAVERHABITS_URL: "" # no path, just the base URL BEAVERHABITS_USERNAME: "" # yes i know this is bad BEAVERHABITS_PASSWORD: "" # however, i don't care restart: unless-stopped diff --git a/app.py b/app.py index a9d9c30..566745d 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,8 @@ import os import time import logging -import datetime +from datetime import datetime, timedelta, date +from zoneinfo import ZoneInfo import threading from flask import Flask, request, Response, abort, jsonify @@ -12,12 +13,14 @@ import requests # ----------------------------------------------------------------------------- # Configuration via ENV # ----------------------------------------------------------------------------- -BEAVER_URL = os.getenv("BEAVERHABITS_URL", "") +BEAVER_URL = os.getenv("BEAVERHABITS_URL", "http://beaverhabits.lan") USERNAME = os.getenv("BEAVERHABITS_USERNAME", "") PASSWORD = os.getenv("BEAVERHABITS_PASSWORD", "") # How long to reuse a token (secs) TOKEN_TTL = int(os.getenv("BEAVERHABITS_TOKEN_TTL", "3600")) +TZ = ZoneInfo(os.getenv("TIMEZONE", "UTC")) + # ----------------------------------------------------------------------------- # Logging # ----------------------------------------------------------------------------- @@ -35,6 +38,10 @@ NS = {"D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav"} for p, uri in NS.items(): ET.register_namespace(p, uri) +def to_bytes(elem: ET.Element) -> bytes: + """Serialize an ElementTree Element to UTF-8 XML with declaration.""" + return ET.tostring(elem, encoding="utf-8", xml_declaration=True) + # ----------------------------------------------------------------------------- # Beaver Habits token cache with TTL # ----------------------------------------------------------------------------- @@ -73,51 +80,85 @@ def beaver_headers() -> dict[str,str]: return {"Accept": "application/json", "Authorization": f"Bearer {get_token()}"} +# ----------------------------------------------------------------------------- +# Utility: Datetime functions +# ----------------------------------------------------------------------------- + +def local_now() -> datetime: + """ + Return “now” as an aware datetime in our configured TZ. + """ + return datetime.now(TZ) + + +def next_midnight_local(now: datetime) -> datetime: + """ + Given an aware datetime ‘now’, return tomorrow at 00:00 in the same TZ. + """ + tomorrow = now.date() + timedelta(days=1) + # Year, month, day at midnight + return datetime(tomorrow.year, tomorrow.month, tomorrow.day, tzinfo=TZ) + # ----------------------------------------------------------------------------- # Utility: build iCalendar VTODO # ----------------------------------------------------------------------------- + def make_vtodo(uid: str, summary: str, completed: bool) -> bytes: + """ + Construct a VCALENDAR/VTODO where: + - DTSTAMP = local_now() + - DUE = next_midnight_local(local_now()) + """ + now = local_now() + due = next_midnight_local(now) + cal = Calendar() cal.add("prodid", "-//CalDAV⇆BeaverHabits Proxy//EN") cal.add("version", "2.0") + todo = Todo() todo.add("uid", uid) todo.add("summary", summary) - now = datetime.datetime.now(datetime.timezone.utc) todo.add("dtstamp", now) - todo.add("due", now + datetime.timedelta(days=1)) + # todo.add("due", due) # causes problems todo.add("status", "COMPLETED" if completed else "NEEDS-ACTION") + cal.add_component(todo) return cal.to_ical() -def to_bytes(elem: ET.Element) -> bytes: - return ET.tostring(elem, encoding="utf-8", xml_declaration=True) - # ----------------------------------------------------------------------------- # Helper: fetch habits + today's completion from Beaver # ----------------------------------------------------------------------------- + def fetch_tasks() -> dict[str, dict]: - """Return a dict of {habit_id: {SUMMARY, STATUS, DTSTAMP, DUE}}.""" + """ + Fetch habit list + today's completion from Beaver Habits. + Returns a dict: + { habit_id: { "SUMMARY": , "STATUS": } } + DATES are handled later by make_vtodo(). + """ try: - r = _session.get(f"{BEAVER_URL}/api/v1/habits", headers=beaver_headers(), timeout=5) + r = _session.get(f"{BEAVER_URL}/api/v1/habits", + headers=beaver_headers(), timeout=5) r.raise_for_status() habits = r.json() except Exception as e: log.error("Failed to list habits: %s", e) return {} - today = datetime.date.today().strftime("%d-%m-%Y") + # Use the same date format Beaver expects + today_str = local_now().strftime("%d-%m-%Y") out: dict[str, dict] = {} + for h in habits: - hid = h["id"] + hid = h["id"] name = h["name"] - # check completion for today try: params = { "date_fmt": "%d-%m-%Y", - "date_start": today, - "date_end": today, - "sort": "asc" + "date_start": today_str, + "date_end": today_str, + "sort": "asc", } rc = _session.get( f"{BEAVER_URL}/api/v1/habits/{hid}/completions", @@ -127,19 +168,17 @@ def fetch_tasks() -> dict[str, dict]: ) rc.raise_for_status() done_list = rc.json() - completed = today in done_list + completed = today_str in done_list except Exception: - log.warning("Failed to get completions for %s", hid) + log.warning("Failed to fetch completions for habit %s", hid) completed = False out[hid] = { "SUMMARY": name, "STATUS": "COMPLETED" if completed else "NEEDS-ACTION", - "DTSTAMP": datetime.datetime.now(datetime.timezone.utc), - "DUE": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1), } - return out + return out # ----------------------------------------------------------------------------- # Standard PROPFIND / discovery endpoints # ----------------------------------------------------------------------------- @@ -256,7 +295,7 @@ def single_task(uid): summary = tasks_map[uid]["SUMMARY"] completed = (tasks_map[uid]["STATUS"] == "COMPLETED") - today = datetime.date.today() + today = date.today() if request.method == "GET": return Response(make_vtodo(uid, summary, completed),