diff --git a/pygittools/tasks.py b/pygittools/tasks.py
index ecd7a63..c69e13d 100644
--- a/pygittools/tasks.py
+++ b/pygittools/tasks.py
@@ -110,6 +110,7 @@ class Task:
 		assignee: str | None = None,
 		due_date: datetime | None = None,
 		tags: str = "",
+		points: int | None = None,
 	):
 		if name is None:
 			name = f"{TASK_REF_PREFIX}{title.lower().replace(' ', '_')}"
@@ -127,6 +128,7 @@ class Task:
 		self.assignee = assignee
 		self.due_date = due_date
 		self.tags = tags
+		self.points = points  # Sprint story points (Fibonacci scale), None = unestimated
 		self.created_at = datetime.now()
 		self.comments: list[Oid | str] = []  # Comment OIDs
 		self.update_message()
@@ -184,6 +186,7 @@ def get_task(repo: Repository, ref: str) -> Task | None:
 		assignee=j.get("assignee"),
 		due_date=_parse_dt(j.get("due_date")),
 		tags=j.get("tags", "") or "",
+		points=j.get("points"),
 	)
 	t.comments = j.get("comments", [])
 	t.created_at = _parse_dt(j.get("created_at")) or t.created_at
@@ -210,6 +213,7 @@ def get_task_by_oid(repo: Repository, oid: Oid) -> Task | None:
 		assignee=j.get("assignee"),
 		due_date=_parse_dt(j.get("due_date")),
 		tags=j.get("tags", "") or "",
+		points=j.get("points"),
 	)
 	t.comments = j.get("comments", [])
 	t.created_at = _parse_dt(j.get("created_at")) or t.created_at
diff --git a/pygitweb/capacity_config.py b/pygitweb/capacity_config.py
new file mode 100644
index 0000000..878db26
--- /dev/null
+++ b/pygitweb/capacity_config.py
@@ -0,0 +1,79 @@
+"""
+Sprint point capacity: per-user weekly story point capacity.
+
+PyGitWeb has no database, so this follows the same JSON-file pattern as
+auth_config.py and settings_config.py: persisted at ~/.pygitweb/capacity.json
+(or PYGITWEB_CAPACITY_CONFIG).
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from datetime import date
+from pathlib import Path
+
+from pydantic import BaseModel, Field
+
+DEFAULT_CAPACITY_CONFIG_PATH = Path.home() / ".pygitweb" / "capacity.json"
+DEFAULT_WEEKLY_CAPACITY_POINTS = 20
+
+
+def capacity_config_path() -> Path:
+	explicit = os.environ.get("PYGITWEB_CAPACITY_CONFIG", "").strip()
+	if explicit:
+		return Path(explicit).expanduser()
+	return DEFAULT_CAPACITY_CONFIG_PATH
+
+
+def iso_week_key(d: date) -> str:
+	"""ISO week identifier, e.g. 2026-W38."""
+	year, week, _ = d.isocalendar()
+	return f"{year:04d}-W{week:02d}"
+
+
+def current_iso_week() -> str:
+	return iso_week_key(date.today())
+
+
+class UserCapacity(BaseModel):
+	default_points: int = DEFAULT_WEEKLY_CAPACITY_POINTS
+	weeks: dict[str, int] = Field(default_factory=dict)
+
+
+class CapacityConfig(BaseModel):
+	users: dict[str, UserCapacity] = Field(default_factory=dict)
+
+
+def load_capacity_config() -> CapacityConfig:
+	path = capacity_config_path()
+	if not path.is_file():
+		return CapacityConfig()
+	try:
+		return CapacityConfig.model_validate_json(path.read_text(encoding="utf-8"))
+	except ValueError:
+		return CapacityConfig()
+
+
+def write_capacity_config(config: CapacityConfig) -> None:
+	path = capacity_config_path()
+	path.parent.mkdir(parents=True, exist_ok=True)
+	path.write_text(json.dumps(config.model_dump(mode="json"), indent=2) + "\n", encoding="utf-8")
+
+
+def get_user_week_capacity(username: str, iso_week: str) -> int:
+	"""Points capacity for a user in a given ISO week (falls back to their default, then the global default)."""
+	config = load_capacity_config()
+	user = config.users.get(username)
+	if user is None:
+		return DEFAULT_WEEKLY_CAPACITY_POINTS
+	return user.weeks.get(iso_week, user.default_points)
+
+
+def set_user_week_capacity(username: str, iso_week: str, points: int) -> CapacityConfig:
+	config = load_capacity_config()
+	user = config.users.get(username) or UserCapacity()
+	user.weeks[iso_week] = points
+	config.users[username] = user
+	write_capacity_config(config)
+	return config
diff --git a/pygitweb/main.py b/pygitweb/main.py
index aa892ea..59bbd7e 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -116,8 +116,10 @@ from pygitweb.tasks import (
 	comment_router,
 	create_board_for_project,
 	get_board_tasks_grouped,
+	list_project_boards,
 	task_router,
 )
+from pygitweb.team import team_router
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.timeline_cache import clear_timeline_cache_async, get_all_timeline_events, warm_timeline_cache_async
 from pygitweb.validation import is_valid_pathname
@@ -209,6 +211,7 @@ app.include_router(auth_router)
 app.include_router(settings_router)
 app.include_router(board_router, prefix="/board")
 app.include_router(task_router, prefix="/tasks")
+app.include_router(team_router, prefix="/team")
 app.include_router(comment_router, prefix="/comments")
 app.include_router(merge_router, prefix="/mr")
 app.include_router(http_router)
@@ -399,6 +402,44 @@ def activity_page(
 	return HTMLResponse(f"{pre}<h1>Activity</h1>{table}{pagination_html}{POSTAMBLE}")
 
 
+@app.get("/boards", response_class=HTMLResponse)
+def boards_overview_page(
+	token: Annotated[str | None, Depends(access_token_from_request)],
+) -> HTMLResponse:
+	"""Cross-project index of boards, so users can jump straight to any project's board."""
+	projects = filter_projects_by_read_access(
+		git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
+		token,
+	)
+	rows: list[list[str]] = []
+	for pr in projects:
+		project = pr.get("path", "")
+		if not project:
+			continue
+		project_url = f"/project/{quote(project, safe='/')}"
+		boards = list_project_boards(project)
+		if not boards:
+			proj_q = quote(project, safe="")
+			grey_style = ' style="color: #999; cursor: not-allowed;"' if settings.AUTH else ""
+			rows.append([
+				f"<a href='{project_url}'>{jinja_escape(project)}</a>",
+				f"<a href='/board/create?project={proj_q}'{grey_style}>create board</a>",
+				"",
+			])
+			continue
+		for b in boards:
+			board_url = f"{project_url}/board/?b={quote(b['name'], safe='')}"
+			rows.append([
+				f"<a href='{project_url}'>{jinja_escape(project)}</a>",
+				f"<a href='{board_url}'>{jinja_escape(b['name'])}</a>",
+				str(b.get("task_count", 0)),
+			])
+	table = env.get_template("table.html").render(cols=["Project", "Board", "Tasks"], rows=rows)
+	body = table if rows else '<p class="text-muted">No projects found.</p>'
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Boards", site_name=settings.SITE_NAME)
+	return HTMLResponse(f"{pre}<h1>Boards</h1>{body}{POSTAMBLE}")
+
+
 @app.get("/opml", response_class=PlainTextResponse)
 def git_opml(token: Annotated[str | None, Depends(access_token_from_request)]) -> PlainTextResponse:
 	"""OPML feed list. Port of git_opml (stub)."""
diff --git a/pygitweb/static/board.js b/pygitweb/static/board.js
index 41a5e12..c28948a 100644
--- a/pygitweb/static/board.js
+++ b/pygitweb/static/board.js
@@ -139,7 +139,8 @@
     initBoardSelector();
     assignCloudIconToAllCards();
     refreshBoardRefManifest().then(function () {
-      loadTasksForBoard();
+      return loadTasksForBoard();
+    }).then(function () {
       openTaskFromQueryString();
     });
   }
@@ -191,6 +192,8 @@
     return { project: project || '', board: board || 'Tasks' };
   }
 
