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", "curl -sf http://127.0.0.1:8080/ || 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
      - ix-databases_shared-databases
      - pangolin
    ports:
      - "2021:2021"
    environment:
      TZ: ${TZ}
      DT_ENV: selfhosted
      DT_JWT_SECRET: ${DT_JWT_SECRET}
      DT_DB_DRIVER: ${DT_DB_DRIVER}
    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 -qO- http://127.0.0.1:2021/ >/dev/null 2>&1 || 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'<span class="chip" style="background:{html.escape(c.get("color","#607d8b"))};">{html.escape(c.get("name",""))}</span>' for c in chips]
                    chips_html = f'<div class="chips">{"".join(chip_bits)}</div>'
                subtitle_html = f'<div class="subtitle">{subtitle}</div>' if subtitle else ""
                cards.append(f'<div class="task {state}"><div class="due" style="{due_style}">{due}</div><div class="name">{title}</div>{chips_html}{subtitle_html}</div>')
            tasks_html = "\n".join(cards) if cards else '<div class="empty">No tasks right now</div>'
            return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="{REFRESH_SECONDS}"><title>DoneTick Tasks</title><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Shadows+Into+Light&display=swap" rel="stylesheet"><style>:root{{--card:rgba(18,22,30,0.48);--line:rgba(255,255,255,0.10);--text:#f4f7fb;--muted:rgba(244,247,251,0.72);--overdue:#ff6b6b;--today:#ffd166;--tomorrow:#8ecae6;--upcoming:#f4f7fb;--nodue:#adb5bd}}*{{box-sizing:border-box}}html,body{{margin:0;padding:0;width:100%;height:100%;background:transparent;color:var(--text);font-family:Inter,Segoe UI,Roboto,Arial,sans-serif}}body{{padding:10px 12px}}.wrap{{width:100%;height:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px;align-content:start}}.header{{grid-column:1/-1;font-family:"Shadows Into Light",cursive;font-size:2.35rem;line-height:1;margin-bottom:2px;color:var(--text);text-shadow:0 1px 10px rgba(0,0,0,0.35)}}.task{{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:12px 14px 10px;box-shadow:0 4px 18px rgba(0,0,0,0.14);backdrop-filter:blur(4px);min-width:0}}.due{{font-family:"Shadows Into Light",cursive;font-size:1.45rem;font-weight:700;line-height:1.02;margin-bottom:2px;letter-spacing:0.01em}}.name{{font-family:"Shadows Into Light",cursive;font-size:2rem;font-weight:600;line-height:1.0;margin-bottom:8px;color:var(--text);word-break:break-word}}.chips{{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:5px}}.chip{{display:inline-block;padding:4px 10px;border-radius:999px;color:#ffffff;font-size:0.80rem;font-weight:700;letter-spacing:0.02em;line-height:1.05;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.08)}}.subtitle{{font-size:0.78rem;line-height:1.15;color:var(--muted);letter-spacing:0.02em;word-break:break-word}}.task.overdue .due{{color:var(--overdue)}}.task.today .due{{color:var(--today)}}.task.tomorrow .due{{color:var(--tomorrow)}}.task.upcoming .due{{color:var(--upcoming)}}.task.nodue .due{{color:var(--nodue)}}.empty{{grid-column:1/-1;font-size:1.2rem;color:var(--muted);padding:20px 4px}}</style></head><body><div class="wrap"><div class="header">{html.escape(header_title)}</div>{tasks_html}</div></body></html>"""

        class Handler(BaseHTTPRequestHandler):
            def _json(self, status, payload):
                body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
                self.send_response(status)
                self.send_header("Content-Type", "application/json; charset=utf-8")
                self.send_header("Content-Length", str(len(body)))
                self.end_headers()
                self.wfile.write(body)

            def _html(self, status, payload):
                body = payload.encode("utf-8")
                self.send_response(status)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.send_header("Content-Length", str(len(body)))
                self.end_headers()
                self.wfile.write(body)

            def _authorized(self):
                if not BRIDGE_TOKEN:
                    return True
                auth = self.headers.get("Authorization", "")
                if auth == f"Bearer {BRIDGE_TOKEN}":
                    return True
                if self.headers.get("X-Bridge-Token", "") == BRIDGE_TOKEN:
                    return True
                qs = parse_qs(urlparse(self.path).query)
                if qs.get("token", [None])[0] == BRIDGE_TOKEN:
                    return True
                return False

            def do_GET(self):
                parsed = urlparse(self.path)
                path = parsed.path
                qs = parse_qs(parsed.query)
                if path == "/healthz":
                    return self._json(200, {"ok": True})
                if path not in ("/", "/dakboard/tasks.json", "/embed"):
                    return self._json(404, {"error": "not_found"})
                if not self._authorized():
                    return self._json(401, {"error": "unauthorized"})
                include_label = (qs.get("label", [""])[0] or "").strip()
                exclude_label = (qs.get("exclude_label", [""])[0] or "").strip()
                if include_label:
                    header = include_label
                elif exclude_label:
                    header = f"Not {exclude_label}"
                else:
                    header = "Tasks"
                try:
                    items = fetch_tasks(include_label=include_label, exclude_label=exclude_label)
                    if path == "/embed":
                        return self._html(200, render_embed(items, header))
                    return self._json(200, items)
                except urllib.error.HTTPError as e:
                    return self._json(502, {"error": "upstream_http_error", "status": e.code})
                except Exception as e:
                    return self._json(502, {"error": "bridge_error", "detail": str(e)})

            def log_message(self, format, *args):
                print("%s - - [%s] %s" % (self.address_string(), self.log_date_time_string(), format % args))

        HTTPServer((HOST, PORT), Handler).serve_forever()

  qui:
    container_name: qui
    image: ghcr.io/autobrr/qui:latest
    restart: unless-stopped
    networks:
      - home-net
      - pangolin
    ports:
      - "7476:7476"
    environment:
      TZ: ${TZ}
      HOME: /config
      XDG_CONFIG_HOME: /config
      XDG_DATA_HOME: /config
    volumes:
      - /mnt/tank/docker/appdata/qui/qui/config:/config
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:7476/ >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

  shlink:
    container_name: shlink
    image: shlinkio/shlink:stable
    restart: unless-stopped
    networks:
      - home-net
      - ix-databases_shared-databases
      - pangolin
    ports:
      - "8087:8080"
    environment:
      TZ: ${TZ}
      DEFAULT_DOMAIN: ${SHLINK_DOMAIN}
      IS_HTTPS_ENABLED: "true"
      DB_DRIVER: postgres
      DB_HOST: shared-postgres
      DB_PORT: 5432
      DB_NAME: ${SHLINK_DB_NAME}
      DB_USER: ${SHLINK_DB_USER}
      DB_PASSWORD: ${SHLINK_DB_PASS}
      DB_USE_ENCRYPTION: "false"
      REDIRECT_STATUS_CODE: "302"
      SHELL_VERBOSITY: "3"
      GEOLITE_LICENSE_KEY: ""
    volumes:
      - /mnt/docker-ssd/docker/appdata/shlink:/etc/shlink/data
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/rest/health >/dev/null 2>&1 || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

  shlink-web-client:
    container_name: shlink-web-client
    image: ghcr.io/shlinkio/shlink-web-client:stable
    restart: unless-stopped
    networks:
      - home-net
      - pangolin
    ports:
      - "8088:8080"
    environment:
      TZ: ${TZ}
    healthcheck:
      test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/ || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s
