# exXxa Projektmanagement – Brython Frontend (eine Datei, keine Modul-Imports)
import json
import random
import time
import traceback
from datetime import datetime

from browser import aio, document, html, console, timer, window

# --- API ----------------------------------------------------------------------

class ApiError(Exception):
    def __init__(self, status, detail=""):
        self.status = status
        self.detail = detail
        super().__init__(detail or f"HTTP {status}")


async def _request(method, url, body=None):
    headers = {"Content-Type": "application/json", "Accept": "application/json"}
    # Brython: data=None ist ungültig — bei GET kein data-Argument übergeben
    if method == "GET":
        req = await aio.ajax("GET", url, headers=headers)
    else:
        payload = json.dumps(body) if body is not None else "{}"
        req = await aio.ajax(method, url, headers=headers, data=payload)
    text = req.data or ""
    if req.status >= 400:
        detail = ""
        if text:
            try:
                data = json.loads(text)
                if isinstance(data, dict):
                    detail = data.get("detail", "")
                    if isinstance(detail, list):
                        detail = str(detail)
                else:
                    detail = text
            except Exception:
                detail = text
        raise ApiError(req.status, detail or text)
    if req.status == 204 or not text or not str(text).strip():
        return None
    try:
        return json.loads(text)
    except Exception:
        return None


def _api_run(coro, on_success, on_error):
    async def wrapper():
        try:
            result = await coro
            if on_success:
                on_success(result)
        except ApiError as e:
            if on_error:
                on_error(e)
        except Exception as e:
            console.error(traceback.format_exc())
            if on_error:
                on_error(ApiError(0, str(e)))

    aio.run(wrapper())


def api_get(url, on_success, on_error=None):
    _api_run(_request("GET", url), on_success, on_error)


def api_post(url, body, on_success, on_error=None):
    _api_run(_request("POST", url, body), on_success, on_error)


def api_put(url, body, on_success, on_error=None):
    _api_run(_request("PUT", url, body), on_success, on_error)


def api_delete(url, on_success, on_error=None):
    _api_run(_request("DELETE", url), on_success, on_error)


def api_patch(url, body, on_success, on_error=None):
    _api_run(_request("PATCH", url, body), on_success, on_error)


# --- Timer UI -----------------------------------------------------------------

def _format_seconds(seconds):
    s = int(seconds)
    h = s // 3600
    m = (s % 3600) // 60
    sec = s % 60
    if h:
        return f"{h}:{m:02d}:{sec:02d}"
    return f"{m}:{sec:02d}"


def _utc_timestamp(iso_str):
    """Server-Zeiten sind UTC; naive ISO-Strings als UTC interpretieren."""
    s = str(iso_str)
    if s.endswith("Z"):
        s = s[:-1] + "+00:00"
    elif "+" not in s[10:] and not s.endswith("+00:00"):
        s = s + "+00:00"
    return datetime.fromisoformat(s).timestamp()


def _live_seconds(row, now_ts=None):
    total = float(row.get("total_seconds", 0) or 0)
    if row.get("is_running") and row.get("running_since"):
        if now_ts is None:
            now_ts = time.time()
        try:
            total += max(0, now_ts - _utc_timestamp(row["running_since"]))
        except Exception:
            pass
    return total


def _elapsed_seconds(row, now_ts=None):
    if row.get("is_running"):
        return _live_seconds(row, now_ts)
    es = row.get("elapsed_seconds")
    if es is not None:
        return float(es)
    return _live_seconds(row, now_ts)


def display_seconds(task_time, now_ts=None):
    return _format_seconds(_elapsed_seconds(task_time, now_ts))


def display_user_time(row, now_ts=None):
    return _format_seconds(_elapsed_seconds(row, now_ts))


def _format_dt(iso_str):
    if not iso_str:
        return "—"
    try:
        ts = _utc_timestamp(iso_str)
        return datetime.fromtimestamp(ts).strftime("%d.%m.%Y %H:%M")
    except Exception:
        return str(iso_str)[:16]


def _format_date(day_str):
    if not day_str:
        return "—"
    parts = str(day_str).split("-")
    if len(parts) == 3:
        return f"{parts[2]}.{parts[1]}.{parts[0]}"
    return str(day_str)


def _day_live_seconds(row, now_ts=None):
    total = float(row.get("total_seconds", 0) or 0)
    if row.get("is_running"):
        if now_ts is None:
            now_ts = time.time()
        sync = _state.get("report_sync_at") or now_ts
        total += max(0, now_ts - sync)
    return total


def _day_meets_target(row, now_ts=None):
    target = float(row.get("target_seconds", 0) or 0)
    return _day_live_seconds(row, now_ts) >= target


def _week_live_worked(wp, now_ts=None):
    worked = float(wp.get("worked_seconds", 0) or 0)
    if wp.get("is_running"):
        if now_ts is None:
            now_ts = time.time()
        sync = _state.get("report_sync_at") or now_ts
        worked += max(0, now_ts - sync)
    return worked


def _week_on_pace(wp, now_ts=None):
    return _week_live_worked(wp, now_ts) >= float(wp.get("expected_seconds_to_date", 0) or 0)


_BOOMER_QUIPS = {
    "disaster": [
        "Wenn du so weitermachst, arbeitest du am Wochenende. Spaß. Vielleicht.",
        "Bei dem Tempo schick ich dich nicht ins Homeoffice, sondern ins Archiv.",
        "Früher war Feierabend ein Gefühl. Heute ist es eine Legende. Deine.",
        "Ich sag nur: Sonntag ist auch ein Tag. Mit Potenzial.",
    ],
    "behind": [
        "Donnerstag und Freitag sind keine Vorschläge, sondern Arbeitstage. Merk dir das.",
        "Wenn's so weitergeht, wird aus dem Wochenende eine Verlängerung der Woche.",
        "Nacharbeiten heißt nicht 'optional', sondern 'noch nicht gemacht'.",
        "Ich hab Excel geöffnet. Das ist bei mir wie eine Drohung.",
    ],
    "running_behind": [
        "Der Timer läuft, aber die Woche nicht. Interessante Strategie.",
        "Du bist noch dran — genau wie die Deadline, die ich mir ausgedacht habe.",
        "Weiter so, und Freitag wird zum Vorbereitungstag für Samstag.",
    ],
    "slightly_behind": [
        "Knapp hinterher. Wie ein Zug, der 'gleich' kommt — seit 1998.",
        "Noch ein bisschen Gas, dann musst du am Wochenende nicht 'kurz noch'.",
        "Das reicht fast. 'Fast' war schon immer mein Lieblingswort im Jahresgespräch.",
    ],
    "on_pace": [
        "Läuft. Sag ich ungern, aber: fürs Protokoll läuft's.",
        "Im Soll. Das klingt langweilig, ist aber besser als mein Gesicht am Montag.",
        "Solide. Nicht spektakulär — wie meine Witze, aber zuverlässig.",
        "Passt. Jetzt tu bitte so, als wär das normal. Ist es nämlich.",
    ],
    "ahead": [
        "Voraus? Gut. Aber Überstunden sind kein Hobby, auch wenn's sich so anfühlt.",
        "Stark. Nur: Freitag ist kein Preis, den man sich mit Montag verdient.",
        "Mehr als Soll. Ich notier das — irgendwo, wo's weh tut.",
    ],
    "hero": [
        "So viel Output? Dann brauchst du ja bald keinen Chef. *hust* Theoretisch.",
        "Wenn alle so arbeiten würden, hätte ich weniger zu meckern. Stell dir das vor.",
        "Respekt. Oder Panik. Bei mir ist das oft dasselbe.",
    ],
    "friday_ok": [
        "Woche geschafft. Freitag ist jetzt offiziell erlaubt. Bis 17:01.",
        "40 Stunden? Schön. Jetzt bitte keinen Heldenstreich mehr vor der Kaffeemaschine.",
        "Feierabend steht in der Pipeline. Bitte nicht wieder verschieben.",
    ],
    "weekend": [
        "Samstag ist für den Garten. Oder für Nacharbeit. Du entscheidest. Ich auch.",
        "Wochenende ist kein Bug, sondern ein Feature. Bitte nicht patchen.",
    ],
    "ghost": [
        "Noch keine Stunden? Früher hieß das 'Motivation'. Heute nenn ich's 'Mysterium'.",
        "Die Karte ist leer, die Woche auch. Zeit für einen Plan, nicht für Ausreden.",
        "Wenn Zeit Geld wäre, wärst du gerade sehr reich. Im Minus, aber reich an Freizeit.",
    ],
    "minimal": [
        "Wenig Stunden, große Pläne? Das kenne ich. Aus dem Jahresbudget.",
        "Kurz eingeloggt, kurz gearbeitet — lang erklärt, warum's reicht.",
    ],
}

_BOOMER_FACE_IMG = {
    "disaster": "/img/boomer/boss-disaster.png",
    "behind": "/img/boomer/boss-behind.png",
    "running_behind": "/img/boomer/boss-running-behind.png",
    "slightly_behind": "/img/boomer/boss-slightly-behind.png",
    "on_pace": "/img/boomer/boss-on-pace.png",
    "ahead": "/img/boomer/boss-ahead.png",
    "hero": "/img/boomer/boss-hero.png",
    "friday_ok": "/img/boomer/boss-friday-ok.png",
    "weekend": "/img/boomer/boss-weekend.png",
    "ghost": "/img/boomer/boss-ghost.png",
    "minimal": "/img/boomer/boss-ghost.png",
}


def _boomer_quip_category(wp, rows):
    if not wp:
        return "ghost" if not rows else "minimal"
    worked = _week_live_worked(wp)
    expected = float(wp.get("expected_seconds_to_date", 0) or 0)
    target_week = float(wp.get("target_week_seconds", 0) or 0)
    rem = int(wp.get("remaining_workdays", 0) or 0)
    today_wd = datetime.now().weekday()

    if expected > 0:
        pace_ratio = worked / expected
    else:
        pace_ratio = 1.0

    if today_wd > 4 and worked < target_week * 0.9:
        return "weekend"
    if pace_ratio < 0.55:
        return "disaster"
    if pace_ratio < 0.88:
        return "running_behind" if wp.get("is_running") else "behind"
    if pace_ratio < 1.0:
        return "slightly_behind"
    if rem == 0 and worked >= target_week * 0.95:
        return "friday_ok"
    if pace_ratio >= 1.25 or (target_week and worked >= target_week * 0.92):
        return "hero"
    if pace_ratio >= 1.05:
        return "ahead"
    return "on_pace"


def _format_boomer_line(user, line):
    name = _display_user((user or {}).get("username", ""))
    if name and random.random() < 0.35:
        return f"{name}, {line[0].lower()}{line[1:]}" if line else line
    return line


