fix timezone shenanigans

This commit is contained in:
ultrablob 2025-05-03 21:19:55 -04:00
parent 75dbaebccd
commit e9f63e8658
2 changed files with 62 additions and 22 deletions

View file

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

81
app.py
View file

@ -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": <name>, "STATUS": <flag> } }
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),