feat(stats): optimize dashboard data loading with internal ui endpoints

This commit is contained in:
Alexander Kalinovsky
2026-04-01 19:05:57 +03:00
parent 19d659df6b
commit 9b9c7b5575
11 changed files with 552 additions and 140 deletions

29
CHANGELOG.md Normal file
View File

@@ -0,0 +1,29 @@
# Changelog
## Unreleased
### Changed
- Optimized the HTML dashboard data flow by splitting board loading and task detail loading.
- Kept the public `/api/tasks/*` API unchanged.
- Added internal `/ui_data/*` routes for lightweight board data and on-demand task details.
- Updated the `/stats` page to load a lightweight board model first and fetch full task details only for the selected task.
- Hid internal UI routes from Swagger / OpenAPI documentation.
- Updated tests and project documentation to match the new dashboard behavior.
### Why
- The previous dashboard rendered the full task payload for every card directly into the HTML.
- This caused unnecessary data transfer and made the page size grow linearly with the number of tasks.
- The change reduces the initial payload and loads detailed data only when it is actually needed.
### Effect
- The `/stats` HTML response became nearly constant in size instead of growing with the full board.
- Embedded per-card task payloads were removed from the DOM.
- The dashboard now transfers less data on initial load and keeps full task details as a separate targeted request.
- Measured impact on a 1000-task dataset:
- `/stats` HTML reduced from `632,377 B` to `11,413 B`
- embedded task payload reduced from `250,800 B` to `0 B`
- full board data moved into a lightweight JSON response (`162,656 B`)
- selected task details are loaded separately in a small response (`179 B`)

29
PROMPTS.md Normal file
View File

@@ -0,0 +1,29 @@
# Prompts
## Prompt 1: Analize HTML dashboard
Проанализируй функционал html-дашборда на предмет оптимизации количества загружаемых данных. Предложи варианты оптимизации, если необходимо. Только опиши, пока не трогай код
## Prompt 2: Optimization plan
Предложи план оптимизации загрузки только необходимых данных и динамической подгрузки деталей таска. Не трогай публичные контракты, вспомогательные api вынеси в отдельный роут /ui_data без публичной swagger документации. зафиксируй текущие метрики для оценки эффективности изменений. Пока только план без модификации кода
## Prompt 3: Plan detalization
Пока обойдемся только загрузкой легкой списочной модели и отдельной загрузкой деталей задачи
## Prompt 4: Implementation plan
подготовь план применения
## Prompt 5: Implementation
примени изменения
## Prompt 6: README update
обнови README.md в соответсвии с примененными изменениями
## Prompt 7: Changelog update
Сгенерируй CHANGELOG.md, что и зачем было изменено, какой эффект оказало, кратко без подробностей реализации на английском

View File

@@ -32,6 +32,7 @@ The service exposes a clean HTTP API, provides workflow metrics, persists state
- CSV export with injection protection
- Persistent storage (`data/tasks.json`)
- HTML dashboard (`/stats`)
- Lightweight dashboard data loading via internal `/ui_data/*` routes
- Atomic file writes with corruption recovery
---
@@ -194,6 +195,8 @@ HTML page:
- Top block:
- Selected task details:
- Title
- Status
- Created datetime
- Start datetime
- Done datetime
- Cycle time
@@ -204,6 +207,53 @@ HTML page:
- In Progress
- Done
Implementation notes:
- `/stats` returns a lightweight HTML shell
- Task lists are loaded separately from an internal UI endpoint
- Selected task details are loaded on demand when a card is selected
- Full task payloads are not embedded into the HTML for every card
### Internal UI Data Routes
These routes are used only by the HTML dashboard and are intentionally hidden from Swagger / OpenAPI.
#### Board data
```
GET /ui_data/stats/board
```
Returns lightweight task list items grouped by status:
- `backlog_tasks`
- `in_progress_tasks`
- `done_tasks`
Each list item contains only:
- `id`
- `title`
- `status`
- `display_date_label`
- `display_date_value`
#### Task details
```
GET /ui_data/tasks/{id}
```
Returns the full details needed for the selected-task panel:
- `id`
- `title`
- `status`
- `created_at`
- `started_at`
- `done_at`
- `cycle_time`
---
## Error Format