def _pick_boomer_quip_for_category(user, cat, avoid=None):
    pool = list(_BOOMER_QUIPS.get(cat, _BOOMER_QUIPS["on_pace"]))
    if avoid and len(pool) > 1:
        filtered = [p for p in pool if p not in avoid and avoid not in p]
        if filtered:
            pool = filtered
    return _format_boomer_line(user, random.choice(pool))


def _pick_boomer_quip(user, wp, rows):
    cat = _boomer_quip_category(wp, rows)
    return _pick_boomer_quip_for_category(user, cat)


def _find_report_user(uid):
    for u in _state.get("users") or []:
        if str(u["id"]) == str(uid):
            return u
    report = _state.get("report") or {}
    for w in report.get("week_progress") or []:
        if str(w["user"]["id"]) == str(uid):
            return w["user"]
    return None


def _append_boomer_boss_quip(block, user, wp, rows):
    cat = _boomer_quip_category(wp, rows)
    quote = _pick_boomer_quip_for_category(user, cat)
    face_url = _BOOMER_FACE_IMG.get(cat, _BOOMER_FACE_IMG["on_pace"])
    uid = str(user["id"])

    box = html.DIV(Class="report-boomer")
    box.setAttribute("data-boomer-mood", cat)
    box.setAttribute("data-user-id", uid)
    inner = html.DIV(Class="report-boomer-inner")
    face = html.IMG(src=face_url, alt="Boomer-Chef", Class="report-boomer-face")
    body = html.DIV(Class="report-boomer-body")
    body <= html.SPAN("Chefspruch", Class="report-boomer-label")
    text_el = html.P(quote, Class="report-boomer-text")
    body <= text_el
    inner <= face
    inner <= body
    box <= inner

    def on_shuffle(ev):
        mood = box.getAttribute("data-boomer-mood") or cat
        u = _find_report_user(uid) or user
        new_quote = _pick_boomer_quip_for_category(u, mood, avoid=text_el.text)
        text_el.text = new_quote
        face.src = _BOOMER_FACE_IMG.get(mood, face_url)
        face.classList.add("report-boomer-wiggle")
        box.classList.add("report-boomer-flash")

        def clear_anim():
            face.classList.remove("report-boomer-wiggle")
            box.classList.remove("report-boomer-flash")

        timer.set_timeout(clear_anim, 450)

    shuffle_btn = html.BUTTON("Noch ein Spruch", Class="btn btn-sm report-boomer-shuffle")
    shuffle_btn.bind("click", on_shuffle)
    box <= shuffle_btn
    block <= box


def _week_status_text(wp, now_ts=None):
    worked = _week_live_worked(wp, now_ts)
    expected = float(wp.get("expected_seconds_to_date", 0) or 0)
    target_week = float(wp.get("target_week_seconds", 0) or 0)
    elapsed_lbl = wp.get("elapsed_range_label", "")
    rem_lbl = wp.get("remaining_range_label", "")
    if _week_on_pace(wp, now_ts):
        ahead = worked - expected
        if ahead > 60:
            return f"Im Soll ({elapsed_lbl}), +{_format_seconds(ahead)} voraus"
        return f"Im Soll ({elapsed_lbl})"
    behind = expected - worked
    rem_days = int(wp.get("remaining_workdays", 0) or 0)
    if rem_days > 0 and rem_lbl:
        need_total = max(0.0, target_week - worked)
        per_day = need_total / rem_days
        return (
            f"{_format_seconds(behind)} nachholen "
            f"({rem_lbl}: Ø {_format_hours_short(per_day)}/Tag)"
        )
    return f"{_format_seconds(behind)} unter Soll ({elapsed_lbl})"


def _day_status_text(row, now_ts=None):
    worked = _day_live_seconds(row, now_ts)
    target = float(row.get("target_seconds", 0) or 0)
    if _day_meets_target(row, now_ts):
        return "Soll erreicht"
    if row.get("is_running"):
        return "läuft noch"
    missing = max(0, target - worked)
    return f"{_format_seconds(missing)} fehlen"


def _format_date_short(day_str):
    parts = str(day_str).split("-")
    if len(parts) == 3:
        return f"{parts[2]}.{parts[1]}."
    return str(day_str)[:6]


def _format_hours_short(seconds):
    h = float(seconds) / 3600
    if h >= 1:
        return f"{h:.1f} h"
    return _format_seconds(seconds)


def _day_productivity_pct(row, now_ts=None):
    target = float(row.get("target_seconds", 0) or 0)
    if target <= 0:
        return 0
    return min(150, int(round(100 * _day_live_seconds(row, now_ts) / target)))


def _chart_max_scale(rows, target_seconds):
    if not rows:
        return target_seconds * 1.08
    peak = max(_day_live_seconds(r) for r in rows)
    return max(target_seconds, peak) * 1.08


def _bar_fill_class(row, now_ts=None):
    if _day_meets_target(row, now_ts):
        return "report-bar-fill bar-ok"
    if row.get("is_running"):
        return "report-bar-fill bar-pending running"
    return "report-bar-fill bar-miss"


def _update_bar_column(col_el, row, max_scale, target_seconds, now_ts=None):
    worked = _day_live_seconds(row, now_ts)
    fill_pct = min(100, (worked / max_scale) * 100) if max_scale else 0
    target_pct = min(100, (target_seconds / max_scale) * 100) if max_scale else 0
    pct = _day_productivity_pct(row, now_ts)

    pct_el = col_el.querySelector(".report-bar-pct")
    if pct_el:
        pct_el.text = f"{pct}%"
    fill_el = col_el.querySelector(".report-bar-fill")
    if fill_el:
        fill_el.style.height = f"{fill_pct}%"
        fill_el.className = _bar_fill_class(row, now_ts)
    line_el = col_el.querySelector(".report-bar-target-line")
    if line_el:
        line_el.style.bottom = f"{target_pct}%"
    val_el = col_el.querySelector(".report-bar-value")
    if val_el:
        val_el.text = _format_hours_short(worked)


# --- Drag & Drop --------------------------------------------------------------

_drag_task_id = None


def setup_column_drop(column_el, column_key, drop_callback):
    def on_dragover(ev):
        ev.preventDefault()
        column_el.classList.add("drag-over")

    def on_dragleave(ev):
        column_el.classList.remove("drag-over")

    def on_drop_event(ev):
        ev.preventDefault()
        column_el.classList.remove("drag-over")
        global _drag_task_id
        task_id = _drag_task_id
        if task_id is None:
            task_id = ev.dataTransfer.getData("text/plain")
        if not task_id:
            return
        cards = column_el.querySelectorAll(".task-card")
        position = len(cards)
        for i, card in enumerate(cards):
            rect = card.getBoundingClientRect()
            if ev.clientY < rect.top + rect.height / 2:
                position = i
                break
        drop_callback(int(task_id), column_key, position)

    column_el.bind("dragover", on_dragover)
    column_el.bind("dragleave", on_dragleave)
    column_el.bind("drop", on_drop_event)


def setup_card_drag(card_el, task_id):
    global _drag_task_id

    def on_dragstart(ev):
        global _drag_task_id
        _drag_task_id = str(task_id)
        ev.dataTransfer.setData("text/plain", str(task_id))
        ev.dataTransfer.effectAllowed = "move"
        card_el.classList.add("dragging")

    def on_dragend(ev):
        global _drag_task_id
        _drag_task_id = None
        card_el.classList.remove("dragging")

    card_el.draggable = True
    card_el.bind("dragstart", on_dragstart)
    card_el.bind("dragend", on_dragend)


# --- Login --------------------------------------------------------------------

def render_login(on_success):
    app = document["app"]
    app.clear()

    error_el = html.DIV(Class="login-error", style={"display": "none"})
    username = html.INPUT(type="text", id="username", placeholder="johannes oder mijail")
    password = html.INPUT(type="password", id="password", placeholder="Passwort")

    def show_error(msg):
        error_el.text = msg
        error_el.style.display = "block"

    def do_login(ev):
        ev.preventDefault()
        error_el.style.display = "none"
        api_post(
            "/api/auth/login",
            {"username": username.value, "password": password.value},
            on_success,
            lambda e: show_error(e.detail or "Login fehlgeschlagen"),
        )

    # Brython: Kinder per <= einfügen (Positional-Args in FORM sind oft leer im DOM)
    wrap = html.DIV(Class="login-wrap")
    form = html.FORM(Class="login-card")
    form.bind("submit", do_login)
    form <= html.H1("exXxa Projektmanagement")
    form <= html.P("Melde dich an, um das Board zu nutzen.")
    form <= error_el
    form <= html.LABEL("Benutzername", **{"for": "username"})
    form <= username
    form <= html.LABEL("Passwort", **{"for": "password"})
    form <= password
    form <= html.BUTTON("Anmelden", type="submit")
    wrap <= form
    app <= wrap


# --- Board --------------------------------------------------------------------

BOARD_COLUMNS = [
    ("pause", "Pause"),
    ("backlog", "Backlog"),
    ("in_progress", "In Arbeit"),
    ("to_review", "To Review"),
    ("in_review", "In Review"),
    ("done", "Erledigt"),
]

TRASH_COLUMN = ("trash", "Papierkorb")

_AUTO_COLLAPSE_COLUMNS = frozenset(("backlog", "done"))

PLAN_UNASSIGNED_EPIC_TITLE = "Unassigned"
PLAN_UNASSIGNED_COLUMN = "unassigned"

_drag_plan_task_id = None

_state = {
    "user": None,
    "board": None,
    "report": None,
    "users": [],
    "projects": [],
    "tick": None,
    "sync_interval": None,
    "sync_setup": False,
    "trash_expanded": False,
    "collapsed_cards": set(),
    "active_view": "kanban",
    "epics": [],
    "plan_tasks": [],
    "import_epics": [],
    "import_tasks": [],
    "import_epic_id_seq": 0,
    "import_task_id_seq": 0,
    "staging_epics": [],
    "staging_tasks": [],
    "staging_epic_id_seq": 0,
    "staging_task_id_seq": 0,
    "staging_deleted_epic_db_ids": [],
    "staging_deleted_task_db_ids": [],
}


def _is_card_collapsed(task_id):
    return int(task_id) in _state.get("collapsed_cards", set())


def _collapse_card(task_id):
    _state.setdefault("collapsed_cards", set()).add(int(task_id))


def _toggle_card_collapsed(task_id):
    collapsed = _state.setdefault("collapsed_cards", set())
    tid = int(task_id)
    if tid in collapsed:
        collapsed.discard(tid)
        is_collapsed = False
    else:
        collapsed.add(tid)
        is_collapsed = True
    card = document.getElementById(f"task-{tid}")
    if card is None:
        return
    if is_collapsed:
        card.classList.add("task-card-collapsed")
    else:
        card.classList.remove("task-card-collapsed")
    btn = card.querySelector(".btn-collapse")
    if btn:
        btn.text = "▶" if is_collapsed else "▼"
        btn.title = "Aufklappen" if is_collapsed else "Einklappen"


