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

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