feat(stats): optimize dashboard data loading with internal ui endpoints
This commit is contained in:
29
CHANGELOG.md
Normal file
29
CHANGELOG.md
Normal 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
29
PROMPTS.md
Normal 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, что и зачем было изменено, какой эффект оказало, кратко без подробностей реализации на английском
|
||||||
50
README.md
50
README.md
@@ -32,6 +32,7 @@ The service exposes a clean HTTP API, provides workflow metrics, persists state
|
|||||||
- CSV export with injection protection
|
- CSV export with injection protection
|
||||||
- Persistent storage (`data/tasks.json`)
|
- Persistent storage (`data/tasks.json`)
|
||||||
- HTML dashboard (`/stats`)
|
- HTML dashboard (`/stats`)
|
||||||
|
- Lightweight dashboard data loading via internal `/ui_data/*` routes
|
||||||
- Atomic file writes with corruption recovery
|
- Atomic file writes with corruption recovery
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -194,6 +195,8 @@ HTML page:
|
|||||||
- Top block:
|
- Top block:
|
||||||
- Selected task details:
|
- Selected task details:
|
||||||
- Title
|
- Title
|
||||||
|
- Status
|
||||||
|
- Created datetime
|
||||||
- Start datetime
|
- Start datetime
|
||||||
- Done datetime
|
- Done datetime
|
||||||
- Cycle time
|
- Cycle time
|
||||||
@@ -204,6 +207,53 @@ HTML page:
|
|||||||
- In Progress
|
- In Progress
|
||||||
- Done
|
- 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
|
## Error Format
|
||||||
|
|||||||
@@ -140,3 +140,33 @@ class TaskExportRowDTO(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
done_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
|
||||||
|
|||||||
@@ -2,79 +2,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.api.tasks import get_task_repository
|
|
||||||
from app.storage import JsonFileTaskRepository, StoredTask
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["stats"])
|
router = APIRouter(tags=["stats"])
|
||||||
|
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
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)
|
@router.get("/stats", response_class=HTMLResponse)
|
||||||
def stats_page(
|
def stats_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
repo: JsonFileTaskRepository = Depends(get_task_repository),
|
|
||||||
) -> HTMLResponse:
|
) -> 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(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="stats.html",
|
name="stats.html",
|
||||||
context={
|
context={
|
||||||
"selected_task": selected_task,
|
"selected_task": None,
|
||||||
"backlog_tasks": backlog_tasks,
|
|
||||||
"in_progress_tasks": in_progress_tasks,
|
|
||||||
"done_tasks": done_tasks,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
120
app/api/ui_data.py
Normal file
120
app/api/ui_data.py
Normal 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)
|
||||||
@@ -3,6 +3,7 @@ from fastapi import FastAPI
|
|||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.stats import router as stats_router
|
from app.api.stats import router as stats_router
|
||||||
from app.api.tasks import router as tasks_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:
|
def create_app() -> FastAPI:
|
||||||
@@ -14,9 +15,9 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(tasks_router)
|
app.include_router(tasks_router)
|
||||||
app.include_router(stats_router)
|
app.include_router(stats_router)
|
||||||
|
app.include_router(ui_data_router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
|||||||
@@ -175,74 +175,55 @@
|
|||||||
<section class="board">
|
<section class="board">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h2 class="column-title">Backlog</h2>
|
<h2 class="column-title">Backlog</h2>
|
||||||
<div class="task-list">
|
<div class="task-list" id="backlog-list">
|
||||||
{% for task in backlog_tasks %}
|
<div class="empty">Loading tasks...</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h2 class="column-title">In Progress</h2>
|
<h2 class="column-title">In Progress</h2>
|
||||||
<div class="task-list">
|
<div class="task-list" id="in-progress-list">
|
||||||
{% for task in in_progress_tasks %}
|
<div class="empty">Loading tasks...</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h2 class="column-title">Done</h2>
|
<h2 class="column-title">Done</h2>
|
||||||
<div class="task-list">
|
<div class="task-list" id="done-list">
|
||||||
{% for task in done_tasks %}
|
<div class="empty">Loading tasks...</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const cards = Array.from(document.querySelectorAll(".task-card"));
|
|
||||||
|
|
||||||
const selectedTitle = document.getElementById("selected-title");
|
const selectedTitle = document.getElementById("selected-title");
|
||||||
const selectedStatus = document.getElementById("selected-status");
|
const selectedStatus = document.getElementById("selected-status");
|
||||||
const selectedStartedAt = document.getElementById("selected-started-at");
|
const selectedStartedAt = document.getElementById("selected-started-at");
|
||||||
const selectedDoneAt = document.getElementById("selected-done-at");
|
const selectedDoneAt = document.getElementById("selected-done-at");
|
||||||
const selectedCycleTime = document.getElementById("selected-cycle-time");
|
const selectedCycleTime = document.getElementById("selected-cycle-time");
|
||||||
const selectedCreatedAt = document.getElementById("selected-created-at");
|
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";
|
selectedTitle.textContent = task.title || "No task selected";
|
||||||
selectedStatus.textContent = task.status || "—";
|
selectedStatus.textContent = task.status || "—";
|
||||||
selectedStartedAt.textContent = task.started_at || "—";
|
selectedStartedAt.textContent = task.started_at || "—";
|
||||||
@@ -251,18 +232,130 @@
|
|||||||
selectedCreatedAt.textContent = task.created_at || "—";
|
selectedCreatedAt.textContent = task.created_at || "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
cards.forEach((card, index) => {
|
function renderTaskDetailsError() {
|
||||||
if (index === 0) {
|
selectedTitle.textContent = "Failed to load task";
|
||||||
card.classList.add("active");
|
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", () => {
|
card.addEventListener("click", () => {
|
||||||
cards.forEach((item) => item.classList.remove("active"));
|
setActiveCard(card);
|
||||||
card.classList.add("active");
|
loadTaskDetails(task.id);
|
||||||
renderTask(JSON.parse(card.dataset.task));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ def test_stats_returns_html_page_for_empty_board(tmp_path):
|
|||||||
assert "Backlog" in response.text
|
assert "Backlog" in response.text
|
||||||
assert "In Progress" in response.text
|
assert "In Progress" in response.text
|
||||||
assert "Done" 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)
|
client, repo = make_client(tmp_path)
|
||||||
backlog = make_task(title="Backlog title", started=False, done=False)
|
backlog = make_task(title="Backlog title", started=False, done=False)
|
||||||
in_progress = make_task(title="In progress title", started=True, 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")
|
response = client.get("/stats")
|
||||||
|
|
||||||
assert response.status_code == 200
|
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 "Backlog" in response.text
|
||||||
assert "In Progress" in response.text
|
assert "In Progress" in response.text
|
||||||
assert "Done" 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)
|
client, repo = make_client(tmp_path)
|
||||||
first = make_task(title="First task", started=False, done=False)
|
first = make_task(title="First task", started=False, done=False)
|
||||||
second = make_task(title="Second task", started=True, 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 response.status_code == 200
|
||||||
assert 'id="selected-title"' in response.text
|
assert 'id="selected-title"' in response.text
|
||||||
assert 'id="selected-created-at"' in response.text
|
assert 'id="selected-created-at"' in response.text
|
||||||
assert "First task" in response.text
|
assert "No task selected" in response.text
|
||||||
assert first.created_at.isoformat() in response.text
|
assert first.created_at.isoformat() not in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_stats_shows_placeholders_for_missing_dates(tmp_path):
|
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 response.status_code == 200
|
||||||
assert 'id="selected-cycle-time"' in response.text
|
assert 'id="selected-cycle-time"' in response.text
|
||||||
assert "0h 9m" in response.text
|
assert "0h 9m" not in response.text
|
||||||
assert ">—<" not in response.text.split('id="selected-cycle-time"', 1)[1].split("</div>", 2)[1]
|
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)
|
client, repo = make_client(tmp_path)
|
||||||
task = make_task(title="Payload task", started=True, done=True)
|
task = make_task(title="Payload task", started=True, done=True)
|
||||||
repo.create_task(task)
|
repo.create_task(task)
|
||||||
@@ -129,14 +130,9 @@ def test_stats_embeds_task_payload_for_client_side_switching(tmp_path):
|
|||||||
response = client.get("/stats")
|
response = client.get("/stats")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "data-task='" in response.text
|
assert "data-task='" not in response.text
|
||||||
assert str(task.id) in response.text
|
assert str(task.id) not in response.text
|
||||||
assert '"title": "Payload task"' in response.text
|
assert "Payload task" not 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
|
|
||||||
|
|
||||||
|
|
||||||
def test_stats_includes_js_hooks_for_dynamic_selected_task_update(tmp_path):
|
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 'id="selected-created-at"' in response.text
|
||||||
assert 'document.querySelectorAll(".task-card")' in response.text
|
assert 'document.querySelectorAll(".task-card")' in response.text
|
||||||
assert 'card.addEventListener("click"' 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):
|
def test_stats_handles_utf8_titles(tmp_path):
|
||||||
@@ -164,7 +160,7 @@ def test_stats_handles_utf8_titles(tmp_path):
|
|||||||
response = client.get("/stats")
|
response = client.get("/stats")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Задача пример" in response.text
|
assert "Задача пример" not in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_stats_escapes_html_in_task_title(tmp_path):
|
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 response.status_code == 200
|
||||||
assert "<script>alert(1)</script>" not in response.text
|
assert "<script>alert(1)</script>" 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):
|
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
|
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)
|
client, repo = make_client(tmp_path)
|
||||||
task = make_task(title="Detailed task", started=True, done=True)
|
task = make_task(title="Detailed task", started=True, done=True)
|
||||||
repo.create_task(task)
|
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")
|
response = client.get("/stats")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert task.created_at.isoformat() in response.text
|
assert task.created_at.isoformat() not in response.text
|
||||||
assert task.started_at.isoformat() in response.text
|
assert task.started_at.isoformat() not in response.text
|
||||||
assert task.done_at.isoformat() in response.text
|
assert task.done_at.isoformat() not in response.text
|
||||||
|
|
||||||
|
|||||||
121
tests/test_api_ui_data.py
Normal file
121
tests/test_api_ui_data.py
Normal 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
|
||||||
Reference in New Issue
Block a user