def _display_user(username):
    if not username:
        return ""
    names = {"johannes": "Johannes", "mijail": "Mijail"}
    return names.get(username.lower(), username[0].upper() + username[1:])


def _reload(on_ready=None):
    done = {"n": 0}

    def check():
        done["n"] += 1
        if done["n"] >= 2 and on_ready:
            on_ready()

    api_get("/api/subtasks", lambda data: _set_board(data, check))
    api_get("/api/report/time", lambda data: _set_report(data, check))


def _apply_board_data(data):
    _state["board"] = data
    _state["users"] = data.get("users") or []
    _state["projects"] = data.get("projects") or []
    _state["plan_tasks"] = data.get("tasks") or []
    _state["epics"] = data.get("epics") or []


def _set_board(data, on_ready=None):
    _apply_board_data(data)
    _render_board()
    if on_ready:
        on_ready()


def _set_report(data, on_ready=None):
    _state["report"] = data
    _state["report_sync_at"] = time.time()
    _render_report()
    if on_ready:
        on_ready()


def _load_report():
    api_get("/api/report/time", lambda data: _set_report(data))


def _pause(task_id):
    api_post(f"/api/subtasks/{task_id}/pause", {}, lambda _: _reload())


def _resume(task_id):
    api_post(f"/api/subtasks/{task_id}/resume", {}, lambda _: _reload())


def _move(task_id, column, position):
    if column in _AUTO_COLLAPSE_COLUMNS:
        _collapse_card(task_id)
    api_post(
        f"/api/subtasks/{task_id}/move",
        {"column": column, "position": position},
        lambda _: _reload(),
    )


def _confirm(message):
    return window.confirm(message)


def _move_to_trash(task_id, task_title=""):
    title = (task_title or "diese Aufgabe").strip() or "diese Aufgabe"
    if not _confirm(
        f'„{title}" wirklich in den Papierkorb verschieben?\n\n'
        "Die Aufgabe kann im Papierkorb wiederhergestellt oder endgültig geleert werden."
    ):
        return
    api_post(f"/api/subtasks/{task_id}/trash", {}, lambda _: _reload())


def _empty_trash():
    board = _state.get("board") or {}
    count = len(board.get("columns", {}).get("trash", []))
    if count == 0:
        return
    if not _confirm(
        f"Papierkorb wirklich leeren?\n\n{count} Aufgabe(n) werden endgültig gelöscht."
    ):
        return
    def on_ok(_data):
        _reload()

    def on_fail(err):
        console.error(f"Papierkorb leeren: {err.detail or err}")
        window.alert(f"Papierkorb konnte nicht geleert werden:\n{err.detail or err}")

    api_post("/api/subtasks/trash/empty", {}, on_ok, on_fail)


def _toggle_trash_expanded():
    _state["trash_expanded"] = not _state.get("trash_expanded", False)
    _render_columns()


def _change_assignee(task_id, assignee_id):
    if not assignee_id:
        return
    api_patch(
        f"/api/subtasks/{task_id}",
        {"assignee_id": int(assignee_id)},
        lambda _: _reload(),
    )


def _create_subtask(title, assignee_id, project_id, plan_task_id):
    if not title.strip():
        return
    if (
        not assignee_id
        or not project_id
        or not plan_task_id
        or assignee_id == ""
        or project_id == ""
        or plan_task_id == ""
    ):
        console.error("Bitte Task (Epic), Ausführenden und Projekt wählen.")
        return

    def on_created(data):
        if data and data.get("id"):
            _collapse_card(data["id"])
        _reload()

    api_post(
        "/api/subtasks",
        {
            "title": title.strip(),
            "assignee_id": int(assignee_id),
            "project_id": int(project_id),
            "task_id": int(plan_task_id),
        },
        on_created,
    )


def _logout():
    api_post("/api/auth/logout", {}, lambda _: _show_login())


def _show_login():
    if _state["tick"]:
        timer.clear_interval(_state["tick"])
        _state["tick"] = None
    if _state["sync_interval"]:
        timer.clear_interval(_state["sync_interval"])
        _state["sync_interval"] = None
    render_login(lambda user: _after_login(user))


def _after_login(user):
    _state["user"] = user
    api_get("/api/subtasks", lambda data: _init_board(user, data))


def _init_board(user, data):
    _apply_board_data(data)
    _render_shell(user)
    _setup_background_sync()
    _start_tick()
    _load_report()


def _setup_background_sync():
    if _state["sync_setup"]:
        return
    _state["sync_setup"] = True

    def on_visible(ev):
        if getattr(document, "visibilityState", "visible") == "visible" and _state.get("board"):
            _reload()

    document.bind("visibilitychange", on_visible)

    def on_pageshow(ev):
        if getattr(ev, "persisted", False) and _state.get("board"):
            _reload()

    window.bind("pageshow", on_pageshow)

    def periodic_sync():
        if getattr(document, "visibilityState", "visible") != "visible":
            return
        if _state.get("board"):
            _reload()

    _state["sync_interval"] = timer.set_interval(periodic_sync, 30000)


def _start_tick():
    if _state["tick"]:
        timer.clear_interval(_state["tick"])

    def tick():
        now_ts = time.time()
        for el in document.querySelectorAll(".timer-badge.running"):
            task_id = el.getAttribute("data-task-id")
            if not task_id or not _state["board"]:
                continue
            task = _find_task(int(task_id))
            if task:
                el.text = display_seconds(task["time"], now_ts)
        for el in document.querySelectorAll(".time-by-user-line.running"):
            task_id = el.getAttribute("data-task-id")
            user_id = el.getAttribute("data-user-id")
            if not task_id or not user_id:
                continue
            task = _find_task(int(task_id))
            if not task:
                continue
            for row in task.get("time_by_user") or []:
                if str(row["user"]["id"]) == user_id:
                    el.text = f"{_display_user(row['user']['username'])}: {display_user_time(row, now_ts)}"
                    break
        report = _state.get("report")
        if report:
            running = {
                (str(r["user"]["id"]), str(r["day"])): r
                for r in report.get("days") or []
                if r.get("is_running")
            }
            for el in document.querySelectorAll(".report-day-duration.running"):
                key = (el.getAttribute("data-user-id"), el.getAttribute("data-day"))
                row = running.get(key)
                if row:
                    el.text = _format_seconds(_day_live_seconds(row, now_ts))
            for el in document.querySelectorAll(".report-day-status.status-pending"):
                key = (el.getAttribute("data-user-id"), el.getAttribute("data-day"))
                row = running.get(key)
                if row:
                    el.text = _day_status_text(row, now_ts)
                    if _day_meets_target(row, now_ts):
                        el.className = "report-day-status status-ok"
            for col in document.querySelectorAll(".report-bar-col"):
                key = (col.getAttribute("data-user-id"), col.getAttribute("data-day"))
                row = running.get(key)
                if row:
                    max_scale = float(col.getAttribute("data-max-scale") or 0)
                    target_seconds = float(
                        col.getAttribute("data-target-seconds") or row.get("target_seconds", 28800)
                    )
                    _update_bar_column(col, row, max_scale, target_seconds, now_ts)
            week_map = {str(w["user"]["id"]): w for w in report.get("week_progress") or []}
            for uid, wp in week_map.items():
                if not wp.get("is_running"):
                    continue
                for el in document.querySelectorAll(f'.report-week-worked.running[data-user-id="{uid}"]'):
                    el.text = _format_hours_short(_week_live_worked(wp, now_ts))
                for el in document.querySelectorAll(f'.report-week-status.running[data-user-id="{uid}"]'):
                    el.text = _week_status_text(wp, now_ts)
                    if _week_on_pace(wp, now_ts):
                        el.className = "report-week-status status-ok running"
                    else:
                        el.className = "report-week-status status-pending running"

    _state["tick"] = timer.set_interval(tick, 1000)


def _find_task(task_id):
    board = _state.get("board")
    if not board:
        return None
    for col_tasks in board.get("columns", {}).values():
        for t in col_tasks:
            if t["id"] == task_id:
                return t
    return None


def _set_active_view(view):
    _state["active_view"] = view
    kanban_panel = document.getElementById("view-kanban")
    plan_panel = document.getElementById("view-plan")
    import_panel = document.getElementById("view-import")
    if kanban_panel:
        kanban_panel.style.display = "block" if view == "kanban" else "none"
    if plan_panel:
        plan_panel.style.display = "block" if view == "plan" else "none"
    if import_panel:
        import_panel.style.display = "flex" if view == "import" else "none"
    for el in document.querySelectorAll(".nav-tab"):
        tab_view = el.getAttribute("data-view")
        if tab_view == view:
            el.classList.add("active")
        else:
            el.classList.remove("active")
    if view == "plan":
        _reload_plan_board()


def _plan_unassigned_epic_id():
    for epic in _state.get("epics") or []:
        if epic.get("title") == PLAN_UNASSIGNED_EPIC_TITLE:
            return epic.get("id")
    return None


def _plan_display_epics():
    return [
        e for e in (_state.get("epics") or [])
        if e.get("title") != PLAN_UNASSIGNED_EPIC_TITLE
    ]


def _plan_tasks_grouped():
    unassigned_id = _plan_unassigned_epic_id()
    grouped = {PLAN_UNASSIGNED_COLUMN: []}
    for epic in _plan_display_epics():
        grouped[str(epic["id"])] = []
    for task in _state.get("plan_tasks") or []:
        epic_id = task.get("epic_id")
        if unassigned_id is not None and epic_id == unassigned_id:
            grouped[PLAN_UNASSIGNED_COLUMN].append(task)
        else:
            key = str(epic_id) if epic_id is not None else PLAN_UNASSIGNED_COLUMN
            if key not in grouped:
                grouped[PLAN_UNASSIGNED_COLUMN].append(task)
            else:
                grouped[key].append(task)
    for tasks in grouped.values():
        tasks.sort(key=lambda t: (t.get("position", 0), t.get("id", 0)))
    return grouped


def _reload_plan_board(on_ready=None):
    def on_data(data):
        _apply_board_data(data)
        _render_plan_board()
        if on_ready:
            on_ready()

    api_get("/api/subtasks", on_data)


def _assign_plan_task_epic(task_id, column_key, _position):
    unassigned_id = _plan_unassigned_epic_id()
    if column_key == PLAN_UNASSIGNED_COLUMN:
        if unassigned_id is None:
            return
        epic_id = unassigned_id
    else:
        try:
            epic_id = int(column_key)
        except (TypeError, ValueError):
            return
    api_put(f"/api/tasks/{task_id}", {"epic_id": epic_id}, lambda _: _reload_plan_board())


