networks: home-net: driver: bridge ix-databases_shared-databases: external: true pangolin: external: true services: kitchenowl: container_name: kitchenowl image: tombursch/kitchenowl:latest restart: unless-stopped networks: - home-net - pangolin ports: - "8086:8080" environment: TZ: ${TZ} FRONT_URL: ${KITCHENOWL_URL} JWT_SECRET_KEY: ${KITCHENOWL_JWT_SECRET} STORAGE_PATH: /data DEBUG: "False" volumes: - /mnt/tank/docker/appdata/kitchenowl:/data healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"] interval: 30s timeout: 10s retries: 3 start_period: 30s donetick: container_name: donetick image: donetick/donetick:latest restart: unless-stopped networks: - home-net - pangolin ports: - "2021:2021" environment: TZ: ${TZ} DT_ENV: selfhosted volumes: - /mnt/tank/docker/appdata/donetick/data:/donetick-data - /mnt/tank/docker/appdata/donetick/config:/config - /mnt/tank/docker/appdata/donetick/assets:/app/assets healthcheck: test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:2021/ || exit 1"] interval: 1m timeout: 5s retries: 3 start_period: 1m donetick-dakboard-bridge: container_name: donetick-dakboard-bridge image: python:3.12-alpine restart: unless-stopped networks: - home-net - pangolin ports: - "5087:8080" environment: TZ: ${TZ} BRIDGE_TOKEN: ${DONETICK_BRIDGE_TOKEN} DONETICK_API_KEY: ${DONETICK_API_KEY} DONETICK_BASE_URL: ${DONETICK_BASE_URL} TASKS_PATH: /eapi/v1/chore MAX_ITEMS: "20" SHOW_COMPLETED: "false" HIDE_INACTIVE: "true" COMPLETED_STATUS_VALUE: "10" REFRESH_SECONDS: "300" healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"] interval: 30s timeout: 10s retries: 3 start_period: 15s command: - python - "-c" - | import json, os, html, urllib.request, urllib.error from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse, parse_qs from datetime import datetime HOST = "0.0.0.0" PORT = 8080 DONETICK_BASE_URL = os.environ["DONETICK_BASE_URL"].rstrip("/") DONETICK_API_KEY = os.environ["DONETICK_API_KEY"] BRIDGE_TOKEN = os.environ["BRIDGE_TOKEN"].strip() TASKS_PATH = os.environ.get("TASKS_PATH", "/eapi/v1/chore") MAX_ITEMS = int(os.environ.get("MAX_ITEMS", "20")) SHOW_COMPLETED = os.environ.get("SHOW_COMPLETED", "false").lower() in ("1", "true", "yes", "on") HIDE_INACTIVE = os.environ.get("HIDE_INACTIVE", "true").lower() in ("1", "true", "yes", "on") COMPLETED_STATUS_VALUE = os.environ.get("COMPLETED_STATUS_VALUE", "10").strip() REFRESH_SECONDS = int(os.environ.get("REFRESH_SECONDS", "300")) TASKS_URL = f"{DONETICK_BASE_URL}{TASKS_PATH}" LOW_VALUE_LABELS = {"shared", "duesoon", "submitted", "waitingsubmission", "notes"} PRIORITY_COLORS = {1: "#ff6b6b", 2: "#ff9f1c", 3: "#ffd166", 4: "#8ecae6"} def parse_dt(value): if not value: return None try: return datetime.fromisoformat(value.replace("Z", "+00:00")) except Exception: return None def has_label(task, label_name): if not label_name: return False wanted = label_name.strip().lower() labels = task.get("labelsV2") or [] return any(str(lbl.get("name", "")).strip().lower() == wanted for lbl in labels) def priority_text(priority): if priority is None: return None try: p = int(priority) except Exception: return None return f"P{p}" if p in (1, 2, 3, 4) else None def priority_num(priority): try: p = int(priority) if p in (1, 2, 3, 4): return p except Exception: pass return None def recurrence_text(task): ftype = str(task.get("frequencyType", "") or "").strip().lower() freq = task.get("frequency") try: freq_num = int(freq) if freq is not None else None except Exception: freq_num = None if not ftype: return None if ftype == "daily": return "Daily" if freq_num in (None, 1) else f"Every {freq_num} days" if ftype == "weekly": return "Weekly" if freq_num in (None, 1) else f"Every {freq_num} weeks" if ftype == "monthly": return "Monthly" if freq_num in (None, 1) else f"Every {freq_num} months" if ftype == "yearly": return "Yearly" if freq_num in (None, 1) else f"Every {freq_num} years" if ftype == "once": return "One-time" return ftype.capitalize() def due_title_and_state(due_raw): dt = parse_dt(due_raw) if not dt: return ("No due date", "nodue", None) local = dt.astimezone() now = datetime.now().astimezone() days = (local.date() - now.date()).days time_text = local.strftime("%-I:%M %p") if days < 0: overdue_days = abs(days) if overdue_days == 1: return (f"Overdue \u2022 Yesterday {time_text}", "overdue", local) return (f"Overdue \u2022 {local.strftime('%a, %b %-d')} {time_text}", "overdue", local) if days == 0: return (f"Due today \u2022 {time_text}", "today", local) if days == 1: return (f"Due tomorrow \u2022 {time_text}", "tomorrow", local) if days <= 6: return (f"Due {local.strftime('%a')} \u2022 {time_text}", "upcoming", local) return (f"Due {local.strftime('%b %-d')} \u2022 {time_text}", "upcoming", local) def dedupe_parts(parts): seen = set() out = [] for p in parts: if not p: continue key = p.strip().lower() if key in seen: continue seen.add(key) out.append(p) return out def subtitle_text(task): parts = [] labels = task.get("labelsV2") or [] label_names = [str(lbl.get("name", "")).strip() for lbl in labels if lbl.get("name")] prominent = [x for x in label_names if x.strip().lower() not in LOW_VALUE_LABELS] low = [x for x in label_names if x.strip().lower() in LOW_VALUE_LABELS] parts.extend(prominent[:3]) ptxt = priority_text(task.get("priority")) if ptxt: parts.append(ptxt) rtxt = recurrence_text(task) if rtxt: label_keys = {x.strip().lower() for x in label_names} if rtxt.strip().lower() not in label_keys: parts.append(rtxt) if not prominent and low: parts.extend(low[:1]) parts = dedupe_parts(parts) return " \u2022 ".join(parts) def visible_label_chips(task): chips = [] labels = task.get("labelsV2") or [] for lbl in labels: name = str(lbl.get("name", "")).strip() if not name or name.lower() in LOW_VALUE_LABELS: continue color = str(lbl.get("color", "")).strip() or "#607d8b" chips.append({"name": name, "color": color}) return chips[:4] def fetch_tasks(include_label=None, exclude_label=None): req = urllib.request.Request(TASKS_URL, headers={"secretkey": DONETICK_API_KEY, "Accept": "application/json", "User-Agent": "donetick-dakboard-bridge/embed-v5"}, method="GET") with urllib.request.urlopen(req, timeout=20) as resp: data = json.loads(resp.read().decode("utf-8")) if not isinstance(data, list): raise ValueError("DoneTick API did not return a JSON array") items = [] now = datetime.now().astimezone() for task in data: if HIDE_INACTIVE and not task.get("isActive", True): continue status = str(task.get("status", "")) if not SHOW_COMPLETED and status == COMPLETED_STATUS_VALUE: continue if include_label and not has_label(task, include_label): continue if exclude_label and has_label(task, exclude_label): continue name = str(task.get("name", "")).strip() or "Untitled task" due_raw = task.get("nextDueDate") due_title, state, due_local = due_title_and_state(due_raw) subtitle = subtitle_text(task) priority = priority_num(task.get("priority")) label_chips = visible_label_chips(task) if due_local: overdue_rank = 0 if due_local < now else 1 due_rank = due_local.timestamp() missing_rank = 0 else: overdue_rank = 2 due_rank = 99999999999 missing_rank = 1 items.append({"value": due_title, "title": name, "subtitle": subtitle, "state": state, "priority": priority, "priority_color": PRIORITY_COLORS.get(priority), "chips": label_chips, "_sort_overdue": overdue_rank, "_sort_due": due_rank, "_sort_missing": missing_rank, "_sort_name": name.lower()}) items.sort(key=lambda x: (x["_sort_overdue"], x["_sort_missing"], x["_sort_due"], x["_sort_name"])) items = items[:MAX_ITEMS] for item in items: for k in ("_sort_overdue", "_sort_due", "_sort_missing", "_sort_name"): item.pop(k, None) return items def render_embed(items, header_title): cards = [] for item in items: state = html.escape(item.get("state", "upcoming")) due = html.escape(item.get("value", "")) title = html.escape(item.get("title", "")) subtitle = html.escape(item.get("subtitle", "")) priority_color = item.get("priority_color") due_style = "" if state == "overdue": due_style = "color:#ff6b6b;" elif priority_color: due_style = f"color:{html.escape(priority_color)};" chips_html = "" chips = item.get("chips") or [] if chips: chip_bits = [f'{html.escape(c.get("name",""))}' for c in chips] chips_html = f'