From 390b555088ba4b736c1d410769b01d404865c6b6 Mon Sep 17 00:00:00 2001 From: David Butenhof Date: Thu, 7 May 2026 10:28:21 -0400 Subject: [PATCH] Status report date range Improve AGENTS.md, sort status lists, ensure consistency in Generated-by / Assisted-by tagging. Signed-off-by: David Butenhof --- AGENTS.md | 18 +++ backend/src/github_pm/sdlc_api.py | 5 +- backend/src/github_pm/sdlc_metrics.py | 5 +- backend/src/github_pm/sdlc_models.py | 5 +- backend/src/github_pm/sdlc_service.py | 2 + backend/src/github_pm/status_report_api.py | 42 +++++- backend/src/github_pm/status_report_models.py | 7 +- .../src/github_pm/status_report_service.py | 33 ++-- backend/tests/test_api.py | 2 +- backend/tests/test_app.py | 2 +- backend/tests/test_cli.py | 2 +- backend/tests/test_context.py | 2 +- backend/tests/test_sdlc_api.py | 5 +- backend/tests/test_sdlc_metrics.py | 5 +- backend/tests/test_status_report_api.py | 82 +++++++++- frontend/index.html | 2 +- frontend/src/App.jsx | 2 +- frontend/src/App.test.jsx | 2 +- frontend/src/components/CommentCard.jsx | 2 +- frontend/src/components/CommentCard.test.jsx | 2 +- frontend/src/components/IssueCard.jsx | 2 +- frontend/src/components/IssueCard.test.jsx | 2 +- frontend/src/components/ManageLabels.jsx | 2 +- frontend/src/components/ManageMilestones.jsx | 2 +- frontend/src/components/ManageSort.jsx | 2 +- frontend/src/components/MilestoneCard.jsx | 2 +- .../src/components/MilestoneCard.test.jsx | 2 +- .../src/components/ProjectStatusPanel.jsx | 142 +++++++++++++----- .../components/ProjectStatusPanel.test.jsx | 55 ++++++- frontend/src/components/Reactions.jsx | 2 +- frontend/src/components/SdlcKpisPanel.jsx | 2 +- .../src/components/SdlcKpisPanel.test.jsx | 2 +- frontend/src/components/UserAvatar.jsx | 2 +- frontend/src/icon.css | 1 + frontend/src/main.jsx | 2 +- frontend/src/services/api.js | 12 +- frontend/src/services/api.test.js | 18 +-- frontend/src/test/setup.js | 2 +- frontend/src/utils/assigneesCache.js | 2 +- frontend/src/utils/clipboard.js | 2 +- frontend/src/utils/clipboard.test.js | 2 +- frontend/src/utils/dateUtils.js | 16 +- frontend/src/utils/dateUtils.test.js | 19 ++- frontend/src/utils/labelsCache.js | 2 +- frontend/src/utils/milestonesCache.js | 2 +- frontend/vite.config.js | 2 +- 46 files changed, 406 insertions(+), 122 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3c88ee..31bc933 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,24 @@ - **Open `AGENTS.md` again** when you begin a task on this repo or return after a long gap, so required checks (below) stay in context until **`tox`** and frontend checks are green. +## Attributing AI coding assistance + +When using AI coding assistants to generate a module or a new function, annotate that code with a module comment or a function docstring using the `Generated-by: ` tag. When substantial code changes (not including minor formatting or typo fixes) are made to a module or function not already annotated with "Generated-by" or "Assisted-by", annotate the changes with the `Assisted-by: ` tag. + +```markdown +Assisted-by: +``` + +For example: + +```markdown +Assisted-by: Cursor +Assisted-by: GitHub Copilot +Assisted-by: Claude +Assisted-by: gemini-code-assist +Assisted-by: openai-code-assist +``` + ## Required checks before finishing any task ### Python backend (`backend/`) diff --git a/backend/src/github_pm/sdlc_api.py b/backend/src/github_pm/sdlc_api.py index 297fef7..275b6af 100644 --- a/backend/src/github_pm/sdlc_api.py +++ b/backend/src/github_pm/sdlc_api.py @@ -1,4 +1,7 @@ -"""SDLC KPI REST endpoints (GitHub-backed).""" +"""SDLC KPI REST endpoints (GitHub-backed). + +Generated-by: Cursor +""" from __future__ import annotations diff --git a/backend/src/github_pm/sdlc_metrics.py b/backend/src/github_pm/sdlc_metrics.py index 3268271..1cf1568 100644 --- a/backend/src/github_pm/sdlc_metrics.py +++ b/backend/src/github_pm/sdlc_metrics.py @@ -1,4 +1,7 @@ -"""SDLC KPI helpers: PR classification, size buckets, medians, GitHub search.""" +"""SDLC KPI helpers: PR classification, size buckets, medians, GitHub search. + +Generated-by: Cursor +""" from __future__ import annotations diff --git a/backend/src/github_pm/sdlc_models.py b/backend/src/github_pm/sdlc_models.py index f986c55..d59678f 100644 --- a/backend/src/github_pm/sdlc_models.py +++ b/backend/src/github_pm/sdlc_models.py @@ -1,4 +1,7 @@ -"""Pydantic models for SDLC KPI API responses.""" +"""Pydantic models for SDLC KPI API responses. + +Generated-by: Cursor +""" from __future__ import annotations diff --git a/backend/src/github_pm/sdlc_service.py b/backend/src/github_pm/sdlc_service.py index 5b11653..8b43899 100644 --- a/backend/src/github_pm/sdlc_service.py +++ b/backend/src/github_pm/sdlc_service.py @@ -3,6 +3,8 @@ The standalone ``sdlc-report`` script (``scripts/sdlc_report.py``) mirrors this logic without importing ``github_pm``; keep behavior aligned when changing metrics. + +Generated-by: Cursor """ from __future__ import annotations diff --git a/backend/src/github_pm/status_report_api.py b/backend/src/github_pm/status_report_api.py index 96bfc26..ed6c8a3 100644 --- a/backend/src/github_pm/status_report_api.py +++ b/backend/src/github_pm/status_report_api.py @@ -1,11 +1,14 @@ -"""REST API for the weekly project status report.""" +"""REST API for the project status report. + +Generated-by: Cursor +""" from __future__ import annotations -from datetime import date, datetime, UTC +from datetime import date, datetime, timedelta, UTC from typing import Annotated -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from github_pm.api import connection, Connector from github_pm.status_report_models import ProjectStatusReportResponse @@ -13,6 +16,8 @@ status_report_router = APIRouter(tags=["project-status"]) +_MAX_RANGE_DAYS = 365 + def _default_end_date() -> date: return datetime.now(UTC).date() @@ -21,17 +26,42 @@ def _default_end_date() -> date: @status_report_router.get("/project-status", response_model=ProjectStatusReportResponse) async def get_project_status_report( gitctx: Annotated[Connector, Depends(connection)], + start_date: Annotated[ + date | None, + Query( + description="First day of the window (UTC calendar date). Defaults to 7 days before end_date.", + ), + ] = None, end_date: Annotated[ date | None, Query( - description="Last day of the 7-day window (UTC calendar date). Defaults to today in UTC.", + description="Last day of the window (UTC calendar date). Defaults to today in UTC.", ), ] = None, ): """ - Status for seven **calendar** days inclusive: ``end_date - 6 days`` through ``end_date``. + Status for the inclusive calendar range ``start_date`` … ``end_date`` (UTC). + + Defaults: ``end_date`` = today (UTC), ``start_date`` = ``end_date`` minus 7 calendar days. Sections: merged pull requests (by merge date), pull requests opened, issues opened (PRs excluded). """ resolved_end = end_date if end_date is not None else _default_end_date() - return build_project_status_report(gitctx, end_date=resolved_end) + resolved_start = ( + start_date if start_date is not None else resolved_end - timedelta(days=7) + ) + if resolved_start > resolved_end: + raise HTTPException( + status_code=422, + detail="start_date must be on or before end_date", + ) + if (resolved_end - resolved_start).days > _MAX_RANGE_DAYS: + raise HTTPException( + status_code=422, + detail="Date range must not span more than 366 calendar days", + ) + return build_project_status_report( + gitctx, + start_date=resolved_start, + end_date=resolved_end, + ) diff --git a/backend/src/github_pm/status_report_models.py b/backend/src/github_pm/status_report_models.py index 558aed3..5cad704 100644 --- a/backend/src/github_pm/status_report_models.py +++ b/backend/src/github_pm/status_report_models.py @@ -1,4 +1,7 @@ -"""Pydantic models for the weekly project status report API.""" +"""Pydantic models for the weekly project status report API. + +Generated-by: Cursor +""" from __future__ import annotations @@ -16,7 +19,7 @@ class StatusReportItem(BaseModel): class ProjectStatusReportResponse(BaseModel): - """Seven calendar days inclusive ending on ``end_date`` (UTC calendar dates).""" + """Inclusive calendar window from ``start_date`` through ``end_date`` (UTC calendar dates).""" start_date: date = Field(description="First calendar day of the window (inclusive)") end_date: date = Field(description="Last calendar day of the window (inclusive)") diff --git a/backend/src/github_pm/status_report_service.py b/backend/src/github_pm/status_report_service.py index 8037e83..92b7814 100644 --- a/backend/src/github_pm/status_report_service.py +++ b/backend/src/github_pm/status_report_service.py @@ -1,8 +1,11 @@ -"""Weekly project status via GraphQL ``search`` (REST ``/search/issues`` returns 422 for these queries).""" +"""Project status report via GraphQL ``search`` + +Generated-by: Cursor +""" from __future__ import annotations -from datetime import date, datetime, timedelta, UTC +from datetime import date, UTC from typing import Any from github_pm import sdlc_metrics as sm @@ -21,20 +24,6 @@ def _item_from_gql_node(node: dict[str, Any]) -> StatusReportItem: ) -def _merged_at_sort_key(node: dict[str, Any]) -> datetime: - m = sm.parse_github_ts(node.get("mergedAt")) - if not m: - return datetime.min.replace(tzinfo=UTC) - return m - - -def _created_at_sort_key(node: dict[str, Any]) -> datetime: - c = sm.parse_github_ts(node.get("createdAt")) - if not c: - return datetime.min.replace(tzinfo=UTC) - return c - - def _merged_calendar_in_window( node: dict[str, Any], start_d: date, end_d: date ) -> bool: @@ -58,10 +47,12 @@ def _created_calendar_in_window( def build_project_status_report( gitctx: Connector, *, + start_date: date, end_date: date, ) -> ProjectStatusReportResponse: - """Build the report for seven calendar days ending on ``end_date`` (inclusive).""" - start_date = end_date - timedelta(days=6) + """Build the report for ``start_date`` through ``end_date`` (inclusive, UTC calendar dates).""" + if start_date > end_date: + raise ValueError("start_date must be on or before end_date") repo = f"{gitctx.owner}/{gitctx.repo}" def post_gql(payload: dict[str, Any]) -> dict[str, Any]: @@ -76,7 +67,7 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]: merged_in_window = [ n for n in merged_nodes if _merged_calendar_in_window(n, start_date, end_date) ] - merged_in_window.sort(key=_merged_at_sort_key, reverse=True) + merged_in_window.sort(key=lambda n: int(n["number"])) merged_items = [_item_from_gql_node(n) for n in merged_in_window] opened_pr_q = sm.opened_prs_between_query(repo, start_date, end_date) @@ -87,7 +78,7 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]: if n.get("__typename") == "PullRequest" and _created_calendar_in_window(n, start_date, end_date) ] - opened_pr_filtered.sort(key=_created_at_sort_key, reverse=True) + opened_pr_filtered.sort(key=lambda n: int(n["number"])) opened_issue_q = sm.opened_issues_between_query(repo, start_date, end_date) opened_issue_nodes = sm.graphql_search_timeline_nodes(post_gql, opened_issue_q) @@ -97,7 +88,7 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]: if n.get("__typename") == "Issue" and _created_calendar_in_window(n, start_date, end_date) ] - opened_issue_filtered.sort(key=_created_at_sort_key, reverse=True) + opened_issue_filtered.sort(key=lambda n: int(n["number"])) return ProjectStatusReportResponse( start_date=start_date, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 8655ff3..75f64e9 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,6 +1,6 @@ """Tests for the api module. -ai-generated: Cursor +Generated-by: Cursor """ from unittest.mock import Mock, patch diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index e328b6c..4cecf72 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -1,6 +1,6 @@ """Tests for the app module. -ai-generated: Cursor +Generated-by: Cursor """ from fastapi.testclient import TestClient diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py index d3c2177..01243bf 100644 --- a/backend/tests/test_cli.py +++ b/backend/tests/test_cli.py @@ -1,6 +1,6 @@ """Tests for the cli module. -ai-generated: Cursor +Generated-by: Cursor """ import sys diff --git a/backend/tests/test_context.py b/backend/tests/test_context.py index 36cba1e..6463277 100644 --- a/backend/tests/test_context.py +++ b/backend/tests/test_context.py @@ -1,6 +1,6 @@ """Tests for the context module. -ai-generated: Cursor +Generated-by: Cursor """ import os diff --git a/backend/tests/test_sdlc_api.py b/backend/tests/test_sdlc_api.py index 79fb834..aed4a46 100644 --- a/backend/tests/test_sdlc_api.py +++ b/backend/tests/test_sdlc_api.py @@ -1,4 +1,7 @@ -"""Tests for SDLC KPI API routes (mocked GitHub).""" +"""Tests for SDLC KPI API routes (mocked GitHub). + +Generated-by: Cursor +""" from datetime import datetime, UTC from unittest.mock import MagicMock, patch diff --git a/backend/tests/test_sdlc_metrics.py b/backend/tests/test_sdlc_metrics.py index 2d821b4..ab2101e 100644 --- a/backend/tests/test_sdlc_metrics.py +++ b/backend/tests/test_sdlc_metrics.py @@ -1,4 +1,7 @@ -"""Unit tests for sdlc_metrics helpers.""" +"""Unit tests for sdlc_metrics helpers. + +Generated-by: Cursor +""" from datetime import datetime, UTC import re diff --git a/backend/tests/test_status_report_api.py b/backend/tests/test_status_report_api.py index e7e5419..646c2a3 100644 --- a/backend/tests/test_status_report_api.py +++ b/backend/tests/test_status_report_api.py @@ -1,4 +1,7 @@ -"""Tests for project status report API (mocked GitHub GraphQL).""" +"""Tests for project status report API (mocked GitHub GraphQL). + +Generated-by: Cursor +""" from unittest.mock import MagicMock @@ -94,7 +97,9 @@ def post_side(path: str, data=None, **kwargs): class TestProjectStatusReport: - def test_report_ok_with_end_date(self, client, mock_connector_graphql): + def test_report_ok_with_end_date_only_defaults_start( + self, client, mock_connector_graphql + ): async def override_conn(): yield mock_connector_graphql @@ -106,7 +111,7 @@ async def override_conn(): assert r.status_code == 200 body = r.json() - assert body["start_date"] == "2025-04-04" + assert body["start_date"] == "2025-04-03" assert body["end_date"] == "2025-04-10" assert body["merged_pull_requests"] == [ { @@ -118,13 +123,82 @@ async def override_conn(): assert body["opened_pull_requests"][0]["number"] == 11 assert body["opened_issues"][0]["number"] == 12 + def test_report_single_calendar_day(self, client, mock_connector_graphql): + async def override_conn(): + yield mock_connector_graphql + + app.dependency_overrides[connection] = override_conn + try: + r = client.get( + "/api/v1/project-status", + params={"start_date": "2025-04-10", "end_date": "2025-04-10"}, + ) + finally: + app.dependency_overrides.clear() + + assert r.status_code == 200 + body = r.json() + assert body["start_date"] == "2025-04-10" + assert body["end_date"] == "2025-04-10" + + def test_report_explicit_range(self, client, mock_connector_graphql): + async def override_conn(): + yield mock_connector_graphql + + app.dependency_overrides[connection] = override_conn + try: + r = client.get( + "/api/v1/project-status", + params={"start_date": "2025-03-28", "end_date": "2025-04-10"}, + ) + finally: + app.dependency_overrides.clear() + + assert r.status_code == 200 + body = r.json() + assert body["start_date"] == "2025-03-28" + assert body["end_date"] == "2025-04-10" + + def test_start_after_end(self, client, mock_connector_graphql): + async def override_conn(): + yield mock_connector_graphql + + app.dependency_overrides[connection] = override_conn + try: + r = client.get( + "/api/v1/project-status", + params={"start_date": "2025-04-11", "end_date": "2025-04-10"}, + ) + finally: + app.dependency_overrides.clear() + + assert r.status_code == 422 + + def test_range_too_long(self, client, mock_connector_graphql): + async def override_conn(): + yield mock_connector_graphql + + app.dependency_overrides[connection] = override_conn + try: + r = client.get( + "/api/v1/project-status", + params={"start_date": "2024-01-01", "end_date": "2025-01-02"}, + ) + finally: + app.dependency_overrides.clear() + + assert r.status_code == 422 + def test_graphql_queries_cover_window(self, client, mock_connector_graphql): async def override_conn(): yield mock_connector_graphql app.dependency_overrides[connection] = override_conn try: - client.get("/api/v1/project-status", params={"end_date": "2025-04-10"}) + client.get( + "/api/v1/project-status", + params={"start_date": "2025-04-04", "end_date": "2025-04-10"}, + ) finally: app.dependency_overrides.clear() diff --git a/frontend/index.html b/frontend/index.html index 50cc8a1..a245b52 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,4 +1,4 @@ - + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 001bb38..d493296 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React, { useState, useEffect } from 'react'; import { Page, diff --git a/frontend/src/App.test.jsx b/frontend/src/App.test.jsx index b410995..8ffe601 100644 --- a/frontend/src/App.test.jsx +++ b/frontend/src/App.test.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; diff --git a/frontend/src/components/CommentCard.jsx b/frontend/src/components/CommentCard.jsx index 1057275..dccf083 100644 --- a/frontend/src/components/CommentCard.jsx +++ b/frontend/src/components/CommentCard.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React, { useState, useEffect } from 'react'; import { Spinner, Alert } from '@patternfly/react-core'; import { getDaysSince, formatDate } from '../utils/dateUtils'; diff --git a/frontend/src/components/CommentCard.test.jsx b/frontend/src/components/CommentCard.test.jsx index 83223bc..a576c3e 100644 --- a/frontend/src/components/CommentCard.test.jsx +++ b/frontend/src/components/CommentCard.test.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import CommentCard from './CommentCard'; diff --git a/frontend/src/components/IssueCard.jsx b/frontend/src/components/IssueCard.jsx index da4ed25..07b2513 100644 --- a/frontend/src/components/IssueCard.jsx +++ b/frontend/src/components/IssueCard.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React, { useState, useEffect, useRef, useCallback } from 'react'; import { ExpandableSection, diff --git a/frontend/src/components/IssueCard.test.jsx b/frontend/src/components/IssueCard.test.jsx index fbdef90..d9743dc 100644 --- a/frontend/src/components/IssueCard.test.jsx +++ b/frontend/src/components/IssueCard.test.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; diff --git a/frontend/src/components/ManageLabels.jsx b/frontend/src/components/ManageLabels.jsx index 27505cb..5deb67b 100644 --- a/frontend/src/components/ManageLabels.jsx +++ b/frontend/src/components/ManageLabels.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React, { useState, useEffect } from 'react'; import { Modal, diff --git a/frontend/src/components/ManageMilestones.jsx b/frontend/src/components/ManageMilestones.jsx index 99bdd58..eb0fa26 100644 --- a/frontend/src/components/ManageMilestones.jsx +++ b/frontend/src/components/ManageMilestones.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React, { useState, useEffect } from 'react'; import { Modal, diff --git a/frontend/src/components/ManageSort.jsx b/frontend/src/components/ManageSort.jsx index b7764fd..2d6817f 100644 --- a/frontend/src/components/ManageSort.jsx +++ b/frontend/src/components/ManageSort.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React, { useState, useEffect, useRef } from 'react'; import { Modal, Button, Tooltip, Spinner, Alert } from '@patternfly/react-core'; import { fetchLabels } from '../services/api'; diff --git a/frontend/src/components/MilestoneCard.jsx b/frontend/src/components/MilestoneCard.jsx index d498250..ef39d99 100644 --- a/frontend/src/components/MilestoneCard.jsx +++ b/frontend/src/components/MilestoneCard.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Card, diff --git a/frontend/src/components/MilestoneCard.test.jsx b/frontend/src/components/MilestoneCard.test.jsx index aa17243..7d49c48 100644 --- a/frontend/src/components/MilestoneCard.test.jsx +++ b/frontend/src/components/MilestoneCard.test.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; diff --git a/frontend/src/components/ProjectStatusPanel.jsx b/frontend/src/components/ProjectStatusPanel.jsx index 681b414..0abe3bc 100644 --- a/frontend/src/components/ProjectStatusPanel.jsx +++ b/frontend/src/components/ProjectStatusPanel.jsx @@ -1,5 +1,5 @@ -// ai-generated: Cursor -import React, { useCallback, useEffect, useState } from 'react'; +// Generated-by: Cursor +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, Button, @@ -15,7 +15,38 @@ import { import { OutlinedCopyIcon } from '@patternfly/react-icons'; import { fetchProjectStatusReport } from '../services/api'; import { copyStatusSectionToClipboard } from '../utils/clipboard'; -import { formatDate, getLocalDateISOString } from '../utils/dateUtils'; +import { + addDaysToLocalDateISO, + formatDate, + getLocalDateISOString, +} from '../utils/dateUtils'; + +const STORAGE_START_KEY = 'pmStatsProjectStatusStartDate'; +const STORAGE_END_KEY = 'pmStatsProjectStatusEndDate'; + +const readStoredDate = (key) => { + try { + const raw = localStorage.getItem(key); + if (raw && /^\d{4}-\d{2}-\d{2}$/.test(raw)) { + return raw; + } + } catch (error) { + console.error(`Failed to read ${key} from localStorage:`, error); + } + return null; +}; + +const getInitialDateRange = () => { + const today = getLocalDateISOString(); + const storedEnd = readStoredDate(STORAGE_END_KEY); + const storedStart = readStoredDate(STORAGE_START_KEY); + const end = storedEnd ?? today; + const start = storedStart ?? addDaysToLocalDateISO(end, -7); + if (start > end) { + return { start: addDaysToLocalDateISO(end, -7), end }; + } + return { start, end }; +}; const emptyListMessage = 'None in this period.'; @@ -81,19 +112,21 @@ const StatusSection = ({ heading, items }) => { }; const ProjectStatusPanel = () => { - const [endDate, setEndDate] = useState(() => getLocalDateISOString()); - const [draftEndDate, setDraftEndDate] = useState(() => - getLocalDateISOString() - ); + const initial = useMemo(() => getInitialDateRange(), []); + + const [startDate, setStartDate] = useState(initial.start); + const [endDate, setEndDate] = useState(initial.end); + const [draftStartDate, setDraftStartDate] = useState(initial.start); + const [draftEndDate, setDraftEndDate] = useState(initial.end); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [report, setReport] = useState(null); - const load = useCallback(async (iso) => { + const load = useCallback(async (startIso, endIso) => { setLoading(true); setError(null); try { - const data = await fetchProjectStatusReport(iso); + const data = await fetchProjectStatusReport(startIso, endIso); setReport(data); } catch (e) { setReport(null); @@ -104,10 +137,25 @@ const ProjectStatusPanel = () => { }, []); useEffect(() => { - load(endDate); - }, [endDate, load]); + load(startDate, endDate); + }, [startDate, endDate, load]); + + useEffect(() => { + try { + localStorage.setItem(STORAGE_START_KEY, startDate); + localStorage.setItem(STORAGE_END_KEY, endDate); + } catch (error) { + console.error('Failed to save project status date range:', error); + } + }, [startDate, endDate]); - const onApplyDate = () => { + const onApply = () => { + if (draftStartDate > draftEndDate) { + setDraftStartDate(startDate); + setDraftEndDate(endDate); + return; + } + setStartDate(draftStartDate); setEndDate(draftEndDate); }; @@ -116,6 +164,18 @@ const ProjectStatusPanel = () => { ? `${formatDate(`${report.start_date}T12:00:00Z`)} — ${formatDate(`${report.end_date}T12:00:00Z`)}` : ''; + const dateInputOnChange = (setter) => (value, event) => { + let stringValue = ''; + if (typeof value === 'string') { + stringValue = value; + } else if (value && typeof value === 'object' && 'target' in value) { + stringValue = value.target?.value || ''; + } else if (event && 'target' in event) { + stringValue = event.target?.value || ''; + } + setter(stringValue); + }; + return (
@@ -123,12 +183,37 @@ const ProjectStatusPanel = () => { Project status

- Seven calendar days ending on the selected date (UTC boundaries on the - server). PRs and issues link to GitHub. + Choose the first and last calendar day to include (UTC boundaries on + the server). Defaults match ending today and starting seven days + earlier. PRs and issues link to GitHub.

- +
+ + + + + +
{ alignItems: 'flex-end', }} > - { - let stringValue = ''; - if (typeof value === 'string') { - stringValue = value; - } else if ( - value && - typeof value === 'object' && - 'target' in value - ) { - stringValue = value.target?.value || ''; - } else if (event && 'target' in event) { - stringValue = event.target?.value || ''; - } - setDraftEndDate(stringValue); - }} - /> -
- +
{windowLabel && !loading && !error && (

diff --git a/frontend/src/components/ProjectStatusPanel.test.jsx b/frontend/src/components/ProjectStatusPanel.test.jsx index a9bfd21..fac70e2 100644 --- a/frontend/src/components/ProjectStatusPanel.test.jsx +++ b/frontend/src/components/ProjectStatusPanel.test.jsx @@ -1,5 +1,5 @@ -// ai-generated: Cursor -import { describe, it, expect, vi, beforeEach } from 'vitest'; +// Generated-by: Cursor +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import ProjectStatusPanel from './ProjectStatusPanel'; @@ -11,6 +11,9 @@ vi.mock('../utils/clipboard', () => ({ copyStatusSectionToClipboard: vi.fn().mockResolvedValue(undefined), })); +const STORAGE_START_KEY = 'pmStatsProjectStatusStartDate'; +const STORAGE_END_KEY = 'pmStatsProjectStatusEndDate'; + describe('ProjectStatusPanel', () => { const merged = [ { @@ -29,8 +32,12 @@ describe('ProjectStatusPanel', () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.removeItem(STORAGE_START_KEY); + localStorage.removeItem(STORAGE_END_KEY); + localStorage.setItem(STORAGE_START_KEY, '2025-04-03'); + localStorage.setItem(STORAGE_END_KEY, '2025-04-10'); api.fetchProjectStatusReport.mockResolvedValue({ - start_date: '2025-04-04', + start_date: '2025-04-03', end_date: '2025-04-10', merged_pull_requests: merged, opened_pull_requests: [], @@ -38,11 +45,19 @@ describe('ProjectStatusPanel', () => { }); }); + afterEach(() => { + localStorage.removeItem(STORAGE_START_KEY); + localStorage.removeItem(STORAGE_END_KEY); + }); + it('loads report and renders linked rows', async () => { render(); await waitFor(() => { - expect(api.fetchProjectStatusReport).toHaveBeenCalled(); + expect(api.fetchProjectStatusReport).toHaveBeenCalledWith( + '2025-04-03', + '2025-04-10' + ); }); const prLink = await screen.findByRole('link', { name: '#1' }); @@ -83,4 +98,36 @@ describe('ProjectStatusPanel', () => { ); expect(clipboard.copyStatusSectionToClipboard).toHaveBeenCalledWith(issues); }); + + it('persists start and end dates when Apply is used', async () => { + const user = userEvent.setup(); + api.fetchProjectStatusReport.mockResolvedValue({ + start_date: '2025-04-01', + end_date: '2025-04-15', + merged_pull_requests: [], + opened_pull_requests: [], + opened_issues: [], + }); + + render(); + + await waitFor(() => { + expect(api.fetchProjectStatusReport).toHaveBeenCalled(); + }); + + await user.clear(screen.getByLabelText('Starting')); + await user.type(screen.getByLabelText('Starting'), '2025-04-01'); + await user.clear(screen.getByLabelText('Ending')); + await user.type(screen.getByLabelText('Ending'), '2025-04-15'); + await user.click(screen.getByRole('button', { name: 'Apply' })); + + await waitFor(() => { + expect(api.fetchProjectStatusReport).toHaveBeenCalledWith( + '2025-04-01', + '2025-04-15' + ); + }); + expect(localStorage.getItem(STORAGE_START_KEY)).toBe('2025-04-01'); + expect(localStorage.getItem(STORAGE_END_KEY)).toBe('2025-04-15'); + }); }); diff --git a/frontend/src/components/Reactions.jsx b/frontend/src/components/Reactions.jsx index b7f5183..43745b6 100644 --- a/frontend/src/components/Reactions.jsx +++ b/frontend/src/components/Reactions.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React from 'react'; import { Tooltip } from '@patternfly/react-core'; diff --git a/frontend/src/components/SdlcKpisPanel.jsx b/frontend/src/components/SdlcKpisPanel.jsx index e3d5987..ab5a5f9 100644 --- a/frontend/src/components/SdlcKpisPanel.jsx +++ b/frontend/src/components/SdlcKpisPanel.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React, { useState, useCallback, useEffect } from 'react'; import { Card, diff --git a/frontend/src/components/SdlcKpisPanel.test.jsx b/frontend/src/components/SdlcKpisPanel.test.jsx index d088849..89d6ed3 100644 --- a/frontend/src/components/SdlcKpisPanel.test.jsx +++ b/frontend/src/components/SdlcKpisPanel.test.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; diff --git a/frontend/src/components/UserAvatar.jsx b/frontend/src/components/UserAvatar.jsx index a46b3a5..18b0f95 100644 --- a/frontend/src/components/UserAvatar.jsx +++ b/frontend/src/components/UserAvatar.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React from 'react'; const UserAvatar = ({ user, size = 32, showName = false }) => { diff --git a/frontend/src/icon.css b/frontend/src/icon.css index 0d74a1e..d1c8857 100644 --- a/frontend/src/icon.css +++ b/frontend/src/icon.css @@ -1,3 +1,4 @@ +/* Generated-by: Cursor */ /* Override PatternFly styles to ensure transparent background for icon */ html, body, diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 82c08a8..1636ee0 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import React from 'react'; import ReactDOM from 'react-dom/client'; import '@patternfly/react-core/dist/styles/base.css'; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 1c5a9bd..f778066 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor const API_BASE = '/api/v1'; export const fetchMilestones = async () => { @@ -244,9 +244,15 @@ export const fetchBugBacklogDelta = async (weeks = 4, weekDays = 7) => { return response.json(); }; -/** @param {string} endDateISO ``YYYY-MM-DD`` — last day of the 7-day window (UTC). */ -export const fetchProjectStatusReport = async (endDateISO) => { +/** + * @param {string} [startDateISO] ``YYYY-MM-DD`` — first calendar day of the window (UTC). + * @param {string} [endDateISO] ``YYYY-MM-DD`` — last calendar day of the window (UTC). + */ +export const fetchProjectStatusReport = async (startDateISO, endDateISO) => { const params = new URLSearchParams(); + if (startDateISO) { + params.set('start_date', startDateISO); + } if (endDateISO) { params.set('end_date', endDateISO); } diff --git a/frontend/src/services/api.test.js b/frontend/src/services/api.test.js index 1815b1f..9bbce09 100644 --- a/frontend/src/services/api.test.js +++ b/frontend/src/services/api.test.js @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fetchMilestones, @@ -172,7 +172,7 @@ describe('api', () => { }); describe('fetchProjectStatusReport', () => { - it('requests report with end_date', async () => { + it('requests report with start_date and end_date', async () => { const body = { start_date: '2025-04-04', end_date: '2025-04-10', @@ -181,14 +181,14 @@ describe('api', () => { opened_issues: [], }; global.fetch.mockResolvedValue({ ok: true, json: async () => body }); - const result = await fetchProjectStatusReport('2025-04-10'); + const result = await fetchProjectStatusReport('2025-04-04', '2025-04-10'); expect(result).toEqual(body); expect(global.fetch).toHaveBeenCalledWith( - '/api/v1/project-status?end_date=2025-04-10' + '/api/v1/project-status?start_date=2025-04-04&end_date=2025-04-10' ); }); - it('requests report without query when end date omitted', async () => { + it('requests report without query when both dates omitted', async () => { global.fetch.mockResolvedValue({ ok: true, json: async () => ({ @@ -199,15 +199,15 @@ describe('api', () => { opened_issues: [], }), }); - await fetchProjectStatusReport(''); + await fetchProjectStatusReport('', ''); expect(global.fetch).toHaveBeenCalledWith('/api/v1/project-status'); }); it('throws on failure', async () => { global.fetch.mockResolvedValue({ ok: false, statusText: 'Bad Gateway' }); - await expect(fetchProjectStatusReport('2025-04-10')).rejects.toThrow( - 'Failed to fetch project status report' - ); + await expect( + fetchProjectStatusReport('2025-04-04', '2025-04-10') + ).rejects.toThrow('Failed to fetch project status report'); }); }); }); diff --git a/frontend/src/test/setup.js b/frontend/src/test/setup.js index cd69f94..5b2fa9b 100644 --- a/frontend/src/test/setup.js +++ b/frontend/src/test/setup.js @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import { expect, afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; diff --git a/frontend/src/utils/assigneesCache.js b/frontend/src/utils/assigneesCache.js index 10fba16..3778a66 100644 --- a/frontend/src/utils/assigneesCache.js +++ b/frontend/src/utils/assigneesCache.js @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor // Shared assignees cache - loaded once and shared across all components let assigneesCache = { data: [], diff --git a/frontend/src/utils/clipboard.js b/frontend/src/utils/clipboard.js index f0f77e1..25bf399 100644 --- a/frontend/src/utils/clipboard.js +++ b/frontend/src/utils/clipboard.js @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor /** * @param {string} s * @returns {string} diff --git a/frontend/src/utils/clipboard.test.js b/frontend/src/utils/clipboard.test.js index 7fb1385..6b5399e 100644 --- a/frontend/src/utils/clipboard.test.js +++ b/frontend/src/utils/clipboard.test.js @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import { describe, it, expect, vi, beforeEach } from 'vitest'; import { copyTextToClipboard, diff --git a/frontend/src/utils/dateUtils.js b/frontend/src/utils/dateUtils.js index d6b9ed2..637270d 100644 --- a/frontend/src/utils/dateUtils.js +++ b/frontend/src/utils/dateUtils.js @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor /** Today's date in the user's local timezone as ``YYYY-MM-DD`` (for ````). */ export const getLocalDateISOString = (d = new Date()) => { const y = d.getFullYear(); @@ -7,6 +7,20 @@ export const getLocalDateISOString = (d = new Date()) => { return `${y}-${m}-${day}`; }; +/** + * Add signed calendar days to a local ``YYYY-MM-DD`` string (for ```` values). + * + * @param {string} dateIso + * @param {number} deltaDays + * @returns {string} + */ +export const addDaysToLocalDateISO = (dateIso, deltaDays) => { + const [y, m, d] = dateIso.split('-').map(Number); + const dt = new Date(y, m - 1, d); + dt.setDate(dt.getDate() + deltaDays); + return getLocalDateISOString(dt); +}; + export const getDaysSince = (dateString) => { if (!dateString) return 0; const date = new Date(dateString); diff --git a/frontend/src/utils/dateUtils.test.js b/frontend/src/utils/dateUtils.test.js index 7665509..977f66a 100644 --- a/frontend/src/utils/dateUtils.test.js +++ b/frontend/src/utils/dateUtils.test.js @@ -1,6 +1,11 @@ -// ai-generated: Cursor +// Generated-by: Cursor import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { getDaysSince, formatDate, getLocalDateISOString } from './dateUtils'; +import { + addDaysToLocalDateISO, + getDaysSince, + formatDate, + getLocalDateISOString, +} from './dateUtils'; describe('dateUtils', () => { beforeEach(() => { @@ -55,4 +60,14 @@ describe('dateUtils', () => { expect(getLocalDateISOString(new Date(2025, 5, 5))).toBe('2025-06-05'); }); }); + + describe('addDaysToLocalDateISO', () => { + it('subtracts calendar days in the local timezone', () => { + expect(addDaysToLocalDateISO('2025-04-10', -7)).toBe('2025-04-03'); + }); + + it('adds calendar days', () => { + expect(addDaysToLocalDateISO('2025-04-10', 1)).toBe('2025-04-11'); + }); + }); }); diff --git a/frontend/src/utils/labelsCache.js b/frontend/src/utils/labelsCache.js index 90c5602..3dd071e 100644 --- a/frontend/src/utils/labelsCache.js +++ b/frontend/src/utils/labelsCache.js @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor // Shared labels cache - loaded once and shared across all components let labelsCache = { data: [], diff --git a/frontend/src/utils/milestonesCache.js b/frontend/src/utils/milestonesCache.js index 4a086fd..ad2e67f 100644 --- a/frontend/src/utils/milestonesCache.js +++ b/frontend/src/utils/milestonesCache.js @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor // Shared milestones cache - loaded once and shared across all components let milestonesCache = { data: [], diff --git a/frontend/vite.config.js b/frontend/vite.config.js index c8fd439..a4be00b 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,4 +1,4 @@ -// ai-generated: Cursor +// Generated-by: Cursor import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react';