def setup_plan_column_drop(column_el, column_key):
    def on_dragover(ev):
        ev.preventDefault()
        column_el.classList.add("drag-over")

    def on_dragleave(ev):
        column_el.classList.remove("drag-over")

    def on_drop_event(ev):
        ev.preventDefault()
        column_el.classList.remove("drag-over")
        global _drag_plan_task_id
        task_id = _drag_plan_task_id
        if task_id is None:
            task_id = ev.dataTransfer.getData("text/plain")
        if not task_id:
            return
        cards = column_el.querySelectorAll(".plan-task-card")
        position = len(cards)
        for i, card in enumerate(cards):
            rect = card.getBoundingClientRect()
            if ev.clientY < rect.top + rect.height / 2:
                position = i
                break
        _assign_plan_task_epic(int(task_id), column_key, position)

    column_el.bind("dragover", on_dragover)
    column_el.bind("dragleave", on_dragleave)
    column_el.bind("drop", on_drop_event)


def setup_plan_card_drag(card_el, task_id):
    global _drag_plan_task_id

    def on_dragstart(ev):
        global _drag_plan_task_id
        _drag_plan_task_id = str(task_id)
        ev.dataTransfer.setData("text/plain", str(task_id))
        ev.dataTransfer.effectAllowed = "move"
        card_el.classList.add("dragging")

    def on_dragend(ev):
        global _drag_plan_task_id
        _drag_plan_task_id = None
        card_el.classList.remove("dragging")

    card_el.draggable = True
    card_el.bind("dragstart", on_dragstart)
    card_el.bind("dragend", on_dragend)


def _render_plan_task_card(task):
    card = html.DIV(Class="plan-task-card", id=f"plan-task-{task['id']}")
    title = html.DIV(task.get("title", ""), Class="plan-task-title")
    card <= title
    if task.get("estimated_days") is not None:
        days = _format_days_label(_coerce_days(task.get("estimated_days"), 0))
        card <= html.SPAN(days, Class="plan-task-days")
    setup_plan_card_drag(card, task["id"])
    return card


def _render_plan_column(board_el, column_key, label, tasks):
    body = html.DIV(Class="column-body", id=f"plan-col-body-{column_key}")
    setup_plan_column_drop(body, column_key)
    for task in tasks:
        body <= _render_plan_task_card(task)
    col_class = "column plan-epic-column"
    if column_key == PLAN_UNASSIGNED_COLUMN:
        col_class += " plan-unassigned-column"
    col = html.DIV(Class=col_class, id=f"plan-col-{column_key}")
    head = html.DIV(Class="column-header")
    head <= html.SPAN(label)
    head <= html.SPAN(str(len(tasks)), Class="column-count")
    col <= head
    col <= body
    board_el <= col


def _render_plan_board():
    board_el = document.getElementById("plan-board-columns")
    if board_el is None:
        return
    board_el.clear()
    grouped = _plan_tasks_grouped()
    _render_plan_column(
        board_el,
        PLAN_UNASSIGNED_COLUMN,
        "Ohne Zuordnung",
        grouped.get(PLAN_UNASSIGNED_COLUMN, []),
    )
    for epic in _plan_display_epics():
        key = str(epic["id"])
        _render_plan_column(board_el, key, epic.get("title", "?"), grouped.get(key, []))


def _create_plan_task(title, epic_id, project_id):
    title = (title or "").strip()
    if not title:
        return
    if not epic_id or epic_id == "":
        return
    if not project_id or project_id == "":
        return
    try:
        epic_id = int(epic_id)
        project_id = int(project_id)
    except (TypeError, ValueError):
        return
    api_post(
        "/api/tasks",
        {"title": title, "epic_id": epic_id, "project_id": project_id},
        lambda _: _reload_plan_board(),
    )


def _render_plan_view(panel):
    panel.clear()
    user = _state.get("user") or {}
    users = _state.get("users") or []
    projects = _state.get("projects") or []

    title_input = html.INPUT(type="text", placeholder="Neue Task…", Class="toolbar-title", id="plan-task-title")
    epic_sel = html.SELECT(Class="toolbar-select", id="plan-epic-select")
    first_set = False
    for epic in _state.get("epics") or []:
        label = epic.get("title", "?")
        if label == PLAN_UNASSIGNED_EPIC_TITLE:
            label = "Ohne Zuordnung"
        opt = html.OPTION(label, value=str(epic["id"]))
        if epic.get("title") != PLAN_UNASSIGNED_EPIC_TITLE and not first_set:
            opt.selected = True
            first_set = True
        epic_sel <= opt
    if not (_state.get("epics") or []):
        epic_sel <= html.OPTION("— kein Epic —", value="")

    project_sel = html.SELECT(Class="toolbar-select", id="plan-project-select")
    for p in projects:
        opt = html.OPTION(p.get("slug", "?"), value=str(p["id"]))
        project_sel <= opt
    if not projects:
        project_sel <= html.OPTION("— kein Projekt —", value="")

    def add_task(ev=None):
        if ev is not None and getattr(ev, "type", None) == "keydown" and ev.key != "Enter":
            return
        _create_plan_task(title_input.value, epic_sel.value, project_sel.value)
        title_input.value = ""

    title_input.bind("keydown", add_task)
    toolbar = html.DIV(Class="board-toolbar")
    toolbar <= title_input
    toolbar <= epic_sel
    toolbar <= project_sel
    add_btn = html.BUTTON("+ Task", Class="btn btn-primary")
    add_btn.bind("click", add_task)
    toolbar <= add_btn

    board_el = html.DIV(id="plan-board-columns", Class="board plan-board")
    panel <= toolbar
    panel <= board_el
    _render_plan_board()


def _coerce_days(days, default=1.0):
    """JSON null kommt als None — .get(key, default) reicht dann nicht."""
    if days is None or days == "":
        return float(default)
    try:
        return float(days)
    except (TypeError, ValueError):
        return float(default)


def _format_days_label(days):
    d = _coerce_days(days, 0)
    if d <= 0:
        return "0 Tage"
    rounded = round(d)
    if abs(d - rounded) < 0.05:
        n = int(rounded)
        if n == 1:
            return "1 Tag"
        return f"{n} Tage"
    label = f"{d:.1f}".rstrip("0").rstrip(".")
    return f"{label} Tage"


def _imported_epic_days(epic_id):
    total = 0.0
    for task in _state.get("import_tasks") or []:
        if task.get("epic_id") == epic_id:
            total += _coerce_days(task.get("estimated_days"), 0)
    return total


def _staging_epic_days(epic_cid):
    total = 0.0
    for task in _state.get("staging_tasks") or []:
        if task.get("epic_cid") == epic_cid:
            total += _coerce_days(task.get("estimated_days"), 0)
    return total


def _sync_import_epic_days():
    for epic in _state.get("import_epics") or []:
        epic["estimated_days"] = _imported_epic_days(epic["id"])


def _sync_staging_epic_days():
    for epic in _state.get("staging_epics") or []:
        epic["estimated_days"] = _staging_epic_days(epic["cid"])


def _find_log_epic(log_kind, item_id):
    key = "import_epics" if log_kind == "imported" else "staging_epics"
    id_key = "id" if log_kind == "imported" else "cid"
    for epic in _state.get(key) or []:
        if epic.get(id_key) == item_id:
            return epic
    return None


def _find_log_task(log_kind, item_id):
    key = "import_tasks" if log_kind == "imported" else "staging_tasks"
    id_key = "id" if log_kind == "imported" else "cid"
    for task in _state.get(key) or []:
        if task.get(id_key) == item_id:
            return task
    return None


def _find_staging_epic_by_cid(epic_cid):
    return _find_log_epic("staging", epic_cid)


def _next_staging_epic_cid():
    seq = int(_state.get("staging_epic_id_seq", 0)) + 1
    _state["staging_epic_id_seq"] = seq
    return seq


def _next_staging_task_cid():
    seq = int(_state.get("staging_task_id_seq", 0)) + 1
    _state["staging_task_id_seq"] = seq
    return seq


def _log_card_remove_btn(on_remove):
    btn = html.BUTTON("×", Class="log-card-remove", type="button", title="Entfernen")

    def on_click(ev):
        ev.stopPropagation()
        on_remove()

    btn.bind("click", on_click)
    return btn


def _bind_log_title_edit(el, log_kind, item_type, item_id):
    if el.getAttribute("data-bound") == "1":
        return
    el.setAttribute("data-bound", "1")

    def on_click(ev):
        ev.stopPropagation()
        if el.getAttribute("data-editing") == "1":
            return
        current = el.text or ""
        el.setAttribute("data-editing", "1")
        inp = html.INPUT(type="text", Class="import-inline-input", value=current)
        el.clear()
        el <= inp
        inp.focus()

        def finish(restore=False):
            el.removeAttribute("data-editing")
            el.clear()
            new_title = current
            if not restore:
                new_title = (inp.value or "").strip() or current
                item = _find_log_epic(log_kind, item_id) if item_type == "epic" else _find_log_task(log_kind, item_id)
                if item is not None:
                    item["title"] = new_title
            el.text = new_title
            if not restore and item_type == "epic":
                if log_kind == "imported":
                    _render_imported_task_log()
                else:
                    _render_staging_task_log()

        def on_blur(ev):
            finish()

        def on_keydown(ev):
            if ev.key == "Enter":
                finish()
            elif ev.key == "Escape":
                finish(restore=True)

        inp.bind("blur", on_blur)
        inp.bind("keydown", on_keydown)

    el.bind("click", on_click)


def _bind_log_task_days_edit(el, log_kind, task_id):
    if el.getAttribute("data-bound") == "1":
        return
    el.setAttribute("data-bound", "1")

    def on_click(ev):
        ev.stopPropagation()
        if el.getAttribute("data-editing") == "1":
            return
        task = _find_log_task(log_kind, task_id)
        if task is None:
            return
        current = _coerce_days(task.get("estimated_days"), 1)
        el.setAttribute("data-editing", "1")
        inp = html.INPUT(
            type="number",
            Class="import-inline-input import-inline-input-days",
            value=str(current),
            min="0.25",
            max="365",
            step="0.25",
        )
        el.clear()
        el <= inp
        inp.focus()
        inp.select()

        def finish(restore=False):
            el.removeAttribute("data-editing")
            el.clear()
            days = current
            if not restore:
                days = max(0.25, min(365.0, _coerce_days(inp.value, current)))
                task["estimated_days"] = days
                if log_kind == "imported":
                    _sync_import_epic_days()
                    _render_imported_epic_log()
                else:
                    _sync_staging_epic_days()
                    _render_staging_epic_log()
            el.text = _format_days_label(days)

        def on_blur(ev):
            finish()

        def on_keydown(ev):
            if ev.key == "Enter":
                finish()
            elif ev.key == "Escape":
                finish(restore=True)

        inp.bind("blur", on_blur)
        inp.bind("keydown", on_keydown)

    el.bind("click", on_click)


