# Kanban board

> A kanban board in Python: columns with counters, cards with tags, priorities, due dates, checklists and assignees, filters and moving cards between columns.

Source: https://albakore.github.io/pyzed/blocks/kanban/

A sprint board: four columns with counters, cards with colored tags, priority arrows, due dates (today in red), comment counts, checklist progress and assignees, a text filter, an *only mine* toggle, and buttons to add cards and move the selected one between columns.

The screenshot follows the site's theme: switch between light and dark at the top of the page to see the block in One Light and One Dark.

## Run it

From the repository root:

```bash
python examples/blocks/kanban.py
```

## What it shows

- Cards are dataclasses; moving one is changing its `column` field.
- The selected card gets an accent border and its move buttons.
- Each column scrolls on its own inside a `pz.scrollable`.
- A soft shadow on the cards with `.style(shadow=...)`.

## Components used

| Component | Used for |
| --- | --- |
| [Badge](https://albakore.github.io/pyzed/library/badge/) | Tags |
| [Count badge](https://albakore.github.io/pyzed/library/count-badge/) | Column counters |
| [Progress](https://albakore.github.io/pyzed/library/progress/) | Checklists |
| [Facepile](https://albakore.github.io/pyzed/library/facepile/) | Assignees |
| [Breadcrumbs](https://albakore.github.io/pyzed/library/breadcrumbs/) | Location |
| [Search input](https://albakore.github.io/pyzed/library/search-input/) | Filter |
| [Icon button](https://albakore.github.io/pyzed/library/icon-button/) | Add and move |
| [Scrollable](https://albakore.github.io/pyzed/library/scrollable/) | Columns |
| [Button](https://albakore.github.io/pyzed/library/button/) | Only mine, new task |

## Code

The whole block, ready to copy. It only needs `pip install pyzed-ui`.

```python title="examples/blocks/kanban.py"
"""Kanban block: a project board with columns, labelled cards, assignees, progress and moving cards along."""

from dataclasses import dataclass, field
from typing import List

import pyzed as pz
from pyzed import ui

COLUMNS = [("backlog", "Backlog", "circle-dashed", "text_muted"), ("todo", "To do", "circle", "info"),
           ("doing", "In progress", "circle-dot", "warning"), ("done", "Done", "circle-check", "success")]
TAGS = {"design": "accent", "backend": "info", "bug": "error", "docs": "success", "ai": "warning"}
PRIORITY = {"high": ("chevrons-up", "error"), "medium": ("chevron-up", "warning"), "low": ("chevron-down", "info")}

@dataclass
class Card:
    id: int
    title: str
    column: str
    tags: List[str] = field(default_factory=list)
    people: List[str] = field(default_factory=list)
    priority: str = "medium"
    due: str = ""
    comments: int = 0
    checklist: tuple = (0, 0)

CARDS = [
    Card(1, "Research onboarding drop-off", "backlog", ["design"], ["ML"], "low", "Nov 12", 2),
    Card(2, "Dark mode for the billing page", "backlog", ["design"], [], "low"),
    Card(3, "Rate limit the public API", "backlog", ["backend"], ["JL"], "medium", "Nov 20"),
    Card(4, "Streaming answers in the support bot", "todo", ["ai", "backend"], ["KV", "IN"], "high", "Nov 3", 5,
         (1, 4)),
    Card(5, "Write the MCP setup guide", "todo", ["docs", "ai"], ["SD"], "medium", "Nov 6", 1),
    Card(6, "Crash when exporting large CSVs", "doing", ["bug"], ["JL"], "high", "Today", 8, (2, 3)),
    Card(7, "New pricing table", "doing", ["design"], ["ML", "WK"], "medium", "Oct 30", 3, (3, 5)),
    Card(8, "Migrate jobs to SQLModel", "doing", ["backend"], ["KV"], "medium", "Nov 1", 0, (4, 6)),
    Card(9, "Landing page hero", "done", ["design"], ["ML"], "medium", "", 6, (4, 4)),
    Card(10, "Fix login redirect loop", "done", ["bug"], ["IN"], "high", "", 2, (2, 2)),
]

class Kanban(pz.App):
    title = "Board"
    size = (1200, 760)

    def __init__(self):
        self.cards = [Card(**vars(c)) for c in CARDS]
        self.query = ""
        self.mine = False
        self.selected = 6

    def move(self, card, step):
        keys = [c[0] for c in COLUMNS]
        card.column = keys[max(0, min(len(keys) - 1, keys.index(card.column) + step))]
        self.selected = card.id

    def add(self, column):
        new_id = max(c.id for c in self.cards) + 1
        self.cards.append(Card(new_id, "Untitled task", column, [], ["KV"], "low"))
        self.selected = new_id

    # -- view ------------------------------------------------------------------------------
    def card(self, card):
        t = self.resolved_theme()
        icon, tint = PRIORITY[card.priority]
        done, total = card.checklist
        meta = []
        if card.due:
            meta.append(pz.row([pz.icon("calendar", size=13, color=t.error if card.due == "Today" else t.icon_muted),
                                ui.label(card.due, size="xs", color="error" if card.due == "Today" else "text_muted")],
                               spacing=4, align="center"))
        if card.comments:
            meta.append(pz.row([pz.icon("message-square", size=13, color=t.icon_muted),
                                ui.muted(str(card.comments), size="xs")], spacing=4, align="center"))
        if total:
            meta.append(pz.row([pz.icon("list-checks", size=13, color=t.success if done == total else t.icon_muted),
                                ui.muted(f"{done}/{total}", size="xs")], spacing=4, align="center"))
        selected = card.id == self.selected
        body = pz.column([
            pz.row([*[ui.badge(tag, tint=TAGS[tag]) for tag in card.tags], pz.hspace(),
                    pz.icon(icon, size=14, color=t.color(tint))], spacing=6, align="center"),
            ui.label(card.title, weight="medium"),
            ui.progress(done, max=total, height=3, tint="success" if done == total else "accent") if total else None,
            pz.row([*meta, pz.hspace(),
                    ui.facepile(card.people, size=22) if card.people else None], spacing=12, align="center"),
            pz.row([ui.icon_button("arrow-left", lambda: self.move(card, -1), size="xs", tooltip="Move left"),
                    pz.hspace(),
                    ui.icon_button("arrow-right", lambda: self.move(card, 1), size="xs", tooltip="Move right")])
            if selected else None,
        ], spacing=10)
        return pz.container(body, padding=12, width="fill").style(
            background="elevated", radius=10, border=(1.5 if selected else 1, "accent" if selected else "border_variant"),
            shadow=("#0000001f", (0, 2), 6),
        ).on(click=lambda: setattr(self, "selected", card.id), cursor="pointer")

    def column(self, key, title, icon, tint):
        q = self.query.lower()
        cards = [c for c in self.cards if c.column == key and q in c.title.lower()
                 and (not self.mine or "KV" in c.people)]
        header = pz.row([
            pz.icon(icon, size=15, color=self.resolved_theme().color(tint)),
            ui.label(title, weight="semibold"),
            ui.count_badge(len(cards), tint="accent" if key == "doing" else "info"),
            pz.hspace(),
            ui.icon_button("plus", lambda: self.add(key), size="sm", tooltip="Add a card"),
            ui.icon_button("ellipsis", None, size="sm"),
        ], spacing=8, align="center")
        return pz.container(
            pz.column([header, pz.scrollable(pz.column([self.card(c) for c in cards], spacing=10), height="fill")],
                      spacing=12, height="fill"),
            padding=12, width="fill", height="fill",
        ).style(background="panel", radius=12)

    def view(self):
        top = pz.row([
            pz.column([
                ui.breadcrumbs([("folder", "Acme"), "Product", "Sprint 42"]),
                pz.row([ui.heading("Sprint 42", level=2), ui.badge("Oct 21 – Nov 3", tint="info", icon="calendar")],
                       spacing=10, align="center"),
            ], spacing=6),
            pz.hspace(),
            pz.container(ui.search_input(self.query, lambda v: setattr(self, "query", v), placeholder="Filter cards…"),
                         width=220),
            ui.button("Only mine", lambda: setattr(self, "mine", not self.mine), icon="user",
                      selected=self.mine, variant="ghost"),
            ui.facepile(["ML", "JL", "IN", "SD", "KV", "WK"], size=28, max_shown=5),
            ui.button("New task", lambda: self.add("todo"), icon="plus", variant="solid"),
        ], spacing=10, align="center")
        board = pz.row([self.column(*c) for c in COLUMNS], spacing=14, height="fill")
        return pz.column([top, board], spacing=20, padding=24, height="fill").style(background="background")

if __name__ == "__main__":
    pz.run(Kanban)
```

## Make it yours

Change `theme` (or `self.theme = pz.themes.get("One Dark").with_accent("#e11d48")`), replace the sample data with your own and split pieces into [components](https://albakore.github.io/pyzed/guides/components/) when they grow. An AI agent with the [MCP server](https://albakore.github.io/pyzed/ai/mcp/) can read this block with `read_example("blocks/kanban")` and adapt it for you.