+  var lastLoadedBoards = [];
+
   function boardViewUrl(project, boardName) {
     var path = '/project/' + project + '/board/';
     var params = new URLSearchParams();
@@ -209,6 +212,7 @@
       .then(function (res) { return res.ok ? res.json() : null; })
       .then(function (boards) {
         if (!Array.isArray(boards) || !boards.length) return;
+        lastLoadedBoards = boards;
         var names = boards.map(function (b) { return b.name; });
         select.innerHTML = '';
         boards.forEach(function (b) {
@@ -237,14 +241,80 @@
     });
   }
 
+  function pushTaskFieldUpdate(card, fields) {
+    var taskRef = card && card.dataset.taskRef;
+    if (!taskRef) return Promise.resolve({ ok: false, status: 0 });
+    var ctx = getBoardContext();
+    if (!ctx.project) return Promise.resolve({ ok: false, status: 0 });
+    return fetch('/tasks/update?' + new URLSearchParams({ project: ctx.project, board: ctx.board, task: taskRef }).toString(), {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(fields)
+    }).then(function(res) {
+      return { ok: res.ok, status: res.status };
+    }).catch(function() {
+      return { ok: false, status: 0 };
+    });
+  }
+
+  /** Wire click-to-edit assignee directly on the collapsed card, without opening the popout. */
+  function bindCompactAssigneeEditable(card) {
+    var span = card && card.querySelector && card.querySelector('.board-card-assignee-compact');
+    if (!span || span.dataset.editableBound === '1') return;
+    span.dataset.editableBound = '1';
+    span.classList.add('board-card-assignee-compact-editable');
+    span.addEventListener('click', function (e) {
+      e.stopPropagation();
+      e.preventDefault();
+      if (card.querySelector('.board-card-assignee-compact-edit')) return;
+      var current = (span.textContent || '').trim();
+      if (current === 'None') current = '';
+      var wasDraggable = card.draggable;
+      card.draggable = false;
+      var input = document.createElement('input');
+      input.type = 'text';
+      input.className = 'form-control form-control-sm board-card-assignee-compact-edit';
+      input.placeholder = 'Unassigned';
+      input.value = current;
+      span.classList.add('d-none');
+      span.parentNode.insertBefore(input, span);
+      input.focus();
+      input.select();
+      input.addEventListener('click', function (ev) { ev.stopPropagation(); });
+      input.addEventListener('mousedown', function (ev) { ev.stopPropagation(); });
+      function restoreDraggable() {
+        card.draggable = wasDraggable;
+        if (wasDraggable) card.setAttribute('draggable', 'true');
+      }
+      function commit() {
+        var val = (input.value || '').trim();
+        span.textContent = val || 'None';
+        span.classList.remove('d-none');
+        input.remove();
+        restoreDraggable();
+        var detailAssignee = card.querySelector('.board-card-assignee');
+        if (detailAssignee) detailAssignee.textContent = val || 'None';
+        pushTaskFieldUpdate(card, { assignee: val || null });
+      }
+      input.addEventListener('blur', commit);
+      input.addEventListener('keydown', function (ev) {
+        ev.stopPropagation();
+        if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
+        if (ev.key === 'Escape') { ev.preventDefault(); input.value = current; input.blur(); }
+      });
+    });
+  }
+
   function createCardFromTask(task) {
     if (!template || !template.content) return null;
     var clone = template.content.cloneNode(true);
     var card = clone.querySelector && clone.querySelector('.board-card');
     if (!card) return null;
     var status = task.status || 'TODO';
+    var points = (task.points !== null && task.points !== undefined) ? task.points : null;
     card.dataset.taskRef = task.ref || '';
     card.dataset.taskStatus = status;
+    card.dataset.taskPoints = points !== null ? String(points) : '';
     setCardTaskRefDisplay(card, task.ref || '', task.id);
     var titleEl = card.querySelector('.board-card-title');
     if (titleEl) titleEl.textContent = task.title || 'Untitled';
@@ -256,12 +326,22 @@
     var meta = card.querySelector('.board-card-meta');
     if (meta) {
       var badge = meta.querySelector('.badge');
-      var assigneeSpan = meta.querySelectorAll('.text-truncate')[0];
+      var assigneeSpan = meta.querySelector('.board-card-assignee-compact');
       var commentsSpan = meta.querySelector('.board-card-comments-count-meta') || meta.querySelector('.ms-auto');
       if (badge) {
         badge.textContent = task.priority || 'LOW';
         badge.className = 'badge badge-outline text-' + (task.priority === 'CRITICAL' ? 'danger' : task.priority === 'HIGH' ? 'warning' : 'secondary');
       }
+      var pointsBadge = meta.querySelector('.board-card-points-badge');
+      if (pointsBadge) {
+        if (points !== null) {
+          pointsBadge.textContent = points + ' pt' + (points !== 1 ? 's' : '');
+          pointsBadge.classList.remove('d-none');
+        } else {
+          pointsBadge.textContent = '';
+          pointsBadge.classList.add('d-none');
+        }
+      }
       if (assigneeSpan) assigneeSpan.textContent = task.assignee || 'None';
       if (commentsSpan) commentsSpan.textContent = commentCountLabel(task.comments_count || 0);
     }
@@ -271,16 +351,19 @@
       var updatedAt = details.querySelector('.board-card-updated-at');
       var statusEl = details.querySelector('.board-card-status');
       var priorityEl = details.querySelector('.board-card-priority');
+      var pointsEl = details.querySelector('.board-card-points');
       var assigneeEl = details.querySelector('.board-card-assignee');
       var tagsEl = details.querySelector('.board-card-tags');
       if (dueDate) dueDate.textContent = task.due_date || 'None';
       if (updatedAt) updatedAt.textContent = task.updated_at || 'None';
       if (statusEl) statusEl.textContent = status;
       if (priorityEl) priorityEl.textContent = task.priority || 'LOW';
+      if (pointsEl) pointsEl.textContent = points !== null ? String(points) : '–';
       if (assigneeEl) assigneeEl.textContent = task.assignee || 'None';
       if (tagsEl) tagsEl.textContent = task.tags || '';
       updateCardCommentCount(card, task.comments_count || 0);
     }
+    bindCompactAssigneeEditable(card);
     return clone;
   }
 
@@ -297,9 +380,9 @@
 
   function loadTasksForBoard() {
     var ctx = getBoardContext();
-    if (!ctx.project) return;
+    if (!ctx.project) return Promise.resolve();
     var listUrl = '/tasks/list?' + new URLSearchParams({ project: ctx.project, board: ctx.board }).toString();
-    fetch(listUrl).then(function(res) {
+    return fetch(listUrl).then(function(res) {
       if (!res.ok) return;
       return res.json();
     }).then(function(tasks) {
@@ -376,6 +459,7 @@
     if (titleEl) titleEl.textContent = 'New task';
     syncCardTitleLineTooltip(card);
     assignCloudIconToCard(card);
+    bindCompactAssigneeEditable(card);
     cardsContainer.appendChild(clone);
     var countBadge = column.querySelector('.board-column-task-count');
     if (countBadge) {
@@ -624,8 +708,17 @@
   function updateColumnBadge(column) {
     var cardsContainer = column && column.querySelector('.board-column-cards');
     var countBadge = column && column.querySelector('.board-column-task-count');
-    if (countBadge && cardsContainer) {
-      countBadge.textContent = cardsContainer.querySelectorAll('.board-card').length;
+    var pointsBadge = column && column.querySelector('.board-column-points-total');
+    if (!cardsContainer) return;
+    var cards = cardsContainer.querySelectorAll('.board-card');
+    if (countBadge) countBadge.textContent = cards.length;
+    if (pointsBadge) {
+      var total = 0;
+      cards.forEach(function(c) {
+        var v = c.dataset.taskPoints;
+        if (v) total += parseInt(v, 10) || 0;
+      });
+      pointsBadge.textContent = total + ' pts';
     }
   }
 
@@ -668,6 +761,9 @@
     var statusEl = popoutCard.querySelector('.board-card-status');
     var priorityEl = popoutCard.querySelector('.board-card-priority');
     var tagsEl = popoutCard.querySelector('.board-card-tags');
+    var pointsEl = popoutCard.querySelector('.board-card-points');
+    var assigneeEl = popoutCard.querySelector('.board-card-assignee');
+    var FIBONACCI_POINTS = ['–', '0', '1', '2', '3', '5', '8', '13', '21'];
 
     function pushTaskUpdate() {
       var taskRef = popoutCard.dataset.taskRef;
@@ -679,17 +775,20 @@
       var dueEl_ = popoutCard.querySelector('.board-card-due-date');
       var sEl = popoutCard.querySelector('.board-card-status');
       var pEl = popoutCard.querySelector('.board-card-priority');
+      var ptEl = popoutCard.querySelector('.board-card-points');
       var aEl = popoutCard.querySelector('.board-card-assignee');
       var tagsEl_ = popoutCard.querySelector('.board-card-tags');
       var dueVal = (dueEl_ && dueEl_.textContent) ? dueEl_.textContent.trim() : '';
       var statusDisplay = (sEl && sEl.textContent) ? sEl.textContent.trim() : 'TODO';
       var priorityDisplay = (pEl && pEl.textContent) ? pEl.textContent.trim() : 'LOW';
       var tagsVal = (tagsEl_ && tagsEl_.textContent) ? tagsEl_.textContent.trim() : '';
+      var pointsDisplay = (ptEl && ptEl.textContent) ? ptEl.textContent.trim() : '–';
       var body = {
         title: (tEl && tEl.textContent) ? tEl.textContent.trim() || 'Untitled' : 'Untitled',
         description: getMarkdownFromElement(dEl),
         status: statusDisplay ? statusDisplay.replace(/\s+/g, '_').toUpperCase() : 'TODO',
         priority: priorityDisplay ? priorityDisplay.replace(/\s+/g, '_').toUpperCase() : 'LOW',
+        points: (pointsDisplay && pointsDisplay !== '–') ? parseInt(pointsDisplay, 10) : null,
         assignee: (aEl && aEl.textContent && aEl.textContent.trim() !== 'None') ? aEl.textContent.trim() : null,
         due_date: (dueVal && dueVal !== 'None') ? dueVal : null,
         tags: tagsVal || ''
@@ -698,6 +797,13 @@
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify(body)
+      }).then(function() {
+        var card = popoutCard.closest ? popoutCard : null;
+        if (card) {
+          card.dataset.taskPoints = (body.points !== null && body.points !== undefined) ? String(body.points) : '';
+          var col = card.closest('.board-column');
+          if (col) updateColumnBadge(col);
+        }
       }).catch(function() {});
     }
 
@@ -843,6 +949,65 @@
         pushTaskUpdate();
       });
     }
+
+    function commitPoints() {
+      var sel = popoutCard.querySelector('.board-card-points-edit');
+      if (!sel) return;
+      var display = (sel.options[sel.selectedIndex] && sel.options[sel.selectedIndex].text) ? sel.options[sel.selectedIndex].text : '–';
+      pointsEl.textContent = display;
+      pointsEl.classList.remove('d-none');
+      sel.remove();
+      pushTaskUpdate();
+    }
+    if (pointsEl) {
+      pointsEl.addEventListener('click', function () {
+        if (popoutCard.querySelector('.board-card-points-edit')) return;
+        var current = (pointsEl.textContent || '').trim() || '–';
+        var sel = document.createElement('select');
+        sel.className = 'form-select form-select-sm board-card-points-edit mt-1';
+        FIBONACCI_POINTS.forEach(function (val) {
+          var opt = document.createElement('option');
+          opt.value = val;
+          opt.textContent = val;
+          if (val === current) opt.selected = true;
+          sel.appendChild(opt);
+        });
+        pointsEl.classList.add('d-none');
+        pointsEl.parentNode.insertBefore(sel, pointsEl);
+        sel.focus();
+        sel.addEventListener('blur', commitPoints);
+        sel.addEventListener('change', commitPoints);
+      });
+    }
+
+    function commitAssignee() {
+      var input = popoutCard.querySelector('.board-card-assignee-edit');
+      if (!input) return;
+      var val = (input.value || '').trim();
+      assigneeEl.textContent = val || 'None';
+      assigneeEl.classList.remove('d-none');
+      input.remove();
+      var compactSpan = popoutCard.querySelector('.board-card-assignee-compact');
+      if (compactSpan) compactSpan.textContent = val || 'None';
+      pushTaskUpdate();
+    }
+    if (assigneeEl) {
+      assigneeEl.addEventListener('click', function () {
+        if (popoutCard.querySelector('.board-card-assignee-edit')) return;
+        var current = (assigneeEl.textContent || '').trim();
+        if (current === 'None') current = '';
+        var input = document.createElement('input');
+        input.type = 'text';
+        input.className = 'form-control form-control-sm board-card-assignee-edit mt-1';
+        input.placeholder = 'Unassigned';
+        input.value = current;
+        assigneeEl.classList.add('d-none');
+        assigneeEl.parentNode.insertBefore(input, assigneeEl);
+        input.focus();
+        input.addEventListener('blur', commitAssignee);
+        input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
+      });
+    }
   }
 
   function setupPopoutActions(popoutCard, closePopout, cardsContainer) {
@@ -895,6 +1060,61 @@
     }
   }
 
+  function loadBoardOptionsForMove(select, excludeName) {
+    var ctx = getBoardContext();
+    if (!ctx.project) return;
+    fetch('/board/list?' + new URLSearchParams({ project: ctx.project }).toString())
+      .then(function(res) { return res.ok ? res.json() : []; })
+      .then(function(boards) {
+        select.innerHTML = '<option value="">Move to board…</option>';
+        (boards || []).forEach(function(b) {
+          if (b.name === excludeName) return;
+          var opt = document.createElement('option');
+          opt.value = b.name;
+          opt.textContent = b.name;
+          select.appendChild(opt);
+        });
+      })
+      .catch(function() {});
+  }
+
+  function setupMoveControl(popoutCard) {
+    var select = popoutCard.querySelector('.board-card-move-select');
+    if (!select) return;
+    var ctx = getBoardContext();
+    loadBoardOptionsForMove(select, ctx.board);
+    if (select.dataset.bound === '1') return;
+    select.dataset.bound = '1';
+    select.addEventListener('change', function() {
+      var target = select.value;
+      var taskRef = popoutCard.dataset.taskRef;
+      if (!target || !taskRef) return;
+      var ctx2 = getBoardContext();
+      if (!ctx2.project) return;
+      fetch('/tasks/move?' + new URLSearchParams({ project: ctx2.project, board: ctx2.board, task: taskRef, target: target }).toString(), {
+        method: 'POST',
+        credentials: 'same-origin'
+      }).then(function(res) {
+        if (!res.ok) {
+          var msg = (res.status === 401 || res.status === 403)
+            ? 'Not authorized to move tasks (sign in again if needed).'
+            : 'Could not move task.';
+          showBoardError(msg);
+          select.value = '';
+          return;
+        }
+        var overlay = popoutCard.closest('.board-card-popout-overlay');
+        if (overlay && overlay.parentNode) {
+          overlay.parentNode.removeChild(overlay);
+        }
+        loadTasksForBoard();
+      }).catch(function() {
+        showBoardError('Could not move task.');
+        select.value = '';
+      });
+    });
+  }
+
   function showCardPopout(card) {
     var cardsContainer = card.parentNode;
     var nextSibling = card.nextSibling;
@@ -905,6 +1125,7 @@
     if (details) details.classList.remove('d-none');
     setupPopoutEditables(card);
     setupCommentForm(card);
+    setupMoveControl(card);
     var descFull = card.querySelector('.board-card-description-full');
     if (descFull && !Object.prototype.hasOwnProperty.call(descFull.dataset, 'boardMarkdown')) {
       setMarkdownElement(descFull, getMarkdownFromElement(descFull));
@@ -1035,4 +1256,75 @@
     boardColumns.addEventListener('dragover', handleBoardDragOver, true);
     boardColumns.addEventListener('drop', handleBoardDrop, true);
   }
+
+  // ---- Board switcher / create (including "+ Sprint" quick-create) ----
+
+  function suggestSprintName() {
+    var max = 0;
+    lastLoadedBoards.forEach(function(b) {
+      var m = /^Sprint-(\d+)$/.exec(b.name || '');
+      if (m) {
+        var n = parseInt(m[1], 10);
+        if (n > max) max = n;
+      }
+    });
+    return 'Sprint-' + (max + 1);
+  }
+
+  function showBoardNewForm(prefill) {
+    var form = document.getElementById('board-new-form');
+    var input = document.getElementById('board-new-name');
+    var err = document.getElementById('board-new-error');
+    if (!form || !input) return;
+    form.classList.remove('d-none');
+    if (err) err.classList.add('d-none');
+    input.value = prefill || '';
+    input.focus();
+    input.select();
+  }
+
+  var newBoardBtn = document.getElementById('board-new-board-btn');
+  if (newBoardBtn) newBoardBtn.addEventListener('click', function() { showBoardNewForm(''); });
+
+  var newSprintBtn = document.getElementById('board-new-sprint-btn');
+  if (newSprintBtn) newSprintBtn.addEventListener('click', function() { showBoardNewForm(suggestSprintName()); });
+
+  var newBoardCancel = document.getElementById('board-new-cancel');
+  if (newBoardCancel) newBoardCancel.addEventListener('click', function() {
+    var form = document.getElementById('board-new-form');
+    if (form) form.classList.add('d-none');
+  });
+
+  var newBoardForm = document.getElementById('board-new-form');
+  if (newBoardForm) {
+    newBoardForm.addEventListener('submit', function(e) {
+      e.preventDefault();
+      var ctx = getBoardContext();
+      var input = document.getElementById('board-new-name');
+      var err = document.getElementById('board-new-error');
+      var name = (input && input.value) ? input.value.trim().replace(/\s+/g, '-') : '';
+      if (!ctx.project || !name) return;
+      fetch('/board/create?' + new URLSearchParams({ project: ctx.project, name: name }).toString(), {
+        method: 'POST',
+        credentials: 'same-origin'
+      }).then(function(res) {
+        if (res.ok) {
+          location.assign(boardViewUrl(ctx.project, name));
+          return null;
+        }
+        return res.json().catch(function() { return {}; });
+      }).then(function(body) {
+        if (!body) return;
+        if (err) {
+          err.textContent = (body && body.detail) || 'Could not create board.';
+          err.classList.remove('d-none');
+        }
+      }).catch(function() {
+        if (err) {
+          err.textContent = 'Could not create board.';
+          err.classList.remove('d-none');
+        }
+      });
+    });
+  }
 })();
diff --git a/pygitweb/static/main.css b/pygitweb/static/main.css
index ed0b1ee..0c42ec9 100644
--- a/pygitweb/static/main.css
+++ b/pygitweb/static/main.css
@@ -1069,6 +1069,20 @@ pre {
 	padding-right: 1.75rem;
 }
 
+.board-card-assignee-compact-editable {
+	cursor: pointer;
+	border-radius: 0.25rem;
+
+	&:hover {
+		background-color: var(--pgw-bg-muted);
+	}
+}
+
+.board-card-assignee-compact-edit {
+	min-width: 8rem;
+	max-width: 100%;
+}
+
 .board-card-popout .board-card-task-id {
 	display: none;
 }
@@ -1122,7 +1136,9 @@ pre {
 	.board-card-description-edit,
 	.board-card-due-date-edit,
 	.board-card-status-edit,
-	.board-card-priority-edit {
+	.board-card-priority-edit,
+	.board-card-points-edit,
+	.board-card-assignee-edit {
 		min-width: 10rem;
 		width: 100%;
 	}
diff --git a/pygitweb/tasks.py b/pygitweb/tasks.py
index d411b84..1cc51b1 100644
--- a/pygitweb/tasks.py
+++ b/pygitweb/tasks.py
@@ -9,10 +9,11 @@ import os
 import re
 import time
 from typing import Annotated, Any
+from urllib.parse import quote
 
 import pygit2
 from fastapi import APIRouter, Depends, HTTPException, Query, Request
-from fastapi.responses import JSONResponse
+from fastapi.responses import HTMLResponse, JSONResponse
 
 from pygittools.tasks import (
 	ARCHIVE_BOARD_NAME,
@@ -36,13 +37,29 @@ from pygitweb.auth import (
 	require_permission,
 )
 from pygitweb.config import settings
-from pygitweb.dependencies import ValidatedReadableQueryProject
+from pygitweb.dependencies import ValidatedReadableQueryProject, filter_projects_by_read_access
 from pygitweb.gravatar import gravatar_url
 from pygitweb.permissions import Permission, PermissionPrincipal
+from pygitweb.projects import git_get_projects_list
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 
 # Well-known empty tree OID for boards/tasks when repo has no HEAD
 EMPTY_TREE_OID = pygit2.Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
 
+# Allowed sprint story point values (Fibonacci scale). Anything else is treated as unestimated.
+SPRINT_POINTS: tuple[int, ...] = (0, 1, 2, 3, 5, 8, 13, 21)
+
+
+def _parse_points(raw: Any) -> int | None:
+	"""Parse a story points value, keeping only values on the Fibonacci scale."""
+	if raw is None or raw == "":
+		return None
+	try:
+		value = int(raw)
+	except (TypeError, ValueError):
+		return None
+	return value if value in SPRINT_POINTS else None
+
 
 def _board_ref(name: str) -> str:
 	return f"{BOARD_REF_PREFIX}{name}"
@@ -132,12 +149,8 @@ def create_board_for_project(
 board_router = APIRouter(tags=["boards"])
 
 
-@board_router.get(
-	"/list",
-	response_class=JSONResponse,
-)
-def boards_list(project: ValidatedReadableQueryProject) -> JSONResponse:
-	"""List all boards for the project."""
+def list_project_boards(project: str) -> list[dict[str, Any]]:
+	"""Return [{name, description, task_count}, ...] for every board in a project."""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	refs = [
 		(ref_name, str(repo.references[ref_name].resolve().target))
@@ -156,7 +169,16 @@ def boards_list(project: ValidatedReadableQueryProject) -> JSONResponse:
 				})
 		except (ValueError, KeyError):
 			continue
-	return JSONResponse(content=boards)
+	return boards
+
+
+@board_router.get(
+	"/list",
+	response_class=JSONResponse,
+)
+def boards_list(project: ValidatedReadableQueryProject) -> JSONResponse:
+	"""List all boards for the project."""
+	return JSONResponse(content=list_project_boards(project))
 
 
 @board_router.post(
@@ -170,7 +192,7 @@ def boards_create(
 	description: str = Query("", description="Board description"),
 ) -> JSONResponse:
 	"""Create a new board."""
-	if not name or "/" in name or ".." in name:
+	if not name or not pygit2.reference_is_valid_name(_board_ref(name)):
 		raise HTTPException(status_code=400, detail="Invalid board name")
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	ref = _board_ref(name)
@@ -224,6 +246,7 @@ def _task_to_json(t: Task) -> dict[str, Any]:
 		"assignee": t.assignee,
 		"due_date": t.due_date.isoformat() if t.due_date else None,
 		"tags": getattr(t, "tags", "") or "",
+		"points": getattr(t, "points", None),
 		"created_at": t.created_at.isoformat() if t.created_at else None,
 		"updated_at": t.updated_at.isoformat() if t.updated_at else None,
 		"comments_count": len(getattr(t, "comments", [])),
@@ -264,11 +287,95 @@ def get_board_tasks_grouped(
 			"status": s,
 			"label": s.replace("_", " ").title(),
 			"tasks": by_status.get(s, []),
+			"points_total": sum(t.get("points") or 0 for t in by_status.get(s, [])),
 		}
 		for s in BOARD_STATUS_ORDER
 	]
 
 
+def get_all_project_tasks(project: str) -> list[dict[str, Any]]:
+	"""
+	Return every task across all boards in a project, each tagged with its board name.
+
+	Used by the team overview page, which needs to see who has what regardless of
+	which board a task currently sits on. Skips validation/auth (caller must validate project).
+	"""
+	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+	out: list[dict[str, Any]] = []
+	for ref_name in repo.references:
+		if not ref_name.startswith(BOARD_REF_PREFIX):
+			continue
+		board_name = ref_name.removeprefix(BOARD_REF_PREFIX)
+		b = get_board(repo, ref_name)
+		for oid_hex in getattr(b, "tasks", []) or []:
+			try:
+				t = _task_from_board_entry(repo, oid_hex, board=board_name)
+				if t:
+					d = _task_to_json(t)
+					d["board"] = board_name
+					out.append(d)
+			except (ValueError, KeyError, TypeError):
+				continue
+	return out
+
+
+@task_router.get("", response_class=HTMLResponse)
+def all_tasks_page(
+	token: Annotated[str | None, Depends(access_token_from_request)],
+	status: Annotated[str | None, Query()] = None,
+) -> HTMLResponse:
+	"""Flat list of every task across every project and board you can read."""
+	status_filter = (status or "").strip().upper() or None
+	projects = filter_projects_by_read_access(
+		git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
+		token,
+	)
+	rows: list[list[str]] = []
+	for pr in projects:
+		project = pr.get("path", "")
+		if not project:
+			continue
+		try:
+			tasks = get_all_project_tasks(project)
+		except Exception:
+			continue
+		for t in tasks:
+			task_status = t.get("status") or "TODO"
+			if status_filter and task_status != status_filter:
+				continue
+			project_url = f"/project/{quote(project, safe='/')}"
+			board_url = f"{project_url}/board/?b={quote(t['board'], safe='')}"
+			task_url = f"{board_url}&task={quote(t['ref'], safe='')}"
+			rows.append([
+				f"<a href='{task_url}'>{jinja_escape(t.get('title') or '(untitled)')}</a>",
+				f"<a href='{project_url}'>{jinja_escape(project)}</a>",
+				jinja_escape(t.get("board") or "") or "",
+				jinja_escape(task_status.replace("_", " ").title()) or "",
+				jinja_escape((t.get("priority") or "LOW").title()) or "",
+				jinja_escape(t.get("assignee") or "Unassigned") or "",
+				str(t.get("points") if t.get("points") is not None else ""),
+			])
+	table = env.get_template("table.html").render(
+		cols=["Task", "Project", "Board", "Status", "Priority", "Assignee", "Points"],
+		rows=rows,
+	)
+	status_options = "".join(
+		f'<option value="{s}"{" selected" if status_filter == s else ""}>{s.replace("_", " ").title()}</option>'
+		for s in BOARD_STATUS_ORDER
+	)
+	filter_form = (
+		'<form method="get" action="/tasks" class="d-flex align-items-center gap-2 mb-3">'
+		'<label class="form-label mb-0" for="tasks-status-filter">Status</label>'
+		'<select id="tasks-status-filter" name="status" class="form-select form-select-sm" '
+		'style="width: auto;" onchange="this.form.submit()">'
+		f'<option value=""{" selected" if not status_filter else ""}>All</option>{status_options}'
+		"</select></form>"
+	)
+	body = table if rows else '<p class="text-muted">No tasks found.</p>'
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Tasks", site_name=settings.SITE_NAME)
+	return HTMLResponse(f"{pre}<h1>Tasks</h1>{filter_form}{body}{POSTAMBLE}")
+
+
 @task_router.get(
 	"/list",
 	response_class=JSONResponse,
@@ -310,6 +417,7 @@ def task_create(
 	assignee: str = Query("", description="Assignee"),
 	due_date: str = Query("", description="Due date ISO"),
 	tags: str = Query("", description="Task tags (KEY=VALUE list)"),
+	points: str = Query("", description="Sprint story points (Fibonacci: 0,1,2,3,5,8,13,21)"),
 ) -> JSONResponse:
 	"""Create a new task on a board."""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
@@ -341,6 +449,7 @@ def task_create(
 		assignee=assignee or None,
 		due_date=due,
 		tags=tags or "",
+		points=_parse_points(points),
 	)
 	oid = task.write(repo)
 	b.tasks = getattr(b, "tasks", []) or []
@@ -382,6 +491,8 @@ async def task_update(
 		t.priority = Task.Priority(body["priority"])
 	if "assignee" in body:
 		t.assignee = body["assignee"] or None
+	if "points" in body:
+		t.points = _parse_points(body["points"])
 	if "due_date" in body:
 		try:
 			from datetime import datetime
@@ -448,6 +559,47 @@ def task_archive(
 	)
 
 
+@task_router.post(
+	"/move",
+	response_class=JSONResponse,
+	dependencies=[Depends(require_permission(Permission.TASKS, project_from_query=True))],
+)
+def task_move(
+	project: ValidatedReadableQueryProject,
+	board: str = Query(..., description="Source board name"),
+	task_ref: str = Query(..., alias="task", description="Task ref"),
+	target: str = Query(..., description="Target board name (e.g. a sprint board)"),
+) -> JSONResponse:
+	"""Move a task onto another board (e.g. pulling it into a sprint board)."""
+	target_ref = _board_ref(target)
+	if not target or not pygit2.reference_is_valid_name(target_ref):
+		raise HTTPException(status_code=400, detail="Invalid board name")
+	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+	ref = task_ref if task_ref.startswith("refs/") else _task_ref(task_ref)
+	if ref not in repo.references:
+		raise HTTPException(status_code=404, detail="Task not found")
+	source_ref = _board_ref(board)
+	if target_ref not in repo.references:
+		raise HTTPException(status_code=404, detail="Target board not found")
+	tb = get_board(repo, target_ref)
+	if not tb:
+		raise HTTPException(status_code=404, detail="Target board not found")
+	if source_ref == target_ref:
+		return JSONResponse(content={"message": "Task already on board", "board": target})
+	oid_hex = str(repo.references[ref].resolve().target)
+	tb.tasks = getattr(tb, "tasks", []) or []
+	if oid_hex not in tb.tasks:
+		tb.tasks.append(oid_hex)
+		tb.update_message()
+		tb.write(repo)
+	sb = get_board(repo, source_ref) if source_ref in repo.references else None
+	if sb and getattr(sb, "tasks", None) and oid_hex in sb.tasks:
+		sb.tasks = [x for x in sb.tasks if x != oid_hex]
+		sb.update_message()
+		sb.write(repo)
+	return JSONResponse(content={"message": "Task moved", "board": target})
+
+
 # ---------- Comment Routes ----------
 
 comment_router = APIRouter(tags=["comments"])
diff --git a/pygitweb/tasks_routes_test.py b/pygitweb/tasks_routes_test.py
index d6577a7..a3d14d9 100644
--- a/pygitweb/tasks_routes_test.py
+++ b/pygitweb/tasks_routes_test.py
@@ -245,6 +245,122 @@ class TestTaskRoutes:
 			assert updated["assignee"] == "bob"
 			assert updated["due_date"] == "2025-02-03T04:05:06+00:00"
 
+		def test_task_create_and_update_points_snap_to_fibonacci(
+			self, mutable_env: dict[str, str | TestClient]
+		) -> None:
+			client = mutable_env["client"]
+			assert isinstance(client, TestClient)
+			create_resp = client.post(
+				"/tasks/create",
+				params={
+					"project": mutable_env["project"],
+					"board": mutable_env["board"],
+					"title": "Pointed task",
+					"points": "5",
+				},
+			)
+			assert create_resp.status_code == 200
+			task_ref = create_resp.json()["ref"]
+			list_resp = client.get(
+				"/tasks/list",
+				params={"project": mutable_env["project"], "board": mutable_env["board"]},
+			)
+			by_ref = {entry["ref"]: entry for entry in list_resp.json()}
+			assert by_ref[task_ref]["points"] == 5
+
+			update_resp = client.post(
+				"/tasks/update",
+				params={"project": mutable_env["project"], "board": mutable_env["board"], "task": task_ref},
+				json={"points": 4},
+			)
+			assert update_resp.status_code == 200
+			assert update_resp.json()["points"] is None
+
+			clear_resp = client.post(
+				"/tasks/update",
+				params={"project": mutable_env["project"], "board": mutable_env["board"], "task": task_ref},
+				json={"points": None},
+			)
+			assert clear_resp.status_code == 200
+			assert clear_resp.json()["points"] is None
+
+		def test_task_move_transfers_task_between_boards(self, mutable_env: dict[str, str | TestClient]) -> None:
+			client = mutable_env["client"]
+			assert isinstance(client, TestClient)
+			create_resp = client.post(
+				"/board/create",
+				params={"project": mutable_env["project"], "name": "Sprint-1"},
+			)
+			assert create_resp.status_code == 200
+			task_ref = client.post(
+				"/tasks/create",
+				params={
+					"project": mutable_env["project"],
+					"board": mutable_env["board"],
+					"title": "Movable task",
+				},
+			).json()["ref"]
+
+			move_resp = client.post(
+				"/tasks/move",
+				params={
+					"project": mutable_env["project"],
+					"board": mutable_env["board"],
+					"task": task_ref,
+					"target": "Sprint-1",
+				},
+			)
+			assert move_resp.status_code == 200
+			assert move_resp.json() == {"message": "Task moved", "board": "Sprint-1"}
+
+			source_list = client.get(
+				"/tasks/list",
+				params={"project": mutable_env["project"], "board": mutable_env["board"]},
+			).json()
+			assert task_ref not in [t["ref"] for t in source_list]
+
+			target_list = client.get(
+				"/tasks/list",
+				params={"project": mutable_env["project"], "board": "Sprint-1"},
+			).json()
+			assert task_ref in [t["ref"] for t in target_list]
+
+			client.post(
+				"/board/delete",
+				params={"project": mutable_env["project"], "name": "Sprint-1"},
+			)
+
+		def test_board_create_rejects_invalid_ref_name(self, mutable_env: dict[str, str | TestClient]) -> None:
+			client = mutable_env["client"]
+			assert isinstance(client, TestClient)
+			resp = client.post(
+				"/board/create",
+				params={"project": mutable_env["project"], "name": "Sprint 1"},
+			)
+			assert resp.status_code == 400
+
+		def test_task_move_to_missing_board_returns_404(self, mutable_env: dict[str, str | TestClient]) -> None:
+			client = mutable_env["client"]
+			assert isinstance(client, TestClient)
+			task_ref = client.post(
+				"/tasks/create",
+				params={
+					"project": mutable_env["project"],
+					"board": mutable_env["board"],
+					"title": "Orphan candidate",
+				},
+			).json()["ref"]
+			move_resp = client.post(
+				"/tasks/move",
+				params={
+					"project": mutable_env["project"],
+					"board": mutable_env["board"],
+					"task": task_ref,
+					"target": "does-not-exist",
+				},
+			)
+			assert move_resp.status_code == 404
+
 		def test_task_status_update_moves_oid_to_end_of_board_list(
 			self, mutable_env: dict[str, str | TestClient]
 		) -> None:
diff --git a/pygitweb/team.py b/pygitweb/team.py
new file mode 100644
index 0000000..0c0aa7f
--- /dev/null
+++ b/pygitweb/team.py
@@ -0,0 +1,178 @@
+"""
+Team overview: what everyone is working on across projects, plus per-user weekly
+sprint point capacity. There is no database; capacity is persisted via capacity_config.py.
+"""
+
+from __future__ import annotations
+
+import re
+from collections import defaultdict
+from typing import Annotated, Any
+from urllib.parse import quote
+
+from fastapi import APIRouter, Depends, Form, HTTPException, Query
+from fastapi.responses import HTMLResponse, RedirectResponse
+
+from pygitweb.auth import (
+	access_token_from_request,
+	ensure_active_user_if_auth_enabled,
+	has_permission,
+	principal_from_session,
+)
+from pygitweb.capacity_config import current_iso_week, get_user_week_capacity, set_user_week_capacity
+from pygitweb.config import settings
+from pygitweb.dependencies import filter_projects_by_read_access
+from pygitweb.permissions import Permission
+from pygitweb.projects import git_get_projects_list
+from pygitweb.tasks import get_all_project_tasks
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
+
+team_router = APIRouter(tags=["team"])
+
+# Tasks in these statuses count as a person's current workload.
+OPEN_STATUSES = frozenset({"TODO", "IN_PROGRESS", "IN_REVIEW"})
+
+_ISO_WEEK_RE = re.compile(r"^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$")
+
+
+def _gather_open_tasks_by_assignee(token: str | None) -> dict[str, list[dict[str, Any]]]:
+	projects = filter_projects_by_read_access(
+		git_get_projects_list(paranoid=settings.STRICT_EXPORT, export_ok=settings.EXPORT_OK),
+		token,
+	)
+	by_assignee: dict[str, list[dict[str, Any]]] = defaultdict(list)
+	for pr in projects:
+		project = pr.get("path", "")
+		if not project:
+			continue
+		try:
+			tasks = get_all_project_tasks(project)
+		except Exception:
+			continue
+		for t in tasks:
+			if t.get("status") not in OPEN_STATUSES:
+				continue
+			assignee = (t.get("assignee") or "").strip() or "Unassigned"
+			t["project"] = project
+			by_assignee[assignee].append(t)
+	return by_assignee
+
+
+def _ensure_can_set_capacity(token: str | None, target_user: str) -> None:
+	if not settings.AUTH:
+		return
+	ensure_active_user_if_auth_enabled(token)
+	principal = principal_from_session(token)
+	if principal is not None and principal.username == target_user:
+		return
+	if has_permission(Permission.SETTINGS, token=token):
+		return
+	raise HTTPException(status_code=403, detail="Insufficient permissions")
+
+
+@team_router.get("", response_class=HTMLResponse)
+def team_page(
+	token: Annotated[str | None, Depends(access_token_from_request)],
+	week: Annotated[str | None, Query()] = None,
+) -> HTMLResponse:
+	"""Team overview: open tasks grouped by assignee, with weekly point capacity vs. load."""
+	iso_week = (week or current_iso_week()).strip()
+	if not _ISO_WEEK_RE.match(iso_week):
+		iso_week = current_iso_week()
+	by_assignee = _gather_open_tasks_by_assignee(token)
+
+	principal = principal_from_session(token)
+	current_username = principal.username if principal else None
+	can_edit_others = (not settings.AUTH) or has_permission(Permission.SETTINGS, token=token)
+
+	groups: list[dict[str, Any]] = []
+	for assignee in sorted(by_assignee, key=lambda a: (a == "Unassigned", a.lower())):
+		tasks = sorted(by_assignee[assignee], key=lambda t: (t.get("priority") or "", t.get("title") or ""))
+		points_total = sum(t.get("points") or 0 for t in tasks)
+		is_unassigned = assignee == "Unassigned"
+		capacity = None if is_unassigned else get_user_week_capacity(assignee, iso_week)
+
+		rows: list[list[str]] = []
+		for t in tasks:
+			project_enc = quote(t["project"], safe="/")
+			board_enc = quote(t["board"], safe="")
+			task_enc = quote(t["ref"], safe="")
+			project_url = f"/project/{project_enc}"
+			task_url = f"{project_url}/board/?b={board_enc}&task={task_enc}"
+			rows.append([
+				f"<a href='{task_url}'>{jinja_escape(t.get('title') or '(untitled)')}</a>",
+				f"<a href='{project_url}'>{jinja_escape(t['project'])}</a>",
+				jinja_escape(t.get("board") or "") or "",
+				jinja_escape((t.get("status") or "").replace("_", " ").title()) or "",
+				jinja_escape((t.get("priority") or "").title()) or "",
+				str(t.get("points") if t.get("points") is not None else ""),
+			])
+		table_html = env.get_template("table.html").render(
+			cols=["Task", "Project", "Board", "Status", "Priority", "Points"],
+			rows=rows,
+		)
+		can_edit = (not is_unassigned) and (can_edit_others or assignee == current_username)
+		capacity_url = f"/team/capacity?user={quote(assignee, safe='')}&week={quote(iso_week, safe='')}"
+		groups.append({
+			"assignee": jinja_escape(assignee),
+			"points_total": points_total,
+			"capacity": capacity,
+			"over_capacity": capacity is not None and points_total > capacity,
+			"can_edit": can_edit,
+			"capacity_url": capacity_url,
+			"table_html": table_html,
+		})
+
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Team", site_name=settings.SITE_NAME)
+	body = env.get_template("team.html").render(iso_week=iso_week, groups=groups)
+	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+@team_router.get("/capacity", response_class=HTMLResponse)
+def team_capacity_form(
+	token: Annotated[str | None, Depends(access_token_from_request)],
+	user: Annotated[str | None, Query()] = None,
+	week: Annotated[str | None, Query()] = None,
+) -> HTMLResponse:
+	"""Form to set a user's sprint point capacity for a given ISO week."""
+	ensure_active_user_if_auth_enabled(token)
+	principal = principal_from_session(token)
+	target_user = (user or (principal.username if principal else "")).strip()
+	if not target_user:
+		raise HTTPException(status_code=400, detail="user is required")
+	iso_week = (week or current_iso_week()).strip()
+	if not _ISO_WEEK_RE.match(iso_week):
+		iso_week = current_iso_week()
+	current_points = get_user_week_capacity(target_user, iso_week)
+	pre = PREAMBLE.render(title=f"{settings.SITE_NAME} - Set capacity", site_name=settings.SITE_NAME)
+	body = env.get_template("team_capacity_form.html").render(
+		target_user=jinja_escape(target_user),
+		iso_week=jinja_escape(iso_week),
+		current_points=current_points,
+	)
+	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+@team_router.post("/capacity/submit", response_class=HTMLResponse)
+def team_capacity_submit(
+	token: Annotated[str | None, Depends(access_token_from_request)],
+	user: Annotated[str, Form()],
+	week: Annotated[str, Form()],
+	points: Annotated[str, Form()],
+) -> RedirectResponse:
+	"""Save a user's sprint point capacity for a given ISO week."""
+	target_user = user.strip()
+	iso_week = week.strip()
+	if not target_user:
+		raise HTTPException(status_code=400, detail="user is required")
+	if not _ISO_WEEK_RE.match(iso_week):
+		raise HTTPException(status_code=400, detail="week must be in ISO format YYYY-Www")
+	_ensure_can_set_capacity(token, target_user)
+	try:
+		points_val = int(points)
+	except ValueError as exc:
+		raise HTTPException(status_code=400, detail="points must be an integer") from exc
+	if points_val < 0:
+		raise HTTPException(status_code=400, detail="points must be non-negative")
+	set_user_week_capacity(target_user, iso_week, points_val)
+	return RedirectResponse(url=f"/team?week={quote(iso_week, safe='')}", status_code=303)
diff --git a/pygitweb/team_routes_test.py b/pygitweb/team_routes_test.py
new file mode 100644
index 0000000..8edd55f
--- /dev/null
+++ b/pygitweb/team_routes_test.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import patch
+
+import pygit2
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from pygittools.tasks import BOARD_REF_PREFIX, TASK_REF_PREFIX, Board, Task
+from pygitweb.config import settings
+from pygitweb.tasks import EMPTY_TREE_OID
+from pygitweb.team import team_router
+
+
+def _build_client() -> TestClient:
+	app = FastAPI()
+	app.include_router(team_router, prefix="/team")
+	return TestClient(app)
+
+
+class TestTeamRoutes:
+	@pytest.fixture(scope="class")
+	def seeded_env(
+		self, tmp_path_factory: pytest.TempPathFactory
+	) -> Generator[dict[str, str | TestClient], None, None]:
+		root: Path = tmp_path_factory.mktemp("team-routes")
+		repo_dir: Path = root / "demo"
+		pygit2.init_repository(str(repo_dir), bare=False)
+		repo = pygit2.Repository(str(repo_dir))
+		project = "demo"
+
+		board_ref = f"{BOARD_REF_PREFIX}Tasks"
+		board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Main board")
+
+		alice_task = Task(
+			EMPTY_TREE_OID,
+			f"{TASK_REF_PREFIX}task_alice",
+			tagger="",
+			title="Fix the bug",
+			status=Task.Status.IN_PROGRESS,
+			priority=Task.Priority.HIGH,
+			assignee="alice",
+			points=5,
+		)
+		alice_oid = alice_task.write(repo)
+
+		done_task = Task(
+			EMPTY_TREE_OID,
+			f"{TASK_REF_PREFIX}task_done",
+			tagger="",
+			title="Already shipped",
+			status=Task.Status.DONE,
+			priority=Task.Priority.LOW,
+			assignee="alice",
+			points=13,
+		)
+		done_oid = done_task.write(repo)
+
+		unassigned_task = Task(
+			EMPTY_TREE_OID,
+			f"{TASK_REF_PREFIX}task_open",
+			tagger="",
+			title="Needs an owner",
+			status=Task.Status.TODO,
+			priority=Task.Priority.MEDIUM,
+			points=3,
+		)
+		unassigned_oid = unassigned_task.write(repo)
+
+		board.tasks = [str(alice_oid), str(done_oid), str(unassigned_oid)]
+		board.update_message()
+		board.write(repo)
+
+		capacity_path = root / "capacity.json"
+
+		with (
+			patch.object(settings, "PROJECTROOT", str(root)),
+			patch.object(settings, "PROJECTS_LIST", str(root)),
+			patch.object(settings, "STRICT_EXPORT", False),
+			patch.object(settings, "EXPORT_OK", ""),
+			patch.object(settings, "AUTH", False),
+			patch("pygitweb.capacity_config.capacity_config_path", return_value=capacity_path),
+		):
+			client = _build_client()
+			yield {"client": client, "project": project}
+
+	def test_team_page_groups_open_tasks_by_assignee(self, seeded_env: dict[str, str | TestClient]) -> None:
+		client = seeded_env["client"]
+		assert isinstance(client, TestClient)
+		resp = client.get("/team")
+		assert resp.status_code == 200
+		body = resp.text
+		assert "alice" in body
+		assert "Fix the bug" in body
+		# DONE tasks are not part of current workload.
+		assert "Already shipped" not in body
+		# Unassigned open tasks show up under their own bucket.
+		assert "Unassigned" in body
+		assert "Needs an owner" in body
+
+	def test_capacity_form_shows_default(self, seeded_env: dict[str, str | TestClient]) -> None:
+		client = seeded_env["client"]
+		assert isinstance(client, TestClient)
+		resp = client.get("/team/capacity", params={"user": "alice", "week": "2026-W10"})
+		assert resp.status_code == 200
+		assert "alice" in resp.text
+
+	def test_capacity_submit_persists_and_reflects_on_team_page(self, seeded_env: dict[str, str | TestClient]) -> None:
+		client = seeded_env["client"]
+		assert isinstance(client, TestClient)
+		resp = client.post(
+			"/team/capacity/submit",
+			data={"user": "alice", "week": "2026-W10", "points": "8"},
+			follow_redirects=False,
+		)
+		assert resp.status_code == 303
+		assert resp.headers["location"] == "/team?week=2026-W10"
+
+		team_resp = client.get("/team", params={"week": "2026-W10"})
+		assert team_resp.status_code == 200
+		# alice has 5 open points against an 8-point capacity for this week.
+		assert "5 / 8 pts this week" in team_resp.text
+
+	def test_capacity_submit_rejects_invalid_week(self, seeded_env: dict[str, str | TestClient]) -> None:
+		client = seeded_env["client"]
+		assert isinstance(client, TestClient)
+		resp = client.post(
+			"/team/capacity/submit",
+			data={"user": "alice", "week": "not-a-week", "points": "8"},
+		)
+		assert resp.status_code == 400
+
+	def test_capacity_submit_rejects_negative_points(self, seeded_env: dict[str, str | TestClient]) -> None:
+		client = seeded_env["client"]
+		assert isinstance(client, TestClient)
+		resp = client.post(
+			"/team/capacity/submit",
+			data={"user": "alice", "week": "2026-W11", "points": "-1"},
+		)
+		assert resp.status_code == 400
diff --git a/pygitweb/templates/board.html b/pygitweb/templates/board.html
index 31adcf3..dd19341 100644
--- a/pygitweb/templates/board.html
+++ b/pygitweb/templates/board.html
@@ -1,4 +1,4 @@
-{# Board view: columns (dropzones) and task cards. Expects: project, project_url, board_name, board_url, columns [ { status, label, tasks } ] #}
+{# Board view: columns (dropzones) and task cards. Expects: project, project_url, board_name, board_url, columns [ { status, label, tasks, points_total } ] #}
 
 <template id="board-card-template">
   {% include "board_card.html" without context %}
@@ -14,6 +14,19 @@
   <span class="text-muted">board</span>
 </h1>
 
+<div class="d-flex flex-wrap align-items-center justify-content-end gap-2 mb-3 board-toolbar">
+  <div class="d-flex align-items-center gap-2">
+    <button type="button" class="btn btn-sm btn-outline-secondary" id="board-new-board-btn">+ Board</button>
+    <button type="button" class="btn btn-sm btn-outline-primary" id="board-new-sprint-btn">+ Sprint</button>
+  </div>
+</div>
+<form id="board-new-form" class="d-none mb-3 d-flex flex-wrap gap-2 align-items-center" autocomplete="off">
+  <input type="text" id="board-new-name" class="form-control form-control-sm" style="max-width: 260px;" placeholder="Board name" required>
+  <button type="submit" class="btn btn-sm btn-primary">Create</button>
+  <button type="button" id="board-new-cancel" class="btn btn-sm btn-link">Cancel</button>
+  <span class="small text-danger d-none" id="board-new-error"></span>
+</form>
+
 <div class="board-columns d-flex gap-3 overflow-auto pb-2" style="min-height: 70vh;" data-project="{{ project }}" data-board="{{ board_name }}">
   {% for col in columns %}
   <div class="board-column flex-shrink-0 rounded border p-2 dropzone"
@@ -21,10 +34,13 @@
        style="min-width: 280px; width: 280px;">
     <div class="d-flex align-items-center justify-content-between mb-2">
       <h3 class="h6 mb-0 text-muted">{{ col.label }}</h3>
-      <button type="button" class="board-column-header-control board-column-add-card" aria-label="Add task">
-        <span class="board-column-add-label">(+ Add Task)</span>
-        <span class="board-column-task-count">{{ col.tasks | length }}</span>
-      </button>
+      <div class="d-flex align-items-center gap-1">
+        <span class="badge bg-light text-dark border board-column-points-total" title="Sprint points in this column">{{ col.points_total | default(0) }} pts</span>
+        <button type="button" class="board-column-header-control board-column-add-card" aria-label="Add task">
+          <span class="board-column-add-label">(+ Add Task)</span>
+          <span class="board-column-task-count">{{ col.tasks | length }}</span>
+        </button>
+      </div>
     </div>
     <div class="board-column-cards d-flex flex-column">
       {% for task in col.tasks %}
diff --git a/pygitweb/templates/board_card.html b/pygitweb/templates/board_card.html
index bd0e6b0..971d8d0 100644
--- a/pygitweb/templates/board_card.html
+++ b/pygitweb/templates/board_card.html
@@ -11,15 +11,17 @@
         {% else %}
         <span class="badge badge-outline text-secondary">LOW</span>
         {% endif %}
+        <span class="badge badge-outline text-info board-card-points-badge{% if task.points is none %} d-none{% endif %}">{% if task.points is not none %}{{ task.points }} pt{{ 's' if task.points != 1 else '' }}{% endif %}</span>
         {% if task.assignee %}
-        <span class="text-truncate">{{ task.assignee }}</span>
+        <span class="text-truncate board-card-assignee-compact">{{ task.assignee }}</span>
         {% else %}
-        <span class="text-truncate">None</span>
+        <span class="text-truncate board-card-assignee-compact">None</span>
         {% endif %}
         <span class="ms-auto board-card-comments-count-meta">{{ task.comments_count | default(0) }} comment{{ 's' if (task.comments_count | default(0)) != 1 else '' }}</span>
       {% else %}
         <span class="badge badge-outline text-secondary">LOW</span>
-        <span class="text-truncate">None</span>
+        <span class="badge badge-outline text-info board-card-points-badge d-none"></span>
+        <span class="text-truncate board-card-assignee-compact">None</span>
         <span class="ms-auto">0 comments</span>
       {% endif %}
     </div>
@@ -49,8 +51,12 @@
         <div class="board-card-priority board-card-editable text-muted mt-1">{% if task and task.priority %}{{ task.priority }}{% else %}LOW{% endif %}</div>
       </div>
       <div class="mb-2">
+        <strong class="text-muted">Points</strong>
+        <div class="board-card-points board-card-editable text-muted mt-1">{% if task and task.points is not none %}{{ task.points }}{% else %}–{% endif %}</div>
+      </div>
+      <div class="mb-2">
         <strong class="text-muted">Assignee</strong>
-        <div class="board-card-assignee text-muted mt-1">{% if task and task.assignee %}{{ task.assignee }}{% else %}None{% endif %}</div>
+        <div class="board-card-assignee board-card-editable text-muted mt-1">{% if task and task.assignee %}{{ task.assignee }}{% else %}None{% endif %}</div>
       </div>
       <div class="mb-2">
         <details class="board-card-tags-details" open>
@@ -64,6 +70,14 @@
           </div>
         </details>
       </div>
+      <div class="mb-2">
+        <strong class="text-muted">Move to board</strong>
+        <div class="mt-1">
+          <select class="form-select form-select-sm board-card-move-select">
+            <option value="">Move to board…</option>
+          </select>
+        </div>
+      </div>
       <div class="board-card-actions d-flex gap-2 mb-2">
         <button type="button" class="btn btn-sm btn-outline-secondary board-card-archive-btn">Archive</button>
         <button type="button" class="btn btn-sm btn-outline-danger board-card-delete-btn">Delete</button>
diff --git a/pygitweb/templates/preamble.html b/pygitweb/templates/preamble.html
index 919909d..7f06406 100644
--- a/pygitweb/templates/preamble.html
+++ b/pygitweb/templates/preamble.html
@@ -42,19 +42,31 @@
             </a>
           </li>
           <li class="nav-item">
+            <a class="nav-link" href="/boards">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-kanban"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 1a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1z" /><path d="M9 8v7" /><path d="M15 8v4" /></svg></span>
+              <span class="nav-link-title">Boards</span>
+            </a>
+          </li>
+          <li class="nav-item">
             <a class="nav-link" href="/activity">
               <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-activity"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12h4l3 8l4 -16l3 8h4" /></svg></span>
               <span class="nav-link-title">Activity</span>
             </a>
           </li>
           <li class="nav-item">
-            <a class="nav-link" href="#">
+            <a class="nav-link" href="/team">
+              <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-users"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" /><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /><path d="M21 21v-2a4 4 0 0 0 -3 -3.85" /></svg></span>
+              <span class="nav-link-title">Team</span>
+            </a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="/activity">
               <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-messages"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M21 14l-3 -3h-7a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1h9a1 1 0 0 1 1 1v10" /><path d="M14 15v2a1 1 0 0 1 -1 1h-7l-3 3v-10a1 1 0 0 1 1 -1h2" /></svg></span>
               <span class="nav-link-title">Discussions</span>
             </a>
           </li>
           <li class="nav-item">
-            <a class="nav-link" href="#">
+            <a class="nav-link" href="/tasks">
               <span class="nav-link-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-checklist" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 11l3 3l8 -8" /><path d="M20 12v6a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h9" /></svg></span>
               <span class="nav-link-title">Tasks</span>
             </a>
diff --git a/pygitweb/templates/team.html b/pygitweb/templates/team.html
new file mode 100644
index 0000000..a49c806
--- /dev/null
+++ b/pygitweb/templates/team.html
@@ -0,0 +1,35 @@
+{# Team overview: open tasks across projects, grouped by assignee. Expects: iso_week, groups [ { assignee, points_total, capacity, over_capacity, can_edit, capacity_url, table_html } ] #}
+
+<h1 class="page-title">Team</h1>
+<p class="text-muted">What everyone is currently working on, across every project you can read.</p>
+
+<form method="get" action="/team" class="d-flex align-items-center gap-2 mb-3">
+  <label class="form-label mb-0" for="team-week">Week</label>
+  <input type="text" id="team-week" class="form-control form-control-sm" style="max-width: 140px;" name="week" value="{{ iso_week }}" placeholder="YYYY-Www">
+  <button type="submit" class="btn btn-sm btn-outline-secondary">Go</button>
+</form>
+
+{% if not groups %}
+<p class="text-muted">No open tasks found.</p>
+{% endif %}
+
+{% for group in groups %}
+<div class="card mb-3">
+  <div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
+    <h3 class="card-title mb-0">{{ group.assignee }}</h3>
+    <div class="d-flex align-items-center gap-2">
+      {% if group.capacity is not none %}
+      <span class="badge {{ 'bg-danger' if group.over_capacity else 'bg-success' }}">{{ group.points_total }} / {{ group.capacity }} pts this week</span>
+      {% else %}
+      <span class="badge bg-secondary">{{ group.points_total }} pts</span>
+      {% endif %}
+      {% if group.can_edit %}
+      <a class="small" href="{{ group.capacity_url }}">set capacity</a>
+      {% endif %}
+    </div>
+  </div>
+  <div class="card-body p-0">
+    {{ group.table_html | safe }}
+  </div>
+</div>
+{% endfor %}
diff --git a/pygitweb/templates/team_capacity_form.html b/pygitweb/templates/team_capacity_form.html
new file mode 100644
index 0000000..c19f262
--- /dev/null
+++ b/pygitweb/templates/team_capacity_form.html
@@ -0,0 +1,25 @@
+{# Set a user's sprint point capacity for an ISO week. Expects: target_user, iso_week, current_points #}
+
+<h1 class="page-title">Set weekly capacity</h1>
+<div class="card" style="max-width: 480px;">
+  <div class="card-body">
+    <form action="/team/capacity/submit" method="post">
+      <div class="mb-3">
+        <label class="form-label" for="capacity-user">User</label>
+        <input type="text" class="form-control" id="capacity-user" name="user" value="{{ target_user }}" required>
+      </div>
+      <div class="mb-3">
+        <label class="form-label" for="capacity-week">Week (ISO, e.g. 2026-W38)</label>
+        <input type="text" class="form-control" id="capacity-week" name="week" value="{{ iso_week }}" pattern="\d{4}-W\d{2}" required>
+      </div>
+      <div class="mb-3">
+        <label class="form-label" for="capacity-points">Sprint point capacity</label>
+        <input type="number" min="0" step="1" class="form-control" id="capacity-points" name="points" value="{{ current_points }}" required>
+      </div>
+      <div class="form-footer">
+        <button type="submit" class="btn btn-primary">Save</button>
+        <a href="/team" class="btn btn-ghost-secondary">Cancel</a>
+      </div>
+    </form>
+  </div>
+</div>