def _next_import_epic_id():
    seq = int(_state.get("import_epic_id_seq", 0)) + 1
    _state["import_epic_id_seq"] = seq
    return seq


def _next_import_task_id():
    seq = int(_state.get("import_task_id_seq", 0)) + 1
    _state["import_task_id_seq"] = seq
    return seq


def _render_log_epic_card(epic, log_kind):
    id_key = "id" if log_kind == "imported" else "cid"
    item_id = epic[id_key]
    prefix = "imported" if log_kind == "imported" else "staging"
    card = html.DIV(Class="epic-card", id=f"epic-{prefix}-{item_id}")
    header = html.DIV(Class="epic-card-header")
    title_el = html.DIV(epic["title"], Class="epic-title import-editable")
    _bind_log_title_edit(title_el, log_kind, "epic", item_id)
    header <= title_el
    if log_kind == "imported":
        header <= _log_card_remove_btn(lambda eid=item_id: _remove_imported_epic(eid))
    else:
        header <= _log_card_remove_btn(lambda cid=item_id: _remove_staging_epic(cid))
    card <= header
    body = html.DIV(Class="epic-card-body")
    if log_kind == "imported":
        days = _imported_epic_days(item_id)
    else:
        days = _staging_epic_days(item_id)
    body <= html.SPAN(_format_days_label(days), Class="epic-duration")
    card <= body
    return card


def _render_log_task_card(task, log_kind):
    id_key = "id" if log_kind == "imported" else "cid"
    item_id = task[id_key]
    prefix = "imported" if log_kind == "imported" else "staging"
    epic_list_key = "import_epics" if log_kind == "imported" else "staging_epics"
    epic_ref_key = "epic_id" if log_kind == "imported" else "epic_cid"
    epic_opt_key = "id" if log_kind == "imported" else "cid"
    card = html.DIV(Class="import-task-card", id=f"task-{prefix}-{item_id}")
    header = html.DIV(Class="import-task-card-header")
    title_el = html.DIV(task["title"], Class="import-task-title import-editable")
    _bind_log_title_edit(title_el, log_kind, "task", item_id)
    header <= title_el
    if log_kind == "imported":
        header <= _log_card_remove_btn(lambda tid=item_id: _remove_imported_task(tid))
    else:
        header <= _log_card_remove_btn(lambda cid=item_id: _remove_staging_task(cid))
    card <= header
    body = html.DIV(Class="import-task-card-body")
    days_el = html.SPAN(
        _format_days_label(_coerce_days(task.get("estimated_days"), 1)),
        Class="import-task-duration import-editable",
    )
    _bind_log_task_days_edit(days_el, log_kind, item_id)
    body <= days_el
    epic_row = html.DIV(Class="import-task-epic-row")
    epic_row <= html.LABEL(
        "Epic",
        Class="import-task-epic-label",
        **{"for": f"task-epic-{prefix}-{item_id}"},
    )
    select = html.SELECT(Class="import-task-epic-select", id=f"task-epic-{prefix}-{item_id}")
    for epic in _state.get(epic_list_key) or []:
        opt = html.OPTION(epic["title"], value=str(epic[epic_opt_key]))
        if epic[epic_opt_key] == task.get(epic_ref_key):
            opt.selected = True
        select <= opt
    if log_kind == "imported":
        select.bind("change", lambda ev, tid=item_id: _on_imported_task_epic_change(tid, ev))
    else:
        select.bind("change", lambda ev, cid=item_id: _on_staging_task_epic_change(cid, ev))
    epic_row <= select
    body <= epic_row
    card <= body
    return card


def _render_imported_epic_log():
    body = document.getElementById("col-body-imported-epic-log")
    count_el = document.getElementById("imported-epic-log-count")
    if body is None:
        return
    body.clear()
    epics = _state.get("import_epics") or []
    for epic in epics:
        body <= _render_log_epic_card(epic, "imported")
    if count_el is not None:
        count_el.text = str(len(epics))


def _render_imported_task_log():
    body = document.getElementById("col-body-imported-task-log")
    count_el = document.getElementById("imported-task-log-count")
    if body is None:
        return
    body.clear()
    tasks = _state.get("import_tasks") or []
    for task in tasks:
        body <= _render_log_task_card(task, "imported")
    if count_el is not None:
        count_el.text = str(len(tasks))


def _render_staging_epic_log():
    body = document.getElementById("col-body-staging-epic-log")
    count_el = document.getElementById("staging-epic-log-count")
    if body is None:
        return
    body.clear()
    epics = _state.get("staging_epics") or []
    for epic in epics:
        body <= _render_log_epic_card(epic, "staging")
    if count_el is not None:
        count_el.text = str(len(epics))


def _render_staging_task_log():
    body = document.getElementById("col-body-staging-task-log")
    count_el = document.getElementById("staging-task-log-count")
    if body is None:
        return
    body.clear()
    tasks = _state.get("staging_tasks") or []
    for task in tasks:
        body <= _render_log_task_card(task, "staging")
    if count_el is not None:
        count_el.text = str(len(tasks))


def _remove_imported_epic(epic_id):
    _state["import_epics"] = [e for e in (_state.get("import_epics") or []) if e["id"] != epic_id]
    for task in _state.get("import_tasks") or []:
        if task.get("epic_id") == epic_id:
            task["epic_id"] = None
    _sync_import_epic_days()
    _render_imported_epic_log()
    _render_imported_task_log()


def _remove_imported_task(task_id):
    _state["import_tasks"] = [t for t in (_state.get("import_tasks") or []) if t["id"] != task_id]
    _sync_import_epic_days()
    _render_imported_epic_log()
    _render_imported_task_log()


def _remove_staging_epic(epic_cid):
    epic = _find_staging_epic_by_cid(epic_cid)
    if epic and epic.get("db_id"):
        deleted = _state.setdefault("staging_deleted_epic_db_ids", [])
        if epic["db_id"] not in deleted:
            deleted.append(epic["db_id"])
    _state["staging_epics"] = [e for e in (_state.get("staging_epics") or []) if e["cid"] != epic_cid]
    _state["staging_tasks"] = [t for t in (_state.get("staging_tasks") or []) if t.get("epic_cid") != epic_cid]
    _sync_staging_epic_days()
    _render_staging_epic_log()
    _render_staging_task_log()


def _remove_staging_task(task_cid):
    task = _find_log_task("staging", task_cid)
    if task and task.get("db_id"):
        deleted = _state.setdefault("staging_deleted_task_db_ids", [])
        if task["db_id"] not in deleted:
            deleted.append(task["db_id"])
    _state["staging_tasks"] = [t for t in (_state.get("staging_tasks") or []) if t["cid"] != task_cid]
    _sync_staging_epic_days()
    _render_staging_epic_log()
    _render_staging_task_log()


def _on_imported_task_epic_change(task_id, ev):
    select = ev.target
    if select is None:
        return
    try:
        epic_id = int(select.value)
    except (TypeError, ValueError):
        return
    for task in _state.get("import_tasks") or []:
        if task["id"] == task_id:
            task["epic_id"] = epic_id
            break
    _sync_import_epic_days()
    _render_imported_epic_log()


def _on_staging_task_epic_change(task_cid, ev):
    select = ev.target
    if select is None:
        return
    try:
        epic_cid = int(select.value)
    except (TypeError, ValueError):
        return
    for task in _state.get("staging_tasks") or []:
        if task["cid"] == task_cid:
            task["epic_cid"] = epic_cid
            break
    _sync_staging_epic_days()
    _render_staging_epic_log()


def _append_import_result(data):
    new_epics = (data or {}).get("epics") or []
    new_tasks = (data or {}).get("tasks") or []
    stored_epics = _state.setdefault("import_epics", [])
    for item in new_epics:
        stored_epics.append(
            {
                "id": _next_import_epic_id(),
                "title": item.get("title", ""),
                "estimated_days": _coerce_days(item.get("estimated_days"), 1),
            }
        )
    title_to_id = {
        e["title"].strip().lower(): e["id"] for e in stored_epics
    }
    default_epic_id = stored_epics[0]["id"] if stored_epics else None
    stored_tasks = _state.setdefault("import_tasks", [])
    for item in new_tasks:
        epic_title = (item.get("epic_title") or "").strip()
        epic_id = title_to_id.get(epic_title.lower()) if epic_title else None
        if epic_id is None:
            epic_id = default_epic_id
        if epic_id is None:
            continue
        stored_tasks.append(
            {
                "id": _next_import_task_id(),
                "title": item.get("title", ""),
                "estimated_days": _coerce_days(item.get("estimated_days"), 1),
                "epic_id": epic_id,
            }
        )
    _sync_import_epic_days()
    _render_imported_epic_log()
    _render_imported_task_log()


def _adopt_all_imported(ev=None):
    if ev is not None:
        ev.preventDefault()
    import_epics = list(_state.get("import_epics") or [])
    import_tasks = list(_state.get("import_tasks") or [])
    if not import_epics and not import_tasks:
        return
    epic_map = {}
    for epic in import_epics:
        cid = _next_staging_epic_cid()
        epic_map[epic["id"]] = cid
        _state.setdefault("staging_epics", []).append(
            {
                "cid": cid,
                "db_id": None,
                "title": epic.get("title", ""),
                "estimated_days": _coerce_days(epic.get("estimated_days"), 0),
            }
        )
    for task in import_tasks:
        epic_cid = epic_map.get(task.get("epic_id"))
        if epic_cid is None:
            continue
        _state.setdefault("staging_tasks", []).append(
            {
                "cid": _next_staging_task_cid(),
                "db_id": None,
                "title": task.get("title", ""),
                "estimated_days": _coerce_days(task.get("estimated_days"), 1),
                "epic_cid": epic_cid,
            }
        )
    _sync_staging_epic_days()
    _render_staging_epic_log()
    _render_staging_task_log()


def _clear_import_logs(ev=None):
    if ev is not None:
        ev.preventDefault()
    _state["import_epics"] = []
    _state["import_tasks"] = []
    _render_imported_epic_log()
    _render_imported_task_log()


def _default_project_id():
    projects = _state.get("projects") or []
    for p in projects:
        if p.get("slug") == "NASP":
            return p.get("id")
    if projects:
        return projects[0].get("id")
    return None


def _run_save_queue(items, worker, on_done, on_error=None):
    if not items:
        on_done()
        return

    def step(idx):
        if idx >= len(items):
            on_done()
            return

        def next_ok(_data=None):
            step(idx + 1)

        def next_err(e):
            if on_error:
                on_error(e)
            else:
                step(idx + 1)

        worker(items[idx], next_ok, next_err)

    step(0)