View File

@@ -140,3 +140,33 @@ class TaskExportRowDTO(BaseModel):
created_at: datetime
started_at: datetime | None = None
done_at: datetime | None = None
class TaskBoardItemDTO(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
id: UUID
title: str = Field(..., min_length=1, max_length=100)
status: TaskStatus
display_date_label: str = Field(..., min_length=1, max_length=20)
display_date_value: datetime | None = None
class TaskBoardDTO(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
backlog_tasks: list[TaskBoardItemDTO]
in_progress_tasks: list[TaskBoardItemDTO]
done_tasks: list[TaskBoardItemDTO]
class TaskDetailsDTO(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
id: UUID
title: str = Field(..., min_length=1, max_length=100)
status: TaskStatus
created_at: datetime
started_at: datetime | None = None
done_at: datetime | None = None
cycle_time: str

View File

@@ -2,79 +2,23 @@ from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.api.tasks import get_task_repository
from app.storage import JsonFileTaskRepository, StoredTask
router = APIRouter(tags=["stats"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
def get_task_status(task: StoredTask) -> str:
if task.done_at is not None:
return "done"
if task.started_at is not None:
return "in_progress"
return "backlog"
def format_dt(value: object) -> str:
if value is None:
return ""
return str(value)
def format_duration(task: StoredTask) -> str:
if task.started_at is None or task.done_at is None:
return ""
delta = task.done_at - task.started_at
total_seconds = int(delta.total_seconds())
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours}h {minutes}m {seconds}s"
def serialize_task(task: StoredTask) -> dict[str, str]:
return {
"id": str(task.id),
"title": task.title,
"status": get_task_status(task),
"created_at": task.created_at.isoformat(),
"started_at": task.started_at.isoformat() if task.started_at is not None else "",
"done_at": task.done_at.isoformat() if task.done_at is not None else "",
"cycle_time": format_duration(task),
}
@router.get("/stats", response_class=HTMLResponse)
def stats_page(
request: Request,
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> HTMLResponse:
tasks = repo.list_tasks()
backlog_tasks = [serialize_task(task) for task in tasks if get_task_status(task) == "backlog"]
in_progress_tasks = [serialize_task(task) for task in tasks if get_task_status(task) == "in_progress"]
done_tasks = [serialize_task(task) for task in tasks if get_task_status(task) == "done"]
selected_task = None
if tasks:
selected_task = serialize_task(tasks[0])
return templates.TemplateResponse(
request=request,
name="stats.html",
context={
"selected_task": selected_task,
"backlog_tasks": backlog_tasks,
"in_progress_tasks": in_progress_tasks,
"done_tasks": done_tasks,
"selected_task": None,
},
)

120
app/api/ui_data.py Normal file
View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Path
from fastapi.responses import JSONResponse
from app.api.dto.tasks import ErrorDTO, TaskBoardDTO, TaskBoardItemDTO, TaskDetailsDTO
from app.api.tasks import get_task_or_error, get_task_repository
from app.storage import JsonFileTaskRepository, StoredTask
router = APIRouter(prefix="/ui_data", tags=["ui_data"])
def get_task_status(task: StoredTask) -> str:
if task.done_at is not None:
return "done"
if task.started_at is not None:
return "in_progress"
return "backlog"
def format_duration(task: StoredTask) -> str:
if task.started_at is None or task.done_at is None:
return ""
delta = task.done_at - task.started_at
total_seconds = int(delta.total_seconds())
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours}h {minutes}m {seconds}s"
def to_board_item_dto(task: StoredTask) -> TaskBoardItemDTO:
status = get_task_status(task)
display_date_label: str
display_date_value: datetime | None
if status == "backlog":
display_date_label = "Created"
display_date_value = task.created_at
elif status == "in_progress":
display_date_label = "Started"
display_date_value = task.started_at
else:
display_date_label = "Done"
display_date_value = task.done_at
return TaskBoardItemDTO(
id=task.id,
title=task.title,
status=status,
display_date_label=display_date_label,
display_date_value=display_date_value,
)
def to_task_details_dto(task: StoredTask) -> TaskDetailsDTO:
return TaskDetailsDTO(
id=task.id,
title=task.title,
status=get_task_status(task),
created_at=task.created_at,
started_at=task.started_at,
done_at=task.done_at,
cycle_time=format_duration(task),
)
@router.get(
"/stats/board",
response_model=TaskBoardDTO,
responses={
400: {"model": ErrorDTO},
},
include_in_schema=False,
)
def stats_board(
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> TaskBoardDTO:
tasks = repo.list_tasks()
backlog_tasks: list[TaskBoardItemDTO] = []
in_progress_tasks: list[TaskBoardItemDTO] = []
done_tasks: list[TaskBoardItemDTO] = []
for task in tasks:
item = to_board_item_dto(task)
if item.status == "backlog":
backlog_tasks.append(item)
elif item.status == "in_progress":
in_progress_tasks.append(item)
else:
done_tasks.append(item)
return TaskBoardDTO(
backlog_tasks=backlog_tasks,
in_progress_tasks=in_progress_tasks,
done_tasks=done_tasks,
)
@router.get(
"/tasks/{task_id}",
response_model=TaskDetailsDTO,
responses={
400: {"model": ErrorDTO},
404: {"model": ErrorDTO},
},
include_in_schema=False,
)
def task_details(
task_id: str = Path(...),
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> TaskDetailsDTO | JSONResponse:
task, error = get_task_or_error(repo=repo, task_id=task_id)
if error is not None:
return error
return to_task_details_dto(task)

View File

@@ -3,6 +3,7 @@ from fastapi import FastAPI
from app.api.health import router as health_router
from app.api.stats import router as stats_router
from app.api.tasks import router as tasks_router
from app.api.ui_data import router as ui_data_router
def create_app() -> FastAPI:
@@ -14,9 +15,9 @@ def create_app() -> FastAPI:
app.include_router(health_router)
app.include_router(tasks_router)
app.include_router(stats_router)
app.include_router(ui_data_router)
return app
app = create_app()

View File

@@ -175,74 +175,55 @@
<section class="board">
<div class="column">
<h2 class="column-title">Backlog</h2>
<div class="task-list">
{% for task in backlog_tasks %}
<button
type="button"
class="task-card"
data-task='{{ task|tojson }}'
>
<div class="task-title">{{ task.title }}</div>
<div class="task-meta">Created: {{ task.created_at }}</div>
</button>
{% endfor %}
{% if not backlog_tasks %}
<div class="empty">No tasks</div>
{% endif %}
<div class="task-list" id="backlog-list">
<div class="empty">Loading tasks...</div>
</div>
</div>
<div class="column">
<h2 class="column-title">In Progress</h2>
<div class="task-list">
{% for task in in_progress_tasks %}
<button
type="button"
class="task-card"
data-task='{{ task|tojson }}'
>
<div class="task-title">{{ task.title }}</div>
<div class="task-meta">Started: {{ task.started_at }}</div>
</button>
{% endfor %}
{% if not in_progress_tasks %}
<div class="empty">No tasks</div>
{% endif %}
<div class="task-list" id="in-progress-list">
<div class="empty">Loading tasks...</div>
</div>
</div>
<div class="column">
<h2 class="column-title">Done</h2>
<div class="task-list">
{% for task in done_tasks %}
<button
type="button"
class="task-card"
data-task='{{ task|tojson }}'
>
<div class="task-title">{{ task.title }}</div>
<div class="task-meta">Done: {{ task.done_at }}</div>
</button>
{% endfor %}
{% if not done_tasks %}
<div class="empty">No tasks</div>
{% endif %}
<div class="task-list" id="done-list">
<div class="empty">Loading tasks...</div>
</div>
</div>
</section>
</div>
<script>
const cards = Array.from(document.querySelectorAll(".task-card"));
const selectedTitle = document.getElementById("selected-title");
const selectedStatus = document.getElementById("selected-status");
const selectedStartedAt = document.getElementById("selected-started-at");
const selectedDoneAt = document.getElementById("selected-done-at");
const selectedCycleTime = document.getElementById("selected-cycle-time");
const selectedCreatedAt = document.getElementById("selected-created-at");
const boardLists = {
backlog: document.getElementById("backlog-list"),
in_progress: document.getElementById("in-progress-list"),
done: document.getElementById("done-list"),
};
const detailFields = [
selectedTitle,
selectedStatus,
selectedStartedAt,
selectedDoneAt,
selectedCycleTime,
selectedCreatedAt,
];
function renderTask(task) {
function setDetailsLoading() {
detailFields.forEach((field) => {
field.textContent = "Loading...";
});
}
function renderTaskDetails(task) {
selectedTitle.textContent = task.title || "No task selected";
selectedStatus.textContent = task.status || "—";
selectedStartedAt.textContent = task.started_at || "—";
@@ -251,18 +232,130 @@
selectedCreatedAt.textContent = task.created_at || "—";
}
cards.forEach((card, index) => {
if (index === 0) {
card.classList.add("active");
}
function renderTaskDetailsError() {
selectedTitle.textContent = "Failed to load task";
selectedStatus.textContent = "—";
selectedStartedAt.textContent = "—";
selectedDoneAt.textContent = "—";
selectedCycleTime.textContent = "—";
selectedCreatedAt.textContent = "—";
}
function createTaskCard(task) {
const card = document.createElement("button");
card.type = "button";
card.className = "task-card";
card.dataset.taskId = task.id;
const title = document.createElement("div");
title.className = "task-title";
title.textContent = task.title;
const meta = document.createElement("div");
meta.className = "task-meta";
meta.textContent = `${task.display_date_label}: ${task.display_date_value || "—"}`;
card.appendChild(title);
card.appendChild(meta);
card.addEventListener("click", () => {
cards.forEach((item) => item.classList.remove("active"));
card.classList.add("active");
renderTask(JSON.parse(card.dataset.task));
setActiveCard(card);
loadTaskDetails(task.id);
});
});
return card;
}
function renderTaskList(listElement, tasks) {
listElement.replaceChildren();
if (!tasks.length) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "No tasks";
listElement.appendChild(empty);
return;
}
tasks.forEach((task) => {
listElement.appendChild(createTaskCard(task));
});
}
function renderBoard(board) {
renderTaskList(boardLists.backlog, board.backlog_tasks);
renderTaskList(boardLists.in_progress, board.in_progress_tasks);
renderTaskList(boardLists.done, board.done_tasks);
}
function setActiveCard(activeCard) {
document.querySelectorAll(".task-card").forEach((card) => {
card.classList.toggle("active", card === activeCard);
});
}
async function loadTaskDetails(taskId) {
setDetailsLoading();
try {
const response = await fetch(`/ui_data/tasks/${taskId}`);
if (!response.ok) {
throw new Error("Failed to load task details");
}
const task = await response.json();
renderTaskDetails(task);
} catch (error) {
renderTaskDetailsError();
}
}
function getFirstTaskId(board) {
const groups = [board.backlog_tasks, board.in_progress_tasks, board.done_tasks];
for (const group of groups) {
if (group.length) {
return group[0].id;
}
}
return null;
}
async function loadBoard() {
try {
const response = await fetch("/ui_data/stats/board");
if (!response.ok) {
throw new Error("Failed to load board");
}
const board = await response.json();
renderBoard(board);
const firstTaskId = getFirstTaskId(board);
if (firstTaskId === null) {
return;
}
const firstCard = document.querySelector(`.task-card[data-task-id="${firstTaskId}"]`);
if (firstCard !== null) {
setActiveCard(firstCard);
}
await loadTaskDetails(firstTaskId);
} catch (error) {
Object.values(boardLists).forEach((listElement) => {
listElement.replaceChildren();
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "Failed to load tasks";
listElement.appendChild(empty);
});
renderTaskDetailsError();
}
}
void loadBoard();
</script>
</body>
</html>

View File

@@ -55,10 +55,10 @@ def test_stats_returns_html_page_for_empty_board(tmp_path):
assert "Backlog" in response.text
assert "In Progress" in response.text
assert "Done" in response.text
assert response.text.count("No tasks") == 3
assert response.text.count("Loading tasks...") == 3
def test_stats_renders_tasks_in_all_kanban_columns(tmp_path):
def test_stats_includes_client_side_board_loading_hooks(tmp_path):
client, repo = make_client(tmp_path)
backlog = make_task(title="Backlog title", started=False, done=False)
in_progress = make_task(title="In progress title", started=True, done=False)
@@ -70,15 +70,16 @@ def test_stats_renders_tasks_in_all_kanban_columns(tmp_path):
response = client.get("/stats")
assert response.status_code == 200
assert "Backlog title" in response.text
assert "In progress title" in response.text
assert "Done title" in response.text
assert "Backlog" in response.text
assert "In Progress" in response.text
assert "Done" in response.text
assert 'id="backlog-list"' in response.text
assert 'id="in-progress-list"' in response.text
assert 'id="done-list"' in response.text
assert 'fetch("/ui_data/stats/board")' in response.text
def test_stats_preselects_first_task_in_details_block(tmp_path):
def test_stats_renders_empty_details_block_until_client_side_fetch(tmp_path):
client, repo = make_client(tmp_path)
first = make_task(title="First task", started=False, done=False)
second = make_task(title="Second task", started=True, done=False)
@@ -90,8 +91,8 @@ def test_stats_preselects_first_task_in_details_block(tmp_path):
assert response.status_code == 200
assert 'id="selected-title"' in response.text
assert 'id="selected-created-at"' in response.text
assert "First task" in response.text
assert first.created_at.isoformat() in response.text
assert "No task selected" in response.text
assert first.created_at.isoformat() not in response.text
def test_stats_shows_placeholders_for_missing_dates(tmp_path):
@@ -117,11 +118,11 @@ def test_stats_renders_cycle_time_for_completed_task(tmp_path):
assert response.status_code == 200
assert 'id="selected-cycle-time"' in response.text
assert "0h 9m" in response.text
assert ">—<" not in response.text.split('id="selected-cycle-time"', 1)[1].split("</div>", 2)[1]
assert "0h 9m" not in response.text
assert ">—<" in response.text
def test_stats_embeds_task_payload_for_client_side_switching(tmp_path):
def test_stats_does_not_embed_task_payload_for_client_side_switching(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Payload task", started=True, done=True)
repo.create_task(task)
@@ -129,14 +130,9 @@ def test_stats_embeds_task_payload_for_client_side_switching(tmp_path):
response = client.get("/stats")
assert response.status_code == 200
assert "data-task='" in response.text
assert str(task.id) in response.text
assert '"title": "Payload task"' in response.text
assert '"status": "done"' in response.text
assert '"created_at":' in response.text
assert '"started_at":' in response.text
assert '"done_at":' in response.text
assert '"cycle_time":' in response.text
assert "data-task='" not in response.text
assert str(task.id) not in response.text
assert "Payload task" not in response.text
def test_stats_includes_js_hooks_for_dynamic_selected_task_update(tmp_path):
@@ -154,7 +150,7 @@ def test_stats_includes_js_hooks_for_dynamic_selected_task_update(tmp_path):
assert 'id="selected-created-at"' in response.text
assert 'document.querySelectorAll(".task-card")' in response.text
assert 'card.addEventListener("click"' in response.text
assert "JSON.parse(card.dataset.task)" in response.text
assert "fetch(`/ui_data/tasks/${taskId}`)" in response.text
def test_stats_handles_utf8_titles(tmp_path):
@@ -164,7 +160,7 @@ def test_stats_handles_utf8_titles(tmp_path):
response = client.get("/stats")
assert response.status_code == 200
assert "Задача пример" in response.text
assert "Задача пример" not in response.text
def test_stats_escapes_html_in_task_title(tmp_path):
@@ -175,7 +171,7 @@ def test_stats_escapes_html_in_task_title(tmp_path):
assert response.status_code == 200
assert "<script>alert(1)</script>" not in response.text
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in response.text
assert "&lt;script&gt;alert(1)&lt;/script&gt;" not in response.text
def test_stats_rejects_unsupported_method(tmp_path):
@@ -202,7 +198,7 @@ def test_stats_still_works_after_recovery_from_corrupted_file(tmp_path):
assert "No task selected" in response.text
def test_stats_renders_created_started_and_done_values_for_completed_task(tmp_path):
def test_stats_does_not_render_created_started_and_done_values_server_side(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Detailed task", started=True, done=True)
repo.create_task(task)
@@ -210,7 +206,6 @@ def test_stats_renders_created_started_and_done_values_for_completed_task(tmp_pa
response = client.get("/stats")
assert response.status_code == 200
assert task.created_at.isoformat() in response.text
assert task.started_at.isoformat() in response.text
assert task.done_at.isoformat() in response.text
assert task.created_at.isoformat() not in response.text
assert task.started_at.isoformat() not in response.text
assert task.done_at.isoformat() not in response.text

121
tests/test_api_ui_data.py Normal file
View File

@@ -0,0 +1,121 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from fastapi.testclient import TestClient
from app.api.tasks import get_task_repository
from app.main import app
from app.storage import JsonFileTaskRepository, StoredTask
def make_repo(tmp_path) -> JsonFileTaskRepository:
return JsonFileTaskRepository(tmp_path / "tasks.json")
def make_task(
*,
title: str = "Task",
started: bool = False,
done: bool = False,
) -> StoredTask:
now = datetime.now(UTC)
started_at = now - timedelta(minutes=10) if started else None
done_at = now - timedelta(minutes=1) if done else None
return StoredTask(
id=uuid4(),
title=title,
created_at=now - timedelta(hours=1),
started_at=started_at,
done_at=done_at,
)
def make_client(tmp_path) -> tuple[TestClient, JsonFileTaskRepository]:
repo = make_repo(tmp_path)
app.dependency_overrides[get_task_repository] = lambda: repo
client = TestClient(app)
return client, repo
def teardown_function() -> None:
app.dependency_overrides.clear()
def test_ui_data_board_returns_lightweight_grouped_lists(tmp_path):
client, repo = make_client(tmp_path)
backlog = make_task(title="Backlog title")
in_progress = make_task(title="In progress title", started=True)
done = make_task(title="Done title", started=True, done=True)
repo.create_task(backlog)
repo.create_task(in_progress)
repo.create_task(done)
response = client.get("/ui_data/stats/board")
assert response.status_code == 200
data = response.json()
assert [item["title"] for item in data["backlog_tasks"]] == ["Backlog title"]
assert [item["title"] for item in data["in_progress_tasks"]] == ["In progress title"]
assert [item["title"] for item in data["done_tasks"]] == ["Done title"]
assert data["backlog_tasks"][0]["display_date_label"] == "Created"
assert data["in_progress_tasks"][0]["display_date_label"] == "Started"
assert data["done_tasks"][0]["display_date_label"] == "Done"
assert "created_at" not in data["backlog_tasks"][0]
assert "started_at" not in data["in_progress_tasks"][0]
assert "done_at" not in data["done_tasks"][0]
assert "cycle_time" not in data["done_tasks"][0]
def test_ui_data_task_details_returns_full_selected_task_data(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Detailed task", started=True, done=True)
repo.create_task(task)
response = client.get(f"/ui_data/tasks/{task.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == str(task.id)
assert data["title"] == "Detailed task"
assert data["status"] == "done"
assert datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")) == task.created_at
assert datetime.fromisoformat(data["started_at"].replace("Z", "+00:00")) == task.started_at
assert datetime.fromisoformat(data["done_at"].replace("Z", "+00:00")) == task.done_at
assert data["cycle_time"].startswith("0h 9m")
def test_ui_data_task_details_rejects_invalid_uuid(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get("/ui_data/tasks/bad-id")
assert response.status_code == 400
assert response.json() == {
"error": "invalid_id",
"message": "Task id must be a valid UUID",
}
def test_ui_data_task_details_returns_not_found_for_missing_task(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get(f"/ui_data/tasks/{uuid4()}")
assert response.status_code == 404
assert response.json() == {
"error": "invalid_id",
"message": "Task was not found",
}
def test_ui_data_routes_are_hidden_from_openapi_schema(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get("/openapi.json")
assert response.status_code == 200
paths = response.json()["paths"]
assert "/ui_data/stats/board" not in paths
assert "/ui_data/tasks/{task_id}" not in paths