From 9b9c7b5575d12420faa2856022b59e1ab3dd6587 Mon Sep 17 00:00:00 2001 From: Alexander Kalinovsky Date: Wed, 1 Apr 2026 19:05:57 +0300 Subject: [PATCH] feat(stats): optimize dashboard data loading with internal ui endpoints --- CHANGELOG.md | 29 +++++ PROMPTS.md | 29 +++++ PROMTS.md => PROMPTS_0001.md | 0 README.md | 50 +++++++++ app/api/dto/tasks.py | 30 ++++++ app/api/stats.py | 60 +---------- app/api/ui_data.py | 120 +++++++++++++++++++++ app/main.py | 3 +- templates/stats.html | 201 +++++++++++++++++++++++++---------- tests/test_api_stats.py | 49 ++++----- tests/test_api_ui_data.py | 121 +++++++++++++++++++++ 11 files changed, 552 insertions(+), 140 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 PROMPTS.md rename PROMTS.md => PROMPTS_0001.md (100%) create mode 100644 app/api/ui_data.py create mode 100644 tests/test_api_ui_data.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad353c3 --- /dev/null +++ b/CHANGELOG.md @@ -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`) diff --git a/PROMPTS.md b/PROMPTS.md new file mode 100644 index 0000000..f654b84 --- /dev/null +++ b/PROMPTS.md @@ -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, что и зачем было изменено, какой эффект оказало, кратко без подробностей реализации на английском \ No newline at end of file diff --git a/PROMTS.md b/PROMPTS_0001.md similarity index 100% rename from PROMTS.md rename to PROMPTS_0001.md diff --git a/README.md b/README.md index f4ac291..b6ea1b3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/dto/tasks.py b/app/api/dto/tasks.py index b78cdf5..f1524a2 100644 --- a/app/api/dto/tasks.py +++ b/app/api/dto/tasks.py @@ -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 diff --git a/app/api/stats.py b/app/api/stats.py index 3084a35..8744d0f 100644 --- a/app/api/stats.py +++ b/app/api/stats.py @@ -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, }, ) - diff --git a/app/api/ui_data.py b/app/api/ui_data.py new file mode 100644 index 0000000..02c620c --- /dev/null +++ b/app/api/ui_data.py @@ -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) diff --git a/app/main.py b/app/main.py index b445a40..a5bc2fe 100644 --- a/app/main.py +++ b/app/main.py @@ -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() - diff --git a/templates/stats.html b/templates/stats.html index 44bf374..df0e892 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -175,74 +175,55 @@

Backlog

-
- {% for task in backlog_tasks %} - - {% endfor %} - {% if not backlog_tasks %} -
No tasks
- {% endif %} +
+
Loading tasks...

In Progress

-
- {% for task in in_progress_tasks %} - - {% endfor %} - {% if not in_progress_tasks %} -
No tasks
- {% endif %} +
+
Loading tasks...

Done

-
- {% for task in done_tasks %} - - {% endfor %} - {% if not done_tasks %} -
No tasks
- {% endif %} +
+
Loading tasks...
- diff --git a/tests/test_api_stats.py b/tests/test_api_stats.py index b336a4d..4a13922 100644 --- a/tests/test_api_stats.py +++ b/tests/test_api_stats.py @@ -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("", 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 "" not in response.text - assert "<script>alert(1)</script>" in response.text + assert "<script>alert(1)</script>" 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 diff --git a/tests/test_api_ui_data.py b/tests/test_api_ui_data.py new file mode 100644 index 0000000..fee2821 --- /dev/null +++ b/tests/test_api_ui_data.py @@ -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