def _save_staging_to_db(ev=None):
    if ev is not None:
        ev.preventDefault()
    _show_import_error("")

    def start_save():
        project_id = _default_project_id()
        if project_id is None:
            _show_import_error("Kein Projekt geladen.")
            _set_save_loading(False)
            return
        _persist_staging(project_id)

    _set_save_loading(True)
    if _default_project_id() is not None:
        start_save()
    else:
        api_get(
            "/api/subtasks",
            lambda data: (_apply_board_data(data), start_save()),
            lambda e: (_set_save_loading(False), _show_import_error(e.detail or "Laden fehlgeschlagen")),
        )


def _persist_staging(project_id):
    del_tasks = list(_state.get("staging_deleted_task_db_ids") or [])
    del_epics = list(_state.get("staging_deleted_epic_db_ids") or [])
    epics = list(_state.get("staging_epics") or [])
    tasks = list(_state.get("staging_tasks") or [])

    def finish_ok():
        _state["staging_deleted_epic_db_ids"] = []
        _state["staging_deleted_task_db_ids"] = []
        _set_save_loading(False)
        _load_staging_from_db()
        _reload()

    def finish_err(e):
        _set_save_loading(False)
        _show_import_error(e.detail or "Speichern fehlgeschlagen")

    def delete_tasks_then_epics():
        def delete_epics():
            _run_save_queue(
                del_epics,
                lambda eid, ok, err: api_delete(f"/api/epics/{eid}", ok, err),
                save_epics,
                finish_err,
            )

        _run_save_queue(
            del_tasks,
            lambda tid, ok, err: api_delete(f"/api/tasks/{tid}", ok, err),
            delete_epics,
            finish_err,
        )

    def save_tasks():
        def save_one(task, ok, err):
            epic = _find_staging_epic_by_cid(task.get("epic_cid"))
            if epic is None:
                ok()
                return
            epic_db_id = epic.get("db_id")
            if not epic_db_id:
                err(ApiError(400, "Epic ohne Datenbank-ID"))
                return
            days = max(0.25, min(365.0, _coerce_days(task.get("estimated_days"), 1)))
            body = {
                "title": task.get("title", ""),
                "estimated_days": days,
                "epic_id": epic_db_id,
                "project_id": project_id,
            }
            if task.get("db_id"):
                api_put(f"/api/tasks/{task['db_id']}", body, ok, err)
            else:
                def on_created(data):
                    task["db_id"] = data["id"]
                    ok()

                api_post("/api/tasks", body, on_created, err)

        _run_save_queue(tasks, save_one, finish_ok, finish_err)

    def save_epics():
        def save_one(epic, ok, err):
            days = max(0.25, min(365.0, _staging_epic_days(epic["cid"])))
            body = {"title": epic.get("title", ""), "estimated_days": days}
            if epic.get("db_id"):
                api_put(f"/api/epics/{epic['db_id']}", body, ok, err)
            else:
                body["project_id"] = project_id

                def on_created(data):
                    epic["db_id"] = data["id"]
                    ok()

                api_post("/api/epics", body, on_created, err)

        _run_save_queue(epics, save_one, save_tasks, finish_err)

    delete_tasks_then_epics()


def _load_staging_from_db():
    _state["staging_deleted_epic_db_ids"] = []
    _state["staging_deleted_task_db_ids"] = []
    done = {"n": 0}

    def check():
        done["n"] += 1
        if done["n"] >= 2:
            _sync_staging_epic_days()
            _render_staging_epic_log()
            _render_staging_task_log()

    def on_epics(data):
        epics = []
        seq = 0
        for item in data or []:
            seq += 1
            epics.append(
                {
                    "cid": seq,
                    "db_id": item["id"],
                    "title": item.get("title", ""),
                    "estimated_days": _coerce_days(item.get("estimated_days"), 0),
                }
            )
        _state["staging_epics"] = epics
        _state["staging_epic_id_seq"] = seq
        check()

    def on_tasks(data):
        epic_db_to_cid = {e["db_id"]: e["cid"] for e in (_state.get("staging_epics") or []) if e.get("db_id")}
        tasks = []
        seq = 0
        for item in data or []:
            epic_db_id = item.get("epic_id") or (item.get("epic") or {}).get("id")
            epic_cid = epic_db_to_cid.get(epic_db_id)
            if epic_cid is None:
                continue
            seq += 1
            tasks.append(
                {
                    "cid": seq,
                    "db_id": item["id"],
                    "title": item.get("title", ""),
                    "estimated_days": _coerce_days(item.get("estimated_days"), 1),
                    "epic_cid": epic_cid,
                }
            )
        _state["staging_tasks"] = tasks
        _state["staging_task_id_seq"] = seq
        check()

    api_get("/api/epics", on_epics)
    api_get("/api/tasks", on_tasks)


def _set_import_loading(loading):
    btn = document.getElementById("import-btn")
    if btn is None:
        return
    btn.disabled = loading
    btn.text = "Importiere…" if loading else "Importieren"


def _set_save_loading(loading):
    btn = document.getElementById("save-staging-btn")
    if btn is None:
        return
    btn.disabled = loading
    btn.text = "Speichere…" if loading else "Speichern"


def _show_import_error(msg):
    err = document.getElementById("import-error")
    if err is None:
        return
    if msg:
        err.text = msg
        err.style.display = "block"
    else:
        err.text = ""
        err.style.display = "none"


def _do_import_epics(ev=None):
    if ev is not None:
        ev.preventDefault()
    text_el = document.getElementById("import-text")
    inst_el = document.getElementById("import-instructions")
    if text_el is None:
        return
    text = (text_el.value or "").strip()
    if not text:
        _show_import_error("Bitte zuerst Text einfügen.")
        return
    instructions = (inst_el.value or "").strip() if inst_el is not None else ""
    _show_import_error("")
    _set_import_loading(True)

    def on_ok(data):
        _set_import_loading(False)
        _append_import_result(data)

    def on_err(e):
        _set_import_loading(False)
        _show_import_error(e.detail or "Import fehlgeschlagen")

    api_post(
        "/api/import/epics",
        {"text": text, "instructions": instructions},
        on_ok,
        on_err,
    )


def _render_import_view(panel):
    panel.clear()
    layout = html.DIV(Class="import-layout")

    input_area = html.DIV(Class="import-input-area")
    texts = html.DIV(Class="import-input-stack")
    textarea = html.TEXTAREA(
        placeholder="Text hier einfügen…",
        Class="import-textarea",
        id="import-text",
    )
    texts <= textarea
    texts <= html.LABEL("Zusätzliche Anweisungen", Class="import-instructions-label", **{"for": "import-instructions"})
    instructions = html.TEXTAREA(
        placeholder="z. B. nur technische Epics, maximal 5 Stück…",
        Class="import-instructions",
        id="import-instructions",
    )
    texts <= instructions
    error_el = html.DIV(Class="import-error", id="import-error", style={"display": "none"})
    texts <= error_el
    import_btn = html.BUTTON(
        "Importieren",
        Class="btn btn-primary import-btn",
        type="button",
        id="import-btn",
    )
    import_btn.bind("click", _do_import_epics)
    adopt_btn = html.BUTTON(
        "Alle übernehmen",
        Class="btn import-action-btn",
        type="button",
        id="adopt-all-btn",
    )
    adopt_btn.bind("click", _adopt_all_imported)
    save_btn = html.BUTTON(
        "Speichern",
        Class="btn btn-primary import-action-btn",
        type="button",
        id="save-staging-btn",
    )
    save_btn.bind("click", _save_staging_to_db)
    clear_btn = html.BUTTON(
        "Import-Logs leeren",
        Class="btn import-action-btn",
        type="button",
        id="clear-import-logs-btn",
    )
    clear_btn.bind("click", _clear_import_logs)
    actions = html.DIV(Class="import-actions")
    actions <= import_btn
    actions <= adopt_btn
    actions <= save_btn
    actions <= clear_btn
    input_area <= texts
    input_area <= actions

    logs = html.DIV(Class="import-logs")
    for col_id, title, body_id, count_id in (
        ("col-imported-epic", "Imported Epic Log", "col-body-imported-epic-log", "imported-epic-log-count"),
        ("col-imported-task", "Imported Task Log", "col-body-imported-task-log", "imported-task-log-count"),
        ("col-staging-epic", "Epic Log", "col-body-staging-epic-log", "staging-epic-log-count"),
        ("col-staging-task", "Task Log", "col-body-staging-task-log", "staging-task-log-count"),
    ):
        col = html.DIV(Class="import-log-column", id=col_id)
        head = html.DIV(Class="import-log-header")
        head <= html.SPAN(title, Class="import-log-title")
        cnt = html.SPAN("0", Class="import-log-count", id=count_id)
        head <= cnt
        body = html.DIV(Class="import-log-body", id=body_id)
        col <= head
        col <= body
        logs <= col

    layout <= input_area
    layout <= logs
    panel <= layout
    _render_imported_epic_log()
    _render_imported_task_log()
    _load_staging_from_db()


def _render_shell(user):
    app = document["app"]
    app.clear()

    def logout_click(ev):
        _logout()

    header = html.DIV(Class="board-header")
    header <= html.H1("exXxa Projektmanagement")

    nav = html.NAV(Class="main-nav")
    for label, view_id in (("Kanban", "kanban"), ("Planung", "plan"), ("Import", "import")):
        tab = html.BUTTON(label, Class="nav-tab", type="button")
        tab.setAttribute("data-view", view_id)
        if _state.get("active_view", "kanban") == view_id:
            tab.classList.add("active")
        tab.bind("click", lambda ev, v=view_id: _set_active_view(v))
        nav <= tab
    header <= nav

    header_actions = html.DIV(Class="header-actions")
    header_actions <= html.SPAN(
        f"Angemeldet als {_display_user(user['username'])}", Class="user-badge"
    )
    logout_btn = html.BUTTON("Abmelden", Class="btn")
    logout_btn.bind("click", logout_click)
    header_actions <= logout_btn
    header <= header_actions

    users = _state.get("users", [])
    projects = _state.get("projects", [])

    title_input = html.INPUT(type="text", placeholder="Neue Subtask…", Class="toolbar-title")

    plan_tasks = _state.get("plan_tasks") or []
    plan_task_sel = html.SELECT(Class="toolbar-select", id="plan-task-select")
    for t in plan_tasks:
        opt = html.OPTION()
        epic = (t.get("epic") or {}).get("title", "?")
        opt.text = f"{epic} · {t.get('title', '?')}"
        opt.value = str(t["id"])
        plan_task_sel <= opt
    if not plan_tasks:
        plan_task_sel <= html.OPTION("— kein Task —", value="")

    assignee_sel = html.SELECT(Class="toolbar-select", id="assignee-select")
    for u in users:
        opt = html.OPTION()
        opt.text = _display_user(u["username"])
        opt.value = str(u["id"])
        if user["id"] == u["id"]:
            opt.selected = True
        assignee_sel <= opt
    if not users:
        assignee_sel <= html.OPTION("— kein User —", value="")

    project_sel = html.SELECT(Class="toolbar-select", id="project-select")
    for p in projects:
        opt = html.OPTION()
        opt.text = p["slug"]
        opt.value = str(p["id"])
        project_sel <= opt
    if not projects:
        project_sel <= html.OPTION("— kein Projekt —", value="")

    def add_subtask(ev=None):
        if ev is not None and getattr(ev, "type", None) == "keydown" and ev.key != "Enter":
            return
        _create_subtask(
            title_input.value,
            assignee_sel.value,
            project_sel.value,
            plan_task_sel.value,
        )
        title_input.value = ""

    title_input.bind("keydown", add_subtask)

    toolbar = html.DIV(Class="board-toolbar")
    toolbar <= title_input
    toolbar <= plan_task_sel
    toolbar <= assignee_sel
    toolbar <= project_sel
    add_btn = html.BUTTON("+ Subtask", Class="btn btn-primary")
    add_btn.bind("click", add_subtask)
    toolbar <= add_btn

    board_el = html.DIV(id="board-columns", Class="board")
    report_el = html.DIV(id="time-report", Class="time-report")

    kanban_panel = html.DIV(id="view-kanban", Class="view-panel")
    kanban_panel <= toolbar
    kanban_panel <= board_el
    kanban_panel <= report_el

    plan_panel = html.DIV(id="view-plan", Class="view-panel")
    _render_plan_view(plan_panel)

    import_panel = html.DIV(id="view-import", Class="view-panel")
    _render_import_view(import_panel)

    active = _state.get("active_view", "kanban")
    kanban_panel.style.display = "block" if active == "kanban" else "none"
    plan_panel.style.display = "block" if active == "plan" else "none"
    import_panel.style.display = "flex" if active == "import" else "none"

    app <= header
    app <= kanban_panel
    app <= plan_panel
    app <= import_panel
    _render_columns()
    _render_report()


def _report_users_order(by_uid):
    users = _state.get("users") or []
    if users:
        return users
    seen = {}
    for rows in by_uid.values():
        if rows:
            u = rows[0]["user"]
            seen[u["id"]] = u
    return sorted(seen.values(), key=lambda u: u.get("username", ""))


def _append_user_week_progress(block, wp, weekly_target_h):
    if not wp:
        return
    uid = str(wp["user"]["id"])
    worked = _week_live_worked(wp)
    target_week = float(wp.get("target_week_seconds", weekly_target_h * 3600))
    expected = float(wp.get("expected_seconds_to_date", 0))
    elapsed_lbl = wp.get("elapsed_range_label", "Mo–Fr")
    week_pct = min(100, (worked / target_week) * 100) if target_week else 0
    pace_pct = min(100, (worked / expected) * 100) if expected else 0

    box = html.DIV(Class="report-week-box")
    box.setAttribute("data-user-id", uid)
    box <= html.H4("Woche (Mo–Fr)", Class="report-week-heading")

    meter = html.DIV(Class="report-week-meter")
    meter_track = html.DIV(Class="report-week-meter-track")
    fill = html.DIV(Class="report-week-meter-fill")
    fill.style.width = f"{week_pct}%"
    if _week_on_pace(wp):
        fill.classList.add("week-ok")
    else:
        fill.classList.add("week-behind")
    if wp.get("is_running"):
        fill.classList.add("running")
    meter_track <= fill
    meter <= meter_track
    meter <= html.SPAN(
        f"{_format_hours_short(worked)} / {_format_hours_short(target_week)}",
        Class="report-week-meter-label",
    )
    box <= meter

    grid = html.DIV(Class="report-week-grid")
    for label, value, extra_class in (
        (f"Soll bis heute ({elapsed_lbl})", _format_hours_short(expected), ""),
        ("Ist (diese Woche)", _format_hours_short(worked), "report-week-worked"),
        ("Noch bis Freitag", _format_hours_short(wp.get("remaining_week_seconds", 0)), ""),
    ):
        cell = html.DIV(Class="report-week-cell")
        cell <= html.SPAN(label, Class="report-week-cell-label")
        val = html.SPAN(value, Class="report-week-cell-value " + extra_class)
        if extra_class and wp.get("is_running"):
            val.classList.add("running")
        val.setAttribute("data-user-id", uid)
        cell <= val
        grid <= cell
    box <= grid

    pace_track = html.DIV(Class="report-week-pace-track")
    pace_fill = html.DIV(Class="report-week-pace-fill")
    pace_fill.style.width = f"{pace_pct}%"
    pace_track <= pace_fill
    box <= pace_track

    status_class = "report-week-status status-ok" if _week_on_pace(wp) else "report-week-status status-miss"
    if wp.get("is_running") and not _week_on_pace(wp):
        status_class = "report-week-status status-pending"
    status = html.P(_week_status_text(wp), Class=status_class)
    status.setAttribute("data-user-id", uid)
    if wp.get("is_running"):
        status.classList.add("running")
    box <= status

    block <= box


def _append_user_report_chart(block, user, rows, target_h):
    target_seconds = target_h * 3600
    uid = str(user["id"])
    user_color = user.get("color", "#64748b")

    rows_chrono = sorted(rows, key=lambda r: str(r.get("day", "")))
    if len(rows_chrono) > 14:
        rows_chrono = rows_chrono[-14:]
    max_scale = _chart_max_scale(rows_chrono, target_seconds)

    chart = html.DIV(Class="report-chart")
    chart.setAttribute("data-user-id", uid)
    chart.setAttribute("data-max-scale", str(max_scale))
    chart.setAttribute("data-target-seconds", str(target_seconds))
    chart <= html.H4("Arbeitszeit & Produktivität", Class="report-chart-title")

    days_met = sum(1 for r in rows if _day_meets_target(r))
    days_count = len(rows)
    prod_rate = int(round(100 * days_met / days_count)) if days_count else 0
    avg_sec = sum(_day_live_seconds(r) for r in rows) / days_count if days_count else 0

    summary = html.DIV(Class="report-chart-summary")
    for label, value in (
        ("Ø pro Tag", _format_hours_short(avg_sec)),
        ("Soll-Tage", f"{days_met} / {days_count}"),
        ("Produktivität", f"{prod_rate} %"),
    ):
        stat = html.DIV(Class="report-stat")
        stat <= html.SPAN(label, Class="report-stat-label")
        stat <= html.SPAN(value, Class="report-stat-value")
        summary <= stat

    prod_meter = html.DIV(Class="report-prod-meter")
    prod_fill = html.DIV(Class="report-prod-fill")
    prod_fill.style.width = f"{min(100, prod_rate)}%"
    prod_fill.style.background = user_color
    if prod_rate >= 80:
        prod_fill.classList.add("prod-high")
    elif prod_rate >= 50:
        prod_fill.classList.add("prod-mid")
    else:
        prod_fill.classList.add("prod-low")
    prod_meter <= prod_fill
    summary <= prod_meter
    chart <= summary

    bars = html.DIV(Class="report-bars")
    for row in rows_chrono:
        day = str(row["day"])
        worked = _day_live_seconds(row)
        fill_pct = min(100, (worked / max_scale) * 100) if max_scale else 0
        target_pct = min(100, (target_seconds / max_scale) * 100) if max_scale else 0

        col = html.DIV(Class="report-bar-col")
        col.setAttribute("data-user-id", uid)
        col.setAttribute("data-day", day)
        col.setAttribute("data-max-scale", str(max_scale))
        col.setAttribute("data-target-seconds", str(target_seconds))

        col <= html.SPAN(f"{_day_productivity_pct(row)}%", Class="report-bar-pct")

        track = html.DIV(Class="report-bar-track")
        target_line = html.DIV(Class="report-bar-target-line")
        target_line.style.bottom = f"{target_pct}%"
        track <= target_line

        fill = html.DIV(Class=_bar_fill_class(row))
        fill.style.height = f"{fill_pct}%"
        fill.setAttribute("data-user-id", uid)
        fill.setAttribute("data-day", day)
        track <= fill
        col <= track

        col <= html.SPAN(_format_date_short(day), Class="report-bar-day")
        col <= html.SPAN(_format_hours_short(worked), Class="report-bar-value")
        bars <= col

    chart <= bars

    legend = html.DIV(Class="report-chart-legend")
    legend <= html.SPAN(Class="legend-swatch legend-target")
    legend <= html.SPAN(f" Soll ({_format_hours_short(target_seconds)})  ")
    legend <= html.SPAN(Class="legend-swatch bar-ok")
    legend <= html.SPAN(" erreicht  ")
    legend <= html.SPAN(Class="legend-swatch bar-pending")
    legend <= html.SPAN(" läuft  ")
    legend <= html.SPAN(Class="legend-swatch bar-miss")
    legend <= html.SPAN(" unter Soll")
    chart <= legend

    block <= chart


def _append_user_report_table(parent, user, rows, target_h, week_progress=None):
    uid = str(user["id"])
    block = html.DIV(Class="report-person")
    block.setAttribute("data-user-id", uid)
    block.style.setProperty("--person-color", user.get("color", "#64748b"))

    header = html.DIV(Class="report-person-header")
    header <= html.SPAN(Class="user-dot", style={"background": user.get("color", "#64748b")})
    header <= html.H3(_display_user(user["username"]))
    block <= header

    weekly_h = (_state.get("report") or {}).get("weekly_target_hours", 40)

    if week_progress:
        _append_user_week_progress(block, week_progress, weekly_h)

    if not rows:
        if not week_progress:
            block <= html.P("Noch keine erfassten Arbeitszeiten.", Class="report-empty")
        _append_boomer_boss_quip(block, user, week_progress, rows)
        parent <= block
        return

    _append_user_report_chart(block, user, rows, target_h)

    rows = sorted(rows, key=lambda r: str(r.get("day", "")), reverse=True)

    table = html.TABLE(Class="report-table")
    thead = html.THEAD()
    head_row = html.TR()
    for label in ("Tag", "Gearbeitet", "Soll", "Status"):
        head_row <= html.TH(label)
    thead <= head_row
    table <= thead

    tbody = html.TBODY()
    for row in rows:
        day = str(row["day"])
        tr = html.TR()
        if row.get("is_running"):
            tr.classList.add("report-row-running")
        if _day_meets_target(row):
            tr.classList.add("report-row-ok")
        elif not row.get("is_running"):
            tr.classList.add("report-row-short")

        tr <= html.TD(_format_date(day), Class="report-day")

        dur_class = "report-day-duration"
        if row.get("is_running"):
            dur_class += " running"
        dur_td = html.TD(_format_seconds(_day_live_seconds(row)), Class=dur_class)
        dur_td.setAttribute("data-user-id", uid)
        dur_td.setAttribute("data-day", day)
        tr <= dur_td

        tr <= html.TD(
            _format_seconds(row.get("target_seconds", target_h * 3600)),
            Class="report-target",
        )

        if _day_meets_target(row):
            status_class = "report-day-status status-ok"
        elif row.get("is_running"):
            status_class = "report-day-status status-pending"
        else:
            status_class = "report-day-status status-miss"
        status_td = html.TD(_day_status_text(row), Class=status_class)
        status_td.setAttribute("data-user-id", uid)
        status_td.setAttribute("data-day", day)
        tr <= status_td

        tbody <= tr
    table <= tbody
    block <= table
    _append_boomer_boss_quip(block, user, week_progress, rows)
    parent <= block


def _render_report():
    report_el = document["time-report"]
    if not report_el:
        return
    report_el.clear()

    data = _state.get("report")
    section = html.SECTION(Class="report-section")
    target_h = (data or {}).get("target_hours", 8)
    weekly_h = (data or {}).get("weekly_target_hours", 40)
    section <= html.H2("Zeitauswertung")
    section <= html.P(
        f"Tagesziel: {_format_seconds(target_h * 3600)} · Wochenziel: {_format_hours_short(weekly_h * 3600)} "
        f"(Mo–Fr, anteilig bis heute) · Zeitzone Europe/Berlin",
        Class="report-hint",
    )

    days = (data or {}).get("days") or []
    by_uid = {}
    for row in days:
        uid = row["user"]["id"]
        if uid not in by_uid:
            by_uid[uid] = []
        by_uid[uid].append(row)

    grid = html.DIV(Class="report-grid")
    users = _report_users_order(by_uid)
    if not users and not days:
        section <= html.P("Noch keine erfassten Arbeitszeiten.", Class="report-empty")
        report_el <= section
        return

    week_by_uid = {w["user"]["id"]: w for w in (data or {}).get("week_progress") or []}
    for user in users:
        _append_user_report_table(
            grid,
            user,
            by_uid.get(user["id"], []),
            target_h,
            week_by_uid.get(user["id"]),
        )

    section <= grid
    footer = html.P(
        "Sprüche vom virtuellen Boomer-Chef — nicht von HR geprüft, nicht verbindlich, "
        "dafür mit Excel-Energie.",
        Class="report-boomer-footer",
    )
    section <= footer
    report_el <= section


def _render_column(board_el, key, label, tasks):
    body = html.DIV(Class="column-body", id=f"col-body-{key}")
    setup_column_drop(body, key, _move)
    for task in tasks:
        body <= _render_card(task, key)

    col = html.DIV(Class="column", id=f"col-{key}")
    col_head = html.DIV(Class="column-header")
    col_head <= html.SPAN(label)
    col_head <= html.SPAN(str(len(tasks)), Class="column-count")
    col <= col_head
    col <= body
    board_el <= col


def _render_trash_column(board_el, tasks):
    key, label = TRASH_COLUMN
    expanded = _state.get("trash_expanded", False)
    col_classes = "column column-trash"
    if not expanded:
        col_classes += " column-trash-collapsed"

    col = html.DIV(Class=col_classes, id=f"col-{key}")
    col_head = html.DIV(Class="column-header column-trash-header")
    col_head <= html.SPAN(label)
    col_head <= html.SPAN(str(len(tasks)), Class="column-count")

    actions = html.DIV(Class="column-trash-actions")

    def on_toggle(ev):
        ev.stopPropagation()
        _toggle_trash_expanded()

    toggle_btn = html.BUTTON(
        "▼" if expanded else "▶",
        Class="btn btn-sm btn-trash-toggle",
        title="Papierkorb ein- oder ausklappen",
    )
    toggle_btn.bind("click", on_toggle)
    actions <= toggle_btn

    def on_empty(ev):
        ev.stopPropagation()
        _empty_trash()

    empty_btn = html.BUTTON(
        "Leeren",
        Class="btn btn-sm btn-trash-empty",
        title="Papierkorb endgültig leeren",
    )
    empty_btn.bind("click", on_empty)
    actions <= empty_btn
    col_head <= actions

    if expanded:
        body = html.DIV(Class="column-body", id=f"col-body-{key}")
        setup_column_drop(body, key, _move)
        for task in tasks:
            body <= _render_card(task, key)
        col <= col_head
        col <= body
    else:
        col <= col_head
        hint = html.DIV(
            f"{len(tasks)} im Papierkorb" if tasks else "leer",
            Class="column-trash-collapsed-hint",
        )
        col <= hint

    board_el <= col


def _render_columns():
    board_el = document["board-columns"]
    if not board_el:
        return
    board_el.clear()
    data = _state.get("board") or {"columns": {}}
    columns = data.get("columns", {})

    for key, label in BOARD_COLUMNS:
        _render_column(board_el, key, label, columns.get(key, []))

    trash_key, _ = TRASH_COLUMN
    _render_trash_column(board_el, columns.get(trash_key, []))


def _render_board():
    _render_columns()


def _render_card(task, column_key):
    tid = task["id"]
    t = task["time"]
    running = t.get("is_running", False)
    badge_class = "timer-badge running" if running else "timer-badge"
    label = display_seconds(t)

    assignee = task.get("assignee") or {}
    ac = assignee.get("color", "#64748b")

    actions = []
    if column_key in ("in_progress", "in_review") and not running:
        btn = html.BUTTON("▶", Class="btn btn-sm", title="Fortsetzen")

        def on_resume(ev, task_id=tid):
            ev.stopPropagation()
            _resume(task_id)

        btn.bind("click", on_resume)
        btn.bind("mousedown", lambda ev: ev.stopPropagation())
        actions.append(btn)
    if column_key in ("in_progress", "in_review") and running:
        btn = html.BUTTON("⏸", Class="btn btn-sm", title="Pausieren")

        def on_pause(ev, task_id=tid):
            ev.stopPropagation()
            _pause(task_id)

        btn.bind("click", on_pause)
        btn.bind("mousedown", lambda ev: ev.stopPropagation())
        actions.append(btn)

    collapsed = _is_card_collapsed(tid)
    card_class = "task-card"
    if collapsed:
        card_class += " task-card-collapsed"

    card = html.DIV(Class=card_class, id=f"task-{tid}")
    card.style.borderLeft = f"3px solid {ac}"
    card.style.backgroundColor = ac
    card.style.color = "#fff"

    header = html.DIV(Class="task-card-header")
    header <= html.DIV(task["title"], Class="task-title")

    header_actions = html.DIV(Class="task-card-header-actions")

    def on_collapse(ev):
        ev.stopPropagation()
        ev.preventDefault()
        _toggle_card_collapsed(tid)

    collapse_btn = html.BUTTON(
        "▶" if collapsed else "▼",
        Class="btn-collapse",
        title="Aufklappen" if collapsed else "Einklappen",
    )
    collapse_btn.bind("click", on_collapse)
    collapse_btn.bind("mousedown", lambda ev: ev.stopPropagation())
    header_actions <= collapse_btn

    if column_key != "trash":
        def on_delete(ev):
            ev.stopPropagation()
            ev.preventDefault()
            _move_to_trash(tid, task.get("title", ""))

        del_btn = html.BUTTON("×", Class="btn-delete", title="In den Papierkorb")
        del_btn.bind("click", on_delete)
        del_btn.bind("mousedown", lambda ev: ev.stopPropagation())
        header_actions <= del_btn

    header <= header_actions
    card <= header

    body = html.DIV(Class="task-card-body")

    owner_row = html.DIV(Class="task-owner-row")
    owner_label = html.SPAN("To Do", Class="task-owner-label")
    owner_row <= owner_label

    assignee_sel = html.SELECT(Class="task-assignee-select")
    for u in _state.get("users", []):
        opt = html.OPTION()
        opt.text = _display_user(u["username"])
        opt.value = str(u["id"])
        if assignee.get("id") == u["id"]:
            opt.selected = True
        assignee_sel <= opt

    def on_assignee_change(ev):
        ev.stopPropagation()
        _change_assignee(tid, assignee_sel.value)

    assignee_sel.bind("change", on_assignee_change)
    assignee_sel.bind("mousedown", lambda ev: ev.stopPropagation())
    assignee_sel.bind("click", lambda ev: ev.stopPropagation())
    owner_row <= assignee_sel
    body <= owner_row

    status_row = html.DIV(Class="task-status-row")
    parent = task.get("parent_task") or {}
    epic = parent.get("epic") or {}
    epic_badge = html.SPAN(epic.get("title", "?"), Class="epic-badge")
    status_row <= epic_badge
    task_badge = html.SPAN(parent.get("title", "?"), Class="task-badge")
    status_row <= task_badge
    proj = task.get("project") or {}
    badge = html.SPAN(proj.get("slug", "?"), Class="project-badge")
    badge.style.background = "rgba(0,0,0,0.25)"
    status_row <= badge
    timer_el = html.SPAN(label, Class=badge_class, data_task_id=str(tid))
    status_row <= timer_el
    for btn in actions:
        status_row <= btn
    body <= status_row

    breakdown = task.get("time_by_user") or []
    if breakdown:
        breakdown = sorted(breakdown, key=lambda r: _elapsed_seconds(r), reverse=True)
        times_box = html.DIV(Class="task-times")
        times_box <= html.SPAN("Zeiten", Class="task-times-title")
        for row in breakdown:
            uname = _display_user(row["user"]["username"])
            uid = row["user"]["id"]
            line_class = "time-by-user-line"
            if row.get("is_running"):
                line_class += " running"
            line = html.SPAN(
                f"{uname}: {display_user_time(row)}",
                Class=line_class,
                data_task_id=str(tid),
                data_user_id=str(uid),
            )
            line.style.color = "rgba(255,255,255,0.9)"
            times_box <= line
        body <= times_box

    card <= body
    setup_card_drag(card, tid)
    return card


def start():
    def on_me(data):
        if data and data.get("authenticated") and data.get("user"):
            _after_login(data["user"])
        else:
            _show_login()

    def on_fail(err):
        msg = err.detail or str(err)
        console.error(f"Auth check failed: {msg}")
        document["app"].html = f'<pre class="boot-error">Fehler: {msg}</pre>'

    api_get("/api/auth/me", on_me, on_fail)


def _boot():
    try:
        document["app"].clear()
        start()
    except Exception:
        tb = traceback.format_exc()
        console.error(tb)
        document["app"].html = f'<pre class="boot-error">{tb}</pre>'


_boot()
